Compare commits

..

122 Commits
dev ... main

Author SHA1 Message Date
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
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
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
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
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
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
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
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
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
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
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
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
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
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
Herculino Trotta
07cb0a2a0f Merge pull request #465 from eitchtee/dev
fix: "lax" deduplication fails if the comparison field has a numeric value
2025-12-20 00:18:44 -03:00
Herculino Trotta
20b6366a18 Merge pull request #464 from eitchtee/dev
fix: input fields with text inside looks wrong
2025-12-20 00:10:03 -03:00
eitchtee
a3d38ff9e0 chore(locale): update translation files
[skip ci] Automatically generated by Django makemessages workflow
2025-12-20 02:59:45 +00:00
Herculino Trotta
776e2117a0 Merge pull request #463 from eitchtee/dev
fix: recurring transactions not adding entities or tags to created transactions
2025-12-19 23:59:17 -03:00
Herculino Trotta
2d51d21035 Merge pull request #462 from eitchtee/dev
fix: datepicker doesn't recalculate position when changing view mode
2025-12-19 23:48:19 -03:00
Herculino Trotta
88a5c103e5 Merge pull request #461 from eitchtee/dev
feat: speedup startup by moving collectstatic to the Dockerfile
2025-12-19 22:18:20 -03:00
Herculino Trotta
41d8564e8b Merge pull request #460 from eitchtee/dev
fix: try to fix stale database connections (again)
2025-12-19 22:02:03 -03:00
Herculino Trotta
5ee2fd244f Merge pull request #448 from eitchtee/weblate
Translations update from Weblate
2025-12-19 22:01:05 -03:00
BRodolfo
7bd1d2d751 locale(Spanish): update translation
Currently translated at 100.0% (697 of 697 strings)

Translation: WYGIWYH/App
Translate-URL: https://translations.herculino.com/projects/wygiwyh/app/es/
2025-12-16 05:24:30 +00:00
Dimitri Decrock
9a4ec449df locale(Dutch): update translation
Currently translated at 100.0% (697 of 697 strings)

Translation: WYGIWYH/App
Translate-URL: https://translations.herculino.com/projects/wygiwyh/app/nl/
2025-12-15 05:24:30 +00:00
Herculino Trotta
ef66b3a1e5 Merge pull request #447 from eitchtee/weblate
Translations update from Weblate
2025-12-14 12:10:00 -03:00
Herculino Trotta
7486660223 locale(Dutch): update translation
Currently translated at 99.7% (695 of 697 strings)

Translation: WYGIWYH/App
Translate-URL: https://translations.herculino.com/projects/wygiwyh/app/nl/
2025-12-14 15:08:44 +00:00
Herculino Trotta
00d5ccda34 locale(Portuguese (Brazil)): update translation
Currently translated at 100.0% (697 of 697 strings)

Translation: WYGIWYH/App
Translate-URL: https://translations.herculino.com/projects/wygiwyh/app/pt_BR/
2025-12-14 15:07:23 +00:00
Herculino Trotta
1656eec601 Merge pull request #446 from eitchtee/weblate
Translations update from Weblate
2025-12-14 12:05:38 -03:00
Herculino Trotta
64b96ed2f3 Merge branch 'main' into weblate 2025-12-14 12:05:24 -03:00
Herculino Trotta
1f5e4f132d locale(Portuguese (Brazil)): update translation
Currently translated at 99.1% (693 of 699 strings)

Translation: WYGIWYH/App
Translate-URL: https://translations.herculino.com/projects/wygiwyh/app/pt_BR/
2025-12-14 14:57:54 +00:00
eitchtee
edf056b68c chore(locale): update translation files
[skip ci] Automatically generated by Django makemessages workflow
2025-12-14 14:57:52 +00:00
Herculino Trotta
35865ce21c Merge pull request #445 from eitchtee/dev
fix: extra space on some translations
2025-12-14 11:57:16 -03:00
Herculino Trotta
15eaa2239a Merge pull request #444 from eitchtee/weblate
Translations update from Weblate
2025-12-14 11:54:11 -03:00
Herculino Trotta
fd7214df95 Merge branch 'main' into weblate 2025-12-14 11:53:10 -03:00
Herculino Trotta
e531c63de3 locale(Portuguese (Brazil)): update translation
Currently translated at 99.2% (693 of 698 strings)

