mirror of
https://github.com/eitchtee/WYGIWYH.git
synced 2026-07-06 21:05:15 +02:00
Compare commits
254 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| fee40fd527 | |||
| f59e53f6dc | |||
| 2b379987ac | |||
| 4b8ccf426d | |||
| 4805ce9e04 | |||
| 845a8d846b | |||
| 1497500c4f | |||
| 9e9e60ccec | |||
| ca14f77f41 | |||
| 0fb37a59fa | |||
| e74d9177df | |||
| 106d721279 | |||
| d0e9c05283 | |||
| 4e16831f4d | |||
| 7f5a91c11f | |||
| 009a7038c8 | |||
| 4273c541c5 | |||
| 5c4cb16a0a | |||
| 9641e169f2 | |||
| 25ff0214ab | |||
| 0f9d333834 | |||
| ae115cca15 | |||
| bb23ac6df9 | |||
| 7db0fcf097 | |||
| 02896f21ed | |||
| 5082c17d0f | |||
| 33570296e0 | |||
| fc99491f78 | |||
| cb0d379261 | |||
| 524e390a62 | |||
| db7e22b627 | |||
| 6987b54dba | |||
| b44563b09b | |||
| e839f31104 | |||
| 2282625790 | |||
| aa8b559152 | |||
| e1862b8241 | |||
| eb6be8548c | |||
| 6ee4e21939 | |||
| bdd8aed891 | |||
| 801b2a9edd | |||
| 968499f1ab | |||
| 5b351821b1 | |||
| 7b49072848 | |||
| 0ee32724f1 | |||
| 6a19381672 | |||
| 248fec8b4c | |||
| b34c0557fa | |||
| 2af4066aab | |||
| d72ff3cdf5 | |||
| 63c69e5c6a | |||
| 78171183cc | |||
| 34a2b6bfd4 | |||
| 1dc24f855e | |||
| 1390aff07d | |||
| 8fc11b0acf | |||
| 9a30a0d3c0 | |||
| 10eecd09ff | |||
| 2cfb3fb12e | |||
| 47af8b135b | |||
| 39d0e63375 | |||
| 792154eba2 | |||
| dc76ed3156 | |||
| e627dd50be | |||
| 5527389196 | |||
| be24ca014e | |||
| 7c7056536e | |||
| 66d5d7a83b | |||
| 4d3ce087d6 | |||
| aeaf9fac43 | |||
| dcedf53b83 | |||
| 549648bd6b | |||
| 79149abdd2 | |||
| d66d1530bb | |||
| 4b9b6484d3 | |||
| a02944bdae | |||
| 3ede5304f1 | |||
| 27041695b8 | |||
| 0c927a2fe9 | |||
| c52db80c64 | |||
| 330ce8069c | |||
| 2989f11b01 | |||
| 738bb7fb74 | |||
| 79e50cd853 | |||
| 0a23c3ad5b | |||
| 43c7749102 | |||
| c1c4ccda8c | |||
| 615a689c61 | |||
| c5ccc42f99 | |||
| 2baa8b21e8 | |||
| 2e554141ba | |||
| 73ec6dc0fe | |||
| e19449ff99 | |||
| e81651119c | |||
| 55e9ef1b3f | |||
| c414179135 | |||
| 14c507de0f | |||
| 4722690fe9 | |||
| 493619a4ff | |||
| fb4aec88f1 | |||
| 4a35e770a4 | |||
| 83b81edbae | |||
| a7dc2c955e | |||
| ce2ae562c6 | |||
| de2881ffd4 | |||
| 838bf22498 | |||
| d3797ae4a5 | |||
| 0532397afd | |||
| 8106dc58e5 | |||
| 5986cf675b | |||
| 80da9142f1 | |||
| 766516d248 | |||
| 3fd0fba1b8 | |||
| c787565c04 | |||
| 0413921dbe | |||
| 9ecf8279b4 | |||
| 86cf625158 | |||
| ea097ab6f0 | |||
| b1201b51bb | |||
| 4c1d20215c | |||
| 27e85c4776 | |||
| 5a73cd20da | |||
| e305fab300 | |||
| c11f525373 | |||
| ea5d86dbf8 | |||
| a1d3539e3c | |||
| 1028a11c8b | |||
| e387a5e2a8 | |||
| 624dc382cf | |||
| f88699b333 | |||
| ca98dc073b | |||
| 63ba7af3c8 | |||
| 2d0dee4a9b | |||
| 0000a9ee03 | |||
| 41adb37fdb | |||
| 496651173e | |||
| 8836f06b80 | |||
| e98a48b3a7 | |||
| f9bc9f449b | |||
| 26eb1ae813 | |||
| 29a2cb9813 | |||
| be79e1b25a | |||
| 3fd08466a7 | |||
| 6896cdcdca | |||
| 2532930a64 | |||
| 24a1ef2d0a | |||
| 163f2f4e5b | |||
| ede63acf5f | |||
| a8ba3d8754 | |||
| e2f1156264 | |||
| d5bbad7887 | |||
| 7ebacff6e4 | |||
| df8ef5d04c | |||
| fa2a8b8c65 | |||
| e44ac5dab6 | |||
| f9261d1283 | |||
| 4c73c1cae5 | |||
| 0315a56f88 | |||
| 44d6b8b53c | |||
| 3e4d7c6b1f | |||
| 63868514f9 | |||
| 9055a24327 | |||
| 9dc963ed7b | |||
| 49cac0588e | |||
| 3b2b6d6473 | |||
| db30bcbeb7 | |||
| a122733a47 | |||
| 37f3e4d99a | |||
| d756286135 | |||
| 06a7378fd8 | |||
| ab4075c500 | |||
| 96318f003d | |||
| 1a0412264a | |||
| 2588404876 | |||
| fdc273103b | |||
| c015b78cd6 | |||
| 50e5492ea1 | |||
| 796089cdb3 | |||
| c83b1bf2d6 | |||
| b074ef7929 | |||
| ec7e33b3b0 | |||
| 72fedea0db | |||
| 0a03745ce6 | |||
| ff4bd79634 | |||
| 383b42e26d | |||
| 48e43ac031 | |||
| 21c60c4059 | |||
| dd6a390e6b | |||
| 0c961a8250 | |||
| e28c651973 | |||
| 7687ff81c3 | |||
| b2d78c9190 | |||
| b0815e00c7 | |||
| fbe9726338 | |||
| 0df3a57a33 | |||
| f86613b17a | |||
| ffa4644e1b | |||
| 6611559696 | |||
| b455a0251a | |||
| 9d7c3212f1 | |||
| 0da3185996 | |||
| 6c90e1bb7f | |||
| c6543c0841 | |||
| d4740b8406 | |||
| 5a51795e6a | |||
| 64d7765357 | |||
| 070e11ca77 | |||
| 39f66b620a | |||
| ad164866e0 | |||
| 05c465cb34 | |||
| 92cf526b76 | |||
| 639236b890 | |||
| 519a85d256 | |||
| 700d35b5d5 | |||
| 10e51971db | |||
| ec0d5fc121 | |||
| 01f91352d6 | |||
| 63ce57a315 | |||
| eadeb649a1 | |||
| a2871d5289 | |||
| f2a362bc0f | |||
| 2076903740 | |||
| c752c0b16e | |||
| 1674766253 | |||
| 7ea9d56132 | |||
| 3699c6c671 | |||
| d7c255aa14 | |||
| d17b9d5736 | |||
| c7ff6db0bf | |||
| a4c7753f69 | |||
| 7e08028557 | |||
| 5eaf5086d2 | |||
| c949c6cea0 | |||
| 71c0e9a271 | |||
| bc65980511 | |||
| ecdb1a52cc | |||
| afc06582b4 | |||
| 07cb0a2a0f | |||
| 05ede58c36 | |||
| 20b6366a18 | |||
| b0101dae1a | |||
| a3d38ff9e0 | |||
| 776e2117a0 | |||
| edcad37926 | |||
| 2d51d21035 | |||
| 94f5c25829 | |||
| 88a5c103e5 | |||
| 3dce9e1c55 | |||
| 41d8564e8b | |||
| 5ee2fd244f | |||
| 0545fb7651 | |||
| 7bd1d2d751 | |||
| 9a4ec449df | |||
| f918351303 |
@@ -0,0 +1 @@
|
||||
__pycache__/
|
||||
@@ -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=""
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -165,3 +165,6 @@ cython_debug/
|
||||
node_modules/
|
||||
postgres_data/
|
||||
.prod.env
|
||||
|
||||
# Private local uploads
|
||||
app/attachments/
|
||||
|
||||
Vendored
+29
@@ -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"
|
||||
}
|
||||
]
|
||||
}
|
||||
Vendored
+119
@@ -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": []
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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.
|
||||
|
||||
+75
-23
@@ -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",
|
||||
@@ -143,6 +145,9 @@ WSGI_APPLICATION = "WYGIWYH.wsgi.application"
|
||||
# Database
|
||||
# https://docs.djangoproject.com/en/5.1/ref/settings/#databases
|
||||
|
||||
THREADS = int(os.getenv("GUNICORN_THREADS", 1))
|
||||
MAX_POOL_SIZE = THREADS + 1
|
||||
|
||||
DATABASES = {
|
||||
"default": {
|
||||
"ENGINE": "django.db.backends.postgresql",
|
||||
@@ -151,8 +156,16 @@ DATABASES = {
|
||||
"PASSWORD": os.getenv("SQL_PASSWORD", "password"),
|
||||
"HOST": os.getenv("SQL_HOST", "localhost"),
|
||||
"PORT": os.getenv("SQL_PORT", "5432"),
|
||||
"CONN_MAX_AGE": 0,
|
||||
"CONN_HEALTH_CHECKS": True,
|
||||
"OPTIONS": {
|
||||
"pool": True,
|
||||
"pool": {
|
||||
"min_size": 1,
|
||||
"max_size": MAX_POOL_SIZE,
|
||||
"timeout": 10,
|
||||
"max_lifetime": 600,
|
||||
"max_idle": 300,
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -299,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",
|
||||
@@ -331,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 = [
|
||||
@@ -364,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 = [
|
||||
@@ -378,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
|
||||
@@ -422,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",
|
||||
@@ -438,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",
|
||||
@@ -446,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,
|
||||
},
|
||||
},
|
||||
}
|
||||
@@ -485,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,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
@@ -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/",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
@@ -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 *
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
@@ -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()
|
||||
)
|
||||
@@ -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())
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
|
||||
@@ -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__'
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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."
|
||||
)
|
||||
)
|
||||
@@ -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))
|
||||
@@ -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)
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
|
||||
@@ -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),
|
||||
),
|
||||
]
|
||||
@@ -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": _(
|
||||
|
||||
@@ -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)
|
||||
@@ -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",
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -459,12 +462,13 @@ class ImportService:
|
||||
# Build query conditions for each field in the rule
|
||||
for field in rule.fields:
|
||||
if field in transaction_data:
|
||||
if rule.match_type == "strict":
|
||||
query = query.filter(**{field: transaction_data[field]})
|
||||
else: # lax matching
|
||||
query = query.filter(
|
||||
**{f"{field}__iexact": transaction_data[field]}
|
||||
)
|
||||
value = transaction_data[field]
|
||||
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():
|
||||
@@ -472,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]:
|
||||
@@ -844,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.
|
||||
@@ -870,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(
|
||||
|
||||
@@ -1,3 +0,0 @@
|
||||
from django.test import TestCase
|
||||
|
||||
# Create your tests here.
|
||||
@@ -0,0 +1,311 @@
|
||||
"""
|
||||
Tests for ImportService v1, specifically for deduplication logic.
|
||||
|
||||
These tests verify that the _check_duplicate_transaction method handles
|
||||
different field types correctly, particularly ensuring that __iexact
|
||||
is only used for string fields (not dates, decimals, etc.).
|
||||
"""
|
||||
|
||||
from datetime import date
|
||||
from decimal import Decimal
|
||||
|
||||
from django.test import TestCase
|
||||
|
||||
from apps.accounts.models import Account, AccountGroup
|
||||
from apps.currencies.models import Currency
|
||||
from apps.import_app.models import ImportProfile, ImportRun
|
||||
from apps.import_app.services.v1 import ImportService
|
||||
from apps.transactions.models import Transaction, TransactionEntity
|
||||
|
||||
|
||||
class DeduplicationTests(TestCase):
|
||||
"""Tests for transaction deduplication during import."""
|
||||
|
||||
def setUp(self):
|
||||
"""Set up test data."""
|
||||
self.currency = Currency.objects.create(
|
||||
code="USD", name="US Dollar", decimal_places=2, prefix="$ "
|
||||
)
|
||||
self.account_group = AccountGroup.objects.create(name="Test Group")
|
||||
self.account = Account.objects.create(
|
||||
name="Test Account", group=self.account_group, currency=self.currency
|
||||
)
|
||||
|
||||
# Create an existing transaction for deduplication tests
|
||||
self.existing_transaction = Transaction.objects.create(
|
||||
account=self.account,
|
||||
type=Transaction.Type.EXPENSE,
|
||||
date=date(2024, 1, 15),
|
||||
amount=Decimal("100.00"),
|
||||
description="Existing Transaction",
|
||||
internal_id="ABC123",
|
||||
)
|
||||
|
||||
def _create_import_service_with_deduplication(
|
||||
self, fields: list[str], match_type: str = "lax"
|
||||
) -> ImportService:
|
||||
"""Helper to create an ImportService with specific deduplication rules."""
|
||||
yaml_config = f"""
|
||||
settings:
|
||||
file_type: csv
|
||||
importing: transactions
|
||||
trigger_transaction_rules: false
|
||||
mapping:
|
||||
date_field:
|
||||
source: date
|
||||
target: date
|
||||
format: "%Y-%m-%d"
|
||||
amount_field:
|
||||
source: amount
|
||||
target: amount
|
||||
description_field:
|
||||
source: description
|
||||
target: description
|
||||
account_field:
|
||||
source: account
|
||||
target: account
|
||||
type: id
|
||||
deduplication:
|
||||
- type: compare
|
||||
fields: {fields}
|
||||
match_type: {match_type}
|
||||
"""
|
||||
profile = ImportProfile.objects.create(
|
||||
name=f"Test Profile {match_type} {'_'.join(fields)}",
|
||||
yaml_config=yaml_config,
|
||||
version=ImportProfile.Versions.VERSION_1,
|
||||
)
|
||||
import_run = ImportRun.objects.create(
|
||||
profile=profile,
|
||||
file_name="test.csv",
|
||||
)
|
||||
return ImportService(import_run)
|
||||
|
||||
def test_deduplication_with_date_field_strict_match(self):
|
||||
"""Test that date fields work with strict matching."""
|
||||
service = self._create_import_service_with_deduplication(
|
||||
fields=["date"], match_type="strict"
|
||||
)
|
||||
|
||||
# Should find duplicate when date matches
|
||||
is_duplicate = service._check_duplicate_transaction({"date": date(2024, 1, 15)})
|
||||
self.assertTrue(is_duplicate)
|
||||
|
||||
# Should not find duplicate when date differs
|
||||
is_duplicate = service._check_duplicate_transaction({"date": date(2024, 2, 20)})
|
||||
self.assertFalse(is_duplicate)
|
||||
|
||||
def test_deduplication_with_date_field_lax_match(self):
|
||||
"""
|
||||
Test that date fields use strict matching even when match_type is 'lax'.
|
||||
|
||||
This is the fix for the UPPER(date) PostgreSQL error. Date fields
|
||||
cannot use __iexact, so they should fall back to strict matching.
|
||||
"""
|
||||
service = self._create_import_service_with_deduplication(
|
||||
fields=["date"], match_type="lax"
|
||||
)
|
||||
|
||||
# Should find duplicate when date matches (using strict comparison)
|
||||
is_duplicate = service._check_duplicate_transaction({"date": date(2024, 1, 15)})
|
||||
self.assertTrue(is_duplicate)
|
||||
|
||||
# Should not find duplicate when date differs
|
||||
is_duplicate = service._check_duplicate_transaction({"date": date(2024, 2, 20)})
|
||||
self.assertFalse(is_duplicate)
|
||||
|
||||
def test_deduplication_with_amount_field_lax_match(self):
|
||||
"""
|
||||
Test that Decimal fields use strict matching even when match_type is 'lax'.
|
||||
|
||||
Decimal fields cannot use __iexact, so they should fall back to strict matching.
|
||||
"""
|
||||
service = self._create_import_service_with_deduplication(
|
||||
fields=["amount"], match_type="lax"
|
||||
)
|
||||
|
||||
# Should find duplicate when amount matches
|
||||
is_duplicate = service._check_duplicate_transaction(
|
||||
{"amount": Decimal("100.00")}
|
||||
)
|
||||
self.assertTrue(is_duplicate)
|
||||
|
||||
# Should not find duplicate when amount differs
|
||||
is_duplicate = service._check_duplicate_transaction(
|
||||
{"amount": Decimal("200.00")}
|
||||
)
|
||||
self.assertFalse(is_duplicate)
|
||||
|
||||
def test_deduplication_with_string_field_lax_match(self):
|
||||
"""
|
||||
Test that string fields use case-insensitive matching with match_type 'lax'.
|
||||
"""
|
||||
service = self._create_import_service_with_deduplication(
|
||||
fields=["description"], match_type="lax"
|
||||
)
|
||||
|
||||
# Should find duplicate with case-insensitive match
|
||||
is_duplicate = service._check_duplicate_transaction(
|
||||
{"description": "EXISTING TRANSACTION"}
|
||||
)
|
||||
self.assertTrue(is_duplicate)
|
||||
|
||||
# Should find duplicate with exact case match
|
||||
is_duplicate = service._check_duplicate_transaction(
|
||||
{"description": "Existing Transaction"}
|
||||
)
|
||||
self.assertTrue(is_duplicate)
|
||||
|
||||
# Should not find duplicate when description differs
|
||||
is_duplicate = service._check_duplicate_transaction(
|
||||
{"description": "Different Transaction"}
|
||||
)
|
||||
self.assertFalse(is_duplicate)
|
||||
|
||||
def test_deduplication_with_string_field_strict_match(self):
|
||||
"""
|
||||
Test that string fields use case-sensitive matching with match_type 'strict'.
|
||||
"""
|
||||
service = self._create_import_service_with_deduplication(
|
||||
fields=["description"], match_type="strict"
|
||||
)
|
||||
|
||||
# Should NOT find duplicate with different case (strict matching)
|
||||
is_duplicate = service._check_duplicate_transaction(
|
||||
{"description": "EXISTING TRANSACTION"}
|
||||
)
|
||||
self.assertFalse(is_duplicate)
|
||||
|
||||
# Should find duplicate with exact case match
|
||||
is_duplicate = service._check_duplicate_transaction(
|
||||
{"description": "Existing Transaction"}
|
||||
)
|
||||
self.assertTrue(is_duplicate)
|
||||
|
||||
def test_deduplication_with_multiple_fields_mixed_types(self):
|
||||
"""
|
||||
Test deduplication with multiple fields of different types.
|
||||
|
||||
Verifies that string fields use __iexact while non-string fields
|
||||
use strict matching, all in the same deduplication rule.
|
||||
"""
|
||||
service = self._create_import_service_with_deduplication(
|
||||
fields=["date", "amount", "description"], match_type="lax"
|
||||
)
|
||||
|
||||
# Should find duplicate when all fields match (with case-insensitive description)
|
||||
is_duplicate = service._check_duplicate_transaction(
|
||||
{
|
||||
"date": date(2024, 1, 15),
|
||||
"amount": Decimal("100.00"),
|
||||
"description": "existing transaction", # lowercase should match
|
||||
}
|
||||
)
|
||||
self.assertTrue(is_duplicate)
|
||||
|
||||
# Should NOT find duplicate when date differs
|
||||
is_duplicate = service._check_duplicate_transaction(
|
||||
{
|
||||
"date": date(2024, 2, 20),
|
||||
"amount": Decimal("100.00"),
|
||||
"description": "existing transaction",
|
||||
}
|
||||
)
|
||||
self.assertFalse(is_duplicate)
|
||||
|
||||
# Should NOT find duplicate when amount differs
|
||||
is_duplicate = service._check_duplicate_transaction(
|
||||
{
|
||||
"date": date(2024, 1, 15),
|
||||
"amount": Decimal("999.99"),
|
||||
"description": "existing transaction",
|
||||
}
|
||||
)
|
||||
self.assertFalse(is_duplicate)
|
||||
|
||||
def test_deduplication_with_internal_id_lax_match(self):
|
||||
"""Test deduplication with internal_id field using lax matching."""
|
||||
service = self._create_import_service_with_deduplication(
|
||||
fields=["internal_id"], match_type="lax"
|
||||
)
|
||||
|
||||
# Should find duplicate with case-insensitive match
|
||||
is_duplicate = service._check_duplicate_transaction(
|
||||
{"internal_id": "abc123"} # lowercase should match ABC123
|
||||
)
|
||||
self.assertTrue(is_duplicate)
|
||||
|
||||
# Should find duplicate with exact match
|
||||
is_duplicate = service._check_duplicate_transaction({"internal_id": "ABC123"})
|
||||
self.assertTrue(is_duplicate)
|
||||
|
||||
# Should not find duplicate when internal_id differs
|
||||
is_duplicate = service._check_duplicate_transaction({"internal_id": "XYZ789"})
|
||||
self.assertFalse(is_duplicate)
|
||||
|
||||
def test_no_duplicate_when_no_transactions_exist(self):
|
||||
"""Test that no duplicate is found when there are no matching transactions."""
|
||||
# Hard delete to bypass signals that require user context
|
||||
self.existing_transaction.hard_delete()
|
||||
|
||||
service = self._create_import_service_with_deduplication(
|
||||
fields=["date", "amount"], match_type="lax"
|
||||
)
|
||||
|
||||
is_duplicate = service._check_duplicate_transaction(
|
||||
{
|
||||
"date": date(2024, 1, 15),
|
||||
"amount": Decimal("100.00"),
|
||||
}
|
||||
)
|
||||
self.assertFalse(is_duplicate)
|
||||
|
||||
def test_deduplication_with_missing_field_in_data(self):
|
||||
"""Test that missing fields in transaction_data are handled gracefully."""
|
||||
service = self._create_import_service_with_deduplication(
|
||||
fields=["date", "nonexistent_field"], match_type="lax"
|
||||
)
|
||||
|
||||
# Should still work, only checking the fields that exist
|
||||
is_duplicate = service._check_duplicate_transaction(
|
||||
{
|
||||
"date": date(2024, 1, 15),
|
||||
}
|
||||
)
|
||||
self.assertTrue(is_duplicate)
|
||||
|
||||
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
|
||||
@@ -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",
|
||||
),
|
||||
]
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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)
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
|
||||
@@ -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)
|
||||
@@ -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
|
||||
|
||||
@@ -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'],
|
||||
},
|
||||
),
|
||||
]
|
||||
@@ -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,14 +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.forms import ValidationError
|
||||
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 _
|
||||
@@ -33,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):
|
||||
@@ -384,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:
|
||||
@@ -523,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):
|
||||
@@ -871,10 +937,8 @@ class RecurringTransaction(models.Model):
|
||||
notes=self.notes if self.add_notes_to_transaction else "",
|
||||
owner=self.account.owner,
|
||||
)
|
||||
if self.tags.exists():
|
||||
created_transaction.tags.set(self.tags.all())
|
||||
if self.entities.exists():
|
||||
created_transaction.entities.set(self.entities.all())
|
||||
created_transaction.tags.set(self.tags.all())
|
||||
created_transaction.entities.set(self.entities.all())
|
||||
|
||||
def get_recurrence_delta(self):
|
||||
if self.recurrence_type == self.RecurrenceType.DAY:
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
+73
-3
@@ -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,24 +141,33 @@ 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"
|
||||
self.helper.layout = Layout(
|
||||
"language",
|
||||
"timezone",
|
||||
HTML("<hr />"),
|
||||
HTML('<hr class="hr my-3" />'),
|
||||
"date_format",
|
||||
"datetime_format",
|
||||
"number_format",
|
||||
HTML("<hr />"),
|
||||
HTML('<hr class="hr my-3" />'),
|
||||
"start_page",
|
||||
HTML("<hr />"),
|
||||
"default_account",
|
||||
HTML('<hr class="hr my-3" />'),
|
||||
"volume",
|
||||
FormActions(
|
||||
NoClassSubmit("submit", _("Save"), css_class="btn btn-primary"),
|
||||
@@ -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
@@ -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()
|
||||
|
||||
@@ -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())
|
||||
@@ -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
@@ -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."
|
||||
}
|
||||
+739
-329
File diff suppressed because it is too large
Load Diff
+656
-293
File diff suppressed because it is too large
Load Diff
+705
-304
File diff suppressed because it is too large
Load Diff
+695
-312
File diff suppressed because it is too large
Load Diff
+870
-469
File diff suppressed because it is too large
Load Diff
+655
-293
File diff suppressed because it is too large
Load Diff
+745
-315
File diff suppressed because it is too large
Load Diff
+666
-302
File diff suppressed because it is too large
Load Diff
+1253
-807
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
+658
-293
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
+700
-293
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 %}"
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user