Compare commits

..

237 Commits

Author SHA1 Message Date
Herculino Trotta fee40fd527 Merge pull request #560 from eitchtee/weblate
Translations update from Weblate
2026-07-05 20:35:03 -03:00
Herculino Trotta f59e53f6dc locale(Portuguese (Brazil)): update translation
Currently translated at 100.0% (777 of 777 strings)

Translation: WYGIWYH/App
Translate-URL: https://translations.herculino.com/projects/wygiwyh/app/pt_BR/
2026-07-05 23:34:35 +00:00
Dimitri Decrock 2b379987ac locale(Dutch): update translation
Currently translated at 100.0% (777 of 777 strings)

Translation: WYGIWYH/App
Translate-URL: https://translations.herculino.com/projects/wygiwyh/app/nl/
2026-07-05 15:57:19 +00:00
eitchtee 4b8ccf426d chore(locale): update translation files
[skip ci] Automatically generated by Django makemessages workflow
2026-07-04 15:39:07 +00:00
Herculino Trotta 4805ce9e04 Merge pull request #557 from obervinov/obervinov/app-owned-oauth-api-tokens
feat(api): add API tokens and OAuth2 client support for external integrations
2026-07-04 12:38:44 -03:00
Herculino Trotta 845a8d846b Merge pull request #556 from eitchtee/dependabot/uv/cryptography-48.0.1
build(deps): bump cryptography from 48.0.0 to 48.0.1
2026-07-04 12:38:02 -03:00
Herculino Trotta 1497500c4f Merge pull request #559 from eitchtee/weblate
Translations update from Weblate
2026-07-04 12:37:46 -03:00
obervinov 9e9e60ccec fix: copy the raw API token from the input value
The copy button passed the token through Django's escapejs filter into the
hyperscript writeText() call, which turns every "-" into -. hyperscript
does not decode \u escapes, so any token containing "-" (common with
token_urlsafe) was copied corrupted and failed auth on paste. Copy from the
input's value instead, which holds the unescaped raw token.
2026-06-30 01:02:54 +04:00
obervinov ca14f77f41 test: cover demo-mode block on revoked-token delete
Parity with the existing demo-mode tests for token create/revoke.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-30 00:00:10 +04:00
obervinov 0fb37a59fa feat: add delete button for revoked API tokens
Revoked tokens previously stayed in the list with no way to remove them.
Adds a delete action (hard delete, scoped to the owner, gated behind
demo mode) shown on revoked rows, alongside the existing revoke action on
active ones.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-29 23:55:38 +04:00
sorcierwax e74d9177df locale(French): update translation
Currently translated at 100.0% (739 of 739 strings)

Translation: WYGIWYH/App
Translate-URL: https://translations.herculino.com/projects/wygiwyh/app/fr/
2026-06-27 21:57:19 +00:00
Herculino Trotta 106d721279 feat: add demo mode tests to ensure API is disabled on it 2026-06-27 18:02:31 -03:00
Herculino Trotta d0e9c05283 feat: disable oauth and token creation while on demo mode 2026-06-27 18:02:03 -03:00
sorcierwax 4e16831f4d locale(French): update translation
Currently translated at 97.1% (718 of 739 strings)

Translation: WYGIWYH/App
Translate-URL: https://translations.herculino.com/projects/wygiwyh/app/fr/
2026-06-27 20:57:19 +00:00
Herculino Trotta 7f5a91c11f fix: wrong Guam timezone string 2026-06-27 17:56:24 -03:00
Herculino Trotta 009a7038c8 style: improve api token box look 2026-06-27 17:56:05 -03:00
obervinov 4273c541c5 Add API tokens and OAuth2 client support for external integrations
- Personal API tokens (model, user-settings UI, admin, management command,
  DRF auth class) for non-interactive API access from automations like n8n.
  Raw token shown once; only a SHA-256 hash is stored; last_used_at writes
  are throttled.
- OAuth2 authorization server via django-oauth-toolkit with authorization
  server metadata and optional, off-by-default Dynamic Client Registration
  (RFC 7591), so remote OAuth/MCP clients can authenticate and self-register.
- Tests for token auth, DCR gating and the management commands, plus
  .env.example and README documentation.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-24 19:15:31 +04:00