Translation: WYGIWYH/App
Translate-URL: https://translations.herculino.com/projects/wygiwyh/app/pt_BR/
2025-12-14 14:47:24 +00:00
eitchtee
5a79dd5424 chore(locale): update translation files
[skip ci] Automatically generated by Django makemessages workflow
2025-12-14 14:47:21 +00:00
Herculino Trotta
315dd1479a Merge pull request #443 from eitchtee/dev
feat: improve text for rules
2025-12-14 11:46:47 -03:00
eitchtee
272c34d3b3 chore(locale): update translation files
[skip ci] Automatically generated by Django makemessages workflow
2025-12-14 14:08:41 +00:00
Herculino Trotta
43ce79ae65 Merge pull request #442 from eitchtee/dev
feat: remove bootstrap's collapses; improve animations
2025-12-14 11:08:11 -03:00
Herculino Trotta
fd1fcb832c Merge pull request #441 from eitchtee/weblate
Translations update from Weblate
2025-12-14 11:01:10 -03:00
Dimitri Decrock
b5fd928a5d locale(Dutch): update translation
Currently translated at 100.0% (699 of 699 strings)

Translation: WYGIWYH/App
Translate-URL: https://translations.herculino.com/projects/wygiwyh/app/nl/
2025-12-14 11:24:30 +00:00
Herculino Trotta
2dc398f82b Merge pull request #440 from eitchtee/dev
feat: improve  transactions action bar animation
2025-12-13 20:48:25 -03:00
eitchtee
e9c3af1a85 chore(locale): update translation files
[skip ci] Automatically generated by Django makemessages workflow
2025-12-13 19:47:32 +00:00
Herculino Trotta
b121e8e982 Merge pull request #439 from eitchtee/dev
fix(style): demo mode close button is place incorrectly
2025-12-13 16:46:57 -03:00
eitchtee
6e46b5abb8 chore(locale): update translation files
[skip ci] Automatically generated by Django makemessages workflow
2025-12-13 19:44:25 +00:00
Herculino Trotta
5b4dab93a1 Merge pull request #438 from eitchtee/dev
feat: add "invert selection" option to transactions action bar
2025-12-13 16:43:48 -03:00
Herculino Trotta
484686b709 Merge pull request #437 from eitchtee/dev
fix: show muted transactions/categories on account and currency flow.
2025-12-13 16:23:34 -03:00
Herculino Trotta
8123f7f3cb Merge pull request #436 from eitchtee/dev
feat: prevent background tasks from running all at once
2025-12-13 15:14:46 -03:00
Herculino Trotta
547dc90d9e Merge pull request #430 from eitchtee/weblate
Translations update from Weblate
2025-12-13 15:13:50 -03:00
Weblate
92960d1b9a Merge remote-tracking branch 'origin/main' 2025-12-09 23:10:08 +00:00
Herculino Trotta
1978a467cb fix: pin PostgreSQL image to 15-bookworm 2025-12-09 20:10:05 -03:00
Juan David Afanador
5bdafbba91 locale(Spanish): update translation
Currently translated at 100.0% (695 of 695 strings)

