diff --git a/expenses_manager/expenses/forms.py b/expenses_manager/expenses/forms.py index 67b42c4..89bc673 100644 --- a/expenses_manager/expenses/forms.py +++ b/expenses_manager/expenses/forms.py @@ -30,7 +30,7 @@ class ExpenseForm(forms.ModelForm): ] widgets = { "date": forms.DateInput(format="%Y-%m-%d", attrs={"type": "date"}), - "widget": forms.CheckboxSelectMultiple(), + "tags": forms.CheckboxSelectMultiple(), } @@ -95,6 +95,8 @@ class CategoryForm(forms.ModelForm): self.fields["parent"].queryset = Category.objects.filter( owner=user, ) + + class GoalForm(forms.ModelForm): class Meta: model = Goal diff --git a/expenses_manager/expenses/management/commands/seed_demo.py b/expenses_manager/expenses/management/commands/seed_demo.py index 876ced3..e2c8bd4 100644 --- a/expenses_manager/expenses/management/commands/seed_demo.py +++ b/expenses_manager/expenses/management/commands/seed_demo.py @@ -1,8 +1,9 @@ from datetime import date from decimal import Decimal +from django.conf import settings from django.utils import timezone from django.contrib.auth import get_user_model -from django.core.management.base import BaseCommand +from django.core.management.base import BaseCommand, CommandError from expenses.models import Account, Category, Tag, Expense @@ -11,6 +12,13 @@ class Command(BaseCommand): help = 'Seed demo data for development' def handle(self, *args, **options): + if not settings.DEBUG: + raise CommandError( + "seed_demo solo puede ejecutarse con DEBUG=True " + "(entorno de desarrollo). Abortando para no crear un " + "superusuario con contraseña conocida en producción." + ) + User = get_user_model() user, created = User.objects.get_or_create( @@ -20,13 +28,14 @@ class Command(BaseCommand): if created: user.set_password('demo1234') - user.save() user.is_staff = True user.is_superuser = True + user.save() self.stdout.write(self.style.SUCCESS("Demo user created")) else: user.is_staff = True user.is_superuser = True + user.save() self.stdout.write('Demo user exists!!') # ------------------ diff --git a/expenses_manager/expenses/static/expenses/css/base.css b/expenses_manager/expenses/static/expenses/css/base.css index 70ce213..60255bc 100644 --- a/expenses_manager/expenses/static/expenses/css/base.css +++ b/expenses_manager/expenses/static/expenses/css/base.css @@ -430,4 +430,92 @@ tbody tr:hover { padding: 10px; border-radius: 8px; background: #f9f9f9; +} + +.row-inactive { + opacity: 0.55; +} + +.badge { + display: inline-block; + padding: 2px 8px; + border-radius: 12px; + font-size: 0.8em; + font-weight: 600; +} + +.badge-active { + background-color: #e6f4ea; + color: #1e7e34; +} + +.badge-inactive { + background-color: #fbe9e7; + color: #b71c1c; +} + +.settings-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); + gap: 16px; + margin-top: 20px; +} + +.settings-card { + display: block; + padding: 16px; + border: 1px solid #ddd; + border-radius: 8px; + text-decoration: none; + color: inherit; + transition: box-shadow 0.15s ease; +} + +.settings-card:hover { + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1); +} + +.settings-card h3 { + margin: 0 0 4px 0; +} + +.settings-card p { + margin: 0; + font-size: 0.9em; + color: #666; +} + +.tag-chip-list { + display: flex; + flex-wrap: wrap; + gap: 8px; + margin: 8px 0; + max-height: 160px; + overflow-y: auto; + padding: 8px; + border: 1px solid #ddd; + border-radius: 8px; +} + +.tag-chip { + display: inline-flex; + align-items: center; + gap: 4px; + padding: 4px 10px; + border: 1px solid #ccc; + border-radius: 16px; + font-size: 0.85em; + cursor: pointer; + user-select: none; + background-color: #fafafa; +} + +.tag-chip input[type="checkbox"] { + accent-color: #1e7e34; +} + +.tag-chip:has(input:checked) { + background-color: #e6f4ea; + border-color: #1e7e34; + color: #1e7e34; } \ No newline at end of file diff --git a/expenses_manager/expenses/templates/expenses/account_list.html b/expenses_manager/expenses/templates/expenses/account_list.html index 6867499..cc7bd4d 100644 --- a/expenses_manager/expenses/templates/expenses/account_list.html +++ b/expenses_manager/expenses/templates/expenses/account_list.html @@ -22,10 +22,18 @@ {{ account.name }} {{ account.initial_balance }} {{ account.current_balance|floatformat:2 }} - {{ account.active }} + + {% if account.active %} + Activa + {% else %} + Inactiva + {% endif %} + Editar - Eliminar + {% if account.active %} + Eliminar + {% endif %} {% empty %} diff --git a/expenses_manager/expenses/templates/expenses/expense_form.html b/expenses_manager/expenses/templates/expenses/expense_form.html index e2fe7e5..090e5d5 100644 --- a/expenses_manager/expenses/templates/expenses/expense_form.html +++ b/expenses_manager/expenses/templates/expenses/expense_form.html @@ -19,7 +19,33 @@
{% csrf_token %} - {{ form.as_p }} + + {% for field in form %} + {% if field.name == "tags" %} +
+ {{ field.label_tag }} +
+ {% for checkbox in field %} + + {% endfor %} +
+ {% if field.errors %} +
{{ field.errors }}
+ {% endif %} +
+ {% else %} +

+ {{ field.label_tag }} {{ field }} + {% if field.errors %} + {{ field.errors }} + {% endif %} +

+ {% endif %} + {% endfor %} + + Cancelar +
+ +{% endblock %} \ No newline at end of file diff --git a/expenses_manager/expenses/templates/settings/index.html b/expenses_manager/expenses/templates/settings/index.html new file mode 100644 index 0000000..7226251 --- /dev/null +++ b/expenses_manager/expenses/templates/settings/index.html @@ -0,0 +1,29 @@ +{% extends "expenses/base.html" %} + +{% block title %} + Configuración +{% endblock %} + +{% block content %} +

Configuración

+ +

Gestiona las categorías, etiquetas y objetivos de tu cuenta

+ +
+ +

Categorías

+

Organiza tus gastos por tipo.

+
+ + +

Etiquetas

+

Añade etiquetas libres a tus gastos.

+
+ + +

Objetivos

+

Define y controla tus metas de gasto.

+
+
+ +{% endblock %} \ No newline at end of file diff --git a/expenses_manager/expenses/views.py b/expenses_manager/expenses/views.py index 0f86762..bbb139e 100644 --- a/expenses_manager/expenses/views.py +++ b/expenses_manager/expenses/views.py @@ -705,12 +705,17 @@ def fuel_create(request): form = FuelEntryForm(request.POST, user=request.user) if form.is_valid(): + category, _ = Category.objects.get_or_create( + slug="gasolina", + owner=request.user, + defaults={"name": "Gasolina"}, + ) expense = Expense.objects.create( owner=request.user, date=form.cleaned_data["date"], amount=form.cleaned_data["amount"], account=form.cleaned_data["account"], - category=Category.objects.get(slug="gasolina"), + category=category, description="Repostaje", ) @@ -725,9 +730,9 @@ def fuel_create(request): else: form = FuelEntryForm(user=request.user) - return render( - request, "fuel/create.html", {"active_menu": "fuel", "form": form} - ) + return render( + request, "fuel/create.html", {"active_menu": "fuel", "form": form} + ) @login_required @@ -938,10 +943,18 @@ def goal_delete(request, pk): pk=pk, owner=request.user) - goal.delete() - return redirect("goal_list") + if request.method == "POST": + goal.delete() + messages.success(request, "Objetivo eliminado.") + return redirect("goal_list") + + return render( + request, + "goals/confirm_delete.html", + {"active_menu":"settings", "goal":goal} + ) @login_required def settings_index(request): - return render(request, "settings/index.html") + return render(request, "settings/index.html", {"active_menu": "settings"}) diff --git a/expenses_manager/expenses_manager/settings.py b/expenses_manager/expenses_manager/settings.py index e07368b..d1b1d8a 100644 --- a/expenses_manager/expenses_manager/settings.py +++ b/expenses_manager/expenses_manager/settings.py @@ -13,7 +13,7 @@ https://docs.djangoproject.com/en/5.2/ref/settings/ import os from pathlib import Path from dotenv import load_dotenv -from django.contrib.messages import constants as messages +from django.core.exceptions import ImproperlyConfigured load_dotenv() @@ -30,13 +30,34 @@ BASE_DIR = Path(__file__).resolve().parent.parent # SECURITY WARNING: don't run with debug turned on in production! DEBUG = os.getenv("DEBUG", "False") == "True" -SECRET_KEY = os.environ.get('SECRET_KEY', 'fallback-secret-key-for-dev') +SECRET_KEY = os.environ.get("SECRET_KEY") +if not SECRET_KEY: + if DEBUG: + SECRET_KEY = "django-insecure-dev-only-fallback-key" + else: + raise ImproperlyConfigured( + "SECRET_KEY no está definido. Es obligatorio en producción " + "(DEBUG-False) - revisa el .env del despliegue en el NAS" + ) ALLOWED_HOSTS = [ - "finanzas.kuijper.es", + h.strip() + for h in os.environ.get("ALLOWED_HOSTS", "localhost, 127.0.0.1").split(",") + if h.strip() ] +CSRF_TRUSTED_ORIGINS = [ + o.strip() + for o in os.environ.get("CSRF_TRUSTED_ORIGINS", "").split(",") + if o.strip() +] + +if not DEBUG: + SESSION_COOKIE_SECURE = True + CSRF_COOKIE_SECURE = True + SECURE_SSL_REDIRECT = True + SECURE_PROXY_SSL_HEADER = ("HTTP_X_FORWARDED_PROTO", "https") # Application definition @@ -52,6 +73,7 @@ INSTALLED_APPS = [ MIDDLEWARE = [ 'django.middleware.security.SecurityMiddleware', + 'whitenoise.middleware.WhiteNoiseMiddleware', 'django.contrib.sessions.middleware.SessionMiddleware', 'django.middleware.common.CommonMiddleware', 'django.middleware.csrf.CsrfViewMiddleware', @@ -82,50 +104,61 @@ WSGI_APPLICATION = 'expenses_manager.wsgi.application' # Database # https://docs.djangoproject.com/en/5.2/ref/settings/#databases - -DATABASES = { - 'default': { - 'ENGINE': 'django.db.backends.sqlite3', - 'NAME': BASE_DIR / 'db.sqlite3', + +DB_ENGINE = os.environ.get("DB_ENGINE", "sqlite3") +if DB_ENGINE == "postgresql": + DATABASES = { + 'default': { + "ENGINE": "django.db.backends.postgresql", + "NAME": os.environ["DB_NAME"], + "USER": os.environ["DB_USER"], + "PASSWORD": os.environ["DB_PASSWORD"], + "HOST": os.environ.get("DB_HOST", "localhost"), + "PORT": os.environ.get("DB_PORT", "5432"), + } } -} +else: + DATABASES = { + 'default': { + 'ENGINE': 'django.db.backends.sqlite3', + 'NAME': BASE_DIR / 'db.sqlite3', + } + } + + + # Password validation # https://docs.djangoproject.com/en/5.2/ref/settings/#auth-password-validators AUTH_PASSWORD_VALIDATORS = [ - { - 'NAME': 'django.contrib.auth.password_validation.UserAttributeSimilarityValidator', - }, - { - 'NAME': 'django.contrib.auth.password_validation.MinimumLengthValidator', - }, - { - 'NAME': 'django.contrib.auth.password_validation.CommonPasswordValidator', - }, - { - 'NAME': 'django.contrib.auth.password_validation.NumericPasswordValidator', - }, + {'NAME': 'django.contrib.auth.password_validation.UserAttributeSimilarityValidator',}, + {'NAME': 'django.contrib.auth.password_validation.MinimumLengthValidator',}, + {'NAME': 'django.contrib.auth.password_validation.CommonPasswordValidator',}, + {'NAME': 'django.contrib.auth.password_validation.NumericPasswordValidator',}, ] # Internationalization # https://docs.djangoproject.com/en/5.2/topics/i18n/ -LANGUAGE_CODE = 'en-us' - +LANGUAGE_CODE = 'es' +LANGUAGE_CODE = 'es' TIME_ZONE = 'UTC' - USE_I18N = True - USE_TZ = True +DATE_INPUT_FORMATS = ["%d/%m/%Y"] +DATETIME_INPUT_FORMATS = ["%d/%m/%Y %H:%M"] + # Static files (CSS, JavaScript, Images) # https://docs.djangoproject.com/en/5.2/howto/static-files/ -STATIC_URL = 'static/' +STATIC_URL = '/static/' +STATIC_ROOT = BASE_DIR / 'staticfiles' +STATICFILES_STORAGE = "whitenoise.storage.CompressedManifestStaticFilesStorage" # Default primary key field type # https://docs.djangoproject.com/en/5.2/ref/settings/#default-auto-field @@ -134,4 +167,23 @@ DEFAULT_AUTO_FIELD = 'django.db.models.BigAutoField' LOGIN_URL = 'login' LOGIN_REDIRECT_URL = 'home' -LOGOUT_REDIRECT_URL = 'login' \ No newline at end of file +LOGOUT_REDIRECT_URL = 'login' + +LOGGING = { + "version": 1, + "disable_existing_loggers": False, + "handlers": { + "console": {"class": "logging.StreamHandler"}, + }, + "root": { + "handlers": ["console"], + "level": "WARNING", + }, + "loggers": { + "django": { + "handlers": ["console"], + "level": "INFO", + "propagate": False, + }, + }, +} \ No newline at end of file diff --git a/expenses_manager/requirements.txt b/expenses_manager/requirements.txt index fcb9d89..f46da45 100644 Binary files a/expenses_manager/requirements.txt and b/expenses_manager/requirements.txt differ