Introducción
Como desarrollador, probablemente has implementado sistemas donde el tiempo es crucial: logs con timestamps, fechas de creación/modificación, o incluso sistemas de caché. Los períodos contables son similares, pero con una particularidad: son la columna vertebral de cualquier sistema financiero serio.
¿Has intentado alguna vez modificar un registro financiero del mes pasado y te has preguntado por qué tu contador puso el grito en el cielo? En este tutorial, aprenderás por qué, y más importante aún, cómo implementar un sistema robusto de períodos contables en Django.
Al finalizar este tutorial, serás capaz de:
- Implementar períodos contables seguros en Django
- Proteger la integridad de los datos financieros
- Utilizar el admin de Django para gestionar períodos contables
Prerrequisitos
- Python 3.8+
- Django 5.0+
- Conocimientos básicos de modelos Django
- SQLite o PostgreSQL
Conceptos Clave
Período Contable: El Transaction Commit de la Contabilidad
Piensa en un período contable como un git commit o una transacción de base de datos: una vez "committed", no deberías modificarlo. En contabilidad, esto se conoce como "cerrar un período", y es tan crítico como hacer un commit en producción.
Estados de un Período
Similar a los estados de un proceso en un sistema operativo:
- DRAFT (borrador): Como una rama de desarrollo
- ACTIVE (activo): Como la rama principal
- CLOSED (cerrado): Como un tag de versión
Implementación
Modelos Base
from django.db import models
from django.core.exceptions import ValidationError
from django.utils import timezone
from datetime import datetime, timedelta
from django.contrib import admin
from django.utils.translation import gettext_lazy as _
class AccountingPeriod(models.Model):
STATUS_CHOICES = [
('DRAFT', 'Borrador'),
('ACTIVE', 'Activo'),
('CLOSED', 'Cerrado'),
]
name = models.CharField(
max_length=100,
unique=True,
help_text="Ejemplo: 'Enero 2024'"
)
start_date = models.DateField()
end_date = models.DateField()
status = models.CharField(
max_length=10,
choices=STATUS_CHOICES,
default='DRAFT'
)
created_at = models.DateTimeField(auto_now_add=True)
closed_at = models.DateTimeField(null=True, blank=True)
class Meta:
ordering = ['-start_date']
constraints = [
models.CheckConstraint(
check=models.Q(end_date__gte=models.F('start_date')),
name='end_date_after_start_date'
)
]
def clean(self):
if not self.pk: # Solo para nuevos períodos
self.validate_no_overlapping()
if self.status == 'CLOSED' and not self.closed_at:
self.closed_at = timezone.now()
def validate_no_overlapping(self):
overlapping = AccountingPeriod.objects.filter(
models.Q(start_date__lte=self.end_date) &
models.Q(end_date__gte=self.start_date)
)
if overlapping.exists():
raise ValidationError(
'El período se superpone con períodos existentes.'
)
def close_period(self):
if self.status != 'ACTIVE':
raise ValidationError(
'Solo se pueden cerrar períodos activos.'
)
self.status = 'CLOSED'
self.closed_at = timezone.now()
self.save()
def activate_period(self):
if self.status != 'DRAFT':
raise ValidationError(
'Solo se pueden activar períodos en borrador.'
)
self.status = 'ACTIVE'
self.save()
def __str__(self):
return f"{self.name} ({self.get_status_display()})"
class AccountingEntry(models.Model):
period = models.ForeignKey(
AccountingPeriod,
on_delete=models.PROTECT,
related_name='entries'
)
date = models.DateField()
description = models.CharField(max_length=200)
amount = models.DecimalField(
max_digits=10,
decimal_places=2
)
created_at = models.DateTimeField(auto_now_add=True)
def clean(self):
if not self.period_id:
self.period = self.get_appropriate_period()
if self.period.status == 'CLOSED':
raise ValidationError(
'No se pueden crear entradas en períodos cerrados.'
)
if not (self.period.start_date <= self.date <= self.period.end_date):
raise ValidationError(
'La fecha debe estar dentro del período contable.'
)
def get_appropriate_period(self):
period = AccountingPeriod.objects.filter(
start_date__lte=self.date,
end_date__gte=self.date,
status='ACTIVE'
).first()
if not period:
raise ValidationError(
'No existe un período activo para la fecha especificada.'
)
return period
def __str__(self):
return f"{self.date} - {self.description} (${self.amount})"
Configuración del Admin
from django.contrib import admin
from django.utils.html import format_html
@admin.register(AccountingPeriod)
class AccountingPeriodAdmin(admin.ModelAdmin):
list_display = ['name', 'start_date', 'end_date', 'status_badge',
'created_at']
list_filter = ['status']
search_fields = ['name']
readonly_fields = ['closed_at']
def status_badge(self, obj):
colors = {
'DRAFT': 'gray',
'ACTIVE': 'green',
'CLOSED': 'red',
}
return format_html(
'<span style="color: {};">{}</span>',
colors[obj.status],
obj.get_status_display()
)
status_badge.short_description = 'Estado'
def get_readonly_fields(self, request, obj=None):
if obj and obj.status == 'CLOSED':
return ['name', 'start_date', 'end_date', 'status', 'closed_at']
return self.readonly_fields
actions = ['activate_periods', 'close_periods']
def activate_periods(self, request, queryset):
for period in queryset:
try:
period.activate_period()
except ValidationError as e:
self.message_user(
request,
f"Error al activar {period.name}: {str(e)}",
level='ERROR'
)
activate_periods.short_description = "Activar períodos seleccionados"
def close_periods(self, request, queryset):
for period in queryset:
try:
period.close_period()
except ValidationError as e:
self.message_user(
request,
f"Error al cerrar {period.name}: {str(e)}",
level='ERROR'
)
close_periods.short_description = "Cerrar períodos seleccionados"
@admin.register(AccountingEntry)
class AccountingEntryAdmin(admin.ModelAdmin):
list_display = ['date', 'description', 'amount', 'period']
list_filter = ['period', 'date']
search_fields = ['description']
def get_readonly_fields(self, request, obj=None):
if obj and obj.period.status == 'CLOSED':
return ['date', 'description', 'amount', 'period']
return []
def has_delete_permission(self, request, obj=None):
if obj and obj.period.status == 'CLOSED':
return False
return super().has_delete_permission(request, obj)
Tests
from django.test import TestCase
from django.core.exceptions import ValidationError
from datetime import date, timedelta
from .models import AccountingPeriod, AccountingEntry
class AccountingPeriodTests(TestCase):
def setUp(self):
self.period = AccountingPeriod.objects.create(
name="Test Period",
start_date=date(2024, 1, 1),
end_date=date(2024, 1, 31),
status='DRAFT'
)
def test_period_activation(self):
self.period.activate_period()
self.assertEqual(self.period.status, 'ACTIVE')
def test_period_closure(self):
self.period.activate_period()
self.period.close_period()
self.assertEqual(self.period.status, 'CLOSED')
def test_overlapping_periods(self):
with self.assertRaises(ValidationError):
AccountingPeriod.objects.create(
name="Overlapping Period",
start_date=date(2024, 1, 15),
end_date=date(2024, 2, 15),
status='DRAFT'
)
class AccountingEntryTests(TestCase):
def setUp(self):
self.period = AccountingPeriod.objects.create(
name="Test Period",
start_date=date(2024, 1, 1),
end_date=date(2024, 1, 31),
status='ACTIVE'
)
def test_entry_creation(self):
entry = AccountingEntry.objects.create(
period=self.period,
date=date(2024, 1, 15),
description="Test Entry",
amount=100.00
)
self.assertEqual(entry.period, self.period)
def test_entry_in_closed_period(self):
self.period.close_period()
with self.assertRaises(ValidationError):
AccountingEntry.objects.create(
period=self.period,
date=date(2024, 1, 15),
description="Test Entry",
amount=100.00
)
Mejores Prácticas
Validaciones de Seguridad
- Usar
PROTECTen las foreign keys para evitar eliminación accidental - Implementar validaciones a nivel de modelo
- Usar el sistema de permisos de Django
- Usar
Manejo de Errores
- Validaciones descriptivas
- Mensajes de error claros
- Logging de operaciones críticas
Patrones de Diseño
- State Pattern para estados del período
- Factory Method para crear entradas
- Observer Pattern para auditoría
Conclusión
Has aprendido a implementar un sistema robusto de períodos contables en Django que:
- Protege la integridad de los datos financieros
- Implementa validaciones de negocio
- Utiliza el admin de Django eficientemente
SOCIAL SHARE CARD GENERATOR