Translation: WYGIWYH/App
Translate-URL: https://translations.herculino.com/projects/wygiwyh/app/es/
2025-12-08 19:20:40 +00:00
Herculino Trotta
16de87376a Merge pull request #429
fix(api): inefficient transaction update operation
2025-12-07 13:55:29 -03:00
eitchtee
157f357a7a chore(locale): update translation files
[skip ci] Automatically generated by Django makemessages workflow
2025-12-07 16:45:08 +00:00
Herculino Trotta
d77eddbd26 Merge pull request #428 from SerafimPikalov/fix/null-category-serialization
fix: handle null category in TransactionCategoryField serialization
2025-12-07 13:44:25 -03:00
Sera Pikalov
fb1b383962 fix: handle null category in TransactionCategoryField serialization
Fix AttributeError when serializing transactions with null categories.
The to_representation method now checks for None before accessing
category properties, returning None instead of crashing.

Fixes issue where API returns 500 error when retrieving transactions
without assigned categories.
2025-12-07 12:37:05 +02:00
eitchtee
11998475c5 chore(locale): update translation files
[skip ci] Automatically generated by Django makemessages workflow
2025-12-07 03:33:34 +00:00
Herculino Trotta
21363e23a1 Merge pull request #425
feat(api): add endpoints for importing files and getting account balance
2025-12-07 00:33:00 -03:00
Herculino Trotta
9c92bbd3cf Merge pull request #424
fix(import:v1): always_* types for is_paid and type requires assigning a source
2025-12-06 17:53:15 -03:00
Herculino Trotta
231b9065c9 Merge pull request #423
fix: decouple DEBUG env variable from vite dev server
2025-12-06 17:33:14 -03:00
Herculino Trotta
c57fa1630b Merge pull request #422
fix: try to fix "the connection is closed" db errors
2025-12-06 16:47:13 -03:00
eitchtee
58b855f55e chore(locale): update translation files
[skip ci] Automatically generated by Django makemessages workflow
2025-12-06 19:19:20 +00:00
Herculino Trotta
d4d51301b3 Merge pull request #421
feat: accept query params on standalone add transaction page
2025-12-06 16:18:50 -03:00
Herculino Trotta
70d427bec4 Merge pull request #420
chore: bump dependencies
2025-12-06 14:23:43 -03:00
Herculino Trotta
8d76c40b7e Merge pull request #419 from eitchtee/dev
chore: bump dependencies for safety
2025-12-06 14:05:25 -03:00
Herculino Trotta
588ae2de6e Merge pull request #407 from eitchtee/weblate
Translations update from Weblate
2025-11-26 21:44:07 -03:00
Herculino Trotta
4b97ba681a Merge branch 'main' into weblate 2025-11-26 21:43:56 -03:00
eitchtee
1a903507ad chore(locale): update translation files
[skip ci] Automatically generated by Django makemessages workflow
2025-11-26 19:41:38 +00:00
Weblate
bf920df771 Merge remote-tracking branch 'origin/main' 2025-11-26 19:41:04 +00:00
Herculino Trotta
23ae6f3d54 Merge pull request #411
fix: unable to create transactions with an empty reference date when importing
2025-11-26 16:41:01 -03:00
Dimitri Decrock
4351027b87 locale(Dutch): update translation
Currently translated at 100.0% (695 of 695 strings)

Translation: WYGIWYH/App
Translate-URL: https://translations.herculino.com/projects/wygiwyh/app/nl/
2025-11-25 05:20:40 +00:00
32 changed files with 9662 additions and 5257 deletions

1
.dockerignore Normal file
View File

@@ -0,0 +1 @@
__pycache__/

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

View File

@@ -70,6 +70,7 @@ INSTALLED_APPS = [
"apps.api.apps.ApiConfig",
"cachalot",
"rest_framework",
"rest_framework.authtoken",
"drf_spectacular",
"django_cotton",
"apps.rules.apps.RulesConfig",
@@ -433,8 +434,16 @@ 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': [
'rest_framework.authentication.BasicAuthentication',
'rest_framework.authentication.SessionAuthentication',
'rest_framework.authentication.TokenAuthentication',
],
"DEFAULT_PAGINATION_CLASS": "apps.api.custom.pagination.CustomPageNumberPagination",
"DEFAULT_SCHEMA_CLASS": "drf_spectacular.openapi.AutoSchema",
}

