Miethai 2: Hausverwaltung

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.