Compare commits

...

12 Commits

10 changed files with 285 additions and 40 deletions

View File

@ -30,7 +30,7 @@ class ExpenseForm(forms.ModelForm):
] ]
widgets = { widgets = {
"date": forms.DateInput(format="%Y-%m-%d", attrs={"type": "date"}), "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( self.fields["parent"].queryset = Category.objects.filter(
owner=user, owner=user,
) )
class GoalForm(forms.ModelForm): class GoalForm(forms.ModelForm):
class Meta: class Meta:
model = Goal model = Goal

View File

@ -1,8 +1,9 @@
from datetime import date from datetime import date
from decimal import Decimal from decimal import Decimal
from django.conf import settings
from django.utils import timezone from django.utils import timezone
from django.contrib.auth import get_user_model 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 from expenses.models import Account, Category, Tag, Expense
@ -11,6 +12,13 @@ class Command(BaseCommand):
help = 'Seed demo data for development' help = 'Seed demo data for development'
def handle(self, *args, **options): 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 = get_user_model()
user, created = User.objects.get_or_create( user, created = User.objects.get_or_create(
@ -20,13 +28,14 @@ class Command(BaseCommand):
if created: if created:
user.set_password('demo1234') user.set_password('demo1234')
user.save()
user.is_staff = True user.is_staff = True
user.is_superuser = True user.is_superuser = True
user.save()
self.stdout.write(self.style.SUCCESS("Demo user created")) self.stdout.write(self.style.SUCCESS("Demo user created"))
else: else:
user.is_staff = True user.is_staff = True
user.is_superuser = True user.is_superuser = True
user.save()
self.stdout.write('Demo user exists!!') self.stdout.write('Demo user exists!!')
# ------------------ # ------------------

View File

@ -431,3 +431,91 @@ tbody tr:hover {
border-radius: 8px; border-radius: 8px;
background: #f9f9f9; 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;
}

View File

@ -22,10 +22,18 @@
<td>{{ account.name }}</td> <td>{{ account.name }}</td>
<td>{{ account.initial_balance }}</td> <td>{{ account.initial_balance }}</td>
<td>{{ account.current_balance|floatformat:2 }}</td> <td>{{ account.current_balance|floatformat:2 }}</td>
<td>{{ account.active }}</td> <td>
{% if account.active %}
<span class="badge badge-active">Activa</span>
{% else %}
<span class="badge badge-inactive">Inactiva</span>
{% endif %}
</td>
<td class="table-actions"> <td class="table-actions">
<a href="{% url 'account_edit' account.id %}">Editar</a> <a href="{% url 'account_edit' account.id %}">Editar</a>
{% if account.active %}
<a href="{% url 'account_delete' account.id %}" class="danger">Eliminar</a> <a href="{% url 'account_delete' account.id %}" class="danger">Eliminar</a>
{% endif %}
</td> </td>
</tr> </tr>
{% empty %} {% empty %}

View File

@ -19,7 +19,33 @@
<form method="post"> <form method="post">
{% csrf_token %} {% csrf_token %}
{{ form.as_p }}
{% for field in form %}
{% if field.name == "tags" %}
<div class="form-field">
{{ field.label_tag }}
<div class="tag-chip-list">
{% for checkbox in field %}
<label class="tag-chip">
{{ checkbox.tag }}
<span>{{ checkbox.choice_label }}</span>
</label>
{% endfor %}
</div>
{% if field.errors %}
<div class="form-errors">{{ field.errors }}</div>
{% endif %}
</div>
{% else %}
<p>
{{ field.label_tag }} {{ field }}
{% if field.errors %}
<span class="form-errors">{{ field.errors }}</span>
{% endif %}
</p>
{% endif %}
{% endfor %}
<button type="submit"> <button type="submit">
{% if form.instance.pk %} {% if form.instance.pk %}
Guardar gasto Guardar gasto

View File

@ -0,0 +1,18 @@
{% extends "expenses/base.html" %}
{% block title %}Objetivos{% endblock %}
{% block content %}
<h1>Eliminar objetivo</h1>
<p>
¿Seguro que quieres eliminar el objetivo
<strong>{{ goal.name }}</strong>?
</p>
<form method="post">
{% csrf_token %}
<button class="btn">Eliminar</button>
<a class="btn secondary" href="{% url 'goal_list' %}">Cancelar</a>
</form>
{% endblock %}

View File

@ -0,0 +1,29 @@
{% extends "expenses/base.html" %}
{% block title %}
Configuración
{% endblock %}
{% block content %}
<h2>Configuración</h2>
<p>Gestiona las categorías, etiquetas y objetivos de tu cuenta</p>
<div class="settings-grid">
<a class="settings-card" href="{% url 'category_list' %}">
<h3>Categorías</h3>
<p>Organiza tus gastos por tipo.</p>
</a>
<a class="settings-card" href="{% url 'tag_list' %}">
<h3>Etiquetas</h3>
<p>Añade etiquetas libres a tus gastos.</p>
</a>
<a class="settings-card" href="{% url 'goal_list' %}">
<h3>Objetivos</h3>
<p>Define y controla tus metas de gasto.</p>
</a>
</div>
{% endblock %}

View File

@ -705,12 +705,17 @@ def fuel_create(request):
form = FuelEntryForm(request.POST, user=request.user) form = FuelEntryForm(request.POST, user=request.user)
if form.is_valid(): if form.is_valid():
category, _ = Category.objects.get_or_create(
slug="gasolina",
owner=request.user,
defaults={"name": "Gasolina"},
)
expense = Expense.objects.create( expense = Expense.objects.create(
owner=request.user, owner=request.user,
date=form.cleaned_data["date"], date=form.cleaned_data["date"],
amount=form.cleaned_data["amount"], amount=form.cleaned_data["amount"],
account=form.cleaned_data["account"], account=form.cleaned_data["account"],
category=Category.objects.get(slug="gasolina"), category=category,
description="Repostaje", description="Repostaje",
) )
@ -938,10 +943,18 @@ def goal_delete(request, pk):
pk=pk, pk=pk,
owner=request.user) owner=request.user)
if request.method == "POST":
goal.delete() goal.delete()
messages.success(request, "Objetivo eliminado.")
return redirect("goal_list") return redirect("goal_list")
return render(
request,
"goals/confirm_delete.html",
{"active_menu":"settings", "goal":goal}
)
@login_required @login_required
def settings_index(request): def settings_index(request):
return render(request, "settings/index.html") return render(request, "settings/index.html", {"active_menu": "settings"})

