Compare commits

...

131 Commits

Author SHA1 Message Date
Herculino Trotta
621799f445 locale(Portuguese (Brazil)): update translation
Currently translated at 100.0% (608 of 608 strings)

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

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

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

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

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

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

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

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

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

View File

@@ -20,6 +20,10 @@ on:
env:
IMAGE_NAME: wygiwyh
concurrency:
group: release
cancel-in-progress: false
jobs:
build-and-push:
runs-on: ubuntu-latest

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

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

View File

@@ -13,6 +13,7 @@
<a href="#key-features">Features</a> •
<a href="#how-to-use">Usage</a> •
<a href="#how-it-works">How</a> •
<a href="#help-us-translate-wygiwyh">Translate</a> •
<a href="#caveats-and-warnings">Caveats and Warnings</a> •
<a href="#built-with">Built with</a>
</p>
@@ -133,6 +134,14 @@ To create the first user, open the container's console using Unraid's UI, by cli
Check out our [Wiki](https://github.com/eitchtee/WYGIWYH/wiki) for more information.
# Help us translate WYGIWYH!
<a href="https://translations.herculino.com/engage/wygiwyh/">
<img src="https://translations.herculino.com/widget/wygiwyh/open-graph.png" alt="Translation status" />
</a>
> [!NOTE]
> Login with your github account
# Caveats and Warnings
- I'm not an accountant, some terms and even calculations might be wrong. Make sure to open an issue if you see anything that could be improved.

View File

@@ -55,6 +55,7 @@ INSTALLED_APPS = [
"hijack",
"hijack.contrib.admin",
"django_filters",
"import_export",
"apps.users.apps.UsersConfig",
"procrastinate.contrib.django",
"apps.transactions.apps.TransactionsConfig",
@@ -63,6 +64,7 @@ INSTALLED_APPS = [
"apps.common.apps.CommonConfig",
"apps.net_worth.apps.NetWorthConfig",
"apps.import_app.apps.ImportConfig",
"apps.export_app.apps.ExportConfig",
"apps.api.apps.ApiConfig",
"cachalot",
"rest_framework",
@@ -161,6 +163,7 @@ AUTH_USER_MODEL = "users.User"
LANGUAGE_CODE = "en"
LANGUAGES = (
("de", "Deutsch"),
("en", "English"),
("nl", "Nederlands"),
("pt-br", "Português (Brasil)"),

View File

@@ -49,4 +49,6 @@ urlpatterns = [
path("", include("apps.dca.urls")),
path("", include("apps.mini_tools.urls")),
path("", include("apps.import_app.urls")),
path("", include("apps.export_app.urls")),
path("", include("apps.insights.urls")),
]

View File

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

View File

@@ -77,6 +77,8 @@ class AccountForm(forms.ModelForm):
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self.fields["group"].queryset = AccountGroup.objects.all()
self.helper = FormHelper()
self.helper.form_tag = False
self.helper.form_method = "post"
@@ -151,5 +153,11 @@ class AccountBalanceForm(forms.Form):
decimal_places=self.currency_decimal_places
)
self.fields["category"].queryset = TransactionCategory.objects.filter(
active=True
)
self.fields["tags"].queryset = TransactionTag.objects.filter(active=True)
AccountBalanceFormSet = forms.formset_factory(AccountBalanceForm, extra=0)

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,24 +1,31 @@
from django.conf import settings
from django.core.exceptions import ValidationError
from django.db import models
from django.db.models import Q
from django.utils.translation import gettext_lazy as _
from apps.transactions.models import Transaction
from apps.common.models import SharedObject, SharedObjectManager
class AccountGroup(models.Model):
name = models.CharField(max_length=255, verbose_name=_("Name"), unique=True)
class AccountGroup(SharedObject):
name = models.CharField(max_length=255, verbose_name=_("Name"))
objects = SharedObjectManager()
all_objects = models.Manager() # Unfiltered manager
class Meta:
verbose_name = _("Account Group")
verbose_name_plural = _("Account Groups")
db_table = "account_groups"
unique_together = (("owner", "name"),)
def __str__(self):
return self.name
class Account(models.Model):
name = models.CharField(max_length=255, verbose_name=_("Name"), unique=True)
class Account(SharedObject):
name = models.CharField(max_length=255, verbose_name=_("Name"))
group = models.ForeignKey(
AccountGroup,
on_delete=models.SET_NULL,
@@ -55,9 +62,13 @@ class Account(models.Model):
help_text=_("Archived accounts don't show up nor count towards your net worth"),
)
objects = SharedObjectManager()
all_objects = models.Manager() # Unfiltered manager
class Meta:
verbose_name = _("Account")
verbose_name_plural = _("Accounts")
unique_together = (("owner", "name"),)
def __str__(self):
return self.name

View File

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

View File

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

View File

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

View File

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

View File

View File

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

View File

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

View File

@@ -1,3 +1,5 @@
from decimal import Decimal
from django.utils.translation import gettext_lazy as _
from drf_spectacular import openapi
from drf_spectacular.types import OpenApiTypes
@@ -48,9 +50,9 @@ class TransactionEntitySerializer(serializers.ModelSerializer):
class InstallmentPlanSerializer(serializers.ModelSerializer):
category = TransactionCategoryField(required=False)
tags = TransactionTagField(required=False)
entities = TransactionEntityField(required=False)
category: str | int = TransactionCategoryField(required=False)
tags: str | int = TransactionTagField(required=False)
entities: str | int = TransactionEntityField(required=False)
permission_classes = [IsAuthenticated]
@@ -88,9 +90,9 @@ class InstallmentPlanSerializer(serializers.ModelSerializer):
class RecurringTransactionSerializer(serializers.ModelSerializer):
category = TransactionCategoryField(required=False)
tags = TransactionTagField(required=False)
entities = TransactionEntityField(required=False)
category: str | int = TransactionCategoryField(required=False)
tags: str | int = TransactionTagField(required=False)
entities: str | int = TransactionEntityField(required=False)
class Meta:
model = RecurringTransaction
@@ -127,9 +129,9 @@ class RecurringTransactionSerializer(serializers.ModelSerializer):
class TransactionSerializer(serializers.ModelSerializer):
category = TransactionCategoryField(required=False)
tags = TransactionTagField(required=False)
entities = TransactionEntityField(required=False)
category: str | int = TransactionCategoryField(required=False)
tags: str | int = TransactionTagField(required=False)
entities: str | int = TransactionEntityField(required=False)
exchanged_amount = serializers.SerializerMethodField()
@@ -192,5 +194,5 @@ class TransactionSerializer(serializers.ModelSerializer):
return instance
@staticmethod
def get_exchanged_amount(obj):
def get_exchanged_amount(obj) -> Decimal:
return obj.exchanged_amount()

View File

@@ -1,4 +1,6 @@
from rest_framework import viewsets
from apps.api.custom.pagination import CustomPageNumberPagination
from apps.accounts.models import AccountGroup, Account
from apps.api.serializers import AccountGroupSerializer, AccountSerializer
@@ -6,12 +8,18 @@ from apps.api.serializers import AccountGroupSerializer, AccountSerializer
class AccountGroupViewSet(viewsets.ModelViewSet):
queryset = AccountGroup.objects.all()
serializer_class = AccountGroupSerializer
pagination_class = CustomPageNumberPagination
def get_queryset(self):
return AccountGroup.objects.all().order_by("id")
class AccountViewSet(viewsets.ModelViewSet):
queryset = Account.objects.all()
serializer_class = AccountSerializer
pagination_class = CustomPageNumberPagination
def get_queryset(self):
queryset = super().get_queryset()
return queryset.select_related("group", "currency", "exchange_currency")
return Account.objects.all().select_related(
"group", "currency", "exchange_currency"
)

View File

@@ -1,5 +1,6 @@
from rest_framework import viewsets
from apps.api.custom.pagination import CustomPageNumberPagination
from apps.api.serializers import (
TransactionSerializer,
TransactionCategorySerializer,
@@ -22,6 +23,7 @@ from apps.rules.signals import transaction_updated, transaction_created
class TransactionViewSet(viewsets.ModelViewSet):
queryset = Transaction.objects.all()
serializer_class = TransactionSerializer
pagination_class = CustomPageNumberPagination
def perform_create(self, serializer):
instance = serializer.save()
@@ -35,27 +37,50 @@ 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
def get_queryset(self):
return TransactionCategory.objects.all().order_by("id")
class TransactionTagViewSet(viewsets.ModelViewSet):
queryset = TransactionTag.objects.all()
queryset = TransactionTag.objects.all().order_by("id")
serializer_class = TransactionTagSerializer
pagination_class = CustomPageNumberPagination
def get_queryset(self):
return TransactionTag.objects.all().order_by("id")
class TransactionEntityViewSet(viewsets.ModelViewSet):
queryset = TransactionEntity.objects.all()
queryset = TransactionEntity.objects.all().order_by("id")
serializer_class = TransactionEntitySerializer
pagination_class = CustomPageNumberPagination
def get_queryset(self):
return TransactionEntity.objects.all().order_by("id")
class InstallmentPlanViewSet(viewsets.ModelViewSet):
queryset = InstallmentPlan.objects.all()
queryset = InstallmentPlan.objects.all().order_by("id")
serializer_class = InstallmentPlanSerializer
pagination_class = CustomPageNumberPagination
def get_queryset(self):
return InstallmentPlan.objects.all().order_by("id")
class RecurringTransactionViewSet(viewsets.ModelViewSet):
queryset = RecurringTransaction.objects.all()
queryset = RecurringTransaction.objects.all().order_by("id")
serializer_class = RecurringTransactionSerializer
pagination_class = CustomPageNumberPagination
def get_queryset(self):
return RecurringTransaction.objects.all().order_by("id")

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

@@ -0,0 +1,7 @@
from django.contrib import admin
class SharedObjectModelAdmin(admin.ModelAdmin):
def get_queryset(self, request):
# Use the all_objects manager to show all transactions, including deleted ones
return self.model.all_objects.all()

View File

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

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

@@ -0,0 +1,95 @@
from crispy_forms.bootstrap import FormActions
from django import forms
from django.contrib.auth import get_user_model
from django.utils.translation import gettext_lazy as _
from crispy_forms.helper import FormHelper
from crispy_forms.layout import Layout, Field, Submit, Div, HTML
from apps.common.widgets.tom_select import TomSelect, TomSelectMultiple
from apps.common.models import SharedObject
from apps.common.widgets.crispy.submit import NoClassSubmit
User = get_user_model()
class SharedObjectForm(forms.Form):
"""
Generic form for editing visibility and sharing settings
for models inheriting from SharedObject.
"""
owner = forms.ModelChoiceField(
queryset=User.objects.all(),
required=False,
label=_("Owner"),
widget=TomSelect(clear_button=False),
help_text=_(
"The owner of this object, if empty all users can see, edit and take ownership."
),
)
shared_with_users = forms.ModelMultipleChoiceField(
queryset=User.objects.all(),
required=False,
widget=TomSelectMultiple(clear_button=True),
label=_("Shared with users"),
help_text=_("Select users to share this object with"),
)
visibility = forms.ChoiceField(
choices=SharedObject.Visibility.choices,
required=True,
label=_("Visibility"),
help_text=_(
"Private: Only shown for the owner and shared users. Only editable by the owner."
"<br/>"
"Public: Shown for all users. Only editable by the owner."
),
)
class Meta:
fields = ["visibility", "shared_with_users"]
widgets = {
"visibility": TomSelect(clear_button=False),
}
def __init__(self, *args, **kwargs):
# Get the current user to filter available sharing options
self.user = kwargs.pop("user", None)
self.instance = kwargs.pop("instance", None)
super().__init__(*args, **kwargs)
# Pre-populate shared users if instance exists
if self.instance:
self.fields["shared_with_users"].initial = self.instance.shared_with.all()
self.fields["visibility"].initial = self.instance.visibility
self.fields["owner"].initial = self.instance.owner
# Set up crispy form helper
self.helper = FormHelper()
self.helper.form_method = "post"
self.helper.form_tag = False
self.helper.layout = Layout(
Field("owner"),
Field("visibility"),
HTML("<hr>"),
Field("shared_with_users"),
FormActions(
NoClassSubmit(
"submit", _("Save"), css_class="btn btn-outline-primary w-100"
),
),
)
def save(self):
instance = self.instance
instance.visibility = self.cleaned_data["visibility"]
instance.owner = self.cleaned_data["owner"]
instance.save()
# Clear and set shared_with users
instance.shared_with.set(self.cleaned_data.get("shared_with_users", []))
return instance

View File

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

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

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

View File

@@ -19,6 +19,8 @@ class AirDatePickerInput(widgets.DateInput):
format=None,
clear_button=True,
auto_close=True,
read_only=True,
toggle_selected=None,
*args,
**kwargs,
):
@@ -26,6 +28,10 @@ class AirDatePickerInput(widgets.DateInput):
super().__init__(attrs=attrs, format=format, *args, **kwargs)
self.clear_button = clear_button
self.auto_close = auto_close
self.read_only = read_only
self.toggle_selected = (
toggle_selected if isinstance(toggle_selected, bool) else self.clear_button
)
@staticmethod
def _get_current_language():
@@ -47,9 +53,13 @@ class AirDatePickerInput(widgets.DateInput):
attrs["data-now-button-txt"] = _("Today")
attrs["data-auto-close"] = str(self.auto_close).lower()
attrs["data-clear-button"] = str(self.clear_button).lower()
attrs["data-toggle-selected"] = str(self.toggle_selected).lower()
attrs["data-language"] = self._get_current_language()
attrs["data-date-format"] = django_to_airdatepicker_datetime(self._get_format())
if self.read_only:
attrs["readonly"] = True
return attrs
def format_value(self, value):
@@ -89,6 +99,8 @@ class AirDateTimePickerInput(widgets.DateTimeInput):
timepicker=True,
clear_button=True,
auto_close=True,
read_only=True,
toggle_selected=None,
*args,
**kwargs,
):
@@ -97,6 +109,10 @@ class AirDateTimePickerInput(widgets.DateTimeInput):
self.timepicker = timepicker
self.clear_button = clear_button
self.auto_close = auto_close
self.read_only = read_only
self.toggle_selected = (
toggle_selected if isinstance(toggle_selected, bool) else self.clear_button
)
@staticmethod
def _get_current_language():
@@ -123,11 +139,15 @@ class AirDateTimePickerInput(widgets.DateTimeInput):
attrs["data-now-button-txt"] = _("Now")
attrs["data-timepicker"] = str(self.timepicker).lower()
attrs["data-auto-close"] = str(self.auto_close).lower()
attrs["data-toggle-selected"] = str(self.toggle_selected).lower()
attrs["data-clear-button"] = str(self.clear_button).lower()
attrs["data-language"] = self._get_current_language()
attrs["data-date-format"] = date_format
attrs["data-time-format"] = time_format
if self.read_only:
attrs["readonly"] = True
return attrs
def format_value(self, value):
@@ -227,3 +247,56 @@ class AirMonthYearPickerInput(AirDatePickerInput):
except (ValueError, KeyError):
return None
return None
class AirYearPickerInput(AirDatePickerInput):
def __init__(self, attrs=None, format=None, *args, **kwargs):
super().__init__(attrs=attrs, format=format, *args, **kwargs)
# Store the display format for AirDatepicker
self.display_format = "yyyy"
# Store the Python format for internal use
self.python_format = "%Y"
def build_attrs(self, base_attrs, extra_attrs=None):
attrs = super().build_attrs(base_attrs, extra_attrs)
# Add data attributes for AirDatepicker configuration
attrs["data-now-button-txt"] = _("Today")
attrs["data-date-format"] = "yyyy"
return attrs
def format_value(self, value):
"""Format the value for display in the widget."""
if value:
self.attrs["data-value"] = (
value # We use this to dynamically select the initial date on AirDatePicker
)
if value is None:
return ""
if isinstance(value, str):
try:
value = datetime.datetime.strptime(value, "%Y-%m-%d").date()
except ValueError:
return value
if isinstance(value, (datetime.datetime, datetime.date)):
# Use Django's date translation
return f"{value.year}"
return value
def value_from_datadict(self, data, files, name):
"""Convert the value from the widget format back to a format Django can handle."""
value = super().value_from_datadict(data, files, name)
if value:
try:
# Split the value into month name and year
year_str = value
year = int(year_str)
if year:
# Return the first day of the month in Django's expected format
return datetime.date(year, 1, 1).strftime("%Y-%m-%d")
except (ValueError, KeyError):
return None
return None

View File

@@ -1,4 +1,5 @@
from django.forms import widgets, SelectMultiple
from django.urls import reverse
from django.utils.translation import gettext_lazy as _
@@ -129,3 +130,28 @@ class TomSelect(widgets.Select):
class TomSelectMultiple(SelectMultiple, TomSelect):
pass
class TransactionSelect(TomSelect):
def __init__(self, income: bool = True, expense: bool = True, *args, **kwargs):
super().__init__(*args, **kwargs)
self.load_income = income
self.load_expense = expense
self.create = False
def build_attrs(self, base_attrs, extra_attrs=None):
attrs = super().build_attrs(base_attrs, extra_attrs)
if self.load_income and self.load_expense:
attrs["data-load"] = reverse("transactions_search")
elif self.load_income and not self.load_expense:
attrs["data-load"] = reverse(
"transactions_search", kwargs={"filter_type": "income"}
)
elif self.load_expense and not self.load_income:
attrs["data-load"] = reverse(
"transactions_search", kwargs={"filter_type": "expenses"}
)
return attrs

View File

@@ -6,8 +6,10 @@ from django.utils import timezone
from apps.currencies.exchange_rates.providers import (
SynthFinanceProvider,
SynthFinanceStockProvider,
CoinGeckoFreeProvider,
CoinGeckoProProvider,
TransitiveRateProvider,
)
from apps.currencies.models import ExchangeRateService, ExchangeRate, Currency
@@ -17,8 +19,10 @@ logger = logging.getLogger(__name__)
# Map service types to provider classes
PROVIDER_MAPPING = {
"synth_finance": SynthFinanceProvider,
"synth_finance_stock": SynthFinanceStockProvider,
"coingecko_free": CoinGeckoFreeProvider,
"coingecko_pro": CoinGeckoProProvider,
"transitive": TransitiveRateProvider,
}

View File

@@ -3,11 +3,11 @@ import time
import requests
from decimal import Decimal
from typing import Tuple, List
from typing import Tuple, List, Optional, Dict
from django.db.models import QuerySet
from apps.currencies.models import Currency
from apps.currencies.models import Currency, ExchangeRate
from apps.currencies.exchange_rates.base import ExchangeRateProvider
logger = logging.getLogger(__name__)
@@ -150,3 +150,159 @@ class CoinGeckoProProvider(CoinGeckoFreeProvider):
super().__init__(api_key)
self.session = requests.Session()
self.session.headers.update({"x-cg-pro-api-key": api_key})
class SynthFinanceStockProvider(ExchangeRateProvider):
"""Implementation for Synth Finance API Real-Time Prices endpoint (synthfinance.com)"""
BASE_URL = "https://api.synthfinance.com/tickers"
rates_inverted = True
def __init__(self, api_key: str = None):
super().__init__(api_key)
self.session = requests.Session()
self.session.headers.update(
{"Authorization": f"Bearer {self.api_key}", "accept": "application/json"}
)
def get_rates(
self, target_currencies: QuerySet, exchange_currencies: set
) -> List[Tuple[Currency, Currency, Decimal]]:
results = []
for currency in target_currencies:
if currency.exchange_currency not in exchange_currencies:
continue
try:
# Same currency has rate of 1
if currency.code == currency.exchange_currency.code:
rate = Decimal("1")
results.append((currency.exchange_currency, currency, rate))
continue
# Fetch real-time price for this ticker
response = self.session.get(
f"{self.BASE_URL}/{currency.code}/real-time"
)
response.raise_for_status()
data = response.json()
# Use fair market value as the rate
rate = Decimal(data["data"]["fair_market_value"])
results.append((currency.exchange_currency, currency, rate))
# Log API usage
credits_used = data["meta"]["credits_used"]
credits_remaining = data["meta"]["credits_remaining"]
logger.info(
f"Synth Finance API call for {currency.code}: {credits_used} credits used, {credits_remaining} remaining"
)
except requests.RequestException as e:
logger.error(
f"Error fetching rate from Synth Finance API for ticker {currency.code}: {e}",
exc_info=True,
)
except KeyError as e:
logger.error(
f"Unexpected response structure from Synth Finance API for ticker {currency.code}: {e}",
exc_info=True,
)
except Exception as e:
logger.error(
f"Unexpected error processing Synth Finance data for ticker {currency.code}: {e}",
exc_info=True,
)
return results
class TransitiveRateProvider(ExchangeRateProvider):
"""Calculates exchange rates through paths of existing rates"""
rates_inverted = True
def __init__(self, api_key: str = None):
super().__init__(api_key) # API key not needed but maintaining interface
@classmethod
def requires_api_key(cls) -> bool:
return False
def get_rates(
self, target_currencies: QuerySet, exchange_currencies: set
) -> List[Tuple[Currency, Currency, Decimal]]:
results = []
# Get recent rates for building the graph
recent_rates = ExchangeRate.objects.all()
# Build currency graph
currency_graph = self._build_currency_graph(recent_rates)
for target in target_currencies:
if (
not target.exchange_currency
or target.exchange_currency not in exchange_currencies
):
continue
# Find path and calculate rate
from_id = target.exchange_currency.id
to_id = target.id
path, rate = self._find_conversion_path(currency_graph, from_id, to_id)
if path and rate:
path_codes = [Currency.objects.get(id=cid).code for cid in path]
logger.info(
f"Found conversion path: {' -> '.join(path_codes)}, rate: {rate}"
)
results.append((target.exchange_currency, target, rate))
else:
logger.debug(
f"No conversion path found for {target.exchange_currency.code}->{target.code}"
)
return results
@staticmethod
def _build_currency_graph(rates) -> Dict[int, Dict[int, Decimal]]:
"""Build a graph representation of currency relationships"""
graph = {}
for rate in rates:
# Add both directions to make the graph bidirectional
if rate.from_currency_id not in graph:
graph[rate.from_currency_id] = {}
graph[rate.from_currency_id][rate.to_currency_id] = rate.rate
if rate.to_currency_id not in graph:
graph[rate.to_currency_id] = {}
graph[rate.to_currency_id][rate.from_currency_id] = Decimal("1") / rate.rate
return graph
@staticmethod
def _find_conversion_path(
graph, from_id, to_id
) -> Tuple[Optional[list], Optional[Decimal]]:
"""Find the shortest path between currencies using breadth-first search"""
if from_id not in graph or to_id not in graph:
return None, None
queue = [(from_id, [from_id], Decimal("1"))]
visited = {from_id}
while queue:
current, path, current_rate = queue.pop(0)
if current == to_id:
return path, current_rate
for neighbor, rate in graph.get(current, {}).items():
if neighbor not in visited:
visited.add(neighbor)
queue.append((neighbor, path + [neighbor], current_rate * rate))
return None, None

View File

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

View File

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

View File

@@ -92,8 +92,10 @@ class ExchangeRateService(models.Model):
class ServiceType(models.TextChoices):
SYNTH_FINANCE = "synth_finance", "Synth Finance"
SYNTH_FINANCE_STOCK = "synth_finance_stock", "Synth Finance Stock"
COINGECKO_FREE = "coingecko_free", "CoinGecko (Demo/Free)"
COINGECKO_PRO = "coingecko_pro", "CoinGecko (Pro)"
TRANSITIVE = "transitive", "Transitive (Calculated from Existing Rates)"
class IntervalType(models.TextChoices):
ON = "on", _("On")
@@ -204,11 +206,11 @@ class ExchangeRateService(models.Model):
}
)
hours = int(self.fetch_interval)
if hours < 0 or hours > 23:
if hours < 1 or hours > 24:
raise ValidationError(
{
"fetch_interval": _(
"'Every X hours' interval must be between 0 and 23."
"'Every X hours' interval must be between 1 and 24."
)
}
)

