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= # DATABASE_URL=
# REDIS_URL= # REDIS_URL=
# === CONFIGURACIÓN DOCKER (Descomentar para Producción/Docker) === # === CONFIGURACIÓN BASE DE DATOS (Supabase Postgres) ===
# DATABASE_URL=postgres://efintel_user:efintel_password@db:5432/efintel_db # 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 # 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 - **Miguel Angel Contreras Orozco** — Jefe de Área I+D+i
- **Ginevra Ortega Acuña** — Encargada de Contratos y Auditoría Documental - **Ginevra Ortega Acuña** — Encargada de Contratos y Auditoría Documental
- **Samuel Abdias Contreras Orozco** — Encargado de Desarrollo Comercial - **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 = { DATABASES = {
'default': env.db('DATABASE_URL', default=f"sqlite:///{BASE_DIR / 'db.sqlite3'}") '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 # Password validation
# https://docs.djangoproject.com/en/6.1/ref/settings/#auth-password-validators # https://docs.djangoproject.com/en/6.1/ref/settings/#auth-password-validators
@@ -143,8 +143,29 @@ STATIC_URL = '/static/'
STATICFILES_DIRS = [BASE_DIR / 'static'] STATICFILES_DIRS = [BASE_DIR / 'static']
# Media files (Uploads) # Media files (Uploads)
MEDIA_URL = '/media/' if env.bool('USE_S3', default=False):
MEDIA_ROOT = BASE_DIR / 'media' # 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 # Authentication
LOGIN_URL = '/login/' 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('logout/', views.logout_view, name='logout'),
path('', views.dashboard, name='dashboard'), path('', views.dashboard, name='dashboard'),
path('gastos/', views.expense_list, name='expense_list'), path('gastos/', views.ExpenseListView.as_view(), name='expense_list'),
path('gastos/nuevo/', views.expense_create, name='expense_create'), 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>/editar/', views.expense_edit, name='expense_edit'),
path('gastos/<int:pk>/eliminar/', views.expense_delete, name='expense_delete'), path('gastos/<int:pk>/eliminar/', views.expense_delete, name='expense_delete'),
path('gastos/documento/<int:pk>/eliminar/', views.delete_document, name='delete_document'), path('gastos/documento/<int:pk>/eliminar/', views.delete_document, name='delete_document'),
+73 -66
View File
@@ -1,6 +1,11 @@
import csv import csv
import io import io
from decimal import Decimal 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.shortcuts import render, redirect, get_object_or_404
from django.contrib.auth.decorators import login_required from django.contrib.auth.decorators import login_required
from django.views.decorators.cache import cache_page from django.views.decorators.cache import cache_page
@@ -96,81 +101,83 @@ def dashboard(request):
return render(request, 'expenses/dashboard.html', context) return render(request, 'expenses/dashboard.html', context)
@login_required class ExpenseListView(LoginRequiredMixin, ListView):
def expense_list(request): model = Expense
expenses = Expense.objects.select_related('project', 'category', 'user').order_by('-date') template_name = 'expenses/expense_list.html'
context_object_name = 'expenses'
# Filtros def get_queryset(self):
project_id = request.GET.get('project') queryset = super().get_queryset().select_related('project', 'category', 'user').order_by('-date')
category_id = request.GET.get('category') project_id = self.request.GET.get('project')
category_id = self.request.GET.get('category')
if project_id: if project_id:
expenses = expenses.filter(project_id=project_id) queryset = queryset.filter(project_id=project_id)
if category_id: if category_id:
expenses = expenses.filter(category_id=category_id) queryset = queryset.filter(category_id=category_id)
return queryset
context = { def get_context_data(self, **kwargs):
'expenses': expenses, context = super().get_context_data(**kwargs)
'projects': Project.objects.all(), context['projects'] = Project.objects.all()
'categories': Category.objects.all(), context['categories'] = Category.objects.all()
'selected_project': project_id, context['selected_project'] = self.request.GET.get('project')
'selected_category': category_id, context['selected_category'] = self.request.GET.get('category')
} return context
return render(request, 'expenses/expense_list.html', context)
@login_required from django.utils.decorators import method_decorator
@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() @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')
receipts = request.FILES.getlist('receipt') def get_context_data(self, **kwargs):
if receipts: context = super().get_context_data(**kwargs)
from .models import Document context['regions_communes_json'] = json.dumps(REGIONS_COMMUNES)
import os return context
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( def form_valid(self, form):
user=request.user, expense = form.save(commit=False)
action="Gasto Creado", expense.user = self.request.user
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 = { setting, _ = SystemSetting.objects.get_or_create(id=1)
'form': form, if expense.currency == CurrencyEnum.CLP:
'regions_communes_json': json.dumps(REGIONS_COMMUNES) expense.amount_usd = expense.amount_clp / setting.usd_exchange_rate
} else:
return render(request, 'expenses/expense_form.html', context) 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)
@login_required @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 zope.interface==8.5
whitenoise==6.7.0 whitenoise==6.7.0
django-storages==2.14.2
boto3==1.34.69