View File

@ -13,7 +13,7 @@ https://docs.djangoproject.com/en/5.2/ref/settings/
import os import os
from pathlib import Path from pathlib import Path
from dotenv import load_dotenv from dotenv import load_dotenv
from django.contrib.messages import constants as messages from django.core.exceptions import ImproperlyConfigured
load_dotenv() load_dotenv()
@ -30,13 +30,34 @@ BASE_DIR = Path(__file__).resolve().parent.parent
# SECURITY WARNING: don't run with debug turned on in production! # SECURITY WARNING: don't run with debug turned on in production!
DEBUG = os.getenv("DEBUG", "False") == "True" 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 = [ 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 # Application definition
@ -52,6 +73,7 @@ INSTALLED_APPS = [
MIDDLEWARE = [ MIDDLEWARE = [
'django.middleware.security.SecurityMiddleware', 'django.middleware.security.SecurityMiddleware',
'whitenoise.middleware.WhiteNoiseMiddleware',
'django.contrib.sessions.middleware.SessionMiddleware', 'django.contrib.sessions.middleware.SessionMiddleware',
'django.middleware.common.CommonMiddleware', 'django.middleware.common.CommonMiddleware',
'django.middleware.csrf.CsrfViewMiddleware', 'django.middleware.csrf.CsrfViewMiddleware',
@ -83,6 +105,19 @@ WSGI_APPLICATION = 'expenses_manager.wsgi.application'
# Database # Database
# https://docs.djangoproject.com/en/5.2/ref/settings/#databases # https://docs.djangoproject.com/en/5.2/ref/settings/#databases
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 = { DATABASES = {
'default': { 'default': {
'ENGINE': 'django.db.backends.sqlite3', 'ENGINE': 'django.db.backends.sqlite3',
@ -91,41 +126,39 @@ DATABASES = {
} }
# Password validation # Password validation
# https://docs.djangoproject.com/en/5.2/ref/settings/#auth-password-validators # https://docs.djangoproject.com/en/5.2/ref/settings/#auth-password-validators
AUTH_PASSWORD_VALIDATORS = [ AUTH_PASSWORD_VALIDATORS = [
{ {'NAME': 'django.contrib.auth.password_validation.UserAttributeSimilarityValidator',},
'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.MinimumLengthValidator',
},
{
'NAME': 'django.contrib.auth.password_validation.CommonPasswordValidator',
},
{
'NAME': 'django.contrib.auth.password_validation.NumericPasswordValidator',
},
] ]
# Internationalization # Internationalization
# https://docs.djangoproject.com/en/5.2/topics/i18n/ # https://docs.djangoproject.com/en/5.2/topics/i18n/
LANGUAGE_CODE = 'en-us' LANGUAGE_CODE = 'es'
LANGUAGE_CODE = 'es'
TIME_ZONE = 'UTC' TIME_ZONE = 'UTC'
USE_I18N = True USE_I18N = True
USE_TZ = True USE_TZ = True
DATE_INPUT_FORMATS = ["%d/%m/%Y"]
DATETIME_INPUT_FORMATS = ["%d/%m/%Y %H:%M"]
# Static files (CSS, JavaScript, Images) # Static files (CSS, JavaScript, Images)
# https://docs.djangoproject.com/en/5.2/howto/static-files/ # 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 # Default primary key field type
# https://docs.djangoproject.com/en/5.2/ref/settings/#default-auto-field # https://docs.djangoproject.com/en/5.2/ref/settings/#default-auto-field
@ -135,3 +168,22 @@ DEFAULT_AUTO_FIELD = 'django.db.models.BigAutoField'
LOGIN_URL = 'login' LOGIN_URL = 'login'
LOGIN_REDIRECT_URL = 'home' LOGIN_REDIRECT_URL = 'home'
LOGOUT_REDIRECT_URL = 'login' 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,
},
},
}

Binary file not shown.