Compare commits

...

95 Commits

Author SHA1 Message Date
eitchtee
c7e32d1576 chore(locale): update translation files
[skip ci] Automatically generated by Django makemessages workflow
2025-04-21 18:45:41 +00:00
Herculino Trotta
157e59a1d1 Merge pull request #250
fix(transactions): save and add similar not initializing dates properly
2025-04-21 15:45:05 -03:00
Herculino Trotta
d9c505ac79 fix(transactions): save and add similar not initializing dates properly
Fixes #248
2025-04-21 15:44:42 -03:00
Herculino Trotta
7274a13f3c Merge pull request #249
fix(accounts): unable to share some accounts; wrong url getting used
2025-04-21 14:33:41 -03:00
Herculino Trotta
5d64665ddd fix(accounts): unable to share some accounts; wrong url getting used 2025-04-21 14:33:11 -03:00
Herculino Trotta
e0d92d15c8 locale(Portuguese (Brazil)): update translation
Currently translated at 100.0% (650 of 650 strings)

Translation: WYGIWYH/App
Translate-URL: https://translations.herculino.com/projects/wygiwyh/app/pt_BR/
2025-04-20 21:16:53 +00:00
eitchtee
48dd658627 chore(locale): update translation files
[skip ci] Automatically generated by Django makemessages workflow
2025-04-20 18:57:34 +00:00
Herculino Trotta
80dbbd02f0 Merge pull request #246 from eitchtee/dev
feat(transactions): add another transaction (or a similar one)
2025-04-20 15:56:58 -03:00
Herculino Trotta
4b7ca61c29 feat(transactions): add another transaction (or a similar one) right after adding one 2025-04-20 15:55:50 -03:00
eitchtee
b2f04ae1f9 chore(locale): update translation files
[skip ci] Automatically generated by Django makemessages workflow
2025-04-19 15:30:24 +00:00
Herculino Trotta
f34d4b5e28 Merge pull request #245 from eitchtee/dev
feat(insights:category-overview): pick between Projected/Current/Final totals
2025-04-19 12:28:50 -03:00
Herculino Trotta
d2ebfbd615 feat(insights:category-overview): pick between Projected/Current/Final totals 2025-04-19 12:28:22 -03:00
Herculino Trotta
812abbe488 feat(insights:category-overview): pick between Projected/Current/Final totals 2025-04-19 12:16:21 -03:00
eitchtee
9602a4affc chore(locale): update translation files
[skip ci] Automatically generated by Django makemessages workflow
2025-04-19 05:41:07 +00:00
Herculino Trotta
bf548c0747 Merge pull request #244 from eitchtee/dev
feat(insights:category-overview): display tags breakdown alongside categories
2025-04-19 02:40:26 -03:00
Herculino Trotta
55ad2be08b feat(insights:category-overview): display tags breakdown alongside categories 2025-04-19 02:36:38 -03:00
eitchtee
2cd58c2464 chore(locale): update translation files
[skip ci] Automatically generated by Django makemessages workflow
2025-04-19 05:36:33 +00:00
Herculino Trotta
4675ba9d56 Merge pull request #243
feat(insights:category-overview): select if you want to view table or bar charts, defaults to table
2025-04-19 02:35:55 -03:00
Herculino Trotta
a25c992d5c feat(cotton): convert help icon to cotton template; allow setting custom icon 2025-04-19 02:34:16 -03:00
Herculino Trotta
2eadfe99a5 feat(insights:category-overview): select if you want to view table or bar charts, defaults to table 2025-04-19 00:00:31 -03:00
Herculino Trotta
11086a726f fix(insights): missing page title 2025-04-18 23:59:32 -03:00
Herculino Trotta
cd99b40b0a Merge pull request #242
fix(users): users doesn't activate management menu entry
2025-04-18 15:08:19 -03:00
Herculino Trotta
63aa51dc0d fix(users): users doesn't activate management menu entry 2025-04-18 15:08:00 -03:00
eitchtee
4708c5bc7e chore(locale): update translation files
[skip ci] Automatically generated by Django makemessages workflow
2025-04-18 18:05:59 +00:00
Herculino Trotta
5a8462c050 Merge pull request #241
fix(users): disables profile editing on demo mode
2025-04-18 15:05:24 -03:00
Herculino Trotta
6cac02e01f fix(users): disables profile editing on demo mode 2025-04-18 15:04:58 -03:00
Dimitri Decrock
8d12ceeebb locale(Dutch): update translation
Currently translated at 100.0% (643 of 643 strings)

Translation: WYGIWYH/App
Translate-URL: https://translations.herculino.com/projects/wygiwyh/app/nl/
2025-04-14 08:16:53 +00:00
Emil
4681d3ca1d locale(Swedish): update translation
Currently translated at 0.1% (1 of 643 strings)

Translation: WYGIWYH/App
Translate-URL: https://translations.herculino.com/projects/wygiwyh/app/sv/
2025-04-14 06:16:53 +00:00
Dimitri Decrock
60ded03ea9 locale(Dutch): update translation
Currently translated at 96.5% (621 of 643 strings)

Translation: WYGIWYH/App
Translate-URL: https://translations.herculino.com/projects/wygiwyh/app/nl/
2025-04-14 06:16:53 +00:00
Emil
b20d137dc3 locale((Swedish)): added translation using Weblate 2025-04-14 05:02:06 +00:00
Herculino Trotta
29ca6eed6c locale(Portuguese (Brazil)): update translation
Currently translated at 100.0% (643 of 643 strings)

Translation: WYGIWYH/App
Translate-URL: https://translations.herculino.com/projects/wygiwyh/app/pt_BR/
2025-04-13 23:16:53 +00:00
eitchtee
fa85303f36 chore(locale): update translation files
[skip ci] Automatically generated by Django makemessages workflow
2025-04-13 22:01:20 +00:00
Herculino Trotta
a5f4f43678 Merge pull request #240
feat: user management screen; allow users to edit their profile
2025-04-13 19:00:43 -03:00
Herculino Trotta
d807bd5da3 feat: user management screen; allow users to edit their profile 2025-04-13 19:00:25 -03:00
eitchtee
85314fb749 chore(locale): update translation files
[skip ci] Automatically generated by Django makemessages workflow
2025-04-13 15:49:29 +00:00
Herculino Trotta
c4d5e93a41 Merge pull request #239
feat(transactions): add transaction owner to recurring and installments
2025-04-13 12:48:50 -03:00
Herculino Trotta
86f0c4365e feat(transactions): add transaction owner to recurring and installments 2025-04-13 12:48:18 -03:00
Herculino Trotta
202592b940 Merge pull request #238
fix(transactions): recurring transactions not getting created
2025-04-13 12:47:35 -03:00
Herculino Trotta
aea149bd13 fix(transactions): recurring transactions not getting created 2025-04-13 12:47:01 -03:00
Dimitri Decrock
411365f101 locale(Dutch): update translation
Currently translated at 100.0% (620 of 620 strings)

Translation: WYGIWYH/App
Translate-URL: https://translations.herculino.com/projects/wygiwyh/app/nl/
2025-04-13 11:16:53 +00:00
Herculino Trotta
2008476021 locale(Portuguese): update translation
Currently translated at 100.0% (620 of 620 strings)

Translation: WYGIWYH/App
Translate-URL: https://translations.herculino.com/projects/wygiwyh/app/pt/
2025-04-13 08:16:53 +00:00
Herculino Trotta
53afe5b8eb locale(Portuguese (Brazil)): update translation
Currently translated at 100.0% (620 of 620 strings)

Translation: WYGIWYH/App
Translate-URL: https://translations.herculino.com/projects/wygiwyh/app/pt_BR/
2025-04-13 08:16:53 +00:00
Herculino Trotta
6193c7a048 Merge pull request #237
chore: bump deps
2025-04-13 03:57:45 -03:00
Herculino Trotta
41f81d90d7 chore: bump deps 2025-04-13 03:57:27 -03:00
eitchtee
bf623cf16b chore(locale): update translation files
[skip ci] Automatically generated by Django makemessages workflow
2025-04-13 06:46:15 +00:00
Herculino Trotta
ec213330cd Merge pull request #236
feat(insights:category-overview): add bar chart with category totals
2025-04-13 03:45:43 -03:00
Herculino Trotta
7aedf524c6 feat(insights:category-overview): add bar chart with category totals
Closes #231
2025-04-13 03:45:22 -03:00
eitchtee
04602b1964 chore(locale): update translation files
[skip ci] Automatically generated by Django makemessages workflow
2025-04-13 04:00:29 +00:00
Herculino Trotta
15cfc4f300 Merge pull request #235
locale: add all supported languages by django as an available option
2025-04-13 00:59:51 -03:00
Herculino Trotta
3463c7c62c locale: add all supported languages by django as an available option 2025-04-13 00:58:57 -03:00
Herculino Trotta
7b76c10093 locale(pt): copy strings from pt-br 2025-04-13 00:25:47 -03:00
Herculino Trotta
7ad26a2e7b Merge pull request #234
fix(select): only 50 select options would be shown at a time
2025-04-13 00:03:03 -03:00
Herculino Trotta
7706ca2d5f fix(select): only 50 select options would be shown at a time 2025-04-13 00:02:23 -03:00
Herculino Trotta
56198e93ce locale((Portuguese)): added translation using Weblate 2025-04-13 02:50:39 +00:00
Herculino Trotta
a74323f739 locale((English (United Kingdom))): deleted translation using Weblate 2025-04-13 02:50:09 +00:00
Herculino Trotta
e4efde177b locale((English (Australia))): deleted translation using Weblate 2025-04-13 02:49:54 +00:00
Herculino Trotta
5871a03ee2 locale((Portuguese)): deleted translation using Weblate 2025-04-13 02:49:10 +00:00
Prefill add-on
67af4430e1 locale(Portuguese): update translation
Currently translated at 0.0% (0 of 618 strings)

Translation: WYGIWYH/App
Translate-URL: https://translations.herculino.com/projects/wygiwyh/app/pt/
2025-04-13 02:40:11 +00:00
Prefill add-on
696dcdf951 locale(English (United Kingdom)): update translation
Currently translated at 0.0% (0 of 618 strings)

Translation: WYGIWYH/App
Translate-URL: https://translations.herculino.com/projects/wygiwyh/app/en_GB/
2025-04-13 02:40:10 +00:00
Prefill add-on
e35bad0e08 locale(English (Australia)): update translation
Currently translated at 0.0% (0 of 618 strings)

Translation: WYGIWYH/App
Translate-URL: https://translations.herculino.com/projects/wygiwyh/app/en_AU/
2025-04-13 02:40:09 +00:00
Prefill add-on
904f7cac22 locale(Spanish): update translation
Currently translated at 0.0% (0 of 618 strings)

Translation: WYGIWYH/App
Translate-URL: https://translations.herculino.com/projects/wygiwyh/app/es/
2025-04-13 02:40:09 +00:00
Prefill add-on
ccd73963ca locale(French): update translation
Currently translated at 27.6% (171 of 618 strings)

Translation: WYGIWYH/App
Translate-URL: https://translations.herculino.com/projects/wygiwyh/app/fr/
2025-04-13 02:40:08 +00:00
Prefill add-on
b5469b0413 locale(German): update translation
Currently translated at 98.7% (610 of 618 strings)

Translation: WYGIWYH/App
Translate-URL: https://translations.herculino.com/projects/wygiwyh/app/de/
2025-04-13 02:40:07 +00:00
Herculino Trotta
dae848d951 locale((Portuguese)): added translation using Weblate 2025-04-13 02:34:18 +00:00
Herculino Trotta
45a33ad0c0 locale((English (United Kingdom))): added translation using Weblate 2025-04-13 02:33:05 +00:00
Herculino Trotta
89e50b17bd locale((English (Australia))): added translation using Weblate 2025-04-13 02:33:05 +00:00
Herculino Trotta
ac54ba3da1 Merge pull request #233
fix(cotton): sometimes distribution bars don't get filled completely
2025-04-12 16:33:47 -03:00
Herculino Trotta
2da610f15e fix(cotton): sometimes distribution bars don't get filled completely 2025-04-12 16:33:22 -03:00
valentin-p
4ab6c4c6b6 locale(French): update translation
Currently translated at 27.6% (171 of 618 strings)

Translation: WYGIWYH/App
Translate-URL: https://translations.herculino.com/projects/wygiwyh/app/fr/
2025-04-07 08:16:53 +00:00
H4ndrew
68dbedd938 locale(French): update translation
Currently translated at 26.5% (164 of 618 strings)

Translation: WYGIWYH/App
Translate-URL: https://translations.herculino.com/projects/wygiwyh/app/fr/
2025-04-03 15:16:53 +00:00
Herculino Trotta
2800c53346 Create FUNDING.yml 2025-04-02 23:03:33 -03:00
Herculino Trotta
132547a074 locale((Spanish)): added translation using Weblate 2025-04-03 01:42:19 +00:00
Herculino Trotta
61ed87dc45 locale(Portuguese (Brazil)): update translation
Currently translated at 100.0% (618 of 618 strings)

Translation: WYGIWYH/App
Translate-URL: https://translations.herculino.com/projects/wygiwyh/app/pt_BR/
2025-04-02 20:16:53 +00:00
H4ndrew
96c1227c4f locale(French): update translation
Currently translated at 24.2% (150 of 618 strings)

Translation: WYGIWYH/App
Translate-URL: https://translations.herculino.com/projects/wygiwyh/app/fr/
2025-04-02 17:07:17 +00:00
H4ndrew
33f1ac1785 locale(French): update translation
Currently translated at 19.4% (120 of 618 strings)