dependabot[bot] 5c4cb16a0a build(deps): bump cryptography from 48.0.0 to 48.0.1
Bumps [cryptography](https://github.com/pyca/cryptography) from 48.0.0 to 48.0.1.
- [Changelog](https://github.com/pyca/cryptography/blob/main/CHANGELOG.rst)
- [Commits](https://github.com/pyca/cryptography/compare/48.0.0...48.0.1)

---
updated-dependencies:
- dependency-name: cryptography
  dependency-version: 48.0.1
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-06-17 01:11:02 +00:00
Herculino Trotta 9641e169f2 Merge pull request #554 from eitchtee/dependabot/npm_and_yarn/frontend/multi-f57e1e291f
build(deps): bump esbuild and vite in /frontend
2026-06-12 23:42:06 -03:00
Herculino Trotta 25ff0214ab Merge pull request #555 from eitchtee/feat/tom-select-improvements
feat(tom-select): clear input after picking on a multi-item select
2026-06-12 23:38:31 -03:00
Herculino Trotta 0f9d333834 Merge pull request #553 from eitchtee/weblate
Translations update from Weblate
2026-06-12 23:37:59 -03:00
Herculino Trotta ae115cca15 feat(tom-select): clear input after picking on a multi-item select 2026-06-12 23:32:17 -03:00
dependabot[bot] bb23ac6df9 build(deps): bump esbuild and vite in /frontend
Removes [esbuild](https://github.com/evanw/esbuild). It's no longer used after updating ancestor dependency [vite](https://github.com/vitejs/vite/tree/HEAD/packages/vite). These dependencies need to be updated together.


Removes `esbuild`

Updates `vite` from 7.3.2 to 8.0.16
- [Release notes](https://github.com/vitejs/vite/releases)
- [Changelog](https://github.com/vitejs/vite/blob/main/packages/vite/CHANGELOG.md)
- [Commits](https://github.com/vitejs/vite/commits/v8.0.16/packages/vite)

---
updated-dependencies:
- dependency-name: esbuild
  dependency-version:
  dependency-type: indirect
- dependency-name: vite
  dependency-version: 8.0.16
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-06-12 23:11:26 +00:00
Pawel Augustyn 7db0fcf097 locale(Polish): update translation
Currently translated at 73.8% (546 of 739 strings)

Translation: WYGIWYH/App
Translate-URL: https://translations.herculino.com/projects/wygiwyh/app/pl/
2026-06-08 07:57:18 +00:00
Pawel Augustyn 02896f21ed locale(Polish): update translation
Currently translated at 73.8% (546 of 739 strings)

Translation: WYGIWYH/App
Translate-URL: https://translations.herculino.com/projects/wygiwyh/app/pl/
2026-06-08 06:57:18 +00:00
Pawel Augustyn 5082c17d0f locale(Polish): update translation
Currently translated at 73.7% (545 of 739 strings)

Translation: WYGIWYH/App
Translate-URL: https://translations.herculino.com/projects/wygiwyh/app/pl/
2026-06-08 05:57:18 +00:00
Herculino Trotta 33570296e0 Merge pull request #552 from eitchtee/weblate
Translations update from Weblate
2026-06-07 17:21:15 -03:00
Herculino Trotta fc99491f78 locale(Portuguese (Brazil)): update translation
Currently translated at 100.0% (739 of 739 strings)

Translation: WYGIWYH/App
Translate-URL: https://translations.herculino.com/projects/wygiwyh/app/pt_BR/
2026-06-07 14:57:19 +00:00
Dimitri Decrock cb0d379261 locale(Dutch): update translation
Currently translated at 100.0% (739 of 739 strings)

Translation: WYGIWYH/App
Translate-URL: https://translations.herculino.com/projects/wygiwyh/app/nl/
2026-06-07 08:57:18 +00:00
eitchtee 524e390a62 chore(locale): update translation files
[skip ci] Automatically generated by Django makemessages workflow
2026-06-06 08:15:55 +00:00
Herculino Trotta db7e22b627 Merge pull request #551 from eitchtee/chore/bump-deps
chore: bump python and node dependencies
2026-06-06 05:15:27 -03:00
Herculino Trotta 6987b54dba chore: bump python and node dependencies 2026-06-06 05:14:26 -03:00
Herculino Trotta b44563b09b Merge pull request #550 from eitchtee/dependabot/npm_and_yarn/frontend/postcss-8.5.15
build(deps): bump postcss from 8.5.6 to 8.5.15 in /frontend
2026-06-06 04:42:23 -03:00
Herculino Trotta e839f31104 Merge pull request #549 from eitchtee/dependabot/uv/django-5.2.14
build(deps): bump django from 5.2.13 to 5.2.14
2026-06-06 04:42:09 -03:00
Herculino Trotta 2282625790 Merge pull request #548 from eitchtee/dependabot/uv/idna-3.15
build(deps): bump idna from 3.11 to 3.15
2026-06-06 04:41:50 -03:00
Herculino Trotta aa8b559152 Merge pull request #547 from eitchtee/dependabot/uv/mistune-3.2.1
build(deps): bump mistune from 3.1.4 to 3.2.1
2026-06-06 04:41:37 -03:00
Herculino Trotta e1862b8241 Merge pull request #546 from eitchtee/dependabot/uv/urllib3-2.7.0
build(deps): bump urllib3 from 2.6.3 to 2.7.0
2026-06-06 04:41:22 -03:00
eitchtee eb6be8548c chore(locale): update translation files
[skip ci] Automatically generated by Django makemessages workflow
2026-06-06 07:41:13 +00:00
Herculino Trotta 6ee4e21939 Merge pull request #545 from eitchtee/feat/attatchments
feat(transactions): add attatchments
2026-06-06 04:40:49 -03:00
dependabot[bot] bdd8aed891 build(deps): bump postcss from 8.5.6 to 8.5.15 in /frontend
Bumps [postcss](https://github.com/postcss/postcss) from 8.5.6 to 8.5.15.
- [Release notes](https://github.com/postcss/postcss/releases)
- [Changelog](https://github.com/postcss/postcss/blob/main/CHANGELOG.md)
- [Commits](https://github.com/postcss/postcss/compare/8.5.6...8.5.15)

---
updated-dependencies:
- dependency-name: postcss
  dependency-version: 8.5.15
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-06-06 07:39:37 +00:00
dependabot[bot] 801b2a9edd build(deps): bump django from 5.2.13 to 5.2.14
Bumps [django](https://github.com/django/django) from 5.2.13 to 5.2.14.
- [Commits](https://github.com/django/django/compare/5.2.13...5.2.14)

---
updated-dependencies:
- dependency-name: django
  dependency-version: 5.2.14
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-06-06 07:39:25 +00:00
dependabot[bot] 968499f1ab build(deps): bump idna from 3.11 to 3.15
Bumps [idna](https://github.com/kjd/idna) from 3.11 to 3.15.
- [Release notes](https://github.com/kjd/idna/releases)
- [Changelog](https://github.com/kjd/idna/blob/master/HISTORY.md)
- [Commits](https://github.com/kjd/idna/compare/v3.11...v3.15)

---
updated-dependencies:
- dependency-name: idna
  dependency-version: '3.15'
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-06-06 07:37:29 +00:00
dependabot[bot] 5b351821b1 build(deps): bump mistune from 3.1.4 to 3.2.1
Bumps [mistune](https://github.com/lepture/mistune) from 3.1.4 to 3.2.1.
- [Release notes](https://github.com/lepture/mistune/releases)
- [Changelog](https://github.com/lepture/mistune/blob/main/docs/changes.rst)
- [Commits](https://github.com/lepture/mistune/compare/v3.1.4...v3.2.1)

---
updated-dependencies:
- dependency-name: mistune
  dependency-version: 3.2.1
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-06-06 07:36:43 +00:00
dependabot[bot] 7b49072848 build(deps): bump urllib3 from 2.6.3 to 2.7.0
Bumps [urllib3](https://github.com/urllib3/urllib3) from 2.6.3 to 2.7.0.
- [Release notes](https://github.com/urllib3/urllib3/releases)
- [Changelog](https://github.com/urllib3/urllib3/blob/main/CHANGES.rst)
- [Commits](https://github.com/urllib3/urllib3/compare/2.6.3...2.7.0)

---
updated-dependencies:
- dependency-name: urllib3
  dependency-version: 2.7.0
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-06-06 07:36:28 +00:00
Herculino Trotta 0ee32724f1 style(toas): move toast to the top of offcanvas 2026-06-06 04:33:23 -03:00
Herculino Trotta 6a19381672 feat(transactions): add attachments 2026-06-06 04:33:06 -03:00
Herculino Trotta 248fec8b4c Merge pull request #541 from eitchtee/weblate
Translations update from Weblate
2026-05-02 16:30:56 -03:00
Weblate b34c0557fa Merge remote-tracking branch 'origin/main' 2026-05-02 19:30:37 +00:00
Herculino Trotta 2af4066aab Merge pull request #542 from eitchtee/fix-procrastinate
fix: stabilize Procrastinate worker database handling
2026-05-02 16:30:33 -03:00
Herculino Trotta d72ff3cdf5 fix(rules): allow category expressions to clear categories 2026-05-02 16:16:27 -03:00
Herculino Trotta 63c69e5c6a test(api): expect unauthorized for anonymous requests 2026-05-02 16:16:08 -03:00
Herculino Trotta 78171183cc test(currencies): avoid test discovery collision 2026-05-02 16:15:48 -03:00
Herculino Trotta 34a2b6bfd4 fix(procrastinate): close Django connections around jobs 2026-05-02 16:15:26 -03:00
masttera 1dc24f855e locale(Russian): update translation
Currently translated at 75.2% (545 of 724 strings)

Translation: WYGIWYH/App
Translate-URL: https://translations.herculino.com/projects/wygiwyh/app/ru/
2026-05-01 07:24:32 +00:00
masttera 1390aff07d locale(Russian): update translation
Currently translated at 74.4% (539 of 724 strings)

Translation: WYGIWYH/App
Translate-URL: https://translations.herculino.com/projects/wygiwyh/app/ru/
2026-05-01 06:24:32 +00:00
Herculino Trotta 8fc11b0acf feat(transactions): hide filter on page load to prevent flashing 2026-05-01 00:07:43 -03:00
Herculino Trotta 9a30a0d3c0 chore: bump versions and other minor things 2026-05-01 00:07:15 -03:00
Herculino Trotta 10eecd09ff fix(frontend): hyperscript not working correctly for offcanvas and modals 2026-04-30 23:16:19 -03:00
Herculino Trotta 2cfb3fb12e fix(frontend): bootstrap-grid-plugin broke 2026-04-30 23:03:42 -03:00
Herculino Trotta 47af8b135b Merge pull request #540 from eitchtee/weblate
Translations update from Weblate
2026-04-30 18:09:27 -03:00
Herculino Trotta 39d0e63375 locale(Portuguese (Brazil)): update translation
Currently translated at 100.0% (724 of 724 strings)

Translation: WYGIWYH/App
Translate-URL: https://translations.herculino.com/projects/wygiwyh/app/pt_BR/
2026-04-30 02:24:32 +00:00
Herculino Trotta 792154eba2 Merge pull request #539 from eitchtee/dev
fix(tom-select): dropdown covers select field when height increases
2026-04-23 23:30:56 -03:00
Herculino Trotta dc76ed3156 fix(tom-select): dropdown covers select field when height increases
Co-authored-by: Copilot <copilot@github.com>
2026-04-23 23:29:42 -03:00
Herculino Trotta e627dd50be Merge pull request #537 from eitchtee/dev
fix: deduplication breaks when given m2m fields
2026-04-18 11:48:44 -03:00
Herculino Trotta 5527389196 fix: deduplication breaks when given m2m fields 2026-04-18 14:47:19 +00:00
Herculino Trotta be24ca014e Merge pull request #536 from eitchtee/dev
chore: deps upgrade
2026-04-18 10:48:18 -03:00
Herculino Trotta 7c7056536e feat: enable chunk splitting 2026-04-18 13:46:30 +00:00
Herculino Trotta 66d5d7a83b fix: _hyperscript not working 2026-04-18 13:45:58 +00:00
Herculino Trotta 4d3ce087d6 chore: bump versions 2026-04-18 13:45:28 +00:00
Herculino Trotta aeaf9fac43 Merge pull request #535 from eitchtee/dependabot/uv/requests-2.33.0
build(deps): bump requests from 2.32.5 to 2.33.0
2026-04-18 02:41:18 -03:00
Herculino Trotta dcedf53b83 Merge pull request #534 from eitchtee/dependabot/uv/cryptography-46.0.7
build(deps): bump cryptography from 46.0.5 to 46.0.7
2026-04-18 02:41:06 -03:00
Herculino Trotta 549648bd6b Merge pull request #532 from eitchtee/dependabot/npm_and_yarn/frontend/multi-bf05dc1ecf
build(deps): bump picomatch in /frontend
2026-04-18 02:40:43 -03:00
dependabot[bot] 79149abdd2 build(deps): bump picomatch in /frontend
Bumps  and [picomatch](https://github.com/micromatch/picomatch). These dependencies needed to be updated together.

Updates `picomatch` from 4.0.3 to 4.0.4
- [Release notes](https://github.com/micromatch/picomatch/releases)
- [Changelog](https://github.com/micromatch/picomatch/blob/master/CHANGELOG.md)
- [Commits](https://github.com/micromatch/picomatch/compare/4.0.3...4.0.4)

Updates `picomatch` from 2.3.1 to 2.3.2
- [Release notes](https://github.com/micromatch/picomatch/releases)
- [Changelog](https://github.com/micromatch/picomatch/blob/master/CHANGELOG.md)
- [Commits](https://github.com/micromatch/picomatch/compare/4.0.3...4.0.4)

---
updated-dependencies:
- dependency-name: picomatch
  dependency-version: 4.0.4
  dependency-type: indirect
- dependency-name: picomatch
  dependency-version: 2.3.2
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-04-18 05:40:24 +00:00
Herculino Trotta d66d1530bb Merge pull request #533 from eitchtee/dependabot/uv/pyjwt-2.12.0
build(deps): bump pyjwt from 2.10.1 to 2.12.0
2026-04-18 02:40:12 -03:00
Herculino Trotta 4b9b6484d3 Merge pull request #531 from eitchtee/dependabot/uv/django-5.2.13
build(deps): bump django from 5.2.11 to 5.2.13
2026-04-18 02:39:59 -03:00
Herculino Trotta a02944bdae Merge pull request #530 from eitchtee/dependabot/npm_and_yarn/frontend/immutable-5.1.5
build(deps): bump immutable from 5.1.3 to 5.1.5 in /frontend
2026-04-18 02:39:31 -03:00
Herculino Trotta 3ede5304f1 Merge pull request #529 from eitchtee/dependabot/npm_and_yarn/frontend/mathjs-15.2.0
build(deps): bump mathjs from 15.1.0 to 15.2.0 in /frontend
2026-04-18 02:39:19 -03:00
Herculino Trotta 27041695b8 Merge pull request #528 from eitchtee/dependabot/npm_and_yarn/frontend/vite-7.3.2
build(deps): bump vite from 7.2.2 to 7.3.2 in /frontend
2026-04-18 02:39:04 -03:00
dependabot[bot] 0c927a2fe9 build(deps): bump requests from 2.32.5 to 2.33.0
Bumps [requests](https://github.com/psf/requests) from 2.32.5 to 2.33.0.
- [Release notes](https://github.com/psf/requests/releases)
- [Changelog](https://github.com/psf/requests/blob/main/HISTORY.md)
- [Commits](https://github.com/psf/requests/compare/v2.32.5...v2.33.0)

---
updated-dependencies:
- dependency-name: requests
  dependency-version: 2.33.0
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-04-18 05:37:40 +00:00
dependabot[bot] c52db80c64 build(deps): bump cryptography from 46.0.5 to 46.0.7
Bumps [cryptography](https://github.com/pyca/cryptography) from 46.0.5 to 46.0.7.
- [Changelog](https://github.com/pyca/cryptography/blob/main/CHANGELOG.rst)
- [Commits](https://github.com/pyca/cryptography/compare/46.0.5...46.0.7)

---
updated-dependencies:
- dependency-name: cryptography
  dependency-version: 46.0.7
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-04-18 05:37:14 +00:00
dependabot[bot] 330ce8069c build(deps): bump pyjwt from 2.10.1 to 2.12.0
Bumps [pyjwt](https://github.com/jpadilla/pyjwt) from 2.10.1 to 2.12.0.
- [Release notes](https://github.com/jpadilla/pyjwt/releases)
- [Changelog](https://github.com/jpadilla/pyjwt/blob/master/CHANGELOG.rst)
- [Commits](https://github.com/jpadilla/pyjwt/compare/2.10.1...2.12.0)

---
updated-dependencies:
- dependency-name: pyjwt
  dependency-version: 2.12.0
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-04-18 05:36:41 +00:00
dependabot[bot] 2989f11b01 build(deps): bump django from 5.2.11 to 5.2.13
Bumps [django](https://github.com/django/django) from 5.2.11 to 5.2.13.
- [Commits](https://github.com/django/django/compare/5.2.11...5.2.13)

---
updated-dependencies:
- dependency-name: django
  dependency-version: 5.2.13
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-04-18 05:36:10 +00:00
dependabot[bot] 738bb7fb74 build(deps): bump immutable from 5.1.3 to 5.1.5 in /frontend
Bumps [immutable](https://github.com/immutable-js/immutable-js) from 5.1.3 to 5.1.5.
- [Release notes](https://github.com/immutable-js/immutable-js/releases)
- [Changelog](https://github.com/immutable-js/immutable-js/blob/main/CHANGELOG.md)
- [Commits](https://github.com/immutable-js/immutable-js/compare/v5.1.3...v5.1.5)

---
updated-dependencies:
- dependency-name: immutable
  dependency-version: 5.1.5
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-04-18 05:35:35 +00:00
dependabot[bot] 79e50cd853 build(deps): bump mathjs from 15.1.0 to 15.2.0 in /frontend
Bumps [mathjs](https://github.com/josdejong/mathjs) from 15.1.0 to 15.2.0.
- [Changelog](https://github.com/josdejong/mathjs/blob/develop/HISTORY.md)
- [Commits](https://github.com/josdejong/mathjs/compare/v15.1.0...v15.2.0)

---
updated-dependencies:
- dependency-name: mathjs
  dependency-version: 15.2.0
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-04-18 05:35:08 +00:00
dependabot[bot] 0a23c3ad5b build(deps): bump vite from 7.2.2 to 7.3.2 in /frontend
Bumps [vite](https://github.com/vitejs/vite/tree/HEAD/packages/vite) from 7.2.2 to 7.3.2.
- [Release notes](https://github.com/vitejs/vite/releases)
- [Changelog](https://github.com/vitejs/vite/blob/v7.3.2/packages/vite/CHANGELOG.md)
- [Commits](https://github.com/vitejs/vite/commits/v7.3.2/packages/vite)

---
updated-dependencies:
- dependency-name: vite
  dependency-version: 7.3.2
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-04-18 05:34:28 +00:00
Herculino Trotta 43c7749102 Merge pull request #523 from eitchtee/dependabot/uv/simpleeval-1.0.5
build(deps): bump simpleeval from 1.0.3 to 1.0.5
2026-04-18 02:32:39 -03:00
Herculino Trotta c1c4ccda8c Merge pull request #527 from eitchtee/dependabot/npm_and_yarn/frontend/rollup-4.60.1
build(deps): bump rollup from 4.52.3 to 4.60.1 in /frontend
2026-04-18 02:32:26 -03:00
Herculino Trotta 615a689c61 Merge pull request #524 from eitchtee/weblate
Translations update from Weblate
2026-04-18 02:31:51 -03:00
dependabot[bot] c5ccc42f99 build(deps): bump rollup from 4.52.3 to 4.60.1 in /frontend
Bumps [rollup](https://github.com/rollup/rollup) from 4.52.3 to 4.60.1.
- [Release notes](https://github.com/rollup/rollup/releases)
- [Changelog](https://github.com/rollup/rollup/blob/master/CHANGELOG.md)
- [Commits](https://github.com/rollup/rollup/compare/v4.52.3...v4.60.1)

---
updated-dependencies:
- dependency-name: rollup
  dependency-version: 4.60.1
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-04-18 05:30:37 +00:00
LordTimothyHKIT 2baa8b21e8 locale(German): update translation
Currently translated at 92.4% (669 of 724 strings)

Translation: WYGIWYH/App
Translate-URL: https://translations.herculino.com/projects/wygiwyh/app/de/
2026-04-09 11:24:31 +00:00
LordTimothyHKIT 2e554141ba locale(German): update translation
Currently translated at 91.9% (666 of 724 strings)

Translation: WYGIWYH/App
Translate-URL: https://translations.herculino.com/projects/wygiwyh/app/de/
2026-04-09 10:24:32 +00:00
masttera 73ec6dc0fe locale(Russian): update translation
Currently translated at 72.7% (527 of 724 strings)

Translation: WYGIWYH/App
Translate-URL: https://translations.herculino.com/projects/wygiwyh/app/ru/
2026-03-31 13:24:31 +00:00
masttera e19449ff99 locale(Russian): update translation
Currently translated at 72.7% (527 of 724 strings)

Translation: WYGIWYH/App
Translate-URL: https://translations.herculino.com/projects/wygiwyh/app/ru/
2026-03-31 12:24:31 +00:00
masttera e81651119c locale(Russian): update translation
Currently translated at 72.7% (527 of 724 strings)

Translation: WYGIWYH/App
Translate-URL: https://translations.herculino.com/projects/wygiwyh/app/ru/
2026-03-31 12:21:41 +00:00
masttera 55e9ef1b3f locale(Russian): update translation
Currently translated at 72.7% (527 of 724 strings)

Translation: WYGIWYH/App
Translate-URL: https://translations.herculino.com/projects/wygiwyh/app/ru/
2026-03-31 11:50:00 +00:00
masttera c414179135 locale(Russian): update translation
Currently translated at 66.0% (478 of 724 strings)

Translation: WYGIWYH/App
Translate-URL: https://translations.herculino.com/projects/wygiwyh/app/ru/
2026-03-31 11:24:31 +00:00
masttera 14c507de0f locale(Russian): update translation
Currently translated at 65.8% (477 of 724 strings)

Translation: WYGIWYH/App
Translate-URL: https://translations.herculino.com/projects/wygiwyh/app/ru/
2026-03-31 11:24:08 +00:00
masttera 4722690fe9 locale(Russian): update translation
Currently translated at 62.1% (450 of 724 strings)

Translation: WYGIWYH/App
Translate-URL: https://translations.herculino.com/projects/wygiwyh/app/ru/
2026-03-29 11:24:32 +00:00
masttera 493619a4ff locale(Russian): update translation
Currently translated at 55.6% (403 of 724 strings)

Translation: WYGIWYH/App
Translate-URL: https://translations.herculino.com/projects/wygiwyh/app/ru/
2026-03-28 18:24:32 +00:00
masttera fb4aec88f1 locale(Russian): update translation
Currently translated at 35.3% (256 of 724 strings)

Translation: WYGIWYH/App
Translate-URL: https://translations.herculino.com/projects/wygiwyh/app/ru/
2026-03-28 17:24:32 +00:00
dependabot[bot] 4a35e770a4 build(deps): bump simpleeval from 1.0.3 to 1.0.5
Bumps [simpleeval](https://github.com/danthedeckie/simpleeval) from 1.0.3 to 1.0.5.
- [Release notes](https://github.com/danthedeckie/simpleeval/releases)
- [Commits](https://github.com/danthedeckie/simpleeval/compare/1.0.3...1.0.5)

---
updated-dependencies:
- dependency-name: simpleeval
  dependency-version: 1.0.5
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-03-14 05:54:33 +00:00
Herculino Trotta 83b81edbae Merge pull request #522 from eitchtee/weblate
Translations update from Weblate
2026-03-02 19:30:42 -03:00
Herculino Trotta a7dc2c955e locale(Portuguese (Brazil)): update translation
Currently translated at 100.0% (724 of 724 strings)

Translation: WYGIWYH/App
Translate-URL: https://translations.herculino.com/projects/wygiwyh/app/pt_BR/
2026-03-02 22:30:07 +00:00
Herculino Trotta ce2ae562c6 Merge pull request #520 from eitchtee/weblate
Translations update from Weblate
2026-03-02 19:27:28 -03:00
Juan David Afanador de2881ffd4 locale(Spanish): update translation
Currently translated at 100.0% (724 of 724 strings)

Translation: WYGIWYH/App
Translate-URL: https://translations.herculino.com/projects/wygiwyh/app/es/
2026-02-24 01:24:32 +00:00
Erwan Colin 838bf22498 locale(French): update translation
Currently translated at 99.0% (717 of 724 strings)

Translation: WYGIWYH/App
Translate-URL: https://translations.herculino.com/projects/wygiwyh/app/fr/
2026-02-23 08:24:31 +00:00
Pawel Augustyn d3797ae4a5 locale(Polish): update translation
Currently translated at 72.6% (526 of 724 strings)

Translation: WYGIWYH/App
Translate-URL: https://translations.herculino.com/projects/wygiwyh/app/pl/
2026-02-18 11:24:31 +00:00
Dimitri Decrock 0532397afd locale(Dutch): update translation
Currently translated at 100.0% (724 of 724 strings)

Translation: WYGIWYH/App
Translate-URL: https://translations.herculino.com/projects/wygiwyh/app/nl/
2026-02-17 18:24:31 +00:00
Pawel Augustyn 8106dc58e5 locale(Polish): update translation
Currently translated at 70.4% (510 of 724 strings)

Translation: WYGIWYH/App
Translate-URL: https://translations.herculino.com/projects/wygiwyh/app/pl/
2026-02-16 07:24:31 +00:00
Herculino Trotta 5986cf675b Merge pull request #519
fix: pulltorefresh enabled globally
2026-02-15 23:37:42 -03:00
Herculino Trotta 80da9142f1 fix: pulltorefresh enabled globally 2026-02-15 23:36:23 -03:00
eitchtee 766516d248 chore(locale): update translation files
[skip ci] Automatically generated by Django makemessages workflow
2026-02-16 02:24:31 +00:00
Herculino Trotta 3fd0fba1b8 Merge pull request #513 from pawelaugustyn/feat/default-account
feat: default account for new transactions
2026-02-15 23:24:08 -03:00
Herculino Trotta c787565c04 refactor: move help_text to model definition 2026-02-15 23:22:52 -03:00
Herculino Trotta 0413921dbe fix: migrations set default as 0 instead of null 2026-02-15 23:22:10 -03:00
pawelaugustyn 9ecf8279b4 feat: default account for new transactions 2026-02-15 22:59:18 +01:00
Herculino Trotta 86cf625158 Merge pull request #518 from eitchtee/dev
feat(auth): trust OIDC connections and automatically connect them with local accounts
2026-02-15 14:43:33 -03:00
Herculino Trotta ea097ab6f0 feat(auth): trust OIDC connections and connect them with local accounts 2026-02-15 14:41:45 -03:00
eitchtee b1201b51bb chore(locale): update translation files
[skip ci] Automatically generated by Django makemessages workflow
2026-02-15 14:37:18 +00:00
Herculino Trotta 4c1d20215c Merge pull request #517 from eitchtee/dev
feat(frontend): add pull to refresh for iOS PWA
2026-02-15 11:36:59 -03:00
Herculino Trotta 27e85c4776 feat(frontend): add pull to refresh for iOS PWA 2026-02-15 11:34:28 -03:00
Herculino Trotta 5a73cd20da Merge pull request #516 from eitchtee/dependabot/uv/cryptography-46.0.5
build(deps): bump cryptography from 46.0.3 to 46.0.5
2026-02-11 21:47:14 -03:00
dependabot[bot] e305fab300 build(deps): bump cryptography from 46.0.3 to 46.0.5
Bumps [cryptography](https://github.com/pyca/cryptography) from 46.0.3 to 46.0.5.
- [Changelog](https://github.com/pyca/cryptography/blob/main/CHANGELOG.rst)
- [Commits](https://github.com/pyca/cryptography/compare/46.0.3...46.0.5)

---
updated-dependencies:
- dependency-name: cryptography
  dependency-version: 46.0.5
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-02-12 00:44:59 +00:00
Herculino Trotta c11f525373 Merge pull request #515 from eitchtee/dev
add allauth logging and fix allauth not sending https redirect_uri
2026-02-11 21:43:12 -03:00
Herculino Trotta ea5d86dbf8 fix: allauth not sending https redirect_uri 2026-02-11 21:42:00 -03:00
Herculino Trotta a1d3539e3c feat: add allauth logging 2026-02-11 21:41:17 -03:00
Herculino Trotta 1028a11c8b Merge pull request #511 from eitchtee/dependabot/uv/django-5.2.11
build(deps): bump django from 5.2.9 to 5.2.11
2026-02-11 21:18:05 -03:00
Herculino Trotta e387a5e2a8 Merge pull request #510 from eitchtee/weblate
Translations update from Weblate
2026-02-11 21:17:18 -03:00
dependabot[bot] 624dc382cf build(deps): bump django from 5.2.9 to 5.2.11
Bumps [django](https://github.com/django/django) from 5.2.9 to 5.2.11.
- [Commits](https://github.com/django/django/compare/5.2.9...5.2.11)

---
updated-dependencies:
- dependency-name: django
  dependency-version: 5.2.11
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-02-03 23:24:38 +00:00
Pawel Augustyn f88699b333 locale(Polish): update translation
Currently translated at 69.6% (500 of 718 strings)

Translation: WYGIWYH/App
Translate-URL: https://translations.herculino.com/projects/wygiwyh/app/pl/
2026-02-03 11:24:30 +00:00
Dimitri Decrock ca98dc073b locale(Dutch): update translation
Currently translated at 100.0% (718 of 718 strings)

Translation: WYGIWYH/App
Translate-URL: https://translations.herculino.com/projects/wygiwyh/app/nl/
2026-02-03 05:24:31 +00:00
eitchtee 63ba7af3c8 chore(locale): update translation files
[skip ci] Automatically generated by Django makemessages workflow
2026-02-02 01:28:27 +00:00
Herculino Trotta 2d0dee4a9b Merge pull request #509 from eitchtee/dev
feat: add reload button to the HTMX error popup
2026-02-01 22:28:03 -03:00
Herculino Trotta 0000a9ee03 Merge pull request #507 from eitchtee/weblate
Translations update from Weblate
2026-02-01 22:27:06 -03:00
Herculino Trotta 41adb37fdb feat: add reload button to the HTMX error popup 2026-02-01 22:25:47 -03:00
Pawel Augustyn 496651173e locale(Polish): update translation
Currently translated at 69.4% (498 of 717 strings)

Translation: WYGIWYH/App
Translate-URL: https://translations.herculino.com/projects/wygiwyh/app/pl/
2026-01-31 09:24:30 +00:00
Pawel Augustyn 8836f06b80 locale(Polish): update translation
Currently translated at 68.6% (492 of 717 strings)

Translation: WYGIWYH/App
Translate-URL: https://translations.herculino.com/projects/wygiwyh/app/pl/
2026-01-31 08:24:31 +00:00
Herculino Trotta e98a48b3a7 Merge pull request #505 from eitchtee/weblate
Translations update from Weblate
2026-01-30 10:37:50 -03:00
obervinov f9bc9f449b locale(Russian): update translation
Currently translated at 27.4% (197 of 717 strings)

Translation: WYGIWYH/App
Translate-URL: https://translations.herculino.com/projects/wygiwyh/app/ru/
2026-01-30 10:24:30 +00:00
Pawel Augustyn 26eb1ae813 locale(Polish): update translation
Currently translated at 62.9% (451 of 717 strings)

Translation: WYGIWYH/App
Translate-URL: https://translations.herculino.com/projects/wygiwyh/app/pl/
2026-01-28 12:24:31 +00:00
Pawel Augustyn 29a2cb9813 locale(Polish): update translation
Currently translated at 52.5% (377 of 717 strings)

Translation: WYGIWYH/App
Translate-URL: https://translations.herculino.com/projects/wygiwyh/app/pl/
2026-01-27 21:24:32 +00:00
Pawel Augustyn be79e1b25a locale(Polish): update translation
Currently translated at 22.3% (160 of 717 strings)

Translation: WYGIWYH/App
Translate-URL: https://translations.herculino.com/projects/wygiwyh/app/pl/
2026-01-27 20:24:31 +00:00
obervinov 3fd08466a7 locale(Russian): update translation
Currently translated at 26.4% (190 of 717 strings)

Translation: WYGIWYH/App
Translate-URL: https://translations.herculino.com/projects/wygiwyh/app/ru/
2026-01-27 09:24:30 +00:00
obervinov 6896cdcdca locale(Russian): update translation
Currently translated at 25.2% (181 of 717 strings)

Translation: WYGIWYH/App
Translate-URL: https://translations.herculino.com/projects/wygiwyh/app/ru/
2026-01-27 08:24:30 +00:00
Pawel Augustyn 2532930a64 locale(Polish): update translation
Currently translated at 15.2% (109 of 717 strings)

Translation: WYGIWYH/App
Translate-URL: https://translations.herculino.com/projects/wygiwyh/app/pl/
2026-01-25 21:24:31 +00:00
Herculino Trotta 24a1ef2d0a fix: add encoding to qif preset 2026-01-25 16:57:00 -03:00
Herculino Trotta 163f2f4e5b Merge pull request #504 from eitchtee/dev
feat: add QIF import
2026-01-25 16:54:02 -03:00
Herculino Trotta ede63acf5f Merge pull request #503 from eitchtee/dependabot/uv/urllib3-2.6.3
build(deps): bump urllib3 from 2.6.2 to 2.6.3
2026-01-25 16:53:41 -03:00
Herculino Trotta a8ba3d8754 Merge pull request #500 from eitchtee/weblate
Translations update from Weblate
2026-01-25 16:53:09 -03:00
dependabot[bot] e2f1156264 build(deps): bump urllib3 from 2.6.2 to 2.6.3
Bumps [urllib3](https://github.com/urllib3/urllib3) from 2.6.2 to 2.6.3.
- [Release notes](https://github.com/urllib3/urllib3/releases)
- [Changelog](https://github.com/urllib3/urllib3/blob/main/CHANGES.rst)
- [Commits](https://github.com/urllib3/urllib3/compare/2.6.2...2.6.3)

---
updated-dependencies:
- dependency-name: urllib3
  dependency-version: 2.6.3
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-01-25 19:49:42 +00:00
Herculino Trotta d5bbad7887 feat: add QIF import 2026-01-25 16:46:56 -03:00
Andrei Kamianets 7ebacff6e4 locale(Russian): update translation
Currently translated at 25.1% (180 of 717 strings)

Translation: WYGIWYH/App
Translate-URL: https://translations.herculino.com/projects/wygiwyh/app/ru/
2026-01-19 10:24:31 +00:00
Andrei Kamianets df8ef5d04c locale(Russian): update translation
Currently translated at 12.6% (91 of 717 strings)

Translation: WYGIWYH/App
Translate-URL: https://translations.herculino.com/projects/wygiwyh/app/ru/
2026-01-19 09:24:31 +00:00
Andrei Kamianets fa2a8b8c65 locale((Russian)): added translation using Weblate 2026-01-19 08:57:52 +00:00
Juan David Afanador e44ac5dab6 locale(Spanish): update translation
Currently translated at 100.0% (717 of 717 strings)

Translation: WYGIWYH/App
Translate-URL: https://translations.herculino.com/projects/wygiwyh/app/es/
2026-01-18 21:24:30 +00:00
Ebrahim Tayabali f9261d1283 locale(Swahili): update translation
Currently translated at 0.9% (7 of 717 strings)

Translation: WYGIWYH/App
Translate-URL: https://translations.herculino.com/projects/wygiwyh/app/sw/
2026-01-18 19:24:30 +00:00
Ebrahim Tayabali 4c73c1cae5 locale(Swahili): update translation
Currently translated at 0.0% (0 of 717 strings)

Translation: WYGIWYH/App
Translate-URL: https://translations.herculino.com/projects/wygiwyh/app/sw/
2026-01-15 21:24:30 +00:00
Ebrahim Tayabali 0315a56f88 locale((Swahili)): added translation using Weblate 2026-01-15 20:56:34 +00:00
sorcierwax 44d6b8b53c locale(French): update translation
Currently translated at 100.0% (717 of 717 strings)

Translation: WYGIWYH/App
Translate-URL: https://translations.herculino.com/projects/wygiwyh/app/fr/
2026-01-14 22:24:30 +00:00
Herculino Trotta 3e4d7c6b1f Merge pull request #497 from icovada/pagination_simplification
refactor(api): apply CustomNumberPagination to all API views
2026-01-11 13:58:57 -03:00
Herculino Trotta 63868514f9 Merge pull request #499 from eitchtee/weblate
Translations update from Weblate
2026-01-11 13:55:39 -03:00
Herculino Trotta 9055a24327 locale(Portuguese (Brazil)): update translation
Currently translated at 100.0% (717 of 717 strings)

Translation: WYGIWYH/App
Translate-URL: https://translations.herculino.com/projects/wygiwyh/app/pt_BR/
2026-01-11 16:55:26 +00:00
Dimitri Decrock 9dc963ed7b locale(Dutch): update translation
Currently translated at 100.0% (717 of 717 strings)

Translation: WYGIWYH/App
Translate-URL: https://translations.herculino.com/projects/wygiwyh/app/nl/
2026-01-11 13:24:30 +00:00
Herculino Trotta 49cac0588e add tests and fix missing get_queryset 2026-01-11 12:20:27 +01:00
icovada 3b2b6d6473 Query all DCA Strategies 2026-01-11 12:19:57 +01:00
icovada db30bcbeb7 Remove filtering function superseesed by search_fields 2026-01-11 12:19:57 +01:00
icovada a122733a47 Enable filtering and sorting on all API views 2026-01-11 12:19:30 +01:00
eitchtee 37f3e4d99a chore(locale): update translation files
[skip ci] Automatically generated by Django makemessages workflow
2026-01-10 20:50:08 +00:00
Herculino Trotta d756286135 Merge pull request #496 from eitchtee/dev
feat(automatic-exchange-rate): track and display unsuccessful runs
2026-01-10 17:49:44 -03:00
Herculino Trotta 06a7378fd8 Merge pull request #491 from icovada/rest_filtering
feat(api): filtering
2026-01-10 17:46:04 -03:00
Herculino Trotta ab4075c500 fix: missing list close 2026-01-10 17:44:57 -03:00
Herculino Trotta 96318f003d Merge branch 'main' into rest_filtering 2026-01-10 17:43:45 -03:00
Herculino Trotta 1a0412264a add tests and fix missing get_queryset 2026-01-10 17:42:37 -03:00
icovada 2588404876 Merge branch 'main' into pagination_simplification 2026-01-10 18:16:34 +01:00
Herculino Trotta fdc273103b Merge pull request #485 from icovada/token_authentication
feat(api): add token authentication
2026-01-10 14:15:28 -03:00
icovada c015b78cd6 Apply CustomNumberPagination to all API views 2026-01-10 17:14:53 +00:00
Herculino Trotta 50e5492ea1 feat(automatic-exchange-rate): track unsuccessful runs 2026-01-10 14:10:21 -03:00
eitchtee 796089cdb3 chore(locale): update translation files
[skip ci] Automatically generated by Django makemessages workflow
2026-01-10 05:54:02 +00:00
Herculino Trotta c83b1bf2d6 Merge pull request #495 from eitchtee/dev
feat: add late section to monthly and all views (w/ default ordering)
2026-01-10 02:53:30 -03:00
Herculino Trotta b074ef7929 feat: add late section to monthly and all views (w/ default ordering) 2026-01-10 02:52:46 -03:00
Herculino Trotta ec7e33b3b0 Merge pull request #494 from eitchtee/weblate
Translations update from Weblate
2026-01-10 00:10:03 -03:00
Herculino Trotta 72fedea0db locale(Hungarian): update translation
Currently translated at 21.6% (155 of 715 strings)

Translation: WYGIWYH/App
Translate-URL: https://translations.herculino.com/projects/wygiwyh/app/hu/
2026-01-10 03:09:45 +00:00
Herculino Trotta 0a03745ce6 Merge pull request #493 from eitchtee/dev
fix(dca): strategy api endpoint returns nothing
2026-01-09 23:53:04 -03:00
Herculino Trotta ff4bd79634 fix(dca): strategy api endpoint returns nothing 2026-01-09 23:51:31 -03:00
eitchtee 383b42e26d chore(locale): update translation files
[skip ci] Automatically generated by Django makemessages workflow
2026-01-10 02:27:25 +00:00
Herculino Trotta 48e43ac031 Merge pull request #492 from eitchtee/dev
fix(transactions): empty internal_id raises duplicate error when editing via django admin
2026-01-09 23:27:01 -03:00
Herculino Trotta 21c60c4059 Merge pull request #483 from eitchtee/weblate
Translations update from Weblate
2026-01-09 23:26:22 -03:00
Herculino Trotta dd6a390e6b fix(transactions): empty internal_id raises duplicate error when editing via django admin 2026-01-09 23:25:13 -03:00
icovada 0c961a8250 Query all DCA Strategies 2026-01-08 22:51:50 +01:00
icovada e28c651973 Remove filtering function superseesed by search_fields 2026-01-08 22:51:50 +01:00
icovada 7687ff81c3 Enable filtering and sorting on all API views 2026-01-08 22:51:49 +01:00
Janez b2d78c9190 locale(Hungarian): update translation
Currently translated at 21.6% (155 of 715 strings)

Translation: WYGIWYH/App
Translate-URL: https://translations.herculino.com/projects/wygiwyh/app/hu/
2026-01-02 13:24:30 +00:00
icovada b0815e00c7 Add token authentication to the API 2026-01-02 13:56:15 +01:00
Janez fbe9726338 locale(Hungarian): update translation
Currently translated at 19.4% (139 of 715 strings)

Translation: WYGIWYH/App
Translate-URL: https://translations.herculino.com/projects/wygiwyh/app/hu/
2026-01-02 12:30:11 +00:00
Janez 0df3a57a33 locale(Hungarian): update translation
Currently translated at 17.6% (126 of 715 strings)

Translation: WYGIWYH/App
Translate-URL: https://translations.herculino.com/projects/wygiwyh/app/hu/
2026-01-02 12:24:31 +00:00
Janez f86613b17a locale(Hungarian): update translation
Currently translated at 4.6% (33 of 715 strings)

Translation: WYGIWYH/App
Translate-URL: https://translations.herculino.com/projects/wygiwyh/app/hu/
2026-01-02 11:24:30 +00:00
Herculino Trotta ffa4644e1b Merge pull request #482 from eitchtee/dev
fix(import_restore): unable to restore installment plans when there's multiple accounts with the same name
2025-12-30 22:00:33 -03:00
eitchtee 6611559696 chore(locale): update translation files
[skip ci] Automatically generated by Django makemessages workflow
2025-12-31 00:59:30 +00:00
Herculino Trotta b455a0251a fix(import_restore): unable to restore installment plans when there's multiple accounts with the same name 2025-12-30 21:59:29 -03:00
Herculino Trotta 9d7c3212f1 Merge pull request #481 from eitchtee/dev
refactor: improve month by month and year by year value display
2025-12-30 21:59:04 -03:00
Herculino Trotta 0da3185996 Merge pull request #479 from eitchtee/weblate
Translations update from Weblate
2025-12-30 21:58:42 -03:00
Herculino Trotta 6c90e1bb7f refactor: improve month by month and year by year value display 2025-12-30 21:58:12 -03:00
Dimitri Decrock c6543c0841 locale(Dutch): update translation
Currently translated at 100.0% (715 of 715 strings)

Translation: WYGIWYH/App
Translate-URL: https://translations.herculino.com/projects/wygiwyh/app/nl/
2025-12-29 07:24:30 +00:00
Herculino Trotta d4740b8406 locale(Portuguese (Brazil)): update translation
Currently translated at 100.0% (715 of 715 strings)

Translation: WYGIWYH/App
Translate-URL: https://translations.herculino.com/projects/wygiwyh/app/pt_BR/
2025-12-29 02:24:30 +00:00
eitchtee 5a51795e6a chore(locale): update translation files
[skip ci] Automatically generated by Django makemessages workflow
2025-12-29 01:59:20 +00:00
Herculino Trotta 64d7765357 Merge pull request #478 from eitchtee/dev
feat(insights): new month by month insight
2025-12-28 22:58:56 -03:00
eitchtee 070e11ca77 chore(locale): update translation files
[skip ci] Automatically generated by Django makemessages workflow
2025-12-29 01:57:39 +00:00
Herculino Trotta 39f66b620a feat(insights): new month by month insight 2025-12-28 22:57:29 -03:00
Herculino Trotta ad164866e0 Merge pull request #477 from eitchtee/dev
feat(insights): new year by year insight
2025-12-28 22:57:16 -03:00
Herculino Trotta 05c465cb34 Merge pull request #476 from eitchtee/weblate
Translations update from Weblate
2025-12-28 22:56:32 -03:00
Herculino Trotta 92cf526b76 feat(insights): new year by year insight 2025-12-28 22:55:58 -03:00
icovada 639236b890 locale(Italian): update translation
Currently translated at 99.4% (694 of 698 strings)

Translation: WYGIWYH/App
Translate-URL: https://translations.herculino.com/projects/wygiwyh/app/it/
2025-12-28 22:24:30 +00:00
Herculino Trotta 519a85d256 Merge pull request #474 from eitchtee/dev
feat(tests): add tests for monthly summaries
2025-12-28 13:36:54 -03:00
Herculino Trotta 700d35b5d5 feat(tests): add tests for monthly summaries 2025-12-28 13:36:21 -03:00
eitchtee 10e51971db chore(locale): update translation files
[skip ci] Automatically generated by Django makemessages workflow
2025-12-28 16:22:23 +00:00
Herculino Trotta ec0d5fc121 Merge pull request #473 from eitchtee/dev
feat(transactions:filter): make monthly summary filter-aware
2025-12-28 13:21:55 -03:00
Herculino Trotta 01f91352d6 feat(transactions:filter): make montlhy summary filter-aware 2025-12-28 13:20:25 -03:00
eitchtee 63ce57a315 chore(locale): update translation files
[skip ci] Automatically generated by Django makemessages workflow
2025-12-28 16:11:03 +00:00
Herculino Trotta eadeb649a1 Merge pull request #470 from eitchtee/dev
feat(transactions:filter): add filter for muted and unmuted transactions
2025-12-28 13:10:39 -03:00
Herculino Trotta a2871d5289 feat(transactions:filter): add filter for muted and unmuted transactions 2025-12-28 13:09:41 -03:00
Herculino Trotta f2a362bc0f Merge pull request #469 from eitchtee/dev
feat(app): add sanity checks for env variables & refactor: order management lists by name instead of id
2025-12-27 23:47:04 -03:00
Herculino Trotta 2076903740 refactor: order management lists by name instead of id 2025-12-27 23:43:57 -03:00
Herculino Trotta c752c0b16e Merge pull request #468 from icovada/migrate-to-uv
Manage dependencies with `uv`
2025-12-27 20:18:20 -03:00
Herculino Trotta 1674766253 Merge pull request #467 from eitchtee/weblate
Translations update from Weblate
2025-12-27 20:18:00 -03:00
Herculino Trotta 7ea9d56132 docs: update uv.lock 2025-12-27 20:11:36 -03:00
Herculino Trotta 3699c6c671 docs: remove version from pyproject.yml 2025-12-27 19:58:46 -03:00
Herculino Trotta d7c255aa14 refactor: remove build context from production image 2025-12-27 19:53:57 -03:00
Herculino Trotta d17b9d5736 fix: dev image fails due to the environment being overwritten at runtime 2025-12-26 10:30:22 -03:00
Herculino Trotta c7ff6db0bf feat(app): add sanity checks for env variables 2025-12-26 09:55:57 -03:00
Federico Tabbò a4c7753f69 Configure setuptools to filter folders 2025-12-26 10:14:45 +01:00
icovada 7e08028557 use uv in GH actions 2025-12-21 17:28:45 +01:00
icovada 5eaf5086d2 build prod image with uv 2025-12-21 17:28:39 +01:00
icovada c949c6cea0 add build instructions to prod docker-compose 2025-12-21 17:28:24 +01:00
icovada 71c0e9a271 locale(Italian): update translation
Currently translated at 99.5% (694 of 697 strings)

Translation: WYGIWYH/App
Translate-URL: https://translations.herculino.com/projects/wygiwyh/app/it/
2025-12-21 15:24:30 +00:00
icovada bc65980511 migrate dev dockerfile to uv 2025-12-21 16:12:18 +01:00
icovada ecdb1a52cc stop docker copying pycache in images 2025-12-21 16:10:50 +01:00
icovada afc06582b4 set up dependencies in uv 2025-12-21 16:09:12 +01:00
135 changed files with 27265 additions and 6132 deletions
+1
View File
@@ -0,0 +1 @@
__pycache__/
+18
View File
@@ -38,3 +38,21 @@ TASK_WORKERS=1 # This only work if you're using the single container option. Inc
#OIDC_CLIENT_SECRET=""
#OIDC_SERVER_URL=""
#OIDC_ALLOW_SIGNUP=true
# Personal access tokens. How often (seconds) a token's last_used_at is rewritten.
#API_TOKEN_LAST_USED_UPDATE_INTERVAL=600
# MCP OAuth Application. Uncomment to auto-create/update the OAuth client
# used by remote MCP integrations after migrations complete.
#MCP_OAUTH_CLIENT_NAME="WYGIWYH MCP"
#MCP_OAUTH_CLIENT_ID="mcp-wygiwyh"
#MCP_OAUTH_CLIENT_SECRET="<INSERT A SAFE SECRET HERE>"
#MCP_OAUTH_REDIRECT_URIS="http://127.0.0.1:8765/callback"
#MCP_OAUTH_SKIP_AUTHORIZATION=false
# Dynamic Client Registration (RFC 7591). Disabled by default because an open
# registration endpoint lets anyone create OAuth applications. Enable only if
# remote MCP clients must self-register, and optionally require an initial
# access token (sent as "Authorization: Bearer <token>" on /oauth/register/).
#OAUTH2_DCR_ENABLED=false
#OAUTH2_DCR_INITIAL_ACCESS_TOKEN=""
+8 -7
View File
@@ -32,15 +32,16 @@ jobs:
token: ${{ secrets.PAT }}
ref: ${{ github.head_ref }}
- name: Set up Python 3.11
uses: actions/setup-python@v4
- name: Install uv
uses: astral-sh/setup-uv@v5
with:
python-version: '3.11'
enable-cache: true
- name: Set up Python 3.11
run: uv python install 3.11
- name: Install dependencies
run: |
python -m pip install --upgrade pip
pip install -r requirements.txt
run: uv sync --frozen --no-dev
- name: Install gettext
run: sudo apt-get install -y gettext
@@ -48,7 +49,7 @@ jobs:
- name: Run makemessages
run: |
cd app
python manage.py makemessages -a
uv run python manage.py makemessages -a
- name: Check for changes
id: check_changes
+3
View File
@@ -165,3 +165,6 @@ cython_debug/
node_modules/
postgres_data/
.prod.env
# Private local uploads
app/attachments/
+29
View File
@@ -0,0 +1,29 @@
{
"version": "0.2.0",
"configurations": [
{
"name": "Docker: Dev",
"type": "node-terminal",
"request": "launch",
"command": "docker compose --env-file .env -f docker-compose.dev.yml up --build",
"cwd": "${workspaceFolder}",
"postDebugTask": "Docker: Dev Down"
},
{
"name": "Docker: Dev (no rebuild)",
"type": "node-terminal",
"request": "launch",
"command": "docker compose --env-file .env -f docker-compose.dev.yml up",
"cwd": "${workspaceFolder}",
"postDebugTask": "Docker: Dev Down"
},
{
"name": "Docker: Prod",
"type": "node-terminal",
"request": "launch",
"command": "docker compose --env-file .prod.env -f docker-compose.prod.yml up --build",
"cwd": "${workspaceFolder}",
"postDebugTask": "Docker: Prod Down"
}
]
}
+119
View File
@@ -0,0 +1,119 @@
{
"version": "2.0.0",
"tasks": [
{
"label": "Docker: Dev",
"type": "shell",
"command": "docker",
"args": [
"compose",
"--env-file",
".env",
"-f",
"docker-compose.dev.yml",
"up",
"--build"
],
"options": {
"cwd": "${workspaceFolder}"
},
"group": "build",
"problemMatcher": []
},
{
"label": "Docker: Dev (no rebuild)",
"type": "shell",
"command": "docker",
"args": [
"compose",
"--env-file",
".env",
"-f",
"docker-compose.dev.yml",
"up"
],
"options": {
"cwd": "${workspaceFolder}"
},
"problemMatcher": []
},
{
"label": "Docker: Dev Refresh Vite Deps",
"type": "shell",
"command": "docker compose --env-file .env -f docker-compose.dev.yml rm -sfv vite; docker compose --env-file .env -f docker-compose.dev.yml up --build",
"options": {
"cwd": "${workspaceFolder}"
},
"problemMatcher": []
},
{
"label": "Docker: Dev Down",
"type": "shell",
"command": "docker",
"args": [
"compose",
"--env-file",
".env",
"-f",
"docker-compose.dev.yml",
"down"
],
"options": {
"cwd": "${workspaceFolder}"
},
"problemMatcher": []
},
{
"label": "Docker: Prod",
"type": "shell",
"command": "docker",
"args": [
"compose",
"--env-file",
".prod.env",
"-f",
"docker-compose.prod.yml",
"up",
"--build"
],
"options": {
"cwd": "${workspaceFolder}"
},
"problemMatcher": []
},
{
"label": "Docker: Prod Down",
"type": "shell",
"command": "docker",
"args": [
"compose",
"--env-file",
".prod.env",
"-f",
"docker-compose.prod.yml",
"down"
],
"options": {
"cwd": "${workspaceFolder}"
},
"problemMatcher": []
},
{
"label": "Django: Runserver localhost:8000",
"type": "shell",
"command": "${command:python.interpreterPath}",
"args": [
"manage.py",
"runserver",
"localhost:8000"
],
"options": {
"cwd": "${workspaceFolder}/app",
"env": {
"PYTHONUNBUFFERED": "1"
}
},
"problemMatcher": []
}
]
}
+50
View File
@@ -157,6 +157,13 @@ WYGIWYH supports login via OpenID Connect (OIDC) through `django-allauth`. This
> [!NOTE]
> Currently only OpenID Connect is supported as a provider, open an issue if you need something else.
> [!Caution]
> WYGIWYH automatically connects OIDC accounts to existing local accounts with matching email addresses.
> This means if a user already exists with email `user@example.com` and someone logs in via OIDC with the same email, the OIDC account will be automatically linked to the existing account without requiring user confirmation.
> This is only recommended for trusted OIDC providers that verify email addresses and where you control who can create accounts.
### Configuration
To configure OIDC, you need to set the following environment variables:
| Variable | Description |
@@ -175,6 +182,49 @@ When configuring your OIDC provider, you will need to provide a callback URL (al
Replace `https://your.wygiwyh.domain` with the actual URL where your WYGIWYH instance is accessible. And `<OIDC_CLIENT_NAME>` with the slugfied value set in OIDC_CLIENT_NAME or the default `openid-connect` if you haven't set this variable.
### API Tokens for n8n and other automations
If you need a stable non-browser credential for automations such as `n8n`, WYGIWYH can also issue its own user-bound API tokens. This avoids Keycloak login flows and can be used directly against `/api/`.
Create a token from the container or application shell:
```bash
python manage.py create_api_token you@example.com --name n8n
```
Optional expiration:
```bash
python manage.py create_api_token you@example.com --name n8n --expires-in-days 90
```
The command prints the raw token **once**. Store it in your secret manager and use it like this:
```bash
curl -H "Authorization: Token wygiwyh_pat_<key>.<secret>" \
https://your.wygiwyh.domain/api/accounts/
```
Recommended usage for automation is a dedicated WYGIWYH user such as `n8n@...`, so API ownership and audit trails stay separate from your interactive account.
### MCP OAuth Application Bootstrap
If you want WYGIWYH to act as the OAuth authorization server for a remote MCP server, you can let the container create or update the OAuth application automatically on startup.
Set these environment variables:
| Variable | Description |
|---|---|
| `MCP_OAUTH_CLIENT_NAME` | Optional display name for the OAuth client. Defaults to `WYGIWYH MCP`. |
| `MCP_OAUTH_CLIENT_ID` | Client ID that will be created or updated in `django-oauth-toolkit`. |
| `MCP_OAUTH_CLIENT_SECRET` | Client secret for that OAuth application. |
| `MCP_OAUTH_REDIRECT_URIS` | Space-separated redirect URIs allowed for the MCP OAuth client. |
| `MCP_OAUTH_SKIP_AUTHORIZATION` | Set to `true` to bypass the consent screen. Defaults to `false`. |
When these variables are present, startup runs `python manage.py setup_oauth` after migrations and keeps the OAuth application in sync without needing a manual Django admin step.
WYGIWYH also exposes OAuth Dynamic Client Registration at `/.well-known/oauth-authorization-server` via `registration_endpoint`, so MCP clients that support RFC 7591 can self-register instead of relying on a pre-created `MCP_OAUTH_CLIENT_ID` / `MCP_OAUTH_CLIENT_SECRET`. The current implementation supports `authorization_code` + PKCE clients using `none`, `client_secret_basic`, or `client_secret_post` token endpoint auth methods.
# How it works
Check out our [Wiki](https://github.com/eitchtee/WYGIWYH/wiki) for more information.
+63 -22
View File
@@ -70,7 +70,9 @@ INSTALLED_APPS = [
"apps.api.apps.ApiConfig",
"cachalot",
"rest_framework",
"rest_framework.authtoken",
"drf_spectacular",
"oauth2_provider",
"django_cotton",
"apps.rules.apps.RulesConfig",
"apps.calendar_view.apps.CalendarViewConfig",
@@ -310,6 +312,7 @@ LOCALE_PATHS = [BASE_DIR / "locale"]
STATIC_URL = "static/"
STATIC_ROOT = BASE_DIR / "static_files"
ATTACHMENT_MEDIA_ROOT = BASE_DIR / "attachments"
STATICFILES_DIRS = [
ROOT_DIR / "frontend" / "build",
@@ -342,6 +345,11 @@ DEFAULT_AUTO_FIELD = "django.db.models.BigAutoField"
LOGIN_REDIRECT_URL = "/"
LOGIN_URL = "/login/"
LOGOUT_REDIRECT_URL = "/login/"
# Public base URL advertised in OAuth metadata. Falls back to the first entry
# of the existing space-separated URL env var, then to the request host.
PUBLIC_BASE_URL = (
os.getenv("PUBLIC_BASE_URL", "") or os.getenv("URL", "").split(" ")[0]
).rstrip("/")
# Allauth settings
AUTHENTICATION_BACKENDS = [
@@ -375,8 +383,16 @@ ACCOUNT_EMAIL_VERIFICATION = "none"
SOCIALACCOUNT_LOGIN_ON_GET = True
SOCIALACCOUNT_ONLY = True
SOCIALACCOUNT_AUTO_SIGNUP = os.getenv("OIDC_ALLOW_SIGNUP", "true").lower() == "true"
SOCIALACCOUNT_EMAIL_AUTHENTICATION = True
SOCIALACCOUNT_EMAIL_AUTHENTICATION_AUTO_CONNECT = True
ACCOUNT_ADAPTER = "allauth.account.adapter.DefaultAccountAdapter"
SOCIALACCOUNT_ADAPTER = "allauth.socialaccount.adapter.DefaultSocialAccountAdapter"
SOCIALACCOUNT_ADAPTER = "apps.users.adapters.AutoConnectSocialAccountAdapter"
# Personal access tokens. last_used_at is only rewritten once per interval to
# avoid a database write on every authenticated request.
API_TOKEN_LAST_USED_UPDATE_INTERVAL = int(
os.getenv("API_TOKEN_LAST_USED_UPDATE_INTERVAL", "600")
)
# CRISPY FORMS
CRISPY_ALLOWED_TEMPLATE_PACKS = [
@@ -389,6 +405,10 @@ SESSION_EXPIRE_AT_BROWSER_CLOSE = False
SESSION_COOKIE_AGE = int(os.getenv("SESSION_EXPIRY_TIME", 2678400)) # 31 days
SESSION_COOKIE_SECURE = os.getenv("HTTPS_ENABLED", "false").lower() == "true"
HTTPS_ENABLED = os.getenv("HTTPS_ENABLED", "false").lower() == "true"
ACCOUNT_DEFAULT_HTTP_PROTOCOL = "https" if HTTPS_ENABLED else "http"
SECURE_PROXY_SSL_HEADER = ("HTTP_X_FORWARDED_PROTO", "https") if HTTPS_ENABLED else None
DEBUG_TOOLBAR_CONFIG = {
"ROOT_TAG_EXTRA_ATTRS": "hx-preserve",
# "SHOW_TOOLBAR_CALLBACK": lambda r: False, # disables it
@@ -433,11 +453,38 @@ REST_FRAMEWORK = {
"apps.api.permissions.NotInDemoMode",
"rest_framework.permissions.DjangoModelPermissions",
],
"DEFAULT_PAGINATION_CLASS": "rest_framework.pagination.PageNumberPagination",
"PAGE_SIZE": 10,
"DEFAULT_FILTER_BACKENDS": [
"django_filters.rest_framework.DjangoFilterBackend",
"rest_framework.filters.OrderingFilter",
],
"DEFAULT_AUTHENTICATION_CLASSES": [
"oauth2_provider.contrib.rest_framework.OAuth2Authentication",
"apps.api.authentication.APITokenAuthentication",
"rest_framework.authentication.TokenAuthentication",
"rest_framework.authentication.SessionAuthentication",
"rest_framework.authentication.BasicAuthentication",
],
"DEFAULT_PAGINATION_CLASS": "apps.api.custom.pagination.CustomPageNumberPagination",
"DEFAULT_SCHEMA_CLASS": "drf_spectacular.openapi.AutoSchema",
}
OAUTH2_PROVIDER = {
"PKCE_REQUIRED": True,
"ACCESS_TOKEN_EXPIRE_SECONDS": int(
os.getenv("OAUTH2_ACCESS_TOKEN_EXPIRE_SECONDS", "3600")
),
"SCOPES": {
"mcp": "Access WYGIWYH from MCP clients.",
},
}
# Dynamic Client Registration (RFC 7591). Disabled by default: an open
# registration endpoint lets anyone create OAuth applications. Enable it only
# when remote MCP clients must self-register, and optionally require an initial
# access token presented as `Authorization: Bearer <token>`.
OAUTH2_DCR_ENABLED = os.getenv("OAUTH2_DCR_ENABLED", "false").lower() == "true"
OAUTH2_DCR_INITIAL_ACCESS_TOKEN = os.getenv("OAUTH2_DCR_INITIAL_ACCESS_TOKEN", "")
SPECTACULAR_SETTINGS = {
"TITLE": "WYGIWYH API",
"DESCRIPTION": "A no-frills expense tracker",
@@ -449,7 +496,7 @@ SPECTACULAR_SETTINGS = {
if "procrastinate" in sys.argv:
LOGGING = {
"version": 1,
"disable_existing_loggers": False,
"disable_existing_loggers": True,
"formatters": {
"standard": {
"format": "[%(asctime)s] - %(levelname)s - %(name)s - %(message)s",
@@ -457,26 +504,19 @@ if "procrastinate" in sys.argv:
},
},
"handlers": {
"procrastinate": {
"level": "INFO",
"class": "logging.StreamHandler",
"formatter": "standard",
},
"console": {
"class": "logging.StreamHandler",
"formatter": "standard",
"level": "INFO",
},
},
"root": {
"handlers": ["console"],
"level": "INFO",
},
"loggers": {
"procrastinate": {
"handlers": ["procrastinate"],
"propagate": False,
},
"root": {
"handlers": ["console"],
"level": "INFO",
"propagate": False,
},
},
}
@@ -496,19 +536,20 @@ else:
"formatter": "standard",
"level": "INFO",
},
"procrastinate": {
"level": "INFO",
"class": "logging.StreamHandler",
},
},
"root": {
"handlers": ["console"],
"level": "INFO",
},
"loggers": {
"procrastinate": {
"handlers": None,
"handlers": [],
"propagate": False,
},
"root": {
"allauth": {
"handlers": ["console"],
"level": "INFO",
"level": "DEBUG",
"propagate": False,
},
},
}
+37
View File
@@ -22,6 +22,29 @@ from drf_spectacular.views import (
SpectacularSwaggerView,
)
from allauth.socialaccount.providers.openid_connect.views import login, callback
from apps.common.decorators.demo import disabled_on_demo
from apps.common.oauth_views import (
authorization_server_metadata,
dynamic_client_registration,
)
from oauth2_provider import urls as _dot_urls
def _decorate_included(patterns, decorator):
"""Apply ``decorator`` to every view callback inside an included URLconf.
django.urls does not support decorating ``include()`` directly, so we wrap
each URLPattern's callback here. The OAuth2 endpoints issue credentials, so
gate them behind the same DEMO-mode guard used elsewhere.
"""
wrapped = []
for pattern in patterns:
pattern.callback = decorator(pattern.callback)
wrapped.append(pattern)
return wrapped
_oauth_patterns = _decorate_included(_dot_urls.urlpatterns, disabled_on_demo)
urlpatterns = [
@@ -39,6 +62,20 @@ urlpatterns = [
name="swagger-ui",
),
path("auth/", include("allauth.urls")), # allauth urls
path(
"oauth/",
include((_oauth_patterns, _dot_urls.app_name), namespace="oauth2_provider"),
),
path(
".well-known/oauth-authorization-server",
disabled_on_demo(authorization_server_metadata),
name="oauth-authorization-server-metadata",
),
path(
"oauth/register/",
disabled_on_demo(dynamic_client_registration),
name="oauth-dynamic-client-registration",
),
# path("auth/oidc/<str:provider_id>/login/", login, name="openid_connect_login"),
# path(
# "auth/oidc/<str:provider_id>/login/callback/",
+1 -1
View File
@@ -25,7 +25,7 @@ def account_groups_index(request):
@login_required
@require_http_methods(["GET"])
def account_groups_list(request):
account_groups = AccountGroup.objects.all().order_by("id")
account_groups = AccountGroup.objects.all().order_by("name")
return render(
request,
"account_groups/fragments/list.html",
+1 -1
View File
@@ -25,7 +25,7 @@ def accounts_index(request):
@login_required
@require_http_methods(["GET"])
def accounts_list(request):
accounts = Account.objects.all().order_by("id")
accounts = Account.objects.all().order_by("name")
return render(
request,
"accounts/fragments/list.html",
+64
View File
@@ -0,0 +1,64 @@
from datetime import timedelta
from django.conf import settings
from django.utils import timezone
from rest_framework.authentication import BaseAuthentication, get_authorization_header
from rest_framework.exceptions import AuthenticationFailed
from apps.users.models import APIToken
class APITokenAuthentication(BaseAuthentication):
keyword = "Token"
def authenticate(self, request):
auth = get_authorization_header(request).split()
if not auth or auth[0].lower() != self.keyword.lower().encode():
return None
if len(auth) != 2:
raise AuthenticationFailed("Invalid API token header.")
try:
raw_token = auth[1].decode("utf-8")
except UnicodeDecodeError as exc:
raise AuthenticationFailed("Invalid API token header.") from exc
# Only claim tokens carrying our prefix; otherwise return None so the
# request falls through to other authenticators (e.g. DRF's built-in
# TokenAuthentication, which shares the "Token" keyword).
if not raw_token.startswith(APIToken.TOKEN_PREFIX):
return None
try:
token_key, token_secret = APIToken.parse_raw_token(raw_token)
except ValueError as exc:
raise AuthenticationFailed("Invalid API token.") from exc
token = APIToken.objects.select_related("user").filter(token_key=token_key).first()
if token is None or not token.check_secret(token_secret):
raise AuthenticationFailed("Invalid API token.")
if token.revoked_at is not None:
raise AuthenticationFailed("API token has been revoked.")
if token.is_expired():
raise AuthenticationFailed("API token has expired.")
if not token.user.is_active:
raise AuthenticationFailed("User account is disabled.")
self._touch_last_used(token)
return (token.user, token)
@staticmethod
def _touch_last_used(token):
# Avoid a write on every request: only refresh once per interval.
now = timezone.now()
interval = settings.API_TOKEN_LAST_USED_UPDATE_INTERVAL
if (
token.last_used_at is None
or (now - token.last_used_at) >= timedelta(seconds=interval)
):
token.last_used_at = now
token.save(update_fields=["last_used_at"])
def authenticate_header(self, request):
return self.keyword
+2 -1
View File
@@ -1,4 +1,5 @@
# Import all test classes for Django test discovery
from .test_imports import *
from .test_accounts import *
from .test_data_isolation import *
from .test_shared_access import *
+2 -2
View File
@@ -90,10 +90,10 @@ class AccountBalanceAPITests(TestCase):
self.assertEqual(response.status_code, status.HTTP_404_NOT_FOUND)
def test_get_balance_unauthenticated(self):
"""Test unauthenticated request returns 403"""
"""Test unauthenticated request returns 401"""
unauthenticated_client = APIClient()
response = unauthenticated_client.get(
f"/api/accounts/{self.account.id}/balance/"
)
self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN)
self.assertEqual(response.status_code, status.HTTP_401_UNAUTHORIZED)
+109
View File
@@ -0,0 +1,109 @@
from datetime import timedelta
from django.contrib.auth import get_user_model
from django.test import RequestFactory, TestCase, override_settings
from django.utils import timezone
from rest_framework.exceptions import AuthenticationFailed
from apps.api.authentication import APITokenAuthentication
from apps.users.models import APIToken
class APITokenAuthenticationTests(TestCase):
def setUp(self):
self.factory = RequestFactory()
self.authentication = APITokenAuthentication()
self.user = get_user_model().objects.create_user(
email="automation@example.com",
password="test-password",
)
def test_returns_none_without_token_header(self):
request = self.factory.get("/api/accounts/")
self.assertIsNone(self.authentication.authenticate(request))
def test_authenticates_valid_api_token(self):
token, raw_token = APIToken.objects.create_token(user=self.user, name="n8n")
request = self.factory.get(
"/api/accounts/",
HTTP_AUTHORIZATION=f"Token {raw_token}",
)
authenticated_user, authenticated_token = self.authentication.authenticate(request)
self.assertEqual(authenticated_user, self.user)
self.assertEqual(authenticated_token.pk, token.pk)
token.refresh_from_db()
self.assertIsNotNone(token.last_used_at)
def test_rejects_expired_api_token(self):
token, raw_token = APIToken.objects.create_token(user=self.user, name="n8n")
token.expires_at = timezone.now() - timedelta(minutes=1)
token.save(update_fields=["expires_at"])
request = self.factory.get(
"/api/accounts/",
HTTP_AUTHORIZATION=f"Token {raw_token}",
)
with self.assertRaisesRegex(AuthenticationFailed, "expired"):
self.authentication.authenticate(request)
def test_rejects_revoked_api_token(self):
token, raw_token = APIToken.objects.create_token(user=self.user, name="n8n")
token.revoked_at = timezone.now()
token.save(update_fields=["revoked_at"])
request = self.factory.get(
"/api/accounts/",
HTTP_AUTHORIZATION=f"Token {raw_token}",
)
with self.assertRaisesRegex(AuthenticationFailed, "revoked"):
self.authentication.authenticate(request)
def test_stores_secret_as_sha256_not_raw(self):
token, raw_token = APIToken.objects.create_token(user=self.user, name="n8n")
_key, secret = APIToken.parse_raw_token(raw_token)
self.assertNotIn(secret, token.token_hash)
self.assertEqual(len(token.token_hash), 64)
self.assertTrue(token.check_secret(secret))
def test_falls_through_for_non_prefixed_token(self):
request = self.factory.get(
"/api/accounts/",
HTTP_AUTHORIZATION="Token deadbeefdeadbeefdeadbeef",
)
# Not our prefix: return None so another authenticator can handle it.
self.assertIsNone(self.authentication.authenticate(request))
@override_settings(API_TOKEN_LAST_USED_UPDATE_INTERVAL=600)
def test_last_used_at_is_throttled_within_interval(self):
token, raw_token = APIToken.objects.create_token(user=self.user, name="n8n")
request = self.factory.get(
"/api/accounts/",
HTTP_AUTHORIZATION=f"Token {raw_token}",
)
self.authentication.authenticate(request)
token.refresh_from_db()
first_used = token.last_used_at
self.assertIsNotNone(first_used)
self.authentication.authenticate(request)
token.refresh_from_db()
self.assertEqual(token.last_used_at, first_used)
@override_settings(API_TOKEN_LAST_USED_UPDATE_INTERVAL=0)
def test_last_used_at_updates_after_interval(self):
token, raw_token = APIToken.objects.create_token(user=self.user, name="n8n")
token.last_used_at = timezone.now() - timedelta(minutes=5)
token.save(update_fields=["last_used_at"])
stale = token.last_used_at
request = self.factory.get(
"/api/accounts/",
HTTP_AUTHORIZATION=f"Token {raw_token}",
)
self.authentication.authenticate(request)
token.refresh_from_db()
self.assertGreater(token.last_used_at, stale)
+719
View File
@@ -0,0 +1,719 @@
from datetime import date
from decimal import Decimal
from django.contrib.auth import get_user_model
from django.test import TestCase, override_settings
from rest_framework import status
from rest_framework.test import APIClient
from apps.accounts.models import Account, AccountGroup
from apps.currencies.models import Currency
from apps.dca.models import DCAStrategy, DCAEntry
from apps.transactions.models import (
Transaction,
TransactionCategory,
TransactionTag,
TransactionEntity,
InstallmentPlan,
RecurringTransaction,
)
ACCESS_DENIED_CODES = [status.HTTP_403_FORBIDDEN, status.HTTP_404_NOT_FOUND]
@override_settings(
STORAGES={
"default": {"BACKEND": "django.core.files.storage.FileSystemStorage"},
"staticfiles": {
"BACKEND": "django.contrib.staticfiles.storage.StaticFilesStorage"
},
},
WHITENOISE_AUTOREFRESH=True,
)
class AccountDataIsolationTests(TestCase):
"""Tests to ensure users cannot access other users' accounts."""
def setUp(self):
"""Set up test data with two distinct users."""
User = get_user_model()
# User 1 - the requester
self.user1 = User.objects.create_user(
email="user1@test.com", password="testpass123"
)
self.client1 = APIClient()
self.client1.force_authenticate(user=self.user1)
# User 2 - owner of data that user1 should NOT access
self.user2 = User.objects.create_user(
email="user2@test.com", password="testpass123"
)
self.client2 = APIClient()
self.client2.force_authenticate(user=self.user2)
# Shared currency
self.currency = Currency.objects.create(
code="USD", name="US Dollar", decimal_places=2, prefix="$ "
)
# User 1's account
self.user1_account_group = AccountGroup.all_objects.create(
name="User1 Group", owner=self.user1
)
self.user1_account = Account.all_objects.create(
name="User1 Account",
group=self.user1_account_group,
currency=self.currency,
owner=self.user1,
)
# User 2's account (private, should be invisible to user1)
self.user2_account_group = AccountGroup.all_objects.create(
name="User2 Group", owner=self.user2
)
self.user2_account = Account.all_objects.create(
name="User2 Account",
group=self.user2_account_group,
currency=self.currency,
owner=self.user2,
)
def test_user_cannot_see_other_users_accounts_in_list(self):
"""GET /api/accounts/ should only return user's own accounts."""
response = self.client1.get("/api/accounts/")
self.assertEqual(response.status_code, status.HTTP_200_OK)
# User1 should only see their own account
account_ids = [acc["id"] for acc in response.data["results"]]
self.assertIn(self.user1_account.id, account_ids)
self.assertNotIn(self.user2_account.id, account_ids)
def test_user_cannot_access_other_users_account_detail(self):
"""GET /api/accounts/{id}/ should deny access to other user's account."""
response = self.client1.get(f"/api/accounts/{self.user2_account.id}/")
self.assertIn(response.status_code, ACCESS_DENIED_CODES)
def test_user_cannot_modify_other_users_account(self):
"""PATCH on other user's account should deny access."""
response = self.client1.patch(
f"/api/accounts/{self.user2_account.id}/",
{"name": "Hacked Account"},
)
self.assertIn(response.status_code, ACCESS_DENIED_CODES)
# Verify account name wasn't changed
self.user2_account.refresh_from_db()
self.assertEqual(self.user2_account.name, "User2 Account")
def test_user_cannot_delete_other_users_account(self):
"""DELETE on other user's account should deny access."""
response = self.client1.delete(f"/api/accounts/{self.user2_account.id}/")
self.assertIn(response.status_code, ACCESS_DENIED_CODES)
# Verify account still exists
self.assertTrue(Account.all_objects.filter(id=self.user2_account.id).exists())
def test_user_cannot_get_balance_of_other_users_account(self):
"""Balance action on other user's account should deny access."""
response = self.client1.get(f"/api/accounts/{self.user2_account.id}/balance/")
self.assertIn(response.status_code, ACCESS_DENIED_CODES)
def test_user_can_access_own_account(self):
"""User can access their own account normally."""
response = self.client1.get(f"/api/accounts/{self.user1_account.id}/")
self.assertEqual(response.status_code, status.HTTP_200_OK)
self.assertEqual(response.data["name"], "User1 Account")
@override_settings(
STORAGES={
"default": {"BACKEND": "django.core.files.storage.FileSystemStorage"},
"staticfiles": {
"BACKEND": "django.contrib.staticfiles.storage.StaticFilesStorage"
},
},
WHITENOISE_AUTOREFRESH=True,
)
class AccountGroupDataIsolationTests(TestCase):
"""Tests to ensure users cannot access other users' account groups."""
def setUp(self):
"""Set up test data with two distinct users."""
User = get_user_model()
self.user1 = User.objects.create_user(
email="user1@test.com", password="testpass123"
)
self.client1 = APIClient()
self.client1.force_authenticate(user=self.user1)
self.user2 = User.objects.create_user(
email="user2@test.com", password="testpass123"
)
# User 1's account group
self.user1_group = AccountGroup.all_objects.create(
name="User1 Group", owner=self.user1
)
# User 2's account group
self.user2_group = AccountGroup.all_objects.create(
name="User2 Group", owner=self.user2
)
def test_user_cannot_see_other_users_account_groups(self):
"""GET /api/account-groups/ should only return user's own groups."""
response = self.client1.get("/api/account-groups/")
self.assertEqual(response.status_code, status.HTTP_200_OK)
group_ids = [grp["id"] for grp in response.data["results"]]
self.assertIn(self.user1_group.id, group_ids)
self.assertNotIn(self.user2_group.id, group_ids)
def test_user_cannot_access_other_users_account_group_detail(self):
"""GET /api/account-groups/{id}/ should deny access to other user's group."""
response = self.client1.get(f"/api/account-groups/{self.user2_group.id}/")
self.assertIn(response.status_code, ACCESS_DENIED_CODES)
def test_user_cannot_modify_other_users_account_group(self):
"""PATCH on other user's account group should deny access."""
response = self.client1.patch(
f"/api/account-groups/{self.user2_group.id}/",
{"name": "Hacked Group"},
)
self.assertIn(response.status_code, ACCESS_DENIED_CODES)
self.user2_group.refresh_from_db()
self.assertEqual(self.user2_group.name, "User2 Group")
def test_user_cannot_delete_other_users_account_group(self):
"""DELETE on other user's account group should deny access."""
response = self.client1.delete(f"/api/account-groups/{self.user2_group.id}/")
self.assertIn(response.status_code, ACCESS_DENIED_CODES)
self.assertTrue(
AccountGroup.all_objects.filter(id=self.user2_group.id).exists()
)
@override_settings(
STORAGES={
"default": {"BACKEND": "django.core.files.storage.FileSystemStorage"},
"staticfiles": {
"BACKEND": "django.contrib.staticfiles.storage.StaticFilesStorage"
},
},
WHITENOISE_AUTOREFRESH=True,
)
class TransactionDataIsolationTests(TestCase):
"""Tests to ensure users cannot access other users' transactions."""
def setUp(self):
"""Set up test data with transactions for two distinct users."""
User = get_user_model()
self.user1 = User.objects.create_user(
email="user1@test.com", password="testpass123"
)
self.client1 = APIClient()
self.client1.force_authenticate(user=self.user1)
self.user2 = User.objects.create_user(
email="user2@test.com", password="testpass123"
)
self.currency = Currency.objects.create(
code="USD", name="US Dollar", decimal_places=2, prefix="$ "
)
# User 1's account and transaction
self.user1_account = Account.all_objects.create(
name="User1 Account", currency=self.currency, owner=self.user1
)
self.user1_transaction = Transaction.userless_all_objects.create(
account=self.user1_account,
type=Transaction.Type.INCOME,
amount=Decimal("100.00"),
is_paid=True,
date=date(2025, 1, 1),
description="User1 Income",
owner=self.user1,
)
# User 2's account and transaction
self.user2_account = Account.all_objects.create(
name="User2 Account", currency=self.currency, owner=self.user2
)
self.user2_transaction = Transaction.userless_all_objects.create(
account=self.user2_account,
type=Transaction.Type.EXPENSE,
amount=Decimal("50.00"),
is_paid=True,
date=date(2025, 1, 1),
description="User2 Expense",
owner=self.user2,
)
def test_user_cannot_see_other_users_transactions_in_list(self):
"""GET /api/transactions/ should only return user's own transactions."""
response = self.client1.get("/api/transactions/")
self.assertEqual(response.status_code, status.HTTP_200_OK)
transaction_ids = [t["id"] for t in response.data["results"]]
self.assertIn(self.user1_transaction.id, transaction_ids)
self.assertNotIn(self.user2_transaction.id, transaction_ids)
def test_user_cannot_access_other_users_transaction_detail(self):
"""GET /api/transactions/{id}/ should deny access to other user's transaction."""
response = self.client1.get(f"/api/transactions/{self.user2_transaction.id}/")
self.assertIn(response.status_code, ACCESS_DENIED_CODES)
def test_user_cannot_modify_other_users_transaction(self):
"""PATCH on other user's transaction should deny access."""
response = self.client1.patch(
f"/api/transactions/{self.user2_transaction.id}/",
{"description": "Hacked Transaction"},
)
self.assertIn(response.status_code, ACCESS_DENIED_CODES)
self.user2_transaction.refresh_from_db()
self.assertEqual(self.user2_transaction.description, "User2 Expense")
def test_user_cannot_delete_other_users_transaction(self):
"""DELETE on other user's transaction should deny access."""
response = self.client1.delete(
f"/api/transactions/{self.user2_transaction.id}/"
)
self.assertIn(response.status_code, ACCESS_DENIED_CODES)
self.assertTrue(
Transaction.userless_all_objects.filter(
id=self.user2_transaction.id
).exists()
)
def test_user_cannot_create_transaction_in_other_users_account(self):
"""POST /api/transactions/ with other user's account should fail."""
response = self.client1.post(
"/api/transactions/",
{
"account": self.user2_account.id,
"type": "IN",
"amount": "100.00",
"date": "2025-01-15",
"description": "Sneaky transaction",
},
format="json",
)
# Should deny access - 400 (validation error), 403, or 404
self.assertIn(
response.status_code,
ACCESS_DENIED_CODES + [status.HTTP_400_BAD_REQUEST],
)
@override_settings(
STORAGES={
"default": {"BACKEND": "django.core.files.storage.FileSystemStorage"},
"staticfiles": {
"BACKEND": "django.contrib.staticfiles.storage.StaticFilesStorage"
},
},
WHITENOISE_AUTOREFRESH=True,
)
class CategoryTagEntityIsolationTests(TestCase):
"""Tests for isolation of categories, tags, and entities between users."""
def setUp(self):
"""Set up test data."""
User = get_user_model()
self.user1 = User.objects.create_user(
email="user1@test.com", password="testpass123"
)
self.client1 = APIClient()
self.client1.force_authenticate(user=self.user1)
self.user2 = User.objects.create_user(
email="user2@test.com", password="testpass123"
)
# User 1's categories, tags, entities
self.user1_category = TransactionCategory.all_objects.create(
name="User1 Category", owner=self.user1
)
self.user1_tag = TransactionTag.all_objects.create(
name="User1 Tag", owner=self.user1
)
self.user1_entity = TransactionEntity.all_objects.create(
name="User1 Entity", owner=self.user1
)
# User 2's categories, tags, entities
self.user2_category = TransactionCategory.all_objects.create(
name="User2 Category", owner=self.user2
)
self.user2_tag = TransactionTag.all_objects.create(
name="User2 Tag", owner=self.user2
)
self.user2_entity = TransactionEntity.all_objects.create(
name="User2 Entity", owner=self.user2
)
def test_user_cannot_see_other_users_categories(self):
"""GET /api/categories/ should only return user's own categories."""
response = self.client1.get("/api/categories/")
self.assertEqual(response.status_code, status.HTTP_200_OK)
category_ids = [c["id"] for c in response.data["results"]]
self.assertIn(self.user1_category.id, category_ids)
self.assertNotIn(self.user2_category.id, category_ids)
def test_user_cannot_access_other_users_category_detail(self):
"""GET /api/categories/{id}/ should deny access to other user's category."""
response = self.client1.get(f"/api/categories/{self.user2_category.id}/")
self.assertIn(response.status_code, ACCESS_DENIED_CODES)
def test_user_cannot_see_other_users_tags(self):
"""GET /api/tags/ should only return user's own tags."""
response = self.client1.get("/api/tags/")
self.assertEqual(response.status_code, status.HTTP_200_OK)
tag_ids = [t["id"] for t in response.data["results"]]
self.assertIn(self.user1_tag.id, tag_ids)
self.assertNotIn(self.user2_tag.id, tag_ids)
def test_user_cannot_access_other_users_tag_detail(self):
"""GET /api/tags/{id}/ should deny access to other user's tag."""
response = self.client1.get(f"/api/tags/{self.user2_tag.id}/")
self.assertIn(response.status_code, ACCESS_DENIED_CODES)
def test_user_cannot_see_other_users_entities(self):
"""GET /api/entities/ should only return user's own entities."""
response = self.client1.get("/api/entities/")
self.assertEqual(response.status_code, status.HTTP_200_OK)
entity_ids = [e["id"] for e in response.data["results"]]
self.assertIn(self.user1_entity.id, entity_ids)
self.assertNotIn(self.user2_entity.id, entity_ids)
def test_user_cannot_access_other_users_entity_detail(self):
"""GET /api/entities/{id}/ should deny access to other user's entity."""
response = self.client1.get(f"/api/entities/{self.user2_entity.id}/")
self.assertIn(response.status_code, ACCESS_DENIED_CODES)
def test_user_cannot_modify_other_users_category(self):
"""PATCH on other user's category should deny access."""
response = self.client1.patch(
f"/api/categories/{self.user2_category.id}/",
{"name": "Hacked Category"},
)
self.assertIn(response.status_code, ACCESS_DENIED_CODES)
def test_user_cannot_delete_other_users_tag(self):
"""DELETE on other user's tag should deny access."""
response = self.client1.delete(f"/api/tags/{self.user2_tag.id}/")
self.assertIn(response.status_code, ACCESS_DENIED_CODES)
self.assertTrue(
TransactionTag.all_objects.filter(id=self.user2_tag.id).exists()
)
@override_settings(
STORAGES={
"default": {"BACKEND": "django.core.files.storage.FileSystemStorage"},
"staticfiles": {
"BACKEND": "django.contrib.staticfiles.storage.StaticFilesStorage"
},
},
WHITENOISE_AUTOREFRESH=True,
)
class DCADataIsolationTests(TestCase):
"""Tests to ensure users cannot access other users' DCA strategies and entries."""
def setUp(self):
"""Set up test data."""
User = get_user_model()
self.user1 = User.objects.create_user(
email="user1@test.com", password="testpass123"
)
self.client1 = APIClient()
self.client1.force_authenticate(user=self.user1)
self.user2 = User.objects.create_user(
email="user2@test.com", password="testpass123"
)
self.currency1 = Currency.objects.create(
code="BTC", name="Bitcoin", decimal_places=8, prefix=""
)
self.currency2 = Currency.objects.create(
code="USD", name="US Dollar", decimal_places=2, prefix="$ "
)
# User 1's DCA strategy and entry
self.user1_strategy = DCAStrategy.all_objects.create(
name="User1 BTC Strategy",
target_currency=self.currency1,
payment_currency=self.currency2,
owner=self.user1,
)
self.user1_entry = DCAEntry.objects.create(
strategy=self.user1_strategy,
date=date(2025, 1, 1),
amount_paid=Decimal("100.00"),
amount_received=Decimal("0.001"),
)
# User 2's DCA strategy and entry
self.user2_strategy = DCAStrategy.all_objects.create(
name="User2 BTC Strategy",
target_currency=self.currency1,
payment_currency=self.currency2,
owner=self.user2,
)
self.user2_entry = DCAEntry.objects.create(
strategy=self.user2_strategy,
date=date(2025, 1, 1),
amount_paid=Decimal("200.00"),
amount_received=Decimal("0.002"),
)
def test_user_cannot_see_other_users_dca_strategies(self):
"""GET /api/dca/strategies/ should only return user's own strategies."""
response = self.client1.get("/api/dca/strategies/")
self.assertEqual(response.status_code, status.HTTP_200_OK)
strategy_ids = [s["id"] for s in response.data["results"]]
self.assertIn(self.user1_strategy.id, strategy_ids)
self.assertNotIn(self.user2_strategy.id, strategy_ids)
def test_user_cannot_access_other_users_dca_strategy_detail(self):
"""GET /api/dca/strategies/{id}/ should deny access to other user's strategy."""
response = self.client1.get(f"/api/dca/strategies/{self.user2_strategy.id}/")
self.assertIn(response.status_code, ACCESS_DENIED_CODES)
def test_user_cannot_access_other_users_dca_entries(self):
"""GET /api/dca/entries/ filtered by other user's strategy should return empty."""
response = self.client1.get(
f"/api/dca/entries/?strategy={self.user2_strategy.id}"
)
# Either OK with empty results or error
if response.status_code == status.HTTP_200_OK:
entry_ids = [e["id"] for e in response.data["results"]]
self.assertNotIn(self.user2_entry.id, entry_ids)
def test_user_cannot_access_other_users_dca_entry_detail(self):
"""GET /api/dca/entries/{id}/ should deny access to other user's entry."""
response = self.client1.get(f"/api/dca/entries/{self.user2_entry.id}/")
self.assertIn(response.status_code, ACCESS_DENIED_CODES)
def test_user_cannot_access_other_users_strategy_investment_frequency(self):
"""investment_frequency action on other user's strategy should deny access."""
response = self.client1.get(
f"/api/dca/strategies/{self.user2_strategy.id}/investment_frequency/"
)
self.assertIn(response.status_code, ACCESS_DENIED_CODES)
def test_user_cannot_access_other_users_strategy_price_comparison(self):
"""price_comparison action on other user's strategy should deny access."""
response = self.client1.get(
f"/api/dca/strategies/{self.user2_strategy.id}/price_comparison/"
)
self.assertIn(response.status_code, ACCESS_DENIED_CODES)
def test_user_cannot_access_other_users_strategy_current_price(self):
"""current_price action on other user's strategy should deny access."""
response = self.client1.get(
f"/api/dca/strategies/{self.user2_strategy.id}/current_price/"
)
self.assertIn(response.status_code, ACCESS_DENIED_CODES)
def test_user_cannot_modify_other_users_dca_strategy(self):
"""PATCH on other user's DCA strategy should deny access."""
response = self.client1.patch(
f"/api/dca/strategies/{self.user2_strategy.id}/",
{"name": "Hacked Strategy"},
)
self.assertIn(response.status_code, ACCESS_DENIED_CODES)
def test_user_cannot_delete_other_users_dca_entry(self):
"""DELETE on other user's DCA entry should deny access."""
response = self.client1.delete(f"/api/dca/entries/{self.user2_entry.id}/")
self.assertIn(response.status_code, ACCESS_DENIED_CODES)
self.assertTrue(DCAEntry.objects.filter(id=self.user2_entry.id).exists())
@override_settings(
STORAGES={
"default": {"BACKEND": "django.core.files.storage.FileSystemStorage"},
"staticfiles": {
"BACKEND": "django.contrib.staticfiles.storage.StaticFilesStorage"
},
},
WHITENOISE_AUTOREFRESH=True,
)
class InstallmentRecurringIsolationTests(TestCase):
"""Tests for isolation of installment plans and recurring transactions."""
def setUp(self):
"""Set up test data."""
User = get_user_model()
self.user1 = User.objects.create_user(
email="user1@test.com", password="testpass123"
)
self.client1 = APIClient()
self.client1.force_authenticate(user=self.user1)
self.user2 = User.objects.create_user(
email="user2@test.com", password="testpass123"
)
self.currency = Currency.objects.create(
code="USD", name="US Dollar", decimal_places=2, prefix="$ "
)
# User 1's account
self.user1_account = Account.all_objects.create(
name="User1 Account", currency=self.currency, owner=self.user1
)
# User 2's account
self.user2_account = Account.all_objects.create(
name="User2 Account", currency=self.currency, owner=self.user2
)
# User 1's installment plan
self.user1_installment = InstallmentPlan.all_objects.create(
account=self.user1_account,
type=Transaction.Type.EXPENSE,
description="User1 Installment",
number_of_installments=12,
start_date=date(2025, 1, 1),
installment_amount=Decimal("100.00"),
)
# User 2's installment plan
self.user2_installment = InstallmentPlan.all_objects.create(
account=self.user2_account,
type=Transaction.Type.EXPENSE,
description="User2 Installment",
number_of_installments=6,
start_date=date(2025, 1, 1),
installment_amount=Decimal("200.00"),
)
# User 1's recurring transaction
self.user1_recurring = RecurringTransaction.all_objects.create(
account=self.user1_account,
type=Transaction.Type.EXPENSE,
amount=Decimal("50.00"),
description="User1 Recurring",
start_date=date(2025, 1, 1),
recurrence_type=RecurringTransaction.RecurrenceType.MONTH,
recurrence_interval=1,
)
# User 2's recurring transaction
self.user2_recurring = RecurringTransaction.all_objects.create(
account=self.user2_account,
type=Transaction.Type.INCOME,
amount=Decimal("1000.00"),
description="User2 Recurring",
start_date=date(2025, 1, 1),
recurrence_type=RecurringTransaction.RecurrenceType.MONTH,
recurrence_interval=1,
)
def test_user_cannot_see_other_users_installment_plans(self):
"""GET /api/installment-plans/ should only return user's own plans."""
response = self.client1.get("/api/installment-plans/")
self.assertEqual(response.status_code, status.HTTP_200_OK)
plan_ids = [p["id"] for p in response.data["results"]]
self.assertIn(self.user1_installment.id, plan_ids)
self.assertNotIn(self.user2_installment.id, plan_ids)
def test_user_cannot_access_other_users_installment_plan_detail(self):
"""GET /api/installment-plans/{id}/ should deny access to other user's plan."""
response = self.client1.get(
f"/api/installment-plans/{self.user2_installment.id}/"
)
self.assertIn(response.status_code, ACCESS_DENIED_CODES)
def test_user_cannot_see_other_users_recurring_transactions(self):
"""GET /api/recurring-transactions/ should only return user's own recurring."""
response = self.client1.get("/api/recurring-transactions/")
self.assertEqual(response.status_code, status.HTTP_200_OK)
recurring_ids = [r["id"] for r in response.data["results"]]
self.assertIn(self.user1_recurring.id, recurring_ids)
self.assertNotIn(self.user2_recurring.id, recurring_ids)
def test_user_cannot_access_other_users_recurring_transaction_detail(self):
"""GET /api/recurring-transactions/{id}/ should deny access to other user's recurring."""
response = self.client1.get(
f"/api/recurring-transactions/{self.user2_recurring.id}/"
)
self.assertIn(response.status_code, ACCESS_DENIED_CODES)
def test_user_cannot_modify_other_users_installment_plan(self):
"""PATCH on other user's installment plan should deny access."""
response = self.client1.patch(
f"/api/installment-plans/{self.user2_installment.id}/",
{"description": "Hacked Installment"},
)
self.assertIn(response.status_code, ACCESS_DENIED_CODES)
def test_user_cannot_delete_other_users_recurring_transaction(self):
"""DELETE on other user's recurring transaction should deny access."""
response = self.client1.delete(
f"/api/recurring-transactions/{self.user2_recurring.id}/"
)
self.assertIn(response.status_code, ACCESS_DENIED_CODES)
self.assertTrue(
RecurringTransaction.all_objects.filter(id=self.user2_recurring.id).exists()
)
+166
View File
@@ -0,0 +1,166 @@
from datetime import timedelta
from django.contrib.auth import get_user_model
from django.test import TestCase, override_settings
from django.urls import reverse
from django.utils import timezone
from oauth2_provider.models import get_access_token_model, get_application_model
from apps.users.models import APIToken
User = get_user_model()
Application = get_application_model()
AccessToken = get_access_token_model()
@override_settings(DEMO=True)
class DemoModeAPITests(TestCase):
"""The DEMO-mode gate (apps.api.permissions.NotInDemoMode) must reject
API access regardless of the authentication method used, including the
PAT and OAuth2 backends introduced for MCP integrations."""
def setUp(self):
self.user = User.objects.create_user(
email="demo@example.com",
password="test-password",
)
def test_pat_cannot_access_api_in_demo_mode(self):
_token, raw_token = APIToken.objects.create_token(
user=self.user, name="n8n"
)
response = self.client.get(
"/api/accounts/",
HTTP_AUTHORIZATION=f"Token {raw_token}",
)
self.assertEqual(response.status_code, 403)
def test_oauth_access_token_cannot_access_api_in_demo_mode(self):
app = Application.objects.create(
name="Test Client",
client_type=Application.CLIENT_CONFIDENTIAL,
authorization_grant_type=Application.GRANT_AUTHORIZATION_CODE,
redirect_uris="http://127.0.0.1:8765/callback",
client_secret="secret",
)
access_token = AccessToken.objects.create(
user=self.user,
scope="mcp",
expires=timezone.now() + timedelta(hours=1),
token="demo-oauth-access-token-xyz",
application=app,
)
response = self.client.get(
"/api/accounts/",
HTTP_AUTHORIZATION=f"Bearer {access_token.token}",
)
self.assertEqual(response.status_code, 403)
def test_superuser_pat_can_access_api_in_demo_mode(self):
admin = User.objects.create_superuser(
email="admin@example.com",
password="test-password",
)
_token, raw_token = APIToken.objects.create_token(
user=admin, name="admin"
)
response = self.client.get(
"/api/accounts/",
HTTP_AUTHORIZATION=f"Token {raw_token}",
)
# NotInDemoMode grants superusers access in DEMO mode; the request is
# authenticated by the PAT, so the API responds normally (never 403).
self.assertNotEqual(response.status_code, 403)
@override_settings(DEMO=True)
class DemoModeOAuthEndpointTests(TestCase):
"""OAuth2 issuance and discovery endpoints must be disabled in DEMO mode
so demo tenants cannot obtain (or even discover) credentials."""
def setUp(self):
self.user = User.objects.create_user(
email="demo@example.com",
password="test-password",
)
def test_oauth_authorize_rejects_non_superuser_in_demo_mode(self):
self.client.force_login(self.user)
response = self.client.get(reverse("oauth2_provider:authorize"))
self.assertEqual(response.status_code, 403)
def test_oauth_token_rejects_non_superuser_in_demo_mode(self):
self.client.force_login(self.user)
response = self.client.post(reverse("oauth2_provider:token"))
self.assertEqual(response.status_code, 403)
def test_oauth_authorization_server_metadata_rejects_in_demo_mode(self):
response = self.client.get(reverse("oauth-authorization-server-metadata"))
self.assertEqual(response.status_code, 403)
def test_oauth_dynamic_client_registration_rejects_in_demo_mode(self):
response = self.client.post(
reverse("oauth-dynamic-client-registration"),
data="{}",
content_type="application/json",
)
self.assertEqual(response.status_code, 403)
@override_settings(DEMO=True)
class DemoModeAPITokenViewsTests(TestCase):
"""The PAT management UI must be disabled in DEMO mode just like the
other mutating user views."""
def setUp(self):
self.user = User.objects.create_user(
email="demo@example.com",
password="test-password",
)
self.client.force_login(self.user)
self.htmx_headers = {"HTTP_HX_REQUEST": "true"}
def test_cannot_create_api_token_from_ui_in_demo_mode(self):
response = self.client.post(
reverse("user_api_token_add"),
{"name": "n8n", "expires_in_days": "30"},
**self.htmx_headers,
)
self.assertEqual(response.status_code, 403)
self.assertEqual(APIToken.objects.count(), 0)
def test_cannot_revoke_api_token_from_ui_in_demo_mode(self):
token, _ = APIToken.objects.create_token(user=self.user, name="n8n")
response = self.client.delete(
reverse("user_api_token_revoke", kwargs={"token_id": token.id}),
**self.htmx_headers,
)
self.assertEqual(response.status_code, 403)
token.refresh_from_db()
self.assertIsNone(token.revoked_at)
def test_cannot_delete_api_token_from_ui_in_demo_mode(self):
token, _ = APIToken.objects.create_token(user=self.user, name="n8n")
response = self.client.delete(
reverse("user_api_token_delete", kwargs={"token_id": token.id}),
**self.htmx_headers,
)
self.assertEqual(response.status_code, 403)
self.assertTrue(APIToken.objects.filter(id=token.id).exists())
+6 -6
View File
@@ -159,7 +159,7 @@ column_mapping:
self.assertIn("import_run_id", response.data)
def test_unauthenticated_request(self):
"""Test unauthenticated request returns 403"""
"""Test unauthenticated request returns 401"""
unauthenticated_client = APIClient()
csv_content = b"date,description,amount\n2025-01-01,Test,100"
@@ -173,7 +173,7 @@ column_mapping:
format="multipart",
)
self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN)
self.assertEqual(response.status_code, status.HTTP_401_UNAUTHORIZED)
@override_settings(
@@ -266,11 +266,11 @@ column_mapping:
self.assertEqual(response.status_code, status.HTTP_404_NOT_FOUND)
def test_profiles_unauthenticated(self):
"""Test unauthenticated request returns 403"""
"""Test unauthenticated request returns 401"""
unauthenticated_client = APIClient()
response = unauthenticated_client.get("/api/import/profiles/")
self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN)
self.assertEqual(response.status_code, status.HTTP_401_UNAUTHORIZED)
@override_settings(
@@ -397,8 +397,8 @@ column_mapping:
self.assertEqual(response.status_code, status.HTTP_404_NOT_FOUND)
def test_runs_unauthenticated(self):
"""Test unauthenticated request returns 403"""
"""Test unauthenticated request returns 401"""
unauthenticated_client = APIClient()
response = unauthenticated_client.get("/api/import/runs/")
self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN)
self.assertEqual(response.status_code, status.HTTP_401_UNAUTHORIZED)
+587
View File
@@ -0,0 +1,587 @@
from datetime import date
from decimal import Decimal
from django.contrib.auth import get_user_model
from django.test import TestCase, override_settings
from rest_framework import status
from rest_framework.test import APIClient
from apps.accounts.models import Account, AccountGroup
from apps.currencies.models import Currency
from apps.dca.models import DCAStrategy, DCAEntry
from apps.transactions.models import (
Transaction,
TransactionCategory,
TransactionTag,
TransactionEntity,
)
ACCESS_DENIED_CODES = [status.HTTP_403_FORBIDDEN, status.HTTP_404_NOT_FOUND]
@override_settings(
STORAGES={
"default": {"BACKEND": "django.core.files.storage.FileSystemStorage"},
"staticfiles": {
"BACKEND": "django.contrib.staticfiles.storage.StaticFilesStorage"
},
},
WHITENOISE_AUTOREFRESH=True,
)
class SharedAccountAccessTests(TestCase):
"""Tests for shared account access via shared_with field."""
def setUp(self):
"""Set up test data with shared accounts."""
User = get_user_model()
# User 1 - owner
self.user1 = User.objects.create_user(
email="user1@test.com", password="testpass123"
)
self.client1 = APIClient()
self.client1.force_authenticate(user=self.user1)
# User 2 - will have shared access
self.user2 = User.objects.create_user(
email="user2@test.com", password="testpass123"
)
self.client2 = APIClient()
self.client2.force_authenticate(user=self.user2)
# User 3 - no shared access
self.user3 = User.objects.create_user(
email="user3@test.com", password="testpass123"
)
self.client3 = APIClient()
self.client3.force_authenticate(user=self.user3)
self.currency = Currency.objects.create(
code="USD", name="US Dollar", decimal_places=2, prefix="$ "
)
# User 1's account shared with user 2
self.shared_account = Account.all_objects.create(
name="Shared Account",
currency=self.currency,
owner=self.user1,
visibility="private",
)
self.shared_account.shared_with.add(self.user2)
# User 1's private account (not shared)
self.private_account = Account.all_objects.create(
name="Private Account",
currency=self.currency,
owner=self.user1,
visibility="private",
)
# Transaction in shared account
self.shared_transaction = Transaction.userless_all_objects.create(
account=self.shared_account,
type=Transaction.Type.INCOME,
amount=Decimal("100.00"),
is_paid=True,
date=date(2025, 1, 1),
description="Shared Transaction",
owner=self.user1,
)
# Transaction in private account
self.private_transaction = Transaction.userless_all_objects.create(
account=self.private_account,
type=Transaction.Type.EXPENSE,
amount=Decimal("50.00"),
is_paid=True,
date=date(2025, 1, 1),
description="Private Transaction",
owner=self.user1,
)
def test_user_can_see_accounts_shared_with_them(self):
"""User2 should see the account shared with them."""
response = self.client2.get("/api/accounts/")
self.assertEqual(response.status_code, status.HTTP_200_OK)
account_ids = [acc["id"] for acc in response.data["results"]]
self.assertIn(self.shared_account.id, account_ids)
def test_user_cannot_see_accounts_not_shared_with_them(self):
"""User2 should NOT see user1's private (non-shared) account."""
response = self.client2.get("/api/accounts/")
self.assertEqual(response.status_code, status.HTTP_200_OK)
account_ids = [acc["id"] for acc in response.data["results"]]
self.assertNotIn(self.private_account.id, account_ids)
def test_user_can_access_shared_account_detail(self):
"""User2 should be able to access shared account details."""
response = self.client2.get(f"/api/accounts/{self.shared_account.id}/")
self.assertEqual(response.status_code, status.HTTP_200_OK)
self.assertEqual(response.data["name"], "Shared Account")
def test_user_without_share_cannot_access_shared_account(self):
"""User3 should NOT be able to access the shared account."""
response = self.client3.get(f"/api/accounts/{self.shared_account.id}/")
self.assertIn(response.status_code, ACCESS_DENIED_CODES)
def test_user_can_see_transactions_in_shared_account(self):
"""User2 should see transactions in the shared account."""
response = self.client2.get("/api/transactions/")
self.assertEqual(response.status_code, status.HTTP_200_OK)
transaction_ids = [t["id"] for t in response.data["results"]]
self.assertIn(self.shared_transaction.id, transaction_ids)
self.assertNotIn(self.private_transaction.id, transaction_ids)
def test_user_can_access_transaction_in_shared_account(self):
"""User2 should be able to access transaction details in shared account."""
response = self.client2.get(f"/api/transactions/{self.shared_transaction.id}/")
self.assertEqual(response.status_code, status.HTTP_200_OK)
self.assertEqual(response.data["description"], "Shared Transaction")
def test_user_cannot_access_transaction_in_non_shared_account(self):
"""User2 should NOT access transactions in user1's private account."""
response = self.client2.get(f"/api/transactions/{self.private_transaction.id}/")
self.assertIn(response.status_code, ACCESS_DENIED_CODES)
def test_user_can_get_balance_of_shared_account(self):
"""User2 should be able to get balance of shared account."""
response = self.client2.get(f"/api/accounts/{self.shared_account.id}/balance/")
self.assertEqual(response.status_code, status.HTTP_200_OK)
self.assertIn("current_balance", response.data)
def test_sharing_works_with_multiple_users(self):
"""Account shared with multiple users should be accessible by all."""
# Add user3 to shared_with
self.shared_account.shared_with.add(self.user3)
# User2 still has access
response2 = self.client2.get(f"/api/accounts/{self.shared_account.id}/")
self.assertEqual(response2.status_code, status.HTTP_200_OK)
# User3 now has access
response3 = self.client3.get(f"/api/accounts/{self.shared_account.id}/")
self.assertEqual(response3.status_code, status.HTTP_200_OK)
@override_settings(
STORAGES={
"default": {"BACKEND": "django.core.files.storage.FileSystemStorage"},
"staticfiles": {
"BACKEND": "django.contrib.staticfiles.storage.StaticFilesStorage"
},
},
WHITENOISE_AUTOREFRESH=True,
)
class PublicVisibilityTests(TestCase):
"""Tests for public visibility access."""
def setUp(self):
"""Set up test data with public accounts."""
User = get_user_model()
self.user1 = User.objects.create_user(
email="user1@test.com", password="testpass123"
)
self.client1 = APIClient()
self.client1.force_authenticate(user=self.user1)
self.user2 = User.objects.create_user(
email="user2@test.com", password="testpass123"
)
self.client2 = APIClient()
self.client2.force_authenticate(user=self.user2)
self.currency = Currency.objects.create(
code="USD", name="US Dollar", decimal_places=2, prefix="$ "
)
# User 1's public account
self.public_account = Account.all_objects.create(
name="Public Account",
currency=self.currency,
owner=self.user1,
visibility="public",
)
# User 1's private account
self.private_account = Account.all_objects.create(
name="Private Account",
currency=self.currency,
owner=self.user1,
visibility="private",
)
# Transaction in public account
self.public_transaction = Transaction.userless_all_objects.create(
account=self.public_account,
type=Transaction.Type.INCOME,
amount=Decimal("100.00"),
is_paid=True,
date=date(2025, 1, 1),
description="Public Transaction",
owner=self.user1,
)
def test_user_can_see_public_accounts(self):
"""User2 should see user1's public account."""
response = self.client2.get("/api/accounts/")
self.assertEqual(response.status_code, status.HTTP_200_OK)
account_ids = [acc["id"] for acc in response.data["results"]]
self.assertIn(self.public_account.id, account_ids)
self.assertNotIn(self.private_account.id, account_ids)
def test_user_can_access_public_account_detail(self):
"""User2 should be able to access public account details."""
response = self.client2.get(f"/api/accounts/{self.public_account.id}/")
self.assertEqual(response.status_code, status.HTTP_200_OK)
self.assertEqual(response.data["name"], "Public Account")
def test_user_can_see_transactions_in_public_accounts(self):
"""User2 should see transactions in public accounts."""
response = self.client2.get("/api/transactions/")
self.assertEqual(response.status_code, status.HTTP_200_OK)
transaction_ids = [t["id"] for t in response.data["results"]]
self.assertIn(self.public_transaction.id, transaction_ids)
@override_settings(
STORAGES={
"default": {"BACKEND": "django.core.files.storage.FileSystemStorage"},
"staticfiles": {
"BACKEND": "django.contrib.staticfiles.storage.StaticFilesStorage"
},
},
WHITENOISE_AUTOREFRESH=True,
)
class SharedCategoryTagEntityTests(TestCase):
"""Tests for shared categories, tags, and entities."""
def setUp(self):
"""Set up test data with shared categories/tags/entities."""
User = get_user_model()
self.user1 = User.objects.create_user(
email="user1@test.com", password="testpass123"
)
self.client1 = APIClient()
self.client1.force_authenticate(user=self.user1)
self.user2 = User.objects.create_user(
email="user2@test.com", password="testpass123"
)
self.client2 = APIClient()
self.client2.force_authenticate(user=self.user2)
self.user3 = User.objects.create_user(
email="user3@test.com", password="testpass123"
)
self.client3 = APIClient()
self.client3.force_authenticate(user=self.user3)
# User 1's category shared with user 2
self.shared_category = TransactionCategory.all_objects.create(
name="Shared Category", owner=self.user1
)
self.shared_category.shared_with.add(self.user2)
# User 1's private category
self.private_category = TransactionCategory.all_objects.create(
name="Private Category", owner=self.user1
)
# User 1's public category
self.public_category = TransactionCategory.all_objects.create(
name="Public Category", owner=self.user1, visibility="public"
)
# User 1's tag shared with user 2
self.shared_tag = TransactionTag.all_objects.create(
name="Shared Tag", owner=self.user1
)
self.shared_tag.shared_with.add(self.user2)
# User 1's entity shared with user 2
self.shared_entity = TransactionEntity.all_objects.create(
name="Shared Entity", owner=self.user1
)
self.shared_entity.shared_with.add(self.user2)
def test_user_can_see_shared_categories(self):
"""User2 should see categories shared with them."""
response = self.client2.get("/api/categories/")
self.assertEqual(response.status_code, status.HTTP_200_OK)
category_ids = [c["id"] for c in response.data["results"]]
self.assertIn(self.shared_category.id, category_ids)
self.assertNotIn(self.private_category.id, category_ids)
def test_user_can_access_shared_category_detail(self):
"""User2 should be able to access shared category details."""
response = self.client2.get(f"/api/categories/{self.shared_category.id}/")
self.assertEqual(response.status_code, status.HTTP_200_OK)
self.assertEqual(response.data["name"], "Shared Category")
def test_user_can_see_public_categories(self):
"""User3 should see public categories."""
response = self.client3.get("/api/categories/")
self.assertEqual(response.status_code, status.HTTP_200_OK)
category_ids = [c["id"] for c in response.data["results"]]
self.assertIn(self.public_category.id, category_ids)
def test_user_without_share_cannot_see_shared_category(self):
"""User3 should NOT see category shared only with user2."""
response = self.client3.get("/api/categories/")
self.assertEqual(response.status_code, status.HTTP_200_OK)
category_ids = [c["id"] for c in response.data["results"]]
self.assertNotIn(self.shared_category.id, category_ids)
def test_user_can_see_shared_tags(self):
"""User2 should see tags shared with them."""
response = self.client2.get("/api/tags/")
self.assertEqual(response.status_code, status.HTTP_200_OK)
tag_ids = [t["id"] for t in response.data["results"]]
self.assertIn(self.shared_tag.id, tag_ids)
def test_user_can_access_shared_tag_detail(self):
"""User2 should be able to access shared tag details."""
response = self.client2.get(f"/api/tags/{self.shared_tag.id}/")
self.assertEqual(response.status_code, status.HTTP_200_OK)
self.assertEqual(response.data["name"], "Shared Tag")
def test_user_can_see_shared_entities(self):
"""User2 should see entities shared with them."""
response = self.client2.get("/api/entities/")
self.assertEqual(response.status_code, status.HTTP_200_OK)
entity_ids = [e["id"] for e in response.data["results"]]
self.assertIn(self.shared_entity.id, entity_ids)
def test_user_can_access_shared_entity_detail(self):
"""User2 should be able to access shared entity details."""
response = self.client2.get(f"/api/entities/{self.shared_entity.id}/")
self.assertEqual(response.status_code, status.HTTP_200_OK)
self.assertEqual(response.data["name"], "Shared Entity")
@override_settings(
STORAGES={
"default": {"BACKEND": "django.core.files.storage.FileSystemStorage"},
"staticfiles": {
"BACKEND": "django.contrib.staticfiles.storage.StaticFilesStorage"
},
},
WHITENOISE_AUTOREFRESH=True,
)
class SharedDCAAccessTests(TestCase):
"""Tests for shared DCA strategy access."""
def setUp(self):
"""Set up test data with shared DCA strategies."""
User = get_user_model()
self.user1 = User.objects.create_user(
email="user1@test.com", password="testpass123"
)
self.client1 = APIClient()
self.client1.force_authenticate(user=self.user1)
self.user2 = User.objects.create_user(
email="user2@test.com", password="testpass123"
)
self.client2 = APIClient()
self.client2.force_authenticate(user=self.user2)
self.user3 = User.objects.create_user(
email="user3@test.com", password="testpass123"
)
self.client3 = APIClient()
self.client3.force_authenticate(user=self.user3)
self.currency1 = Currency.objects.create(
code="BTC", name="Bitcoin", decimal_places=8, prefix=""
)
self.currency2 = Currency.objects.create(
code="USD", name="US Dollar", decimal_places=2, prefix="$ "
)
# User 1's DCA strategy shared with user 2
self.shared_strategy = DCAStrategy.all_objects.create(
name="Shared BTC Strategy",
target_currency=self.currency1,
payment_currency=self.currency2,
owner=self.user1,
)
self.shared_strategy.shared_with.add(self.user2)
# Entry in shared strategy
self.shared_entry = DCAEntry.objects.create(
strategy=self.shared_strategy,
date=date(2025, 1, 1),
amount_paid=Decimal("100.00"),
amount_received=Decimal("0.001"),
)
# User 1's private strategy
self.private_strategy = DCAStrategy.all_objects.create(
name="Private BTC Strategy",
target_currency=self.currency1,
payment_currency=self.currency2,
owner=self.user1,
)
def test_user_can_see_shared_dca_strategies(self):
"""User2 should see DCA strategies shared with them."""
response = self.client2.get("/api/dca/strategies/")
self.assertEqual(response.status_code, status.HTTP_200_OK)
strategy_ids = [s["id"] for s in response.data["results"]]
self.assertIn(self.shared_strategy.id, strategy_ids)
self.assertNotIn(self.private_strategy.id, strategy_ids)
def test_user_can_access_shared_dca_strategy_detail(self):
"""User2 should be able to access shared strategy details."""
response = self.client2.get(f"/api/dca/strategies/{self.shared_strategy.id}/")
self.assertEqual(response.status_code, status.HTTP_200_OK)
self.assertEqual(response.data["name"], "Shared BTC Strategy")
def test_user_without_share_cannot_see_shared_strategy(self):
"""User3 should NOT see strategy shared only with user2."""
response = self.client3.get("/api/dca/strategies/")
self.assertEqual(response.status_code, status.HTTP_200_OK)
strategy_ids = [s["id"] for s in response.data["results"]]
self.assertNotIn(self.shared_strategy.id, strategy_ids)
def test_user_can_access_shared_strategy_actions(self):
"""User2 should be able to access actions on shared strategy."""
# investment_frequency
response1 = self.client2.get(
f"/api/dca/strategies/{self.shared_strategy.id}/investment_frequency/"
)
self.assertEqual(response1.status_code, status.HTTP_200_OK)
# price_comparison
response2 = self.client2.get(
f"/api/dca/strategies/{self.shared_strategy.id}/price_comparison/"
)
self.assertEqual(response2.status_code, status.HTTP_200_OK)
# current_price
response3 = self.client2.get(
f"/api/dca/strategies/{self.shared_strategy.id}/current_price/"
)
self.assertEqual(response3.status_code, status.HTTP_200_OK)
@override_settings(
STORAGES={
"default": {"BACKEND": "django.core.files.storage.FileSystemStorage"},
"staticfiles": {
"BACKEND": "django.contrib.staticfiles.storage.StaticFilesStorage"
},
},
WHITENOISE_AUTOREFRESH=True,
)
class SharedAccountGroupTests(TestCase):
"""Tests for shared account group access."""
def setUp(self):
"""Set up test data with shared account groups."""
User = get_user_model()
self.user1 = User.objects.create_user(
email="user1@test.com", password="testpass123"
)
self.client1 = APIClient()
self.client1.force_authenticate(user=self.user1)
self.user2 = User.objects.create_user(
email="user2@test.com", password="testpass123"
)
self.client2 = APIClient()
self.client2.force_authenticate(user=self.user2)
self.user3 = User.objects.create_user(
email="user3@test.com", password="testpass123"
)
self.client3 = APIClient()
self.client3.force_authenticate(user=self.user3)
# User 1's account group shared with user 2
self.shared_group = AccountGroup.all_objects.create(
name="Shared Group", owner=self.user1
)
self.shared_group.shared_with.add(self.user2)
# User 1's private account group
self.private_group = AccountGroup.all_objects.create(
name="Private Group", owner=self.user1
)
# User 1's public account group
self.public_group = AccountGroup.all_objects.create(
name="Public Group", owner=self.user1, visibility="public"
)
def test_user_can_see_shared_account_groups(self):
"""User2 should see account groups shared with them."""
response = self.client2.get("/api/account-groups/")
self.assertEqual(response.status_code, status.HTTP_200_OK)
group_ids = [g["id"] for g in response.data["results"]]
self.assertIn(self.shared_group.id, group_ids)
self.assertNotIn(self.private_group.id, group_ids)
def test_user_can_access_shared_account_group_detail(self):
"""User2 should be able to access shared account group details."""
response = self.client2.get(f"/api/account-groups/{self.shared_group.id}/")
self.assertEqual(response.status_code, status.HTTP_200_OK)
self.assertEqual(response.data["name"], "Shared Group")
def test_user_can_see_public_account_groups(self):
"""User3 should see public account groups."""
response = self.client3.get("/api/account-groups/")
self.assertEqual(response.status_code, status.HTTP_200_OK)
group_ids = [g["id"] for g in response.data["results"]]
self.assertIn(self.public_group.id, group_ids)
def test_user_without_share_cannot_access_shared_group(self):
"""User3 should NOT be able to access shared account group."""
response = self.client3.get(f"/api/account-groups/{self.shared_group.id}/")
self.assertIn(response.status_code, ACCESS_DENIED_CODES)
+37 -18
View File
@@ -6,8 +6,11 @@ from rest_framework.response import Response
from apps.accounts.models import AccountGroup, Account
from apps.accounts.services import get_account_balance
from apps.api.custom.pagination import CustomPageNumberPagination
from apps.api.serializers import AccountGroupSerializer, AccountSerializer, AccountBalanceSerializer
from apps.api.serializers import (
AccountGroupSerializer,
AccountSerializer,
AccountBalanceSerializer,
)
class AccountGroupViewSet(viewsets.ModelViewSet):
@@ -15,10 +18,16 @@ class AccountGroupViewSet(viewsets.ModelViewSet):
queryset = AccountGroup.objects.all()
serializer_class = AccountGroupSerializer
pagination_class = CustomPageNumberPagination
filterset_fields = {
"name": ["exact", "icontains"],
"owner": ["exact"],
}
search_fields = ["name"]
ordering_fields = "__all__"
ordering = ["id"]
def get_queryset(self):
return AccountGroup.objects.all().order_by("id")
return AccountGroup.objects.all()
@extend_schema_view(
@@ -33,28 +42,38 @@ class AccountViewSet(viewsets.ModelViewSet):
queryset = Account.objects.all()
serializer_class = AccountSerializer
pagination_class = CustomPageNumberPagination
filterset_fields = {
"name": ["exact", "icontains"],
"group": ["exact", "isnull"],
"currency": ["exact"],
"exchange_currency": ["exact", "isnull"],
"is_asset": ["exact"],
"is_archived": ["exact"],
"owner": ["exact"],
}
search_fields = ["name"]
ordering_fields = "__all__"
ordering = ["id"]
def get_queryset(self):
return (
Account.objects.all()
.order_by("id")
.select_related("group", "currency", "exchange_currency")
return Account.objects.all().select_related(
"group", "currency", "exchange_currency"
)
@action(detail=True, methods=["get"], permission_classes=[IsAuthenticated])
def balance(self, request, pk=None):
"""Get current and projected balance for an account."""
account = self.get_object()
current_balance = get_account_balance(account, paid_only=True)
projected_balance = get_account_balance(account, paid_only=False)
serializer = AccountBalanceSerializer({
"current_balance": current_balance,
"projected_balance": projected_balance,
"currency": account.currency,
})
return Response(serializer.data)
serializer = AccountBalanceSerializer(
{
"current_balance": current_balance,
"projected_balance": projected_balance,
"currency": account.currency,
}
)
return Response(serializer.data)
+20
View File
@@ -9,8 +9,28 @@ from apps.currencies.models import ExchangeRate
class CurrencyViewSet(viewsets.ModelViewSet):
queryset = Currency.objects.all()
serializer_class = CurrencySerializer
filterset_fields = {
'name': ['exact', 'icontains'],
'code': ['exact', 'icontains'],
'decimal_places': ['exact', 'gte', 'lte', 'gt', 'lt'],
'prefix': ['exact', 'icontains'],
'suffix': ['exact', 'icontains'],
'exchange_currency': ['exact'],
'is_archived': ['exact'],
}
search_fields = '__all__'
ordering_fields = '__all__'
class ExchangeRateViewSet(viewsets.ModelViewSet):
queryset = ExchangeRate.objects.all()
serializer_class = ExchangeRateSerializer
filterset_fields = {
'from_currency': ['exact'],
'to_currency': ['exact'],
'rate': ['exact', 'gte', 'lte', 'gt', 'lt'],
'date': ['exact', 'gte', 'lte', 'gt', 'lt'],
'automatic': ['exact'],
}
search_fields = '__all__'
ordering_fields = '__all__'
+30 -5
View File
@@ -8,6 +8,19 @@ from apps.api.serializers import DCAStrategySerializer, DCAEntrySerializer
class DCAStrategyViewSet(viewsets.ModelViewSet):
queryset = DCAStrategy.objects.all()
serializer_class = DCAStrategySerializer
filterset_fields = {
"name": ["exact", "icontains"],
"target_currency": ["exact"],
"payment_currency": ["exact"],
"notes": ["exact", "icontains"],
"created_at": ["exact", "gte", "lte", "gt", "lt"],
"updated_at": ["exact", "gte", "lte", "gt", "lt"],
}
search_fields = ["name", "notes"]
ordering_fields = "__all__"
def get_queryset(self):
return DCAStrategy.objects.all()
@action(detail=True, methods=["get"])
def investment_frequency(self, request, pk=None):
@@ -32,10 +45,22 @@ class DCAStrategyViewSet(viewsets.ModelViewSet):
class DCAEntryViewSet(viewsets.ModelViewSet):
queryset = DCAEntry.objects.all()
serializer_class = DCAEntrySerializer
filterset_fields = {
"strategy": ["exact"],
"date": ["exact", "gte", "lte", "gt", "lt"],
"amount_paid": ["exact", "gte", "lte", "gt", "lt"],
"amount_received": ["exact", "gte", "lte", "gt", "lt"],
"expense_transaction": ["exact", "isnull"],
"income_transaction": ["exact", "isnull"],
"notes": ["exact", "icontains"],
"created_at": ["exact", "gte", "lte", "gt", "lt"],
"updated_at": ["exact", "gte", "lte", "gt", "lt"],
}
search_fields = ["notes"]
ordering_fields = "__all__"
ordering = ["-date"]
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
# Filter entries by strategies the user has access to
accessible_strategies = DCAStrategy.objects.all()
return DCAEntry.objects.filter(strategy__in=accessible_strategies)
+24
View File
@@ -28,6 +28,14 @@ class ImportProfileViewSet(viewsets.ReadOnlyModelViewSet):
queryset = ImportProfile.objects.all()
serializer_class = ImportProfileSerializer
permission_classes = [IsAuthenticated]
filterset_fields = {
'name': ['exact', 'icontains'],
'yaml_config': ['exact', 'icontains'],
'version': ['exact'],
}
search_fields = ['name', 'yaml_config']
ordering_fields = '__all__'
ordering = ['name']
@extend_schema_view(
@@ -55,6 +63,22 @@ class ImportRunViewSet(viewsets.ReadOnlyModelViewSet):
queryset = ImportRun.objects.all().order_by("-id")
serializer_class = ImportRunSerializer
permission_classes = [IsAuthenticated]
filterset_fields = {
'status': ['exact'],
'profile': ['exact'],
'file_name': ['exact', 'icontains'],
'logs': ['exact', 'icontains'],
'processed_rows': ['exact', 'gte', 'lte', 'gt', 'lt'],
'total_rows': ['exact', 'gte', 'lte', 'gt', 'lt'],
'successful_rows': ['exact', 'gte', 'lte', 'gt', 'lt'],
'skipped_rows': ['exact', 'gte', 'lte', 'gt', 'lt'],
'failed_rows': ['exact', 'gte', 'lte', 'gt', 'lt'],
'started_at': ['exact', 'gte', 'lte', 'gt', 'lt', 'isnull'],
'finished_at': ['exact', 'gte', 'lte', 'gt', 'lt', 'isnull'],
}
search_fields = ['file_name', 'logs']
ordering_fields = '__all__'
ordering = ['-id']
def get_queryset(self):
queryset = super().get_queryset()
+100 -15
View File
@@ -2,7 +2,6 @@ from copy import deepcopy
from rest_framework import viewsets
from apps.api.custom.pagination import CustomPageNumberPagination
from apps.api.serializers import (
TransactionSerializer,
TransactionCategorySerializer,
@@ -25,7 +24,34 @@ from apps.rules.signals import transaction_updated, transaction_created
class TransactionViewSet(viewsets.ModelViewSet):
queryset = Transaction.objects.all()
serializer_class = TransactionSerializer
pagination_class = CustomPageNumberPagination
filterset_fields = {
"account": ["exact"],
"type": ["exact"],
"is_paid": ["exact"],
"date": ["exact", "gte", "lte", "gt", "lt"],
"reference_date": ["exact", "gte", "lte", "gt", "lt"],
"mute": ["exact"],
"amount": ["exact", "gte", "lte", "gt", "lt"],
"description": ["exact", "icontains"],
"notes": ["exact", "icontains"],
"category": ["exact", "isnull"],
"installment_plan": ["exact", "isnull"],
"installment_id": ["exact", "gte", "lte"],
"recurring_transaction": ["exact", "isnull"],
"internal_note": ["exact", "icontains"],
"internal_id": ["exact"],
"deleted": ["exact"],
"created_at": ["exact", "gte", "lte", "gt", "lt"],
"updated_at": ["exact", "gte", "lte", "gt", "lt"],
"deleted_at": ["exact", "gte", "lte", "gt", "lt", "isnull"],
"owner": ["exact"],
}
search_fields = ["description", "notes", "internal_note"]
ordering_fields = "__all__"
ordering = ["-id"]
def get_queryset(self):
return Transaction.objects.all()
def perform_create(self, serializer):
instance = serializer.save()
@@ -40,50 +66,109 @@ class TransactionViewSet(viewsets.ModelViewSet):
kwargs["partial"] = True
return self.update(request, *args, **kwargs)
def get_queryset(self):
return Transaction.objects.all().order_by("-id")
class TransactionCategoryViewSet(viewsets.ModelViewSet):
queryset = TransactionCategory.objects.all()
serializer_class = TransactionCategorySerializer
pagination_class = CustomPageNumberPagination
filterset_fields = {
"name": ["exact", "icontains"],
"mute": ["exact"],
"active": ["exact"],
"owner": ["exact"],
}
search_fields = ["name"]
ordering_fields = "__all__"
ordering = ["id"]
def get_queryset(self):
return TransactionCategory.objects.all().order_by("id")
return TransactionCategory.objects.all()
class TransactionTagViewSet(viewsets.ModelViewSet):
queryset = TransactionTag.objects.all()
serializer_class = TransactionTagSerializer
pagination_class = CustomPageNumberPagination
filterset_fields = {
"name": ["exact", "icontains"],
"active": ["exact"],
"owner": ["exact"],
}
search_fields = ["name"]
ordering_fields = "__all__"
ordering = ["id"]
def get_queryset(self):
return TransactionTag.objects.all().order_by("id")
return TransactionTag.objects.all()
class TransactionEntityViewSet(viewsets.ModelViewSet):
queryset = TransactionEntity.objects.all()
serializer_class = TransactionEntitySerializer
pagination_class = CustomPageNumberPagination
filterset_fields = {
"name": ["exact", "icontains"],
"active": ["exact"],
"owner": ["exact"],
}
search_fields = ["name"]
ordering_fields = "__all__"
ordering = ["id"]
def get_queryset(self):
return TransactionEntity.objects.all().order_by("id")
return TransactionEntity.objects.all()
class InstallmentPlanViewSet(viewsets.ModelViewSet):
queryset = InstallmentPlan.objects.all()
serializer_class = InstallmentPlanSerializer
pagination_class = CustomPageNumberPagination
filterset_fields = {
"account": ["exact"],
"type": ["exact"],
"description": ["exact", "icontains"],
"number_of_installments": ["exact", "gte", "lte", "gt", "lt"],
"installment_start": ["exact", "gte", "lte", "gt", "lt"],
"installment_total_number": ["exact", "gte", "lte", "gt", "lt"],
"start_date": ["exact", "gte", "lte", "gt", "lt"],
"reference_date": ["exact", "gte", "lte", "gt", "lt", "isnull"],
"end_date": ["exact", "gte", "lte", "gt", "lt", "isnull"],
"recurrence": ["exact"],
"installment_amount": ["exact", "gte", "lte", "gt", "lt"],
"category": ["exact", "isnull"],
"notes": ["exact", "icontains"],
"add_description_to_transaction": ["exact"],
"add_notes_to_transaction": ["exact"],
}
search_fields = ["description", "notes"]
ordering_fields = "__all__"
ordering = ["-id"]
def get_queryset(self):
return InstallmentPlan.objects.all().order_by("-id")
return InstallmentPlan.objects.all()
class RecurringTransactionViewSet(viewsets.ModelViewSet):
queryset = RecurringTransaction.objects.all()
serializer_class = RecurringTransactionSerializer
pagination_class = CustomPageNumberPagination
filterset_fields = {
"is_paused": ["exact"],
"account": ["exact"],
"type": ["exact"],
"amount": ["exact", "gte", "lte", "gt", "lt"],
"description": ["exact", "icontains"],
"category": ["exact", "isnull"],
"notes": ["exact", "icontains"],
"reference_date": ["exact", "gte", "lte", "gt", "lt", "isnull"],
"start_date": ["exact", "gte", "lte", "gt", "lt"],
"end_date": ["exact", "gte", "lte", "gt", "lt", "isnull"],
"recurrence_type": ["exact"],
"recurrence_interval": ["exact", "gte", "lte", "gt", "lt"],
"keep_at_most": ["exact", "gte", "lte", "gt", "lt"],
"last_generated_date": ["exact", "gte", "lte", "gt", "lt", "isnull"],
"last_generated_reference_date": ["exact", "gte", "lte", "gt", "lt", "isnull"],
"add_description_to_transaction": ["exact"],
"add_notes_to_transaction": ["exact"],
}
search_fields = ["description", "notes"]
ordering_fields = "__all__"
ordering = ["-id"]
def get_queryset(self):
return RecurringTransaction.objects.all().order_by("-id")
return RecurringTransaction.objects.all()
+3
View File
@@ -23,3 +23,6 @@ class CommonConfig(AppConfig):
# Delete the cache for update checks to prevent false-positives when the app is restarted
# this will be recreated by the check_for_updates task
cache.delete("update_check")
# Register system checks for required environment variables
from apps.common import checks # noqa: F401
+103
View File
@@ -0,0 +1,103 @@
"""
Django System Checks for required environment variables.
This module validates that required environment variables (those without defaults)
are present before the application starts.
"""
import os
from django.core.checks import Error, register
# List of environment variables that are required (no default values)
# Based on the README.md documentation
REQUIRED_ENV_VARS = [
("SECRET_KEY", "This is used to provide cryptographic signing."),
("SQL_DATABASE", "The name of your postgres database."),
]
# List of environment variables that must be valid integers if set
INT_ENV_VARS = [
("TASK_WORKERS", "How many workers to have for async tasks."),
("SESSION_EXPIRY_TIME", "The age of session cookies, in seconds."),
("INTERNAL_PORT", "The port on which the app listens on."),
("DJANGO_VITE_DEV_SERVER_PORT", "The port where Vite's dev server is running"),
]
@register()
def check_required_env_vars(app_configs, **kwargs):
"""
Check that all required environment variables are set.
Returns a list of Error objects for any missing required variables.
"""
errors = []
for var_name, description in REQUIRED_ENV_VARS:
value = os.getenv(var_name)
if not value:
errors.append(
Error(
f"Required environment variable '{var_name}' is not set.",
hint=f"{description} Please set this variable in your .env file or environment.",
id="wygiwyh.E001",
)
)
return errors
@register()
def check_int_env_vars(app_configs, **kwargs):
"""
Check that environment variables that should be integers are valid.
Returns a list of Error objects for any invalid integer variables.
"""
errors = []
for var_name, description in INT_ENV_VARS:
value = os.getenv(var_name)
if value is not None:
try:
int(value)
except ValueError:
errors.append(
Error(
f"Environment variable '{var_name}' must be a valid integer, got '{value}'.",
hint=f"{description}",
id="wygiwyh.E002",
)
)
return errors
@register()
def check_soft_delete_config(app_configs, **kwargs):
"""
Check that KEEP_DELETED_TRANSACTIONS_FOR is a valid integer when ENABLE_SOFT_DELETE is enabled.
Returns a list of Error objects if the configuration is invalid.
"""
errors = []
enable_soft_delete = os.getenv("ENABLE_SOFT_DELETE", "false").lower() == "true"
if enable_soft_delete:
keep_deleted_for = os.getenv("KEEP_DELETED_TRANSACTIONS_FOR")
if keep_deleted_for is not None:
try:
int(keep_deleted_for)
except ValueError:
errors.append(
Error(
f"Environment variable 'KEEP_DELETED_TRANSACTIONS_FOR' must be a valid integer when ENABLE_SOFT_DELETE is enabled, got '{keep_deleted_for}'.",
hint="Time in days to keep soft deleted transactions for. Set to 0 to keep all transactions indefinitely.",
id="wygiwyh.E003",
)
)
return errors
@@ -0,0 +1,58 @@
from datetime import timedelta
from django.contrib.auth import get_user_model
from django.core.management.base import BaseCommand, CommandError
from django.utils import timezone
from apps.users.models import APIToken
class Command(BaseCommand):
help = "Creates a hashed API token for a WYGIWYH user and prints the raw token once."
def add_arguments(self, parser):
parser.add_argument("email", help="WYGIWYH user email that will own this token.")
parser.add_argument(
"--name",
default="n8n",
help="Human-readable token name. Defaults to 'n8n'.",
)
parser.add_argument(
"--expires-in-days",
type=int,
default=None,
help="Optional token lifetime in whole days.",
)
def handle(self, *args, **options):
email = options["email"].strip()
name = options["name"].strip()
expires_in_days = options["expires_in_days"]
if not email:
raise CommandError("Email is required.")
if not name:
raise CommandError("Token name cannot be empty.")
if expires_in_days is not None and expires_in_days <= 0:
raise CommandError("--expires-in-days must be greater than zero.")
user = get_user_model().objects.filter(email__iexact=email).first()
if user is None:
raise CommandError(f"No WYGIWYH user exists for '{email}'.")
expires_at = None
if expires_in_days is not None:
expires_at = timezone.now() + timedelta(days=expires_in_days)
token, raw_token = APIToken.objects.create_token(
user=user,
name=name,
expires_at=expires_at,
)
self.stdout.write(
self.style.SUCCESS(
f"Created API token '{token.name}' for {user.email} ({token.token_key})."
)
)
self.stdout.write(raw_token)
@@ -0,0 +1,129 @@
import os
from django.contrib.auth.hashers import check_password
from django.core.exceptions import ValidationError
from django.core.management.base import BaseCommand, CommandError
from oauth2_provider.models import get_application_model
Application = get_application_model()
def _get_env(name: str) -> str:
return os.getenv(name, "").strip()
def _get_bool_env(name: str, default: bool = False) -> bool:
raw = _get_env(name)
if not raw:
return default
return raw.lower() in {"1", "true", "yes", "on"}
class Command(BaseCommand):
help = (
"Creates or updates the OAuth application used by MCP clients when "
"MCP_OAUTH_CLIENT_* environment variables are configured."
)
def handle(self, *args, **options):
client_id = _get_env("MCP_OAUTH_CLIENT_ID")
client_secret = _get_env("MCP_OAUTH_CLIENT_SECRET")
redirect_uris = " ".join(_get_env("MCP_OAUTH_REDIRECT_URIS").split())
name = _get_env("MCP_OAUTH_CLIENT_NAME") or "WYGIWYH MCP"
skip_authorization = _get_bool_env("MCP_OAUTH_SKIP_AUTHORIZATION", default=False)
if not any([client_id, client_secret, redirect_uris]):
self.stdout.write(
self.style.NOTICE(
"MCP OAuth client env vars are not set. Skipping OAuth application setup."
)
)
return
missing = []
if not client_id:
missing.append("MCP_OAUTH_CLIENT_ID")
if not client_secret:
missing.append("MCP_OAUTH_CLIENT_SECRET")
if not redirect_uris:
missing.append("MCP_OAUTH_REDIRECT_URIS")
if missing:
raise CommandError(
"Missing required MCP OAuth settings: " + ", ".join(missing)
)
application, created = Application.objects.get_or_create(
client_id=client_id,
defaults={
"name": name,
"client_type": Application.CLIENT_CONFIDENTIAL,
"authorization_grant_type": Application.GRANT_AUTHORIZATION_CODE,
"redirect_uris": redirect_uris,
"skip_authorization": skip_authorization,
"client_secret": client_secret,
"hash_client_secret": True,
},
)
updated_fields = []
if application.name != name:
application.name = name
updated_fields.append("name")
if application.client_type != Application.CLIENT_CONFIDENTIAL:
application.client_type = Application.CLIENT_CONFIDENTIAL
updated_fields.append("client_type")
if (
application.authorization_grant_type
!= Application.GRANT_AUTHORIZATION_CODE
):
application.authorization_grant_type = Application.GRANT_AUTHORIZATION_CODE
updated_fields.append("authorization_grant_type")
if application.redirect_uris != redirect_uris:
application.redirect_uris = redirect_uris
updated_fields.append("redirect_uris")
if application.skip_authorization != skip_authorization:
application.skip_authorization = skip_authorization
updated_fields.append("skip_authorization")
if application.hash_client_secret is not True:
application.hash_client_secret = True
updated_fields.append("hash_client_secret")
if not application.client_secret or not check_password(
client_secret,
application.client_secret,
):
application.client_secret = client_secret
updated_fields.append("client_secret")
try:
application.full_clean()
except ValidationError as exc:
errors = "; ".join(
f"{field}: {', '.join(messages)}"
for field, messages in exc.message_dict.items()
)
raise CommandError(f"Invalid MCP OAuth application settings: {errors}") from exc
if created:
application.save()
self.stdout.write(
self.style.SUCCESS(
f"Created MCP OAuth application '{application.client_id}'."
)
)
return
if updated_fields:
application.save(update_fields=updated_fields)
self.stdout.write(
self.style.SUCCESS(
f"Updated MCP OAuth application '{application.client_id}'."
)
)
return
self.stdout.write(
self.style.SUCCESS(
f"MCP OAuth application '{application.client_id}' is already up to date."
)
)
+253
View File
@@ -0,0 +1,253 @@
import hmac
import json
import time
from secrets import token_urlsafe
from django.conf import settings
from django.core.exceptions import ValidationError
from django.http import JsonResponse
from django.views.decorators.csrf import csrf_exempt
from django.views.decorators.http import require_http_methods
from oauth2_provider.models import get_application_model
Application = get_application_model()
SUPPORTED_TOKEN_ENDPOINT_AUTH_METHODS = {
"none": Application.CLIENT_PUBLIC,
"client_secret_basic": Application.CLIENT_CONFIDENTIAL,
"client_secret_post": Application.CLIENT_CONFIDENTIAL,
}
SUPPORTED_GRANT_TYPES = {"authorization_code", "refresh_token"}
SUPPORTED_RESPONSE_TYPES = {"code"}
def _base_url(request):
return settings.PUBLIC_BASE_URL or request.build_absolute_uri("/").rstrip("/")
def _json_error(error, error_description, status=400):
response = JsonResponse(
{"error": error, "error_description": error_description},
status=status,
)
response["Cache-Control"] = "no-store"
response["Pragma"] = "no-cache"
return response
def _set_no_store_headers(response):
response["Cache-Control"] = "no-store"
response["Pragma"] = "no-cache"
return response
def _parse_json_request_body(request):
try:
payload = json.loads(request.body.decode("utf-8"))
except (UnicodeDecodeError, json.JSONDecodeError) as exc:
raise ValueError("Request body must be valid JSON.") from exc
if not isinstance(payload, dict):
raise ValueError("Request body must be a JSON object.")
return payload
def _get_string_list(payload, field_name, *, required=False, default=None):
value = payload.get(field_name, default)
if value is None:
if required:
raise ValueError(f"'{field_name}' is required.")
return None
if not isinstance(value, list) or not value:
raise ValueError(f"'{field_name}' must be a non-empty array of strings.")
normalized = []
for item in value:
if not isinstance(item, str) or not item.strip():
raise ValueError(f"'{field_name}' must contain only non-empty strings.")
normalized.append(item.strip())
return normalized
def _get_supported_scopes():
return set(settings.OAUTH2_PROVIDER.get("SCOPES", {}).keys())
def _dcr_initial_access_token_ok(request):
"""Validate the optional RFC 7591 initial access token, if one is configured."""
expected = settings.OAUTH2_DCR_INITIAL_ACCESS_TOKEN
if not expected:
return True
header = request.META.get("HTTP_AUTHORIZATION", "")
scheme, _, value = header.partition(" ")
if scheme.lower() != "bearer" or not value:
return False
return hmac.compare_digest(value, expected)
@require_http_methods(["GET"])
def authorization_server_metadata(request):
base_url = _base_url(request)
metadata = {
"issuer": base_url,
"authorization_endpoint": f"{base_url}/oauth/authorize/",
"token_endpoint": f"{base_url}/oauth/token/",
"revocation_endpoint": f"{base_url}/oauth/revoke_token/",
"introspection_endpoint": f"{base_url}/oauth/introspect/",
"scopes_supported": sorted(settings.OAUTH2_PROVIDER["SCOPES"].keys()),
"response_types_supported": ["code"],
"grant_types_supported": ["authorization_code", "refresh_token"],
"token_endpoint_auth_methods_supported": [
"none",
"client_secret_basic",
"client_secret_post",
],
"code_challenge_methods_supported": ["S256"],
}
# Only advertise registration when DCR is actually enabled.
if settings.OAUTH2_DCR_ENABLED:
metadata["registration_endpoint"] = f"{base_url}/oauth/register/"
return JsonResponse(metadata)
@csrf_exempt
@require_http_methods(["POST"])
def dynamic_client_registration(request):
if not settings.OAUTH2_DCR_ENABLED:
return _json_error(
"not_found",
"Dynamic client registration is disabled.",
status=404,
)
if not _dcr_initial_access_token_ok(request):
return _json_error(
"invalid_token",
"A valid initial access token is required to register a client.",
status=401,
)
try:
payload = _parse_json_request_body(request)
redirect_uris = _get_string_list(payload, "redirect_uris", required=True)
grant_types = _get_string_list(
payload,
"grant_types",
default=["authorization_code"],
)
response_types = _get_string_list(
payload,
"response_types",
default=["code"],
)
except ValueError as exc:
return _json_error("invalid_client_metadata", str(exc))
unsupported_grant_types = sorted(set(grant_types) - SUPPORTED_GRANT_TYPES)
if unsupported_grant_types:
return _json_error(
"invalid_client_metadata",
"Unsupported grant_types: " + ", ".join(unsupported_grant_types),
)
if "authorization_code" not in grant_types:
return _json_error(
"invalid_client_metadata",
"grant_types must include 'authorization_code'.",
)
unsupported_response_types = sorted(set(response_types) - SUPPORTED_RESPONSE_TYPES)
if unsupported_response_types:
return _json_error(
"invalid_client_metadata",
"Unsupported response_types: "
+ ", ".join(unsupported_response_types),
)
if "code" not in response_types:
return _json_error(
"invalid_client_metadata",
"response_types must include 'code'.",
)
token_endpoint_auth_method = payload.get(
"token_endpoint_auth_method",
"client_secret_basic",
)
if token_endpoint_auth_method not in SUPPORTED_TOKEN_ENDPOINT_AUTH_METHODS:
return _json_error(
"invalid_client_metadata",
"Unsupported token_endpoint_auth_method: "
+ token_endpoint_auth_method,
)
supported_scopes = _get_supported_scopes()
raw_scope = payload.get("scope", "mcp")
if not isinstance(raw_scope, str):
return _json_error(
"invalid_client_metadata",
"'scope' must be a space-delimited string.",
)
requested_scope = raw_scope.strip() or "mcp"
requested_scopes = set(requested_scope.split())
unsupported_scopes = sorted(requested_scopes - supported_scopes)
if unsupported_scopes:
return _json_error(
"invalid_client_metadata",
"Unsupported scope values: " + ", ".join(unsupported_scopes),
)
client_name = str(payload.get("client_name", "Dynamic MCP Client")).strip()
if not client_name:
client_name = "Dynamic MCP Client"
client_secret = None
client_type = SUPPORTED_TOKEN_ENDPOINT_AUTH_METHODS[token_endpoint_auth_method]
if client_type == Application.CLIENT_CONFIDENTIAL:
client_secret = token_urlsafe(48)
application = Application(
name=client_name,
client_type=client_type,
authorization_grant_type=Application.GRANT_AUTHORIZATION_CODE,
redirect_uris=" ".join(redirect_uris),
skip_authorization=False,
hash_client_secret=True,
client_secret=client_secret or "",
)
try:
application.full_clean()
except ValidationError as exc:
errors = []
for field, messages in exc.message_dict.items():
errors.extend(f"{field}: {message}" for message in messages)
return _json_error(
"invalid_client_metadata",
"; ".join(errors),
)
application.save()
response_payload = {
"client_id": application.client_id,
"client_id_issued_at": int(time.time()),
"client_name": client_name,
"redirect_uris": redirect_uris,
# Report what was actually provisioned, not the raw request echo. The app
# is created with the authorization_code grant; refresh_token is implicit
# to that grant in django-oauth-toolkit rather than a separate capability.
"grant_types": sorted(set(grant_types) & SUPPORTED_GRANT_TYPES),
"response_types": sorted(set(response_types) & SUPPORTED_RESPONSE_TYPES),
"scope": " ".join(sorted(requested_scopes)),
"token_endpoint_auth_method": token_endpoint_auth_method,
}
if client_secret is not None:
response_payload["client_secret"] = client_secret
response_payload["client_secret_expires_at"] = 0
return _set_no_store_headers(JsonResponse(response_payload, status=201))
+42 -1
View File
@@ -1,6 +1,47 @@
import functools
import inspect
import procrastinate
from django.db import close_old_connections
_CONNECTION_CLEANUP_WRAPPED = "_wygiwyh_connection_cleanup_wrapped"
def _wrap_task_with_django_connection_cleanup(task):
if getattr(task.func, _CONNECTION_CLEANUP_WRAPPED, False):
return
func = task.func
if inspect.iscoroutinefunction(func):
@functools.wraps(func)
async def async_wrapped(*args, **kwargs):
close_old_connections()
try:
return await func(*args, **kwargs)
finally:
close_old_connections()
wrapped = async_wrapped
else:
@functools.wraps(func)
def sync_wrapped(*args, **kwargs):
close_old_connections()
try:
return func(*args, **kwargs)
finally:
close_old_connections()
wrapped = sync_wrapped
setattr(wrapped, _CONNECTION_CLEANUP_WRAPPED, True)
task.func = wrapped
def on_app_ready(app: procrastinate.App):
"""This function is ran upon procrastinate initialization."""
...
for task in set(app.tasks.values()):
_wrap_task_with_django_connection_cleanup(task)
+1
View File
@@ -0,0 +1 @@
+298
View File
@@ -0,0 +1,298 @@
import os
import json
from io import StringIO
from unittest.mock import patch
from django.contrib.auth import get_user_model
from django.contrib.auth.hashers import check_password
from django.core.management import call_command
from django.test import SimpleTestCase, TestCase, override_settings
from django.utils import timezone
from django.urls import reverse
from oauth2_provider.models import get_application_model
from apps.users.models import APIToken
Application = get_application_model()
@override_settings(
PUBLIC_BASE_URL="https://wygiwyh.example.com",
SECRET_KEY="test-secret-key",
OAUTH2_PROVIDER={"SCOPES": {"mcp": "Access WYGIWYH from MCP clients."}},
)
class AuthorizationServerMetadataTests(SimpleTestCase):
@override_settings(OAUTH2_DCR_ENABLED=True)
def test_returns_oauth_authorization_server_metadata(self):
response = self.client.get(reverse("oauth-authorization-server-metadata"))
self.assertEqual(response.status_code, 200)
self.assertEqual(response.json()["issuer"], "https://wygiwyh.example.com")
self.assertEqual(
response.json()["authorization_endpoint"],
"https://wygiwyh.example.com/oauth/authorize/",
)
self.assertEqual(
response.json()["registration_endpoint"],
"https://wygiwyh.example.com/oauth/register/",
)
self.assertEqual(response.json()["scopes_supported"], ["mcp"])
self.assertIn("none", response.json()["token_endpoint_auth_methods_supported"])
@override_settings(OAUTH2_DCR_ENABLED=False)
def test_omits_registration_endpoint_when_dcr_disabled(self):
response = self.client.get(reverse("oauth-authorization-server-metadata"))
self.assertEqual(response.status_code, 200)
self.assertNotIn("registration_endpoint", response.json())
@override_settings(
PUBLIC_BASE_URL="https://wygiwyh.example.com",
SECRET_KEY="test-secret-key",
OAUTH2_PROVIDER={"SCOPES": {"mcp": "Access WYGIWYH from MCP clients."}},
OAUTH2_DCR_ENABLED=True,
OAUTH2_DCR_INITIAL_ACCESS_TOKEN="",
)
class DynamicClientRegistrationTests(TestCase):
def test_registers_public_client_for_pkce_flow(self):
response = self.client.post(
reverse("oauth-dynamic-client-registration"),
data=json.dumps(
{
"client_name": "Copilot MCP",
"redirect_uris": ["http://127.0.0.1:8765/callback"],
"grant_types": ["authorization_code", "refresh_token"],
"response_types": ["code"],
"scope": "mcp",
"token_endpoint_auth_method": "none",
}
),
content_type="application/json",
)
self.assertEqual(response.status_code, 201)
payload = response.json()
self.assertEqual(payload["client_name"], "Copilot MCP")
self.assertEqual(
payload["redirect_uris"],
["http://127.0.0.1:8765/callback"],
)
self.assertEqual(
payload["grant_types"],
["authorization_code", "refresh_token"],
)
self.assertEqual(payload["response_types"], ["code"])
self.assertEqual(payload["scope"], "mcp")
self.assertEqual(payload["token_endpoint_auth_method"], "none")
self.assertNotIn("client_secret", payload)
application = Application.objects.get(client_id=payload["client_id"])
self.assertEqual(application.name, "Copilot MCP")
self.assertEqual(application.client_type, Application.CLIENT_PUBLIC)
self.assertEqual(
application.authorization_grant_type,
Application.GRANT_AUTHORIZATION_CODE,
)
self.assertEqual(
application.redirect_uris,
"http://127.0.0.1:8765/callback",
)
def test_registers_confidential_client_with_generated_secret(self):
response = self.client.post(
reverse("oauth-dynamic-client-registration"),
data=json.dumps(
{
"client_name": "Confidential MCP",
"redirect_uris": ["http://127.0.0.1:8765/callback"],
"token_endpoint_auth_method": "client_secret_basic",
}
),
content_type="application/json",
)
self.assertEqual(response.status_code, 201)
payload = response.json()
self.assertEqual(payload["token_endpoint_auth_method"], "client_secret_basic")
self.assertEqual(payload["scope"], "mcp")
self.assertEqual(payload["client_secret_expires_at"], 0)
self.assertTrue(payload["client_secret"])
application = Application.objects.get(client_id=payload["client_id"])
self.assertEqual(application.client_type, Application.CLIENT_CONFIDENTIAL)
self.assertTrue(check_password(payload["client_secret"], application.client_secret))
def test_rejects_unsupported_token_auth_method(self):
response = self.client.post(
reverse("oauth-dynamic-client-registration"),
data=json.dumps(
{
"redirect_uris": ["http://127.0.0.1:8765/callback"],
"token_endpoint_auth_method": "private_key_jwt",
}
),
content_type="application/json",
)
self.assertEqual(response.status_code, 400)
self.assertEqual(response.json()["error"], "invalid_client_metadata")
self.assertIn("token_endpoint_auth_method", response.json()["error_description"])
def test_rejects_missing_redirect_uris(self):
response = self.client.post(
reverse("oauth-dynamic-client-registration"),
data=json.dumps({"client_name": "No redirect"}),
content_type="application/json",
)
self.assertEqual(response.status_code, 400)
self.assertEqual(response.json()["error"], "invalid_client_metadata")
self.assertIn("redirect_uris", response.json()["error_description"])
@override_settings(OAUTH2_DCR_ENABLED=False)
def test_returns_404_when_dcr_disabled(self):
response = self.client.post(
reverse("oauth-dynamic-client-registration"),
data=json.dumps({"redirect_uris": ["http://127.0.0.1:8765/callback"]}),
content_type="application/json",
)
self.assertEqual(response.status_code, 404)
self.assertEqual(Application.objects.count(), 0)
@override_settings(
PUBLIC_BASE_URL="https://wygiwyh.example.com",
SECRET_KEY="test-secret-key",
OAUTH2_PROVIDER={"SCOPES": {"mcp": "Access WYGIWYH from MCP clients."}},
OAUTH2_DCR_ENABLED=True,
OAUTH2_DCR_INITIAL_ACCESS_TOKEN="s3cret-iat",
)
class DynamicClientRegistrationInitialAccessTokenTests(TestCase):
def test_rejects_registration_without_initial_access_token(self):
response = self.client.post(
reverse("oauth-dynamic-client-registration"),
data=json.dumps({"redirect_uris": ["http://127.0.0.1:8765/callback"]}),
content_type="application/json",
)
self.assertEqual(response.status_code, 401)
self.assertEqual(response.json()["error"], "invalid_token")
self.assertEqual(Application.objects.count(), 0)
def test_allows_registration_with_initial_access_token(self):
response = self.client.post(
reverse("oauth-dynamic-client-registration"),
data=json.dumps(
{
"redirect_uris": ["http://127.0.0.1:8765/callback"],
"token_endpoint_auth_method": "none",
}
),
content_type="application/json",
HTTP_AUTHORIZATION="Bearer s3cret-iat",
)
self.assertEqual(response.status_code, 201)
self.assertEqual(Application.objects.count(), 1)
class SetupOAuthCommandTests(TestCase):
@patch.dict(
os.environ,
{
"MCP_OAUTH_CLIENT_ID": "mcp-wygiwyh",
"MCP_OAUTH_CLIENT_SECRET": "super-secret",
"MCP_OAUTH_REDIRECT_URIS": "http://127.0.0.1:8765/callback",
},
clear=False,
)
def test_creates_mcp_oauth_application(self):
call_command("setup_oauth")
application = Application.objects.get(client_id="mcp-wygiwyh")
self.assertEqual(application.name, "WYGIWYH MCP")
self.assertEqual(application.client_type, Application.CLIENT_CONFIDENTIAL)
self.assertEqual(
application.authorization_grant_type,
Application.GRANT_AUTHORIZATION_CODE,
)
self.assertEqual(
application.redirect_uris,
"http://127.0.0.1:8765/callback",
)
self.assertFalse(application.skip_authorization)
self.assertTrue(check_password("super-secret", application.client_secret))
@patch.dict(
os.environ,
{
"MCP_OAUTH_CLIENT_ID": "mcp-wygiwyh",
"MCP_OAUTH_CLIENT_SECRET": "new-secret",
"MCP_OAUTH_REDIRECT_URIS": "http://127.0.0.1:8765/callback http://localhost:8765/callback",
"MCP_OAUTH_CLIENT_NAME": "WYGIWYH MCP Production",
"MCP_OAUTH_SKIP_AUTHORIZATION": "true",
},
clear=False,
)
def test_updates_existing_mcp_oauth_application(self):
Application.objects.create(
client_id="mcp-wygiwyh",
client_secret="old-secret",
name="Old Name",
client_type=Application.CLIENT_CONFIDENTIAL,
authorization_grant_type=Application.GRANT_AUTHORIZATION_CODE,
redirect_uris="http://127.0.0.1:8765/callback",
skip_authorization=False,
)
call_command("setup_oauth")
application = Application.objects.get(client_id="mcp-wygiwyh")
self.assertEqual(application.name, "WYGIWYH MCP Production")
self.assertEqual(
application.redirect_uris,
"http://127.0.0.1:8765/callback http://localhost:8765/callback",
)
self.assertTrue(application.skip_authorization)
self.assertTrue(check_password("new-secret", application.client_secret))
class CreateAPITokenCommandTests(TestCase):
def setUp(self):
self.user = get_user_model().objects.create_user(
email="n8n@example.com",
password="test-password",
)
def test_creates_hashed_api_token_and_prints_raw_value(self):
stdout = StringIO()
call_command(
"create_api_token",
self.user.email,
"--name",
"n8n sync",
stdout=stdout,
)
token = APIToken.objects.get(user=self.user, name="n8n sync")
lines = [line.strip() for line in stdout.getvalue().splitlines() if line.strip()]
raw_token = lines[-1]
self.assertTrue(raw_token.startswith(APIToken.TOKEN_PREFIX))
self.assertNotEqual(token.token_hash, raw_token)
self.assertTrue(token.check_secret(APIToken.parse_raw_token(raw_token)[1]))
def test_supports_expiring_tokens(self):
call_command(
"create_api_token",
self.user.email,
"--expires-in-days",
"7",
)
token = APIToken.objects.get(user=self.user)
self.assertIsNotNone(token.expires_at)
self.assertGreater(token.expires_at, timezone.now())
@@ -0,0 +1,89 @@
from unittest.mock import patch
import procrastinate
from django.db import connection
from django.test import SimpleTestCase, TransactionTestCase
from procrastinate.testing import InMemoryConnector
from apps.common.procrastinate import on_app_ready
def make_app_with_task(func):
app = procrastinate.App(connector=InMemoryConnector())
task = app.task(name="sample_task")(func)
return app, task
class ProcrastinateConnectionCleanupTests(SimpleTestCase):
def test_app_ready_closes_old_connections_around_sync_tasks(self):
calls = []
def sample_task(value):
calls.append(("task", value))
return value * 2
app, task = make_app_with_task(sample_task)
with patch(
"apps.common.procrastinate.close_old_connections",
create=True,
side_effect=lambda: calls.append(("cleanup", None)),
):
on_app_ready(app)
result = task.func(3)
self.assertEqual(result, 6)
self.assertEqual(
calls,
[
("cleanup", None),
("task", 3),
("cleanup", None),
],
)
def test_app_ready_closes_old_connections_when_sync_task_raises(self):
calls = []
def sample_task():
calls.append(("task", None))
raise RuntimeError("boom")
app, task = make_app_with_task(sample_task)
with patch(
"apps.common.procrastinate.close_old_connections",
create=True,
side_effect=lambda: calls.append(("cleanup", None)),
):
on_app_ready(app)
with self.assertRaises(RuntimeError):
task.func()
self.assertEqual(
calls,
[
("cleanup", None),
("task", None),
("cleanup", None),
],
)
class ProcrastinateConnectionRecoveryTests(TransactionTestCase):
def test_wrapped_task_recovers_from_closed_django_connection(self):
def sample_task():
with connection.cursor() as cursor:
cursor.execute("SELECT 1")
return cursor.fetchone()[0]
app, task = make_app_with_task(sample_task)
on_app_ready(app)
connection.ensure_connection()
connection.connection.close()
self.assertEqual(task.func(), 1)
@@ -1,5 +1,4 @@
import logging
from datetime import timedelta
from django.db.models import QuerySet
from django.utils import timezone
@@ -258,7 +257,10 @@ class ExchangeRateFetcher:
processed_pairs.add((from_currency.id, to_currency.id))
service.last_fetch = timezone.now()
service.failure_count = 0
service.save()
except Exception as e:
logger.error(f"Error fetching rates for {service.name}: {e}")
service.failure_count += 1
service.save()
@@ -0,0 +1,18 @@
# Generated by Django 5.2.10 on 2026-01-10 06:08
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('currencies', '0022_currency_is_archived'),
]
operations = [
migrations.AddField(
model_name='exchangerateservice',
name='failure_count',
field=models.PositiveIntegerField(default=0),
),
]
+4 -2
View File
@@ -136,6 +136,8 @@ class ExchangeRateService(models.Model):
null=True, blank=True, verbose_name=_("Last Successful Fetch")
)
failure_count = models.PositiveIntegerField(default=0)
target_currencies = models.ManyToManyField(
Currency,
verbose_name=_("Target Currencies"),
@@ -237,7 +239,7 @@ class ExchangeRateService(models.Model):
hours = self._parse_hour_ranges(self.fetch_interval)
# Store in normalized format (optional)
self.fetch_interval = ",".join(str(h) for h in sorted(hours))
except ValueError as e:
except ValueError:
raise ValidationError(
{
"fetch_interval": _(
@@ -248,7 +250,7 @@ class ExchangeRateService(models.Model):
)
except ValidationError:
raise
except Exception as e:
except Exception:
raise ValidationError(
{
"fetch_interval": _(
+1
View File
@@ -0,0 +1 @@
# Tests package for currencies app
@@ -0,0 +1,109 @@
from decimal import Decimal
from unittest.mock import patch, MagicMock
from django.test import TestCase
from django.utils import timezone
from apps.currencies.models import Currency, ExchangeRateService
from apps.currencies.exchange_rates.fetcher import ExchangeRateFetcher
class ExchangeRateServiceFailureTrackingTests(TestCase):
"""Tests for the failure count tracking functionality."""
def setUp(self):
"""Set up test data."""
self.usd = Currency.objects.create(
code="USD", name="US Dollar", decimal_places=2, prefix="$ "
)
self.eur = Currency.objects.create(
code="EUR", name="Euro", decimal_places=2, prefix=""
)
self.eur.exchange_currency = self.usd
self.eur.save()
self.service = ExchangeRateService.objects.create(
name="Test Service",
service_type=ExchangeRateService.ServiceType.FRANKFURTER,
is_active=True,
)
self.service.target_currencies.add(self.eur)
def test_failure_count_increments_on_provider_error(self):
"""Test that failure_count increments when provider raises an exception."""
self.assertEqual(self.service.failure_count, 0)
with patch.object(
self.service, "get_provider", side_effect=Exception("API Error")
):
ExchangeRateFetcher._fetch_service_rates(self.service)
self.service.refresh_from_db()
self.assertEqual(self.service.failure_count, 1)
def test_failure_count_resets_on_success(self):
"""Test that failure_count resets to 0 on successful fetch."""
# Set initial failure count
self.service.failure_count = 5
self.service.save()
# Mock a successful provider
mock_provider = MagicMock()
mock_provider.requires_api_key.return_value = False
mock_provider.get_rates.return_value = [(self.usd, self.eur, Decimal("0.85"))]
mock_provider.rates_inverted = False
with patch.object(self.service, "get_provider", return_value=mock_provider):
ExchangeRateFetcher._fetch_service_rates(self.service)
self.service.refresh_from_db()
self.assertEqual(self.service.failure_count, 0)
def test_failure_count_accumulates_across_fetches(self):
"""Test that failure_count accumulates with consecutive failures."""
self.assertEqual(self.service.failure_count, 0)
with patch.object(
self.service, "get_provider", side_effect=Exception("API Error")
):
ExchangeRateFetcher._fetch_service_rates(self.service)
self.service.refresh_from_db()
self.assertEqual(self.service.failure_count, 1)
ExchangeRateFetcher._fetch_service_rates(self.service)
self.service.refresh_from_db()
self.assertEqual(self.service.failure_count, 2)
ExchangeRateFetcher._fetch_service_rates(self.service)
self.service.refresh_from_db()
self.assertEqual(self.service.failure_count, 3)
def test_last_fetch_not_updated_on_failure(self):
"""Test that last_fetch is NOT updated when a failure occurs."""
original_last_fetch = self.service.last_fetch
self.assertIsNone(original_last_fetch)
with patch.object(
self.service, "get_provider", side_effect=Exception("API Error")
):
ExchangeRateFetcher._fetch_service_rates(self.service)
self.service.refresh_from_db()
self.assertIsNone(self.service.last_fetch)
self.assertEqual(self.service.failure_count, 1)
def test_last_fetch_updated_on_success(self):
"""Test that last_fetch IS updated when fetch succeeds."""
self.assertIsNone(self.service.last_fetch)
mock_provider = MagicMock()
mock_provider.requires_api_key.return_value = False
mock_provider.get_rates.return_value = [(self.usd, self.eur, Decimal("0.85"))]
mock_provider.rates_inverted = False
with patch.object(self.service, "get_provider", return_value=mock_provider):
ExchangeRateFetcher._fetch_service_rates(self.service)
self.service.refresh_from_db()
self.assertIsNotNone(self.service.last_fetch)
self.assertEqual(self.service.failure_count, 0)
+1 -1
View File
@@ -23,7 +23,7 @@ def currencies_index(request):
@login_required
@require_http_methods(["GET"])
def currencies_list(request):
currencies = Currency.objects.all().order_by("id")
currencies = Currency.objects.all().order_by("name")
return render(
request,
"currencies/fragments/list.html",
+2 -3
View File
@@ -1,4 +1,3 @@
# apps/dca_tracker/views.py
from django.contrib import messages
from django.contrib.auth.decorators import login_required
from django.db.models import Sum, Avg
@@ -23,7 +22,7 @@ def strategy_index(request):
@only_htmx
@login_required
def strategy_list(request):
strategies = DCAStrategy.objects.all().order_by("created_at")
strategies = DCAStrategy.objects.all().order_by("name")
return render(
request, "dca/fragments/strategy/list.html", {"strategies": strategies}
)
@@ -234,7 +233,7 @@ def strategy_entry_add(request, strategy_id):
if request.method == "POST":
form = DCAEntryForm(request.POST, strategy=strategy)
if form.is_valid():
entry = form.save()
form.save()
messages.success(request, _("Entry added successfully"))
return HttpResponse(
+15 -5
View File
@@ -1,8 +1,10 @@
from import_export import fields, resources
from import_export.widgets import ForeignKeyWidget
from apps.accounts.models import Account
from apps.export_app.widgets.foreign_key import AutoCreateForeignKeyWidget
from apps.export_app.widgets.foreign_key import (
AllObjectsForeignKeyWidget,
AutoCreateForeignKeyWidget,
)
from apps.export_app.widgets.many_to_many import AutoCreateManyToManyWidget
from apps.export_app.widgets.string import EmptyStringToNoneField
from apps.transactions.models import (
@@ -20,7 +22,7 @@ class TransactionResource(resources.ModelResource):
account = fields.Field(
attribute="account",
column_name="account",
widget=ForeignKeyWidget(Account, "name"),
widget=AllObjectsForeignKeyWidget(Account, "name"),
)
category = fields.Field(
@@ -86,7 +88,7 @@ class RecurringTransactionResource(resources.ModelResource):
account = fields.Field(
attribute="account",
column_name="account",
widget=ForeignKeyWidget(Account, "name"),
widget=AllObjectsForeignKeyWidget(Account, "name"),
)
category = fields.Field(
@@ -119,12 +121,16 @@ class RecurringTransactionResource(resources.ModelResource):
def get_queryset(self):
return RecurringTransaction.all_objects.all()
def dehydrate_account_owner(self, obj):
"""Export the account's owner ID for proper import matching."""
return obj.account.owner_id if obj.account else None
class InstallmentPlanResource(resources.ModelResource):
account = fields.Field(
attribute="account",
column_name="account",
widget=ForeignKeyWidget(Account, "name"),
widget=AllObjectsForeignKeyWidget(Account, "name"),
)
category = fields.Field(
@@ -156,3 +162,7 @@ class InstallmentPlanResource(resources.ModelResource):
def get_queryset(self):
return InstallmentPlan.all_objects.all()
def dehydrate_account_owner(self, obj):
"""Export the account's owner ID for proper import matching."""
return obj.account.owner_id if obj.account else None
@@ -1,6 +1,60 @@
from import_export.widgets import ForeignKeyWidget
class AllObjectsForeignKeyWidget(ForeignKeyWidget):
"""
ForeignKeyWidget that uses 'all_objects' manager for lookups,
bypassing user-filtered managers like SharedObjectManager.
Also filters by owner if available in the row data.
"""
def get_queryset(self, value, row, *args, **kwargs):
# Use all_objects manager if available, otherwise fall back to default
if hasattr(self.model, "all_objects"):
qs = self.model.all_objects.all()
# Filter by owner if the row has an owner field and the model has owner
if row:
# Check for direct owner field first
owner_id = row.get("owner") if "owner" in row else None
# Fall back to account_owner for models like InstallmentPlan
if not owner_id and "account_owner" in row:
owner_id = row.get("account_owner")
# If still no owner, try to get it from the existing record's account
# This handles backward compatibility with older exports
if not owner_id and "id" in row and row.get("id"):
try:
# Try to find the existing record and get owner from its account
from apps.transactions.models import (
InstallmentPlan,
RecurringTransaction,
)
record_id = row.get("id")
# Try to find the existing InstallmentPlan or RecurringTransaction
for model_class in [InstallmentPlan, RecurringTransaction]:
try:
existing = model_class.all_objects.get(id=record_id)
if existing.account:
owner_id = existing.account.owner_id
break
except model_class.DoesNotExist:
continue
except Exception:
pass
# Final fallback: use the current logged-in user
# This handles restoring to a fresh database with older exports
if not owner_id:
from apps.common.middleware.thread_local import get_current_user
user = get_current_user()
if user and user.is_authenticated:
owner_id = user.id
if owner_id:
qs = qs.filter(owner_id=owner_id)
return qs
return super().get_queryset(value, row, *args, **kwargs)
class AutoCreateForeignKeyWidget(ForeignKeyWidget):
def clean(self, value, row=None, *args, **kwargs):
if value:
+12 -1
View File
@@ -106,6 +106,17 @@ class ExcelImportSettings(BaseModel):
sheets: list[str] | str = "*"
class QIFImportSettings(BaseModel):
skip_errors: bool = Field(
default=False,
description="If True, errors during import will be logged and skipped",
)
file_type: Literal["qif"] = "qif"
importing: Literal["transactions"] = "transactions"
encoding: str = Field(default="utf-8", description="File encoding")
date_format: str = Field(..., description="Date format (e.g. %d/%m/%Y)")
class ColumnMapping(BaseModel):
source: Optional[str] | Optional[list[str]] = Field(
default=None,
@@ -342,7 +353,7 @@ class CurrencyExchangeMapping(ColumnMapping):
class ImportProfileSchema(BaseModel):
settings: CSVImportSettings | ExcelImportSettings
settings: CSVImportSettings | ExcelImportSettings | QIFImportSettings
mapping: Dict[
str,
TransactionAccountMapping
+290 -7
View File
@@ -3,6 +3,8 @@ import hashlib
import logging
import os
import re
import zipfile
from django.db import transaction
from datetime import datetime, date
from decimal import Decimal, InvalidOperation
from typing import Dict, Any, Literal, Union
@@ -11,6 +13,7 @@ import openpyxl
import xlrd
import yaml
from cachalot.api import cachalot_disabled
from django.core.exceptions import FieldDoesNotExist
from django.utils import timezone
from openpyxl.utils.exceptions import InvalidFileException
@@ -363,7 +366,7 @@ class ImportService:
try:
if entities_mapping:
if entities_mapping.type == "id":
entity = TransactionTag.objects.filter(
entity = TransactionEntity.objects.filter(
id=entity_name
).first()
else: # name
@@ -460,12 +463,12 @@ class ImportService:
for field in rule.fields:
if field in transaction_data:
value = transaction_data[field]
# Use __iexact only for string fields; non-string types
# (date, Decimal, bool, int, etc.) don't support UPPER()
if rule.match_type == "strict" or not isinstance(value, str):
query = query.filter(**{field: value})
else: # lax matching for strings only
query = query.filter(**{f"{field}__iexact": value})
query = self._apply_deduplication_filter(
query=query,
field=field,
value=value,
match_type=rule.match_type,
)
# If we found any matching transaction, it's a duplicate
if query.exists():
@@ -473,6 +476,71 @@ class ImportService:
return False
@staticmethod
def _is_int_like(value: Any) -> bool:
try:
int(value)
except (TypeError, ValueError):
return False
return True
def _apply_deduplication_filter(
self,
query,
field: str,
value: Any,
match_type: Literal["lax", "strict"],
):
if isinstance(value, list):
return self._apply_list_deduplication_filter(
query=query,
field=field,
values=value,
match_type=match_type,
)
# Use __iexact only for string fields; non-string types
# (date, Decimal, bool, int, etc.) don't support UPPER()
if match_type == "strict" or not isinstance(value, str):
return query.filter(**{field: value})
return query.filter(**{f"{field}__iexact": value})
def _apply_list_deduplication_filter(
self,
query,
field: str,
values: list[Any],
match_type: Literal["lax", "strict"],
):
clean_values = [v for v in values if v not in (None, "")]
if not clean_values:
return query
try:
model_field = Transaction._meta.get_field(field)
except FieldDoesNotExist:
return query.filter(**{f"{field}__in": clean_values})
if getattr(model_field, "many_to_many", False):
# For m2m fields (e.g., entities/tags), apply one filter per value so
# all provided values must be present in the matched transaction.
if all(self._is_int_like(v) for v in clean_values):
for value in clean_values:
query = query.filter(**{f"{field}__id": int(value)})
else:
for value in clean_values:
lookup = (
f"{field}__name"
if match_type == "strict"
else f"{field}__name__iexact"
)
query = query.filter(**{lookup: str(value).strip()})
return query.distinct()
return query.filter(**{f"{field}__in": clean_values})
def _coerce_type(
self, value: str, mapping: version_1.ColumnMapping
) -> Union[str, int, bool, Decimal, datetime, list, None]:
@@ -845,6 +913,219 @@ class ImportService:
f"Invalid {self.settings.file_type.upper()} file format: {str(e)}"
)
def _parse_and_import_qif(self, content_lines: list[str], filename: str) -> None:
# Infer account from filename (remove extension)
account_name = os.path.splitext(os.path.basename(filename))[0]
current_transaction = {}
raw_lines_buffer = []
account = Account.objects.filter(name=account_name).first()
if not account:
raise ValueError(f"Account '{account_name}' not found.")
row_number = 0
for line in content_lines:
row_number += 1
line = line.strip()
if not line:
continue
raw_lines_buffer.append(line)
if line == "^":
if current_transaction:
# Deduplication using hash of raw lines
raw_content = "".join(raw_lines_buffer)
internal_id = hashlib.sha256(
raw_content.encode("utf-8")
).hexdigest()
# Reset buffer for next transaction
raw_lines_buffer = []
try:
with transaction.atomic():
if Transaction.objects.filter(
internal_id=internal_id
).exists():
self._increment_totals("skipped", 1)
self._log(
"info",
f"Skipped duplicate transaction from {filename}",
)
current_transaction = {}
continue
# Handle Account
if account:
current_transaction["account"] = account
else:
acc = Account.objects.filter(name=account_name).first()
if acc:
current_transaction["account"] = acc
else:
raise ValueError(
f"Account '{account_name}' not found."
)
current_transaction["internal_id"] = internal_id
# Handle Description/Memo mapping
if "memo" in current_transaction:
current_transaction["description"] = (
current_transaction.pop("memo")
)
# Handle Payee mapping
entities = []
if "payee" in current_transaction:
payee_name = current_transaction.pop("payee")
# "Treat the payee (P) as the entity. Use existing or create"
entity, _ = TransactionEntity.objects.get_or_create(
name=payee_name
)
entities.append(entity)
# Handle Label/Category
category = None
tags = []
if "label" in current_transaction:
label = current_transaction.pop("label")
if label.startswith("[") and label.endswith("]"):
# Transfer: set label as description, ignore category/tags
clean_label = label[1:-1]
current_transaction["description"] = clean_label
else:
parts = label.split(":")
if parts:
cat_name = parts[0].strip()
if cat_name:
category, _ = (
TransactionCategory.objects.get_or_create(
name=cat_name
)
)
if len(parts) > 1:
for tag_name in parts[1:]:
tag_name = tag_name.strip()
if tag_name:
tag, _ = (
TransactionTag.objects.get_or_create(
name=tag_name
)
)
tags.append(tag)
current_transaction["category"] = category
# Create transaction
new_trans = Transaction.objects.create(
**current_transaction
)
if entities:
new_trans.entities.set(entities)
if tags:
new_trans.tags.set(tags)
self.import_run.transactions.add(new_trans)
self._increment_totals("successful", 1)
except Exception as e:
if not self.settings.skip_errors:
raise e
self._log(
"warning",
f"Error processing transaction in {filename}: {str(e)}",
)
self._increment_totals("failed", 1)
# Reset for next transaction
current_transaction = {}
else:
# Empty transaction record (orphaned ^)
raw_lines_buffer = []
pass
self._increment_totals("processed", 1)
continue
if line.startswith("!"):
continue
code = line[0]
value = line[1:]
if code == "D":
try:
current_transaction["date"] = datetime.strptime(
value, self.settings.date_format
).date()
except ValueError:
self._log(
"warning",
f"Could not parse date '{value}' using format '{self.settings.date_format}' in {filename}",
)
if not self.settings.skip_errors:
raise ValueError(f"Invalid date format '{value}'")
elif code == "T":
try:
cleaned_value = value.replace(",", "")
amount = Decimal(cleaned_value)
if amount < 0:
current_transaction["type"] = Transaction.Type.EXPENSE
current_transaction["amount"] = abs(amount)
else:
current_transaction["type"] = Transaction.Type.INCOME
current_transaction["amount"] = amount
except InvalidOperation:
self._log(
"warning", f"Could not parse amount '{value}' in {filename}"
)
if not self.settings.skip_errors:
raise ValueError(f"Invalid amount format '{value}'")
elif code == "P":
current_transaction["payee"] = value
elif code == "M":
current_transaction["memo"] = value
elif code == "L":
current_transaction["label"] = value
elif code == "N":
pass
def _process_qif(self, file_path):
def process_logic():
if zipfile.is_zipfile(file_path):
try:
with zipfile.ZipFile(file_path, "r") as zf:
for filename in zf.namelist():
if filename.lower().endswith(
".qif"
) and not filename.startswith("__MACOSX"):
self._log(
"info", f"Processing QIF from ZIP: {filename}"
)
with zf.open(filename) as f:
content = f.read().decode(self.settings.encoding)
self._parse_and_import_qif(
content.splitlines(), filename
)
except Exception as e:
raise ValueError(f"Error processing ZIP file: {str(e)}")
else:
with open(file_path, "r", encoding=self.settings.encoding) as f:
self._parse_and_import_qif(
f.readlines(), os.path.basename(file_path)
)
if not self.settings.skip_errors:
with transaction.atomic():
process_logic()
else:
process_logic()
def _validate_file_path(self, file_path: str) -> str:
"""
Validates that the file path is within the allowed temporary directory.
@@ -871,6 +1152,8 @@ class ImportService:
self._process_csv(file_path)
elif isinstance(self.settings, version_1.ExcelImportSettings):
self._process_excel(file_path)
elif isinstance(self.settings, version_1.QIFImportSettings):
self._process_qif(file_path)
self._update_status("FINISHED")
self._log(
@@ -8,7 +8,6 @@ is only used for string fields (not dates, decimals, etc.).
from datetime import date
from decimal import Decimal
from unittest.mock import MagicMock, patch
from django.test import TestCase
@@ -16,7 +15,7 @@ from apps.accounts.models import Account, AccountGroup
from apps.currencies.models import Currency
from apps.import_app.models import ImportProfile, ImportRun
from apps.import_app.services.v1 import ImportService
from apps.transactions.models import Transaction
from apps.transactions.models import Transaction, TransactionEntity
class DeduplicationTests(TestCase):
@@ -274,3 +273,39 @@ deduplication:
}
)
self.assertTrue(is_duplicate)
def test_deduplication_with_entities_list_value(self):
"""Test that list values for m2m entities deduplicate correctly."""
entity = TransactionEntity.objects.create(name="DB Vertrieb GmbH")
self.existing_transaction.entities.add(entity)
service = self._create_import_service_with_deduplication(
fields=["date", "amount", "entities"], match_type="strict"
)
is_duplicate = service._check_duplicate_transaction(
{
"date": date(2024, 1, 15),
"amount": Decimal("100.00"),
"entities": ["DB Vertrieb GmbH"],
}
)
self.assertTrue(is_duplicate)
def test_deduplication_with_entities_list_value_not_matching(self):
"""Test that non-matching entity list values are not marked duplicate."""
entity = TransactionEntity.objects.create(name="DB Vertrieb GmbH")
self.existing_transaction.entities.add(entity)
service = self._create_import_service_with_deduplication(
fields=["date", "amount", "entities"], match_type="strict"
)
is_duplicate = service._check_duplicate_transaction(
{
"date": date(2024, 1, 15),
"amount": Decimal("100.00"),
"entities": ["Different Entity"],
}
)
self.assertFalse(is_duplicate)
@@ -0,0 +1,259 @@
from decimal import Decimal
import os
import shutil
from django.test import TestCase
from django.contrib.auth import get_user_model
from apps.accounts.models import Account, AccountGroup
from apps.currencies.models import Currency
from apps.common.middleware.thread_local import write_current_user, delete_current_user
from apps.import_app.models import ImportProfile, ImportRun
from apps.import_app.services.v1 import ImportService
from apps.transactions.models import (
Transaction,
)
class QIFImportTests(TestCase):
def setUp(self):
# Patch TEMP_DIR for testing
self.original_temp_dir = ImportService.TEMP_DIR
self.test_dir = os.path.abspath("temp_test_import")
ImportService.TEMP_DIR = self.test_dir
os.makedirs(self.test_dir, exist_ok=True)
# Create user and set context
User = get_user_model()
self.user = User.objects.create_user(
email="test@example.com", password="password"
)
write_current_user(self.user)
self.currency = Currency.objects.create(
code="BRL", name="Real", decimal_places=2, prefix="R$ "
)
self.group = AccountGroup.objects.create(name="Test Group", owner=self.user)
self.account = Account.objects.create(
name="bradesco-checking",
group=self.group,
currency=self.currency,
owner=self.user,
)
def tearDown(self):
delete_current_user()
ImportService.TEMP_DIR = self.original_temp_dir
if os.path.exists(self.test_dir):
shutil.rmtree(self.test_dir)
def test_import_single_qif_valid_mapping(self):
content = """!Type:Bank
D04/01/2015
T8069.46
PMy Payee -> Entity
MNote -> Desc
LOld Cat:New Tag
^
D05/01/2015
T-100.00
PSupermarket
MWeekly shopping
L[Transfer]
^
"""
filename = "bradesco-checking.qif"
file_path = os.path.join(self.test_dir, filename)
with open(file_path, "w", encoding="utf-8") as f:
f.write(content)
yaml_config = """
settings:
file_type: qif
importing: transactions
date_format: "%d/%m/%Y"
mapping: {}
"""
profile = ImportProfile.objects.create(
name="QIF Profile",
yaml_config=yaml_config,
version=ImportProfile.Versions.VERSION_1,
)
run = ImportRun.objects.create(profile=profile, file_name=filename)
service = ImportService(run)
service.process_file(file_path)
self.assertEqual(Transaction.objects.count(), 2)
# Transaction 1: Income, Category+Tag
t1 = Transaction.objects.get(description="Note -> Desc")
self.assertEqual(t1.amount, Decimal("8069.46"))
self.assertEqual(t1.type, Transaction.Type.INCOME)
self.assertEqual(t1.category.name, "Old Cat")
self.assertTrue(t1.tags.filter(name="New Tag").exists())
self.assertTrue(t1.entities.filter(name="My Payee -> Entity").exists())
self.assertEqual(t1.account, self.account)
# Transaction 2: Expense, Transfer ([Transfer] -> Description)
t2 = Transaction.objects.get(description="Transfer")
self.assertEqual(t2.amount, Decimal("100.00"))
self.assertEqual(t2.type, Transaction.Type.EXPENSE)
self.assertIsNone(t2.category)
self.assertFalse(t2.tags.exists())
self.assertTrue(t2.entities.filter(name="Supermarket").exists())
self.assertEqual(t2.description, "Transfer")
def test_import_deduplication_hash(self):
# Same content twice. Should result in only 1 transaction due to hash deduplication.
content = """!Type:Bank
D04/01/2015
T100.00
POK
^
"""
filename = "bradesco-checking.qif"
file_path = os.path.join(self.test_dir, filename)
with open(file_path, "w", encoding="utf-8") as f:
f.write(content)
yaml_config = """
settings:
file_type: qif
importing: transactions
date_format: "%d/%m/%Y"
mapping: {}
"""
profile = ImportProfile.objects.create(
name="QIF Profile",
yaml_config=yaml_config,
version=ImportProfile.Versions.VERSION_1,
)
run = ImportRun.objects.create(profile=profile, file_name=filename)
service = ImportService(run)
# First run
service.process_file(file_path)
self.assertEqual(Transaction.objects.count(), 1)
# Service deletes file after processing, so recreate it for second run
with open(file_path, "w", encoding="utf-8") as f:
f.write(content)
# Second run - Duplicate content
service.process_file(file_path)
self.assertEqual(Transaction.objects.count(), 1)
def test_import_strict_error_rollback(self):
# atomic check.
# Transaction 1 valid, Transaction 2 invalid date.
content = """!Type:Bank
D04/01/2015
T100.00
POK
^
DINVALID
T100.00
PBad
^
"""
filename = "bradesco-checking.qif"
file_path = os.path.join(self.test_dir, filename)
with open(file_path, "w", encoding="utf-8") as f:
f.write(content)
yaml_config = """
settings:
file_type: qif
importing: transactions
date_format: "%d/%m/%Y"
skip_errors: false
mapping: {}
"""
profile = ImportProfile.objects.create(
name="QIF Profile",
yaml_config=yaml_config,
version=ImportProfile.Versions.VERSION_1,
)
run = ImportRun.objects.create(profile=profile, file_name=filename)
service = ImportService(run)
with self.assertRaises(Exception) as cm:
service.process_file(file_path)
self.assertEqual(str(cm.exception), "Import failed")
# Should be 0 transactions because of atomic rollback
self.assertEqual(Transaction.objects.count(), 0)
def test_import_missing_account(self):
# File with account name that doesn't exist
content = """!Type:Bank
D04/01/2015
T100.00
POK
^
"""
filename = "missing-account.qif"
file_path = os.path.join(self.test_dir, filename)
with open(file_path, "w", encoding="utf-8") as f:
f.write(content)
yaml_config = """
settings:
file_type: qif
importing: transactions
date_format: "%d/%m/%Y"
mapping: {}
"""
profile = ImportProfile.objects.create(
name="QIF Profile",
yaml_config=yaml_config,
version=ImportProfile.Versions.VERSION_1,
)
run = ImportRun.objects.create(profile=profile, file_name=filename)
service = ImportService(run)
# Should fail because account doesn't exist
with self.assertRaises(Exception) as cm:
service.process_file(file_path)
self.assertEqual(str(cm.exception), "Import failed")
def test_import_skip_errors(self):
# skip_errors: true.
# Transaction 1 valid, Transaction 2 invalid date.
content = """!Type:Bank
D04/01/2015
T100.00
POK
^
DINVALID
T100.00
PBad
^
"""
filename = "bradesco-checking.qif"
file_path = os.path.join(self.test_dir, filename)
with open(file_path, "w", encoding="utf-8") as f:
f.write(content)
yaml_config = """
settings:
file_type: qif
importing: transactions
date_format: "%d/%m/%Y"
skip_errors: true
mapping: {}
"""
profile = ImportProfile.objects.create(
name="QIF Profile",
yaml_config=yaml_config,
version=ImportProfile.Versions.VERSION_1,
)
run = ImportRun.objects.create(profile=profile, file_name=filename)
service = ImportService(run)
service.process_file(file_path)
# Should be 1 transaction (valid one)
self.assertEqual(Transaction.objects.count(), 1)
self.assertEqual(
Transaction.objects.first().description, ""
) # empty desc if no memo
+10
View File
@@ -49,4 +49,14 @@ urlpatterns = [
views.emergency_fund,
name="insights_emergency_fund",
),
path(
"insights/year-by-year/",
views.year_by_year,
name="insights_year_by_year",
),
path(
"insights/month-by-month/",
views.month_by_month,
name="insights_month_by_month",
),
]
+316
View File
@@ -0,0 +1,316 @@
from collections import OrderedDict
from decimal import Decimal
from django.db import models
from django.db.models import Sum, Case, When, Value
from django.db.models.functions import Coalesce
from django.utils import timezone
from apps.currencies.models import Currency
from apps.currencies.utils.convert import convert
from apps.transactions.models import Transaction
def get_month_by_month_data(year=None, group_by="categories"):
"""
Aggregate transaction totals by month for a specific year, grouped by categories, tags, or entities.
Args:
year: The year to filter transactions (defaults to current year)
group_by: One of "categories", "tags", or "entities"
Returns:
{
"year": 2025,
"available_years": [2025, 2024, ...],
"months": [1, 2, 3, ..., 12],
"items": {
item_id: {
"name": "Item Name",
"month_totals": {
1: {"currencies": {...}},
...
},
"total": {"currencies": {...}}
},
...
},
"month_totals": {...},
"grand_total": {"currencies": {...}}
}
"""
if year is None:
year = timezone.localdate(timezone.now()).year
# Base queryset - all paid transactions, non-muted
transactions = Transaction.objects.filter(
is_paid=True,
account__is_archived=False,
).exclude(account__currency__is_archived=True)
# Get available years for the selector
available_years = list(
transactions.values_list("reference_date__year", flat=True)
.distinct()
.order_by("-reference_date__year")
)
# Filter by the selected year
transactions = transactions.filter(reference_date__year=year)
# Define grouping fields based on group_by parameter
if group_by == "tags":
group_field = "tags"
name_field = "tags__name"
elif group_by == "entities":
group_field = "entities"
name_field = "entities__name"
else: # Default to categories
group_field = "category"
name_field = "category__name"
# Months 1-12
months = list(range(1, 13))
if not available_years:
return {
"year": year,
"available_years": [],
"months": months,
"items": {},
"month_totals": {},
"grand_total": {"currencies": {}},
}
# Aggregate by group, month, and currency
metrics = (
transactions.values(
group_field,
name_field,
"reference_date__month",
"account__currency",
"account__currency__code",
"account__currency__name",
"account__currency__decimal_places",
"account__currency__prefix",
"account__currency__suffix",
"account__currency__exchange_currency",
)
.annotate(
expense_total=Coalesce(
Sum(
Case(
When(type=Transaction.Type.EXPENSE, then="amount"),
default=Value(0),
output_field=models.DecimalField(),
)
),
Decimal("0"),
),
income_total=Coalesce(
Sum(
Case(
When(type=Transaction.Type.INCOME, then="amount"),
default=Value(0),
output_field=models.DecimalField(),
)
),
Decimal("0"),
),
)
.order_by(name_field, "reference_date__month")
)
# Build result structure
result = {
"year": year,
"available_years": available_years,
"months": months,
"items": OrderedDict(),
"month_totals": {},
"grand_total": {"currencies": {}},
}
# Store currency info for later use in totals
currency_info = {}
for metric in metrics:
item_id = metric[group_field]
item_name = metric[name_field]
month = metric["reference_date__month"]
currency_id = metric["account__currency"]
# Use a consistent key for None (uncategorized/untagged/no entity)
item_key = item_id if item_id is not None else "__none__"
if item_key not in result["items"]:
result["items"][item_key] = {
"name": item_name,
"month_totals": {},
"total": {"currencies": {}},
}
if month not in result["items"][item_key]["month_totals"]:
result["items"][item_key]["month_totals"][month] = {"currencies": {}}
# Calculate final total (income - expense)
final_total = metric["income_total"] - metric["expense_total"]
# Store currency info for totals calculation
if currency_id not in currency_info:
currency_info[currency_id] = {
"code": metric["account__currency__code"],
"name": metric["account__currency__name"],
"decimal_places": metric["account__currency__decimal_places"],
"prefix": metric["account__currency__prefix"],
"suffix": metric["account__currency__suffix"],
"exchange_currency_id": metric["account__currency__exchange_currency"],
}
currency_data = {
"currency": {
"code": metric["account__currency__code"],
"name": metric["account__currency__name"],
"decimal_places": metric["account__currency__decimal_places"],
"prefix": metric["account__currency__prefix"],
"suffix": metric["account__currency__suffix"],
},
"final_total": final_total,
"income_total": metric["income_total"],
"expense_total": metric["expense_total"],
}
# Handle currency conversion if exchange currency is set
if metric["account__currency__exchange_currency"]:
from_currency = Currency.objects.get(id=currency_id)
exchange_currency = Currency.objects.get(
id=metric["account__currency__exchange_currency"]
)
converted_amount, prefix, suffix, decimal_places = convert(
amount=final_total,
from_currency=from_currency,
to_currency=exchange_currency,
)
if converted_amount is not None:
currency_data["exchanged"] = {
"final_total": converted_amount,
"currency": {
"prefix": prefix,
"suffix": suffix,
"decimal_places": decimal_places,
"code": exchange_currency.code,
"name": exchange_currency.name,
},
}
result["items"][item_key]["month_totals"][month]["currencies"][currency_id] = (
currency_data
)
# Accumulate item total (across all months for this item)
if currency_id not in result["items"][item_key]["total"]["currencies"]:
result["items"][item_key]["total"]["currencies"][currency_id] = {
"currency": currency_data["currency"].copy(),
"final_total": Decimal("0"),
}
result["items"][item_key]["total"]["currencies"][currency_id][
"final_total"
] += final_total
# Accumulate month total (across all items for this month)
if month not in result["month_totals"]:
result["month_totals"][month] = {"currencies": {}}
if currency_id not in result["month_totals"][month]["currencies"]:
result["month_totals"][month]["currencies"][currency_id] = {
"currency": currency_data["currency"].copy(),
"final_total": Decimal("0"),
}
result["month_totals"][month]["currencies"][currency_id]["final_total"] += (
final_total
)
# Accumulate grand total
if currency_id not in result["grand_total"]["currencies"]:
result["grand_total"]["currencies"][currency_id] = {
"currency": currency_data["currency"].copy(),
"final_total": Decimal("0"),
}
result["grand_total"]["currencies"][currency_id]["final_total"] += final_total
# Add currency conversion for item totals
for item_key, item_data in result["items"].items():
for currency_id, total_data in item_data["total"]["currencies"].items():
if currency_info[currency_id]["exchange_currency_id"]:
from_currency = Currency.objects.get(id=currency_id)
exchange_currency = Currency.objects.get(
id=currency_info[currency_id]["exchange_currency_id"]
)
converted_amount, prefix, suffix, decimal_places = convert(
amount=total_data["final_total"],
from_currency=from_currency,
to_currency=exchange_currency,
)
if converted_amount is not None:
total_data["exchanged"] = {
"final_total": converted_amount,
"currency": {
"prefix": prefix,
"suffix": suffix,
"decimal_places": decimal_places,
"code": exchange_currency.code,
"name": exchange_currency.name,
},
}
# Add currency conversion for month totals
for month, month_data in result["month_totals"].items():
for currency_id, total_data in month_data["currencies"].items():
if currency_info[currency_id]["exchange_currency_id"]:
from_currency = Currency.objects.get(id=currency_id)
exchange_currency = Currency.objects.get(
id=currency_info[currency_id]["exchange_currency_id"]
)
converted_amount, prefix, suffix, decimal_places = convert(
amount=total_data["final_total"],
from_currency=from_currency,
to_currency=exchange_currency,
)
if converted_amount is not None:
total_data["exchanged"] = {
"final_total": converted_amount,
"currency": {
"prefix": prefix,
"suffix": suffix,
"decimal_places": decimal_places,
"code": exchange_currency.code,
"name": exchange_currency.name,
},
}
# Add currency conversion for grand total
for currency_id, total_data in result["grand_total"]["currencies"].items():
if currency_info[currency_id]["exchange_currency_id"]:
from_currency = Currency.objects.get(id=currency_id)
exchange_currency = Currency.objects.get(
id=currency_info[currency_id]["exchange_currency_id"]
)
converted_amount, prefix, suffix, decimal_places = convert(
amount=total_data["final_total"],
from_currency=from_currency,
to_currency=exchange_currency,
)
if converted_amount is not None:
total_data["exchanged"] = {
"final_total": converted_amount,
"currency": {
"prefix": prefix,
"suffix": suffix,
"decimal_places": decimal_places,
"code": exchange_currency.code,
"name": exchange_currency.name,
},
}
return result
+303
View File
@@ -0,0 +1,303 @@
from collections import OrderedDict
from decimal import Decimal
from django.db import models
from django.db.models import Sum, Case, When, Value
from django.db.models.functions import Coalesce
from apps.currencies.models import Currency
from apps.currencies.utils.convert import convert
from apps.transactions.models import Transaction
def get_year_by_year_data(group_by="categories"):
"""
Aggregate transaction totals by year for categories, tags, or entities.
Args:
group_by: One of "categories", "tags", or "entities"
Returns:
{
"years": [2025, 2024, ...], # Sorted descending
"items": {
item_id: {
"name": "Item Name",
"year_totals": {
2025: {"currencies": {...}},
...
},
"total": {"currencies": {...}} # Sum across all years
},
...
},
"year_totals": { # Sum across all items for each year
2025: {"currencies": {...}},
...
},
"grand_total": {"currencies": {...}} # Sum of everything
}
"""
# Base queryset - all paid transactions, non-muted
transactions = Transaction.objects.filter(
is_paid=True,
account__is_archived=False,
).exclude(account__currency__is_archived=True)
# Define grouping fields based on group_by parameter
if group_by == "tags":
group_field = "tags"
name_field = "tags__name"
elif group_by == "entities":
group_field = "entities"
name_field = "entities__name"
else: # Default to categories
group_field = "category"
name_field = "category__name"
# Get all unique years with transactions
years = (
transactions.values_list("reference_date__year", flat=True)
.distinct()
.order_by("-reference_date__year")
)
years = list(years)
if not years:
return {
"years": [],
"items": {},
"year_totals": {},
"grand_total": {"currencies": {}},
}
# Aggregate by group, year, and currency
metrics = (
transactions.values(
group_field,
name_field,
"reference_date__year",
"account__currency",
"account__currency__code",
"account__currency__name",
"account__currency__decimal_places",
"account__currency__prefix",
"account__currency__suffix",
"account__currency__exchange_currency",
)
.annotate(
expense_total=Coalesce(
Sum(
Case(
When(type=Transaction.Type.EXPENSE, then="amount"),
default=Value(0),
output_field=models.DecimalField(),
)
),
Decimal("0"),
),
income_total=Coalesce(
Sum(
Case(
When(type=Transaction.Type.INCOME, then="amount"),
default=Value(0),
output_field=models.DecimalField(),
)
),
Decimal("0"),
),
)
.order_by(name_field, "-reference_date__year")
)
# Build result structure
result = {
"years": years,
"items": OrderedDict(),
"year_totals": {}, # Totals per year across all items
"grand_total": {"currencies": {}}, # Grand total across everything
}
# Store currency info for later use in totals
currency_info = {}
for metric in metrics:
item_id = metric[group_field]
item_name = metric[name_field]
year = metric["reference_date__year"]
currency_id = metric["account__currency"]
# Use a consistent key for None (uncategorized/untagged/no entity)
item_key = item_id if item_id is not None else "__none__"
if item_key not in result["items"]:
result["items"][item_key] = {
"name": item_name,
"year_totals": {},
"total": {"currencies": {}}, # Total for this item across all years
}
if year not in result["items"][item_key]["year_totals"]:
result["items"][item_key]["year_totals"][year] = {"currencies": {}}
# Calculate final total (income - expense)
final_total = metric["income_total"] - metric["expense_total"]
# Store currency info for totals calculation
if currency_id not in currency_info:
currency_info[currency_id] = {
"code": metric["account__currency__code"],
"name": metric["account__currency__name"],
"decimal_places": metric["account__currency__decimal_places"],
"prefix": metric["account__currency__prefix"],
"suffix": metric["account__currency__suffix"],
"exchange_currency_id": metric["account__currency__exchange_currency"],
}
currency_data = {
"currency": {
"code": metric["account__currency__code"],
"name": metric["account__currency__name"],
"decimal_places": metric["account__currency__decimal_places"],
"prefix": metric["account__currency__prefix"],
"suffix": metric["account__currency__suffix"],
},
"final_total": final_total,
"income_total": metric["income_total"],
"expense_total": metric["expense_total"],
}
# Handle currency conversion if exchange currency is set
if metric["account__currency__exchange_currency"]:
from_currency = Currency.objects.get(id=currency_id)
exchange_currency = Currency.objects.get(
id=metric["account__currency__exchange_currency"]
)
converted_amount, prefix, suffix, decimal_places = convert(
amount=final_total,
from_currency=from_currency,
to_currency=exchange_currency,
)
if converted_amount is not None:
currency_data["exchanged"] = {
"final_total": converted_amount,
"currency": {
"prefix": prefix,
"suffix": suffix,
"decimal_places": decimal_places,
"code": exchange_currency.code,
"name": exchange_currency.name,
},
}
result["items"][item_key]["year_totals"][year]["currencies"][currency_id] = (
currency_data
)
# Accumulate item total (across all years for this item)
if currency_id not in result["items"][item_key]["total"]["currencies"]:
result["items"][item_key]["total"]["currencies"][currency_id] = {
"currency": currency_data["currency"].copy(),
"final_total": Decimal("0"),
}
result["items"][item_key]["total"]["currencies"][currency_id][
"final_total"
] += final_total
# Accumulate year total (across all items for this year)
if year not in result["year_totals"]:
result["year_totals"][year] = {"currencies": {}}
if currency_id not in result["year_totals"][year]["currencies"]:
result["year_totals"][year]["currencies"][currency_id] = {
"currency": currency_data["currency"].copy(),
"final_total": Decimal("0"),
}
result["year_totals"][year]["currencies"][currency_id]["final_total"] += (
final_total
)
# Accumulate grand total
if currency_id not in result["grand_total"]["currencies"]:
result["grand_total"]["currencies"][currency_id] = {
"currency": currency_data["currency"].copy(),
"final_total": Decimal("0"),
}
result["grand_total"]["currencies"][currency_id]["final_total"] += final_total
# Add currency conversion for item totals
for item_key, item_data in result["items"].items():
for currency_id, total_data in item_data["total"]["currencies"].items():
if currency_info[currency_id]["exchange_currency_id"]:
from_currency = Currency.objects.get(id=currency_id)
exchange_currency = Currency.objects.get(
id=currency_info[currency_id]["exchange_currency_id"]
)
converted_amount, prefix, suffix, decimal_places = convert(
amount=total_data["final_total"],
from_currency=from_currency,
to_currency=exchange_currency,
)
if converted_amount is not None:
total_data["exchanged"] = {
"final_total": converted_amount,
"currency": {
"prefix": prefix,
"suffix": suffix,
"decimal_places": decimal_places,
"code": exchange_currency.code,
"name": exchange_currency.name,
},
}
# Add currency conversion for year totals
for year, year_data in result["year_totals"].items():
for currency_id, total_data in year_data["currencies"].items():
if currency_info[currency_id]["exchange_currency_id"]:
from_currency = Currency.objects.get(id=currency_id)
exchange_currency = Currency.objects.get(
id=currency_info[currency_id]["exchange_currency_id"]
)
converted_amount, prefix, suffix, decimal_places = convert(
amount=total_data["final_total"],
from_currency=from_currency,
to_currency=exchange_currency,
)
if converted_amount is not None:
total_data["exchanged"] = {
"final_total": converted_amount,
"currency": {
"prefix": prefix,
"suffix": suffix,
"decimal_places": decimal_places,
"code": exchange_currency.code,
"name": exchange_currency.name,
},
}
# Add currency conversion for grand total
for currency_id, total_data in result["grand_total"]["currencies"].items():
if currency_info[currency_id]["exchange_currency_id"]:
from_currency = Currency.objects.get(id=currency_id)
exchange_currency = Currency.objects.get(
id=currency_info[currency_id]["exchange_currency_id"]
)
converted_amount, prefix, suffix, decimal_places = convert(
amount=total_data["final_total"],
from_currency=from_currency,
to_currency=exchange_currency,
)
if converted_amount is not None:
total_data["exchanged"] = {
"final_total": converted_amount,
"currency": {
"prefix": prefix,
"suffix": suffix,
"decimal_places": decimal_places,
"code": exchange_currency.code,
"name": exchange_currency.name,
},
}
return result
+70
View File
@@ -26,6 +26,8 @@ from apps.insights.utils.sankey import (
generate_sankey_data_by_currency,
)
from apps.insights.utils.transactions import get_transactions
from apps.insights.utils.year_by_year import get_year_by_year_data
from apps.insights.utils.month_by_month import get_month_by_month_data
from apps.transactions.models import TransactionCategory, Transaction
from apps.transactions.utils.calculations import calculate_currency_totals
@@ -306,3 +308,71 @@ def emergency_fund(request):
"insights/fragments/emergency_fund.html",
{"data": currency_net_worth},
)
@only_htmx
@login_required
@require_http_methods(["GET"])
def year_by_year(request):
if "group_by" in request.GET:
group_by = request.GET["group_by"]
request.session["insights_year_by_year_group_by"] = group_by
else:
group_by = request.session.get("insights_year_by_year_group_by", "categories")
# Validate group_by value
if group_by not in ("categories", "tags", "entities"):
group_by = "categories"
data = get_year_by_year_data(group_by=group_by)
return render(
request,
"insights/fragments/year_by_year.html",
{
"data": data,
"group_by": group_by,
},
)
@only_htmx
@login_required
@require_http_methods(["GET"])
def month_by_month(request):
# Handle year selection
if "year" in request.GET:
try:
year = int(request.GET["year"])
request.session["insights_month_by_month_year"] = year
except (ValueError, TypeError):
year = request.session.get(
"insights_month_by_month_year", timezone.localdate(timezone.now()).year
)
else:
year = request.session.get(
"insights_month_by_month_year", timezone.localdate(timezone.now()).year
)
# Handle group_by selection
if "group_by" in request.GET:
group_by = request.GET["group_by"]
request.session["insights_month_by_month_group_by"] = group_by
else:
group_by = request.session.get("insights_month_by_month_group_by", "categories")
# Validate group_by value
if group_by not in ("categories", "tags", "entities"):
group_by = "categories"
data = get_month_by_month_data(year=year, group_by=group_by)
return render(
request,
"insights/fragments/month_by_month.html",
{
"data": data,
"group_by": group_by,
"selected_year": year,
},
)
@@ -0,0 +1,331 @@
from datetime import date
from decimal import Decimal
from django.contrib.auth import get_user_model
from django.test import TestCase, override_settings
from apps.accounts.models import Account, AccountGroup
from apps.currencies.models import Currency
from apps.transactions.models import (
Transaction,
TransactionCategory,
TransactionTag,
)
@override_settings(
STORAGES={
"default": {"BACKEND": "django.core.files.storage.FileSystemStorage"},
"staticfiles": {
"BACKEND": "django.contrib.staticfiles.storage.StaticFilesStorage"
},
},
WHITENOISE_AUTOREFRESH=True,
)
class MonthlySummaryFilterBehaviorTests(TestCase):
"""Tests for monthly summary views filter behavior.
These tests verify that:
1. Views work correctly without any filters
2. Views work correctly with filters applied
3. The filter detection logic properly uses different querysets
4. Calculated values reflect the applied filters
"""
def setUp(self):
"""Set up test data"""
User = get_user_model()
self.user = User.objects.create_user(
email="testuser@test.com", password="testpass123"
)
self.client.login(username="testuser@test.com", password="testpass123")
self.currency = Currency.objects.create(
code="USD", name="US Dollar", decimal_places=2, prefix="$ "
)
self.account_group = AccountGroup.objects.create(name="Test Group")
self.account = Account.objects.create(
name="Test Account",
group=self.account_group,
currency=self.currency,
is_asset=False,
)
self.category = TransactionCategory.objects.create(
name="Test Category", owner=self.user
)
self.tag = TransactionTag.objects.create(name="TestTag", owner=self.user)
# Create test transactions for December 2025
# Income: 1000 (paid)
self.income_transaction = Transaction.objects.create(
account=self.account,
type=Transaction.Type.INCOME,
is_paid=True,
date=date(2025, 12, 10),
reference_date=date(2025, 12, 1),
amount=Decimal("1000.00"),
description="December Income",
owner=self.user,
)
# Expense: 200 (paid)
self.expense_transaction = Transaction.objects.create(
account=self.account,
type=Transaction.Type.EXPENSE,
is_paid=True,
date=date(2025, 12, 15),
reference_date=date(2025, 12, 1),
amount=Decimal("200.00"),
description="December Expense",
category=self.category,
owner=self.user,
)
self.expense_transaction.tags.add(self.tag)
# Expense: 150 (projected/unpaid)
self.projected_expense = Transaction.objects.create(
account=self.account,
type=Transaction.Type.EXPENSE,
is_paid=False,
date=date(2025, 12, 20),
reference_date=date(2025, 12, 1),
amount=Decimal("150.00"),
description="Projected Expense",
owner=self.user,
)
def _get_currency_data(self, context_dict):
"""Helper to extract data for our test currency from context dict.
The context dict is keyed by currency ID, so we need to find
the entry for our currency.
"""
if not context_dict:
return None
for currency_id, data in context_dict.items():
if data.get("currency", {}).get("code") == "USD":
return data
return None
# --- monthly_summary view tests ---
def test_monthly_summary_no_filter_returns_200(self):
"""Test that monthly_summary returns 200 without filters"""
response = self.client.get(
"/monthly/12/2025/summary/",
HTTP_HX_REQUEST="true",
)
self.assertEqual(response.status_code, 200)
def test_monthly_summary_no_filter_includes_all_transactions(self):
"""Without filters, summary should include all transactions"""
response = self.client.get(
"/monthly/12/2025/summary/",
HTTP_HX_REQUEST="true",
)
context = response.context
# income_current should have the income: 1000
income_current = context.get("income_current", {})
usd_data = self._get_currency_data(income_current)
self.assertIsNotNone(usd_data)
self.assertEqual(usd_data["income_current"], Decimal("1000.00"))
# expense_current should have paid expense: 200
expense_current = context.get("expense_current", {})
usd_data = self._get_currency_data(expense_current)
self.assertIsNotNone(usd_data)
self.assertEqual(usd_data["expense_current"], Decimal("200.00"))
# expense_projected should have unpaid expense: 150
expense_projected = context.get("expense_projected", {})
usd_data = self._get_currency_data(expense_projected)
self.assertIsNotNone(usd_data)
self.assertEqual(usd_data["expense_projected"], Decimal("150.00"))
def test_monthly_summary_type_filter_only_income(self):
"""With type=IN filter, summary should only include income"""
response = self.client.get(
"/monthly/12/2025/summary/?type=IN",
HTTP_HX_REQUEST="true",
)
context = response.context
# income_current should still have 1000
income_current = context.get("income_current", {})
usd_data = self._get_currency_data(income_current)
self.assertIsNotNone(usd_data)
self.assertEqual(usd_data["income_current"], Decimal("1000.00"))
# expense_current should be empty/zero (filtered out)
expense_current = context.get("expense_current", {})
usd_data = self._get_currency_data(expense_current)
if usd_data:
self.assertEqual(usd_data.get("expense_current", 0), Decimal("0"))
# expense_projected should be empty/zero (filtered out)
expense_projected = context.get("expense_projected", {})
usd_data = self._get_currency_data(expense_projected)
if usd_data:
self.assertEqual(usd_data.get("expense_projected", 0), Decimal("0"))
def test_monthly_summary_type_filter_only_expenses(self):
"""With type=EX filter, summary should only include expenses"""
response = self.client.get(
"/monthly/12/2025/summary/?type=EX",
HTTP_HX_REQUEST="true",
)
context = response.context
# income_current should be empty/zero (filtered out)
income_current = context.get("income_current", {})
usd_data = self._get_currency_data(income_current)
if usd_data:
self.assertEqual(usd_data.get("income_current", 0), Decimal("0"))
# expense_current should have 200
expense_current = context.get("expense_current", {})
usd_data = self._get_currency_data(expense_current)
self.assertIsNotNone(usd_data)
self.assertEqual(usd_data["expense_current"], Decimal("200.00"))
# expense_projected should have 150
expense_projected = context.get("expense_projected", {})
usd_data = self._get_currency_data(expense_projected)
self.assertIsNotNone(usd_data)
self.assertEqual(usd_data["expense_projected"], Decimal("150.00"))
def test_monthly_summary_is_paid_filter_only_paid(self):
"""With is_paid=1 filter, summary should only include paid transactions"""
response = self.client.get(
"/monthly/12/2025/summary/?is_paid=1",
HTTP_HX_REQUEST="true",
)
context = response.context
# income_current should have 1000 (paid)
income_current = context.get("income_current", {})
usd_data = self._get_currency_data(income_current)
self.assertIsNotNone(usd_data)
self.assertEqual(usd_data["income_current"], Decimal("1000.00"))
# expense_current should have 200 (paid)
expense_current = context.get("expense_current", {})
usd_data = self._get_currency_data(expense_current)
self.assertIsNotNone(usd_data)
self.assertEqual(usd_data["expense_current"], Decimal("200.00"))
# expense_projected should be empty/zero (filtered out - unpaid)
expense_projected = context.get("expense_projected", {})
usd_data = self._get_currency_data(expense_projected)
if usd_data:
self.assertEqual(usd_data.get("expense_projected", 0), Decimal("0"))
def test_monthly_summary_is_paid_filter_only_unpaid(self):
"""With is_paid=0 filter, summary should only include unpaid transactions"""
response = self.client.get(
"/monthly/12/2025/summary/?is_paid=0",
HTTP_HX_REQUEST="true",
)
context = response.context
# income_current should be empty/zero (filtered out - paid)
income_current = context.get("income_current", {})
usd_data = self._get_currency_data(income_current)
if usd_data:
self.assertEqual(usd_data.get("income_current", 0), Decimal("0"))
# expense_current should be empty/zero (filtered out - paid)
expense_current = context.get("expense_current", {})
usd_data = self._get_currency_data(expense_current)
if usd_data:
self.assertEqual(usd_data.get("expense_current", 0), Decimal("0"))
# expense_projected should have 150 (unpaid)
expense_projected = context.get("expense_projected", {})
usd_data = self._get_currency_data(expense_projected)
self.assertIsNotNone(usd_data)
self.assertEqual(usd_data["expense_projected"], Decimal("150.00"))
def test_monthly_summary_description_filter(self):
"""With description filter, summary should only include matching transactions"""
response = self.client.get(
"/monthly/12/2025/summary/?description=Income",
HTTP_HX_REQUEST="true",
)
context = response.context
# Only income matches "Income" description
income_current = context.get("income_current", {})
usd_data = self._get_currency_data(income_current)
self.assertIsNotNone(usd_data)
self.assertEqual(usd_data["income_current"], Decimal("1000.00"))
# Expenses should be filtered out
expense_current = context.get("expense_current", {})
usd_data = self._get_currency_data(expense_current)
if usd_data:
self.assertEqual(usd_data.get("expense_current", 0), Decimal("0"))
def test_monthly_summary_amount_filter(self):
"""With amount filter, summary should only include transactions in range"""
# Filter to only get transactions between 100 and 250 (should get 200 and 150)
response = self.client.get(
"/monthly/12/2025/summary/?from_amount=100&to_amount=250",
HTTP_HX_REQUEST="true",
)
context = response.context
# Income (1000) should be filtered out
income_current = context.get("income_current", {})
usd_data = self._get_currency_data(income_current)
if usd_data:
self.assertEqual(usd_data.get("income_current", 0), Decimal("0"))
# expense_current should have 200
expense_current = context.get("expense_current", {})
usd_data = self._get_currency_data(expense_current)
self.assertIsNotNone(usd_data)
self.assertEqual(usd_data["expense_current"], Decimal("200.00"))
# expense_projected should have 150
expense_projected = context.get("expense_projected", {})
usd_data = self._get_currency_data(expense_projected)
self.assertIsNotNone(usd_data)
self.assertEqual(usd_data["expense_projected"], Decimal("150.00"))
# --- monthly_account_summary view tests ---
def test_monthly_account_summary_no_filter_returns_200(self):
"""Test that monthly_account_summary returns 200 without filters"""
response = self.client.get(
"/monthly/12/2025/summary/accounts/",
HTTP_HX_REQUEST="true",
)
self.assertEqual(response.status_code, 200)
def test_monthly_account_summary_with_filter_returns_200(self):
"""Test that monthly_account_summary returns 200 with filter"""
response = self.client.get(
"/monthly/12/2025/summary/accounts/?type=IN",
HTTP_HX_REQUEST="true",
)
self.assertEqual(response.status_code, 200)
# --- monthly_currency_summary view tests ---
def test_monthly_currency_summary_no_filter_returns_200(self):
"""Test that monthly_currency_summary returns 200 without filters"""
response = self.client.get(
"/monthly/12/2025/summary/currencies/",
HTTP_HX_REQUEST="true",
)
self.assertEqual(response.status_code, 200)
def test_monthly_currency_summary_with_filter_returns_200(self):
"""Test that monthly_currency_summary returns 200 with filter"""
response = self.client.get(
"/monthly/12/2025/summary/currencies/?type=EX",
HTTP_HX_REQUEST="true",
)
self.assertEqual(response.status_code, 200)
+126 -23
View File
@@ -2,7 +2,8 @@ from django.contrib.auth.decorators import login_required
from django.db.models import (
Q,
)
from django.http import HttpResponse
from django.http import HttpResponse, Http404
from django.shortcuts import render, redirect
from django.utils import timezone
from django.views.decorators.http import require_http_methods
@@ -36,8 +37,6 @@ def monthly_overview(request, month: int, year: int):
summary_tab = request.session.get("monthly_summary_tab", "summary")
if month < 1 or month > 12:
from django.http import Http404
raise Http404("Month is out of range")
next_month = 1 if month == 12 else month + 1
@@ -76,6 +75,8 @@ def transactions_list(request, month: int, year: int):
if order != request.session.get("monthly_transactions_order", "default"):
request.session["monthly_transactions_order"] = order
today = timezone.localdate(timezone.now())
f = TransactionsFilter(request.GET)
transactions_filtered = f.qs.filter(
reference_date__year=year,
@@ -93,12 +94,28 @@ def transactions_list(request, month: int, year: int):
"dca_income_entries",
)
# Late transactions: date < today and is_paid = False (only shown for default ordering)
late_transactions = None
if order == "default":
late_transactions = transactions_filtered.filter(
date__lt=today,
is_paid=False,
).order_by("date", "id")
# Exclude late transactions from the main list
transactions_filtered = transactions_filtered.exclude(
date__lt=today,
is_paid=False,
)
transactions_filtered = default_order(transactions_filtered, order=order)
return render(
request,
"monthly_overview/fragments/list.html",
context={"transactions": transactions_filtered},
context={
"transactions": transactions_filtered,
"late_transactions": late_transactions,
},
)
@@ -107,17 +124,48 @@ def transactions_list(request, month: int, year: int):
@require_http_methods(["GET"])
def monthly_summary(request, month: int, year: int):
# Base queryset with all required filters
base_queryset = (
Transaction.objects.filter(
reference_date__year=year,
reference_date__month=month,
account__is_asset=False,
)
.exclude(Q(Q(category__mute=True) & ~Q(category=None)) | Q(mute=True))
.exclude(account__in=request.user.untracked_accounts.all())
base_queryset = Transaction.objects.filter(
reference_date__year=year,
reference_date__month=month,
)
data = calculate_currency_totals(base_queryset, ignore_empty=True)
# Apply filters and check if any are active
f = TransactionsFilter(request.GET, queryset=base_queryset)
# Check if any filter has a non-default value
# Default values are: type=['IN', 'EX'], is_paid=['1', '0'], everything else empty
has_active_filter = False
if f.form.is_valid():
for name, value in f.form.cleaned_data.items():
# Skip fields with default/empty values
if not value:
continue
# Skip type if it has both default values
if name == "type" and set(value) == {"IN", "EX"}:
continue
# Skip is_paid if it has both default values (values are strings)
if name == "is_paid" and set(value) == {"1", "0"}:
continue
# Skip mute_status if it has both default values
if name == "mute_status" and set(value) == {"active", "muted"}:
continue
# If we get here, there's an active filter
has_active_filter = True
break
if has_active_filter:
queryset = f.qs
else:
queryset = (
base_queryset.exclude(
Q(Q(category__mute=True) & ~Q(category=None)) | Q(mute=True)
)
.exclude(account__in=request.user.untracked_accounts.all())
.exclude(account__is_asset=True)
)
data = calculate_currency_totals(queryset, ignore_empty=True)
percentages = calculate_percentage_distribution(data)
context = {
@@ -132,6 +180,7 @@ def monthly_summary(request, month: int, year: int):
currency_totals=data, month=month, year=year
),
"percentages": percentages,
"has_active_filter": has_active_filter,
}
return render(
@@ -149,9 +198,38 @@ def monthly_account_summary(request, month: int, year: int):
base_queryset = Transaction.objects.filter(
reference_date__year=year,
reference_date__month=month,
).exclude(Q(Q(category__mute=True) & ~Q(category=None)) | Q(mute=True))
)
account_data = calculate_account_totals(transactions_queryset=base_queryset.all())
# Apply filters and check if any are active
f = TransactionsFilter(request.GET, queryset=base_queryset)
# Check if any filter has a non-default value
has_active_filter = False
if f.form.is_valid():
for name, value in f.form.cleaned_data.items():
if not value:
continue
if name == "type" and set(value) == {"IN", "EX"}:
continue
if name == "is_paid" and set(value) == {"1", "0"}:
continue
if name == "mute_status" and set(value) == {"active", "muted"}:
continue
has_active_filter = True
break
if has_active_filter:
queryset = f.qs
else:
queryset = (
base_queryset.exclude(
Q(Q(category__mute=True) & ~Q(category=None)) | Q(mute=True)
)
.exclude(account__in=request.user.untracked_accounts.all())
.exclude(account__is_asset=True)
)
account_data = calculate_account_totals(transactions_queryset=queryset.all())
account_percentages = calculate_percentage_distribution(account_data)
context = {
@@ -171,16 +249,41 @@ def monthly_account_summary(request, month: int, year: int):
@require_http_methods(["GET"])
def monthly_currency_summary(request, month: int, year: int):
# Base queryset with all required filters
base_queryset = (
Transaction.objects.filter(
reference_date__year=year,
reference_date__month=month,
)
.exclude(Q(Q(category__mute=True) & ~Q(category=None)) | Q(mute=True))
.exclude(account__in=request.user.untracked_accounts.all())
base_queryset = Transaction.objects.filter(
reference_date__year=year,
reference_date__month=month,
)
currency_data = calculate_currency_totals(base_queryset.all(), ignore_empty=True)
# Apply filters and check if any are active
f = TransactionsFilter(request.GET, queryset=base_queryset)
# Check if any filter has a non-default value
has_active_filter = False
if f.form.is_valid():
for name, value in f.form.cleaned_data.items():
if not value:
continue
if name == "type" and set(value) == {"IN", "EX"}:
continue
if name == "is_paid" and set(value) == {"1", "0"}:
continue
if name == "mute_status" and set(value) == {"active", "muted"}:
continue
has_active_filter = True
break
if has_active_filter:
queryset = f.qs
else:
queryset = (
base_queryset.exclude(
Q(Q(category__mute=True) & ~Q(category=None)) | Q(mute=True)
)
.exclude(account__in=request.user.untracked_accounts.all())
.exclude(account__is_asset=True)
)
currency_data = calculate_currency_totals(queryset.all(), ignore_empty=True)
currency_percentages = calculate_percentage_distribution(currency_data)
context = {
+6 -2
View File
@@ -365,7 +365,9 @@ def check_for_transaction_rules(
if processed_action.set_category:
value = simple.eval(processed_action.set_category)
if isinstance(value, int):
if value is None:
transaction.category = None
elif isinstance(value, int):
transaction.category = TransactionCategory.objects.get(id=value)
else:
transaction.category = TransactionCategory.objects.get(name=value)
@@ -458,7 +460,9 @@ def check_for_transaction_rules(
transaction.account = account
elif field == TransactionRuleAction.Field.category:
if isinstance(new_value, int):
if new_value is None:
transaction.category = None
elif isinstance(new_value, int):
category = TransactionCategory.objects.get(id=new_value)
transaction.category = category
elif isinstance(new_value, str):
+1
View File
@@ -0,0 +1 @@
+82
View File
@@ -0,0 +1,82 @@
from datetime import date
from decimal import Decimal
from unittest.mock import patch
from django.contrib.auth import get_user_model
from django.test import TransactionTestCase
from apps.accounts.models import Account
from apps.currencies.models import Currency
from apps.rules.models import TransactionRule, UpdateOrCreateTransactionRuleAction
from apps.rules.tasks import check_for_transaction_rules
from apps.transactions.models import Transaction
def run_check_for_transaction_rules_without_worker_wrapper(**kwargs):
task_func = check_for_transaction_rules.func
task_func = getattr(task_func, "__wrapped__", task_func)
return task_func(**kwargs)
class CheckForTransactionRulesTests(TransactionTestCase):
def setUp(self):
User = get_user_model()
self.user = User.objects.create_user(
email="rules@example.com",
password="testpass123",
)
self.currency = Currency.objects.create(
code="USD",
name="US Dollar",
decimal_places=2,
)
self.account = Account.objects.create(
name="Main Account",
currency=self.currency,
owner=self.user,
)
@patch("apps.rules.signals.check_for_transaction_rules.defer")
def test_update_or_create_action_can_clear_category_from_none_expression(
self, mock_defer
):
source_transaction = Transaction.objects.create(
account=self.account,
type=Transaction.Type.EXPENSE,
amount=Decimal("10.00"),
date=date(2026, 5, 4),
reference_date=date(2026, 5, 1),
description="Source without category",
category=None,
owner=self.user,
)
rule = TransactionRule.objects.create(
active=True,
on_create=False,
on_update=True,
name="Copy transaction",
trigger="True",
owner=self.user,
)
UpdateOrCreateTransactionRuleAction.objects.create(
rule=rule,
set_account="account_id",
set_type="'EX'",
set_date="date",
set_reference_date="reference_date",
set_amount="amount",
set_description="'Generated transaction'",
set_category="category_name",
)
run_check_for_transaction_rules_without_worker_wrapper(
instance_id=source_transaction.id,
user_id=self.user.id,
signal="transaction_updated",
)
generated_transaction = Transaction.objects.get(
description="Generated transaction"
)
self.assertIsNone(generated_transaction.category)
+50
View File
@@ -23,6 +23,11 @@ SITUACAO_CHOICES = (
("0", _("Projected")),
)
MUTE_STATUS_CHOICES = (
("active", _("Active")),
("muted", _("Muted")),
)
def content_filter(queryset, name, value):
queryset = queryset.filter(
@@ -78,6 +83,11 @@ class TransactionsFilter(django_filters.FilterSet):
choices=SITUACAO_CHOICES,
field_name="is_paid",
)
mute_status = django_filters.MultipleChoiceFilter(
choices=MUTE_STATUS_CHOICES,
method="filter_mute_status",
label=_("Mute Status"),
)
date_start = django_filters.DateFilter(
field_name="date",
lookup_expr="gte",
@@ -140,6 +150,9 @@ class TransactionsFilter(django_filters.FilterSet):
if data.get("is_paid") is None:
data.setlist("is_paid", ["1", "0"])
if data.get("mute_status") is None:
data.setlist("mute_status", ["active", "muted"])
super().__init__(data, *args, **kwargs)
self.form.helper = FormHelper()
@@ -155,6 +168,10 @@ class TransactionsFilter(django_filters.FilterSet):
"is_paid",
template="transactions/widgets/transaction_type_filter_buttons.html",
),
Field(
"mute_status",
template="transactions/widgets/transaction_type_filter_buttons.html",
),
Field("description"),
Row(Column("date_start"), Column("date_end")),
Row(
@@ -268,3 +285,36 @@ class TransactionsFilter(django_filters.FilterSet):
return queryset.filter(q).distinct()
return queryset
@staticmethod
def filter_mute_status(queryset, name, value):
from apps.common.middleware.thread_local import get_current_user
if not value:
return queryset
value = list(value)
# If both are selected, return all
if "active" in value and "muted" in value:
return queryset
user = get_current_user()
# Only Active selected: exclude muted transactions
if "active" in value:
return (
queryset.exclude(account__untracked_by=user)
.filter(
mute=False,
)
.filter(Q(category__mute=False) | Q(category__isnull=True))
)
# Only Muted selected: include only muted transactions
if "muted" in value:
return queryset.filter(
Q(account__untracked_by=user) | Q(category__mute=True) | Q(mute=True)
)
return queryset
+63
View File
@@ -5,6 +5,7 @@ from apps.common.fields.forms.dynamic_select import (
DynamicModelChoiceField,
DynamicModelMultipleChoiceField,
)
from apps.common.middleware.thread_local import get_current_user
from apps.common.widgets.crispy.daisyui import Switch
from apps.common.widgets.crispy.submit import NoClassSubmit
from apps.common.widgets.datepicker import AirDatePickerInput, AirMonthYearPickerInput
@@ -13,6 +14,7 @@ from apps.common.widgets.tom_select import TomSelect
from apps.rules.signals import transaction_created, transaction_updated
from apps.transactions.models import (
InstallmentPlan,
TransactionAttachment,
QuickTransaction,
RecurringTransaction,
Transaction,
@@ -35,6 +37,22 @@ from django.db.models import Q
from django.utils.translation import gettext_lazy as _
class MultipleFileInput(forms.ClearableFileInput):
allow_multiple_selected = True
class MultipleFileField(forms.FileField):
widget = MultipleFileInput
def clean(self, data, initial=None):
single_file_clean = super().clean
if isinstance(data, (list, tuple)):
return [single_file_clean(file, initial) for file in data]
if data:
return [single_file_clean(data, initial)]
return []
class TransactionForm(forms.ModelForm):
category = DynamicModelChoiceField(
create_field="name",
@@ -116,6 +134,9 @@ class TransactionForm(forms.ModelForm):
self.fields["account"].queryset = Account.objects.filter(
is_archived=False,
)
user_settings = get_current_user().settings
if user_settings.default_account:
self.fields["account"].initial = user_settings.default_account
self.fields["category"].queryset = TransactionCategory.objects.filter(
active=True
@@ -243,6 +264,41 @@ class TransactionForm(forms.ModelForm):
return instance
class TransactionAttachmentForm(forms.Form):
attachments = MultipleFileField(
required=True,
label=_("Attachments"),
help_text=_("Files are private and only visible to users with access to this transaction."),
)
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(
"attachments",
FormActions(
NoClassSubmit("submit", _("Upload"), css_class="btn btn-primary"),
),
)
def save(self, transaction, uploaded_by):
created = []
for attachment in self.cleaned_data.get("attachments") or []:
created.append(
TransactionAttachment.objects.create(
transaction=transaction,
file=attachment,
original_name=attachment.name,
content_type=getattr(attachment, "content_type", ""),
size=attachment.size,
uploaded_by=uploaded_by,
)
)
return created
class QuickTransactionForm(forms.ModelForm):
category = DynamicModelChoiceField(
create_field="name",
@@ -768,6 +824,9 @@ class InstallmentPlanForm(forms.ModelForm):
).distinct()
else:
self.fields["account"].queryset = Account.objects.filter(is_archived=False)
user_settings = get_current_user().settings
if user_settings.default_account:
self.fields["account"].initial = user_settings.default_account
self.fields["category"].queryset = TransactionCategory.objects.filter(
active=True
@@ -1010,6 +1069,10 @@ class RecurringTransactionForm(forms.ModelForm):
).distinct()
else:
self.fields["account"].queryset = Account.objects.filter(is_archived=False)
user_settings = get_current_user().settings
if user_settings.default_account:
self.fields["account"].initial = user_settings.default_account
self.fields["category"].queryset = TransactionCategory.objects.filter(
active=True
@@ -0,0 +1,38 @@
# Generated by Django 5.2.13 on 2026-06-06 02:34
import apps.transactions.models
import apps.transactions.storage
import django.db.models.deletion
import uuid
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('transactions', '0048_recurringtransaction_keep_at_most'),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.CreateModel(
name='TransactionAttachment',
fields=[
('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)),
('file', models.FileField(storage=apps.transactions.storage.PrivateMediaStorage(), upload_to=apps.transactions.models.transaction_attachment_path, verbose_name='File')),
('original_name', models.CharField(max_length=255, verbose_name='Original Name')),
('content_type', models.CharField(blank=True, max_length=255, verbose_name='Content Type')),
('size', models.PositiveBigIntegerField(default=0, verbose_name='Size')),
('created_at', models.DateTimeField(auto_now_add=True)),
('transaction', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='attachments', to='transactions.transaction', verbose_name='Transaction')),
('uploaded_by', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='transaction_attachments', to=settings.AUTH_USER_MODEL, verbose_name='Uploaded By')),
],
options={
'verbose_name': 'Transaction Attachment',
'verbose_name_plural': 'Transaction Attachments',
'db_table': 'transaction_attachments',
'ordering': ['-created_at', 'original_name'],
},
),
]
+68 -1
View File
@@ -1,6 +1,8 @@
import decimal
import logging
import uuid
from copy import deepcopy
from pathlib import Path
from apps.common.fields.month_year import MonthYearModelField
from apps.common.functions.decimals import truncate_decimal
@@ -13,13 +15,15 @@ from apps.common.models import (
)
from apps.common.templatetags.decimal import drop_trailing_zeros, localize_number
from apps.currencies.utils.convert import convert
from apps.transactions.storage import PrivateMediaStorage
from apps.transactions.validators import validate_decimal_places, validate_non_negative
from dateutil.relativedelta import relativedelta
from django.conf import settings
from django.core.validators import MinValueValidator
from django.db import models, transaction
from django.db.models import Q
from django.dispatch import Signal
from django.db.models.signals import post_delete
from django.dispatch import Signal, receiver
from django.template.defaultfilters import date
from django.utils import timezone
from django.utils.translation import gettext_lazy as _
@@ -32,6 +36,11 @@ transaction_updated = Signal()
transaction_deleted = Signal()
def transaction_attachment_path(instance, filename):
extension = Path(filename).suffix
return f"transaction_attachments/{instance.transaction_id}/{instance.id}{extension}"
class SoftDeleteQuerySet(models.QuerySet):
@staticmethod
def _emit_signals(instances, created=False, old_data=None):
@@ -383,6 +392,10 @@ class Transaction(OwnedObject):
def clean(self):
super().clean()
# Convert empty internal_id to None to allow multiple "empty" values with unique constraint
if self.internal_id == "":
self.internal_id = None
# Only process amount and reference_date if account exists
# If account is missing, Django's required field validation will handle it
try:
@@ -522,6 +535,60 @@ class Transaction(OwnedObject):
return new_obj
class TransactionAttachment(models.Model):
id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False)
transaction = models.ForeignKey(
Transaction,
on_delete=models.CASCADE,
related_name="attachments",
verbose_name=_("Transaction"),
)
file = models.FileField(
upload_to=transaction_attachment_path,
storage=PrivateMediaStorage(),
verbose_name=_("File"),
)
original_name = models.CharField(max_length=255, verbose_name=_("Original Name"))
content_type = models.CharField(
max_length=255, blank=True, verbose_name=_("Content Type")
)
size = models.PositiveBigIntegerField(default=0, verbose_name=_("Size"))
uploaded_by = models.ForeignKey(
settings.AUTH_USER_MODEL,
on_delete=models.PROTECT,
related_name="transaction_attachments",
verbose_name=_("Uploaded By"),
)
created_at = models.DateTimeField(auto_now_add=True)
class Meta:
verbose_name = _("Transaction Attachment")
verbose_name_plural = _("Transaction Attachments")
db_table = "transaction_attachments"
ordering = ["-created_at", "original_name"]
def save(self, *args, **kwargs):
if self.file:
if not self.original_name:
self.original_name = Path(self.file.name).name
if not self.size:
self.size = self.file.size
if not self.content_type:
self.content_type = getattr(self.file.file, "content_type", "")
super().save(*args, **kwargs)
def __str__(self):
return self.original_name
@receiver(post_delete, sender=TransactionAttachment)
def delete_transaction_attachment_file(sender, instance, **kwargs):
if not instance.file.name:
return
storage = instance.file.storage
if storage.exists(instance.file.name):
storage.delete(instance.file.name)
class InstallmentPlan(models.Model):
class Recurrence(models.TextChoices):
+9
View File
@@ -0,0 +1,9 @@
from django.conf import settings
from django.core.files.storage import FileSystemStorage
class PrivateMediaStorage(FileSystemStorage):
def __init__(self, *args, **kwargs):
kwargs.setdefault("location", settings.ATTACHMENT_MEDIA_ROOT)
kwargs.setdefault("base_url", None)
super().__init__(*args, **kwargs)
@@ -0,0 +1,219 @@
import shutil
import tempfile
from datetime import date
from decimal import Decimal
from pathlib import Path
from apps.accounts.models import Account
from apps.common.middleware.thread_local import delete_current_user, write_current_user
from apps.currencies.models import Currency
from apps.transactions.models import Transaction, TransactionAttachment
from django.contrib.auth import get_user_model
from django.core.files.uploadedfile import SimpleUploadedFile
from django.test import TestCase, override_settings
from django.urls import reverse
@override_settings(
STORAGES={
"default": {"BACKEND": "django.core.files.storage.FileSystemStorage"},
"staticfiles": {
"BACKEND": "django.contrib.staticfiles.storage.StaticFilesStorage"
},
},
WHITENOISE_AUTOREFRESH=True,
)
class TransactionAttachmentTests(TestCase):
def setUp(self):
self.attachment_media_root = tempfile.mkdtemp()
self.override_private_media = override_settings(
ATTACHMENT_MEDIA_ROOT=self.attachment_media_root
)
self.override_private_media.enable()
self.addCleanup(self.override_private_media.disable)
self.addCleanup(shutil.rmtree, self.attachment_media_root, ignore_errors=True)
self.attachment_storage = TransactionAttachment._meta.get_field("file").storage
self.original_storage_location = self.attachment_storage._location
self.attachment_storage._location = self.attachment_media_root
self.attachment_storage.__dict__.pop("base_location", None)
self.attachment_storage.__dict__.pop("location", None)
self.addCleanup(self.restore_attachment_storage)
User = get_user_model()
self.user1 = User.objects.create_user(
email="user1@test.com", password="testpass123"
)
self.user2 = User.objects.create_user(
email="user2@test.com", password="testpass123"
)
self.currency = Currency.objects.create(
code="USD", name="US Dollar", decimal_places=2, prefix="$ "
)
self.user1_account = Account.all_objects.create(
name="User1 Account", currency=self.currency, owner=self.user1
)
self.user2_account = Account.all_objects.create(
name="User2 Account", currency=self.currency, owner=self.user2
)
self.transaction = Transaction.userless_all_objects.create(
account=self.user1_account,
type=Transaction.Type.EXPENSE,
amount=Decimal("12.34"),
is_paid=True,
date=date(2026, 6, 5),
description="Receipt transaction",
owner=self.user1,
)
self.other_transaction = Transaction.userless_all_objects.create(
account=self.user2_account,
type=Transaction.Type.EXPENSE,
amount=Decimal("56.78"),
is_paid=True,
date=date(2026, 6, 5),
description="Other receipt transaction",
owner=self.user2,
)
def restore_attachment_storage(self):
self.attachment_storage._location = self.original_storage_location
self.attachment_storage.__dict__.pop("base_location", None)
self.attachment_storage.__dict__.pop("location", None)
def test_attachment_uses_uuid_and_preserves_original_download_name(self):
attachment = TransactionAttachment.objects.create(
transaction=self.transaction,
file=SimpleUploadedFile(
"receipt June.pdf", b"receipt bytes", content_type="application/pdf"
),
uploaded_by=self.user1,
)
self.assertEqual(attachment.original_name, "receipt June.pdf")
self.assertNotIn("receipt June.pdf", attachment.file.name)
self.client.force_login(self.user1)
response = self.client.get(
reverse(
"transaction_attachment_download",
kwargs={"attachment_id": attachment.id},
)
)
self.assertEqual(response.status_code, 200)
self.assertEqual(b"".join(response.streaming_content), b"receipt bytes")
self.assertIn('filename="receipt June.pdf"', response["Content-Disposition"])
def test_user_without_transaction_access_cannot_download_attachment(self):
attachment = TransactionAttachment.objects.create(
transaction=self.other_transaction,
file=SimpleUploadedFile("private.txt", b"private"),
uploaded_by=self.user2,
)
self.client.force_login(self.user1)
response = self.client.get(
reverse(
"transaction_attachment_download",
kwargs={"attachment_id": attachment.id},
)
)
self.assertEqual(response.status_code, 404)
def test_attachment_button_lives_in_transaction_hover_toolbar(self):
template = Path("templates/cotton/transaction/item.html").read_text()
before_toolbar, toolbar = template.split("{# Item actions#}", 1)
self.assertNotIn("transaction_attachments", before_toolbar)
self.assertLess(
toolbar.index("transaction_edit"),
toolbar.index("transaction_attachments"),
)
self.assertLess(
toolbar.index("transaction_attachments"),
toolbar.index("transaction_delete"),
)
def test_transaction_edit_form_does_not_include_attachment_upload(self):
self.client.force_login(self.user1)
response = self.client.get(
reverse("transaction_edit", kwargs={"transaction_id": self.transaction.id}),
HTTP_HX_REQUEST="true",
)
self.assertEqual(response.status_code, 200)
self.assertNotContains(response, "multipart/form-data")
self.assertNotContains(response, 'type="file"')
def test_attachment_management_uploads_multiple_attachments(self):
self.client.force_login(self.user1)
response = self.client.post(
reverse(
"transaction_attachments",
kwargs={"transaction_id": self.transaction.id},
),
{
"attachments": [
SimpleUploadedFile("first.txt", b"first"),
SimpleUploadedFile("second.txt", b"second"),
],
},
HTTP_HX_REQUEST="true",
)
self.assertEqual(response.status_code, 200)
self.assertContains(response, "first.txt")
self.assertContains(response, "second.txt")
self.assertEqual(self.transaction.attachments.count(), 2)
def test_attachment_delete_returns_refreshed_attachment_list(self):
attachment = TransactionAttachment.objects.create(
transaction=self.transaction,
file=SimpleUploadedFile("delete-me.txt", b"delete"),
uploaded_by=self.user1,
)
self.client.force_login(self.user1)
response = self.client.delete(
reverse(
"transaction_attachment_delete",
kwargs={"attachment_id": attachment.id},
),
HTTP_HX_REQUEST="true",
)
self.assertEqual(response.status_code, 200)
self.assertNotContains(response, "delete-me.txt")
self.assertContains(response, "No attachments yet")
self.assertFalse(
TransactionAttachment.objects.filter(id=attachment.id).exists()
)
def test_hard_deleting_transaction_deletes_attachment_files(self):
attachment = TransactionAttachment.objects.create(
transaction=self.transaction,
file=SimpleUploadedFile("hard-delete.txt", b"delete with transaction"),
uploaded_by=self.user1,
)
file_path = Path(attachment.file.path)
self.assertTrue(file_path.exists())
write_current_user(self.user1)
self.addCleanup(delete_current_user)
self.transaction.delete()
self.assertTrue(file_path.exists())
self.assertTrue(TransactionAttachment.objects.filter(id=attachment.id).exists())
self.transaction.delete()
self.assertFalse(file_path.exists())
self.assertFalse(
TransactionAttachment.objects.filter(id=attachment.id).exists()
)
@@ -125,6 +125,70 @@ class TransactionTests(TestCase):
datetime.datetime(day=1, month=2, year=2000).date(),
)
def test_empty_internal_id_converts_to_none(self):
"""Test that empty string internal_id is converted to None"""
transaction = Transaction.objects.create(
account=self.account,
type=Transaction.Type.EXPENSE,
date=timezone.now().date(),
amount=Decimal("100.00"),
description="Test transaction",
internal_id="", # Empty string should become None
)
self.assertIsNone(transaction.internal_id)
def test_unique_internal_id_works(self):
"""Test that unique non-empty internal_id values work correctly"""
transaction1 = Transaction.objects.create(
account=self.account,
type=Transaction.Type.EXPENSE,
date=timezone.now().date(),
amount=Decimal("100.00"),
description="Test transaction 1",
internal_id="unique-id-123",
)
transaction2 = Transaction.objects.create(
account=self.account,
type=Transaction.Type.EXPENSE,
date=timezone.now().date(),
amount=Decimal("100.00"),
description="Test transaction 2",
internal_id="unique-id-456",
)
self.assertEqual(transaction1.internal_id, "unique-id-123")
self.assertEqual(transaction2.internal_id, "unique-id-456")
def test_multiple_transactions_with_empty_internal_id(self):
"""Test that multiple transactions can have empty/None internal_id"""
transaction1 = Transaction.objects.create(
account=self.account,
type=Transaction.Type.EXPENSE,
date=timezone.now().date(),
amount=Decimal("100.00"),
description="Test transaction 1",
internal_id="",
)
transaction2 = Transaction.objects.create(
account=self.account,
type=Transaction.Type.EXPENSE,
date=timezone.now().date(),
amount=Decimal("100.00"),
description="Test transaction 2",
internal_id="",
)
transaction3 = Transaction.objects.create(
account=self.account,
type=Transaction.Type.EXPENSE,
date=timezone.now().date(),
amount=Decimal("100.00"),
description="Test transaction 3",
internal_id=None,
)
# All should be saved successfully with None internal_id
self.assertIsNone(transaction1.internal_id)
self.assertIsNone(transaction2.internal_id)
self.assertIsNone(transaction3.internal_id)
class InstallmentPlanTests(TestCase):
def setUp(self):
+20
View File
@@ -81,6 +81,26 @@ urlpatterns = [
views.transaction_move_to_today,
name="transaction_move_to_today",
),
path(
"transaction/<int:transaction_id>/attachments/",
views.transaction_attachments,
name="transaction_attachments",
),
path(
"transaction/<int:transaction_id>/attachments/list/",
views.transaction_attachments_list,
name="transaction_attachments_list",
),
path(
"transaction/attachments/<uuid:attachment_id>/download/",
views.transaction_attachment_download,
name="transaction_attachment_download",
),
path(
"transaction/attachments/<uuid:attachment_id>/delete/",
views.transaction_attachment_delete,
name="transaction_attachment_delete",
),
path(
"transaction/<int:transaction_id>/delete/",
views.transaction_delete,
+2 -2
View File
@@ -35,7 +35,7 @@ def categories_list(request):
@login_required
@require_http_methods(["GET"])
def categories_table_active(request):
categories = TransactionCategory.objects.filter(active=True).order_by("id")
categories = TransactionCategory.objects.filter(active=True).order_by("name")
return render(
request,
"categories/fragments/table.html",
@@ -47,7 +47,7 @@ def categories_table_active(request):
@login_required
@require_http_methods(["GET"])
def categories_table_archived(request):
categories = TransactionCategory.objects.filter(active=False).order_by("id")
categories = TransactionCategory.objects.filter(active=False).order_by("name")
return render(
request,
"categories/fragments/table.html",
+2 -2
View File
@@ -35,7 +35,7 @@ def entities_list(request):
@login_required
@require_http_methods(["GET"])
def entities_table_active(request):
entities = TransactionEntity.objects.filter(active=True).order_by("id")
entities = TransactionEntity.objects.filter(active=True).order_by("name")
return render(
request,
"entities/fragments/table.html",
@@ -47,7 +47,7 @@ def entities_table_active(request):
@login_required
@require_http_methods(["GET"])
def entities_table_archived(request):
entities = TransactionEntity.objects.filter(active=False).order_by("id")
entities = TransactionEntity.objects.filter(active=False).order_by("name")
return render(
request,
"entities/fragments/table.html",
+2 -2
View File
@@ -35,7 +35,7 @@ def tags_list(request):
@login_required
@require_http_methods(["GET"])
def tags_table_active(request):
tags = TransactionTag.objects.filter(active=True).order_by("id")
tags = TransactionTag.objects.filter(active=True).order_by("name")
return render(
request,
"tags/fragments/table.html",
@@ -47,7 +47,7 @@ def tags_table_active(request):
@login_required
@require_http_methods(["GET"])
def tags_table_archived(request):
tags = TransactionTag.objects.filter(active=False).order_by("id")
tags = TransactionTag.objects.filter(active=False).order_by("name")
return render(
request,
"tags/fragments/table.html",
+138 -22
View File
@@ -1,32 +1,120 @@
import datetime
from copy import deepcopy
from dateutil.relativedelta import relativedelta
from django.contrib import messages
from django.contrib.auth.decorators import login_required
from django.core.paginator import Paginator
from django.db.models import Q, When, Case, Value, IntegerField
from django.http import HttpResponse, JsonResponse
from django.shortcuts import render, get_object_or_404
from django.utils import timezone
from django.utils.translation import gettext_lazy as _, ngettext_lazy
from django.views.decorators.http import require_http_methods
from apps.common.decorators.demo import disabled_on_demo
from apps.common.decorators.htmx import only_htmx
from apps.rules.signals import transaction_created, transaction_updated
from apps.transactions.filters import TransactionsFilter
from apps.transactions.forms import (
BulkEditTransactionForm,
TransactionAttachmentForm,
TransactionForm,
TransferForm,
BulkEditTransactionForm,
)
from apps.transactions.models import Transaction
from apps.transactions.models import Transaction, TransactionAttachment
from apps.transactions.utils.calculations import (
calculate_currency_totals,
calculate_account_totals,
calculate_currency_totals,
calculate_percentage_distribution,
)
from apps.transactions.utils.default_ordering import default_order
from dateutil.relativedelta import relativedelta
from django.contrib import messages
from django.contrib.auth.decorators import login_required
from django.core.paginator import Paginator
from django.db.models import Case, IntegerField, Q, Value, When
from django.http import FileResponse, Http404, HttpResponse, JsonResponse
from django.shortcuts import get_object_or_404, render
from django.utils import timezone
from django.utils.translation import gettext_lazy as _
from django.utils.translation import ngettext_lazy
from django.views.decorators.http import require_http_methods
def _get_accessible_transaction_or_404(transaction_id):
return get_object_or_404(Transaction.objects, id=transaction_id)
def _get_accessible_attachment_or_404(attachment_id):
attachment = get_object_or_404(
TransactionAttachment.objects.select_related("transaction"),
id=attachment_id,
)
if not Transaction.objects.filter(id=attachment.transaction_id).exists():
raise Http404()
return attachment
@only_htmx
@login_required
@disabled_on_demo
@require_http_methods(["GET", "POST"])
def transaction_attachments(request, transaction_id):
transaction = _get_accessible_transaction_or_404(transaction_id)
if request.method == "POST":
form = TransactionAttachmentForm(request.POST, request.FILES)
if form.is_valid():
form.save(transaction=transaction, uploaded_by=request.user)
messages.success(request, _("Attachment uploaded successfully"))
form = TransactionAttachmentForm()
else:
form = TransactionAttachmentForm()
response = render(
request,
"transactions/fragments/attachments_manage.html",
{"form": form, "transaction": transaction},
)
response["HX-Trigger"] = "toasts, updated"
return response
@only_htmx
@login_required
@disabled_on_demo
@require_http_methods(["GET"])
def transaction_attachments_list(request, transaction_id):
transaction = _get_accessible_transaction_or_404(transaction_id)
return render(
request,
"transactions/fragments/attachments.html",
{"transaction": transaction},
)
@login_required
@disabled_on_demo
@require_http_methods(["GET"])
def transaction_attachment_download(request, attachment_id):
attachment = _get_accessible_attachment_or_404(attachment_id)
return FileResponse(
attachment.file.open("rb"),
as_attachment=False,
filename=attachment.original_name,
content_type=attachment.content_type or "application/octet-stream",
)
@only_htmx
@login_required
@disabled_on_demo
@require_http_methods(["DELETE"])
def transaction_attachment_delete(request, attachment_id):
attachment = _get_accessible_attachment_or_404(attachment_id)
transaction = attachment.transaction
attachment.file.delete(save=False)
attachment.delete()
messages.success(request, _("Attachment deleted successfully"))
response = render(
request,
"transactions/fragments/attachments.html",
{"transaction": transaction},
)
response["HX-Trigger"] = "toasts, updated"
return response
@only_htmx
@@ -152,7 +240,9 @@ def transaction_simple_add(request):
date_param = request.GET.get("date")
if date_param:
try:
initial_data["date"] = datetime.datetime.strptime(date_param, "%Y-%m-%d").date()
initial_data["date"] = datetime.datetime.strptime(
date_param, "%Y-%m-%d"
).date()
except ValueError:
pass
@@ -160,7 +250,9 @@ def transaction_simple_add(request):
reference_date_param = request.GET.get("reference_date")
if reference_date_param:
try:
initial_data["reference_date"] = datetime.datetime.strptime(reference_date_param, "%Y-%m-%d").date()
initial_data["reference_date"] = datetime.datetime.strptime(
reference_date_param, "%Y-%m-%d"
).date()
except ValueError:
pass
@@ -172,7 +264,10 @@ def transaction_simple_add(request):
except (ValueError, TypeError):
# Try to find by name
from apps.accounts.models import Account
account = Account.objects.filter(name__iexact=account_param, is_archived=False).first()
account = Account.objects.filter(
name__iexact=account_param, is_archived=False
).first()
if account:
initial_data["account"] = account.pk
@@ -207,7 +302,10 @@ def transaction_simple_add(request):
except (ValueError, TypeError):
# Try to find by name
from apps.transactions.models import TransactionCategory
category = TransactionCategory.objects.filter(name__iexact=category_param, active=True).first()
category = TransactionCategory.objects.filter(
name__iexact=category_param, active=True
).first()
if category:
initial_data["category"] = category.pk
@@ -457,7 +555,7 @@ def transaction_pay(request, transaction_id):
context={"transaction": transaction, **request.GET},
)
response.headers["HX-Trigger"] = (
f'{"paid" if new_is_paid else "unpaid"}, selective_update'
f"{'paid' if new_is_paid else 'unpaid'}, selective_update"
)
return response
@@ -552,6 +650,8 @@ def transaction_all_list(request):
if order != request.session.get("all_transactions_order", "default"):
request.session["all_transactions_order"] = order
today = timezone.localdate(timezone.now())
transactions = Transaction.objects.prefetch_related(
"account",
"account__group",
@@ -565,12 +665,27 @@ def transaction_all_list(request):
"dca_income_entries",
).all()
transactions = default_order(transactions, order=order)
f = TransactionsFilter(request.GET, queryset=transactions)
# Late transactions: date < today and is_paid = False (only shown for default ordering on first page)
late_transactions = None
page_number = request.GET.get("page", 1)
paginator = Paginator(f.qs, 100)
if order == "default" and str(page_number) == "1":
late_transactions = f.qs.filter(
date__lt=today,
is_paid=False,
).order_by("date", "id")
# Exclude late transactions from the main paginated list
main_transactions = f.qs.exclude(
date__lt=today,
is_paid=False,
)
else:
main_transactions = f.qs
main_transactions = default_order(main_transactions, order=order)
paginator = Paginator(main_transactions, 100)
page_obj = paginator.get_page(page_number)
return render(
@@ -579,6 +694,7 @@ def transaction_all_list(request):
{
"page_obj": page_obj,
"paginator": paginator,
"late_transactions": late_transactions,
},
)
+75
View File
@@ -0,0 +1,75 @@
import logging
from allauth.socialaccount.adapter import DefaultSocialAccountAdapter
from django.contrib.auth import get_user_model
User = get_user_model()
logger = logging.getLogger(__name__)
class AutoConnectSocialAccountAdapter(DefaultSocialAccountAdapter):
"""
Custom adapter to automatically connect social accounts to existing users
with the same email address.
SECURITY WARNING:
This adapter automatically connects OIDC accounts to existing local accounts
based on email matching.
If your OIDC provider allows unverified emails, this could lead to
ACCOUNT TAKEOVER attacks where an attacker creates an OIDC account
with someone else's email and gains access to their account.
"""
def pre_social_login(self, request, sociallogin):
"""
Invoked just after a user successfully authenticates via a
social provider, but before the login is actually processed.
If a user with the same email already exists, connect the social
account to that existing user instead of creating a new account.
"""
# If the social account is already connected to a user, do nothing
if sociallogin.is_existing:
return
# Check if we have an email from the social provider
if not sociallogin.email_addresses:
logger.warning(
"OIDC login attempted without email address. "
f"Provider: {sociallogin.account.provider}"
)
return
# Get the email from the social login
email = sociallogin.email_addresses[0].email.lower()
# Try to find an existing user with this email
try:
user = User.objects.get(email__iexact=email)
# Log this connection for security audit trail
logger.info(
f"Auto-connecting OIDC account to existing user. "
f"Email: {email}, Provider: {sociallogin.account.provider}, "
f"User ID: {user.id}"
)
# Connect the social account to the existing user
sociallogin.connect(request, user)
except User.DoesNotExist:
# No user with this email exists, proceed with normal signup flow
logger.debug(
f"No existing user found for email {email}. "
"Proceeding with new account creation."
)
pass
except User.MultipleObjectsReturned:
# Multiple users with the same email (shouldn't happen with unique constraint)
logger.error(
f"Multiple users found with email {email}. "
"This should not happen with unique constraint. "
"Blocking auto-connect."
)
# Let the default behavior handle this
pass
+37 -1
View File
@@ -4,13 +4,19 @@ from django.contrib.auth.forms import (
UserCreationForm,
AdminPasswordChangeForm,
)
from django.utils import timezone
from django.utils.translation import gettext_lazy as _
from django.contrib import admin
from django.contrib.auth.admin import GroupAdmin as BaseGroupAdmin
from django.contrib.auth.admin import UserAdmin as BaseUserAdmin
from django.contrib.auth.models import Group
from apps.users.models import User, UserSettings
from apps.users.models import APIToken, User, UserSettings
@admin.action(description=_("Revoke selected API tokens"))
def revoke_api_tokens(modeladmin, request, queryset):
queryset.update(revoked_at=timezone.now())
admin.site.unregister(Group)
@@ -77,3 +83,33 @@ class GroupAdmin(BaseGroupAdmin, ModelAdmin):
admin.site.register(UserSettings)
@admin.register(APIToken)
class APITokenAdmin(admin.ModelAdmin):
actions = [revoke_api_tokens]
list_display = (
"name",
"user",
"token_key",
"created_at",
"last_used_at",
"expires_at",
"revoked_at",
)
search_fields = ("name", "user__email", "token_key")
# Never expose the secret hash in the form; it must not be editable.
exclude = ("token_hash",)
readonly_fields = (
"user",
"name",
"token_key",
"created_at",
"updated_at",
"last_used_at",
"expires_at",
"revoked_at",
)
def has_add_permission(self, request):
return False
+70
View File
@@ -1,6 +1,11 @@
from datetime import timedelta
from apps.common.middleware.thread_local import get_current_user
from apps.users.models import APIToken
from apps.common.widgets.crispy.submit import NoClassSubmit
from apps.common.widgets.tom_select import TomSelect
from apps.users.models import UserSettings
from apps.accounts.models import Account
from crispy_forms.bootstrap import (
FormActions,
)
@@ -14,6 +19,7 @@ from django.contrib.auth.forms import (
UsernameField,
)
from django.db import transaction
from django.utils import timezone
from django.utils.translation import gettext_lazy as _
@@ -116,6 +122,15 @@ class UserSettingsForm(forms.ModelForm):
label=_("Number Format"),
)
default_account = forms.ModelChoiceField(
queryset=Account.objects.filter(
is_archived=False,
),
label=_("Default Account"),
widget=TomSelect(clear_button=False, group_by="group"),
required=False,
)
class Meta:
model = UserSettings
fields = [
@@ -126,11 +141,19 @@ class UserSettingsForm(forms.ModelForm):
"datetime_format",
"number_format",
"volume",
"default_account",
]
widgets = {
"default_account": TomSelect(clear_button=False, group_by="group"),
}
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self.fields["default_account"].queryset = Account.objects.filter(
is_archived=False,
)
self.helper = FormHelper()
self.helper.form_tag = False
self.helper.form_method = "post"
@@ -143,6 +166,7 @@ class UserSettingsForm(forms.ModelForm):
"number_format",
HTML('<hr class="hr my-3" />'),
"start_page",
"default_account",
HTML('<hr class="hr my-3" />'),
"volume",
FormActions(
@@ -407,3 +431,49 @@ class UserAddForm(UserCreationForm):
if commit:
user.save()
return user
class APITokenCreateForm(forms.Form):
name = forms.CharField(
max_length=255,
label=_("Token name"),
help_text=_(
"Use a descriptive name such as n8n, Home Assistant, or backup job."
),
)
expires_in_days = forms.IntegerField(
required=False,
min_value=1,
label=_("Expires in days"),
help_text=_("Leave empty for a non-expiring token."),
)
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",
"expires_in_days",
FormActions(
NoClassSubmit(
"submit",
_("Create token"),
css_class="btn btn-primary",
),
),
)
def save(self, user):
expires_in_days = self.cleaned_data.get("expires_in_days")
expires_at = None
if expires_in_days:
expires_at = timezone.now() + timedelta(days=expires_in_days)
return APIToken.objects.create_token(
user=user,
name=self.cleaned_data["name"],
expires_at=expires_at,
)
@@ -0,0 +1,25 @@
# Generated by Django 5.2.9 on 2026-02-15 21:35
import django.db.models.deletion
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("accounts", "0016_account_untracked_by"),
("users", "0023_alter_usersettings_timezone"),
]
operations = [
migrations.AddField(
model_name="usersettings",
name="default_account",
field=models.ForeignKey(
blank=True,
null=True,
on_delete=django.db.models.deletion.SET_NULL,
to="accounts.account",
verbose_name="Default account",
),
),
]
@@ -0,0 +1,20 @@
# Generated by Django 5.2.9 on 2026-02-16 01:32
import django.db.models.deletion
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('accounts', '0016_account_untracked_by'),
('users', '0024_usersettings_default_account'),
]
operations = [
migrations.AlterField(
model_name='usersettings',
name='default_account',
field=models.ForeignKey(blank=True, help_text='Selects the account by default when creating new transactions', null=True, on_delete=django.db.models.deletion.SET_NULL, to='accounts.account', verbose_name='Default account'),
),
]
@@ -0,0 +1,36 @@
# Generated by Django 5.2.15 on 2026-06-24 09:21
import django.db.models.deletion
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('users', '0025_alter_usersettings_default_account'),
]
operations = [
migrations.CreateModel(
name='APIToken',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('name', models.CharField(max_length=255, verbose_name='Name')),
('token_key', models.CharField(db_index=True, max_length=16, unique=True, verbose_name='Token key')),
('token_hash', models.CharField(max_length=255, verbose_name='Token hash')),
('last_used_at', models.DateTimeField(blank=True, null=True, verbose_name='Last used at')),
('expires_at', models.DateTimeField(blank=True, null=True, verbose_name='Expires at')),
('revoked_at', models.DateTimeField(blank=True, null=True, verbose_name='Revoked at')),
('created_at', models.DateTimeField(auto_now_add=True, verbose_name='Created at')),
('updated_at', models.DateTimeField(auto_now=True, verbose_name='Updated at')),
('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='api_tokens', to=settings.AUTH_USER_MODEL, verbose_name='User')),
],
options={
'verbose_name': 'API token',
'verbose_name_plural': 'API tokens',
'ordering': ['-created_at'],
'indexes': [models.Index(fields=['user', 'revoked_at'], name='users_apito_user_id_73edec_idx'), models.Index(fields=['expires_at'], name='users_apito_expires_2b737c_idx')],
},
),
]
File diff suppressed because one or more lines are too long
+127 -2
View File
@@ -1,9 +1,14 @@
import hashlib
import hmac
import secrets
import pytz
from django.conf import settings
from django.contrib.auth import get_user_model
from django.contrib.auth.models import AbstractUser, Group
from django.core.validators import MaxValueValidator, MinValueValidator
from django.db import models
from django.db import IntegrityError, models, transaction
from django.utils import timezone
from django.utils.translation import gettext_lazy as _
from apps.users.managers import UserManager
@@ -410,7 +415,7 @@ timezones = [
("Pacific/Galapagos", "Pacific/Galapagos"),
("Pacific/Gambier", "Pacific/Gambier"),
("Pacific/Guadalcanal", "Pacific/Guadalcanal"),
("P2025-06-29T01:43:14.671389745Z acific/Guam", "Pacific/Guam"),
("Pacific/Guam", "Pacific/Guam"),
("Pacific/Honolulu", "Pacific/Honolulu"),
("Pacific/Kanton", "Pacific/Kanton"),
("Pacific/Kiritimati", "Pacific/Kiritimati"),
@@ -510,9 +515,129 @@ class UserSettings(models.Model):
default=StartPage.MONTHLY,
verbose_name=_("Start page"),
)
default_account = models.ForeignKey(
"accounts.Account",
on_delete=models.SET_NULL,
verbose_name=_("Default account"),
help_text=_("Selects the account by default when creating new transactions"),
blank=True,
null=True,
)
def __str__(self):
return f"{self.user.email}'s settings"
def clean(self):
super().clean()
class APITokenManager(models.Manager):
def create_token(self, *, user, name: str, expires_at=None):
token_secret = secrets.token_urlsafe(32)
token_hash = self.model.hash_secret(token_secret)
# token_key is unique; the pre-check in generate_token_key still leaves a
# tiny race window under concurrency, so retry on the unique-constraint
# violation with a fresh key instead of failing the request.
last_error = None
for _ in range(5):
token = self.model(
user=user,
name=name,
token_key=self.model.generate_token_key(),
token_hash=token_hash,
expires_at=expires_at,
)
token.full_clean()
try:
with transaction.atomic():
token.save()
except IntegrityError as exc:
last_error = exc
continue
return token, token.build_raw_token(token_secret)
raise last_error
class APIToken(models.Model):
TOKEN_PREFIX = "wygiwyh_pat_"
user = models.ForeignKey(
settings.AUTH_USER_MODEL,
on_delete=models.CASCADE,
related_name="api_tokens",
verbose_name=_("User"),
)
name = models.CharField(max_length=255, verbose_name=_("Name"))
token_key = models.CharField(
max_length=16,
unique=True,
db_index=True,
verbose_name=_("Token key"),
)
token_hash = models.CharField(max_length=255, verbose_name=_("Token hash"))
last_used_at = models.DateTimeField(
null=True,
blank=True,
verbose_name=_("Last used at"),
)
expires_at = models.DateTimeField(
null=True,
blank=True,
verbose_name=_("Expires at"),
)
revoked_at = models.DateTimeField(
null=True,
blank=True,
verbose_name=_("Revoked at"),
)
created_at = models.DateTimeField(auto_now_add=True, verbose_name=_("Created at"))
updated_at = models.DateTimeField(auto_now=True, verbose_name=_("Updated at"))
objects = APITokenManager()
class Meta:
indexes = [
models.Index(fields=["user", "revoked_at"]),
models.Index(fields=["expires_at"]),
]
ordering = ["-created_at"]
verbose_name = _("API token")
verbose_name_plural = _("API tokens")
def __str__(self):
return f"{self.user} / {self.name}"
@classmethod
def generate_token_key(cls) -> str:
while True:
candidate = secrets.token_hex(8)
if not cls.objects.filter(token_key=candidate).exists():
return candidate
@classmethod
def parse_raw_token(cls, raw_token: str):
if not raw_token.startswith(cls.TOKEN_PREFIX):
raise ValueError("Token is missing the expected prefix.")
payload = raw_token.removeprefix(cls.TOKEN_PREFIX)
token_key, separator, token_secret = payload.partition(".")
if not separator or not token_key or not token_secret:
raise ValueError("Token is malformed.")
return token_key, token_secret
def build_raw_token(self, token_secret: str) -> str:
return f"{self.TOKEN_PREFIX}{self.token_key}.{token_secret}"
@staticmethod
def hash_secret(token_secret: str) -> str:
# The secret is a 256-bit random value (secrets.token_urlsafe(32)), so a
# single SHA-256 is sufficient and avoids a slow KDF on every request.
return hashlib.sha256(token_secret.encode("utf-8")).hexdigest()
def check_secret(self, raw_secret: str) -> bool:
return hmac.compare_digest(self.token_hash, self.hash_secret(raw_secret))
def is_expired(self) -> bool:
return self.expires_at is not None and self.expires_at <= timezone.now()
+73
View File
@@ -0,0 +1,73 @@
from django.contrib.auth import get_user_model
from django.test import TestCase
from django.urls import reverse
from django.utils import timezone
from apps.users.models import APIToken
class UserAPITokenViewsTests(TestCase):
def setUp(self):
self.user = get_user_model().objects.create_user(
email="user@example.com",
password="test-password",
)
self.client.force_login(self.user)
self.htmx_headers = {"HTTP_HX_REQUEST": "true"}
def test_user_settings_renders_api_token_section(self):
response = self.client.get(reverse("user_settings"), **self.htmx_headers)
self.assertContains(response, "API Tokens")
self.assertContains(response, reverse("user_api_token_add"))
def test_can_create_api_token_from_ui(self):
response = self.client.post(
reverse("user_api_token_add"),
{"name": "n8n", "expires_in_days": "30"},
**self.htmx_headers,
)
self.assertEqual(response.status_code, 200)
self.assertContains(response, "Copy this token now")
self.assertEqual(APIToken.objects.filter(user=self.user, name="n8n").count(), 1)
def test_can_revoke_own_api_token(self):
token, _ = APIToken.objects.create_token(user=self.user, name="n8n")
response = self.client.delete(
reverse("user_api_token_revoke", kwargs={"token_id": token.id}),
**self.htmx_headers,
)
self.assertEqual(response.status_code, 200)
token.refresh_from_db()
self.assertIsNotNone(token.revoked_at)
self.assertContains(response, "Revoked")
def test_can_delete_revoked_api_token(self):
token, _ = APIToken.objects.create_token(user=self.user, name="n8n")
token.revoked_at = timezone.now()
token.save(update_fields=["revoked_at"])
response = self.client.delete(
reverse("user_api_token_delete", kwargs={"token_id": token.id}),
**self.htmx_headers,
)
self.assertEqual(response.status_code, 200)
self.assertFalse(APIToken.objects.filter(id=token.id).exists())
def test_cannot_delete_other_users_api_token(self):
other = get_user_model().objects.create_user(
email="other@example.com", password="test-password"
)
token, _ = APIToken.objects.create_token(user=other, name="theirs")
response = self.client.delete(
reverse("user_api_token_delete", kwargs={"token_id": token.id}),
**self.htmx_headers,
)
self.assertEqual(response.status_code, 404)
self.assertTrue(APIToken.objects.filter(id=token.id).exists())
+15
View File
@@ -32,6 +32,21 @@ urlpatterns = [
views.update_settings,
name="user_settings",
),
path(
"user/api-tokens/add/",
views.api_token_add,
name="user_api_token_add",
),
path(
"user/api-tokens/<int:token_id>/revoke/",
views.api_token_revoke,
name="user_api_token_revoke",
),
path(
"user/api-tokens/<int:token_id>/delete/",
views.api_token_delete,
name="user_api_token_delete",
),
path(
"users/",
views.users_index,
+66 -2
View File
@@ -2,12 +2,13 @@ from apps.common.decorators.demo import disabled_on_demo
from apps.common.decorators.htmx import only_htmx
from apps.common.decorators.user import htmx_login_required, is_superuser
from apps.users.forms import (
APITokenCreateForm,
LoginForm,
UserAddForm,
UserSettingsForm,
UserUpdateForm,
)
from apps.users.models import UserSettings
from apps.users.models import APIToken, UserSettings
from django.contrib import messages
from django.contrib.auth import get_user_model, logout
from django.contrib.auth.decorators import login_required
@@ -18,6 +19,7 @@ from django.core.exceptions import PermissionDenied
from django.http import HttpResponse
from django.shortcuts import get_object_or_404, redirect, render
from django.urls import reverse
from django.utils import timezone
from django.utils.translation import gettext_lazy as _
from django.views.decorators.http import require_http_methods
@@ -112,7 +114,69 @@ def update_settings(request):
else:
form = UserSettingsForm(instance=user_settings)
return render(request, "users/fragments/user_settings.html", {"form": form})
return render(
request,
"users/fragments/user_settings.html",
{
"form": form,
"api_token_form": APITokenCreateForm(),
"api_tokens": request.user.api_tokens.all(),
},
)
def _render_api_tokens(request, *, form=None, raw_token=None):
return render(
request,
"users/fragments/api_tokens.html",
{
"api_token_form": form or APITokenCreateForm(),
"api_tokens": request.user.api_tokens.all(),
"raw_token": raw_token,
},
)
@only_htmx
@htmx_login_required
@disabled_on_demo
@require_http_methods(["POST"])
def api_token_add(request):
form = APITokenCreateForm(request.POST)
if form.is_valid():
_token, raw_token = form.save(user=request.user)
messages.success(request, _("API token created successfully"))
return _render_api_tokens(
request,
form=APITokenCreateForm(),
raw_token=raw_token,
)
return _render_api_tokens(request, form=form)
@only_htmx
@htmx_login_required
@disabled_on_demo
@require_http_methods(["DELETE"])
def api_token_revoke(request, token_id):
token = get_object_or_404(APIToken, id=token_id, user=request.user)
if token.revoked_at is None:
token.revoked_at = timezone.now()
token.save(update_fields=["revoked_at"])
messages.success(request, _("API token revoked successfully"))
return _render_api_tokens(request)
@only_htmx
@htmx_login_required
@disabled_on_demo
@require_http_methods(["DELETE"])
def api_token_delete(request, token_id):
token = get_object_or_404(APIToken, id=token_id, user=request.user)
token.delete()
messages.success(request, _("API token deleted successfully"))
return _render_api_tokens(request)
@only_htmx
@@ -0,0 +1,10 @@
settings:
file_type: qif
importing: transactions
encoding: cp1252
date_format: "%d/%m/%Y"
skip_errors: true
mapping: {}
deduplicate: []
@@ -0,0 +1,7 @@
{
"author": "eitchtee",
"description": "Standard QIF Import. Mapping is automatic.",
"schema_version": 1,
"name": "Standard QIF",
"message": "Account is inferred from filename (e.g., 'Checking.qif' -> Account 'Checking').\nYou might need to change the date format to match the date format on your file."
}
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -147,6 +147,12 @@
hx-target="#generic-offcanvas" hx-swap="innerHTML"
data-tippy-content="{% translate "Edit" %}">
<i class="fa-solid fa-pencil fa-fw"></i></a>
<a class="btn btn-soft btn-sm transaction-action gap-1"
role="button"
hx-get="{% url 'transaction_attachments' transaction_id=transaction.id %}"
hx-target="#generic-offcanvas" hx-swap="innerHTML"
data-tippy-content="{% translate "Attachments" %}">
<i class="fa-solid fa-paperclip fa-fw"></i><span>{{ transaction.attachments.count }}</span></a>
<a class="btn btn-error btn-soft btn-sm transaction-action"
role="button"
hx-delete="{% url 'transaction_delete' transaction_id=transaction.id %}"
@@ -56,7 +56,15 @@
</td>
<td class="table-col-auto">{% if service.is_active %}<i class="fa-solid fa-circle text-success"></i>{% else %}
<i class="fa-solid fa-circle text-error"></i>{% endif %}</td>
<td class="table-col-auto">{{ service.name }}</td>
<td>
{{ service.name }}
{% if service.failure_count > 0 %}
<span class="badge badge-error gap-1" data-tippy-content="{% blocktrans count counter=service.failure_count %}{{ counter }} consecutive failure{% plural %}{{ counter }} consecutive failures{% endblocktrans %}">
<i class="fa-solid fa-triangle-exclamation fa-fw"></i>
{{ service.failure_count }}
</span>
{% endif %}
</td>
<td>{{ service.get_service_type_display }}</td>
<td>{{ service.target_currencies.count }} {% trans 'currencies' %}, {{ service.target_accounts.count }} {% trans 'accounts' %}</td>
<td>{{ service.last_fetch|date:"SHORT_DATETIME_FORMAT" }}</td>
+2 -1
View File
@@ -9,9 +9,10 @@
{% include 'includes/scripts/hyperscript/sounds.html' %}
{% include 'includes/scripts/hyperscript/swal.html' %}
{% include 'includes/scripts/hyperscript/autosize.html' %}
{% include 'includes/scripts/pull_to_refresh_i18n.html' %}
<script defer>
let tz = Intl.DateTimeFormat().resolvedOptions().timeZone;
var tz = Intl.DateTimeFormat().resolvedOptions().timeZone;
if (!tz) {
tz = "UTC"
}

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