From 47d34f3c27522919a89a7b78e0dc8bb36ea37528 Mon Sep 17 00:00:00 2001 From: Herculino Trotta Date: Mon, 31 Mar 2025 02:14:00 -0300 Subject: [PATCH] feat(app): add a demo mode --- README.md | 3 +- app/WYGIWYH/settings.py | 1 + app/apps/common/decorators/demo.py | 15 ++++ app/apps/common/management/__init__.py | 0 .../common/management/commands/__init__.py | 0 app/apps/common/tasks.py | 38 ++++++++++ .../views/exchange_rates_services.py | 7 ++ app/apps/export_app/views.py | 4 ++ app/apps/import_app/views.py | 11 +++ app/apps/rules/views.py | 16 +++++ app/fixtures/demo_data.json | 34 +++++++++ .../hyperscript/htmx_error_handler.html | 39 ++++++---- app/templates/layouts/base.html | 71 +++++++++++-------- app/templates/users/login.html | 27 +++++-- 14 files changed, 216 insertions(+), 50 deletions(-) create mode 100644 app/apps/common/decorators/demo.py create mode 100644 app/apps/common/management/__init__.py create mode 100644 app/apps/common/management/commands/__init__.py create mode 100644 app/fixtures/demo_data.json diff --git a/README.md b/README.md index 05733d6..ca580db 100644 --- a/README.md +++ b/README.md @@ -117,7 +117,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 +129,7 @@ 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. | diff --git a/app/WYGIWYH/settings.py b/app/WYGIWYH/settings.py index 009233f..63c142d 100644 --- a/app/WYGIWYH/settings.py +++ b/app/WYGIWYH/settings.py @@ -394,3 +394,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_MODE", "false").lower() == "true" diff --git a/app/apps/common/decorators/demo.py b/app/apps/common/decorators/demo.py new file mode 100644 index 0000000..933d8b4 --- /dev/null +++ b/app/apps/common/decorators/demo.py @@ -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 diff --git a/app/apps/common/management/__init__.py b/app/apps/common/management/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/apps/common/management/commands/__init__.py b/app/apps/common/management/commands/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/apps/common/tasks.py b/app/apps/common/tasks.py index 0a8d688..e9c33b4 100644 --- a/app/apps/common/tasks.py +++ b/app/apps/common/tasks.py @@ -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,39 @@ async def remove_expired_sessions(timestamp=None): "Error while executing 'remove_expired_sessions' task", exc_info=True, ) + + +@app.periodic(cron="0 6 * * *") +@app.task(name="reset_demo_data") +def reset_demo_data(): + """ + Wipes the database and loads fresh demo data if DEMO mode is active. + Runs daily at 6: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...") + # Using --noinput prevents prompts. Specify database if not default. + management.call_command( + "flush", "--noinput", database=DEFAULT_DB_ALIAS, verbosity=1 + ) + logger.info("Database flushed successfully.") + + # 2. Load data from the fixture + 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 diff --git a/app/apps/currencies/views/exchange_rates_services.py b/app/apps/currencies/views/exchange_rates_services.py index c572f3b..7548769 100644 --- a/app/apps/currencies/views/exchange_rates_services.py +++ b/app/apps/currencies/views/exchange_rates_services.py @@ -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() diff --git a/app/apps/export_app/views.py b/app/apps/export_app/views.py index 94dd7bc..b60f4a0 100644 --- a/app/apps/export_app/views.py +++ b/app/apps/export_app/views.py @@ -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): diff --git a/app/apps/import_app/views.py b/app/apps/import_app/views.py index a9f45ad..7309ecc 100644 --- a/app/apps/import_app/views.py +++ b/app/apps/import_app/views.py @@ -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) diff --git a/app/apps/rules/views.py b/app/apps/rules/views.py index 79e8588..7ac0013 100644 --- a/app/apps/rules/views.py +++ b/app/apps/rules/views.py @@ -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) diff --git a/app/fixtures/demo_data.json b/app/fixtures/demo_data.json new file mode 100644 index 0000000..e184873 --- /dev/null +++ b/app/fixtures/demo_data.json @@ -0,0 +1,34 @@ +[ +{ + "model": "users.user", + "pk": 1, + "fields": { + "password": "pbkdf2_sha256$870000$kUmqeqdenjc2yFS8qbniwS$7qOMXzKG+yFmezdjhptkwuMJlqlZnQHXgAnonWurpBk=", + "last_login": "2025-03-31T03:22:25Z", + "is_superuser": false, + "first_name": "Demo", + "last_name": "User", + "is_staff": false, + "is_active": true, + "date_joined": "2025-03-31T03:21:04Z", + "email": "demo@demo.com", + "groups": [], + "user_permissions": [] + } +}, +{ + "model": "users.usersettings", + "pk": 1, + "fields": { + "user": 1, + "hide_amounts": false, + "mute_sounds": false, + "date_format": "SHORT_DATE_FORMAT", + "datetime_format": "SHORT_DATETIME_FORMAT", + "number_format": "AA", + "language": "auto", + "timezone": "auto", + "start_page": "MONTHLY_OVERVIEW" + } +} +] diff --git a/app/templates/includes/scripts/hyperscript/htmx_error_handler.html b/app/templates/includes/scripts/hyperscript/htmx_error_handler.html index 9bff690..f6c6d8d 100644 --- a/app/templates/includes/scripts/hyperscript/htmx_error_handler.html +++ b/app/templates/includes/scripts/hyperscript/htmx_error_handler.html @@ -1,15 +1,30 @@ {% load i18n %} diff --git a/app/templates/layouts/base.html b/app/templates/layouts/base.html index 90f0384..d5dc92f 100644 --- a/app/templates/layouts/base.html +++ b/app/templates/layouts/base.html @@ -1,3 +1,4 @@ +{% load settings %} {% load pwa %} {% load formats %} {% load i18n %} @@ -5,43 +6,53 @@ {% load webpack_loader %} - - - - - {% filter site_title %} - {% block title %} - {% endblock title %} - {% endfilter %} - + + + + + {% filter site_title %} + {% block title %} + {% endblock title %} + {% endfilter %} + - {% 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 %} - - -
+
- {% include 'includes/navbar.html' %} + hx-headers='{"X-CSRFToken": "{{ csrf_token }}"}'> + {% include 'includes/navbar.html' %} -
- {% block content %}{% endblock %} -
- - {% include 'includes/offcanvas.html' %} - {% include 'includes/toasts.html' %} + {% settings "DEMO" as demo_mode %} + {% if demo_mode %} +
+ +
+ {% endif %} - {% include 'includes/tools/calculator.html' %} +
+ {% block content %}{% endblock %} +
- {% block extra_js_body %}{% endblock %} - + {% include 'includes/offcanvas.html' %} + {% include 'includes/toasts.html' %} +
+ +{% include 'includes/tools/calculator.html' %} + +{% block extra_js_body %}{% endblock %} + diff --git a/app/templates/users/login.html b/app/templates/users/login.html index df4a648..341884c 100644 --- a/app/templates/users/login.html +++ b/app/templates/users/login.html @@ -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 @@
-
-
-
-

Login

- {% crispy form %} -
-
+
+ {% settings "DEMO" as demo_mode %} + {% if demo_mode %} +
+
+

{% trans "Welcome to WYGIWYH's demo!" %}

+

{% trans 'Use the credentials below to login' %}:

+

{% trans 'E-mail' %}: demo@demo.com

+

{% trans 'Password' %}: wygiwyhdemo

+
+ {% endif %} +
+
+

Login

+ {% crispy form %} +
+
+
{% endblock %}