View File

@@ -10,15 +10,19 @@ from apps.transactions.models import (
@extend_schema_field(
{
"oneOf": [{"type": "string"}, {"type": "integer"}],
"description": "TransactionCategory ID or name. If the name doesn't exist, a new one will be created",
"oneOf": [{"type": "string"}, {"type": "integer"}, {"type": "null"}],
"description": "TransactionCategory ID or name. If the name doesn't exist, a new one will be created. Can be null if no category is assigned.",
}
)
class TransactionCategoryField(serializers.Field):
def to_representation(self, value):
if value is None:
return None
return {"id": value.id, "name": value.name}
def to_internal_value(self, data):
if data is None:
return None
if isinstance(data, int):
try:
return TransactionCategory.objects.get(pk=data)

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 *

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()
)

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)

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)

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__'

View File

@@ -8,9 +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().order_by("id")
return DCAStrategy.objects.all()
@action(detail=True, methods=["get"])
def investment_frequency(self, request, pk=None):
@@ -35,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)

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()

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()

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

View File

@@ -2,6 +2,7 @@ volumes:
wygiwyh_dev_postgres_data: {}
wygiwyh_temp:
services:
web:
build:

View File

@@ -11,7 +11,7 @@ services:
restart: unless-stopped
db:
image: postgres:15
image: postgres:15-bookworm
container_name: ${DB_NAME}
restart: unless-stopped
volumes:

View File

