From 644bdc8f2518543977cb79e9673164ea471bd86e Mon Sep 17 00:00:00 2001 From: Nachou-u Date: Mon, 7 Sep 2026 22:44:14 -0300 Subject: [PATCH] refactor: migrar a supabase, aplicar cia y poo a gastos --- .env.example | 16 ++++- README.md | 26 ++++++++ config/settings.py | 27 ++++++++- expenses/mixins.py | 27 +++++++++ expenses/urls.py | 4 +- expenses/views.py | 145 ++++++++++++++++++++++++--------------------- refactor_oop.py | 83 ++++++++++++++++++++++++++ refactor_oop2.py | 128 +++++++++++++++++++++++++++++++++++++++ requirements.txt | 2 + 9 files changed, 382 insertions(+), 76 deletions(-) create mode 100644 expenses/mixins.py create mode 100644 refactor_oop.py create mode 100644 refactor_oop2.py diff --git a/.env.example b/.env.example index 0550d5b..14ef5c0 100644 --- a/.env.example +++ b/.env.example @@ -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://:@:/ +# 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 diff --git a/README.md b/README.md index e17adfa..5aba922 100644 --- a/README.md +++ b/README.md @@ -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: +`(): ` + +**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` diff --git a/config/settings.py b/config/settings.py index 664fa59..bffc8b7 100644 --- a/config/settings.py +++ b/config/settings.py @@ -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/' diff --git a/expenses/mixins.py b/expenses/mixins.py new file mode 100644 index 0000000..ae3b50b --- /dev/null +++ b/expenses/mixins.py @@ -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) diff --git a/expenses/urls.py b/expenses/urls.py index 0f5dd57..978e42e 100644 --- a/expenses/urls.py +++ b/expenses/urls.py @@ -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//editar/', views.expense_edit, name='expense_edit'), path('gastos//eliminar/', views.expense_delete, name='expense_delete'), path('gastos/documento//eliminar/', views.delete_document, name='delete_document'), diff --git a/expenses/views.py b/expenses/views.py index 02c819e..321a98c 100644 --- a/expenses/views.py +++ b/expenses/views.py @@ -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,81 +101,83 @@ 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: + queryset = queryset.filter(project_id=project_id) + if category_id: + queryset = queryset.filter(category_id=category_id) + return queryset - 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) + 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(): - 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 +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 = 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 - ) + 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=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) + 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 diff --git a/refactor_oop.py b/refactor_oop.py new file mode 100644 index 0000000..e3194fe --- /dev/null +++ b/refactor_oop.py @@ -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.") diff --git a/refactor_oop2.py b/refactor_oop2.py new file mode 100644 index 0000000..b7c87a3 --- /dev/null +++ b/refactor_oop2.py @@ -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.") diff --git a/requirements.txt b/requirements.txt index 37d8634..390aa39 100644 --- a/requirements.txt +++ b/requirements.txt @@ -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