View File

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

View File

@@ -1,14 +1,22 @@
from crispy_forms.bootstrap import FormActions
from crispy_bootstrap5.bootstrap5 import Switch, BS5Accordion
from crispy_forms.bootstrap import FormActions, AccordionGroup
from crispy_forms.helper import FormHelper
from crispy_forms.layout import Layout, Row, Column
from crispy_forms.layout import Layout, Row, Column, HTML
from django import forms
from django.utils.translation import gettext_lazy as _
from apps.accounts.models import Account
from apps.common.widgets.crispy.submit import NoClassSubmit
from apps.common.widgets.datepicker import AirDatePickerInput
from apps.common.widgets.decimal import ArbitraryDecimalDisplayNumberInput
from apps.common.widgets.tom_select import TomSelect
from apps.dca.models import DCAStrategy, DCAEntry
from apps.common.widgets.tom_select import TransactionSelect
from apps.transactions.models import Transaction, TransactionTag, TransactionCategory
from apps.common.fields.forms.dynamic_select import (
DynamicModelChoiceField,
DynamicModelMultipleChoiceField,
)
class DCAStrategyForm(forms.ModelForm):
@@ -53,6 +61,75 @@ class DCAStrategyForm(forms.ModelForm):
class DCAEntryForm(forms.ModelForm):
create_transaction = forms.BooleanField(
label=_("Create transaction"), initial=False, required=False
)
from_account = forms.ModelChoiceField(
queryset=Account.objects.filter(is_archived=False),
label=_("From Account"),
widget=TomSelect(clear_button=False, group_by="group"),
required=False,
)
to_account = forms.ModelChoiceField(
queryset=Account.objects.filter(is_archived=False),
label=_("To Account"),
widget=TomSelect(clear_button=False, group_by="group"),
required=False,
)
from_category = DynamicModelChoiceField(
create_field="name",
model=TransactionCategory,
required=False,
label=_("Category"),
queryset=TransactionCategory.objects.filter(active=True),
)
to_category = DynamicModelChoiceField(
create_field="name",
model=TransactionCategory,
required=False,
label=_("Category"),
queryset=TransactionCategory.objects.filter(active=True),
)
from_tags = DynamicModelMultipleChoiceField(
model=TransactionTag,
to_field_name="name",
create_field="name",
required=False,
label=_("Tags"),
queryset=TransactionTag.objects.filter(active=True),
)
to_tags = DynamicModelMultipleChoiceField(
model=TransactionTag,
to_field_name="name",
create_field="name",
required=False,
label=_("Tags"),
queryset=TransactionTag.objects.filter(active=True),
)
expense_transaction = DynamicModelChoiceField(
model=Transaction,
to_field_name="id",
label=_("Expense Transaction"),
required=False,
queryset=Transaction.objects.none(),
widget=TransactionSelect(clear_button=True, income=False, expense=True),
help_text=_("Type to search for a transaction to link to this entry"),
)
income_transaction = DynamicModelChoiceField(
model=Transaction,
to_field_name="id",
label=_("Income Transaction"),
required=False,
queryset=Transaction.objects.none(),
widget=TransactionSelect(clear_button=True, income=True, expense=False),
help_text=_("Type to search for a transaction to link to this entry"),
)
class Meta:
model = DCAEntry
fields = [
@@ -60,13 +137,19 @@ class DCAEntryForm(forms.ModelForm):
"amount_paid",
"amount_received",
"notes",
"expense_transaction",
"income_transaction",
]
widgets = {
"notes": forms.Textarea(attrs={"rows": 3}),
}
def __init__(self, *args, **kwargs):
strategy = kwargs.pop("strategy", None)
super().__init__(*args, **kwargs)
self.strategy = strategy if strategy else self.instance.strategy
self.helper = FormHelper()
self.helper.form_tag = False
self.helper.layout = Layout(
@@ -75,18 +158,66 @@ class DCAEntryForm(forms.ModelForm):
Column("amount_paid", css_class="form-group col-md-6"),
Column("amount_received", css_class="form-group col-md-6"),
),
Row(
Column("expense_transaction", css_class="form-group col-md-6"),
Column("income_transaction", css_class="form-group col-md-6"),
),
"notes",
BS5Accordion(
AccordionGroup(
_("Create transaction"),
Switch("create_transaction"),
Row(
Column(
Row(
Column(
"from_account",
css_class="form-group",
),
css_class="form-row",
),
Row(
Column(
"from_category",
css_class="form-group col-md-6 mb-0",
),
Column(
"from_tags", css_class="form-group col-md-6 mb-0"
),
css_class="form-row",
),
),
css_class="p-1 mx-1 my-3 border rounded-3",
),
Row(
Column(
Row(
Column(
"to_account",
css_class="form-group",
),
css_class="form-row",
),
Row(
Column(
"to_category", css_class="form-group col-md-6 mb-0"
),
Column("to_tags", css_class="form-group col-md-6 mb-0"),
css_class="form-row",
),
),
css_class="p-1 mx-1 my-3 border rounded-3",
),
active=False,
),
AccordionGroup(
_("Link transaction"),
"income_transaction",
"expense_transaction",
),
flush=False,
always_open=False,
css_class="mb-3",
),
)
if self.instance and self.instance.pk:
# decimal_places = self.instance.account.currency.decimal_places
# self.fields["amount"].widget = ArbitraryDecimalDisplayNumberInput(
# decimal_places=decimal_places
# )
self.helper.layout.append(
FormActions(
NoClassSubmit(
@@ -95,7 +226,6 @@ class DCAEntryForm(forms.ModelForm):
),
)
else:
# self.fields["amount"].widget = ArbitraryDecimalDisplayNumberInput()
self.helper.layout.append(
FormActions(
NoClassSubmit(
@@ -107,3 +237,136 @@ class DCAEntryForm(forms.ModelForm):
self.fields["amount_paid"].widget = ArbitraryDecimalDisplayNumberInput()
self.fields["amount_received"].widget = ArbitraryDecimalDisplayNumberInput()
self.fields["date"].widget = AirDatePickerInput(clear_button=False)
expense_transaction = None
income_transaction = None
if self.instance and self.instance.pk:
# Edit mode - get from instance
expense_transaction = self.instance.expense_transaction
income_transaction = self.instance.income_transaction
elif self.data.get("expense_transaction"):
# Form validation - get from submitted data
try:
expense_transaction = Transaction.objects.get(
id=self.data["expense_transaction"]
)
income_transaction = Transaction.objects.get(
id=self.data["income_transaction"]
)
except Transaction.DoesNotExist:
pass
# If we have a current transaction, ensure it's in the queryset
if income_transaction:
self.fields["income_transaction"].queryset = Transaction.objects.filter(
id=income_transaction.id
)
if expense_transaction:
self.fields["expense_transaction"].queryset = Transaction.objects.filter(
id=expense_transaction.id
)
self.fields["from_account"].queryset = Account.objects.filter(
is_archived=False,
)
self.fields["from_category"].queryset = TransactionCategory.objects.filter(
active=True
)
self.fields["from_tags"].queryset = TransactionTag.objects.filter(active=True)
self.fields["to_account"].queryset = Account.objects.filter(
is_archived=False,
)
self.fields["to_category"].queryset = TransactionCategory.objects.filter(
active=True
)
self.fields["to_tags"].queryset = TransactionTag.objects.filter(active=True)
def clean(self):
cleaned_data = super().clean()
if cleaned_data.get("create_transaction"):
from_account = cleaned_data.get("from_account")
to_account = cleaned_data.get("to_account")
if not from_account and not to_account:
raise forms.ValidationError(
{
"from_account": _("You must provide an account."),
"to_account": _("You must provide an account."),
}
)
elif not from_account and to_account:
raise forms.ValidationError(
{"from_account": _("You must provide an account.")}
)
elif not to_account and from_account:
raise forms.ValidationError(
{"to_account": _("You must provide an account.")}
)
if from_account == to_account:
raise forms.ValidationError(
_("From and To accounts must be different.")
)
return cleaned_data
def save(self, **kwargs):
instance = super().save(commit=False)
if self.cleaned_data.get("create_transaction"):
from_account = self.cleaned_data["from_account"]
to_account = self.cleaned_data["to_account"]
from_amount = instance.amount_paid
to_amount = instance.amount_received
date = instance.date
description = _("DCA for %(strategy_name)s") % {
"strategy_name": self.strategy.name
}
from_category = self.cleaned_data.get("from_category")
to_category = self.cleaned_data.get("to_category")
notes = self.cleaned_data.get("notes")
# Create "From" transaction
from_transaction = Transaction.objects.create(
account=from_account,
type=Transaction.Type.EXPENSE,
is_paid=True,
date=date,
amount=from_amount,
description=description,
category=from_category,
notes=notes,
)
from_transaction.tags.set(self.cleaned_data.get("from_tags", []))
# Create "To" transaction
to_transaction = Transaction.objects.create(
account=to_account,
type=Transaction.Type.INCOME,
is_paid=True,
date=date,
amount=to_amount,
description=description,
category=to_category,
notes=notes,
)
to_transaction.tags.set(self.cleaned_data.get("to_tags", []))
instance.expense_transaction = from_transaction
instance.income_transaction = to_transaction
else:
if instance.expense_transaction:
instance.expense_transaction.amount = instance.amount_paid
instance.expense_transaction.save()
if instance.income_transaction:
instance.income_transaction.amount = instance.amount_received
instance.income_transaction.save()
instance.strategy = self.strategy
instance.save()
return instance

View File

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

View File

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

View File

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

View File

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

View File

View File

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

View File

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

View File

@@ -0,0 +1,198 @@
from crispy_forms.bootstrap import FormActions
from crispy_forms.helper import FormHelper
from crispy_forms.layout import Layout, HTML
from django import forms
from django.utils.translation import gettext_lazy as _
from apps.common.widgets.crispy.submit import NoClassSubmit
class ExportForm(forms.Form):
users = forms.BooleanField(
required=False,
widget=forms.CheckboxInput(),
label=_("Users"),
initial=True,
)
accounts = forms.BooleanField(
required=False,
widget=forms.CheckboxInput(),
label=_("Accounts"),
initial=True,
)
currencies = forms.BooleanField(
required=False,
widget=forms.CheckboxInput(),
label=_("Currencies"),
initial=True,
)
transactions = forms.BooleanField(
required=False,
widget=forms.CheckboxInput(),
label=_("Transactions"),
initial=True,
)
categories = forms.BooleanField(
required=False,
widget=forms.CheckboxInput(),
label=_("Categories"),
initial=True,
)
tags = forms.BooleanField(
required=False,
widget=forms.CheckboxInput(),
label=_("Tags"),
initial=False,
)
entities = forms.BooleanField(
required=False,
widget=forms.CheckboxInput(),
label=_("Entities"),
initial=False,
)
recurring_transactions = forms.BooleanField(
required=False,
widget=forms.CheckboxInput(),
label=_("Recurring Transactions"),
initial=True,
)
installment_plans = forms.BooleanField(
required=False,
widget=forms.CheckboxInput(),
label=_("Installment Plans"),
initial=True,
)
exchange_rates = forms.BooleanField(
required=False,
widget=forms.CheckboxInput(),
label=_("Exchange Rates"),
initial=False,
)
exchange_rates_services = forms.BooleanField(
required=False,
widget=forms.CheckboxInput(),
label=_("Automatic Exchange Rates"),
initial=False,
)
rules = forms.BooleanField(
required=False,
widget=forms.CheckboxInput(),
label=_("Rules"),
initial=True,
)
dca = forms.BooleanField(
required=False,
widget=forms.CheckboxInput(),
label=_("DCA"),
initial=False,
)
import_profiles = forms.BooleanField(
required=False,
widget=forms.CheckboxInput(),
label=_("Import Profiles"),
initial=True,
)
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(
"users",
"accounts",
"currencies",
"transactions",
"categories",
"entities",
"tags",
"installment_plans",
"recurring_transactions",
"exchange_rates_services",
"exchange_rates",
"rules",
"dca",
"import_profiles",
FormActions(
NoClassSubmit(
"submit", _("Export"), css_class="btn btn-outline-primary w-100"
),
),
)
class RestoreForm(forms.Form):
zip_file = forms.FileField(
required=False,
help_text=_("Import a ZIP file exported from WYGIWYH"),
label=_("ZIP File"),
)
users = forms.FileField(required=False, label=_("Users"))
accounts = forms.FileField(required=False, label=_("Accounts"))
currencies = forms.FileField(required=False, label=_("Currencies"))
transactions_categories = forms.FileField(required=False, label=_("Categories"))
transactions_tags = forms.FileField(required=False, label=_("Tags"))
transactions_entities = forms.FileField(required=False, label=_("Entities"))
transactions = forms.FileField(required=False, label=_("Transactions"))
installment_plans = forms.FileField(required=False, label=_("Installment Plans"))
recurring_transactions = forms.FileField(
required=False, label=_("Recurring Transactions")
)
automatic_exchange_rates = forms.FileField(
required=False, label=_("Automatic Exchange Rates")
)
exchange_rates = forms.FileField(required=False, label=_("Exchange Rates"))
transaction_rules = forms.FileField(required=False, label=_("Transaction rules"))
transaction_rules_actions = forms.FileField(
required=False, label=_("Edit transaction action")
)
transaction_rules_update_or_create = forms.FileField(
required=False, label=_("Update or create transaction actions")
)
dca_strategies = forms.FileField(required=False, label=_("DCA Strategies"))
dca_entries = forms.FileField(required=False, label=_("DCA Entries"))
import_profiles = forms.FileField(required=False, label=_("Import Profiles"))
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(
"zip_file",
HTML("<hr />"),
"users",
"accounts",
"currencies",
"transactions",
"transactions_categories",
"transactions_entities",
"transactions_tags",
"installment_plans",
"recurring_transactions",
"automatic_exchange_rates",
"exchange_rates",
"transaction_rules",
"transaction_rules_actions",
"transaction_rules_update_or_create",
"dca_strategies",
"dca_entries",
"import_profiles",
FormActions(
NoClassSubmit(
"submit", _("Restore"), css_class="btn btn-outline-primary w-100"
),
),
)
def clean(self):
cleaned_data = super().clean()
if not cleaned_data.get("zip_file") and not any(
cleaned_data.get(field) for field in self.fields if field != "zip_file"
):
raise forms.ValidationError(
_("Please upload either a ZIP file or at least one CSV file")
)
return cleaned_data

View File

@@ -0,0 +1,3 @@
from django.db import models
# Create your models here.

View File

@@ -0,0 +1,29 @@
from import_export import fields, resources, widgets
from apps.accounts.models import Account, AccountGroup
from apps.export_app.widgets.foreign_key import AutoCreateForeignKeyWidget
from apps.currencies.models import Currency
class AccountResource(resources.ModelResource):
group = fields.Field(
attribute="group",
column_name="group",
widget=AutoCreateForeignKeyWidget(AccountGroup, "name"),
)
currency = fields.Field(
attribute="currency",
column_name="currency",
widget=widgets.ForeignKeyWidget(Currency, "name"),
)
exchange_currency = fields.Field(
attribute="exchange_currency",
column_name="exchange_currency",
widget=widgets.ForeignKeyWidget(Currency, "name"),
)
class Meta:
model = Account
def get_queryset(self):
return Account.all_objects.all()

View File

@@ -0,0 +1,52 @@
from import_export import fields, resources, widgets
from apps.accounts.models import Account
from apps.currencies.models import Currency, ExchangeRate, ExchangeRateService
from apps.export_app.widgets.foreign_key import SkipMissingForeignKeyWidget
from apps.export_app.widgets.numbers import UniversalDecimalWidget
class CurrencyResource(resources.ModelResource):
exchange_currency = fields.Field(
attribute="exchange_currency",
column_name="exchange_currency",
widget=SkipMissingForeignKeyWidget(Currency, "name"),
)
class Meta:
model = Currency
class ExchangeRateResource(resources.ModelResource):
from_currency = fields.Field(
attribute="from_currency",
column_name="from_currency",
widget=widgets.ForeignKeyWidget(Currency, "name"),
)
to_currency = fields.Field(
attribute="to_currency",
column_name="to_currency",
widget=widgets.ForeignKeyWidget(Currency, "name"),
)
rate = fields.Field(
attribute="rate", column_name="rate", widget=UniversalDecimalWidget()
)
class Meta:
model = ExchangeRate
class ExchangeRateServiceResource(resources.ModelResource):
target_currencies = fields.Field(
attribute="target_currencies",
column_name="target_currencies",
widget=widgets.ManyToManyWidget(Currency, field="name"),
)
target_accounts = fields.Field(
attribute="target_accounts",
column_name="target_accounts",
widget=widgets.ManyToManyWidget(Account, field="name"),
)
class Meta:
model = ExchangeRateService

View File

@@ -0,0 +1,38 @@
from import_export import fields, resources
from import_export.widgets import ForeignKeyWidget
from apps.dca.models import DCAStrategy, DCAEntry
from apps.currencies.models import Currency
from apps.export_app.widgets.numbers import UniversalDecimalWidget
class DCAStrategyResource(resources.ModelResource):
target_currency = fields.Field(
attribute="target_currency",
column_name="target_currency",
widget=ForeignKeyWidget(Currency, "name"),
)
payment_currency = fields.Field(
attribute="payment_currency",
column_name="payment_currency",
widget=ForeignKeyWidget(Currency, "name"),
)
class Meta:
model = DCAStrategy
class DCAEntryResource(resources.ModelResource):
amount_paid = fields.Field(
attribute="amount_paid",
column_name="amount_paid",
widget=UniversalDecimalWidget(),
)
amount_received = fields.Field(
attribute="amount_received",
column_name="amount_received",
widget=UniversalDecimalWidget(),
)
class Meta:
model = DCAEntry

View File

@@ -0,0 +1,8 @@
from import_export import resources
from apps.import_app.models import ImportProfile
class ImportProfileResource(resources.ModelResource):
class Meta:
model = ImportProfile

View File

@@ -0,0 +1,25 @@
from import_export import fields, resources
from import_export.widgets import ForeignKeyWidget
from apps.export_app.widgets.foreign_key import AutoCreateForeignKeyWidget
from apps.export_app.widgets.many_to_many import AutoCreateManyToManyWidget
from apps.rules.models import (
TransactionRule,
TransactionRuleAction,
UpdateOrCreateTransactionRuleAction,
)
class TransactionRuleResource(resources.ModelResource):
class Meta:
model = TransactionRule
class TransactionRuleActionResource(resources.ModelResource):
class Meta:
model = TransactionRuleAction
class UpdateOrCreateTransactionRuleResource(resources.ModelResource):
class Meta:
model = UpdateOrCreateTransactionRuleAction

View File

@@ -0,0 +1,158 @@
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.many_to_many import AutoCreateManyToManyWidget
from apps.export_app.widgets.string import EmptyStringToNoneField
from apps.transactions.models import (
Transaction,
TransactionCategory,
TransactionTag,
TransactionEntity,
RecurringTransaction,
InstallmentPlan,
)
from apps.export_app.widgets.numbers import UniversalDecimalWidget
class TransactionResource(resources.ModelResource):
account = fields.Field(
attribute="account",
column_name="account",
widget=ForeignKeyWidget(Account, "name"),
)
category = fields.Field(
attribute="category",
column_name="category",
widget=AutoCreateForeignKeyWidget(TransactionCategory, "name"),
)
tags = fields.Field(
attribute="tags",
column_name="tags",
widget=AutoCreateManyToManyWidget(TransactionTag, field="name"),
)
entities = fields.Field(
attribute="entities",
column_name="entities",
widget=AutoCreateManyToManyWidget(TransactionEntity, field="name"),
)
internal_id = EmptyStringToNoneField(
column_name="internal_id", attribute="internal_id"
)
amount = fields.Field(
attribute="amount",
column_name="amount",
widget=UniversalDecimalWidget(),
)
class Meta:
model = Transaction
def get_queryset(self):
return Transaction.userless_all_objects.all()
class TransactionTagResource(resources.ModelResource):
class Meta:
model = TransactionTag
def get_queryset(self):
return TransactionTag.all_objects.all()
class TransactionEntityResource(resources.ModelResource):
class Meta:
model = TransactionEntity
def get_queryset(self):
return TransactionEntity.all_objects.all()
class TransactionCategoyResource(resources.ModelResource):
class Meta:
model = TransactionCategory
def get_queryset(self):
return TransactionCategory.all_objects.all()
class RecurringTransactionResource(resources.ModelResource):
account = fields.Field(
attribute="account",
column_name="account",
widget=ForeignKeyWidget(Account, "name"),
)
category = fields.Field(
attribute="category",
column_name="category",
widget=AutoCreateForeignKeyWidget(TransactionCategory, "name"),
)
tags = fields.Field(
attribute="tags",
column_name="tags",
widget=AutoCreateManyToManyWidget(TransactionTag, field="name"),
)
entities = fields.Field(
attribute="entities",
column_name="entities",
widget=AutoCreateManyToManyWidget(TransactionEntity, field="name"),
)
amount = fields.Field(
attribute="amount",
column_name="amount",
widget=UniversalDecimalWidget(),
)
class Meta:
model = RecurringTransaction
def get_queryset(self):
return RecurringTransaction.all_objects.all()
class InstallmentPlanResource(resources.ModelResource):
account = fields.Field(
attribute="account",
column_name="account",
widget=ForeignKeyWidget(Account, "name"),
)
category = fields.Field(
attribute="category",
column_name="category",
widget=AutoCreateForeignKeyWidget(TransactionCategory, "name"),
)
tags = fields.Field(
attribute="tags",
column_name="tags",
widget=AutoCreateManyToManyWidget(TransactionTag, field="name"),
)
entities = fields.Field(
attribute="entities",
column_name="entities",
widget=AutoCreateManyToManyWidget(TransactionEntity, field="name"),
)
installment_amount = fields.Field(
attribute="installment_amount",
column_name="installment_amount",
widget=UniversalDecimalWidget(),
)
class Meta:
model = InstallmentPlan
def get_queryset(self):
return InstallmentPlan.all_objects.all()

View File

@@ -0,0 +1,161 @@
from import_export import resources, fields
from django.contrib.auth import get_user_model
from django.conf import settings
from apps.users.models import UserSettings
User = get_user_model()
class UserResource(resources.ModelResource):
# User fields
email = fields.Field(attribute="email", column_name="Email")
# UserSettings fields - for export only
hide_amounts = fields.Field(
attribute="settings__hide_amounts", column_name="Hide Amounts", readonly=True
)
mute_sounds = fields.Field(
attribute="settings__mute_sounds", column_name="Mute Sounds", readonly=True
)
date_format = fields.Field(
attribute="settings__date_format", column_name="Date Format", readonly=True
)
datetime_format = fields.Field(
attribute="settings__datetime_format",
column_name="Datetime Format",
readonly=True,
)
number_format = fields.Field(
attribute="settings__number_format", column_name="Number Format", readonly=True
)
language = fields.Field(
attribute="settings__language", column_name="Language", readonly=True
)
timezone = fields.Field(
attribute="settings__timezone", column_name="Timezone", readonly=True
)
start_page = fields.Field(
attribute="settings__start_page", column_name="Start Page", readonly=True
)
# Human-readable fields for choice values
start_page_display = fields.Field(column_name="Start Page Display", readonly=True)
language_display = fields.Field(column_name="Language Display", readonly=True)
timezone_display = fields.Field(column_name="Timezone Display", readonly=True)
@staticmethod
def dehydrate_start_page_display(user):
if hasattr(user, "settings"):
return dict(UserSettings.StartPage.choices).get(
user.settings.start_page, ""
)
return ""
@staticmethod
def dehydrate_language_display(user):
if hasattr(user, "settings"):
languages = dict([("auto", "Auto")] + list(settings.LANGUAGES))
return languages.get(user.settings.language, user.settings.language)
return ""
@staticmethod
def dehydrate_timezone_display(user):
if hasattr(user, "settings"):
if user.settings.timezone == "auto":
return "Auto"
return user.settings.timezone
return ""
def after_init_instance(self, instance, new, row, **kwargs):
"""
Store settings data on the instance to be used after save
"""
# Process boolean fields properly
hide_amounts = row.get("Hide Amounts", "").lower() == "true"
mute_sounds = row.get("Mute Sounds", "").lower() == "true"
# Store settings data on the instance for later use
instance._settings_data = {
"hide_amounts": hide_amounts,
"mute_sounds": mute_sounds,
"date_format": row.get("Date Format", "SHORT_DATE_FORMAT"),
"datetime_format": row.get("Datetime Format", "SHORT_DATETIME_FORMAT"),
"number_format": row.get("Number Format", "AA"),
"language": row.get("Language", "auto"),
"timezone": row.get("Timezone", "auto"),
"start_page": row.get("Start Page", UserSettings.StartPage.MONTHLY),
}
return instance
def after_save_instance(self, instance, row, **kwargs):
"""
Create or update UserSettings after User is saved
"""
if not hasattr(instance, "_settings_data"):
return
settings_data = instance._settings_data
# Create or update UserSettings
try:
user_settings = UserSettings.objects.get(user=instance)
# Update existing settings
for key, value in settings_data.items():
setattr(user_settings, key, value)
user_settings.save()
except UserSettings.DoesNotExist:
# Create new settings
UserSettings.objects.create(user=instance, **settings_data)
def get_queryset(self):
"""
Ensure settings are prefetched when exporting users
"""
return super().get_queryset().select_related("settings")
class Meta:
model = User
import_id_fields = ["id"]
fields = (
"id",
"email",
"first_name",
"last_name",
"is_active",
"date_joined",
"password",
"hide_amounts",
"mute_sounds",
"date_format",
"datetime_format",
"number_format",
"language",
"language_display",
"timezone",
"timezone_display",
"start_page",
"start_page_display",
)
export_order = (
"id",
"email",
"first_name",
"last_name",
"is_active",
"date_joined",
"password",
"hide_amounts",
"mute_sounds",
"date_format",
"datetime_format",
"number_format",
"language",
"language_display",
"timezone",
"timezone_display",
"start_page",
"start_page_display",
)

View File

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

View File

@@ -0,0 +1,8 @@
from django.urls import path
import apps.export_app.views as views
urlpatterns = [
path("export/", views.export_index, name="export_index"),
path("export/form/", views.export_form, name="export_form"),
path("export/restore/", views.import_form, name="restore_form"),
]

View File

@@ -0,0 +1,292 @@
import logging
import zipfile
from io import BytesIO
from django.contrib import messages
from django.contrib.auth.decorators import login_required, user_passes_test
from django.db import transaction
from django.http import HttpResponse
from django.shortcuts import render
from django.utils import timezone
from django.utils.translation import gettext_lazy as _
from django.views.decorators.http import require_http_methods
from tablib import Dataset
from apps.common.decorators.htmx import only_htmx
from apps.export_app.forms import ExportForm, RestoreForm
from apps.export_app.resources.accounts import AccountResource
from apps.export_app.resources.currencies import (
CurrencyResource,
ExchangeRateResource,
ExchangeRateServiceResource,
)
from apps.export_app.resources.dca import (
DCAStrategyResource,
DCAEntryResource,
)
from apps.export_app.resources.import_app import (
ImportProfileResource,
)
from apps.export_app.resources.rules import (
TransactionRuleResource,
TransactionRuleActionResource,
UpdateOrCreateTransactionRuleResource,
)
from apps.export_app.resources.transactions import (
TransactionResource,
TransactionTagResource,
TransactionEntityResource,
TransactionCategoyResource,
InstallmentPlanResource,
RecurringTransactionResource,
)
from apps.export_app.resources.users import UserResource
logger = logging.getLogger()
@login_required
@user_passes_test(lambda u: u.is_superuser)
@require_http_methods(["GET"])
def export_index(request):
return render(request, "export_app/pages/index.html")
@login_required
@user_passes_test(lambda u: u.is_superuser)
@require_http_methods(["GET", "POST"])
def export_form(request):
timestamp = timezone.localtime(timezone.now()).strftime("%Y-%m-%dT%H-%M-%S")
if request.method == "POST":
form = ExportForm(request.POST)
if form.is_valid():
zip_buffer = BytesIO()
export_users = form.cleaned_data.get("users", False)
export_accounts = form.cleaned_data.get("accounts", False)
export_currencies = form.cleaned_data.get("currencies", False)
export_transactions = form.cleaned_data.get("transactions", False)
export_categories = form.cleaned_data.get("categories", False)
export_tags = form.cleaned_data.get("tags", False)
export_entities = form.cleaned_data.get("entities", False)
export_installment_plans = form.cleaned_data.get("installment_plans", False)
export_recurring_transactions = form.cleaned_data.get(
"recurring_transactions", False
)
export_exchange_rates_services = form.cleaned_data.get(
"exchange_rates_services", False
)
export_exchange_rates = form.cleaned_data.get("exchange_rates", False)
export_rules = form.cleaned_data.get("rules", False)
export_dca = form.cleaned_data.get("dca", False)
export_import_profiles = form.cleaned_data.get("import_profiles", False)
exports = []
if export_users:
exports.append((UserResource().export(), "users"))
if export_accounts:
exports.append((AccountResource().export(), "accounts"))
if export_currencies:
exports.append((CurrencyResource().export(), "currencies"))
if export_transactions:
exports.append((TransactionResource().export(), "transactions"))
if export_categories:
exports.append(
(TransactionCategoyResource().export(), "transactions_categories")
)
if export_tags:
exports.append((TransactionTagResource().export(), "transactions_tags"))
if export_entities:
exports.append(
(TransactionEntityResource().export(), "transactions_entities")
)
if export_installment_plans:
exports.append(
(InstallmentPlanResource().export(), "installment_plans")
)
if export_recurring_transactions:
exports.append(
(RecurringTransactionResource().export(), "recurring_transactions")
)
if export_exchange_rates_services:
exports.append(
(ExchangeRateServiceResource().export(), "automatic_exchange_rates")
)
if export_exchange_rates:
exports.append((ExchangeRateResource().export(), "exchange_rates"))
if export_rules:
exports.append(
(TransactionRuleResource().export(), "transaction_rules")
)
exports.append(
(
TransactionRuleActionResource().export(),
"transaction_rules_actions",
)
)
exports.append(
(
UpdateOrCreateTransactionRuleResource().export(),
"transaction_rules_update_or_create",
)
)
if export_dca:
exports.append((DCAStrategyResource().export(), "dca_strategies"))
exports.append(
(
DCAEntryResource().export(),
"dca_entries",
)
)
if export_import_profiles:
exports.append((ImportProfileResource().export(), "import_profiles"))
if len(exports) >= 2:
with zipfile.ZipFile(zip_buffer, "w", zipfile.ZIP_DEFLATED) as zip_file:
for dataset, name in exports:
zip_file.writestr(f"{name}.csv", dataset.csv)
response = HttpResponse(
zip_buffer.getvalue(),
content_type="application/zip",
headers={
"HX-Trigger": "hide_offcanvas, updated",
"Content-Disposition": f'attachment; filename="{timestamp}_WYGIWYH_export.zip"',
},
)
return response
elif len(exports) == 1:
dataset, name = exports[0]
response = HttpResponse(
dataset.csv,
content_type="text/csv",
headers={
"HX-Trigger": "hide_offcanvas, updated",
"Content-Disposition": f'attachment; filename="{timestamp}_WYGIWYH_export_{name}.csv"',
},
)
return response
else:
return HttpResponse(
_("You have to select at least one export"),
)
else:
form = ExportForm()
return render(request, "export_app/fragments/export.html", context={"form": form})
@only_htmx
@login_required
@user_passes_test(lambda u: u.is_superuser)
@require_http_methods(["GET", "POST"])
def import_form(request):
if request.method == "POST":
form = RestoreForm(request.POST, request.FILES)
if form.is_valid():
try:
process_imports(request, form.cleaned_data)
messages.success(request, _("Data restored successfully"))
return HttpResponse(
status=204,
headers={
"HX-Trigger": "hide_offcanvas, updated",
},
)
except Exception as e:
logger.error("Error importing", exc_info=e)
messages.error(
request,
_(
"There was an error restoring your data. Check the logs for more details."
),
)
else:
form = RestoreForm()
response = render(request, "export_app/fragments/restore.html", {"form": form})
response["HX-Trigger"] = "updated"
return response
def process_imports(request, cleaned_data):
# Define import order to handle dependencies
import_order = [
("users", UserResource),
("currencies", CurrencyResource),
(
"currencies",
CurrencyResource,
), # We do a double pass because exchange_currency may not exist when currency is initially created
("accounts", AccountResource),
("transactions_categories", TransactionCategoyResource),
("transactions_tags", TransactionTagResource),
("transactions_entities", TransactionEntityResource),
("automatic_exchange_rates", ExchangeRateServiceResource),
("exchange_rates", ExchangeRateResource),
("installment_plans", InstallmentPlanResource),
("recurring_transactions", RecurringTransactionResource),
("transactions", TransactionResource),
("dca_strategies", DCAStrategyResource),
("dca_entries", DCAEntryResource),
("import_profiles", ImportProfileResource),
("transaction_rules", TransactionRuleResource),
("transaction_rules_actions", TransactionRuleActionResource),
("transaction_rules_update_or_create", UpdateOrCreateTransactionRuleResource),
]
def import_dataset(content, resource_class, field_name):
try:
# Create a new resource instance
resource = resource_class()
# Create dataset from CSV content
dataset = Dataset()
dataset.load(content, format="csv")
# Perform the import
result = resource.import_data(
dataset,
dry_run=False,
raise_errors=True,
collect_failed_rows=True,
use_transactions=False,
skip_unchanged=True,
)
if result.has_errors():
raise ImportError(f"Failed rows: {result.failed_dataset}")
return result
except Exception as e:
logger.error(f"Error importing {field_name}: {str(e)}")
raise ImportError(f"Error importing {field_name}: {str(e)}")
with transaction.atomic():
files = {}
if zip_file := cleaned_data.get("zip_file"):
# Process ZIP file
with zipfile.ZipFile(zip_file) as z:
for filename in z.namelist():
name = filename.replace(".csv", "")
with z.open(filename) as f:
content = f.read().decode("utf-8")
files[name] = content
for field_name, resource_class in import_order:
if field_name in files.keys():
content = files[field_name]
import_dataset(content, resource_class, field_name)
else:
# Process individual files
for field_name, resource_class in import_order:
if csv_file := cleaned_data.get(field_name):
content = csv_file.read().decode("utf-8")
import_dataset(content, resource_class, field_name)

View File

View File

@@ -0,0 +1,22 @@
from import_export.widgets import ForeignKeyWidget
class AutoCreateForeignKeyWidget(ForeignKeyWidget):
def clean(self, value, row=None, *args, **kwargs):
if value:
try:
return super().clean(value, row, **kwargs)
except self.model.DoesNotExist:
return self.model.objects.create(name=value)
return None
class SkipMissingForeignKeyWidget(ForeignKeyWidget):
def clean(self, value, row=None, *args, **kwargs):
if not value:
return None
try:
return super().clean(value, row, *args, **kwargs)
except self.model.DoesNotExist:
return None

View File

@@ -0,0 +1,21 @@
from import_export.widgets import ManyToManyWidget
class AutoCreateManyToManyWidget(ManyToManyWidget):
def clean(self, value, row=None, *args, **kwargs):
if not value:
return []
values = value.split(self.separator)
cleaned_values = []
for val in values:
val = val.strip()
if val:
try:
obj = self.model.objects.get(**{self.field: val})
except self.model.DoesNotExist:
obj = self.model.objects.create(name=val)
cleaned_values.append(obj)
return cleaned_values

View File

@@ -0,0 +1,18 @@
from decimal import Decimal
from import_export.widgets import NumberWidget
class UniversalDecimalWidget(NumberWidget):
def clean(self, value, row=None, *args, **kwargs):
if self.is_empty(value):
return None
# Replace comma with dot if present
if isinstance(value, str):
value = value.replace(",", ".")
return Decimal(str(value))
def render(self, value, obj=None, **kwargs):
if value is None:
return ""
return str(value).replace(",", ".")

View File

@@ -0,0 +1,7 @@
from import_export import fields
class EmptyStringToNoneField(fields.Field):
def clean(self, data, **kwargs):
value = super().clean(data)
return None if value == "" else value

View File

@@ -268,14 +268,17 @@ class ImportService:
category = TransactionCategory.objects.get(id=category_name)
else: # name
if getattr(category_mapping, "create", False):
category, _ = TransactionCategory.objects.get_or_create(
name=category_name
)
try:
category = TransactionCategory.objects.get(
name=category_name
)
except TransactionCategory.DoesNotExist:
category = TransactionCategory(name=category_name)
category.save()
else:
category = TransactionCategory.objects.filter(
name=category_name
).first()
if category:
data["category"] = category
self.import_run.categories.add(category)
@@ -325,9 +328,13 @@ class ImportService:
tag = TransactionTag.objects.filter(id=tag_name).first()
else: # name
if getattr(tags_mapping, "create", False):
tag, _ = TransactionTag.objects.get_or_create(
name=tag_name.strip()
)
try:
tag = TransactionTag.objects.get(
name=tag_name.strip()
)
except TransactionTag.DoesNotExist:
tag = TransactionTag(name=tag_name.strip())
tag.save()
else:
tag = TransactionTag.objects.filter(
name=tag_name.strip()
@@ -361,9 +368,13 @@ class ImportService:
).first()
else: # name
if getattr(entities_mapping, "create", False):
entity, _ = TransactionEntity.objects.get_or_create(
name=entity_name.strip()
)
try:
entity = TransactionEntity.objects.get(
name=entity_name.strip()
)
except TransactionEntity.DoesNotExist:
entity = TransactionEntity(name=entity_name.strip())
entity.save()
else:
entity = TransactionEntity.objects.filter(
name=entity_name.strip()
@@ -394,7 +405,11 @@ class ImportService:
def _create_account(self, data: Dict[str, Any]) -> Account:
if "group" in data:
group_name = data.pop("group")
group, _ = AccountGroup.objects.get_or_create(name=group_name)
try:
group = AccountGroup.objects.get(name=group_name)
except AccountGroup.DoesNotExist:
group = AccountGroup(name=group_name)
group.save()
data["group"] = group
# Handle currency references

View File

@@ -1,7 +1,9 @@
import logging
from django.contrib.auth import get_user_model
from procrastinate.contrib.django import app
from apps.common.middleware.thread_local import write_current_user, delete_current_user
from apps.import_app.models import ImportRun
from apps.import_app.services import ImportServiceV1
@@ -9,10 +11,15 @@ logger = logging.getLogger(__name__)
@app.task(name="process_import")
def process_import(import_run_id: int, file_path: str):
def process_import(import_run_id: int, file_path: str, user_id: int):
user = get_user_model().objects.get(id=user_id)
write_current_user(user)
try:
import_run = ImportRun.objects.get(id=import_run_id)
import_service = ImportServiceV1(import_run)
import_service.process_file(file_path)
delete_current_user()
except ImportRun.DoesNotExist:
delete_current_user()
raise ValueError(f"ImportRun with id {import_run_id} not found")

View File

@@ -2,7 +2,6 @@ from django.urls import path
import apps.import_app.views as views
urlpatterns = [
path("import/", views.import_view, name="import"),
path(
"import/presets/",
views.import_presets_list,

View File

@@ -15,19 +15,6 @@ from apps.import_app.services import PresetService
from apps.import_app.tasks import process_import
def import_view(request):
import_profile = ImportProfile.objects.get(id=2)
shutil.copyfile(
"/usr/src/app/apps/import_app/teste2.csv", "/usr/src/app/temp/teste2.csv"
)
ir = ImportRun.objects.create(profile=import_profile, file_name="teste.csv")
process_import.defer(
import_run_id=ir.id,
file_path="/usr/src/app/temp/teste2.csv",
)
return HttpResponse("Hello, world. You're at the polls page.")
@login_required
@require_http_methods(["GET"])
def import_presets_list(request):
@@ -189,7 +176,11 @@ def import_run_add(request, profile_id):
import_run = ImportRun.objects.create(profile=profile, file_name=filename)
# Defer the procrastinate task
process_import.defer(import_run_id=import_run.id, file_path=file_path)
process_import.defer(
import_run_id=import_run.id,
file_path=file_path,
user_id=request.user.id,
)
messages.success(request, _("Import Run queued successfully"))

View File

View File

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

View File

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

131
app/apps/insights/forms.py Normal file
View File

@@ -0,0 +1,131 @@
from crispy_forms.helper import FormHelper
from crispy_forms.layout import Layout, Field, Row, Column
from django import forms
from django.utils.translation import gettext_lazy as _
from apps.common.widgets.datepicker import (
AirMonthYearPickerInput,
AirYearPickerInput,
AirDatePickerInput,
)
from apps.transactions.models import TransactionCategory
from apps.common.widgets.tom_select import TomSelect
class SingleMonthForm(forms.Form):
month = forms.DateField(
widget=AirMonthYearPickerInput(clear_button=False), label="", required=True
)
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self.helper = FormHelper()
self.helper.form_tag = False
self.helper.disable_csrf = True
self.helper.layout = Layout(Field("month"))
class SingleYearForm(forms.Form):
year = forms.DateField(
widget=AirYearPickerInput(clear_button=False), label="", required=True
)
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self.helper = FormHelper()
self.helper.form_tag = False
self.helper.disable_csrf = True
self.helper.layout = Layout(Field("year"))
class MonthRangeForm(forms.Form):
month_from = forms.DateField(
widget=AirMonthYearPickerInput(clear_button=False), label="", required=True
)
month_to = forms.DateField(
widget=AirMonthYearPickerInput(clear_button=False), label="", required=True
)
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self.helper = FormHelper()
self.helper.form_tag = False
self.helper.disable_csrf = True
self.helper.layout = Layout(
Row(
Column("month_from", css_class="form-group col-md-6"),
Column("month_to", css_class="form-group col-md-6"),
),
)
class YearRangeForm(forms.Form):
year_from = forms.DateField(
widget=AirYearPickerInput(clear_button=False), label="", required=True
)
year_to = forms.DateField(
widget=AirYearPickerInput(clear_button=False), label="", required=True
)
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self.helper = FormHelper()
self.helper.form_tag = False
self.helper.disable_csrf = True
self.helper.layout = Layout(
Row(
Column("year_from", css_class="form-group col-md-6"),
Column("year_to", css_class="form-group col-md-6"),
),
)
class DateRangeForm(forms.Form):
date_from = forms.DateField(
widget=AirDatePickerInput(clear_button=False), label="", required=True
)
date_to = forms.DateField(
widget=AirDatePickerInput(clear_button=False), label="", required=True
)
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self.helper = FormHelper()
self.helper.form_tag = False
self.helper.disable_csrf = True
self.helper.layout = Layout(
Row(
Column("date_from", css_class="form-group col-md-6"),
Column("date_to", css_class="form-group col-md-6"),
css_class="mb-0",
),
)
class CategoryForm(forms.Form):
category = forms.ModelChoiceField(
required=False,
label=_("Category"),
empty_label=_("Uncategorized"),
queryset=TransactionCategory.objects.filter(active=True),
widget=TomSelect(clear_button=True),
)
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self.helper = FormHelper()
self.helper.form_tag = False
self.helper.disable_csrf = True
self.helper.layout = Layout("category")

View File

View File

@@ -0,0 +1,3 @@
from django.db import models
# Create your models here.

View File

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

52
app/apps/insights/urls.py Normal file
View File

@@ -0,0 +1,52 @@
from django.urls import path
from . import views
urlpatterns = [
path("insights/", views.index, name="insights_index"),
path(
"insights/sankey/account/",
views.sankey_by_account,
name="insights_sankey_by_account",
),
path(
"insights/sankey/currency/",
views.sankey_by_currency,
name="insights_sankey_by_currency",
),
path(
"insights/category-explorer/",
views.category_explorer_index,
name="category_explorer_index",
),
path(
"insights/category-explorer/account/",
views.category_sum_by_account,
name="category_sum_by_account",
),
path(
"insights/category-explorer/currency/",
views.category_sum_by_currency,
name="category_sum_by_currency",
),
path(
"insights/category-overview/",
views.category_overview,
name="category_overview",
),
path(
"insights/late-transactions/",
views.late_transactions,
name="insights_late_transactions",
),
path(
"insights/latest-transactions/",
views.latest_transactions,
name="insights_latest_transactions",
),
path(
"insights/emergency-fund/",
views.emergency_fund,
name="insights_emergency_fund",
),
]

View File

View File

@@ -0,0 +1,161 @@
from django.db.models import Sum, Case, When, F, DecimalField, Value
from django.db.models.functions import Coalesce
from django.utils.translation import gettext_lazy as _
def get_category_sums_by_account(queryset, category=None):
"""
Returns income/expense sums per account for a specific category.
"""
sums = (
queryset.filter(category=category)
.values("account__name")
.annotate(
current_income=Coalesce(
Sum(
Case(
When(type="IN", is_paid=True, then="amount"),
default=Value(0),
output_field=DecimalField(max_digits=42, decimal_places=30),
)
),
Value(0),
output_field=DecimalField(max_digits=42, decimal_places=30),
),
current_expense=Coalesce(
Sum(
Case(
When(type="EX", is_paid=True, then=-F("amount")),
default=Value(0),
output_field=DecimalField(max_digits=42, decimal_places=30),
)
),
Value(0),
output_field=DecimalField(max_digits=42, decimal_places=30),
),
projected_income=Coalesce(
Sum(
Case(
When(type="IN", is_paid=False, then="amount"),
default=Value(0),
output_field=DecimalField(max_digits=42, decimal_places=30),
)
),
Value(0),
output_field=DecimalField(max_digits=42, decimal_places=30),
),
projected_expense=Coalesce(
Sum(
Case(
When(type="EX", is_paid=False, then=-F("amount")),
default=Value(0),
output_field=DecimalField(max_digits=42, decimal_places=30),
)
),
Value(0),
output_field=DecimalField(max_digits=42, decimal_places=30),
),
)
.order_by("account__name")
)
return {
"labels": [item["account__name"] for item in sums],
"datasets": [
{
"label": _("Current Income"),
"data": [float(item["current_income"]) for item in sums],
},
{
"label": _("Current Expenses"),
"data": [float(item["current_expense"]) for item in sums],
},
{
"label": _("Projected Income"),
"data": [float(item["projected_income"]) for item in sums],
},
{
"label": _("Projected Expenses"),
"data": [float(item["projected_expense"]) for item in sums],
},
],
}
def get_category_sums_by_currency(queryset, category=None):
"""
Returns income/expense sums per currency for a specific category.
"""
sums = (
queryset.filter(category=category)
.values("account__currency__name")
.annotate(
current_income=Coalesce(
Sum(
Case(
When(type="IN", is_paid=True, then="amount"),
default=Value(0),
output_field=DecimalField(max_digits=42, decimal_places=30),
)
),
Value(0),
output_field=DecimalField(max_digits=42, decimal_places=30),
),
current_expense=Coalesce(
Sum(
Case(
When(type="EX", is_paid=True, then=-F("amount")),
default=Value(0),
output_field=DecimalField(max_digits=42, decimal_places=30),
)
),
Value(0),
output_field=DecimalField(max_digits=42, decimal_places=30),
),
projected_income=Coalesce(
Sum(
Case(
When(type="IN", is_paid=False, then="amount"),
default=Value(0),
output_field=DecimalField(max_digits=42, decimal_places=30),
)
),
Value(0),
output_field=DecimalField(max_digits=42, decimal_places=30),
),
projected_expense=Coalesce(
Sum(
Case(
When(type="EX", is_paid=False, then=-F("amount")),
default=Value(0),
output_field=DecimalField(max_digits=42, decimal_places=30),
)
),
Value(0),
output_field=DecimalField(max_digits=42, decimal_places=30),
),
)
.order_by("account__currency__name")
)
return {
"labels": [item["account__currency__name"] for item in sums],
"datasets": [
{
"label": _("Current Income"),
"data": [float(item["current_income"]) for item in sums],
},
{
"label": _("Current Expenses"),
"data": [float(item["current_expense"]) for item in sums],
},
{
"label": _("Projected Income"),
"data": [float(item["projected_income"]) for item in sums],
},
{
"label": _("Projected Expenses"),
"data": [float(item["projected_expense"]) for item in sums],
},
],
}

View File

@@ -0,0 +1,165 @@
from decimal import Decimal
from django.db import models
from django.db.models import Sum, Case, When, Value, DecimalField
from django.db.models.functions import Coalesce
from apps.transactions.models import Transaction
from apps.currencies.models import Currency
from apps.currencies.utils.convert import convert
def get_categories_totals(transactions_queryset, ignore_empty=False):
# Get metrics for each category and currency in a single query
category_currency_metrics = (
transactions_queryset.values(
"category",
"category__name",
"account__currency",
"account__currency__code",
"account__currency__name",
"account__currency__decimal_places",
"account__currency__prefix",
"account__currency__suffix",
"account__currency__exchange_currency",
)
.annotate(
expense_current=Coalesce(
Sum(
Case(
When(
type=Transaction.Type.EXPENSE, is_paid=True, then="amount"
),
default=Value(0),
output_field=models.DecimalField(),
)
),
Decimal("0"),
),
expense_projected=Coalesce(
Sum(
Case(
When(
type=Transaction.Type.EXPENSE, is_paid=False, then="amount"
),
default=Value(0),
output_field=models.DecimalField(),
)
),
Decimal("0"),
),
income_current=Coalesce(
Sum(
Case(
When(type=Transaction.Type.INCOME, is_paid=True, then="amount"),
default=Value(0),
output_field=models.DecimalField(),
)
),
Decimal("0"),
),
income_projected=Coalesce(
Sum(
Case(
When(
type=Transaction.Type.INCOME, is_paid=False, then="amount"
),
default=Value(0),
output_field=models.DecimalField(),
)
),
Decimal("0"),
),
)
.order_by("category__name")
)
# Process the results to structure by category
result = {}
for metric in category_currency_metrics:
# Skip empty categories if ignore_empty is True
if ignore_empty and all(
metric[field] == Decimal("0")
for field in [
"expense_current",
"expense_projected",
"income_current",
"income_projected",
]
):
continue
# Calculate derived totals
total_current = metric["income_current"] - metric["expense_current"]
total_projected = metric["income_projected"] - metric["expense_projected"]
total_income = metric["income_current"] + metric["income_projected"]
total_expense = metric["expense_current"] + metric["expense_projected"]
total_final = total_current + total_projected
category_id = metric["category"]
currency_id = metric["account__currency"]
if category_id not in result:
result[category_id] = {"name": metric["category__name"], "currencies": {}}
# Add currency data
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"],
},
"expense_current": metric["expense_current"],
"expense_projected": metric["expense_projected"],
"total_expense": total_expense,
"income_current": metric["income_current"],
"income_projected": metric["income_projected"],
"total_income": total_income,
"total_current": total_current,
"total_projected": total_projected,
"total_final": total_final,
}
# Add exchanged values if exchange_currency exists
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"]
)
exchanged = {}
for field in [
"expense_current",
"expense_projected",
"income_current",
"income_projected",
"total_income",
"total_expense",
"total_current",
"total_projected",
"total_final",
]:
amount, prefix, suffix, decimal_places = convert(
amount=currency_data[field],
from_currency=from_currency,
to_currency=exchange_currency,
)
if amount is not None:
exchanged[field] = amount
if "currency" not in exchanged:
exchanged["currency"] = {
"prefix": prefix,
"suffix": suffix,
"decimal_places": decimal_places,
"code": exchange_currency.code,
"name": exchange_currency.name,
}
if exchanged:
currency_data["exchanged"] = exchanged
result[category_id]["currencies"][currency_id] = currency_data
return result

View File

@@ -0,0 +1,280 @@
from django.utils.translation import gettext_lazy as _
from decimal import Decimal
from typing import Dict, List, TypedDict
class SankeyNode(TypedDict):
name: str
class SankeyFlow(TypedDict):
from_node: str
to_node: str
flow: float
currency: Dict
original_amount: float
percentage: float
def generate_sankey_data_by_account(transactions_queryset):
"""
Generates Sankey diagram data from transaction queryset using account as intermediary.
"""
nodes: Dict[str, Dict] = {}
flows: List[SankeyFlow] = []
# Aggregate transactions
income_data = {} # {(category, currency, account) -> amount}
expense_data = {} # {(category, currency, account) -> amount}
total_income_by_currency = {} # {currency -> amount}
total_expense_by_currency = {} # {currency -> amount}
total_volume_by_currency = {} # {currency -> amount}
for transaction in transactions_queryset:
currency = transaction.account.currency
account = transaction.account
category = transaction.category or _("Uncategorized")
key = (category, currency, account)
amount = transaction.amount
if transaction.type == "IN":
income_data[key] = income_data.get(key, Decimal("0")) + amount
total_income_by_currency[currency] = (
total_income_by_currency.get(currency, Decimal("0")) + amount
)
else:
expense_data[key] = expense_data.get(key, Decimal("0")) + amount
total_expense_by_currency[currency] = (
total_expense_by_currency.get(currency, Decimal("0")) + amount
)
total_volume_by_currency[currency] = (
total_volume_by_currency.get(currency, Decimal("0")) + amount
)
unique_accounts = {
account_id: idx
for idx, account_id in enumerate(
transactions_queryset.values_list("account", flat=True).distinct()
)
}
def get_node_priority(node_id: str) -> int:
"""Get priority based on the account ID embedded in the node ID."""
account_id = int(node_id.split("_")[-1])
return unique_accounts[account_id]
def get_node_id(node_type: str, name: str, account_id: int) -> str:
"""Generate unique node ID."""
return f"{node_type}_{name}_{account_id}".lower().replace(" ", "_")
def add_node(node_id: str, display_name: str) -> None:
"""Add node with ID, display name and priority."""
nodes[node_id] = {
"id": node_id,
"name": display_name,
"priority": get_node_priority(node_id),
}
def add_flow(
from_node_id: str, to_node_id: str, amount: Decimal, currency, is_income: bool
) -> None:
"""
Add flow with percentage based on total transaction volume for the specific currency.
"""
total_volume = total_volume_by_currency.get(currency, Decimal("0"))
percentage = (amount / total_volume) * 100 if total_volume else 0
scaled_flow = percentage / 100
flows.append(
{
"from_node": from_node_id,
"to_node": to_node_id,
"flow": float(scaled_flow),
"currency": {
"code": currency.code,
"prefix": currency.prefix,
"suffix": currency.suffix,
"decimal_places": currency.decimal_places,
},
"original_amount": float(amount),
"percentage": float(percentage),
}
)
# Process income
for (category, currency, account), amount in income_data.items():
category_node_id = get_node_id("income", category, account.id)
account_node_id = get_node_id("account", account.name, account.id)
add_node(category_node_id, str(category))
add_node(account_node_id, account.name)
add_flow(category_node_id, account_node_id, amount, currency, is_income=True)
# Process expenses
for (category, currency, account), amount in expense_data.items():
category_node_id = get_node_id("expense", category, account.id)
account_node_id = get_node_id("account", account.name, account.id)
add_node(category_node_id, str(category))
add_node(account_node_id, account.name)
add_flow(account_node_id, category_node_id, amount, currency, is_income=False)
# Calculate and add savings flows
savings_data = {} # {(account, currency) -> amount}
for (category, currency, account), amount in income_data.items():
key = (account, currency)
savings_data[key] = savings_data.get(key, Decimal("0")) + amount
for (category, currency, account), amount in expense_data.items():
key = (account, currency)
savings_data[key] = savings_data.get(key, Decimal("0")) - amount
for (account, currency), amount in savings_data.items():
if amount > 0:
account_node_id = get_node_id("account", account.name, account.id)
savings_node_id = get_node_id("savings", _("Saved"), account.id)
add_node(savings_node_id, str(_("Saved")))
add_flow(account_node_id, savings_node_id, amount, currency, is_income=True)
# Calculate total across all currencies (for reference only)
total_amount = sum(float(amount) for amount in total_income_by_currency.values())
return {
"nodes": list(nodes.values()),
"flows": flows,
"total_amount": total_amount,
"total_by_currency": {
curr.code: float(amount)
for curr, amount in total_income_by_currency.items()
},
}
def generate_sankey_data_by_currency(transactions_queryset):
"""
Generates Sankey diagram data from transaction queryset, using currency as intermediary.
"""
nodes: Dict[str, Dict] = {}
flows: List[SankeyFlow] = []
# Aggregate transactions
income_data = {} # {(category, currency) -> amount}
expense_data = {} # {(category, currency) -> amount}
total_income_by_currency = {} # {currency -> amount}
total_expense_by_currency = {} # {currency -> amount}
total_volume_by_currency = {} # {currency -> amount}
for transaction in transactions_queryset:
currency = transaction.account.currency
category = transaction.category or _("Uncategorized")
key = (category, currency)
amount = transaction.amount
if transaction.type == "IN":
income_data[key] = income_data.get(key, Decimal("0")) + amount
total_income_by_currency[currency] = (
total_income_by_currency.get(currency, Decimal("0")) + amount
)
else:
expense_data[key] = expense_data.get(key, Decimal("0")) + amount
total_expense_by_currency[currency] = (
total_expense_by_currency.get(currency, Decimal("0")) + amount
)
total_volume_by_currency[currency] = (
total_volume_by_currency.get(currency, Decimal("0")) + amount
)
unique_currencies = {
currency_id: idx
for idx, currency_id in enumerate(
transactions_queryset.values_list("account__currency", flat=True).distinct()
)
}
def get_node_priority(node_id: str) -> int:
"""Get priority based on the currency ID embedded in the node ID."""
currency_id = int(node_id.split("_")[-1])
return unique_currencies[currency_id]
def get_node_id(node_type: str, name: str, currency_id: int) -> str:
"""Generate unique node ID including currency information."""
return f"{node_type}_{name}_{currency_id}".lower().replace(" ", "_")
def add_node(node_id: str, display_name: str) -> None:
"""Add node with ID, display name and priority."""
nodes[node_id] = {
"id": node_id,
"name": display_name,
"priority": get_node_priority(node_id),
}
def add_flow(
from_node_id: str, to_node_id: str, amount: Decimal, currency, is_income: bool
) -> None:
"""
Add flow with percentage based on total transaction volume for the specific currency.
"""
total_volume = total_volume_by_currency.get(currency, Decimal("0"))
percentage = (amount / total_volume) * 100 if total_volume else 0
scaled_flow = percentage / 100
flows.append(
{
"from_node": from_node_id,
"to_node": to_node_id,
"flow": float(scaled_flow),
"currency": {
"code": currency.code,
"name": currency.name,
"prefix": currency.prefix,
"suffix": currency.suffix,
"decimal_places": currency.decimal_places,
},
"original_amount": float(amount),
"percentage": float(percentage),
}
)
# Process income
for (category, currency), amount in income_data.items():
category_node_id = get_node_id("income", category, currency.id)
currency_node_id = get_node_id("currency", currency.name, currency.id)
add_node(category_node_id, str(category))
add_node(currency_node_id, currency.name)
add_flow(category_node_id, currency_node_id, amount, currency, is_income=True)
# Process expenses
for (category, currency), amount in expense_data.items():
category_node_id = get_node_id("expense", category, currency.id)
currency_node_id = get_node_id("currency", currency.name, currency.id)
add_node(category_node_id, str(category))
add_node(currency_node_id, currency.name)
add_flow(currency_node_id, category_node_id, amount, currency, is_income=False)
# Calculate and add savings flows
savings_data = {} # {currency -> amount}
for (category, currency), amount in income_data.items():
savings_data[currency] = savings_data.get(currency, Decimal("0")) + amount
for (category, currency), amount in expense_data.items():
savings_data[currency] = savings_data.get(currency, Decimal("0")) - amount
for currency, amount in savings_data.items():
if amount > 0:
currency_node_id = get_node_id("currency", currency.name, currency.id)
savings_node_id = get_node_id("savings", _("Saved"), currency.id)
add_node(savings_node_id, str(_("Saved")))
add_flow(
currency_node_id, savings_node_id, amount, currency, is_income=True
)
# Calculate total across all currencies (for reference only)
total_amount = sum(float(amount) for amount in total_income_by_currency.values())
return {
"nodes": list(nodes.values()),
"flows": flows,
"total_amount": total_amount,
"total_by_currency": {
curr.name: float(amount)
for curr, amount in total_income_by_currency.items()
},
}

View File

@@ -0,0 +1,96 @@
from django.db.models import Q
from django.utils import timezone
from dateutil.relativedelta import relativedelta
from apps.transactions.models import Transaction
from apps.insights.forms import (
SingleMonthForm,
SingleYearForm,
MonthRangeForm,
YearRangeForm,
DateRangeForm,
)
def get_transactions(request, include_unpaid=True, include_silent=False):
transactions = Transaction.objects.all()
filter_type = request.GET.get("type", None)
if filter_type is not None:
if filter_type == "month":
form = SingleMonthForm(request.GET)
if form.is_valid():
month = form.cleaned_data["month"].replace(day=1)
else:
month = timezone.localdate(timezone.now()).replace(day=1)
transactions = transactions.filter(
reference_date__month=month.month, reference_date__year=month.year
)
elif filter_type == "year":
form = SingleYearForm(request.GET)
if form.is_valid():
year = form.cleaned_data["year"].replace(day=1, month=1)
else:
year = timezone.localdate(timezone.now()).replace(day=1, month=1)
transactions = transactions.filter(reference_date__year=year.year)
elif filter_type == "month-range":
form = MonthRangeForm(request.GET)
if form.is_valid():
month_from = form.cleaned_data["month_from"].replace(day=1)
month_to = form.cleaned_data["month_to"].replace(day=1)
else:
month_from = timezone.localdate(timezone.now()).replace(day=1)
month_to = (
timezone.localdate(timezone.now()) + relativedelta(months=1)
).replace(day=1)
transactions = transactions.filter(
reference_date__gte=month_from,
reference_date__lte=month_to,
)
elif filter_type == "year-range":
form = YearRangeForm(request.GET)
if form.is_valid():
year_from = form.cleaned_data["year_from"].replace(day=1, month=1)
year_to = form.cleaned_data["year_to"].replace(day=31, month=12)
else:
year_from = timezone.localdate(timezone.now()).replace(day=1, month=1)
year_to = (
timezone.localdate(timezone.now()) + relativedelta(years=1)
).replace(day=31, month=12)
transactions = transactions.filter(
reference_date__gte=year_from,
reference_date__lte=year_to,
)
elif filter_type == "date-range":
form = DateRangeForm(request.GET)
if form.is_valid():
date_from = form.cleaned_data["date_from"]
date_to = form.cleaned_data["date_to"]
else:
date_from = timezone.localdate(timezone.now())
date_to = timezone.localdate(timezone.now()) + relativedelta(months=1)
transactions = transactions.filter(
date__gte=date_from,
date__lte=date_to,
)
else: # Default to current month
month = timezone.localdate(timezone.now())
transactions = transactions.filter(
reference_date__month=month.month, reference_date__year=month.year
)
if not include_unpaid:
transactions = transactions.filter(is_paid=True)
if not include_silent:
transactions = transactions.exclude(Q(category__mute=True) & ~Q(category=None))
return transactions

266
app/apps/insights/views.py Normal file
View File

@@ -0,0 +1,266 @@
import decimal
import json
from collections import defaultdict
from dateutil.relativedelta import relativedelta
from django.contrib.auth.decorators import login_required
from django.db.models import Sum, Avg, F
from django.shortcuts import render
from django.utils import timezone
from django.views.decorators.http import require_http_methods
from apps.common.decorators.htmx import only_htmx
from apps.insights.forms import (
SingleMonthForm,
SingleYearForm,
MonthRangeForm,
YearRangeForm,
DateRangeForm,
CategoryForm,
)
from apps.insights.utils.category_explorer import (
get_category_sums_by_account,
get_category_sums_by_currency,
)
from apps.insights.utils.sankey import (
generate_sankey_data_by_account,
generate_sankey_data_by_currency,
)
from apps.insights.utils.transactions import get_transactions
from apps.transactions.models import TransactionCategory, Transaction
from apps.insights.utils.category_overview import get_categories_totals
from apps.transactions.utils.calculations import calculate_currency_totals
@login_required
@require_http_methods(["GET"])
def index(request):
date = timezone.localdate(timezone.now())
month_form = SingleMonthForm(initial={"month": date.replace(day=1)})
year_form = SingleYearForm(initial={"year": date.replace(day=1)})
month_range_form = MonthRangeForm(
initial={
"month_from": date.replace(day=1),
"month_to": date.replace(day=1) + relativedelta(months=1),
}
)
year_range_form = YearRangeForm(
initial={
"year_from": date.replace(day=1, month=1),
"year_to": date.replace(day=1, month=1) + relativedelta(years=1),
}
)
date_range_form = DateRangeForm(
initial={
"date_from": date,
"date_to": date + relativedelta(months=1),
}
)
return render(
request,
"insights/pages/index.html",
context={
"month_form": month_form,
"year_form": year_form,
"month_range_form": month_range_form,
"year_range_form": year_range_form,
"date_range_form": date_range_form,
},
)
@only_htmx
@login_required
@require_http_methods(["GET"])
def sankey_by_account(request):
# Get filtered transactions
transactions = get_transactions(request)
# Generate Sankey data
sankey_data = generate_sankey_data_by_account(transactions)
return render(
request,
"insights/fragments/sankey.html",
{"sankey_data": sankey_data, "type": "account"},
)
@only_htmx
@login_required
@require_http_methods(["GET"])
def sankey_by_currency(request):
# Get filtered transactions
transactions = get_transactions(request)
# Generate Sankey data
sankey_data = generate_sankey_data_by_currency(transactions)
return render(
request,
"insights/fragments/sankey.html",
{"sankey_data": sankey_data, "type": "currency"},
)
@only_htmx
@login_required
@require_http_methods(["GET"])
def category_explorer_index(request):
category_form = CategoryForm()
return render(
request,
"insights/fragments/category_explorer/index.html",
{"category_form": category_form},
)
@only_htmx
@login_required
@require_http_methods(["GET"])
def category_sum_by_account(request):
# Get filtered transactions
transactions = get_transactions(request, include_silent=True)
category = request.GET.get("category")
if category:
category = TransactionCategory.objects.get(id=category)
# Generate data
account_data = get_category_sums_by_account(transactions, category)
else:
account_data = get_category_sums_by_account(transactions, category=None)
return render(
request,
"insights/fragments/category_explorer/charts/account.html",
{"account_data": account_data},
)
@only_htmx
@login_required
@require_http_methods(["GET"])
def category_sum_by_currency(request):
# Get filtered transactions
transactions = get_transactions(request, include_silent=True)
category = request.GET.get("category")
if category:
category = TransactionCategory.objects.get(id=category)
# Generate data
currency_data = get_category_sums_by_currency(transactions, category)
else:
currency_data = get_category_sums_by_currency(transactions, category=None)
return render(
request,
"insights/fragments/category_explorer/charts/currency.html",
{"currency_data": currency_data},
)
@only_htmx
@login_required
@require_http_methods(["GET"])
def category_overview(request):
# Get filtered transactions
transactions = get_transactions(request, include_silent=True)
total_table = get_categories_totals(
transactions_queryset=transactions, ignore_empty=False
)
return render(
request,
"insights/fragments/category_overview/index.html",
{"total_table": total_table},
)
@only_htmx
@login_required
@require_http_methods(["GET"])
def latest_transactions(request):
limit = timezone.now() - relativedelta(days=3)
transactions = Transaction.objects.filter(created_at__gte=limit).order_by("-id")[
:30
]
return render(
request,
"insights/fragments/latest_transactions.html",
{"transactions": transactions},
)
@only_htmx
@login_required
@require_http_methods(["GET"])
def late_transactions(request):
now = timezone.localdate(timezone.now())
transactions = Transaction.objects.filter(is_paid=False, date__lt=now)
return render(
request,
"insights/fragments/late_transactions.html",
{"transactions": transactions},
)
@only_htmx
@login_required
@require_http_methods(["GET"])
def emergency_fund(request):
transactions_currency_queryset = Transaction.objects.filter(
is_paid=True, account__is_archived=False, account__is_asset=False
).order_by(
"account__currency__name",
)
currency_net_worth = calculate_currency_totals(
transactions_queryset=transactions_currency_queryset, ignore_empty=False
)
end_date = (timezone.now() - relativedelta(months=1)).replace(day=1)
start_date = (end_date - relativedelta(months=12)).replace(day=1)
# Step 1: Calculate total expense for each month and currency
monthly_expenses = (
Transaction.objects.filter(
type=Transaction.Type.EXPENSE,
is_paid=True,
account__is_asset=False,
reference_date__gte=start_date,
reference_date__lte=end_date,
category__mute=False,
)
.values("reference_date", "account__currency")
.annotate(monthly_total=Sum("amount"))
)
# Step 2: Calculate averages by currency using Python
currency_totals = defaultdict(list)
for expense in monthly_expenses:
currency_id = expense["account__currency"]
currency_totals[currency_id].append(expense["monthly_total"])
for currency_id, totals in currency_totals.items():
avg = currency_net_worth[currency_id]["average"] = sum(totals) / len(totals)
if currency_net_worth[currency_id]["total_current"] < 0:
currency_net_worth[currency_id]["months"] = 0
else:
currency_net_worth[currency_id]["months"] = int(
currency_net_worth[currency_id]["total_current"] / avg
)
return render(
request,
"insights/fragments/emergency_fund.html",
{"data": currency_net_worth},
)

View File

View File

@@ -0,0 +1,85 @@
from typing import Dict
from django.db.models import Func, F, Value
from django.db.models.functions import Extract
from django.utils import timezone
from apps.currencies.models import ExchangeRate
def get_currency_exchange_map(date=None) -> Dict[str, dict]:
"""
Creates a nested dictionary of exchange rates and currency information.
Returns:
{
'BTC': {
'decimal_places': 8,
'prefix': '',
'suffix': '',
'rates': {'USD': Decimal('34000.00'), 'EUR': Decimal('31000.00')}
},
'USD': {
'decimal_places': 2,
'prefix': '$',
'suffix': '',
'rates': {'BTC': Decimal('0.0000294'), 'EUR': Decimal('0.91')}
},
...
}
"""
if date is None:
date = timezone.localtime(timezone.now())
# Get all exchange rates for the closest date
exchange_rates = (
ExchangeRate.objects.select_related(
"from_currency", "to_currency"
) # Optimize currency queries
.annotate(
date_diff=Func(Extract(F("date") - Value(date), "epoch"), function="ABS"),
effective_rate=F("rate"),
)
.order_by("from_currency", "to_currency", "date_diff")
.distinct("from_currency", "to_currency")
)
# Initialize the result dictionary
rate_map = {}
# Build the exchange rate mapping with currency info
for rate in exchange_rates:
# Add from_currency info if not exists
if rate.from_currency.name not in rate_map:
rate_map[rate.from_currency.name] = {
"decimal_places": rate.from_currency.decimal_places,
"prefix": rate.from_currency.prefix,
"suffix": rate.from_currency.suffix,
"rates": {},
}
# Add to_currency info if not exists
if rate.to_currency.name not in rate_map:
rate_map[rate.to_currency.name] = {
"decimal_places": rate.to_currency.decimal_places,
"prefix": rate.to_currency.prefix,
"suffix": rate.to_currency.suffix,
"rates": {},
}
# Add direct rate
rate_map[rate.from_currency.name]["rates"][rate.to_currency.name] = {
"rate": rate.rate,
"decimal_places": rate.to_currency.decimal_places,
"prefix": rate.to_currency.prefix,
"suffix": rate.to_currency.suffix,
}
# Add inverse rate
rate_map[rate.to_currency.name]["rates"][rate.from_currency.name] = {
"rate": 1 / rate.rate,
"decimal_places": rate.from_currency.decimal_places,
"prefix": rate.from_currency.prefix,
"suffix": rate.from_currency.suffix,
}
return rate_map

View File

@@ -5,6 +5,7 @@ from apps.common.widgets.decimal import convert_to_decimal
from apps.currencies.models import Currency
from apps.currencies.utils.convert import convert
from apps.mini_tools.forms import CurrencyConverterForm
from apps.mini_tools.utils.exchange_rate_map import get_currency_exchange_map
@login_required
@@ -14,11 +15,13 @@ def unit_price_calculator(request):
@login_required
def currency_converter(request):
rate_map = get_currency_exchange_map()
form = CurrencyConverterForm()
return render(
request,
"mini_tools/currency_converter/currency_converter.html",
context={"form": form},
context={"form": form, "rate_map": rate_map},
)

View File

@@ -77,22 +77,20 @@ def transactions_list(request, month: int, year: int):
request.session["monthly_transactions_order"] = order
f = TransactionsFilter(request.GET)
transactions_filtered = (
f.qs.filter()
.filter(
reference_date__year=year,
reference_date__month=month,
)
.prefetch_related(
"account",
"account__group",
"category",
"tags",
"account__exchange_currency",
"account__currency",
"installment_plan",
"entities",
)
transactions_filtered = f.qs.filter(
reference_date__year=year,
reference_date__month=month,
).prefetch_related(
"account",
"account__group",
"category",
"tags",
"account__exchange_currency",
"account__currency",
"installment_plan",
"entities",
"dca_expense_entries",
"dca_income_entries",
)
transactions_filtered = default_order(transactions_filtered, order=order)

View File

@@ -2,20 +2,13 @@ from collections import OrderedDict, defaultdict
from decimal import Decimal
from dateutil.relativedelta import relativedelta
from django.db.models import (
OuterRef,
Subquery,
)
from django.db.models import Sum, Min, Max, Case, When, F, Value, DecimalField
from django.db.models.functions import Coalesce
from django.db.models.functions import TruncMonth
from django.template.defaultfilters import date as date_filter
from django.utils import timezone
from django.utils.translation import gettext_lazy as _
from apps.accounts.models import Account
from apps.currencies.models import Currency
from apps.currencies.utils.convert import convert
from apps.transactions.models import Transaction
@@ -104,7 +97,9 @@ def calculate_historical_currency_net_worth(is_paid=True):
def calculate_historical_account_balance(is_paid=True):
transactions_params = {**{k: v for k, v in [("is_paid", True)] if is_paid}}
# Get all accounts
accounts = Account.objects.filter(is_archived=False)
accounts = Account.objects.filter(
is_archived=False,
)
# Get the date range
date_range = Transaction.objects.filter(**transactions_params).aggregate(

View File

@@ -1,7 +1,9 @@
import json
from django.contrib.auth.decorators import login_required
from django.core.serializers.json import DjangoJSONEncoder
from django.shortcuts import render
from django.views.decorators.http import require_http_methods
from apps.net_worth.utils.calculate_net_worth import (
calculate_historical_currency_net_worth,
@@ -14,6 +16,8 @@ from apps.transactions.utils.calculations import (
)
@login_required
@require_http_methods(["GET"])
def net_worth_current(request):
transactions_currency_queryset = Transaction.objects.filter(
is_paid=True, account__is_archived=False
@@ -113,6 +117,8 @@ def net_worth_current(request):
)
@login_required
@require_http_methods(["GET"])
def net_worth_projected(request):
transactions_currency_queryset = Transaction.objects.filter(
account__is_archived=False

View File

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

View File

@@ -2,8 +2,10 @@ from django.db import models
from django.db.models import Q
from django.utils.translation import gettext_lazy as _
from apps.common.models import SharedObject, SharedObjectManager
class TransactionRule(models.Model):
class TransactionRule(SharedObject):
active = models.BooleanField(default=True)
on_update = models.BooleanField(default=False)
on_create = models.BooleanField(default=True)
@@ -11,6 +13,9 @@ class TransactionRule(models.Model):
description = models.TextField(blank=True, null=True, verbose_name=_("Description"))
trigger = models.TextField(verbose_name=_("Trigger"))
objects = SharedObjectManager()
all_objects = models.Manager() # Unfiltered manager
class Meta:
verbose_name = _("Transaction rule")
verbose_name_plural = _("Transaction rules")
@@ -297,10 +302,8 @@ class UpdateOrCreateTransactionRuleAction(models.Model):
search_query = Q()
def add_to_query(field_name, value, operator):
if isinstance(value, (int, str)):
lookup = f"{field_name}__{operator}"
return Q(**{lookup: value})
return Q()
lookup = f"{field_name}__{operator}"
return Q(**{lookup: value})
if self.search_account:
value = simple.eval(self.search_account)

View File

@@ -1,17 +1,27 @@
from django.dispatch import Signal, receiver
from django.dispatch import receiver
from apps.transactions.models import Transaction
from apps.transactions.models import (
Transaction,
transaction_created,
transaction_updated,
)
from apps.rules.tasks import check_for_transaction_rules
transaction_created = Signal()
transaction_updated = Signal()
from apps.common.middleware.thread_local import get_current_user
@receiver(transaction_created)
@receiver(transaction_updated)
def transaction_changed_receiver(sender: Transaction, signal, **kwargs):
for dca_entry in sender.dca_expense_entries.all():
dca_entry.amount_paid = sender.amount
dca_entry.save()
for dca_entry in sender.dca_income_entries.all():
dca_entry.amount_received = sender.amount
dca_entry.save()
check_for_transaction_rules.defer(
instance_id=sender.id,
user_id=get_current_user().id,
signal=(
"transaction_created"
if signal is transaction_created

View File

@@ -4,6 +4,7 @@ from datetime import datetime, date
from cachalot.api import cachalot_disabled
from dateutil.relativedelta import relativedelta
from django.contrib.auth import get_user_model
from procrastinate.contrib.django import app
from simpleeval import EvalWithCompoundTypes
@@ -18,6 +19,7 @@ from apps.transactions.models import (
TransactionTag,
TransactionEntity,
)
from apps.common.middleware.thread_local import write_current_user, delete_current_user
logger = logging.getLogger(__name__)
@@ -25,8 +27,12 @@ logger = logging.getLogger(__name__)
@app.task(name="check_for_transaction_rules")
def check_for_transaction_rules(
instance_id: int,
user_id: int,
signal,
):
user = get_user_model().objects.get(id=user_id)
write_current_user(user)
try:
with cachalot_disabled():
instance = Transaction.objects.get(id=instance_id)
@@ -91,8 +97,11 @@ def check_for_transaction_rules(
"Error while executing 'check_for_transaction_rules' task",
exc_info=True,
)
delete_current_user()
raise e
delete_current_user()
def _get_names(instance):
return {
@@ -131,14 +140,16 @@ def _process_update_or_create_transaction_action(action, simple_eval):
# Build search query using the helper method
search_query = action.build_search_query(simple_eval)
logger.info("Searching transactions using: %s", search_query)
# Find latest matching transaction or create new
if search_query:
transaction = (
Transaction.objects.filter(search_query).order_by("-date", "-id").first()
)
transactions = Transaction.objects.filter(search_query).order_by("-date", "-id")
transaction = transactions.first()
logger.info("Found at least one matching transaction, using latest")
else:
transaction = None
logger.info("No matching transaction found, creating a new transaction")
if not transaction:
transaction = Transaction()

View File

@@ -37,6 +37,16 @@ urlpatterns = [
views.transaction_rule_delete,
name="transaction_rule_delete",
),
path(
"rules/transaction/<int:transaction_rule_id>/take-ownership/",
views.transaction_rule_take_ownership,
name="transaction_rule_take_ownership",
),
path(
"rules/transaction/<int:pk>/share/",
views.transaction_rule_share,
name="transaction_rule_share_settings",
),
path(
"rules/transaction/<int:transaction_rule_id>/transaction-action/add/",
views.transaction_rule_action_add,

View File

@@ -16,6 +16,8 @@ from apps.rules.models import (
TransactionRuleAction,
UpdateOrCreateTransactionRuleAction,
)
from apps.common.models import SharedObject
from apps.common.forms import SharedObjectForm
@login_required
@@ -93,6 +95,16 @@ def transaction_rule_add(request, **kwargs):
def transaction_rule_edit(request, transaction_rule_id):
transaction_rule = get_object_or_404(TransactionRule, id=transaction_rule_id)
if transaction_rule.owner and transaction_rule.owner != request.user:
messages.error(request, _("Only the owner can edit this"))
return HttpResponse(
status=204,
headers={
"HX-Trigger": "updated, hide_offcanvas",
},
)
if request.method == "POST":
form = TransactionRuleForm(request.POST, instance=transaction_rule)
if form.is_valid():
@@ -134,9 +146,15 @@ def transaction_rule_view(request, transaction_rule_id):
def transaction_rule_delete(request, transaction_rule_id):
transaction_rule = get_object_or_404(TransactionRule, id=transaction_rule_id)
transaction_rule.delete()
messages.success(request, _("Rule deleted successfully"))
if (
transaction_rule.owner != request.user
and request.user in transaction_rule.shared_with.all()
):
transaction_rule.shared_with.remove(request.user)
messages.success(request, _("Item no longer shared with you"))
else:
transaction_rule.delete()
messages.success(request, _("Rule deleted successfully"))
return HttpResponse(
status=204,
@@ -146,6 +164,65 @@ def transaction_rule_delete(request, transaction_rule_id):
)
@only_htmx
@login_required
@require_http_methods(["GET"])
def transaction_rule_take_ownership(request, transaction_rule_id):
transaction_rule = get_object_or_404(TransactionRule, id=transaction_rule_id)
if not transaction_rule.owner:
transaction_rule.owner = request.user
transaction_rule.visibility = SharedObject.Visibility.private
transaction_rule.save()
messages.success(request, _("Ownership taken successfully"))
return HttpResponse(
status=204,
headers={
"HX-Trigger": "updated, hide_offcanvas",
},
)
@only_htmx
@login_required
@require_http_methods(["GET", "POST"])
def transaction_rule_share(request, pk):
obj = get_object_or_404(TransactionRule, id=pk)
if obj.owner and obj.owner != request.user:
messages.error(request, _("Only the owner can edit this"))
return HttpResponse(
status=204,
headers={
"HX-Trigger": "updated, hide_offcanvas",
},
)
if request.method == "POST":
form = SharedObjectForm(request.POST, instance=obj, user=request.user)
if form.is_valid():
form.save()
messages.success(request, _("Configuration saved successfully"))
return HttpResponse(
status=204,
headers={
"HX-Trigger": "updated, hide_offcanvas",
},
)
else:
form = SharedObjectForm(instance=obj, user=request.user)
return render(
request,
"rules/fragments/share.html",
{"form": form, "object": obj},
)
@only_htmx
@login_required
@require_http_methods(["GET", "POST"])

View File

@@ -8,13 +8,14 @@ from apps.transactions.models import (
RecurringTransaction,
TransactionEntity,
)
from apps.common.admin import SharedObjectModelAdmin
@admin.register(Transaction)
class TransactionModelAdmin(admin.ModelAdmin):
def get_queryset(self, request):
# Use the all_objects manager to show all transactions, including deleted ones
return self.model.all_objects.all()
return self.model.userless_all_objects.all()
list_filter = ["deleted", "type", "is_paid", "date", "account"]
@@ -48,19 +49,29 @@ class TransactionInline(admin.TabularInline):
@admin.register(InstallmentPlan)
class InstallmentPlanAdmin(admin.ModelAdmin):
class InstallmentPlanAdmin(SharedObjectModelAdmin):
inlines = [
TransactionInline,
]
@admin.register(RecurringTransaction)
class RecurringTransactionAdmin(admin.ModelAdmin):
class RecurringTransactionAdmin(SharedObjectModelAdmin):
inlines = [
TransactionInline,
]
admin.site.register(TransactionCategory)
admin.site.register(TransactionTag)
admin.site.register(TransactionEntity)
@admin.register(TransactionCategory)
class TransactionCategoryModelAdmin(SharedObjectModelAdmin):
pass
@admin.register(TransactionTag)
class TransactionTagModelAdmin(SharedObjectModelAdmin):
pass
@admin.register(TransactionEntity)
class TransactionEntityModelAdmin(SharedObjectModelAdmin):
pass

View File

@@ -184,3 +184,8 @@ class TransactionsFilter(django_filters.FilterSet):
self.form.fields["from_amount"].widget = ArbitraryDecimalDisplayNumberInput()
self.form.fields["date_start"].widget = AirDatePickerInput()
self.form.fields["date_end"].widget = AirDatePickerInput()
self.form.fields["account"].queryset = Account.objects.all()
self.form.fields["category"].queryset = TransactionCategory.objects.all()
self.form.fields["tags"].queryset = TransactionTag.objects.all()
self.form.fields["entities"].queryset = TransactionEntity.objects.all()

View File

@@ -29,6 +29,7 @@ from apps.transactions.models import (
RecurringTransaction,
TransactionEntity,
)
from apps.common.middleware.thread_local import get_current_user
class TransactionForm(forms.ModelForm):
@@ -63,7 +64,9 @@ class TransactionForm(forms.ModelForm):
date = forms.DateField(label=_("Date"))
reference_date = forms.DateField(
widget=AirMonthYearPickerInput(), label=_("Reference Date"), required=False
widget=AirMonthYearPickerInput(),
label=_("Reference Date"),
required=False,
)
class Meta:
@@ -92,20 +95,30 @@ class TransactionForm(forms.ModelForm):
# if editing a transaction display non-archived items and it's own item even if it's archived
if self.instance.id:
self.fields["account"].queryset = Account.objects.filter(
Q(is_archived=False) | Q(transactions=self.instance.id)
).distinct()
Q(is_archived=False) | Q(transactions=self.instance.id),
)
self.fields["category"].queryset = TransactionCategory.objects.filter(
Q(active=True) | Q(transaction=self.instance.id)
).distinct()
)
self.fields["tags"].queryset = TransactionTag.objects.filter(
Q(active=True) | Q(transaction=self.instance.id)
).distinct()
)
self.fields["entities"].queryset = TransactionEntity.objects.filter(
Q(active=True) | Q(transactions=self.instance.id)
).distinct()
)
else:
self.fields["account"].queryset = Account.objects.filter(
is_archived=False,
)
self.fields["category"].queryset = TransactionCategory.objects.filter(
active=True
)
self.fields["tags"].queryset = TransactionTag.objects.filter(active=True)
self.fields["entities"].queryset = TransactionEntity.objects.all()
self.helper = FormHelper()
self.helper.form_tag = False
@@ -176,7 +189,6 @@ class TransactionForm(forms.ModelForm):
),
)
self.fields["reference_date"].required = False
self.fields["date"].widget = AirDatePickerInput(clear_button=False)
if self.instance and self.instance.pk:
@@ -404,6 +416,24 @@ class TransferForm(forms.Form):
self.fields["to_amount"].widget = ArbitraryDecimalDisplayNumberInput()
self.fields["date"].widget = AirDatePickerInput(clear_button=False)
self.fields["from_account"].queryset = Account.objects.filter(
is_archived=False,
)
self.fields["from_category"].queryset = TransactionCategory.objects.filter(
active=True
)
self.fields["from_tags"].queryset = TransactionTag.objects.filter(active=True)
self.fields["to_account"].queryset = Account.objects.filter(
is_archived=False,
)
self.fields["to_category"].queryset = TransactionCategory.objects.filter(
active=True
)
self.fields["to_tags"].queryset = TransactionTag.objects.filter(active=True)
def clean(self):
cleaned_data = super().clean()
from_account = cleaned_data.get("from_account")
@@ -535,6 +565,18 @@ class InstallmentPlanForm(forms.ModelForm):
self.fields["entities"].queryset = TransactionEntity.objects.filter(
Q(active=True) | Q(installmentplan=self.instance.id)
).distinct()
else:
self.fields["account"].queryset = Account.objects.filter(is_archived=False)
self.fields["category"].queryset = TransactionCategory.objects.filter(
active=True
)
self.fields["tags"].queryset = TransactionTag.objects.filter(active=True)
self.fields["entities"].queryset = TransactionEntity.objects.filter(
active=True
)
self.helper = FormHelper()
self.helper.form_tag = False
@@ -780,6 +822,18 @@ class RecurringTransactionForm(forms.ModelForm):
self.fields["entities"].queryset = TransactionEntity.objects.filter(
Q(active=True) | Q(recurringtransaction=self.instance.id)
).distinct()
else:
self.fields["account"].queryset = Account.objects.filter(is_archived=False)
self.fields["category"].queryset = TransactionCategory.objects.filter(
active=True
)
self.fields["tags"].queryset = TransactionTag.objects.filter(active=True)
self.fields["entities"].queryset = TransactionEntity.objects.filter(
active=True
)
self.helper = FormHelper()
self.helper.form_method = "post"

View File

@@ -0,0 +1,62 @@
# Generated by Django 5.1.6 on 2025-03-05 04:19
import django.db.models.deletion
import django.db.models.manager
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('transactions', '0033_transaction_internal_id'),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.AlterModelManagers(
name='installmentplan',
managers=[
('all_objects', django.db.models.manager.Manager()),
],
),
migrations.AlterModelManagers(
name='recurringtransaction',
managers=[
('all_objects', django.db.models.manager.Manager()),
],
),
migrations.AlterModelManagers(
name='transactioncategory',
managers=[
('all_objects', django.db.models.manager.Manager()),
],
),
migrations.AlterModelManagers(
name='transactionentity',
managers=[
('all_objects', django.db.models.manager.Manager()),
],
),
migrations.AlterModelManagers(
name='transactiontag',
managers=[
('all_objects', django.db.models.manager.Manager()),
],
),
migrations.AddField(
model_name='transactioncategory',
name='owner',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='owned_transaction_categories', to=settings.AUTH_USER_MODEL, verbose_name='Owner'),
),
migrations.AddField(
model_name='transactionentity',
name='owner',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='owned_transaction_entities', to=settings.AUTH_USER_MODEL, verbose_name='Owner'),
),
migrations.AddField(
model_name='transactiontag',
name='owner',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='owned_transaction_tags', to=settings.AUTH_USER_MODEL, verbose_name='Owner'),
),
]

View File

@@ -0,0 +1,37 @@
# Generated by Django 5.1.6 on 2025-03-05 04:51
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('transactions', '0034_alter_installmentplan_managers_and_more'),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.AlterField(
model_name='transactioncategory',
name='name',
field=models.CharField(max_length=255, verbose_name='Name'),
),
migrations.AlterField(
model_name='transactiontag',
name='name',
field=models.CharField(max_length=255, verbose_name='Name'),
),
migrations.AlterUniqueTogether(
name='transactioncategory',
unique_together={('owner', 'name')},
),
migrations.AlterUniqueTogether(
name='transactionentity',
unique_together={('owner', 'name')},
),
migrations.AlterUniqueTogether(
name='transactiontag',
unique_together={('owner', 'name')},
),
]

View File

@@ -0,0 +1,76 @@
# Generated by Django 5.1.6 on 2025-03-06 00:10
import django.db.models.deletion
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('transactions', '0035_alter_transactioncategory_name_and_more'),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.AlterModelManagers(
name='transactioncategory',
managers=[
],
),
migrations.AlterModelManagers(
name='transactionentity',
managers=[
],
),
migrations.AlterModelManagers(
name='transactiontag',
managers=[
],
),
migrations.AddField(
model_name='transactioncategory',
name='shared_with',
field=models.ManyToManyField(blank=True, related_name='%(class)s_shared', to=settings.AUTH_USER_MODEL),
),
migrations.AddField(
model_name='transactioncategory',
name='visibility',
field=models.CharField(choices=[('private', 'Private'), ('shared', 'Shared'), ('public', 'Public')], default='private', max_length=10),
),
migrations.AddField(
model_name='transactionentity',
name='shared_with',
field=models.ManyToManyField(blank=True, related_name='%(class)s_shared', to=settings.AUTH_USER_MODEL),
),
migrations.AddField(
model_name='transactionentity',
name='visibility',
field=models.CharField(choices=[('private', 'Private'), ('shared', 'Shared'), ('public', 'Public')], default='private', max_length=10),
),
migrations.AddField(
model_name='transactiontag',
name='shared_with',
field=models.ManyToManyField(blank=True, related_name='%(class)s_shared', to=settings.AUTH_USER_MODEL),
),
migrations.AddField(
model_name='transactiontag',
name='visibility',
field=models.CharField(choices=[('private', 'Private'), ('shared', 'Shared'), ('public', 'Public')], default='private', max_length=10),
),
migrations.AlterField(
model_name='transactioncategory',
name='owner',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='%(class)s_owned', to=settings.AUTH_USER_MODEL),
),
migrations.AlterField(
model_name='transactionentity',
name='owner',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='%(class)s_owned', to=settings.AUTH_USER_MODEL),
),
migrations.AlterField(
model_name='transactiontag',
name='owner',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='%(class)s_owned', to=settings.AUTH_USER_MODEL),
),
]

View File

@@ -0,0 +1,28 @@
# Generated by Django 5.1.6 on 2025-03-06 01:47
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('transactions', '0036_alter_transactioncategory_managers_and_more'),
]
operations = [
migrations.AlterField(
model_name='transactioncategory',
name='visibility',
field=models.CharField(choices=[('private', 'Private'), ('public', 'Public')], default='private', max_length=10),
),
migrations.AlterField(
model_name='transactionentity',
name='visibility',
field=models.CharField(choices=[('private', 'Private'), ('public', 'Public')], default='private', max_length=10),
),
migrations.AlterField(
model_name='transactiontag',
name='visibility',
field=models.CharField(choices=[('private', 'Private'), ('public', 'Public')], default='private', max_length=10),
),
]

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