@@ -1,14 +1,4 @@
FROM python:3.11-slim-bookworm AS python-build-stage
RUN apt-get update && apt-get install --no-install-recommends -y \
build-essential \
libpq-dev \
&& rm -rf /var/lib/apt/lists/*
COPY ../requirements.txt .
RUN pip wheel --wheel-dir /usr/src/app/wheels -r requirements.txt
FROM python:3.11-slim-bookworm AS python-run-stage
FROM ghcr.io/astral-sh/uv:python3.11-bookworm-slim
ARG VERSION=dev
ENV APP_VERSION=$VERSION
@@ -16,16 +6,17 @@ ENV APP_VERSION=$VERSION
WORKDIR /usr/src/app
ENV PYTHONDONTWRITEBYTECODE=1 \
PYTHONUNBUFFERED=1
COPY --from=python-build-stage /usr/src/app/wheels /wheels/
PYTHONUNBUFFERED=1 \
UV_COMPILE_BYTECODE=1 \
UV_LINK_MODE=copy \
UV_PROJECT_ENVIRONMENT=/opt/venv
RUN apt-get update && \
apt-get install --no-install-recommends -y gettext supervisor && \
rm -rf /var/lib/apt/lists/* && \
pip install --upgrade pip && \
pip install --no-cache-dir --no-index --find-links=/wheels/ /wheels/* && \
rm -rf /wheels/ ~/.cache/pip/*
rm -rf /var/lib/apt/lists/*
COPY pyproject.toml uv.lock ./
RUN uv sync --frozen --no-install-project --no-dev
COPY ./docker/dev/django/start /start
COPY ./docker/dev/procrastinate/start /start-procrastinate
@@ -40,6 +31,8 @@ RUN sed -i 's/\r$//g' /start && \
sed -i 's/\r$//g' /start-supervisor && \
chmod +x /start-supervisor
ENV PATH="/opt/venv/bin:$PATH"
COPY ./app .
CMD ["/start-supervisor"]

View File

@@ -1,17 +1,19 @@
FROM python:3.11-slim-bookworm AS python-build-stage
FROM ghcr.io/astral-sh/uv:python3.11-bookworm-slim AS python-build-stage
WORKDIR /app
ENV UV_COMPILE_BYTECODE=1 \
UV_LINK_MODE=copy
RUN apt-get update && apt-get install --no-install-recommends -y \
build-essential \
libpq-dev \
curl \
&& rm -rf /var/lib/apt/lists/*
# Install uv for faster package resolution
COPY --from=ghcr.io/astral-sh/uv:latest /uv /usr/local/bin/uv
COPY uv.lock pyproject.toml ./
COPY ./requirements.txt .
RUN --mount=type=cache,target=/root/.cache/uv \
uv pip install --system --compile-bytecode -r requirements.txt
uv sync --frozen --no-install-project --no-dev
FROM node:lts-alpine AS vite_build
WORKDIR /usr/src/frontend
@@ -33,9 +35,8 @@ ENV APP_VERSION=$VERSION
COPY --from=vite_build /usr/src/frontend/build /usr/src/frontend/build
# Copy Python packages from build stage
COPY --from=python-build-stage /usr/local/lib/python3.11/site-packages /usr/local/lib/python3.11/site-packages
COPY --from=python-build-stage /usr/local/bin /usr/local/bin
# Copy virtual environment from build stage
COPY --from=python-build-stage /app/.venv /app/.venv
WORKDIR /usr/src/app
@@ -43,7 +44,8 @@ RUN addgroup --system app && \
adduser --system --ingroup app app
ENV PYTHONDONTWRITEBYTECODE=1 \
PYTHONUNBUFFERED=1
PYTHONUNBUFFERED=1 \
PATH="/app/.venv/bin:$PATH"
# Install runtime dependencies
RUN --mount=type=cache,target=/root/.cache/apt \

40
pyproject.toml Normal file
View File

@@ -0,0 +1,40 @@
[project]
name = "wygiwyh"
dynamic = ["version"]
description = "An opinionated and powerful finance tracker."
readme = "README.md"
requires-python = ">=3.11"
dependencies = [
"crispy-bootstrap5==2025.6",
"django~=5.2.9",
"django-allauth[socialaccount]~=65.13.1",
"django-browser-reload==1.21.0",
"django-cachalot~=2.8.0",
"django-cotton<2.3.0",
"django-crispy-forms==2.5",
"django-debug-toolbar==6.1.0",
"django-filter==25.2",
"django-hijack==3.7.4",
"django-import-export~=4.3.9",
"django-pwa~=2.0.1",
"django-vite==3.1.0",
"djangorestframework~=3.16.0",
"drf-spectacular~=0.29.0",
"gunicorn==23.0.0",
"mistune~=3.1.3",
"openpyxl~=3.1.5",
"procrastinate[django]~=3.5.3",
"psycopg[binary,pool]==3.2.9",
"pydantic~=2.12.3",
"python-dateutil~=2.9.0.post0",
"pytz>=2025.2",
"pyyaml~=6.0.2",
"requests~=2.32.5",
"simpleeval~=1.0.3",
"watchfiles==1.1.1",
"whitenoise[brotli]==6.11.0",
"xlrd~=2.0.1",
]
[tool.setuptools]
packages = ["app"]

View File

@@ -1,33 +0,0 @@
Django~=5.2.9
psycopg[binary,pool]==3.2.9
django-vite==3.1.0
django-crispy-forms==2.5
crispy-bootstrap5==2025.6
django-browser-reload==1.21.0
django-hijack==3.7.4
django-filter==25.2
django-debug-toolbar==6.1.0
django-cachalot~=2.8.0
django-cotton<2.3.0
django-pwa~=2.0.1
djangorestframework~=3.16.0
drf-spectacular~=0.29.0
django-import-export~=4.3.9
gunicorn==23.0.0
whitenoise[brotli]==6.11.0
watchfiles==1.1.1
procrastinate[django]~=3.5.3
requests~=2.32.5
django-allauth[socialaccount]~=65.13.1
pytz
python-dateutil~=2.9.0.post0
simpleeval~=1.0.3
pydantic~=2.12.3
PyYAML~=6.0.2
mistune~=3.1.3
openpyxl~=3.1.5
xlrd~=2.0.1

1337
uv.lock generated Normal file

File diff suppressed because it is too large Load Diff