Nach den ersten Beispielskripten mit Rust migriere ich meinen Python-basierten Integritätsmonitor. Einen signifikanten Geschwindigkeitszuwachs erwarte ich nicht, weil das Skript im Wesentlichen aus I/O-Operationen und der Berechnung von Hash-Werten besteht (und Python sich diesbezüglich auf eine C-Bibliothek stützt). Allerdings beansprucht die Rust-Version rund dreimal so viel Zeit (4 vs. 12 Sekunden), was nicht gut zum Produktversprechen der Sprache passt. Es zeigt sich, dass der Wrapper sha256
mit der Methode try_digest()
sehr komfortabel, aber auch enorm langsam ist:
use sha256::try_digest;
use std::path::Path;
fn get_hash(filepath: &str) -> String {
let input = Path::new(filepath);
let hasher = try_digest(input).unwrap();
hasher
}
Meine Anfrage im Rust-Forum führt binnen Minuten zu Analysen (fixed buffer size
als Wettbewerbsvorteil für Python) und Lösungsvorschlägen (Verwendung von sha2
in Verbindung mit buffer streaming). Den Durchbruch bringen der Verweis auf die Assembly-Implementierung von Hash-Funktionen in sha2
(sha2 = { version = "0.10.6", features = ["asm", "asm-aarch64"] }
bzw. in ring
:
use ring::digest;
use sha2::{Digest, Sha256};
use hex;
fn get_hash_sha2(filepath: &str) -> String {
let bytes = std::fs::read(filepath).unwrap();
let mut h = Sha256::new();
h.update(&bytes[..]);
hex::encode(h.finalize())
}
fn get_hash_ring(filepath: &str) -> String {
let bytes = std::fs::read(filepath).unwrap();
let hasher = digest::digest(&digest::SHA256, &bytes);
hex::encode(hasher)
}
Ich entscheide mich für die (minimal schnellere) ring
-Variante:
use ring::digest;
use hex;
use walkdir::{DirEntry, WalkDir};
use std::fs;
use std::collections::HashMap;
use serde_json;
use std::io;
const HASHPATH: &str = "/Users/snafu/scripts/hashes_rust.json";
const DOCPATH: &str = "/Users/snafu/Documents";
fn get_hash(filepath: &str) -> String {
let bytes = std::fs::read(filepath).unwrap();
let hasher = digest::digest(&digest::SHA256, &bytes);
hex::encode(hasher)
}
fn is_hidden(entry: &DirEntry) -> bool {
entry.file_name()
.to_str()
.map(|s| s.starts_with("."))
.unwrap_or(false)
}
fn create_baseline() {
let file_content = fs::read_to_string(HASHPATH).unwrap();
let mut hash_storage: HashMap<String, String> = serde_json::from_str(&file_content).unwrap();
for entry in WalkDir::new(DOCPATH).follow_links(true).into_iter().filter_entry(|e| !is_hidden(e)) {
let file = match entry {
Ok(value) => value,
Err(_) => continue,
};
let metadata = match fs::metadata(&file.path()) {
Ok(value) => value,
Err(_) => continue,
};
if metadata.is_file() {
let filepath = file.path().display().to_string();
let hash = get_hash(&filepath);
if hash_storage.contains_key(&filepath) {
if hash_storage[&filepath] == hash {
continue;
} else {
println!("Hash for file '{filepath}' does not match baseline. Update hash and proceed (y) or cancel (n)? ");
let mut overwrite = String::new();
io::stdin().read_line(&mut overwrite).expect("Failed to read line");
match overwrite.trim() {
"y" => (),
_ => continue,
};
};
};
println!("Updating hash/creating new hash for file '{filepath}'...");
*hash_storage.entry(filepath).or_insert(hash) = hash.clone();
};
};
let mut to_remove = Vec::new();
for absolute_path in hash_storage.keys() {
match fs::metadata(&absolute_path) {
Ok(_) => continue,
Err(_) => {
println!("Removing hash for deleted file {absolute_path}...");
to_remove.push(absolute_path.to_owned());
},
};
}
for key in to_remove.iter() {
hash_storage.remove(key);
}
let serialized = serde_json::to_string(&hash_storage).unwrap();
fs::write(HASHPATH, serialized).expect("Unable to write file");
}
fn main() {
create_baseline();
}
Das Skript ist mit der Python-Version nahezu identisch, abgesehen von der Entfernung nicht mehr benötigter Hashes aus hash_storage
. Weil die Methode keys()
eine mutable reference auf hash_storage
erzeugt, kann das Objekt nicht innerhalb der for
-Schleife modifiziert werden. Stattdessen wird ein Hilfsobjekt vom Typ Vec<String>
mit den zu entfernenden Einträgen gefüllt und in einer zweiten for
-Schleife abgearbeitet.
Der Aufwand hat sich jedenfalls gelohnt:
snafu@local hasher % /usr/bin/time -h /usr/local/bin/hasher.py
3.95s real 1.33s user 0.78s sys
snafu@local hasher % /usr/bin/time -h target/release/hasher
2.13s real 1.23s user 0.57s sys
Darüber hinaus verleiht mir die freundliche Rust-Gemeinschaft den New User of the Month award for February 2023
:
This badge is granted to congratulate two new users each month for their excellent overall contributions, as measured by how often their posts were liked, and by whom.
Ich bin sehr gerührt.
Update: Wenn Mojo oder Codon ihre Produktversprechen halten, muss ich vielleicht keine völlig neue Sprache lernen.
Weil ich dem Sirenengesang Performance – Reliability – Productivity
gegenüber sehr aufgeschlossen bin, die kurze Haskell-Affäre vor vielen Jahren wenig erfüllend war und die Beschränkung auf eine einzige (wenn auch populäre) Programmiersprache die (durchaus berechtigten) Zweifel an meiner technischen Satisfaktionsfähigkeit kräftig schürt, befasse ich mich seit einigen Tagen mit Rust. Die Referenz-Einführung (the book
) bietet nicht nur ein passendes Lerntempo, sondern auch die richtige Mischung von Theorie und Praxis, und so kann ich am Ende des zweiten Kapitels drei Beispielaufgaben lösen.
Temperaturkonversion
Weltweit bestehen nur noch sehr wenige Länder darauf, Temperaturen ausschließlich in Fahrenheit anzugeben, und keines davon ist das Vereinigte Königreich. Trotzdem bleibt die Umrechnung zwischen Fahrenheit zu Celsius eine beliebte Übung. Mein Skript erwartet die Eingabe eines Zahlenwertes und der Maßeinheit:
use std::io;
fn to_celsius(fahrenheit_value: f32) {
let celsius_value = (fahrenheit_value - 32.0) * 5.0 / 9.0;
println!("{fahrenheit_value} Grad Fahrenheit entspricht {celsius_value} Grad Celsius.");
}
fn to_fahrenheit(celsius_value: f32) {
let fahrenheit_value = (celsius_value * 9.0 / 5.0) + 32.0;
println!("{celsius_value} Grad Celsius entspricht {fahrenheit_value} Grad Fahrenheit.");
}
fn main() {
loop {
let mut input = String::new();
println!("Bitte Temperaturwert und Ausgangseinheit – [F]ahrenheit oder [C]elsius – eingeben!");
io::stdin().read_line(&mut input).expect("Lesefehler!");
let mut splitted_input = input.split_whitespace();
let temperature_value = match splitted_input.next() {
Some(value) => value,
None => continue,
};
let temperature_value: f32 = match temperature_value.parse() {
Ok(num) => num,
Err(_) => continue,
};
let unit = splitted_input.next();
match unit {
Some("F") => to_celsius(temperature_value),
Some("C") => to_fahrenheit(temperature_value),
Some(&_) => { println!("Ungültige Ausgangseinheit!"); }
None => { println!("Fehlende Ausgangseinheit!"); }
}
break;
}
}
Die Eingabe wird als Zeichenkette (vom Typ String
) gespeichert und in zwei Elemente aufgeteilt. Das erste Element wird mit Hilfe von zwei match
-Ausdrücken daraufhin geprüft, ob es nicht leer ist und in einen Fließkommawert umgewandelt werden kann. Falls einer der beiden Schritte nicht zum Erfolg führt, wird die Schleife erneut ausgeführt – und die Nutzerin wieder zur Eingabe von Temperatur und Maßeinheit aufgefordert. Fehlt dagegen die Maßeinheit, oder besteht sie nicht aus den Zeichenketten C bzw. F, gibt das Skript eine Fehlermeldung aus.
Fibonacci
Bei der Berechnung der n-ten Fibonacci-Zahl verzichte ich auf Nutzereingaben und lasse einfach die 150. Zahl (rund 16,1 Quintillionen, genau: 16.130.531.424.904.581.415.797.907.386.349) berechnen:
fn main() {
let mut left = 1;
let mut right = 1;
let mut result: u128 = 0;
let target = 150;
let mut counter = 1;
while target > counter {
result = left + right;
left = right;
right = result;
counter += 1;
}
println!("The {target}th Fibonacci number is {result}");
}
Mit diesem simplen Skript lässt sich das Leistungsversprechen von Rust (im Vergleich mit Python) testen, indem man den obigen Code 10 Millionen mal ausführen lässt. Python benötigt 165,45 Sekunden – und Rust 0,62 (bei sehr viel geringerem Einsatz von Speicherkapazität und Rechenzyklen).
Weihnachtslied
Die dritte Aufgabe besteht darin, den Text eines sehr repetitiven Weihnachtsliedes vollständig auszuschreiben.
fn main() {
let material = [
("first", "and a partridge in the pear tree."),
("second", "two turtle doves"),
("third", "three French hens"),
("fourth", "four calling birds"),
("fifth", "five gold rings"),
("sixth", "six geese a-laying"),
("seventh", "seven swans a-swimming"),
("eight", "eight maids a-milking"),
("ninth", "nine ladies dancing"),
("tenth", "ten lords a-leaping"),
("eleventh", "eleven pipers piping"),
("twelfth", "twelve drummers drumming")
];
let mut storage = String::new();
for (day, gift) in material {
println!("On the {day} day of christmas, my true love sent to me\n{gifted}", gifted=gift.strip_prefix("and ").unwrap_or(gift));
println!("{storage}");
storage = gift.to_owned() + "\n" + &storage;
}
}
Abgesehen von der Methode strip_prefix()
, mit der das initiale and
bei der ersten Verwendung der Zeile and a partridge in the pear tree
entfernt wird, ist nur die letzte Zeile etwas erklärungsbedürftig, denn:
When using strings, Rust beginners puzzle over String
vs. &str
, and Haskell beginners puzzle over String
vs. Text
vs. ByteString
.
Das kann ich sowohl für Haskell als auch für Rust bestätigen. Was geht in der Codezeile vor? Die Add
-Methode verwendet den Buffer des ersten Arguments vom Typ String
(owned), kopiert den Inhalt des zweiten Arguments vom Typ &str
(borrowed) in diesen Buffer und liefert den erweiterten String
zurück. Entsprechend muss die Variable gift
(Typ &str
) in einen String
umgewandelt werden, während storage
als &storage
(Typ &str
) den umgekehrten Weg geht. Um Ownership, References und Borrowing für einen Moment auszublenden, lässt sich alternativ insert_str()
zweimal aufrufen:
storage.insert_str(0, "\n");
storage.insert_str(0, gift);
Mit Mastodon 4.1.0 lassen sich auf meiner Instanz endlich auch Avatare und Header begrenzen:
mastodon@snafu:~/live$ bin/tootctl media usage
Attachments: 1.01 GB (1.33 MB local)
Custom emoji: 188 MB (12.7 KB local)
Preview cards: 24.9 MB
Avatars: 9.87 GB (96.5 KB local)
Headers: 19.8 GB (413 KB local)
Backups: 0 Bytes
Imports: 0 Bytes
Settings: 0 Bytes
mastodon@snafu:~/live$ bin/tootctl media remove --days=2 --prune-profiles
Visited 122464 accounts and removed profile media totaling 29.3 GB
mastodon@snafu:~/live$ bin/tootctl media remove --days=2 --remove-headers
Visited 15010 accounts and removed profile media totaling 179 KB
mastodon@snafu:~/live$ bin/tootctl media usage
Attachments: 1.01 GB (1.33 MB local)
Custom emoji: 188 MB (12.7 KB local)
Preview cards: 25 MB
Avatars: 125 MB (96.5 KB local)
Headers: 308 MB (413 KB local)
Backups: 0 Bytes
Imports: 0 Bytes
Settings: 0 Bytes
Obwohl sich die Interaktion mit der Miethai-Anwendung überwiegend in der Administrationsoberfläche abspielt, werden zwei (zugangsbeschränkte) Schnittstellen zur Außenwelt benötigt, eine Webansicht der Nebenkostenabrechnung und ein E-Mail-Versand. Beides ist mit minimalen Aufwand zu realisieren:
# ../landlord/settings.py
LOGIN_URL = '/admin/login/'
# urls.py
urlpatterns = [
path('balance/<balance_id>', views.display_balance, name='display_balance'),
path('email/balance/<balance_id>', views.email_balance, name='email_balance'),
]
# views.py
@login_required
def display_balance(request, balance_id):
balance = Balance.objects.get(pk=balance_id)
balance_entries = BalanceEntry.objects.filter(balance=balance)
return render(request, 'index.html', { 'balance': balance, 'balance_entries' : balance_entries })
@login_required
def email_balance(request, balance_id):
balance = Balance.objects.get(pk=balance_id)
balance_entries = BalanceEntry.objects.filter(balance=balance)
context = { 'balance': balance, 'balance_entries' : balance_entries }
subject = f'Nebenkostenabrechnung {balance.begin.year}'
message = loader.get_template('email.txt').render(context, request)
html_message = loader.get_template('email.html').render(context, request)
from_email = 'wohnung@eden.one'
recipient_list = [balance.tenancy_agreement.tenant.email]
send_mail(subject, message, from_email, recipient_list, html_message=html_message)
return display_balance(request, balance_id)
Das Template index.html
enthält eine tabellarische Darstellung der gewählten Abrechnung –
<html lang="de">
<head>
<meta charset="utf-8" />
{% block css %}<link rel="stylesheet" href="/static/css/landlord.css" />{% endblock %}
</head>
<body>
{% block email %}{% endblock %}
{% block heading %}<h1>Nebenkostenabrechnung {{balance.begin.year}}</h1>{% endblock %}
<ul><li><strong>Objekt</strong>: {{balance.tenancy_agreement.unit.building.address}} ({{balance.tenancy_agreement.unit.location}})</li>
<li><strong>Mietvertrag</strong>: {{balance.tenancy_agreement.public_id}}</li>
<li><strong>Mieter*in</strong>: {{balance.tenancy_agreement.tenant}}</li>
<li><strong>Abrechnungsdatum</strong>: {{balance.date}}</li>
<li><strong>Abrechnungszeitraum</strong>: {{balance.begin}} – {{balance.end}}</li>
</ul>
<table><tr><th>Dienst</th><th>Gesamtbetrag</th><th>Anteil (Leistung)</th><th>Anteil (Zeitraum)</th><th>Anteil (Rechnung)</th><th>Betrag</th></tr>
{% for entry in balance_entries %}
<tr><td>{{entry.service.name}}</td><td>{{entry.invoice_amount_display}}</td><td>{{entry.service_share_display}}</td><td>{% if entry.service.service_type == "CO" %}–{% else %}{{entry.days_share_display}}{% endif %}</td><td>{{entry.invoice_share_display}}</td><td>{{entry.amount_display}}</td></tr>
{% endfor %}
<tr><td><strong>Gesamtbetrag:</strong></td><td></td><td></td><td></td><td></td><td><strong>{{balance.total_display}}</strong></td></tr>
<tr><td>Vorauszahlungen:</td><td></td><td></td><td></td><td></td><td>{{balance.total_payments_display}}</td></tr>
<tr><td><strong>{{balance.payment_or_return}}:</strong></td><td></td><td><td></td></td><td></td><td><strong>{{balance.payment_due_display}}</strong></td></tr>
</table>
<p>{% if balance.payment_due < 0 %}Das <strong>Guthaben von {{balance.payment_due_display}}</strong> wird auf Ihr Konto {{balance.tenancy_agreement.account_iban}} (BIC: {{balance.tenancy_agreement.account_bic}}) überwiesen.{% else %}Bitte überweisen Sie die <strong>Nachzahlung i.H.v. {{balance.payment_due_display}}</strong> auf unser Konto IBAN DE12 3848 3838 2828 3848 (BIC: DEFGMWW).{% endif %}</p>
{% block email_link %}{% if balance.tenancy_agreement.tenant.email %}<p><a href="{% url 'email_balance' balance_id=balance.pk %}">Als E-Mail senden</a></p>{% endif %}{% endblock %}
</body></html>
– und das Template email.html
modifiziert diese Ansicht geringfügig:
{% extends 'index.html' %}
{% block css %}<style>
[...]
</style>{% endblock %}
{% block email %}<p>Sehr geehrte*r {{balance.tenancy_agreement.tenant}},<br />anbei erhalten Sie Ihre Nebenkostenabrechnung für das Jahr {{balance.begin.year}}.<br />Freundliche Grüße<br />...</p>{% endblock %}
{% block heading %}{% endblock %}
{% block email_link %}{% endblock %}
Ein Link zur Webansicht wird durch die Anpassung eines Snippets in der Admin-Ansicht einer Nebenkostenabrechnung eingeblendet:
# templates/admin/services/balance/change_form_object_tools.html
{% extends "admin/change_form_object_tools.html" %}
{% load i18n admin_urls %}
{% block object-tools-items %}
<li>
<a href="{% url 'display_balance' balance_id=original.pk %}" class="historylink">{% translate "View" %}</a>
</li>
<li>
{% url opts|admin_urlname:'history' original.pk|admin_urlquote as history_url %}
<a href="{% add_preserved_filters history_url %}" class="historylink">{% translate "History" %}</a>
</li>
{% if has_absolute_url %}<li><a href="{{ absolute_url }}" class="viewsitelink">{% translate "View on site" %}</a></li>{% endif %}
{% endblock %}
Eine dritte Ansicht dient ausschließlich der Diskretion. Digitale Rechnungen im PDF-Format sind ein optionaler Bestandteil jeder Invoice
-Instanz:
class Invoice(models.Model):
external_id = models.CharField('Rechnungsnummer', max_length=30)
contract = models.ForeignKey(Contract, verbose_name='Vertrag', on_delete=models.CASCADE)
date = models.DateField('Rechnungsdatum')
begin = models.DateField('Rechnungszeitraum (Beginn)')
end = models.DateField('Rechnungszeitraum (Ende)')
amount = models.DecimalField('Betrag', max_digits=8, decimal_places=2)
consumption = models.DecimalField('Leistung/Verbrauch', max_digits=10, decimal_places=3, blank=True, null=True)
counter_consumption = models.DecimalField('Verbrauch laut Zähler', max_digits=10, decimal_places=3, blank=True, null=True)
invoice_file = models.FileField('Rechnungskopie', upload_to='invoices', blank=True, null=True)
Die Ablage (und Verlinkung) dieser Dateien wird in settings.py
konfiguriert:
MEDIA_ROOT = '/Users/snafu/projects/landlord/media'
MEDIA_URL = ''
Damit Nginx diese Dateien nicht ohne Rücksprache mit Django ausliefert, kommt die Direktive internal
zum Einsatz:
# nginx.conf
location ~ ^/media/ {
internal;
}
Django seinerseits verarbeitet URLs der Form /invoices/<file_name>
und ermöglicht die Anzeige von /media/invoices/<file_name>
:
# ../landlord/urls.py
from services.views import invoice_file
urlpatterns = [
path('services/', include('services.urls')),
path('invoices/<file_name>', invoice_file, name="invoice_file"),
path('admin/', admin.site.urls),
]
# views.py
@login_required
def invoice_file(request, file_name):
response = HttpResponse()
response['Content-Type'] = 'application/pdf'
response['X-Accel-Redirect'] = '/media/invoices/' + file_name
response['Content-Disposition'] = 'inline;filename=' + file_name
return response
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.
Nach rund 10 Jahren bastele ich erstmals eine neue Django-App, diesmal geht es um Nebenkostenabrechnungen und damit erneut um Zeiträume. Datumskalkulationen sind offenbar mein Schicksal. Das Datenmodell ist auf den ersten Blick recht einfach: Jedes Gebäude besteht aus einer Anzahl Wohnungen. Mit jeder Wohnung ist zu jedem Zeitpunkt ein Mietvertrag (und indirekt eine Mieterin) sowie eine Reihe von Dienstleistungen (Heizung, Abwasser etc) verbunden. Für jede Dienstleistung gibt es einen Vertrag, dem Rechnungen zugeordnet sind. Außerdem sind in jeder Wohnung Zähler verbaut, die den Verbrauch für bestimmte Services messen. Jede Nebenkostenabrechnung enthält Abrechnungsposten, die sich jeweils auf einen Service und die zugehörige Rechnung beziehen.
Ganz so einfach ist es leider nicht, denn einige Dienstleistungen werden aufgeteilt und nach unterschiedlichen Schlüsseln abgerechnet. Entsprechend muss die Verbindung zwischen Contract
und Service
als ManyToManyField
abgebildet und für jeden Service ein Rechnungsanteil (<= 100%) ausgewiesen werden:
Erschwerend kommt hinzu, dass in einem Fall die Einheit der Zählerstände nicht identisch ist mit den in Rechnung gestellten Verbrauchseinheiten – der Warmwasserverbrauch wird über die Wassermenge (in m³) erfasst, aber über die eingesetzte Energiemenge (kWh) abgerechnet. Entsprechend muss der Rechnung eine Information über den Verbrauch hinzugefügt werden:
class Invoice(models.Model):
consumption = models.DecimalField('Leistung/Verbrauch', max_digits=10, decimal_places=3, blank=True, null=True)
counter_consumption = models.DecimalField('Verbrauch laut Zähler', max_digits=10, decimal_places=3, blank=True, null=True)
Tatsächlich ist die Handhabung bestimmter Rechnungen noch komplexer: Die gelieferte Gasmenge wird für Heizung und Warmwasserbereitung eingesetzt und muss entsprechend nach Verbrauch aufgeteilt werden, bevor der feste Schlüssel (share_of_invoice
) für die Grund- und Verbrauchsanteile der Heizkosten greift. Die Berücksichtigung dieses mehrstufigen Verfahrens würde das Datenmodell aber so sehr verkomplizieren, dass ich die Gasrechnungen im ersten Schritt manuell und als Teilrechnungen (Heizung/Warmwasser) erfasse.
Der wesentliche Teil der business logic für die Abrechnungen steckt im Service
-Modell:
class Service(models.Model):
SERVICE_TYPE_CHOICES = [
('AR', 'Wohnfläche'),
('OC', 'Personen'),
('CO', 'Verbrauch'),
]
service_type = models.CharField(max_length=2, choices=SERVICE_TYPE_CHOICES, default='AR')
MEASUREMENT_UNIT_CHOICES = [
('NA', 'n/a'),
('KW', 'kWh'),
('CB', 'm³'),
]
measurement_unit = models.CharField(max_length=2, choices=MEASUREMENT_UNIT_CHOICES, default='NA')
building = models.ForeignKey(Building, verbose_name='Gebäude', on_delete=models.CASCADE)
name = models.CharField('Bezeichnung', max_length=100)
share_of_invoice = models.DecimalField('Rechnungsanteil', max_digits=4, decimal_places=3, default=1)
Jedem service_type
entspricht ein proxy model, dem das betreffende Objekt während der Initialisierung zugeordnet wird:
def __init__(self, *args, **kwargs):
super(Service, self).__init__(*args, **kwargs)
subclasses = {
'AR' : AreaService,
'OC' : OccupancyService,
'CO' : ConsumptionService,
}
if self.pk:
self.__class__ = subclasses[self.service_type]
Die Kosten für einen AreaService
werden entsprechend der anteiligen Wohnfläche umgelegt, für einen OccupancyService
ist die Anzahl der Personen ausschlaggebend und für den ConsumptionService
der tatsächliche Verbrauch.
Jedes der drei proxy models verwendet individuelle Versionen der Manager
-Schnittstelle und der save()
-Methode, um für Konsistenz zu sorgen:
class AreaServiceManager(models.Manager):
def get_queryset(self):
return super(AreaServiceManager, self).get_queryset().filter(service_type='AR')
def create(self, **kwargs):
kwargs.update({'service_type': 'AR'})
return super(AreaServiceManager, self).create(**kwargs)
class AreaService(Service):
objects = AreaServiceManager()
def save(self, *args, **kwargs):
self.service_type = 'AR'
return super(AreaService, self).save(*args, **kwargs)
class Meta:
proxy = True
Das Modell für eine Nebenkostenabrechnung enthält nur wenige Felder, aber eine umfangreiche save()
-Methode. Balance.save()
nimmt zunächst Bezug auf alle Rechnungen im Abrechnungszeitraum, und ruft für jede Rechnung die create_balance_entries()
-Methode aller Service
-Objekte auf, die – vermittelt über einen Vertrag – mit der Rechnung verknüpft sind:
class Balance(models.Model):
tenancy_agreement = models.ForeignKey(TenancyAgreement, verbose_name='Mietvertrag', on_delete=models.CASCADE)
begin = models.DateField('Abrechnungszeitraum (Beginn)')
end = models.DateField('Abrechnungszeitraum (Ende)')
date = models.DateField('Abrechnungsdatum', auto_now_add=True)
def save(self, *args, **kwargs):
super().save(*args, **kwargs)
invoices = Invoice.objects.filter(models.Q(contract__services__building=self.tenancy_agreement.unit.building), models.Q(begin__lte=self.begin, end__gt=self.begin) | models.Q(begin__gt=self.begin, begin__lt=self.end)).distinct()
existing_entries = BalanceEntry.objects.filter(balance=self)
if invoices and not existing_entries:
entries = []
for invoice in invoices:
for service in invoice.contract.services.all():
print('Creating entry...')
entries = entries + service.create_balance_entries(invoice, self)
for service, invoice, service_share, service_total, number_of_days, share_of_invoice in entries:
print('Saving entry...')
balance_entry = BalanceEntry(balance=self, service=service, invoice=invoice, days_share=number_of_days, service_share=service_share, service_total=service_total, share_of_invoice=share_of_invoice)
balance_entry.save()
Die AreaService
-Variante von create_balance_entries()
ist am simpelsten, da sie nur die zeitliche Überlappung von Rechnung und Nebenkostenabrechnung errechnen muss:
def create_balance_entries(self, invoice, balance):
latest_start = max(invoice.begin, balance.begin)
earliest_end = min(invoice.end, balance.end)
delta = (earliest_end - latest_start).days + 1
number_of_days = max(0, delta)
return [(self, invoice, balance.tenancy_agreement.unit.area, self.building.area, number_of_days, self.share_of_invoice)]
Für einen OccupancyService
muss dagegen berücksichtigt werden, dass (theoretisch) an jedem einzelnen Tag eine andere Relation von anteiliger Belegung und Gesamtbelegung existieren kann, und entsprechend mehrere Abrechnungsposten im Abrechnungszeitraum entstehen können:
def create_balance_entries(self, invoice, balance):
latest_start = max(balance.begin, invoice.begin)
earliest_end = min(balance.end, invoice.end)
occupancy_dict = {}
current_day = latest_start
while current_day <= earliest_end:
if balance.tenancy_agreement.unit.occupancy_key(current_day) in occupancy_dict:
occupancy_dict[balance.tenancy_agreement.unit.occupancy_key(current_day)][2] += 1
else:
occupancy_dict[balance.tenancy_agreement.unit.occupancy_key(current_day)] = [
balance.tenancy_agreement.unit.occupancy_unit(current_day),
balance.tenancy_agreement.unit.occupancy_building(current_day),
1
]
current_day += datetime.timedelta(days=1)
entries = []
for occupancy_share, occupancy_values in occupancy_dict.items():
unit_occupancy = occupancy_values[0]
building_occupancy = occupancy_values[1]
number_of_days = occupancy_values[2]
entries.append((self, invoice, unit_occupancy, building_occupancy, number_of_days, self.share_of_invoice))
return entries
Ein verbrauchsbezogener ConsumptionService
schließlich muss die Zählerstände für die einzelnen Wohnungen berücksichtigen und kann die zeitliche Dimension ignorieren:
def create_balance_entries(self, invoice, balance):
counters = Counter.objects.filter(service=self, unit=balance.tenancy_agreement.unit)
consumption = 0
for counter in counters:
latest = CounterValue.objects.filter(counter=counter).latest('date')
try:
baseline = CounterValue.objects.get(counter=counter, current_baseline=True)
print(f'Adding difference between {latest.value} and {baseline.value}')
consumption += (latest.value - baseline.value)
baseline.current_baseline=False
baseline.save()
except ObjectDoesNotExist:
consumption += latest.value
latest.current_baseline=True
latest.save()
if invoice.counter_consumption:
consumption = consumption / invoice.counter_consumption * invoice.consumption
number_of_days = invoice.days
return [(self, invoice, consumption, invoice.consumption, number_of_days, self.share_of_invoice)]
Der Verbrauch wird aus der Summe der Differenzen zwischen Ausgangswert und aktuellem Zählerstand ermittelt und (ggf.) in die in Rechnung gestellte Einheit umgerechnet (m³ → kWh). Weil die aktuellen Zählerstände im Zuge der Berechnung als die neuen Ausgangswerte markiert werden, kann eine Nebenkostenabrechnung nur einmal erstellt werden. Die Berechnung des Rechnungsanteils funktioniert außerdem nur, wenn für jeden ConsumptionService
im Abrechnungszeitraum genau eine Rechnung vorliegt, auf die der Verbrauch anhand der Zählerstände bezogen werden kann.
Dieselbe Partei, die Anfang 2023 zum Gedenken an die Märzrevolution von 1848 einlädt (175 Jahre Revolution in Europa – ein guter Grund zu feiern!
), übergibt die Verarbeitung geänderter Bankverbindungen aus dem bundesweiten Online-Portal an die mitgliederpflegende Stelle (den zuständigen Landesverband), der das Mitglied pflichtschuldig über die Änderung am SEPA-Lastschriftmandat informiert.