Translation: WYGIWYH/App
Translate-URL: https://translations.herculino.com/projects/wygiwyh/app/fr/
2025-04-02 15:07:17 +00:00
Dimitri Decrock
e9e94a8343 locale(Dutch): update translation
Currently translated at 100.0% (618 of 618 strings)

Translation: WYGIWYH/App
Translate-URL: https://translations.herculino.com/projects/wygiwyh/app/nl/
2025-04-01 06:07:17 +00:00
Herculino Trotta
ba24a53853 Merge pull request #229
feat(demo): add dummy data
2025-04-01 01:06:02 -03:00
Herculino Trotta
4955fbde33 feat(demo): add dummy data 2025-04-01 01:05:28 -03:00
eitchtee
d04067a91d chore(locale): update translation files
[skip ci] Automatically generated by Django makemessages workflow
2025-04-01 04:05:11 +00:00
Herculino Trotta
01333a439b Merge pull request #228
fix(common:fields:month_year): unable to load data with a date
2025-04-01 01:04:35 -03:00
Herculino Trotta
d26907ea94 fix(common:fields:month_year): unable to load data with a date 2025-04-01 01:04:20 -03:00
Herculino Trotta
c98d9d3ce9 Update README.md 2025-03-31 03:03:35 -03:00
Herculino Trotta
bfa4d3dea3 Merge pull request #226
fix(common:tasks): reset_demo_data not running via cron
2025-03-31 03:02:48 -03:00
Herculino Trotta
90323049eb fix(common:tasks): reset_demo_data not running via cron 2025-03-31 03:02:28 -03:00
Herculino Trotta
b62122ed23 Merge pull request #225
fix(app): rename DEMO_MODE variable to DEMO for simplicity
2025-03-31 02:45:28 -03:00
Herculino Trotta
f74946cba7 fix(app): rename DEMO_MODE variable to DEMO for simplicity 2025-03-31 02:45:09 -03:00
Herculino Trotta
585652064a locale(Portuguese (Brazil)): update translation
Currently translated at 100.0% (617 of 617 strings)

Translation: WYGIWYH/App
Translate-URL: https://translations.herculino.com/projects/wygiwyh/app/pt_BR/
2025-03-31 05:38:18 +00:00
eitchtee
ea6f61d5e4 chore(locale): update translation files
[skip ci] Automatically generated by Django makemessages workflow
2025-03-31 05:30:21 +00:00
Herculino Trotta
e986f7d802 Merge pull request #224
feat: add demo mode and allow for automatic admin creation from env variables
2025-03-31 02:29:44 -03:00
Herculino Trotta
26b218ae51 feat(app): disable API when demo mode is enabled 2025-03-31 02:28:48 -03:00
Herculino Trotta
19f0bc1034 feat(app): show current user e-mail on user menu 2025-03-31 02:28:33 -03:00
Herculino Trotta
47d34f3c27 feat(app): add a demo mode 2025-03-31 02:14:00 -03:00
Herculino Trotta
046e02d506 feat(app): add environment variables to automatically create superuser on startup 2025-03-31 02:11:13 -03:00
valentin-p
92c7a29b6a locale(German): update translation
Currently translated at 99.8% (610 of 611 strings)

Translation: WYGIWYH/App
Translate-URL: https://translations.herculino.com/projects/wygiwyh/app/de/
2025-03-30 15:07:17 +00:00
valentin-p
d95e5f71cc locale((French)): added translation using Weblate 2025-03-30 13:40:51 +00:00
55 changed files with 18272 additions and 745 deletions

View File

@@ -10,6 +10,11 @@ SECRET_KEY=<GENERATE A SAFE SECRET KEY AND PLACE IT HERE>
DJANGO_ALLOWED_HOSTS=localhost 127.0.0.1 [::1]
OUTBOUND_PORT=9005
# Uncomment these variables to automatically create an admin account using these credentials on startup.
# After your first successfull login you can remove these variables from your file for safety reasons.
#ADMIN_EMAIL=<ENTER YOUR EMAIL>
#ADMIN_PASSWORD=<YOUR SAFE PASSWORD>
SQL_DATABASE=wygiwyh
SQL_USER=wygiwyh
SQL_PASSWORD=<INSERT A SAFE PASSWORD HERE>

4
.github/FUNDING.yml vendored Normal file
View File

@@ -0,0 +1,4 @@
# These are supported funding model platforms
github: eitchtee
custom: ["https://www.paypal.com/donate/?hosted_button_id=FFWM4W9NQDMM6"]

View File

