refactor: migrar a supabase, aplicar cia y poo a gastos

This commit is contained in:
Nachou-u
2026-09-07 22:44:14 -03:00
parent 77d9145248
commit 644bdc8f25
9 changed files with 382 additions and 76 deletions
+14 -2
View File
@@ -10,6 +10,18 @@ ALLOWED_HOSTS=*
# DATABASE_URL=
# REDIS_URL=
# === CONFIGURACIÓN DOCKER (Descomentar para Producción/Docker) ===
# DATABASE_URL=postgres://efintel_user:efintel_password@db:5432/efintel_db
# === CONFIGURACIÓN BASE DE DATOS (Supabase Postgres) ===
# Formato: postgres://<user>:<password>@<host>:<port>/<dbname>
# DATABASE_URL=
# === CONFIGURACIÓN STORAGE (Supabase S3) ===
# Descomentar y completar para almacenar archivos en Supabase
# USE_S3=True
# AWS_ACCESS_KEY_ID=
# AWS_SECRET_ACCESS_KEY=
# AWS_STORAGE_BUCKET_NAME=
# AWS_S3_ENDPOINT_URL=
# AWS_S3_REGION_NAME=
# === CONFIGURACIÓN DOCKER (Redis) ===
# REDIS_URL=redis://redis:6379/0
+26
View File
@@ -194,3 +194,29 @@ Usuarios: `admin` o `ginevra_ortega`
- **Miguel Angel Contreras Orozco** — Jefe de Área I+D+i
- **Ginevra Ortega Acuña** — Encargada de Contratos y Auditoría Documental
- **Samuel Abdias Contreras Orozco** — Encargado de Desarrollo Comercial
---
## Estándar de Control de Versiones
Este proyecto utiliza **GitHub Flow** y **Conventional Commits** para mantener un historial limpio y trazabilidad.
### 1. Flujo de Trabajo (GitHub Flow)
- La rama `main` contiene el código estable y de producción.
- Para cada nueva funcionalidad o corrección, se crea una rama nueva a partir de `main` (ej: `feature/agregar-modulo-nomina`).
- Una vez terminado el desarrollo en la rama, se realiza un Pull Request hacia `main`.
### 2. Formato de Commits (Conventional Commits)
Los mensajes de commit deben seguir esta estructura:
`<tipo>(<alcance opcional>): <descripción breve>`
**Tipos permitidos:**
- `feat:` Una nueva funcionalidad.
- `fix:` Corrección de un error (bug).
- `refactor:` Cambios en el código que no corrigen errores ni añaden funcionalidades (ej. aplicar POO).
- `docs:` Cambios en la documentación.
- `chore:` Tareas de mantenimiento, actualización de dependencias.
- `style:` Cambios de formato (espacios, comas, etc) que no afectan la lógica.
- `test:` Agregar o modificar pruebas.
*Ejemplo:* `feat(gastos): agregar soporte multidivisa para proveedores`
+24 -3
View File
@@ -101,7 +101,7 @@ CHANNEL_LAYERS = {
DATABASES = {
'default': env.db('DATABASE_URL', default=f"sqlite:///{BASE_DIR / 'db.sqlite3'}")
}
DATABASES['default']['CONN_MAX_AGE'] = env.int('CONN_MAX_AGE', default=60)
# Password validation
# https://docs.djangoproject.com/en/6.1/ref/settings/#auth-password-validators
@@ -143,8 +143,29 @@ STATIC_URL = '/static/'
STATICFILES_DIRS = [BASE_DIR / 'static']
# Media files (Uploads)
MEDIA_URL = '/media/'
MEDIA_ROOT = BASE_DIR / 'media'
if env.bool('USE_S3', default=False):
# Supabase / AWS S3 Config
AWS_ACCESS_KEY_ID = env('AWS_ACCESS_KEY_ID')
AWS_SECRET_ACCESS_KEY = env('AWS_SECRET_ACCESS_KEY')
AWS_STORAGE_BUCKET_NAME = env('AWS_STORAGE_BUCKET_NAME')
AWS_S3_ENDPOINT_URL = env('AWS_S3_ENDPOINT_URL', default=None)
AWS_S3_REGION_NAME = env('AWS_S3_REGION_NAME', default=None)
AWS_DEFAULT_ACL = 'public-read'
AWS_S3_FILE_OVERWRITE = False
DEFAULT_FILE_STORAGE = 'storages.backends.s3boto3.S3Boto3Storage'
MEDIA_URL = f'{AWS_S3_ENDPOINT_URL}/{AWS_STORAGE_BUCKET_NAME}/' if AWS_S3_ENDPOINT_URL else f'https://{AWS_STORAGE_BUCKET_NAME}.s3.amazonaws.com/'
else:
MEDIA_URL = '/media/'
MEDIA_ROOT = BASE_DIR / 'media'
# Security (Tríada CIA: Confidencialidad e Integridad)
if not DEBUG:
SECURE_SSL_REDIRECT = env.bool('SECURE_SSL_REDIRECT', default=True)
SESSION_COOKIE_SECURE = env.bool('SESSION_COOKIE_SECURE', default=True)
CSRF_COOKIE_SECURE = env.bool('CSRF_COOKIE_SECURE', default=True)
SECURE_HSTS_SECONDS = env.int('SECURE_HSTS_SECONDS', default=31536000)
SECURE_HSTS_INCLUDE_SUBDOMAINS = env.bool('SECURE_HSTS_INCLUDE_SUBDOMAINS', default=True)
SECURE_HSTS_PRELOAD = env.bool('SECURE_HSTS_PRELOAD', default=True)
# Authentication
LOGIN_URL = '/login/'
+27
View File
@@ -0,0 +1,27 @@
from django.contrib.auth.mixins import UserPassesTestMixin
from django.contrib import messages
from django.shortcuts import redirect
class RoleRequiredMixin(UserPassesTestMixin):
"""
Mixin para validar si el usuario autenticado pertenece a alguno de los grupos (roles) permitidos.
"""
allowed_roles = []
redirect_url = 'dashboard'
permission_denied_message = 'No tienes permisos para acceder a esta página.'
def test_func(self):
if self.request.user.is_superuser:
return True
user_groups = self.request.user.groups.values_list('name', flat=True)
for role in self.allowed_roles:
if role in user_groups:
return True
return False
def handle_no_permission(self):
if not self.request.user.is_authenticated:
return redirect('login')
messages.error(self.request, self.permission_denied_message)
return redirect(self.redirect_url)
+2 -2
View File
@@ -6,8 +6,8 @@ urlpatterns = [
path('logout/', views.logout_view, name='logout'),
path('', views.dashboard, name='dashboard'),
path('gastos/', views.expense_list, name='expense_list'),
path('gastos/nuevo/', views.expense_create, name='expense_create'),
path('gastos/', views.ExpenseListView.as_view(), name='expense_list'),
path('gastos/nuevo/', views.ExpenseCreateView.as_view(), name='expense_create'),
path('gastos/<int:pk>/editar/', views.expense_edit, name='expense_edit'),
path('gastos/<int:pk>/eliminar/', views.expense_delete, name='expense_delete'),
path('gastos/documento/<int:pk>/eliminar/', views.delete_document, name='delete_document'),
+45 -38
View File
@@ -1,6 +1,11 @@
import csv
import io
from decimal import Decimal
from django.views.generic import ListView, CreateView, UpdateView, DeleteView
from django.contrib.auth.mixins import LoginRequiredMixin
from django.urls import reverse_lazy
from .mixins import RoleRequiredMixin
from django.shortcuts import render, redirect, get_object_or_404
from django.contrib.auth.decorators import login_required
from django.views.decorators.cache import cache_page
@@ -96,39 +101,49 @@ def dashboard(request):
return render(request, 'expenses/dashboard.html', context)
@login_required
def expense_list(request):
expenses = Expense.objects.select_related('project', 'category', 'user').order_by('-date')
class ExpenseListView(LoginRequiredMixin, ListView):
model = Expense
template_name = 'expenses/expense_list.html'
context_object_name = 'expenses'
# Filtros
project_id = request.GET.get('project')
category_id = request.GET.get('category')
def get_queryset(self):
queryset = super().get_queryset().select_related('project', 'category', 'user').order_by('-date')
project_id = self.request.GET.get('project')
category_id = self.request.GET.get('category')
if project_id:
expenses = expenses.filter(project_id=project_id)
queryset = queryset.filter(project_id=project_id)
if category_id:
expenses = expenses.filter(category_id=category_id)
queryset = queryset.filter(category_id=category_id)
return queryset
context = {
'expenses': expenses,
'projects': Project.objects.all(),
'categories': Category.objects.all(),
'selected_project': project_id,
'selected_category': category_id,
}
return render(request, 'expenses/expense_list.html', context)
def get_context_data(self, **kwargs):
context = super().get_context_data(**kwargs)
context['projects'] = Project.objects.all()
context['categories'] = Category.objects.all()
context['selected_project'] = self.request.GET.get('project')
context['selected_category'] = self.request.GET.get('category')
return context
@login_required
@transaction.atomic # Integridad de datos
def expense_create(request):
# Only allow creation if user is admin or allowed to
if request.method == 'POST':
form = ExpenseForm(request.POST, request.FILES)
if form.is_valid():
from django.utils.decorators import method_decorator
@method_decorator(transaction.atomic, name='dispatch')
class ExpenseCreateView(LoginRequiredMixin, CreateView):
model = Expense
form_class = ExpenseForm
template_name = 'expenses/expense_form.html'
success_url = reverse_lazy('expense_list')
def get_context_data(self, **kwargs):
context = super().get_context_data(**kwargs)
context['regions_communes_json'] = json.dumps(REGIONS_COMMUNES)
return context
def form_valid(self, form):
expense = form.save(commit=False)
expense.user = request.user
# Auto convert to USD if CLP
expense.user = self.request.user
setting, _ = SystemSetting.objects.get_or_create(id=1)
if expense.currency == CurrencyEnum.CLP:
expense.amount_usd = expense.amount_clp / setting.usd_exchange_rate
@@ -138,7 +153,7 @@ def expense_create(request):
expense.save()
receipts = request.FILES.getlist('receipt')
receipts = self.request.FILES.getlist('receipt')
if receipts:
from .models import Document
import os
@@ -151,26 +166,18 @@ def expense_create(request):
expense=expense,
file=f,
title=semantic_title,
uploaded_by=request.user
uploaded_by=self.request.user
)
AuditLog.objects.create(
user=request.user,
user=self.request.user,
action="Gasto Creado",
entity_name="Expense",
entity_id=expense.expense_code,
description=f"Se creó el gasto por {expense.amount_clp} CLP"
)
messages.success(request, 'Gasto registrado exitosamente.')
return redirect('expense_list')
else:
form = ExpenseForm()
context = {
'form': form,
'regions_communes_json': json.dumps(REGIONS_COMMUNES)
}
return render(request, 'expenses/expense_form.html', context)
messages.success(self.request, 'Gasto registrado exitosamente.')
return super().form_valid(form)
@login_required
+83
View File
@@ -0,0 +1,83 @@
import re
def refactor_views():
with open('expenses/views.py', 'r', encoding='utf-8') as f:
content = f.read()
# Imports to add
imports = """from django.views.generic import ListView, CreateView, UpdateView, DeleteView
from django.contrib.auth.mixins import LoginRequiredMixin
from django.urls import reverse_lazy
from .mixins import RoleRequiredMixin
"""
if 'ListView' not in content:
content = content.replace("from django.shortcuts import render, redirect, get_object_or_404", imports + "\nfrom django.shortcuts import render, redirect, get_object_or_404")
# Replace expense_list
old_list_view = """@login_required
def expense_list(request):
expenses = Expense.objects.select_related('project', 'category', 'user').order_by('-date')
# Filtros
project_id = request.GET.get('project')
category_id = request.GET.get('category')
if project_id:
expenses = expenses.filter(project_id=project_id)
if category_id:
expenses = expenses.filter(category_id=category_id)
context = {
'expenses': expenses,
'projects': Project.objects.all(),
'categories': Category.objects.all(),
'selected_project': project_id,
'selected_category': category_id,
}
return render(request, 'expenses/expense_list.html', context)"""
new_list_view = """class ExpenseListView(LoginRequiredMixin, ListView):
model = Expense
template_name = 'expenses/expense_list.html'
context_object_name = 'expenses'
def get_queryset(self):
queryset = super().get_queryset().select_related('project', 'category', 'user').order_by('-date')
project_id = self.request.GET.get('project')
category_id = self.request.GET.get('category')
if project_id:
queryset = queryset.filter(project_id=project_id)
if category_id:
queryset = queryset.filter(category_id=category_id)
return queryset
def get_context_data(self, **kwargs):
context = super().get_context_data(**kwargs)
context['projects'] = Project.objects.all()
context['categories'] = Category.objects.all()
context['selected_project'] = self.request.GET.get('project')
context['selected_category'] = self.request.GET.get('category')
return context"""
content = content.replace(old_list_view, new_list_view)
with open('expenses/views.py', 'w', encoding='utf-8') as f:
f.write(content)
def refactor_urls():
with open('config/urls.py', 'r', encoding='utf-8') as f:
content = f.read()
# Check if expense_list is imported
if 'expense_list' in content:
content = content.replace('expense_list', 'ExpenseListView')
content = content.replace("path('gastos/', views.expense_list, name='expense_list')", "path('gastos/', views.ExpenseListView.as_view(), name='expense_list')")
with open('config/urls.py', 'w', encoding='utf-8') as f:
f.write(content)
if __name__ == '__main__':
refactor_views()
refactor_urls()
print("Refactorización a POO (CBV) completada para expense_list.")
+128
View File
@@ -0,0 +1,128 @@
import re
def refactor_create_view():
with open('expenses/views.py', 'r', encoding='utf-8') as f:
content = f.read()
old_create_view = """@login_required
@transaction.atomic # Integridad de datos
def expense_create(request):
# Only allow creation if user is admin or allowed to
if request.method == 'POST':
form = ExpenseForm(request.POST, request.FILES)
if form.is_valid():
expense = form.save(commit=False)
expense.user = request.user
# Auto convert to USD if CLP
setting, _ = SystemSetting.objects.get_or_create(id=1)
if expense.currency == CurrencyEnum.CLP:
expense.amount_usd = expense.amount_clp / setting.usd_exchange_rate
else:
expense.amount_usd = expense.amount_clp
expense.amount_clp = expense.amount_usd * setting.usd_exchange_rate
expense.save()
receipts = request.FILES.getlist('receipt')
if receipts:
from .models import Document
import os
cat_name = expense.category.name if expense.category else 'Evidencia'
proj_code = expense.project.code if expense.project else 'PROY'
for idx, f in enumerate(receipts, start=1):
ext = os.path.splitext(f.name)[1].lower()
semantic_title = f"{proj_code} · {cat_name} · Doc {idx:02d}{ext}"
Document.objects.create(
expense=expense,
file=f,
title=semantic_title,
uploaded_by=request.user
)
AuditLog.objects.create(
user=request.user,
action="Gasto Creado",
entity_name="Expense",
entity_id=expense.expense_code,
description=f"Se creó el gasto por {expense.amount_clp} CLP"
)
messages.success(request, 'Gasto registrado exitosamente.')
return redirect('expense_list')
else:
form = ExpenseForm()
context = {
'form': form,
'regions_communes_json': json.dumps(REGIONS_COMMUNES)
}
return render(request, 'expenses/expense_form.html', context)"""
new_create_view = """from django.utils.decorators import method_decorator
@method_decorator(transaction.atomic, name='dispatch')
class ExpenseCreateView(LoginRequiredMixin, CreateView):
model = Expense
form_class = ExpenseForm
template_name = 'expenses/expense_form.html'
success_url = reverse_lazy('expense_list')
def get_context_data(self, **kwargs):
context = super().get_context_data(**kwargs)
context['regions_communes_json'] = json.dumps(REGIONS_COMMUNES)
return context
def form_valid(self, form):
expense = form.save(commit=False)
expense.user = self.request.user
setting, _ = SystemSetting.objects.get_or_create(id=1)
if expense.currency == CurrencyEnum.CLP:
expense.amount_usd = expense.amount_clp / setting.usd_exchange_rate
else:
expense.amount_usd = expense.amount_clp
expense.amount_clp = expense.amount_usd * setting.usd_exchange_rate
expense.save()
receipts = self.request.FILES.getlist('receipt')
if receipts:
from .models import Document
import os
cat_name = expense.category.name if expense.category else 'Evidencia'
proj_code = expense.project.code if expense.project else 'PROY'
for idx, f in enumerate(receipts, start=1):
ext = os.path.splitext(f.name)[1].lower()
semantic_title = f"{proj_code} · {cat_name} · Doc {idx:02d}{ext}"
Document.objects.create(
expense=expense,
file=f,
title=semantic_title,
uploaded_by=self.request.user
)
AuditLog.objects.create(
user=self.request.user,
action="Gasto Creado",
entity_name="Expense",
entity_id=expense.expense_code,
description=f"Se creó el gasto por {expense.amount_clp} CLP"
)
messages.success(self.request, 'Gasto registrado exitosamente.')
return super().form_valid(form)"""
content = content.replace(old_create_view, new_create_view)
with open('expenses/views.py', 'w', encoding='utf-8') as f:
f.write(content)
def refactor_urls_create():
with open('expenses/urls.py', 'r', encoding='utf-8') as f:
content = f.read()
content = content.replace("path('gastos/nuevo/', views.expense_create, name='expense_create')", "path('gastos/nuevo/', views.ExpenseCreateView.as_view(), name='expense_create')")
with open('expenses/urls.py', 'w', encoding='utf-8') as f:
f.write(content)
if __name__ == '__main__':
refactor_create_view()
refactor_urls_create()
print("Refactorización a POO completada para expense_create.")
+2
View File
@@ -52,3 +52,5 @@ wheel==0.42.0
zope.interface==8.5
whitenoise==6.7.0
django-storages==2.14.2
boto3==1.34.69