Ein wesentlicher Vorzug des Django-Frameworks ist die mitgelieferte Administrationsoberfläche, und davon profitiert auch meine Miethai-Anwendung. Die Verwaltung der Gebäude umfasst auch die einzelnen Wohnungen und Dienstleistungen. Weil die Entfernung/Änderung einer bestehenden Wohnung/Dienstleistung weitreichende Folgen für bestehende Abrechnungen hat, sind die Funktionen an dieser Stelle deaktiviert:
class UnitInlineAdmin(admin.TabularInline): model = Unit extra = 0 ordering = ('location',) def has_change_permission(self, request, obj=None): return False def has_delete_permission(self, request, obj=None): return False class ServiceInlineAdmin(admin.TabularInline): model = Service extra = 0 ordering = ('name',) def has_change_permission(self, request, obj=None): return False def has_delete_permission(self, request, obj=None): return False class BuildingAdmin(admin.ModelAdmin): list_display = ('name', 'address',) readonly_fields = ('area',) inlines = [UnitInlineAdmin,ServiceInlineAdmin,]
Für Verträge wird über die clean_services()
-Methode sichergestellt, dass sich alle zugeordneten Dienstleistungen auf dasselbe Gebäude beziehen und dass die Summe ihrer Rechnungsanteile 1 (100%) beträgt:
class ContractForm(forms.ModelForm): def clean_services(self): if sum(service.share_of_invoice for service in self.cleaned_data['services']) != 1: raise forms.ValidationError(_('Summe der Rechnungsanteile ist != 1.'), code='invalid_invoice_share') building = self.cleaned_data['services'].first().building for service in self.cleaned_data['services']: if service.building != building: raise forms.ValidationError(_('Dienste für unterschiedliche Gebäude ausgewählt!'), code='different_buildings') return self.cleaned_data['services'] class ContractAdmin(admin.ModelAdmin): list_display = ('external_id', 'contractor', 'get_services',) filter_horizontal = ('services', ) save_as = True form = ContractForm inlines = [InvoiceInlineAdmin,]
Ebenfalls abgesichert wird die Belegung von Wohnungen, um überlappende und unlogische Einträge zu verhindern:
class OccupancyInlineFormset(forms.BaseInlineFormSet): def clean(self): super().clean() for form in self.forms: unit = form.cleaned_data['unit'] begin = form.cleaned_data['begin'] end = form.cleaned_data['end'] if end < begin: raise forms.ValidationError(_('Fehlerhafte Datumsreihenfolge!'), code='wrong_order') conflicts = Occupancy.objects.filter(models.Q(unit=unit), models.Q(begin__lte=begin, end__gte=begin) | models.Q(begin__lte=end, end__gte=end) | models.Q(begin__gte=begin, end__lte=end)).exclude(pk=form.instance.id) if conflicts: raise forms.ValidationError(_('Zeitliche Überschneidung mit bestehender Belegung!'), code='overlap') class OccupancyAdmin(admin.TabularInline): model = Occupancy extra = 0 ordering = ('begin',) formset = OccupancyInlineFormset class UnitAdmin(admin.ModelAdmin): list_display = ('location', 'building', 'id') search_fields = ['building__name', 'location'] inlines = [OccupancyAdmin,]
Dasselbe Prinzip wird für Mietverträge angewendet. Zwischenzeitlich war ich der Illusion verfallen, ich könne beim Eintrag einer neuen Belegung (occupancy.end == datetime.date(9999,12,31)
) automatisch die aktuelle Belegung auf den Vortag befristen:
class OccupancyInlineFormset(forms.BaseInlineFormSet): def clean(self): super().clean() infinity = datetime.date(9999,12,31) for form in self.forms: unit = form.cleaned_data['unit'] begin = form.cleaned_data['begin'] end = form.cleaned_data['end'] if end < begin: raise forms.ValidationError(_('Fehlerhafte Datumsreihenfolge!'), code='wrong_order') conflicts = Occupancy.objects.filter(models.Q(unit=unit), models.Q(begin__lte=begin, end__gte=begin) | models.Q(begin__lte=end, end__gte=end) | models.Q(begin__gte=begin, end__lte=end)).exclude(pk=form.instance.id).exclude(end=infinity) if conflicts: raise forms.ValidationError(_('Zeitliche Überschneidung mit bestehender Belegung!'), code='overlap') elif end == infinity: try: current_occupancy = Occupancy.objects.get(unit=unit, end=datetime.date(9999,12,31)) if current_occupancy.pk != form.instance.id: current_occupancy.end = begin - datetime.timedelta(days=1) current_occupancy.save() except ObjectDoesNotExist: pass
Mit dieser Variante war es allerdings möglich, Überschneidungen mit der aktuellen Belegung zu erzeugen, eine noch komplexere clean()
-Methode widerstrebt mir und die Eingabe neuer Belegungen in zwei Schritten ist auch nicht unzumutbar. Eine Alternative zur Formularvalidierung ist die Verwendung von Datenbank-Restriktionen für die Modelle:
from django.contrib.postgres.constraints import ExclusionConstraint from django.contrib.postgres.fields import DateRangeField, RangeBoundary, RangeOperators class DateRangeFunc(models.Func): function = 'daterange' output_field = DateRangeField() class Occupancy(models.Model): class Meta: constraints = [ ExclusionConstraint( name="exclude_overlapping_occupancies", expressions=( ( DateRangeFunc( "begin", "end", RangeBoundary(inclusive_lower=True, inclusive_upper=True) ), RangeOperators.OVERLAPS, ), ("unit", RangeOperators.EQUAL), ), ), ]
Die verwendete PostgreSQL-Datenbank muss auf diese Herangehensweise vorbereitet sein (CREATE EXTENSION btree_gist;
), um eine pikierte Fehlermeldung –
django.db.utils.ProgrammingError: data type bigint has no default operator class for access method "gist" HINT: You must specify an operator class for the index or define a default operator class for the data type.
– zu vermeiden. Leider greift ein funktionierender ExclusionConstraint
anders als erwartet nicht erst im Rahmen der Speicherung einer Instanz (save()
), sondern bereits vor der Formvalidierung mittels clean()
, so dass Überschneidungen statt des erwarteten ValidationError
stets einen IntegrityError
produzieren.