@@ -51,6 +51,17 @@ Frustrated by the lack of comprehensive options, I set out to build **WYGIWYH**
* **Built-in Dollar-Cost Average (DCA) tracker**: Essential for tracking recurring investments, especially for crypto and stocks.
* **API support for automation**: Seamlessly integrate with existing services to synchronize transactions.
# Demo
You can try WYGIWYH on [wygiwyh-demo.herculino.com](https://wygiwyh-demo.herculino.com/) with the credentials below:
> [!NOTE]
> E-mail: `demo@demo.com`
>
> Password: `wygiwyhdemo`
Keep in mind that **any data you add will be wiped in 24 hours or less**. And that **most automation features like the API, Rules, Automatic Exchange Rates and Import/Export are disabled**.
# How To Use
To run this application, you'll need [Docker](https://docs.docker.com/engine/install/) with [docker-compose](https://docs.docker.com/compose/install/).
@@ -76,7 +87,7 @@ $ nano .env # or any other editor you want to use
# Run the app
$ docker compose up -d
# Create the first admin account
# Create the first admin account. This isn't required if you set the enviroment variables: ADMIN_EMAIL and ADMIN_PASSWORD.
$ docker compose exec -it web python manage.py createsuperuser
```
@@ -117,7 +128,7 @@ To create the first user, open the container's console using Unraid's UI, by cli
|-------------------------------|-------------|-----------------------------------|------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
| DJANGO_ALLOWED_HOSTS | string | localhost 127.0.0.1 | A list of space separated domains and IPs representing the host/domain names that WYGIWYH site can serve. [Click here](https://docs.djangoproject.com/en/5.1/ref/settings/#allowed-hosts) for more details |
| HTTPS_ENABLED | true\|false | false | Whether to use secure cookies. If this is set to true, the cookie will be marked as “secure”, which means browsers may ensure that the cookie is only sent under an HTTPS connection |
| URL | string | http://localhost http://127.0.0.1 | A list of space separated domains and IPs (with the protocol) representing the trusted origins for unsafe requests (e.g. POST). [Click here](https://docs.djangoproject.com/en/5.1/ref/settings/#csrf-trusted-origins ) for more details |
| URL | string | http://localhost http://127.0.0.1 | A list of space separated domains and IPs (with the protocol) representing the trusted origins for unsafe requests (e.g. POST). [Click here](https://docs.djangoproject.com/en/5.1/ref/settings/#csrf-trusted-origins ) for more details |
| SECRET_KEY | string | "" | This is used to provide cryptographic signing, and should be set to a unique, unpredictable value. |
| DEBUG | true\|false | false | Turns DEBUG mode on or off, this is useful to gather more data about possible errors you're having. Don't use in production. |
| SQL_DATABASE | string | None *required | The name of your postgres database |
@@ -129,6 +140,9 @@ To create the first user, open the container's console using Unraid's UI, by cli
| ENABLE_SOFT_DELETE | true\|false | false | Whether to enable transactions soft delete, if enabled, deleted transactions will remain in the database. Useful for imports and avoiding duplicate entries. |
| KEEP_DELETED_TRANSACTIONS_FOR | int | 365 | Time in days to keep soft deleted transactions for. If 0, will keep all transactions indefinitely. Only works if ENABLE_SOFT_DELETE is true. |
| TASK_WORKERS | int | 1 | How many workers to have for async tasks. One should be enough for most use cases |
| DEMO | true\|false | false | If demo mode is enabled. |
| ADMIN_EMAIL | string | None | Automatically creates an admin account with this email. Must have `ADMIN_PASSWORD` also set. |
| ADMIN_PASSWORD | string | None | Automatically creates an admin account with this password. Must have `ADMIN_EMAIL` also set. |
# How it works

View File

@@ -163,10 +163,105 @@ AUTH_USER_MODEL = "users.User"
LANGUAGE_CODE = "en"
LANGUAGES = (
("af", "Afrikaans"),
("ar", "العربية"),
("ar-dz", "العربية (الجزائر)"), # Algerian Arabic often uses the base name + region
("ast", "Asturianu"),
("az", "Azərbaycan"),
("bg", "Български"),
("be", "Беларуская"),
("bn", "বাংলা"),
("br", "Brezhoneg"),
("bs", "Bosanski"),
("ca", "Català"),
("ckb", "کوردیی ناوەندی"), # Central Kurdish (Sorani)
("cs", "Čeština"),
("cy", "Cymraeg"),
("da", "Dansk"),
("de", "Deutsch"),
("dsb", "Dolnoserbšćina"),
("el", "Ελληνικά"),
("en", "English"),
("en-au", "English (Australia)"),
("en-gb", "English (UK)"),
("eo", "Esperanto"),
("es", "Español"),
("es-ar", "Español (Argentina)"),
("es-co", "Español (Colombia)"),
("es-mx", "Español (México)"),
("es-ni", "Español (Nicaragua)"),
("es-ve", "Español (Venezuela)"),
("et", "Eesti"),
("eu", "Euskara"),
("fa", "فارسی"),
("fi", "Suomi"),
("fr", "Français"),
("fy", "Frysk"),
("ga", "Gaeilge"),
("gd", "Gàidhlig"),
("gl", "Galego"),
("he", "עברית"),
("hi", "हिन्दी"),
("hr", "Hrvatski"),
("hsb", "Hornjoserbšćina"),
("hu", "Magyar"),
("hy", "Հայերեն"),
("ia", "Interlingua"),
("id", "Bahasa Indonesia"),
("ig", "Igbo"),
("io", "Ido"),
("is", "Íslenska"),
("it", "Italiano"),
("ja", "日本語"),
("ka", "ქართული"),
("kab", "Taqbaylit"),
("kk", "Қазақша"),
("km", "ខ្មែរ"),
("kn", "ಕನ್ನಡ"),
("ko", "한국어"),
("ky", "Кыргызча"),
("lb", "Lëtzebuergesch"),
("lt", "Lietuvių"),
("lv", "Latviešu"),
("mk", "Македонски"),
("ml", "മലയാളം"),
("mn", "Монгол"),
("mr", "मराठी"),
("ms", "Bahasa Melayu"),
("my", "မြန်မာဘာသာ"),
("nb", "Norsk (Bokmål)"),
("ne", "नेपाली"),
("nl", "Nederlands"),
("nn", "Norsk (Nynorsk)"),
("os", "Ирон"), # Ossetic
("pa", "ਪੰਜਾਬੀ"),
("pl", "Polski"),
("pt", "Português"),
("pt-br", "Português (Brasil)"),
("ro", "Română"),
("ru", "Русский"),
("sk", "Slovenčina"),
("sl", "Slovenščina"),
("sq", "Shqip"),
("sr", "Српски"),
("sr-latn", "Srpski (Latinica)"),
("sv", "Svenska"),
("sw", "Kiswahili"),
("ta", "தமிழ்"),
("te", "తెలుగు"),
("tg", "Тоҷикӣ"),
("th", "ไทย"),
("tk", "Türkmençe"),
("tr", "Türkçe"),
("tt", "Татарча"),
("udm", "Удмурт"),
("ug", "ئۇيغۇرچە"),
("uk", "Українська"),
("ur", "اردو"),
("uz", "Oʻzbekcha"),
("vi", "Tiếng Việt"),
("zh-hans", "简体中文"),
("zh-hant", "繁體中文"),
)
TIME_ZONE = os.getenv("TZ", "UTC")
@@ -261,7 +356,10 @@ if DEBUG:
REST_FRAMEWORK = {
# Use Django's standard `django.contrib.auth` permissions,
# or allow read-only access for unauthenticated users.
"DEFAULT_PERMISSION_CLASSES": ["rest_framework.permissions.DjangoModelPermissions"],
"DEFAULT_PERMISSION_CLASSES": [
"apps.api.permissions.NotInDemoMode",
"rest_framework.permissions.DjangoModelPermissions",
],
"DEFAULT_PAGINATION_CLASS": "rest_framework.pagination.PageNumberPagination",
"PAGE_SIZE": 10,
"DEFAULT_SCHEMA_CLASS": "drf_spectacular.openapi.AutoSchema",
@@ -394,3 +492,4 @@ PWA_SERVICE_WORKER_PATH = BASE_DIR / "templates" / "pwa" / "serviceworker.js"
ENABLE_SOFT_DELETE = os.getenv("ENABLE_SOFT_DELETE", "false").lower() == "true"
KEEP_DELETED_TRANSACTIONS_FOR = int(os.getenv("KEEP_DELETED_ENTRIES_FOR", "365"))
APP_VERSION = os.getenv("APP_VERSION", "unknown")
DEMO = os.getenv("DEMO", "false").lower() == "true"

View File

@@ -51,7 +51,7 @@ urlpatterns = [
),
path(
"account-groups/<int:pk>/share/",
views.account_share,
views.account_group_share,
name="account_group_share_settings",
),
]

View File

@@ -145,7 +145,7 @@ def account_group_take_ownership(request, pk):
@only_htmx
@login_required
@require_http_methods(["GET", "POST"])
def account_share(request, pk):
def account_group_share(request, pk):
obj = get_object_or_404(AccountGroup, id=pk)
if obj.owner and obj.owner != request.user:

View File

@@ -0,0 +1,10 @@
from rest_framework.permissions import BasePermission
from django.conf import settings
class NotInDemoMode(BasePermission):
def has_permission(self, request, view):
if settings.DEMO and not request.user.is_superuser:
return False
else:
return True

View File

@@ -0,0 +1,15 @@
from functools import wraps
from django.conf import settings
from django.core.exceptions import PermissionDenied
def disabled_on_demo(view):
@wraps(view)
def _view(request, *args, **kwargs):
if settings.DEMO and not request.user.is_superuser:
raise PermissionDenied
return view(request, *args, **kwargs)
return _view

View File

@@ -0,0 +1,78 @@
from functools import wraps
from django.conf import settings
from django.core.exceptions import PermissionDenied
from django.http import HttpResponse
from django.shortcuts import redirect
from django.urls import reverse, NoReverseMatch
def is_superuser(view):
@wraps(view)
def _view(request, *args, **kwargs):
if not request.user.is_superuser:
raise PermissionDenied
return view(request, *args, **kwargs)
return _view
def htmx_login_required(function=None, login_url=None):
"""
Decorator that checks if the user is logged in.
Allows overriding the default login URL.
If the user is not logged in:
- If "hx-request" is present in the request header, it returns a 200 response
with a "HX-Redirect" header containing the determined login URL (including the "next" parameter).
- If "hx-request" is not present, it redirects to the determined login page normally.
Args:
function: The view function to decorate.
login_url: Optional. The URL or URL name to redirect to for login.
Defaults to settings.LOGIN_URL.
"""
def decorator(view_func):
# Simplified @wraps usage - it handles necessary attribute assignments by default
@wraps(view_func)
def wrapped_view(request, *args, **kwargs):
if request.user.is_authenticated:
return view_func(request, *args, **kwargs)
else:
# Determine the login URL
resolved_login_url = login_url
if not resolved_login_url:
resolved_login_url = settings.LOGIN_URL
# Try to reverse the URL name if it's not a path
try:
# Check if it looks like a URL path already
if "/" not in resolved_login_url and "." not in resolved_login_url:
login_url_path = reverse(resolved_login_url)
else:
login_url_path = resolved_login_url
except NoReverseMatch:
# If reverse fails, assume it's already a URL path
login_url_path = resolved_login_url
# Construct the full redirect path with 'next' parameter
# Ensure request.path is URL-encoded if needed, though Django usually handles this
redirect_path = f"{login_url_path}?next={request.get_full_path()}" # Use get_full_path() to include query params
if request.headers.get("hx-request"):
# For HTMX requests, return a 200 with the HX-Redirect header.
response = HttpResponse()
response["HX-Redirect"] = login_url_path
return response
else:
# For regular requests, redirect to the login page.
return redirect(redirect_path)
return wrapped_view
if function:
return decorator(function)
return decorator

View File

@@ -20,7 +20,15 @@ class MonthYearModelField(models.DateField):
# Set the day to 1
return date.replace(day=1).date()
except ValueError:
raise ValidationError(_("Invalid date format. Use YYYY-MM."))
try:
# Also accept YYYY-MM-DD format (for loaddata)
return (
datetime.datetime.strptime(value, "%Y-%m-%d").replace(day=1).date()
)
except ValueError:
raise ValidationError(
_("Invalid date format. Use YYYY-MM or YYYY-MM-DD.")
)
def formfield(self, **kwargs):
kwargs["widget"] = MonthYearWidget

View File

View File

@@ -0,0 +1,137 @@
import os
from django.core.management.base import BaseCommand, CommandError
from django.contrib.auth import get_user_model
from django.conf import settings
from django.db import IntegrityError
# Get the custom User model if defined, otherwise the default User model
User = get_user_model()
class Command(BaseCommand):
help = (
"Creates a superuser from environment variables (ADMIN_EMAIL, ADMIN_PASSWORD) "
"and optionally creates a demo user (demo@demo.com) if settings.DEMO is True."
)
def handle(self, *args, **options):
self.stdout.write("Starting user setup...")
# --- Create Superuser ---
admin_email = os.environ.get("ADMIN_EMAIL")
admin_password = os.environ.get("ADMIN_PASSWORD")
if admin_email and admin_password:
self.stdout.write(f"Attempting to create superuser: {admin_email}")
# Use email as username for simplicity, requires USERNAME_FIELD='email'
# or adapt if your USERNAME_FIELD is different.
# If USERNAME_FIELD is 'username', you might need ADMIN_USERNAME env var.
username_field = User.USERNAME_FIELD # Get the actual username field name
# Check if the user already exists by email or username
user_exists_kwargs = {"email": admin_email}
if username_field != "email":
# Assume username should also be the email if not explicitly provided
user_exists_kwargs[username_field] = admin_email
if User.objects.filter(**user_exists_kwargs).exists():
self.stdout.write(
self.style.WARNING(
f"Superuser with email '{admin_email}' (or corresponding username) already exists. Skipping creation."
)
)
else:
try:
create_kwargs = {
username_field: admin_email, # Use email as username by default
"email": admin_email,
"password": admin_password,
}
User.objects.create_superuser(**create_kwargs)
self.stdout.write(
self.style.SUCCESS(
f"Superuser '{admin_email}' created successfully."
)
)
except IntegrityError as e:
self.stdout.write(
self.style.ERROR(
f"Failed to create superuser '{admin_email}'. IntegrityError: {e}"
)
)
except Exception as e:
self.stdout.write(
self.style.ERROR(
f"An unexpected error occurred creating superuser '{admin_email}': {e}"
)
)
else:
self.stdout.write(
self.style.NOTICE(
"ADMIN_EMAIL or ADMIN_PASSWORD environment variables not set. Skipping superuser creation."
)
)
self.stdout.write("---") # Separator
# --- Create Demo User ---
# Use getattr to safely check for the DEMO setting, default to False if not present
create_demo_user = getattr(settings, "DEMO", False)
if create_demo_user:
demo_email = "demo@demo.com"
demo_password = (
"wygiwyhdemo" # Consider making this an env var too for security
)
demo_username = demo_email # Using email as username for consistency
self.stdout.write(
f"DEMO setting is True. Attempting to create demo user: {demo_email}"
)
username_field = User.USERNAME_FIELD # Get the actual username field name
# Check if the user already exists by email or username
user_exists_kwargs = {"email": demo_email}
if username_field != "email":
user_exists_kwargs[username_field] = demo_username
if User.objects.filter(**user_exists_kwargs).exists():
self.stdout.write(
self.style.WARNING(
f"Demo user with email '{demo_email}' (or corresponding username) already exists. Skipping creation."
)
)
else:
try:
create_kwargs = {
username_field: demo_username,
"email": demo_email,
"password": demo_password,
}
User.objects.create_user(**create_kwargs)
self.stdout.write(
self.style.SUCCESS(
f"Demo user '{demo_email}' created successfully."
)
)
except IntegrityError as e:
self.stdout.write(
self.style.ERROR(
f"Failed to create demo user '{demo_email}'. IntegrityError: {e}"
)
)
except Exception as e:
self.stdout.write(
self.style.ERROR(
f"An unexpected error occurred creating demo user '{demo_email}': {e}"
)
)
else:
self.stdout.write(
self.style.NOTICE(
"DEMO setting is not True (or not set). Skipping demo user creation."
)
)
self.stdout.write(self.style.SUCCESS("User setup command finished."))

View File

@@ -1,7 +1,9 @@
import logging
from asgiref.sync import sync_to_async
from django.conf import settings
from django.core import management
from django.db import DEFAULT_DB_ALIAS
from procrastinate import builtin_tasks
from procrastinate.contrib.django import app
@@ -40,3 +42,40 @@ async def remove_expired_sessions(timestamp=None):
"Error while executing 'remove_expired_sessions' task",
exc_info=True,
)
@app.periodic(cron="0 8 * * *")
@app.task(name="reset_demo_data")
def reset_demo_data(timestamp=None):
"""
Wipes the database and loads fresh demo data if DEMO mode is active.
Runs daily at 8:00 AM.
"""
if not settings.DEMO:
return # Exit if not in demo mode
logger.info("Demo mode active. Starting daily data reset...")
try:
# 1. Flush the database (wipe all data)
logger.info("Flushing the database...")
management.call_command(
"flush", "--noinput", database=DEFAULT_DB_ALIAS, verbosity=1
)
logger.info("Database flushed successfully.")
# 2. Load data from the fixture
# TO-DO: Roll dates over based on today's date
fixture_name = "fixtures/demo_data.json"
logger.info(f"Loading data from fixture: {fixture_name}...")
management.call_command(
"loaddata", fixture_name, database=DEFAULT_DB_ALIAS, verbosity=1
)
logger.info(f"Data loaded successfully from {fixture_name}.")
logger.info("Daily demo data reset completed.")
except Exception as e:
logger.exception(f"Error during daily demo data reset: {e}")
raise

View File

@@ -15,10 +15,11 @@ from cachalot.api import invalidate
from apps.common.decorators.htmx import only_htmx
from apps.transactions.models import Transaction
from apps.common.decorators.user import htmx_login_required
@only_htmx
@login_required
@htmx_login_required
@require_http_methods(["GET"])
def toasts(request):
return render(request, "common/fragments/toasts.html")

View File

@@ -11,9 +11,11 @@ from apps.common.decorators.htmx import only_htmx
from apps.currencies.forms import ExchangeRateForm, ExchangeRateServiceForm
from apps.currencies.models import ExchangeRate, ExchangeRateService
from apps.currencies.tasks import manual_fetch_exchange_rates
from apps.common.decorators.demo import disabled_on_demo
@login_required
@disabled_on_demo
@require_http_methods(["GET"])
def exchange_rates_services_index(request):
return render(
@@ -24,6 +26,7 @@ def exchange_rates_services_index(request):
@only_htmx
@login_required
@disabled_on_demo
@require_http_methods(["GET"])
def exchange_rates_services_list(request):
services = ExchangeRateService.objects.all()
@@ -37,6 +40,7 @@ def exchange_rates_services_list(request):
@only_htmx
@login_required
@disabled_on_demo
@require_http_methods(["GET", "POST"])
def exchange_rate_service_add(request):
if request.method == "POST":
@@ -63,6 +67,7 @@ def exchange_rate_service_add(request):
@only_htmx
@login_required
@disabled_on_demo
@require_http_methods(["GET", "POST"])
def exchange_rate_service_edit(request, pk):
service = get_object_or_404(ExchangeRateService, id=pk)
@@ -91,6 +96,7 @@ def exchange_rate_service_edit(request, pk):
@only_htmx
@login_required
@disabled_on_demo
@require_http_methods(["DELETE"])
def exchange_rate_service_delete(request, pk):
service = get_object_or_404(ExchangeRateService, id=pk)
@@ -109,6 +115,7 @@ def exchange_rate_service_delete(request, pk):
@only_htmx
@login_required
@disabled_on_demo
@require_http_methods(["GET"])
def exchange_rate_service_force_fetch(request):
manual_fetch_exchange_rates.defer()

View File

@@ -41,11 +41,13 @@ from apps.export_app.resources.transactions import (
RecurringTransactionResource,
)
from apps.export_app.resources.users import UserResource
from apps.common.decorators.demo import disabled_on_demo
logger = logging.getLogger()
@login_required
@disabled_on_demo
@user_passes_test(lambda u: u.is_superuser)
@require_http_methods(["GET"])
def export_index(request):
@@ -53,6 +55,7 @@ def export_index(request):
@login_required
@disabled_on_demo
@user_passes_test(lambda u: u.is_superuser)
@require_http_methods(["GET", "POST"])
def export_form(request):
@@ -182,6 +185,7 @@ def export_form(request):
@only_htmx
@login_required
@disabled_on_demo
@user_passes_test(lambda u: u.is_superuser)
@require_http_methods(["GET", "POST"])
def import_form(request):

View File

@@ -13,9 +13,11 @@ from apps.import_app.forms import ImportRunFileUploadForm, ImportProfileForm
from apps.import_app.models import ImportRun, ImportProfile
from apps.import_app.services import PresetService
from apps.import_app.tasks import process_import
from apps.common.decorators.demo import disabled_on_demo
@login_required
@disabled_on_demo
@require_http_methods(["GET"])
def import_presets_list(request):
presets = PresetService.get_all_presets()
@@ -27,6 +29,7 @@ def import_presets_list(request):
@login_required
@disabled_on_demo
@require_http_methods(["GET", "POST"])
def import_profile_index(request):
return render(
@@ -37,6 +40,7 @@ def import_profile_index(request):
@only_htmx
@login_required
@disabled_on_demo
@require_http_methods(["GET", "POST"])
def import_profile_list(request):
profiles = ImportProfile.objects.all()
@@ -50,6 +54,7 @@ def import_profile_list(request):
@only_htmx
@login_required
@disabled_on_demo
@require_http_methods(["GET", "POST"])
def import_profile_add(request):
message = request.POST.get("message", None)
@@ -85,6 +90,7 @@ def import_profile_add(request):
@only_htmx
@login_required
@disabled_on_demo
@require_http_methods(["GET", "POST"])
def import_profile_edit(request, profile_id):
profile = get_object_or_404(ImportProfile, id=profile_id)
@@ -114,6 +120,7 @@ def import_profile_edit(request, profile_id):
@only_htmx
@login_required
@disabled_on_demo
@require_http_methods(["DELETE"])
def import_profile_delete(request, profile_id):
profile = ImportProfile.objects.get(id=profile_id)
@@ -132,6 +139,7 @@ def import_profile_delete(request, profile_id):
@only_htmx
@login_required
@disabled_on_demo
@require_http_methods(["GET", "POST"])
def import_runs_list(request, profile_id):
profile = ImportProfile.objects.get(id=profile_id)
@@ -147,6 +155,7 @@ def import_runs_list(request, profile_id):
@only_htmx
@login_required
@disabled_on_demo
@require_http_methods(["GET", "POST"])
def import_run_log(request, profile_id, run_id):
run = ImportRun.objects.get(profile__id=profile_id, id=run_id)
@@ -160,6 +169,7 @@ def import_run_log(request, profile_id, run_id):
@only_htmx
@login_required
@disabled_on_demo
@require_http_methods(["GET", "POST"])
def import_run_add(request, profile_id):
profile = ImportProfile.objects.get(id=profile_id)
@@ -202,6 +212,7 @@ def import_run_add(request, profile_id):
@only_htmx
@login_required
@disabled_on_demo
@require_http_methods(["DELETE"])
def import_run_delete(request, profile_id, run_id):
run = ImportRun.objects.get(profile__id=profile_id, id=run_id)

View File

@@ -10,7 +10,7 @@ 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
# First get the category totals as before
category_currency_metrics = (
transactions_queryset.values(
"category",
@@ -74,9 +74,65 @@ def get_categories_totals(transactions_queryset, ignore_empty=False):
.order_by("category__name")
)
# Get tag totals within each category with currency details
tag_metrics = transactions_queryset.values(
"category",
"tags",
"tags__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"),
),
)
# Process the results to structure by category
result = {}
# Process category totals first
for metric in category_currency_metrics:
# Skip empty categories if ignore_empty is True
if ignore_empty and all(
@@ -101,7 +157,11 @@ def get_categories_totals(transactions_queryset, ignore_empty=False):
currency_id = metric["account__currency"]
if category_id not in result:
result[category_id] = {"name": metric["category__name"], "currencies": {}}
result[category_id] = {
"name": metric["category__name"],
"currencies": {},
"tags": {}, # Add tags container
}
# Add currency data
currency_data = {
@@ -162,4 +222,101 @@ def get_categories_totals(transactions_queryset, ignore_empty=False):
result[category_id]["currencies"][currency_id] = currency_data
# Process tag totals and add them to the result, including untagged
for tag_metric in tag_metrics:
category_id = tag_metric["category"]
tag_id = tag_metric["tags"] # Will be None for untagged transactions
if category_id in result:
# Initialize the tag container if not exists
if "tags" not in result[category_id]:
result[category_id]["tags"] = {}
# Determine if this is a tagged or untagged transaction
tag_key = tag_id if tag_id is not None else "untagged"
tag_name = tag_metric["tags__name"] if tag_id is not None else None
if tag_key not in result[category_id]["tags"]:
result[category_id]["tags"][tag_key] = {
"name": tag_name,
"currencies": {},
}
currency_id = tag_metric["account__currency"]
# Calculate tag totals
tag_total_current = (
tag_metric["income_current"] - tag_metric["expense_current"]
)
tag_total_projected = (
tag_metric["income_projected"] - tag_metric["expense_projected"]
)
tag_total_income = (
tag_metric["income_current"] + tag_metric["income_projected"]
)
tag_total_expense = (
tag_metric["expense_current"] + tag_metric["expense_projected"]
)
tag_total_final = tag_total_current + tag_total_projected
tag_currency_data = {
"currency": {
"code": tag_metric["account__currency__code"],
"name": tag_metric["account__currency__name"],
"decimal_places": tag_metric["account__currency__decimal_places"],
"prefix": tag_metric["account__currency__prefix"],
"suffix": tag_metric["account__currency__suffix"],
},
"expense_current": tag_metric["expense_current"],
"expense_projected": tag_metric["expense_projected"],
"total_expense": tag_total_expense,
"income_current": tag_metric["income_current"],
"income_projected": tag_metric["income_projected"],
"total_income": tag_total_income,
"total_current": tag_total_current,
"total_projected": tag_total_projected,
"total_final": tag_total_final,
}
# Add exchange currency support for tags
if tag_metric["account__currency__exchange_currency"]:
from_currency = Currency.objects.get(id=currency_id)
exchange_currency = Currency.objects.get(
id=tag_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=tag_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:
tag_currency_data["exchanged"] = exchanged
result[category_id]["tags"][tag_key]["currencies"][
currency_id
] = tag_currency_data
return result

View File

@@ -1,10 +1,8 @@
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.db.models import Sum
from django.shortcuts import render
from django.utils import timezone
from django.views.decorators.http import require_http_methods
@@ -22,13 +20,13 @@ from apps.insights.utils.category_explorer import (
get_category_sums_by_account,
get_category_sums_by_currency,
)
from apps.insights.utils.category_overview import get_categories_totals
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
@@ -170,6 +168,24 @@ def category_sum_by_currency(request):
@login_required
@require_http_methods(["GET"])
def category_overview(request):
if "view_type" in request.GET:
view_type = request.GET["view_type"]
request.session["insights_category_explorer_view_type"] = view_type
else:
view_type = request.session.get("insights_category_explorer_view_type", "table")
if "show_tags" in request.GET:
show_tags = request.GET["show_tags"] == "on"
request.session["insights_category_explorer_show_tags"] = show_tags
else:
show_tags = request.session.get("insights_category_explorer_show_tags", True)
if "showing" in request.GET:
showing = request.GET["showing"]
request.session["insights_category_explorer_showing"] = showing
else:
showing = request.session.get("insights_category_explorer_showing", "final")
# Get filtered transactions
transactions = get_transactions(request, include_silent=True)
@@ -180,7 +196,12 @@ def category_overview(request):
return render(
request,
"insights/fragments/category_overview/index.html",
{"total_table": total_table},
{
"total_table": total_table,
"view_type": view_type,
"show_tags": show_tags,
"showing": showing,
},
)

View File

@@ -18,9 +18,11 @@ from apps.rules.models import (
)
from apps.common.models import SharedObject
from apps.common.forms import SharedObjectForm
from apps.common.decorators.demo import disabled_on_demo
@login_required
@disabled_on_demo
@require_http_methods(["GET"])
def rules_index(request):
return render(
@@ -31,6 +33,7 @@ def rules_index(request):
@only_htmx
@login_required
@disabled_on_demo
@require_http_methods(["GET"])
def rules_list(request):
transaction_rules = TransactionRule.objects.all().order_by("id")
@@ -43,6 +46,7 @@ def rules_list(request):
@only_htmx
@login_required
@disabled_on_demo
@require_http_methods(["GET", "POST"])
def transaction_rule_toggle_activity(request, transaction_rule_id, **kwargs):
transaction_rule = get_object_or_404(TransactionRule, id=transaction_rule_id)
@@ -65,6 +69,7 @@ def transaction_rule_toggle_activity(request, transaction_rule_id, **kwargs):
@only_htmx
@login_required
@disabled_on_demo
@require_http_methods(["GET", "POST"])
def transaction_rule_add(request, **kwargs):
if request.method == "POST":
@@ -91,6 +96,7 @@ def transaction_rule_add(request, **kwargs):
@only_htmx
@login_required
@disabled_on_demo
@require_http_methods(["GET", "POST"])
def transaction_rule_edit(request, transaction_rule_id):
transaction_rule = get_object_or_404(TransactionRule, id=transaction_rule_id)
@@ -129,6 +135,7 @@ def transaction_rule_edit(request, transaction_rule_id):
@only_htmx
@login_required
@disabled_on_demo
@require_http_methods(["GET", "POST"])
def transaction_rule_view(request, transaction_rule_id):
transaction_rule = get_object_or_404(TransactionRule, id=transaction_rule_id)
@@ -142,6 +149,7 @@ def transaction_rule_view(request, transaction_rule_id):
@only_htmx
@login_required
@disabled_on_demo
@require_http_methods(["DELETE"])
def transaction_rule_delete(request, transaction_rule_id):
transaction_rule = get_object_or_404(TransactionRule, id=transaction_rule_id)
@@ -166,6 +174,7 @@ def transaction_rule_delete(request, transaction_rule_id):
@only_htmx
@login_required
@disabled_on_demo
@require_http_methods(["GET"])
def transaction_rule_take_ownership(request, transaction_rule_id):
transaction_rule = get_object_or_404(TransactionRule, id=transaction_rule_id)
@@ -187,6 +196,7 @@ def transaction_rule_take_ownership(request, transaction_rule_id):
@only_htmx
@login_required
@disabled_on_demo
@require_http_methods(["GET", "POST"])
def transaction_rule_share(request, pk):
obj = get_object_or_404(TransactionRule, id=pk)
@@ -225,6 +235,7 @@ def transaction_rule_share(request, pk):
@only_htmx
@login_required
@disabled_on_demo
@require_http_methods(["GET", "POST"])
def transaction_rule_action_add(request, transaction_rule_id):
transaction_rule = get_object_or_404(TransactionRule, id=transaction_rule_id)
@@ -252,6 +263,7 @@ def transaction_rule_action_add(request, transaction_rule_id):
@only_htmx
@login_required
@disabled_on_demo
@require_http_methods(["GET", "POST"])
def transaction_rule_action_edit(request, transaction_rule_action_id):
transaction_rule_action = get_object_or_404(
@@ -289,6 +301,7 @@ def transaction_rule_action_edit(request, transaction_rule_action_id):
@only_htmx
@login_required
@disabled_on_demo
@require_http_methods(["DELETE"])
def transaction_rule_action_delete(request, transaction_rule_action_id):
transaction_rule_action = get_object_or_404(
@@ -309,6 +322,7 @@ def transaction_rule_action_delete(request, transaction_rule_action_id):
@only_htmx
@login_required
@disabled_on_demo
@require_http_methods(["GET", "POST"])
def update_or_create_transaction_rule_action_add(request, transaction_rule_id):
transaction_rule = get_object_or_404(TransactionRule, id=transaction_rule_id)
@@ -340,6 +354,7 @@ def update_or_create_transaction_rule_action_add(request, transaction_rule_id):
@only_htmx
@login_required
@disabled_on_demo
@require_http_methods(["GET", "POST"])
def update_or_create_transaction_rule_action_edit(request, pk):
linked_action = get_object_or_404(UpdateOrCreateTransactionRuleAction, id=pk)
@@ -374,6 +389,7 @@ def update_or_create_transaction_rule_action_edit(request, pk):
@only_htmx
@login_required
@disabled_on_demo
@require_http_methods(["DELETE"])
def update_or_create_transaction_rule_action_delete(request, pk):
linked_action = get_object_or_404(UpdateOrCreateTransactionRuleAction, id=pk)

View File

@@ -6,6 +6,7 @@ from crispy_forms.layout import (
Row,
Column,
Field,
Div,
)
from django import forms
from django.db.models import Q
@@ -206,10 +207,21 @@ class TransactionForm(forms.ModelForm):
else:
self.fields["amount"].widget = ArbitraryDecimalDisplayNumberInput()
self.helper.layout.append(
FormActions(
Div(
NoClassSubmit(
"submit", _("Add"), css_class="btn btn-outline-primary w-100"
"submit", _("Add"), css_class="btn btn-outline-primary"
),
NoClassSubmit(
"submit_and_similar",
_("Save and add similar"),
css_class="btn btn-outline-primary",
),
NoClassSubmit(
"submit_and_another",
_("Save and add another"),
css_class="btn btn-outline-primary",
),
css_class="d-grid gap-2",
),
)

View File

@@ -574,6 +574,7 @@ class InstallmentPlan(models.Model):
installment_plan=self,
installment_id=i,
notes=self.notes if self.add_notes_to_transaction else "",
owner=self.account.owner,
)
new_transaction.tags.set(self.tags.all())
new_transaction.entities.set(self.entities.all())
@@ -640,6 +641,7 @@ class InstallmentPlan(models.Model):
installment_plan=self,
installment_id=i,
notes=self.notes if self.add_notes_to_transaction else "",
owner=self.account.owner,
)
new_transaction.tags.set(self.tags.all())
new_transaction.entities.set(self.entities.all())
@@ -775,6 +777,7 @@ class RecurringTransaction(models.Model):
is_paid=False,
recurring_transaction=self,
notes=self.notes if self.add_notes_to_transaction else "",
owner=self.account.owner,
)
if self.tags.exists():
created_transaction.tags.set(self.tags.all())
@@ -797,12 +800,16 @@ class RecurringTransaction(models.Model):
@classmethod
def generate_upcoming_transactions(cls):
today = timezone.now().date()
recurring_transactions = cls.objects.filter(
recurring_transactions = cls.all_objects.filter(
Q(models.Q(end_date__isnull=True) | Q(end_date__gte=today))
& Q(is_paused=False)
)
for recurring_transaction in recurring_transactions:
logger.info(
f"Processing recurring transaction: {recurring_transaction.description}..."
)
if recurring_transaction.last_generated_date:
start_date = recurring_transaction.get_next_date(
recurring_transaction.last_generated_date
@@ -821,7 +828,10 @@ class RecurringTransaction(models.Model):
today + (recurring_transaction.get_recurrence_delta() * 6),
)
logger.info(f"End date: {end_date}")
while current_date <= end_date:
logger.info(f"Creating transaction for date: {current_date}")
recurring_transaction.create_transaction(current_date, reference_date)
current_date = recurring_transaction.get_next_date(current_date)
reference_date = recurring_transaction.get_next_date(reference_date)

View File

@@ -43,16 +43,71 @@ def transaction_add(request):
year=year,
).date()
update = False
if request.method == "POST":
form = TransactionForm(request.POST)
if form.is_valid():
form.save()
saved_instance = form.save()
messages.success(request, _("Transaction added successfully"))
return HttpResponse(
status=204,
headers={"HX-Trigger": "updated, hide_offcanvas"},
)
if "submit" in request.POST:
return HttpResponse(
status=204,
headers={"HX-Trigger": "updated, hide_offcanvas"},
)
elif "submit_and_another" in request.POST:
form = TransactionForm(
initial={
"date": expected_date,
"type": transaction_type,
},
)
update = True
elif "submit_and_similar" in request.POST:
initial_data = {}
# Define fields to copy from the SAVED instance
direct_fields_to_copy = [
"account", # ForeignKey -> will copy the ID
"type", # ChoiceField -> will copy the value
"is_paid", # BooleanField -> will copy True/False
"date", # DateField -> will copy the date object
"reference_date", # DateField -> will copy the date object
"amount", # DecimalField -> will copy the decimal
"description", # CharField -> will copy the string
"notes", # TextField -> will copy the string
"category", # ForeignKey -> will copy the ID
]
m2m_fields_to_copy = [
"tags", # ManyToManyField -> will copy list of IDs
"entities", # ManyToManyField -> will copy list of IDs
]
# Copy direct fields from the saved instance
for field_name in direct_fields_to_copy:
value = getattr(saved_instance, field_name, None)
if value is not None:
# Handle ForeignKey: use the pk
if hasattr(value, "pk"):
initial_data[field_name] = value.pk
# Handle Date/DateTime/Decimal/Boolean/etc.: use the Python object directly
else:
initial_data[field_name] = (
value # This correctly handles date objects!
)
# Copy M2M fields: provide a list of related object pks
for field_name in m2m_fields_to_copy:
m2m_manager = getattr(saved_instance, field_name)
initial_data[field_name] = list(
m2m_manager.values_list("name", flat=True)
)
# Create a new form instance pre-filled with the correctly typed initial data
form = TransactionForm(initial=initial_data)
update = True # Signal HTMX to update the form area
else:
form = TransactionForm(
initial={
@@ -61,11 +116,15 @@ def transaction_add(request):
},
)
return render(
response = render(
request,
"transactions/fragments/add.html",
{"form": form},
)
if update:
response["HX-Trigger"] = "updated"
return response
@login_required

View File

@@ -2,16 +2,20 @@ from crispy_forms.bootstrap import (
FormActions,
)
from crispy_forms.helper import FormHelper
from crispy_forms.layout import Layout, Submit
from crispy_forms.layout import Layout, Submit, Row, Column, Field, Div
from django import forms
from django.contrib.auth import get_user_model
from django.contrib.auth.forms import (
UsernameField,
AuthenticationForm,
UserCreationForm,
)
from django.db import transaction
from django.utils.translation import gettext_lazy as _
from apps.common.widgets.crispy.submit import NoClassSubmit
from apps.users.models import UserSettings
from apps.common.middleware.thread_local import get_current_user
class LoginForm(AuthenticationForm):
@@ -132,3 +136,269 @@ class UserSettingsForm(forms.ModelForm):
),
),
)
self.fields["language"].help_text = _(
"This changes the language (if available) and how numbers and dates are displayed\n"
"Consider helping translate WYGIWYH to your language at %(translation_link)s"
) % {
"translation_link": '<a href="https://translations.herculino.com" target="_blank">translations.herculino.com</a>'
}
class UserUpdateForm(forms.ModelForm):
new_password1 = forms.CharField(
label=_("New Password"),
widget=forms.PasswordInput(attrs={"autocomplete": "new-password"}),
required=False,
help_text=_("Leave blank to keep the current password."),
)
new_password2 = forms.CharField(
label=_("Confirm New Password"),
widget=forms.PasswordInput(attrs={"autocomplete": "new-password"}),
required=False,
)
class Meta:
model = get_user_model()
# Add the administrative fields
fields = ["first_name", "last_name", "email", "is_active", "is_superuser"]
# Help texts can be defined here or directly in the layout/field definition
help_texts = {
"is_active": _(
"Designates whether this user should be treated as active. Unselect this instead of deleting accounts."
),
"is_superuser": _(
"Designates that this user has all permissions without explicitly assigning them."
),
}
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self.instance = kwargs.get("instance") # Store instance for validation/checks
self.requesting_user = get_current_user()
self.helper = FormHelper()
self.helper.form_tag = False
self.helper.form_method = "post"
# Define the layout using Crispy Forms, including the new fields
self.helper.layout = Layout(
Row(
Column("first_name", css_class="form-group col-md-6"),
Column("last_name", css_class="form-group col-md-6"),
css_class="row",
),
Field("email"),
# Group password fields (optional visual grouping)
Div(
Field("new_password1"),
Field("new_password2"),
css_class="border p-3 rounded mb-3",
),
# Group administrative status fields
Div(
Field("is_active"),
Field("is_superuser"),
css_class="border p-3 rounded mb-3 text-bg-secondary", # Example visual separation
),
)
if self.instance and self.instance.pk:
self.helper.layout.append(
FormActions(
NoClassSubmit(
"submit", _("Update"), css_class="btn btn-outline-primary w-100"
),
),
)
else:
self.helper.layout.append(
FormActions(
NoClassSubmit(
"submit", _("Add"), css_class="btn btn-outline-primary w-100"
),
),
)
if (
self.requesting_user == self.instance
or not self.requesting_user.is_superuser
):
self.fields["is_superuser"].disabled = True
self.fields["is_active"].disabled = True
# Keep existing clean methods
def clean_email(self):
email = self.cleaned_data.get("email")
# Use case-insensitive comparison for email uniqueness check
if (
self.instance
and get_user_model()
.objects.filter(email__iexact=email)
.exclude(pk=self.instance.pk)
.exists()
):
raise forms.ValidationError(
_("This email address is already in use by another account.")
)
return email
def clean_new_password2(self):
new_password1 = self.cleaned_data.get("new_password1")
new_password2 = self.cleaned_data.get("new_password2")
if new_password1 and new_password1 != new_password2:
raise forms.ValidationError(_("The two password fields didn't match."))
if new_password1 and not new_password2:
raise forms.ValidationError(_("Please confirm your new password."))
if new_password2 and not new_password1:
raise forms.ValidationError(_("Please enter the new password first."))
return new_password2
def clean(self):
cleaned_data = super().clean()
is_active_val = cleaned_data.get("is_active")
is_superuser_val = cleaned_data.get("is_superuser")
# --- Crucial Security Check Example ---
# Prevent the requesting user from deactivating or removing superuser status
# from their *own* account via this form.
if (
self.requesting_user
and self.instance
and self.requesting_user.pk == self.instance.pk
):
# Check if 'is_active' field exists and user is trying to set it to False
if "is_active" in self.fields and is_active_val is False:
self.add_error(
"is_active",
_("You cannot deactivate your own account using this form."),
)
# Check if 'is_superuser' field exists, the user *is* currently a superuser,
# and they are trying to set it to False
if (
"is_superuser" in self.fields
and self.instance.is_superuser
and is_superuser_val is False
):
if get_user_model().objects.filter(is_superuser=True).count() <= 1:
self.add_error(
"is_superuser",
_("Cannot remove status from the last superuser."),
)
else:
self.add_error(
"is_superuser",
_(
"You cannot remove your own superuser status using this form."
),
)
return cleaned_data
# Save method remains the same, ModelForm handles boolean fields correctly
def save(self, commit=True):
user = super().save(commit=False)
new_password = self.cleaned_data.get("new_password1")
if new_password:
user.set_password(new_password)
if commit:
user.save()
return user
class UserAddForm(UserCreationForm):
"""
A form for administrators to create new users.
Includes fields for first name, last name, email, active status,
and superuser status. Uses email as the username field.
Inherits password handling from UserCreationForm.
"""
class Meta(UserCreationForm.Meta):
model = get_user_model()
# Specify the fields to include. UserCreationForm automatically handles
# 'password1' and 'password2'. We replace 'username' with 'email'.
fields = ("email", "first_name", "last_name", "is_active", "is_superuser")
field_classes = {
"email": forms.EmailField
} # Ensure email field uses EmailField validation
help_texts = {
"is_active": _(
"Designates whether this user should be treated as active. Unselect this instead of deleting accounts."
),
"is_superuser": _(
"Designates that this user has all permissions without explicitly assigning them."
),
}
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
# Set is_active to True by default for new users, can be overridden by admin
self.fields["is_active"].initial = True
self.helper = FormHelper()
self.helper.form_tag = False
self.helper.form_method = "post"
# Define the layout, including password fields from UserCreationForm
self.helper.layout = Layout(
Field("email"),
Row(
Column("first_name", css_class="form-group col-md-6"),
Column("last_name", css_class="form-group col-md-6"),
css_class="row",
),
# UserCreationForm provides 'password1' and 'password2' fields
Div(
Field("password1", autocomplete="new-password"),
Field("password2", autocomplete="new-password"),
css_class="border p-3 rounded mb-3",
),
# Administrative status fields
Div(
Field("is_active"),
Field("is_superuser"),
css_class="border p-3 rounded mb-3 text-bg-secondary",
),
)
if self.instance and self.instance.pk:
self.helper.layout.append(
FormActions(
NoClassSubmit(
"submit", _("Update"), css_class="btn btn-outline-primary w-100"
),
),
)
else:
self.helper.layout.append(
FormActions(
NoClassSubmit(
"submit", _("Add"), css_class="btn btn-outline-primary w-100"
),
),
)
def clean_email(self):
"""Ensure email uniqueness (case-insensitive)."""
email = self.cleaned_data.get("email")
if email and get_user_model().objects.filter(email__iexact=email).exists():
raise forms.ValidationError(
_("A user with this email address already exists.")
)
return email
@transaction.atomic # Ensure user creation is atomic
def save(self, commit=True):
"""
Save the user instance. UserCreationForm's save handles password hashing.
Our Meta class ensures other fields are included.
"""
user = super().save(commit=False)
if commit:
user.save()
return user

View File

@@ -0,0 +1,18 @@
# Generated by Django 5.1.8 on 2025-04-13 03:35
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('users', '0019_alter_usersettings_language'),
]
operations = [
migrations.AlterField(
model_name='usersettings',
name='language',
field=models.CharField(choices=[('auto', 'Auto'), ('af', 'Afrikaans'), ('ar', 'العربية'), ('ar-dz', 'العربية (الجزائر)'), ('ast', 'Asturianu'), ('az', 'Azərbaycan'), ('bg', 'Български'), ('be', 'Беларуская'), ('bn', 'বাংলা'), ('br', 'Brezhoneg'), ('bs', 'Bosanski'), ('ca', 'Català'), ('ckb', 'کوردیی ناوەندی'), ('cs', 'Čeština'), ('cy', 'Cymraeg'), ('da', 'Dansk'), ('de', 'Deutsch'), ('dsb', 'Dolnoserbšćina'), ('el', 'Ελληνικά'), ('en', 'English'), ('en-au', 'English (Australia)'), ('en-gb', 'English (UK)'), ('eo', 'Esperanto'), ('es', 'Español'), ('es-ar', 'Español (Argentina)'), ('es-co', 'Español (Colombia)'), ('es-mx', 'Español (México)'), ('es-ni', 'Español (Nicaragua)'), ('es-ve', 'Español (Venezuela)'), ('et', 'Eesti'), ('eu', 'Euskara'), ('fa', 'فارسی'), ('fi', 'Suomi'), ('fr', 'Français'), ('fy', 'Frysk'), ('ga', 'Gaeilge'), ('gd', 'Gàidhlig'), ('gl', 'Galego'), ('he', 'עברית'), ('hi', 'हिन्दी'), ('hr', 'Hrvatski'), ('hsb', 'Hornjoserbšćina'), ('hu', 'Magyar'), ('hy', 'Հայերեն'), ('ia', 'Interlingua'), ('id', 'Bahasa Indonesia'), ('ig', 'Igbo'), ('io', 'Ido'), ('is', 'Íslenska'), ('it', 'Italiano'), ('ja', '日本語'), ('ka', 'ქართული'), ('kab', 'Taqbaylit'), ('kk', 'Қазақша'), ('km', 'ខ្មែរ'), ('kn', 'ಕನ್ನಡ'), ('ko', '한국어'), ('ky', 'Кыргызча'), ('lb', 'Lëtzebuergesch'), ('lt', 'Lietuvių'), ('lv', 'Latviešu'), ('mk', 'Македонски'), ('ml', 'മലയാളം'), ('mn', 'Монгол'), ('mr', 'मराठी'), ('ms', 'Bahasa Melayu'), ('my', 'မြန်မာဘာသာ'), ('nb', 'Norsk (Bokmål)'), ('ne', 'नेपाली'), ('nl', 'Nederlands'), ('nn', 'Norsk (Nynorsk)'), ('os', 'Ирон'), ('pa', 'ਪੰਜਾਬੀ'), ('pl', 'Polski'), ('pt', 'Português'), ('pt-br', 'Português (Brasil)'), ('ro', 'Română'), ('ru', 'Русский'), ('sk', 'Slovenčina'), ('sl', 'Slovenščina'), ('sq', 'Shqip'), ('sr', 'Српски'), ('sr-latn', 'Srpski (Latinica)'), ('sv', 'Svenska'), ('sw', 'Kiswahili'), ('ta', 'தமிழ்'), ('te', 'తెలుగు'), ('tg', 'Тоҷикӣ'), ('th', 'ไทย'), ('tk', 'Türkmençe'), ('tr', 'Türkçe'), ('tt', 'Татарча'), ('udm', 'Удмурт'), ('ug', 'ئۇيغۇرچە'), ('uk', 'Українська'), ('ur', 'اردو'), ('uz', 'Oʻzbekcha'), ('vi', 'Tiếng Việt'), ('zh-hans', '简体中文'), ('zh-hant', '繁體中文')], default='auto', max_length=10, verbose_name='Language'),
),
]

View File

@@ -22,4 +22,24 @@ urlpatterns = [
views.update_settings,
name="user_settings",
),
path(
"users/",
views.users_index,
name="users_index",
),
path(
"users/list/",
views.users_list,
name="users_list",
),
path(
"user/add/",
views.user_add,
name="user_add",
),
path(
"user/<int:pk>/edit/",
views.user_edit,
name="user_edit",
),
]

View File

@@ -1,20 +1,26 @@
from django.contrib import messages
from django.contrib.auth import logout
from django.contrib.auth import logout, get_user_model
from django.contrib.auth.decorators import login_required
from django.contrib.auth.views import (
LoginView,
)
from django.core.exceptions import PermissionDenied
from django.http import HttpResponse
from django.shortcuts import redirect, render
from django.shortcuts import redirect, render, get_object_or_404
from django.urls import reverse
from django.utils.translation import gettext_lazy as _
from django.views.decorators.http import require_http_methods
from apps.common.decorators.htmx import only_htmx
from apps.common.decorators.user import is_superuser, htmx_login_required
from apps.users.forms import (
LoginForm,
UserSettingsForm,
UserUpdateForm,
UserAddForm,
)
from apps.common.decorators.htmx import only_htmx
from apps.users.models import UserSettings
from apps.common.decorators.demo import disabled_on_demo
def logout_view(request):
@@ -22,7 +28,7 @@ def logout_view(request):
return redirect(reverse("login"))
@login_required
@htmx_login_required
def index(request):
if request.user.settings.start_page == UserSettings.StartPage.MONTHLY:
return redirect(reverse("monthly_index"))
@@ -49,7 +55,7 @@ class UserLoginView(LoginView):
@only_htmx
@login_required
@htmx_login_required
def toggle_amount_visibility(request):
user_settings, created = UserSettings.objects.get_or_create(user=request.user)
current_hide_amounts = user_settings.hide_amounts
@@ -70,7 +76,7 @@ def toggle_amount_visibility(request):
@only_htmx
@login_required
@htmx_login_required
def toggle_sound_playing(request):
user_settings, created = UserSettings.objects.get_or_create(user=request.user)
current_mute_sounds = user_settings.mute_sounds
@@ -91,7 +97,7 @@ def toggle_sound_playing(request):
@only_htmx
@login_required
@htmx_login_required
def update_settings(request):
user_settings = request.user.settings
@@ -108,3 +114,86 @@ def update_settings(request):
form = UserSettingsForm(instance=user_settings)
return render(request, "users/fragments/user_settings.html", {"form": form})
@htmx_login_required
@is_superuser
@require_http_methods(["GET"])
def users_index(request):
return render(
request,
"users/pages/index.html",
)
@only_htmx
@htmx_login_required
@is_superuser
@require_http_methods(["GET"])
def users_list(request):
users = get_user_model().objects.all().order_by("id")
return render(
request,
"users/fragments/list.html",
{"users": users},
)
@only_htmx
@htmx_login_required
@is_superuser
@require_http_methods(["GET", "POST"])
def user_add(request):
if request.method == "POST":
form = UserAddForm(request.POST)
if form.is_valid():
form.save()
messages.success(request, _("Item added successfully"))
return HttpResponse(
status=204,
headers={
"HX-Trigger": "updated, hide_offcanvas",
},
)
else:
form = UserAddForm()
return render(
request,
"users/fragments/add.html",
{"form": form},
)
@only_htmx
@htmx_login_required
@disabled_on_demo
@require_http_methods(["GET", "POST"])
def user_edit(request, pk):
user = get_object_or_404(get_user_model(), id=pk)
if not request.user.is_superuser and user != request.user:
raise PermissionDenied
if request.method == "POST":
form = UserUpdateForm(request.POST, instance=user)
if form.is_valid():
form.save()
messages.success(request, _("Item updated successfully"))
return HttpResponse(
status=204,
headers={
"HX-Trigger": "updated, hide_offcanvas",
},
)
else:
form = UserUpdateForm(instance=user)
return render(
request,
"users/fragments/edit.html",
{"form": form, "user": user},
)

1022
app/fixtures/demo_data.json Normal file

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -1,7 +1,8 @@
{% spaceless %}
{% load i18n %}
<span class="tw-text-xs text-white-50 mx-1"
data-bs-toggle="tooltip"
data-bs-title="{{ content }}">
<i class="fa-solid fa-circle-question fa-fw"></i>
<i class="{% if not icon %}fa-solid fa-circle-question{% else %}{{ icon }}{% endif %} fa-fw"></i>
</span>
{% endspaceless %}

View File

@@ -3,7 +3,7 @@
{% if icon %}<i class="{{ icon }}"></i>{% else %}<span class="fw-bold">{{ title.0 }}</span>{% endif %}
</div>
<div class="card-body">
<h5 class="tw-text-{{ color }}-400 fw-bold tw-mr-[50px]" {{ attrs }}>{{ title }}{% if help_text %}{% include 'includes/help_icon.html' with content=help_text %}{% endif %}</h5>
<h5 class="tw-text-{{ color }}-400 fw-bold tw-mr-[50px]" {{ attrs }}>{{ title }}{% if help_text %}<c-ui.help-icon :content="help_text" icon=""></c-ui.help-icon>{% endif %}</h5>
{{ slot }}
</div>
</div>

View File

@@ -1,27 +1,27 @@
{% load i18n %}
<div class="progress-stacked">
<div class="progress position-relative" role="progressbar" aria-label="{% trans 'Projected Income' %} ({{ percentage.percentages.income_projected|floatformat:2 }}%)" aria-valuenow="{{ percentage.percentages.expense_projected|floatformat:0 }}" aria-valuemin="0" aria-valuemax="100" style="width: {{ percentage.percentages.income_projected|floatformat:0 }}%">
<div class="progress position-relative" role="progressbar" aria-label="{% trans 'Projected Income' %} ({{ percentage.percentages.income_projected|floatformat:2 }}%)" aria-valuenow="{{ percentage.percentages.expense_projected|floatformat:0 }}" aria-valuemin="0" aria-valuemax="100" style="width: {{ percentage.percentages.income_projected|floatformat:"2u" }}%">
<div class="progress-bar progress-bar-striped !tw-bg-green-300"
data-bs-toggle="tooltip"
data-bs-placement="top"
title="{% trans 'Projected Income' %} ({{ percentage.percentages.income_projected|floatformat:2 }}%)">
</div>
</div>
<div class="progress position-relative" role="progressbar" aria-label="{% trans 'Current Income' %} ({{ percentage.percentages.income_current|floatformat:2 }}%)" aria-valuenow="{{ percentage.percentages.expense_projected|floatformat:0 }}" aria-valuemin="0" aria-valuemax="100" style="width: {{ percentage.percentages.income_current|floatformat:0 }}%">
<div class="progress position-relative" role="progressbar" aria-label="{% trans 'Current Income' %} ({{ percentage.percentages.income_current|floatformat:2 }}%)" aria-valuenow="{{ percentage.percentages.expense_projected|floatformat:0 }}" aria-valuemin="0" aria-valuemax="100" style="width: {{ percentage.percentages.income_current|floatformat:"2u" }}%">
<div class="progress-bar !tw-bg-green-400"
data-bs-toggle="tooltip"
data-bs-placement="top"
title="{% trans 'Current Income' %} ({{ p.percentages.income_current|floatformat:2 }}%)">
</div>
</div>
<div class="progress position-relative" role="progressbar" aria-label="{% trans 'Projected Expenses' %} ({{ percentage.percentages.expense_projected|floatformat:2 }}%)" aria-valuenow="{{ percentage.percentages.expense_projected|floatformat:0 }}" aria-valuemin="0" aria-valuemax="100" style="width: {{ percentage.percentages.expense_projected|floatformat:0 }}%">
<div class="progress position-relative" role="progressbar" aria-label="{% trans 'Projected Expenses' %} ({{ percentage.percentages.expense_projected|floatformat:2 }}%)" aria-valuenow="{{ percentage.percentages.expense_projected|floatformat:0 }}" aria-valuemin="0" aria-valuemax="100" style="width: {{ percentage.percentages.expense_projected|floatformat:"2u" }}%">
<div class="progress-bar progress-bar-striped !tw-bg-red-300"
data-bs-toggle="tooltip"
data-bs-placement="top"
title="{% trans 'Projected Expenses' %} ({{ percentage.percentages.expense_projected|floatformat:2 }}%)">
</div>
</div>
<div class="progress position-relative" role="progressbar" aria-label="{% trans 'Current Expenses' %} ({{ percentage.percentages.expense_current|floatformat:2 }}%)" aria-valuenow="{{ percentage.percentages.expense_projected|floatformat:0 }}" aria-valuemin="0" aria-valuemax="100" style="width: {{ percentage.percentages.expense_current|floatformat:0 }}%">
<div class="progress position-relative" role="progressbar" aria-label="{% trans 'Current Expenses' %} ({{ percentage.percentages.expense_current|floatformat:2 }}%)" aria-valuenow="{{ percentage.percentages.expense_projected|floatformat:0 }}" aria-valuemin="0" aria-valuemax="100" style="width: {{ percentage.percentages.expense_current|floatformat:"2u" }}%">
<div class="progress-bar !tw-bg-red-400"
data-bs-toggle="tooltip"
data-bs-placement="top"

View File

@@ -94,7 +94,7 @@
</ul>
</li>
<li class="nav-item dropdown">
<a class="nav-link dropdown-toggle {% active_link views='tags_index||entities_index||categories_index||accounts_index||account_groups_index||currencies_index||exchange_rates_index||rules_index||import_profiles_index||automatic_exchange_rates_index||export_index' %}"
<a class="nav-link dropdown-toggle {% active_link views='tags_index||entities_index||categories_index||accounts_index||account_groups_index||currencies_index||exchange_rates_index||rules_index||import_profiles_index||automatic_exchange_rates_index||export_index||users_index' %}"
href="#" role="button"
data-bs-toggle="dropdown"
aria-expanded="false">
@@ -138,9 +138,13 @@
{% endif %}
<li><a class="dropdown-item {% active_link views='automatic_exchange_rates_index' %}"
href="{% url 'automatic_exchange_rates_index' %}">{% translate 'Automatic Exchange Rates' %}</a></li>
{% if user.is_superuser %}
<li>
<hr class="dropdown-divider">
</li>
<li><h6 class="dropdown-header">{% trans 'Admin' %}</h6></li>
<li><a class="dropdown-item {% active_link views='users_index' %}"
href="{% url 'users_index' %}">{% translate 'Users' %}</a></li>
<li>
<a class="dropdown-item"
href="{% url 'admin:index' %}"
@@ -151,6 +155,7 @@
{% translate 'Django Admin' %}
</a>
</li>
{% endif %}
</ul>
</li>
</ul>

View File

@@ -5,11 +5,18 @@
<i class="fa-solid fa-user"></i>
</a>
<ul class="dropdown-menu dropdown-menu-start dropdown-menu-lg-end">
<li class="dropdown-item-text">{{ user.email }}</li>
<li><hr class="dropdown-divider"></li>
<li><a class="dropdown-item"
hx-get="{% url 'user_settings' %}"
hx-target="#generic-offcanvas"
role="button">
<i class="fa-solid fa-gear me-2 fa-fw"></i>{% translate 'Settings' %}</a></li>
<li><a class="dropdown-item"
hx-get="{% url 'user_edit' pk=request.user.id %}"
hx-target="#generic-offcanvas"
role="button">
<i class="fa-solid fa-user me-2 fa-fw"></i>{% translate 'Edit profile' %}</a></li>
<li><hr class="dropdown-divider"></li>
{% spaceless %}
<li>

View File

@@ -1,15 +1,30 @@
{% load i18n %}
<script type="text/hyperscript">
behavior htmx_error_handler
on htmx:responseError or htmx:afterRequest[detail.failed] or htmx:sendError queue none
call Swal.fire({title: '{% trans 'Something went wrong loading your data' %}',
text: '{% trans 'Try reloading the page or check the console for more information.' %}',
icon: 'error',
customClass: {
confirmButton: 'btn btn-primary'
},
buttonsStyling: true})
then log event
then halt the event
end
behavior htmx_error_handler
on htmx:responseError or htmx:afterRequest[detail.failed] or htmx:sendError queue none
-- Check if the event detail contains the xhr object and the status is 403
if event.detail.xhr.status == 403 then
call Swal.fire({
title: '{% trans "Access Denied" %}',
text: '{% trans "You do not have permission to perform this action or access this resource." %}',
icon: 'warning',
customClass: {
confirmButton: 'btn btn-warning' -- Optional: different button style
},
buttonsStyling: true
})
else
call Swal.fire({
title: '{% trans "Something went wrong loading your data" %}',
text: '{% trans "Try reloading the page or check the console for more information." %}',
icon: 'error',
customClass: {
confirmButton: 'btn btn-primary'
},
buttonsStyling: true
})
end
then log event
then halt the event
end
</script>

View File

@@ -1,68 +1,431 @@
{% load i18n %}
<div hx-get="{% url 'category_overview' %}" hx-trigger="updated from:window" class="show-loading" hx-swap="outerHTML" hx-include="#picker-form, #picker-type">
{% if total_table %}
<div class="table-responsive">
<table class="table table-striped table-hover">
<thead>
<tr>
<th scope="col">{% trans 'Category' %}</th>
<th scope="col">{% trans 'Income' %}</th>
<th scope="col">{% trans 'Expense' %}</th>
<th scope="col">{% trans 'Total' %}</th>
</tr>
</thead>
<tbody>
{% for category in total_table.values %}
<tr>
<th>{% if category.name %}{{ category.name }}{% else %}{% trans 'Uncategorized' %}{% endif %}</th>
<td>
{% for currency in category.currencies.values %}
{% if currency.total_income != 0 %}
<c-amount.display
:amount="currency.total_income"
:prefix="currency.currency.prefix"
:suffix="currency.currency.suffix"
:decimal_places="currency.currency.decimal_places"
color="green"></c-amount.display>
{% else %}
<div>-</div>
{% endif %}
{% endfor %}
</td>
<td>
{% for currency in category.currencies.values %}
{% if currency.total_expense != 0 %}
<c-amount.display
:amount="currency.total_expense"
:prefix="currency.currency.prefix"
:suffix="currency.currency.suffix"
:decimal_places="currency.currency.decimal_places"
color="red"></c-amount.display>
{% else %}
<div>-</div>
{% endif %}
{% endfor %}
</td>
<td>
{% for currency in category.currencies.values %}
{% if currency.total_final != 0 %}
<c-amount.display
:amount="currency.total_final"
:prefix="currency.currency.prefix"
:suffix="currency.currency.suffix"
:decimal_places="currency.currency.decimal_places"
color="{% if currency.total_final < 0 %}red{% else %}green{% endif %}"></c-amount.display>
{% else %}
<div>-</div>
{% endif %}
{% endfor %}
</td>
</tr>
{% endfor %}
</tbody>
</table>
<div hx-get="{% url 'category_overview' %}" hx-trigger="updated from:window" class="show-loading" hx-swap="outerHTML"
hx-include="#picker-form, #picker-type, #view-type, #show-tags, #showing">
<div class="h-100 text-center mb-4">
<div class="btn-group gap-3" role="group" id="view-type" _="on change trigger updated">
<input type="radio" class="btn-check"
name="view_type"
id="table-view"
autocomplete="off"
value="table"
{% if view_type == "table" %}checked{% endif %}>
<label class="btn btn-outline-primary rounded-5" for="table-view"><i
class="fa-solid fa-table fa-fw me-2"></i>{% trans 'Table' %}</label>
<input type="radio"
class="btn-check"
name="view_type"
id="bars-view"
autocomplete="off"
value="bars"
{% if view_type == "bars" %}checked{% endif %}>
<label class="btn btn-outline-primary rounded-5" for="bars-view"><i
class="fa-solid fa-chart-bar fa-fw me-2"></i>{% trans 'Bars' %}</label>
</div>
</div>
<div class="mt-3 mb-1 d-flex flex-column flex-md-row justify-content-between">
<div class="form-check form-switch" id="show-tags">
{% if view_type == 'table' %}
<input type="hidden" name="show_tags" value="off">
<input class="form-check-input" type="checkbox" role="switch" id="show-tags-switch" name="show_tags"
_="on change trigger updated" {% if show_tags %}checked{% endif %}>
{% spaceless %}
<label class="form-check-label" for="show-tags-switch">
{% trans 'Tags' %}
</label>
<c-ui.help-icon
content="{% trans 'Transaction amounts associated with multiple tags will be counted once for each tag' %}"
icon="fa-solid fa-circle-exclamation"></c-ui.help-icon>
{% endspaceless %}
{% endif %}
</div>
<div class="btn-group btn-group-sm" role="group" id="showing" _="on change trigger updated">
<input type="radio" class="btn-check" name="showing" id="showing-projected" autocomplete="off"
value="projected" {% if showing == 'projected' %}checked{% endif %}>
<label class="btn btn-outline-primary" for="showing-projected">{% trans "Projected" %}</label>
<input type="radio" class="btn-check" name="showing" id="showing-current" autocomplete="off" value="current"
{% if showing == 'current' %}checked{% endif %}>
<label class="btn btn-outline-primary" for="showing-current">{% trans "Current" %}</label>
<input type="radio" class="btn-check" name="showing" id="showing-final" autocomplete="off" value="final"
{% if showing == 'final' %}checked{% endif %}>
<label class="btn btn-outline-primary" for="showing-final">{% trans "Final total" %}</label>
</div>
</div>
{% if total_table %}
{% if view_type == "table" %}
<div class="table-responsive">
<table class="table table-striped table-hover table-bordered align-middle">
<thead>
<tr>
<th scope="col">{% trans 'Category' %}</th>
<th scope="col">{% trans 'Income' %}</th>
<th scope="col">{% trans 'Expense' %}</th>
<th scope="col">{% trans 'Total' %}</th>
</tr>
</thead>
<tbody class="table-group-divider">
{% for category in total_table.values %}
<!-- Category row -->
<tr class="table-group-header">
<th>{% if category.name %}{{ category.name }}{% else %}{% trans 'Uncategorized' %}{% endif %}</th>
<td> {# income #}
{% for currency in category.currencies.values %}
{% if showing == 'current' and currency.income_current != 0 %}
<c-amount.display
:amount="currency.income_current"
:prefix="currency.currency.prefix"
:suffix="currency.currency.suffix"
:decimal_places="currency.currency.decimal_places"
color="green"></c-amount.display>
{% elif showing == 'projected' and currency.income_projected != 0 %}
<c-amount.display
:amount="currency.income_projected"
:prefix="currency.currency.prefix"
:suffix="currency.currency.suffix"
:decimal_places="currency.currency.decimal_places"
color="green"></c-amount.display>
{% elif showing == 'final' and currency.total_income != 0 %}
<c-amount.display
:amount="currency.total_income"
:prefix="currency.currency.prefix"
:suffix="currency.currency.suffix"
:decimal_places="currency.currency.decimal_places"
color="green"></c-amount.display>
{% else %}
<div>-</div>
{% endif %}
{% endfor %}
</td>
<td> {# expenses #}
{% for currency in category.currencies.values %}
{% if showing == 'current' and currency.expense_current != 0 %}
<c-amount.display
:amount="currency.expense_current"
:prefix="currency.currency.prefix"
:suffix="currency.currency.suffix"
:decimal_places="currency.currency.decimal_places"
color="red"></c-amount.display>
{% elif showing == 'projected' and currency.expense_projected != 0 %}
<c-amount.display
:amount="currency.expense_projected"
:prefix="currency.currency.prefix"
:suffix="currency.currency.suffix"
:decimal_places="currency.currency.decimal_places"
color="red"></c-amount.display>
{% elif showing == 'final' and currency.total_expense != 0 %}
<c-amount.display
:amount="currency.total_expense"
:prefix="currency.currency.prefix"
:suffix="currency.currency.suffix"
:decimal_places="currency.currency.decimal_places"
color="red"></c-amount.display>
{% else %}
<div>-</div>
{% endif %}
{% endfor %}
</td>
<td> {# total #}
{% for currency in category.currencies.values %}
{% if showing == 'current' and currency.total_current != 0 %}
<c-amount.display
:amount="currency.total_current"
:prefix="currency.currency.prefix"
:suffix="currency.currency.suffix"
:decimal_places="currency.currency.decimal_places"
color="{% if currency.total_final < 0 %}red{% else %}green{% endif %}"></c-amount.display>
{% elif showing == 'projected' and currency.total_projected != 0 %}
<c-amount.display
:amount="currency.total_projected"
:prefix="currency.currency.prefix"
:suffix="currency.currency.suffix"
:decimal_places="currency.currency.decimal_places"
color="{% if currency.total_final < 0 %}red{% else %}green{% endif %}"></c-amount.display>
{% elif showing == 'final' and currency.total_final != 0 %}
<c-amount.display
:amount="currency.total_final"
:prefix="currency.currency.prefix"
:suffix="currency.currency.suffix"
:decimal_places="currency.currency.decimal_places"
color="{% if currency.total_final < 0 %}red{% else %}green{% endif %}"></c-amount.display>
{% else %}
<div>-</div>
{% endif %}
{% endfor %}
</td>
</tr>
<!-- Tag rows -->
{% if show_tags %}
{% for tag_id, tag in category.tags.items %}
{% if tag.name or not tag.name and category.tags.values|length > 1 %}
<tr class="table-row-nested">
<td class="ps-4">
<i class="fa-solid fa-hashtag fa-fw me-2 text-muted"></i>{% if tag.name %}{{ tag.name }}{% else %}{% trans 'Untagged' %}{% endif %}
</td>
<td>
{% for currency in tag.currencies.values %}
{% if showing == 'current' and currency.income_current != 0 %}
<c-amount.display
:amount="currency.income_current"
:prefix="currency.currency.prefix"
:suffix="currency.currency.suffix"
:decimal_places="currency.currency.decimal_places"
color="green"></c-amount.display>
{% elif showing == 'projected' and currency.income_projected != 0 %}
<c-amount.display
:amount="currency.income_projected"
:prefix="currency.currency.prefix"
:suffix="currency.currency.suffix"
:decimal_places="currency.currency.decimal_places"
color="green"></c-amount.display>
{% elif showing == 'final' and currency.total_income != 0 %}
<c-amount.display
:amount="currency.total_income"
:prefix="currency.currency.prefix"
:suffix="currency.currency.suffix"
:decimal_places="currency.currency.decimal_places"
color="green"></c-amount.display>
{% else %}
<div>-</div>
{% endif %}
{% endfor %}
</td>
<td>
{% for currency in tag.currencies.values %}
{% if showing == 'current' and currency.expense_current != 0 %}
<c-amount.display
:amount="currency.expense_current"
:prefix="currency.currency.prefix"
:suffix="currency.currency.suffix"
:decimal_places="currency.currency.decimal_places"
color="red"></c-amount.display>
{% elif showing == 'projected' and currency.expense_projected != 0 %}
<c-amount.display
:amount="currency.expense_projected"
:prefix="currency.currency.prefix"
:suffix="currency.currency.suffix"
:decimal_places="currency.currency.decimal_places"
color="red"></c-amount.display>
{% elif showing == 'final' and currency.total_expense != 0 %}
<c-amount.display
:amount="currency.total_expense"
:prefix="currency.currency.prefix"
:suffix="currency.currency.suffix"
:decimal_places="currency.currency.decimal_places"
color="red"></c-amount.display>
{% else %}
<div>-</div>
{% endif %}
{% endfor %}
</td>
<td>
{% for currency in tag.currencies.values %}
{% if showing == 'current' and currency.total_current != 0 %}
<c-amount.display
:amount="currency.total_current"
:prefix="currency.currency.prefix"
:suffix="currency.currency.suffix"
:decimal_places="currency.currency.decimal_places"
color="{% if currency.total_final < 0 %}red{% else %}green{% endif %}"></c-amount.display>
{% elif showing == 'projected' and currency.total_projected != 0 %}
<c-amount.display
:amount="currency.total_projected"
:prefix="currency.currency.prefix"
:suffix="currency.currency.suffix"
:decimal_places="currency.currency.decimal_places"
color="{% if currency.total_final < 0 %}red{% else %}green{% endif %}"></c-amount.display>
{% elif showing == 'final' and currency.total_final != 0 %}
<c-amount.display
:amount="currency.total_final"
:prefix="currency.currency.prefix"
:suffix="currency.currency.suffix"
:decimal_places="currency.currency.decimal_places"
color="{% if currency.total_final < 0 %}red{% else %}green{% endif %}"></c-amount.display>
{% else %}
<div>-</div>
{% endif %}
{% endfor %}
</td>
</tr>
{% endif %}
{% endfor %}
{% endif %}
{% endfor %}
</tbody>
</table>
</div>
{% elif view_type == "bars" %}
<div>
<div class="chart-container" _="init call setupChart() end" style="position: relative; height:78vh; width:100%">
<canvas id="categoryChart"></canvas>
</div>
</div>
{{ total_table|json_script:"categoryOverviewData" }}
{{ showing|json_script:"showingString" }}
<script>
function setupChart() {
var rawData = JSON.parse(document.getElementById('categoryOverviewData').textContent);
var showing_string = JSON.parse(document.getElementById('showingString').textContent);
console.log(showing_string)
// --- Dynamic Data Processing ---
var categories = [];
var currencyDetails = {}; // Stores details like { BRL: {code: 'BRL', name: 'Real', ...}, ... }
var currencyData = {}; // Stores data arrays like { BRL: [val1, null, val3,...], ... }
// Pass 1: Collect categories and currency details
Object.values(rawData).forEach(cat => {
var categoryName = cat.name === null ? "{% trans 'Uncategorized' %}" : cat.name;
if (!categories.includes(categoryName)) {
categories.push(categoryName);
}
if (cat.currencies) {
Object.values(cat.currencies).forEach(curr => {
var details = curr.currency;
if (details && details.code && !currencyDetails[details.code]) {
var decimals = parseInt(details.decimal_places, 10);
currencyDetails[details.code] = {
code: details.code,
name: details.name || details.code,
prefix: details.prefix || '',
suffix: details.suffix || '',
// Ensure decimal_places is a non-negative integer
decimal_places: !isNaN(decimals) && decimals >= 0 ? decimals : 2
};
}
});
}
});
// Initialize data structure for each currency with nulls
Object.keys(currencyDetails).forEach(code => {
currencyData[code] = new Array(categories.length).fill(null);
});
// Pass 2: Populate data arrays (store all valid numbers now)
Object.values(rawData).forEach(cat => {
var categoryName = cat.name === null ? "{% trans 'Uncategorized' %}" : cat.name;
var catIndex = categories.indexOf(categoryName);
if (catIndex === -1) return;
if (cat.currencies) {
Object.values(cat.currencies).forEach(curr => {
var code = curr.currency?.code;
if (code && currencyData[code]) {
if (showing_string == 'current') {
var value = parseFloat(curr.total_current);
} else if (showing_string == 'projected') {
var value = parseFloat(curr.total_projected);
} else {
var value = parseFloat(curr.total_final);
}
// Store the number if it's valid, otherwise keep null
currencyData[code][catIndex] = !isNaN(value) ? value : null;
}
});
}
});
// --- Dynamic Chart Configuration ---
var datasets = Object.keys(currencyDetails).map((code, index) => {
return {
label: currencyDetails[code].name, // Use currency name for the legend label
data: currencyData[code],
currencyCode: code, // Store code for easy lookup in tooltip
borderWidth: 1
};
});
new Chart(document.getElementById('categoryChart'),
{
type: 'bar',
data: {
labels: categories,
datasets: datasets
},
options: {
indexAxis: 'y',
responsive: true,
interaction: {
intersect: false,
mode: 'nearest',
axis: "y"
},
maintainAspectRatio: false,
plugins: {
title: {
display: false
},
tooltip: {
callbacks: {
label: function (context) {
const dataset = context.dataset;
const currencyCode = dataset.currencyCode;
const details = currencyDetails[currencyCode];
const value = context.parsed.x; // Use 'x' because indexAxis is 'y'
if (value === null || value === undefined || !details) {
// Display the category name if the value is null/undefined
return null;
}
let formattedValue = '';
try {
// Use Intl.NumberFormat for ALL values, configured with locale and exact decimal places
formattedValue = new Intl.NumberFormat(undefined, {
minimumFractionDigits: details.decimal_places,
maximumFractionDigits: details.decimal_places,
// Do NOT use style: 'currency' here, as we add prefix/suffix manually
}).format(value);
} catch (e) {
formattedValue = value.toFixed(details.decimal_places);
}
// Return label with currency name and formatted value including prefix/suffix
return `${details.prefix}${formattedValue}${details.suffix}`;
}
}
},
legend: {
position: 'top',
}
},
scales: {
x: {
stacked: true,
type: 'linear',
title: {
display: true,
text: '{% trans 'Final Total' %}'
},
ticks: {
// Format ticks using the detected locale
callback: function (value, index, ticks) {
return value.toLocaleString();
}
}
},
y: {
stacked: true,
title: {
display: false,
text: '{% trans 'Category' %}'
}
}
}
}
});
}
</script>
{% endif %}
{% else %}
<c-msg.empty title="{% translate "No categories" %}"></c-msg.empty>
{% endif %}

View File

@@ -2,6 +2,8 @@
{% load crispy_forms_tags %}
{% load i18n %}
{% block title %}{% translate 'Insights' %}{% endblock %}
{% block content %}
<div class="container-fluid">
<div class="row my-3 h-100">

View File

@@ -1,3 +1,4 @@
{% load settings %}
{% load pwa %}
{% load formats %}
{% load i18n %}
@@ -5,43 +6,53 @@
{% load webpack_loader %}
<!doctype html>
<html lang="en" data-bs-theme="dark">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>
{% filter site_title %}
{% block title %}
{% endblock title %}
{% endfilter %}
</title>
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>
{% filter site_title %}
{% block title %}
{% endblock title %}
{% endfilter %}
</title>
{% include 'includes/head/favicons.html' %}
{% progressive_web_app_meta %}
{% include 'includes/head/favicons.html' %}
{% progressive_web_app_meta %}
{% include 'includes/styles.html' %}
{% block extra_styles %}{% endblock %}
{% include 'includes/scripts.html' %}
{% include 'includes/styles.html' %}
{% block extra_styles %}{% endblock %}
{% block extra_js_head %}{% endblock %}
</head>
<body class="font-monospace">
<div _="install hide_amounts
{% include 'includes/scripts.html' %}
{% block extra_js_head %}{% endblock %}
</head>
<body class="font-monospace">
<div _="install hide_amounts
install htmx_error_handler
{% block body_hyperscript %}{% endblock %}"
hx-headers='{"X-CSRFToken": "{{ csrf_token }}"}'>
{% include 'includes/navbar.html' %}
hx-headers='{"X-CSRFToken": "{{ csrf_token }}"}'>
{% include 'includes/navbar.html' %}
<div id="content">
{% block content %}{% endblock %}
</div>
{% include 'includes/offcanvas.html' %}
{% include 'includes/toasts.html' %}
{% settings "DEMO" as demo_mode %}
{% if demo_mode %}
<div class="px-3 m-0" id="demo-mode-alert" hx-preserve>
<div class="alert alert-warning alert-dismissible fade show my-3" role="alert">
<strong>{% trans 'This is a demo!' %}</strong> {% trans 'Any data you add here will be wiped in 24hrs or less' %}
<button type="button" class="btn-close" data-bs-dismiss="alert" aria-label="Close"></button>
</div>
</div>
{% endif %}
{% include 'includes/tools/calculator.html' %}
<div id="content">
{% block content %}{% endblock %}
</div>
{% block extra_js_body %}{% endblock %}
</body>
{% include 'includes/offcanvas.html' %}
{% include 'includes/toasts.html' %}
</div>
{% include 'includes/tools/calculator.html' %}
{% block extra_js_body %}{% endblock %}
</body>
</html>

View File

@@ -0,0 +1,11 @@
{% extends 'extends/offcanvas.html' %}
{% load i18n %}
{% load crispy_forms_tags %}
{% block title %}{% translate 'Add user' %}{% endblock %}
{% block body %}
<form hx-post="{% url 'user_add' %}" hx-target="#generic-offcanvas" novalidate>
{% crispy form %}
</form>
{% endblock %}

View File

@@ -0,0 +1,11 @@
{% extends 'extends/offcanvas.html' %}
{% load i18n %}
{% load crispy_forms_tags %}
{% block title %}{% translate 'Edit user' %}{% endblock %}
{% block body %}
<form hx-post="{% url 'user_edit' pk=user.id %}" hx-target="#generic-offcanvas" novalidate>
{% crispy form %}
</form>
{% endblock %}

View File

@@ -0,0 +1,85 @@
{% load hijack %}
{% load i18n %}
<div class="container px-md-3 py-3 column-gap-5">
<div class="tw-text-3xl fw-bold font-monospace tw-w-full mb-3">
{% spaceless %}
<div>{% translate 'Users' %}<span>
<a class="text-decoration-none tw-text-2xl p-1 category-action"
role="button"
data-bs-toggle="tooltip"
data-bs-title="{% translate "Add" %}"
hx-get="{% url 'user_add' %}"
hx-target="#generic-offcanvas">
<i class="fa-solid fa-circle-plus fa-fw"></i></a>
</span></div>
{% endspaceless %}
</div>
<div class="card">
<div class="card-body">
<div id="tags-table">
{% if users %}
<div class="table-responsive">
<c-config.search></c-config.search>
<table class="table table-hover">
<thead>
<tr>
<th scope="col" class="col-auto"></th>
<th scope="col" class="col">{% translate 'Active' %}</th>
<th scope="col" class="col">{% translate 'Name' %}</th>
<th scope="col" class="col">{% translate 'Email' %}</th>
<th scope="col" class="col">{% translate 'Superuser' %}</th>
</tr>
</thead>
<tbody>
{% for user in users %}
<tr class="tag">
<td class="col-auto">
<div class="btn-group" role="group" aria-label="{% translate 'Actions' %}">
<a class="btn btn-secondary btn-sm"
role="button"
hx-swap="innerHTML"
data-bs-toggle="tooltip"
data-bs-title="{% translate "Edit" %}"
hx-get="{% url 'user_edit' pk=user.id %}"
hx-target="#generic-offcanvas">
<i class="fa-solid fa-pencil fa-fw"></i></a>
{% if request.user|can_hijack:user and request.user != user %}
<a class="btn btn-info btn-sm"
role="button"
data-bs-toggle="tooltip"
data-bs-title="{% translate "Impersonate" %}"
hx-post="{% url 'hijack:acquire' %}"
hx-vals='{"user_pk":"{{user.id}}"}'
hx-swap="none"
_="on htmx:afterRequest(event) from me
if event.detail.successful
go to url '/'">
<i class="fa-solid fa-mask fa-fw"></i></a>
{% endif %}
</div>
</td>
<td class="col">
{% if user.is_active %}
<i class="fa-solid fa-solid fa-check text-success"></i>
{% endif %}
</td>
<td class="col">{{ user.first_name }} {{ user.last_name }}</td>
<td class="col">{{ user.email }}</td>
<td class="col">
{% if user.is_superuser %}
<i class="fa-solid fa-solid fa-check text-success"></i>
{% endif %}
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% else %}
<c-msg.empty title="{% translate "No users" %}" remove-padding></c-msg.empty>
{% endif %}
</div>
</div>
</div>
</div>

View File

@@ -1,4 +1,6 @@
{% extends "layouts/base_auth.html" %}
{% load i18n %}
{% load settings %}
{% load crispy_forms_tags %}
{% block title %}Login{% endblock %}
@@ -7,15 +9,26 @@
<div>
<div class="container">
<div class="row vh-100 d-flex justify-content-center align-items-center">
<div class="col-md-6 col-xl-4 col-12">
<div class="card shadow-lg">
<div class="card-body">
<h2 class="card-title text-center mb-4">Login</h2>
{% crispy form %}
</div>
</div>
<div class="col-md-6 col-xl-4 col-12">
{% settings "DEMO" as demo_mode %}
{% if demo_mode %}
<div class="card shadow mb-3">
<div class="card-body">
<h1 class="h5 card-title text-center mb-4">{% trans "Welcome to WYGIWYH's demo!" %}</h1>
<p>{% trans 'Use the credentials below to login' %}:</p>
<p>{% trans 'E-mail' %}: <span class="badge text-bg-secondary user-select-all">demo@demo.com</span></p>
<p>{% trans 'Password' %}: <span class="badge text-bg-secondary user-select-all">wygiwyhdemo</span></p>
</div>
</div>
{% endif %}
<div class="card shadow-lg">
<div class="card-body">
<h1 class="h2 card-title text-center mb-4">Login</h1>
{% crispy form %}
</div>
</div>
</div>
</div>
</div>
</div>
{% endblock %}

View File

@@ -0,0 +1,8 @@
{% extends "layouts/base.html" %}
{% load i18n %}
{% block title %}{% translate 'Users' %}{% endblock %}
{% block content %}
<div hx-get="{% url 'users_list' %}" hx-trigger="load, updated from:window" class="show-loading"></div>
{% endblock %}

View File

@@ -11,4 +11,6 @@ python manage.py migrate
# Create flag file to signal migrations are complete
touch /tmp/migrations_complete
python manage.py setup_users
exec python manage.py runserver 0.0.0.0:8000

View File

@@ -13,4 +13,6 @@ python manage.py migrate
# Create flag file to signal migrations are complete
touch /tmp/migrations_complete
python manage.py setup_users
exec gunicorn WYGIWYH.wsgi:application --bind 0.0.0.0:8000 --timeout 600

View File

@@ -6,6 +6,7 @@ window.TomSelect = function createDynamicTomSelect(element) {
// Basic configuration
const config = {
plugins: {},
maxOptions: null,
// Extract 'create' option from data attribute
create: element.dataset.create === 'true',

View File

@@ -1,32 +1,32 @@
Django~=5.1
psycopg[binary]==3.2.3
python-webpack-boilerplate==1.0.3
psycopg[binary]==3.2.6
python-webpack-boilerplate==1.0.4
django-crispy-forms==2.3
crispy-bootstrap5==2024.10
django-browser-reload==1.12.1
django-hijack==3.4.5
django-filter==24.3
django-debug-toolbar==4.3.0
django-cachalot~=2.6.3
django-cotton~=1.2.1
crispy-bootstrap5==2025.4
django-browser-reload==1.18.0
django-hijack==3.7.1
django-filter==25.1
django-debug-toolbar==4.4.6
django-cachalot~=2.7.0
django-cotton~=1.5.2
django-pwa~=2.0.1
djangorestframework~=3.15.2
drf-spectacular~=0.27.2
django-import-export~=4.3.5
djangorestframework~=3.16.0
drf-spectacular~=0.28.0
django-import-export~=4.3.7
gunicorn==23.0.0
whitenoise[brotli]==6.6.0
whitenoise[brotli]==6.9.0
watchfiles==0.24.0 # https://github.com/samuelcolvin/watchfiles
procrastinate[django]~=2.14
procrastinate[django]~=2.15.1
requests~=2.32.3
pytz~=2024.2
pytz
python-dateutil~=2.9.0.post0
simpleeval~=1.0.0
pydantic~=2.10.5
simpleeval~=1.0.3
pydantic~=2.11.3
PyYAML~=6.0.2
mistune~=3.1.1
openpyxl~=3.1
xlrd~=2.0
mistune~=3.1.3
openpyxl~=3.1.5
xlrd~=2.0.1