refactor: migrar a supabase, aplicar cia y poo a gastos
This commit is contained in:
+14
-2
@@ -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
|
||||
|
||||
@@ -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
@@ -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/'
|
||||
|
||||
@@ -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
@@ -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'),
|
||||
|
||||
+76
-69
@@ -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
|
||||
|
||||
@@ -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.")
|
||||
@@ -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.")
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user