Initial commit: EFINTEL ERP Django Port

This commit is contained in:
Nachou-u
2026-08-19 18:04:18 -04:00
parent 1cd6ff1a6f
commit eb065909bd
39 changed files with 3852 additions and 0 deletions
View File
+35
View File
@@ -0,0 +1,35 @@
from django.contrib import admin
from .models import Project, Category, Expense, UserProfile, AuditLog, SystemSetting, Document
@admin.register(Project)
class ProjectAdmin(admin.ModelAdmin):
list_display = ('code', 'name', 'manager', 'type', 'actual_cost_clp')
search_fields = ('code', 'name')
@admin.register(Category)
class CategoryAdmin(admin.ModelAdmin):
list_display = ('code', 'name', 'parent')
search_fields = ('code', 'name')
@admin.register(Expense)
class ExpenseAdmin(admin.ModelAdmin):
list_display = ('expense_code', 'date', 'project', 'amount_clp', 'approval_status', 'user')
list_filter = ('project', 'approval_status', 'currency', 'date')
search_fields = ('expense_code', 'description', 'vendor')
@admin.register(UserProfile)
class UserProfileAdmin(admin.ModelAdmin):
list_display = ('user', 'rut', 'job_title')
@admin.register(AuditLog)
class AuditLogAdmin(admin.ModelAdmin):
list_display = ('timestamp', 'user', 'action', 'entity_name', 'entity_id')
list_filter = ('action', 'entity_name', 'timestamp')
@admin.register(SystemSetting)
class SystemSettingAdmin(admin.ModelAdmin):
list_display = ('global_budget', 'usd_exchange_rate')
@admin.register(Document)
class DocumentAdmin(admin.ModelAdmin):
list_display = ('title', 'expense', 'uploaded_by', 'uploaded_at')
+9
View File
@@ -0,0 +1,9 @@
from django.apps import AppConfig
class ExpensesConfig(AppConfig):
default_auto_field = 'django.db.models.BigAutoField'
name = 'expenses'
def ready(self):
import expenses.signals
+29
View File
@@ -0,0 +1,29 @@
import json
from channels.generic.websocket import AsyncWebsocketConsumer
class ExpenseConsumer(AsyncWebsocketConsumer):
async def connect(self):
# Todos los clientes se conectan a este grupo para escuchar actualizaciones de gastos
self.group_name = 'expenses_updates'
await self.channel_layer.group_add(
self.group_name,
self.channel_name
)
await self.accept()
async def disconnect(self, close_code):
await self.channel_layer.group_discard(
self.group_name,
self.channel_name
)
# Recibir actualización desde el Channel Layer
async def expense_update(self, event):
expense_data = event['expense']
action = event['action']
# Enviar actualización por WebSocket al cliente
await self.send(text_data=json.dumps({
'action': action,
'expense': expense_data
}))
+19
View File
@@ -0,0 +1,19 @@
from functools import wraps
from django.shortcuts import redirect
from django.contrib import messages
def role_required(*group_names):
"""Decorator that checks if the user belongs to at least one of the specified groups."""
def decorator(view_func):
@wraps(view_func)
def wrapper(request, *args, **kwargs):
if request.user.is_superuser:
return view_func(request, *args, **kwargs)
user_groups = request.user.groups.values_list('name', flat=True)
if any(group in user_groups for group in group_names):
return view_func(request, *args, **kwargs)
messages.error(request, 'No tienes permisos para acceder a esta sección.')
return redirect('dashboard')
return wrapper
return decorator
+133
View File
@@ -0,0 +1,133 @@
from django import forms
from django.contrib.auth.models import User, Group
from .models import Expense, Project, Category, SystemSetting, UserProfile, Document, CurrencyEnum, ExpenseTypeEnum
class ExpenseForm(forms.ModelForm):
date = forms.DateField(
widget=forms.DateInput(attrs={'type': 'date'}),
label='Fecha del Gasto'
)
amount_clp = forms.DecimalField(
label='Monto Neto (CLP)',
required=True,
widget=forms.NumberInput(attrs={'placeholder': 'Ej: 1450000'})
)
receipt = forms.FileField(
label='Adjuntar Boleta/Factura',
required=False,
widget=forms.FileInput(attrs={'accept': 'image/*,.pdf'})
)
class Meta:
model = Expense
fields = [
'project', 'category', 'amount_clp', 'currency', 'date',
'description', 'vendor', 'region', 'comuna', 'vehicle_ownership'
]
widgets = {
'description': forms.Textarea(attrs={'rows': 3, 'placeholder': 'Ej: Carga combustible Copec / Pasajes bus terreno'}),
}
class ProjectForm(forms.ModelForm):
class Meta:
model = Project
fields = ['code', 'name', 'type', 'description', 'manager', 'assigned_budget_clp']
widgets = {
'description': forms.Textarea(attrs={'rows': 3}),
}
class SystemSettingForm(forms.ModelForm):
class Meta:
model = SystemSetting
fields = ['global_budget', 'usd_exchange_rate']
class ResponsableForm(forms.Form):
username = forms.CharField(max_length=150, label="Nombre de Usuario")
password = forms.CharField(widget=forms.PasswordInput(), label="Contraseña", required=False)
email = forms.EmailField(label="Correo Electrónico", required=False)
role = forms.ModelChoiceField(queryset=Group.objects.all(), label="Rol / Grupo")
# Perfil Fields
rut = forms.CharField(max_length=20, label="RUT", required=False)
job_title = forms.CharField(max_length=150, label="Cargo", required=False)
description = forms.CharField(widget=forms.Textarea(attrs={'rows': 3}), label="Descripción / Bio", required=False)
def __init__(self, *args, **kwargs):
self.instance = kwargs.pop('instance', None)
super().__init__(*args, **kwargs)
if self.instance:
self.fields['username'].initial = self.instance.username
self.fields['email'].initial = self.instance.email
# Set role
groups = self.instance.groups.all()
if groups.exists():
self.fields['role'].initial = groups.first()
# Set profile info
try:
self.fields['description'].initial = self.instance.profile.description
self.fields['rut'].initial = self.instance.profile.rut
self.fields['job_title'].initial = self.instance.profile.job_title
except Exception:
pass
# Password is optional for edit
self.fields['password'].required = False
self.fields['password'].help_text = "Dejar en blanco si no desea cambiarla."
def clean_username(self):
username = self.cleaned_data.get('username')
qs = User.objects.filter(username=username)
if self.instance:
qs = qs.exclude(pk=self.instance.pk)
if qs.exists():
raise forms.ValidationError("Este nombre de usuario ya está registrado.")
return username
def save(self):
username = self.cleaned_data.get('username')
password = self.cleaned_data.get('password')
email = self.cleaned_data.get('email')
role = self.cleaned_data.get('role')
description = self.cleaned_data.get('description')
rut = self.cleaned_data.get('rut')
job_title = self.cleaned_data.get('job_title')
if self.instance:
user = self.instance
user.username = username
user.email = email
if password:
user.set_password(password)
else:
user = User.objects.create_user(username=username, email=email, password=password)
if role.name == 'Administrador' or role.name == 'Administrador Finanzas':
user.is_staff = True
user.is_superuser = True
else:
user.is_staff = False
user.is_superuser = False
user.save()
user.groups.clear()
user.groups.add(role)
profile, _ = UserProfile.objects.get_or_create(user=user)
profile.description = description
profile.rut = rut
profile.job_title = job_title
profile.save()
return user
class DocumentForm(forms.ModelForm):
class Meta:
model = Document
fields = ['file', 'title']
+155
View File
@@ -0,0 +1,155 @@
# Generated by Django 6.1 on 2026-08-14 17:39
import django.db.models.deletion
import uuid
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
initial = True
dependencies = [
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.CreateModel(
name='SystemSetting',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('global_budget', models.DecimalField(decimal_places=2, default=25000000.0, max_digits=15, verbose_name='Presupuesto General I+D+i')),
('usd_exchange_rate', models.DecimalField(decimal_places=2, default=950.0, max_digits=10, verbose_name='Tasa USD/CLP por Defecto')),
],
options={
'verbose_name': 'Configuración del Sistema',
'verbose_name_plural': 'Configuraciones del Sistema',
},
),
migrations.CreateModel(
name='AuditLog',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('action', models.CharField(max_length=100, verbose_name='Acción')),
('entity_name', models.CharField(blank=True, max_length=100, null=True, verbose_name='Entidad (Tabla)')),
('entity_id', models.CharField(blank=True, max_length=100, null=True, verbose_name='ID Entidad')),
('field_changed', models.CharField(blank=True, max_length=100, null=True, verbose_name='Campo Modificado')),
('old_value', models.TextField(blank=True, null=True, verbose_name='Valor Anterior')),
('new_value', models.TextField(blank=True, null=True, verbose_name='Valor Nuevo')),
('description', models.TextField(verbose_name='Detalle del Cambio')),
('timestamp', models.DateTimeField(auto_now_add=True, verbose_name='Fecha y Hora')),
('user', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, to=settings.AUTH_USER_MODEL, verbose_name='Usuario')),
],
options={
'verbose_name': 'Log de Auditoría (SAP CDHDR/CDPOS)',
'verbose_name_plural': 'Logs de Auditoría',
'ordering': ['-timestamp'],
},
),
migrations.CreateModel(
name='Category',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('code', models.CharField(max_length=10, unique=True, verbose_name='Código de Categoría')),
('name', models.CharField(max_length=100, verbose_name='Nombre de Categoría')),
('description', models.TextField(blank=True, verbose_name='Descripción')),
('parent', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='subcategories', to='expenses.category')),
],
options={
'verbose_name': 'Categoría',
'verbose_name_plural': 'Categorías',
},
),
migrations.CreateModel(
name='Expense',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('expense_code', models.CharField(default=uuid.uuid4, max_length=50, unique=True, verbose_name='Código Transacción')),
('amount_clp', models.DecimalField(decimal_places=2, max_digits=15, verbose_name='Monto Neto (CLP)')),
('amount_usd', models.DecimalField(decimal_places=2, max_digits=15, verbose_name='Monto Neto (USD)')),
('currency', models.CharField(choices=[('CLP', 'Pesos Chilenos'), ('USD', 'Dólares Estadounidenses')], default='CLP', max_length=10, verbose_name='Moneda Original')),
('exchange_rate', models.DecimalField(decimal_places=2, default=950.0, max_digits=10, verbose_name='Tipo de Cambio')),
('description', models.TextField(verbose_name='Detalle de Imputación')),
('date', models.DateField(verbose_name='Fecha del Gasto')),
('vendor', models.CharField(blank=True, max_length=200, null=True, verbose_name='Proveedor / Emisor')),
('region', models.CharField(blank=True, max_length=100, null=True, verbose_name='Región (Chile)')),
('comuna', models.CharField(blank=True, max_length=100, null=True, verbose_name='Comuna')),
('vehicle_ownership', models.CharField(blank=True, max_length=100, null=True, verbose_name='Modalidad / Vehículo')),
('approval_status', models.CharField(choices=[('EN_REVISION_DOCUMENTAL', 'En Revisión Documental (Paso 1)'), ('SOLICITADO_FINANZAS', 'Solicitado a Finanzas (Paso 2)'), ('LIBERADO_PAGADO', 'Pago Liberado (Paso 3)'), ('OBSERVADO_RECHAZADO', 'Observado / Rechazado')], default='EN_REVISION_DOCUMENTAL', max_length=50, verbose_name='Estado de Aprobación')),
('rejection_reason', models.TextField(blank=True, null=True, verbose_name='Motivo de Observación/Rechazo')),
('paid_at', models.DateTimeField(blank=True, null=True, verbose_name='Fecha de Pago')),
('created_at', models.DateTimeField(auto_now_add=True)),
('updated_at', models.DateTimeField(auto_now=True)),
('category', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='expenses', to='expenses.category', verbose_name='Subcategoría')),
('elevated_by', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='expenses_elevated', to=settings.AUTH_USER_MODEL, verbose_name='Elevado por (Paso 2)')),
('paid_by', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='expenses_paid', to=settings.AUTH_USER_MODEL, verbose_name='Pagado por (Paso 3)')),
('responsible_person', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='expenses_responsible', to=settings.AUTH_USER_MODEL, verbose_name='Responsable Solicitante')),
('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='expenses_created', to=settings.AUTH_USER_MODEL, verbose_name='Registrado por')),
],
options={
'verbose_name': 'Gasto',
'verbose_name_plural': 'Gastos',
'ordering': ['-date', '-created_at'],
},
),
migrations.CreateModel(
name='Document',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('file', models.FileField(upload_to='evidences/', verbose_name='Archivo Adjunto')),
('file_type', models.CharField(blank=True, max_length=50, verbose_name='Tipo de Archivo')),
('title', models.CharField(blank=True, max_length=200, verbose_name='Título del Documento')),
('uploaded_at', models.DateTimeField(auto_now_add=True, verbose_name='Fecha de Subida')),
('uploaded_by', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, to=settings.AUTH_USER_MODEL, verbose_name='Subido por')),
('expense', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='documents', to='expenses.expense', verbose_name='Gasto Relacionado')),
],
options={
'verbose_name': 'Documento Evidencia',
'verbose_name_plural': 'Documentos Evidencias',
},
),
migrations.CreateModel(
name='Project',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('code', models.CharField(default='PROY-000', max_length=50, unique=True, verbose_name='Código')),
('name', models.CharField(max_length=200, verbose_name='Nombre del Proyecto/Centro')),
('description', models.TextField(blank=True, verbose_name='Descripción')),
('assigned_budget_clp', models.DecimalField(decimal_places=2, default=0, max_digits=15, verbose_name='Presupuesto Asignado (CLP)')),
('assigned_budget_usd', models.DecimalField(decimal_places=2, default=0, max_digits=15, verbose_name='Presupuesto Asignado (USD)')),
('actual_cost_clp', models.DecimalField(decimal_places=2, default=0, max_digits=15, verbose_name='Costo Real Acumulado (CLP)')),
('type', models.CharField(choices=[('COSTO_DIRECTO_PROYECTO', 'Costo Directo Proyecto'), ('GASTO_OPERACIONAL_OVERHEAD', 'Gasto Operacional Overhead')], default='COSTO_DIRECTO_PROYECTO', max_length=50, verbose_name='Tipo de Centro')),
('status', models.CharField(default='on-budget', max_length=50, verbose_name='Estado de Presupuesto')),
('created_at', models.DateTimeField(auto_now_add=True, verbose_name='Fecha de Creación')),
('updated_at', models.DateTimeField(auto_now=True, verbose_name='Última Modificación')),
('created_by', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='created_projects', to=settings.AUTH_USER_MODEL, verbose_name='Creado por')),
('manager', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='managed_projects', to=settings.AUTH_USER_MODEL, verbose_name='Encargado')),
],
options={
'verbose_name': 'Proyecto / Centro de Costo',
'verbose_name_plural': 'Proyectos y Centros',
},
),
migrations.AddField(
model_name='expense',
name='project',
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='expenses', to='expenses.project', verbose_name='Proyecto'),
),
migrations.CreateModel(
name='UserProfile',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('rut', models.CharField(blank=True, max_length=20, null=True, verbose_name='RUT')),
('job_title', models.CharField(blank=True, max_length=150, null=True, verbose_name='Cargo')),
('description', models.TextField(blank=True, verbose_name='Descripción del Responsable')),
('created_at', models.DateTimeField(auto_now_add=True, verbose_name='Fecha de Registro')),
('updated_at', models.DateTimeField(auto_now=True, verbose_name='Última Modificación')),
('user', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, related_name='profile', to=settings.AUTH_USER_MODEL, verbose_name='Usuario')),
],
options={
'verbose_name': 'Perfil de Usuario',
'verbose_name_plural': 'Perfiles de Usuario',
},
),
]
View File
+163
View File
@@ -0,0 +1,163 @@
from django.db import models
from django.contrib.auth.models import User
import uuid
# Enums
class CurrencyEnum(models.TextChoices):
CLP = 'CLP', 'Pesos Chilenos'
USD = 'USD', 'Dólares Estadounidenses'
class ExpenseTypeEnum(models.TextChoices):
COSTO_DIRECTO = 'COSTO_DIRECTO_PROYECTO', 'Costo Directo Proyecto'
GASTO_OPERACIONAL = 'GASTO_OPERACIONAL_OVERHEAD', 'Gasto Operacional Overhead'
class ApprovalStatusEnum(models.TextChoices):
EN_REVISION = 'EN_REVISION_DOCUMENTAL', 'En Revisión Documental (Paso 1)'
SOLICITADO_FINANZAS = 'SOLICITADO_FINANZAS', 'Solicitado a Finanzas (Paso 2)'
LIBERADO_PAGADO = 'LIBERADO_PAGADO', 'Pago Liberado (Paso 3)'
OBSERVADO_RECHAZADO = 'OBSERVADO_RECHAZADO', 'Observado / Rechazado'
class Project(models.Model):
# Added core project/costcenter fields mapped from Prisma
code = models.CharField(max_length=50, unique=True, verbose_name="Código", default='PROY-000')
name = models.CharField(max_length=200, verbose_name="Nombre del Proyecto/Centro")
description = models.TextField(blank=True, verbose_name="Descripción")
manager = models.ForeignKey(User, on_delete=models.SET_NULL, null=True, blank=True, related_name='managed_projects', verbose_name="Encargado")
assigned_budget_clp = models.DecimalField(max_digits=15, decimal_places=2, default=0, verbose_name="Presupuesto Asignado (CLP)")
assigned_budget_usd = models.DecimalField(max_digits=15, decimal_places=2, default=0, verbose_name="Presupuesto Asignado (USD)")
actual_cost_clp = models.DecimalField(max_digits=15, decimal_places=2, default=0, verbose_name="Costo Real Acumulado (CLP)")
type = models.CharField(max_length=50, choices=ExpenseTypeEnum.choices, default=ExpenseTypeEnum.COSTO_DIRECTO, verbose_name="Tipo de Centro")
status = models.CharField(max_length=50, default="on-budget", verbose_name="Estado de Presupuesto")
created_at = models.DateTimeField(auto_now_add=True, verbose_name="Fecha de Creación")
updated_at = models.DateTimeField(auto_now=True, verbose_name="Última Modificación")
created_by = models.ForeignKey(User, on_delete=models.SET_NULL, null=True, blank=True, related_name='created_projects', verbose_name="Creado por")
def __str__(self):
return f"{self.code} - {self.name}"
class Meta:
verbose_name = "Proyecto / Centro de Costo"
verbose_name_plural = "Proyectos y Centros"
class Category(models.Model):
# Category / Subcategory hierarchy
code = models.CharField(max_length=10, unique=True, verbose_name="Código de Categoría")
name = models.CharField(max_length=100, verbose_name="Nombre de Categoría")
description = models.TextField(blank=True, verbose_name="Descripción")
parent = models.ForeignKey('self', on_delete=models.CASCADE, null=True, blank=True, related_name='subcategories')
def __str__(self):
return f"{self.code}: {self.name}"
class Meta:
verbose_name = "Categoría"
verbose_name_plural = "Categorías"
class Expense(models.Model):
expense_code = models.CharField(max_length=50, unique=True, default=uuid.uuid4, verbose_name="Código Transacción")
project = models.ForeignKey(Project, on_delete=models.CASCADE, related_name='expenses', verbose_name="Proyecto")
category = models.ForeignKey(Category, on_delete=models.SET_NULL, null=True, blank=True, related_name='expenses', verbose_name="Subcategoría")
# Financial fields
amount_clp = models.DecimalField(max_digits=15, decimal_places=2, verbose_name="Monto Neto (CLP)")
amount_usd = models.DecimalField(max_digits=15, decimal_places=2, verbose_name="Monto Neto (USD)")
currency = models.CharField(max_length=10, choices=CurrencyEnum.choices, default=CurrencyEnum.CLP, verbose_name="Moneda Original")
exchange_rate = models.DecimalField(max_digits=10, decimal_places=2, default=950.0, verbose_name="Tipo de Cambio")
description = models.TextField(verbose_name="Detalle de Imputación")
date = models.DateField(verbose_name="Fecha del Gasto")
vendor = models.CharField(max_length=200, blank=True, null=True, verbose_name="Proveedor / Emisor")
# Geographic & Logistic fields (Operaciones en terreno)
region = models.CharField(max_length=100, blank=True, null=True, verbose_name="Región (Chile)")
comuna = models.CharField(max_length=100, blank=True, null=True, verbose_name="Comuna")
vehicle_ownership = models.CharField(max_length=100, blank=True, null=True, verbose_name="Modalidad / Vehículo")
# 3-step Approval Flow Tracking
approval_status = models.CharField(max_length=50, choices=ApprovalStatusEnum.choices, default=ApprovalStatusEnum.EN_REVISION, verbose_name="Estado de Aprobación")
user = models.ForeignKey(User, on_delete=models.CASCADE, related_name='expenses_created', verbose_name="Registrado por")
responsible_person = models.ForeignKey(User, on_delete=models.SET_NULL, null=True, related_name='expenses_responsible', verbose_name="Responsable Solicitante")
elevated_by = models.ForeignKey(User, on_delete=models.SET_NULL, null=True, blank=True, related_name='expenses_elevated', verbose_name="Elevado por (Paso 2)")
paid_by = models.ForeignKey(User, on_delete=models.SET_NULL, null=True, blank=True, related_name='expenses_paid', verbose_name="Pagado por (Paso 3)")
rejection_reason = models.TextField(blank=True, null=True, verbose_name="Motivo de Observación/Rechazo")
paid_at = models.DateTimeField(null=True, blank=True, verbose_name="Fecha de Pago")
created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)
def __str__(self):
return f"{self.expense_code} - {self.amount_clp} CLP ({self.date})"
class Meta:
verbose_name = "Gasto"
verbose_name_plural = "Gastos"
ordering = ['-date', '-created_at']
class Document(models.Model):
expense = models.ForeignKey(Expense, on_delete=models.CASCADE, related_name='documents', verbose_name="Gasto Relacionado")
file = models.FileField(upload_to='evidences/', verbose_name="Archivo Adjunto")
file_type = models.CharField(max_length=50, blank=True, verbose_name="Tipo de Archivo")
title = models.CharField(max_length=200, blank=True, verbose_name="Título del Documento")
uploaded_at = models.DateTimeField(auto_now_add=True, verbose_name="Fecha de Subida")
uploaded_by = models.ForeignKey(User, on_delete=models.SET_NULL, null=True, verbose_name="Subido por")
def __str__(self):
return f"Evidencia para {self.expense.expense_code}"
class Meta:
verbose_name = "Documento Evidencia"
verbose_name_plural = "Documentos Evidencias"
class UserProfile(models.Model):
user = models.OneToOneField(User, on_delete=models.CASCADE, related_name='profile', verbose_name="Usuario")
rut = models.CharField(max_length=20, blank=True, null=True, verbose_name="RUT")
job_title = models.CharField(max_length=150, blank=True, null=True, verbose_name="Cargo")
description = models.TextField(blank=True, verbose_name="Descripción del Responsable")
created_at = models.DateTimeField(auto_now_add=True, verbose_name="Fecha de Registro")
updated_at = models.DateTimeField(auto_now=True, verbose_name="Última Modificación")
def __str__(self):
return f"Perfil de {self.user.username}"
class Meta:
verbose_name = "Perfil de Usuario"
verbose_name_plural = "Perfiles de Usuario"
class AuditLog(models.Model):
user = models.ForeignKey(User, on_delete=models.SET_NULL, null=True, blank=True, verbose_name="Usuario")
action = models.CharField(max_length=100, verbose_name="Acción")
entity_name = models.CharField(max_length=100, blank=True, null=True, verbose_name="Entidad (Tabla)")
entity_id = models.CharField(max_length=100, blank=True, null=True, verbose_name="ID Entidad")
field_changed = models.CharField(max_length=100, blank=True, null=True, verbose_name="Campo Modificado")
old_value = models.TextField(blank=True, null=True, verbose_name="Valor Anterior")
new_value = models.TextField(blank=True, null=True, verbose_name="Valor Nuevo")
description = models.TextField(verbose_name="Detalle del Cambio")
timestamp = models.DateTimeField(auto_now_add=True, verbose_name="Fecha y Hora")
def __str__(self):
return f"{self.timestamp} - {self.user.username if self.user else 'Sistema'}: {self.action}"
class Meta:
ordering = ['-timestamp']
verbose_name = "Log de Auditoría (SAP CDHDR/CDPOS)"
verbose_name_plural = "Logs de Auditoría"
class SystemSetting(models.Model):
global_budget = models.DecimalField(max_digits=15, decimal_places=2, default=25000000.00, verbose_name="Presupuesto General I+D+i")
usd_exchange_rate = models.DecimalField(max_digits=10, decimal_places=2, default=950.0, verbose_name="Tasa USD/CLP por Defecto")
def __str__(self):
return "Configuración Global EFINTEL"
class Meta:
verbose_name = "Configuración del Sistema"
verbose_name_plural = "Configuraciones del Sistema"
+6
View File
@@ -0,0 +1,6 @@
from django.urls import re_path
from . import consumers
websocket_urlpatterns = [
re_path(r'ws/expenses/$', consumers.ExpenseConsumer.as_asgi()),
]
+41
View File
@@ -0,0 +1,41 @@
from django.db.models.signals import post_save, post_delete
from django.dispatch import receiver
from channels.layers import get_channel_layer
from asgiref.sync import async_to_sync
from .models import Expense
@receiver(post_save, sender=Expense)
def expense_saved(sender, instance, created, **kwargs):
channel_layer = get_channel_layer()
action = 'created' if created else 'updated'
expense_data = {
'id': instance.id,
'amount': str(instance.amount),
'category': instance.category.name if instance.category else None,
'project': instance.project.name if instance.project else None,
'user': instance.user.username if instance.user else None,
'date': instance.date.strftime('%Y-%m-%d'),
'description': instance.description,
}
async_to_sync(channel_layer.group_send)(
'expenses_updates',
{
'type': 'expense_update',
'action': action,
'expense': expense_data
}
)
@receiver(post_delete, sender=Expense)
def expense_deleted(sender, instance, **kwargs):
channel_layer = get_channel_layer()
async_to_sync(channel_layer.group_send)(
'expenses_updates',
{
'type': 'expense_update',
'action': 'deleted',
'expense': {'id': instance.id}
}
)
+3
View File
@@ -0,0 +1,3 @@
from django.test import TestCase
# Create your tests here.
+32
View File
@@ -0,0 +1,32 @@
from django.urls import path
from . import views
urlpatterns = [
path('login/', views.login_view, name='login'),
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/<int:pk>/editar/', views.expense_edit, name='expense_edit'),
path('gastos/<int:pk>/eliminar/', views.expense_delete, name='expense_delete'),
# Flujo de aprobación
path('gastos/<int:pk>/aprobar/<str:action>/', views.approve_expense, name='approve_expense'),
# Export / Import
path('gastos/exportar/csv/', views.export_expenses_csv, name='export_csv'),
path('proyectos/', views.project_list, name='project_list'),
path('gestion/', views.gestion_panel, name='gestion_panel'),
path('gestion/configuracion/', views.settings_edit, name='settings_edit'),
path('gestion/proyectos/nuevo/', views.project_create, name='project_create'),
path('gestion/proyectos/<int:pk>/editar/', views.project_edit, name='project_edit'),
path('gestion/proyectos/<int:pk>/eliminar/', views.project_delete, name='project_delete'),
path('gestion/responsables/nuevo/', views.responsable_create, name='responsable_create'),
path('gestion/responsables/<int:pk>/editar/', views.responsable_edit, name='responsable_edit'),
path('gestion/responsables/<int:pk>/eliminar/', views.responsable_delete, name='responsable_delete'),
]
+421
View File
@@ -0,0 +1,421 @@
import csv
import io
from django.shortcuts import render, redirect, get_object_or_404
from django.contrib.auth.decorators import login_required
from django.contrib.auth import login, authenticate, logout
from django.contrib import messages
from django.db.models import Sum, Count, F
from django.utils import timezone
from datetime import date
from django.http import JsonResponse, HttpResponse
from .models import Expense, Project, Category, SystemSetting, UserProfile, AuditLog, ApprovalStatusEnum, CurrencyEnum
from .forms import ExpenseForm, ProjectForm, SystemSettingForm, ResponsableForm
from .decorators import role_required
from django.contrib.auth.models import User, Group
def login_view(request):
if request.user.is_authenticated:
return redirect('dashboard')
if request.method == 'POST':
username = request.POST.get('username')
password = request.POST.get('password')
user = authenticate(request, username=username, password=password)
if user is not None:
login(request, user)
return redirect('dashboard')
else:
messages.error(request, 'Usuario o contraseña incorrectos.')
return render(request, 'registration/login.html')
def logout_view(request):
logout(request)
return redirect('login')
@login_required
def dashboard(request):
today = date.today()
first_day_of_month = today.replace(day=1)
total_month_clp = Expense.objects.filter(
date__gte=first_day_of_month
).aggregate(total=Sum('amount_clp'))['total'] or 0
total_all_clp = Expense.objects.aggregate(
total=Sum('amount_clp')
)['total'] or 0
project_count = Project.objects.count()
today_count = Expense.objects.filter(date=today).count()
recent_expenses = Expense.objects.select_related(
'project', 'category', 'user', 'responsible_person'
).order_by('-date')[:10]
# Presupuesto Global
setting, _ = SystemSetting.objects.get_or_create(id=1)
global_budget = setting.global_budget
remaining_budget = global_budget - total_all_clp
context = {
'total_month': total_month_clp,
'total_all': total_all_clp,
'project_count': project_count,
'today_count': today_count,
'recent_expenses': recent_expenses,
'global_budget': global_budget,
'remaining_budget': remaining_budget,
}
return render(request, 'expenses/dashboard.html', context)
@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)
@login_required
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()
receipt = request.FILES.get('receipt')
if receipt:
from .models import Document
Document.objects.create(expense=expense, file=receipt, title=receipt.name, 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()
return render(request, 'expenses/expense_form.html', {'form': form})
@login_required
def expense_edit(request, pk):
expense = get_object_or_404(Expense, pk=pk)
user_groups = request.user.groups.values_list('name', flat=True)
if not (request.user.is_superuser or 'Administrador' in user_groups or expense.user == request.user):
messages.error(request, 'No tienes permisos para editar este gasto.')
return redirect('expense_list')
old_amount = expense.amount_clp
if request.method == 'POST':
form = ExpenseForm(request.POST, request.FILES, instance=expense)
if form.is_valid():
exp = form.save(commit=False)
setting, _ = SystemSetting.objects.get_or_create(id=1)
exp.amount_usd = exp.amount_clp / setting.usd_exchange_rate
exp.save()
receipt = request.FILES.get('receipt')
if receipt:
from .models import Document
# Optionally delete old receipt: Document.objects.filter(expense=exp).delete()
Document.objects.create(expense=exp, file=receipt, title=receipt.name, uploaded_by=request.user)
AuditLog.objects.create(
user=request.user,
action="Gasto Editado",
entity_name="Expense",
entity_id=exp.expense_code,
field_changed="amount_clp",
old_value=str(old_amount),
new_value=str(exp.amount_clp),
description=f"Se editó el monto del gasto"
)
messages.success(request, 'Gasto actualizado exitosamente.')
return redirect('expense_list')
else:
form = ExpenseForm(instance=expense)
return render(request, 'expenses/expense_form.html', {'form': form})
@login_required
def expense_delete(request, pk):
expense = get_object_or_404(Expense, pk=pk)
user_groups = request.user.groups.values_list('name', flat=True)
if not (request.user.is_superuser or 'Administrador' in user_groups):
messages.error(request, 'Solo administradores pueden eliminar gastos.')
return redirect('expense_list')
if request.method == 'POST':
code = expense.expense_code
expense.delete()
AuditLog.objects.create(
user=request.user,
action="Gasto Eliminado",
entity_name="Expense",
entity_id=code,
description="Se eliminó el gasto de la base de datos"
)
messages.success(request, 'Gasto eliminado.')
return redirect('expense_list')
# Flujo de 3 pasos
@login_required
def approve_expense(request, pk, action):
expense = get_object_or_404(Expense, pk=pk)
user_groups = request.user.groups.values_list('name', flat=True)
is_admin = request.user.is_superuser or 'Administrador' in user_groups
is_finanzas = 'Administrador Finanzas' in user_groups or is_admin
if action == 'solicitar':
# Step 1 -> 2
expense.approval_status = ApprovalStatusEnum.SOLICITADO_FINANZAS
expense.elevated_by = request.user
expense.save()
AuditLog.objects.create(
user=request.user,
action="ELEVATE_REQUEST",
entity_name="Expense",
entity_id=expense.expense_code,
description="Solicitud elevada a finanzas"
)
messages.success(request, "Solicitud elevada a finanzas.")
elif action == 'pagar':
# Step 2 -> 3
if not is_finanzas:
messages.error(request, "Solo Finanzas puede liberar el pago.")
return redirect('dashboard')
expense.approval_status = ApprovalStatusEnum.LIBERADO_PAGADO
expense.paid_by = request.user
expense.paid_at = timezone.now()
expense.save()
# Update Project Cost
proj = expense.project
proj.actual_cost_clp = proj.actual_cost_clp + expense.amount_clp
proj.save()
AuditLog.objects.create(
user=request.user,
action="APPROVE_PAYMENT",
entity_name="Expense",
entity_id=expense.expense_code,
description="Pago liberado"
)
messages.success(request, "Pago liberado con éxito.")
elif action == 'rechazar':
expense.approval_status = ApprovalStatusEnum.OBSERVADO_RECHAZADO
expense.save()
AuditLog.objects.create(
user=request.user,
action="REJECT",
entity_name="Expense",
entity_id=expense.expense_code,
description="Gasto observado/rechazado"
)
messages.warning(request, "Gasto rechazado.")
return redirect(request.META.get('HTTP_REFERER', 'dashboard'))
@login_required
def export_expenses_csv(request):
response = HttpResponse(content_type='text/csv')
response['Content-Disposition'] = f'attachment; filename="Gastos_EFINTEL_{date.today()}.csv"'
writer = csv.writer(response, delimiter=';')
writer.writerow([
'ID_Gasto', 'Codigo_Transaccion', 'Fecha', 'Proyecto_Codigo', 'Proyecto_Nombre',
'Monto_CLP', 'Monto_USD', 'Moneda_Original', 'Estado_Aprobacion',
'Responsable', 'Detalle', 'Region', 'Comuna', 'Proveedor'
])
expenses = Expense.objects.all().select_related('project', 'responsible_person')
for exp in expenses:
writer.writerow([
exp.id,
exp.expense_code,
exp.date,
exp.project.code,
exp.project.name,
exp.amount_clp,
exp.amount_usd,
exp.currency,
exp.get_approval_status_display(),
exp.responsible_person.username if exp.responsible_person else '',
exp.description,
exp.region,
exp.comuna,
exp.vendor
])
AuditLog.objects.create(
user=request.user,
action="EXPORT_CSV",
description="Se descargó la matriz de gastos en formato CSV."
)
return response
@login_required
def project_list(request):
projects = Project.objects.annotate(
expense_count=Count('expenses'),
total_expenses=Sum('expenses__amount_clp')
).select_related('manager')
context = {
'projects': projects,
}
return render(request, 'expenses/project_list.html', context)
@login_required
@role_required('Administrador')
def gestion_panel(request):
projects = Project.objects.all().select_related('manager', 'created_by')
responsables = User.objects.all().select_related('profile').prefetch_related('groups')
settings_obj, _ = SystemSetting.objects.get_or_create(id=1)
logs = AuditLog.objects.all().select_related('user')[:100]
context = {
'projects': projects,
'responsables': responsables,
'settings': settings_obj,
'logs': logs,
}
return render(request, 'expenses/gestion.html', context)
@login_required
@role_required('Administrador')
def settings_edit(request):
setting, _ = SystemSetting.objects.get_or_create(id=1)
if request.method == 'POST':
form = SystemSettingForm(request.POST, instance=setting)
if form.is_valid():
form.save()
messages.success(request, 'Configuración actualizada.')
return redirect('gestion_panel')
else:
form = SystemSettingForm(instance=setting)
return render(request, 'expenses/settings_form.html', {'form': form})
@login_required
@role_required('Administrador')
def project_create(request):
if request.method == 'POST':
form = ProjectForm(request.POST)
if form.is_valid():
project = form.save(commit=False)
project.created_by = request.user
project.save()
messages.success(request, 'Proyecto creado exitosamente.')
return redirect('gestion_panel')
else:
form = ProjectForm()
return render(request, 'expenses/project_form.html', {'form': form})
@login_required
@role_required('Administrador')
def project_edit(request, pk):
project = get_object_or_404(Project, pk=pk)
if request.method == 'POST':
form = ProjectForm(request.POST, instance=project)
if form.is_valid():
form.save()
messages.success(request, 'Proyecto actualizado exitosamente.')
return redirect('gestion_panel')
else:
form = ProjectForm(instance=project)
return render(request, 'expenses/project_form.html', {'form': form})
@login_required
@role_required('Administrador')
def project_delete(request, pk):
project = get_object_or_404(Project, pk=pk)
if request.method == 'POST':
project.delete()
messages.success(request, 'Proyecto eliminado.')
return redirect('gestion_panel')
@login_required
@role_required('Administrador')
def responsable_create(request):
if request.method == 'POST':
form = ResponsableForm(request.POST)
if form.is_valid():
form.save()
messages.success(request, 'Responsable creado exitosamente.')
return redirect('gestion_panel')
else:
form = ResponsableForm()
return render(request, 'expenses/responsable_form.html', {'form': form})
@login_required
@role_required('Administrador')
def responsable_edit(request, pk):
user = get_object_or_404(User, pk=pk)
if request.method == 'POST':
form = ResponsableForm(request.POST, instance=user)
if form.is_valid():
form.save()
messages.success(request, 'Responsable actualizado exitosamente.')
return redirect('gestion_panel')
else:
form = ResponsableForm(instance=user)
return render(request, 'expenses/responsable_form.html', {'form': form, 'edit_mode': True, 'target_user': user})
@login_required
@role_required('Administrador')
def responsable_delete(request, pk):
user = get_object_or_404(User, pk=pk)
if request.method == 'POST':
user.delete()
messages.success(request, 'Responsable eliminado.')
return redirect('gestion_panel')