cteward-ng/cteward_ng/mappings.py

485 lines
15 KiB
Python
Raw Normal View History

2026-06-06 10:18:15 +02:00
"""Data mapping functions.
Replaces mappings.js transforms raw DB rows into API response shapes.
Mappers:
NONE, CONTRACT, CONTRACTLIST, DEBIT, DEBITLIST, CONTRIBUTIONS,
MEMBER, MEMO, MEMBERLIST, MEMBERLIST_TO_LDAPCSV,
WITHDRAWAL, WITHDRAWALLIST
"""
2026-06-08 20:33:47 +02:00
import os.path
2026-06-06 10:18:15 +02:00
2026-06-08 20:33:47 +02:00
import memberdata
2026-06-06 10:18:15 +02:00
def none_mapper(ctx):
"""Identity mapper — returns context unchanged."""
return ctx
2026-06-08 20:33:47 +02:00
# ── Contract mappers ──────────────────────────────────────────────
def _build_contract_row(row, base_url, path_prefix):
"""Build a single contract dict from a raw DB row."""
newrow = {
'Vertragsnummer': int(row['VertragNr']),
'Vertragsart': row['ArtName'],
'Sollstellung': row['Sollstellung'],
'Betrag': row['Betrag'],
'Vertragsbeginn': memberdata.datum_parsed(row['VertragBegin']),
}
if row.get('VertragEnde'):
newrow['Vertragsende'] = memberdata.datum_parsed(row['VertragEnde'])
Verwendungszweck = []
for field in ('VerwZw1', 'VerwZw2', 'VerwZw3', 'VerwZw4'):
val = row.get(field)
if val and val != '':
Verwendungszweck.append(val)
if Verwendungszweck:
newrow['Verwendungszweck'] = Verwendungszweck
# URL is set by the caller based on whether this is a list or single item
return newrow
2026-06-06 10:18:15 +02:00
def contract_mapper(ctx):
"""Map a single contract record."""
2026-06-08 20:33:47 +02:00
data = ctx.get('data', [])
if len(data) != 1:
ctx['data'] = {}
return ctx
newrow = _build_contract_row(
data[0],
ctx['config'].get('base', ''),
ctx['request'].path,
)
newrow['url'] = ctx['config'].get('base', '') + ctx['request'].path
ctx['data'] = newrow
return ctx
2026-06-06 10:18:15 +02:00
def contractlist_mapper(ctx):
"""Map a list of contract records into paginated format."""
2026-06-08 20:33:47 +02:00
results = []
base = ctx['config'].get('base', '')
path = ctx['request'].path
for row in ctx.get('data', []):
newrow = _build_contract_row(row, base, path)
vertrag_nr = newrow['Vertragsnummer']
newrow['url'] = base + path.rstrip('*') + str(vertrag_nr)
results.append(newrow)
ctx['data'] = {
'count': len(results),
'next': None,
'prev': None,
'results': results,
}
return ctx
# ── Debit mappers ────────────────────────────────────────────────
def _build_debit_row(row, base_url, path_prefix):
"""Build a single debit dict from a raw DB row."""
newrow = {
'Vertragsnummer': int(row['VertragNr']),
'Vertrag': base_url + os.path.normpath(path_prefix + '../contract/') + str(int(row['VertragNr'])),
'Jahr': row['Jahr'],
'Monat': row['Monat'],
'Art': row['ArtName'],
'Datum': memberdata.datum_parsed(row['Datum']),
'Betrag': row.get('Betrag') or 0,
'Bezahlt': row.get('Bezahlt') or 0,
'Offen': row.get('Offen') or 0,
}
if row.get('GUID'):
newrow['url'] = base_url + path_prefix + row['GUID']
return newrow
2026-06-06 10:18:15 +02:00
def debit_mapper(ctx):
"""Map a single debit record."""
2026-06-08 20:33:47 +02:00
data = ctx.get('data', [])
if len(data) != 1:
ctx['data'] = {}
return ctx
base = ctx['config'].get('base', '')
path = ctx['request'].path
ctx['data'] = _build_debit_row(data[0], base, path)
return ctx
2026-06-06 10:18:15 +02:00
def debitlist_mapper(ctx):
"""Map a list of debit records into paginated format."""
2026-06-08 20:33:47 +02:00
results = []
base = ctx['config'].get('base', '')
path = ctx['request'].path
for row in ctx.get('data', []):
newrow = _build_debit_row(row, base, path)
if row.get('GUID'):
clean_path = path.rstrip('*')
newrow['url'] = base + clean_path + row['GUID']
results.append(newrow)
ctx['data'] = {
'count': len(results),
'next': None,
'prev': None,
'results': results,
}
return ctx
2026-06-06 10:18:15 +02:00
2026-06-08 20:33:47 +02:00
# ── Contributions mapper ─────────────────────────────────────────
2026-06-06 10:18:15 +02:00
def contributions_mapper(ctx):
"""Aggregate contributions (billed/paid/unpaid) across contracts and years."""
2026-06-08 20:33:47 +02:00
newdata = {
'contracts': [],
'years': {},
'total': {'billed': 0, 'paid': 0, 'unpaid': 0},
}
base = ctx['config'].get('base', '')
path = ctx['request'].path
for row in ctx.get('data', []):
vertrag_nr = int(row['VertragNr'])
art_name = row['ArtName']
# Find or create contract entry
old_contract = -1
for idx, c in enumerate(newdata['contracts']):
if c['Vertragsnummer'] == vertrag_nr and c['Art'] == art_name:
old_contract = idx
break
if old_contract == -1:
c = {
'Vertragsnummer': vertrag_nr,
'Vertrag': base + os.path.normpath(path + '/../contract/') + str(vertrag_nr),
'Art': art_name,
'Summen': {},
'total': {'billed': 0, 'paid': 0, 'unpaid': 0},
}
newdata['contracts'].append(c)
old_contract = len(newdata['contracts']) - 1
c = newdata['contracts'][old_contract]
jahr = str(row['Jahr'])
# Ensure year buckets exist
if jahr not in c['Summen']:
c['Summen'][jahr] = {'billed': 0, 'paid': 0, 'unpaid': 0}
if jahr not in newdata['years']:
newdata['years'][jahr] = {'billed': 0, 'paid': 0, 'unpaid': 0}
c['Summen'][jahr]['billed'] += row['Betrag']
c['total']['billed'] += row['Betrag']
newdata['years'][jahr]['billed'] += row['Betrag']
newdata['total']['billed'] += row['Betrag']
c['Summen'][jahr]['paid'] += row.get('Bezahlt', 0)
c['total']['paid'] += row.get('Bezahlt', 0)
newdata['years'][jahr]['paid'] += row.get('Bezahlt', 0)
newdata['total']['paid'] += row.get('Bezahlt', 0)
c['Summen'][jahr]['unpaid'] += row.get('Offen', 0)
c['total']['unpaid'] += row.get('Offen', 0)
newdata['years'][jahr]['unpaid'] += row.get('Offen', 0)
newdata['total']['unpaid'] += row.get('Offen', 0)
ctx['data'] = newdata
return ctx
# ── Member mappers ───────────────────────────────────────────────
# Field mapping: DB column → API key
_MEMBER_VARLIST = {
'Vorname': 'Vorname',
'Nachname': 'Nachname',
'Strasse': 'Strasse',
'PLZ': 'PLZ',
'Ort': 'Ort',
'Kurzname': 'Crewname',
'Firma4': 'Firma',
'Telefon1': 'Telefon',
'Bank1': 'Bank',
'BLZ1': 'BLZ',
'IBAN1': 'IBAN',
'BIC1': 'BIC',
'Konto1': 'Konto',
'mandatsrefenz': 'Mandatreferenz',
'Telefon3': 'Ext_Email',
'Zahlungsweise': 'Zahlungsweise',
'EinzugLastMandLiegtVor': 'Lastschriftmandat',
}
_ZAHLUNGSART_MAP = {'B': 'Bar', 'L': 'Lastschrift', 'U': 'Überweisung'}
_GESCHLECHT_MAP = {'WEIBLICH': 'F', 'MÄNNLICH': 'M'}
2026-06-06 10:18:15 +02:00
def member_mapper(ctx):
"""Map a single member record with all fields."""
2026-06-08 20:33:47 +02:00
data = ctx.get('data', [])
if len(data) != 1:
ctx['data'] = {}
return ctx
row = data[0]
newdata = {
'Eintritt': memberdata.datum_parsed(row.get('Eintritt')),
'Paten': memberdata.patenarray(row.get('Kontaktwoher', '')),
}
# Straight field mappings
for db_key, api_key in _MEMBER_VARLIST.items():
val = row.get(db_key)
if val and val != '':
newdata[api_key] = val
# Optional date fields
if row.get('Geburtsdatum'):
newdata['Geburtsdatum'] = memberdata.datum_parsed(row['Geburtsdatum'])
if row.get('Austritt'):
newdata['Austritt'] = memberdata.datum_parsed(row['Austritt'])
# Payment method
zahlungsart = row.get('Zahlungsart')
if zahlungsart and zahlungsart in _ZAHLUNGSART_MAP:
newdata['Zahlungsart'] = _ZAHLUNGSART_MAP[zahlungsart]
# Gender
betreuung = row.get('Betreuung')
if betreuung and betreuung in _GESCHLECHT_MAP:
newdata['Geschlecht'] = _GESCHLECHT_MAP[betreuung]
# Numeric IDs
if row.get('AdrNr'):
newdata['Adressnummer'] = row['AdrNr']
if row.get('MITGLNR'):
newdata['Mitgliedsnummer'] = int(row['MITGLNR'])
# Status + URLs
base = ctx['config'].get('base', '')
path = ctx['request'].path
newdata['Status'] = memberdata.realstatus(row)
newdata['url'] = base + path
newdata['Email'] = row.get('Kurzname', '') + '@c-base.org'
newdata['contracts'] = base + path + '/contract/'
newdata['debits'] = base + path + '/debit/'
newdata['withdrawals'] = base + path + '/withdrawal/'
newdata['contributions'] = base + path + '/contributions'
# Board-only memo link
flags = ctx.get('flags', [])
if '_board_' in flags:
newdata['memo'] = base + path + '/memo'
ctx['data'] = newdata
return ctx
def _try_parse_rtf(rtf_text):
"""Best-effort RTF → plain text parser.
Attempts to use an available RTF library; falls back to stripping
RTF control words if nothing is installed. This keeps the app
working even without the optional dependency.
"""
html = None
try:
from rtfparse import RtfDoc
doc = RtfDoc(rtf_text)
# rtfparse gives plain text directly
parts = []
for block in doc.blocks:
if hasattr(block, 'content'):
parts.append(str(block.content))
else:
parts.extend(str(t.text) if hasattr(t, 'text') else '' for t in block.iter_text())
return ''.join(parts), html
except ImportError:
pass
# Fallback: strip obvious RTF control words (\pard, \plain, etc.)
import re
plain = rtf_text
plain = re.sub(r'\\+[a-zA-Z]+\{[^}]*\}', ' ', plain)
plain = re.sub(r'\\+[a-zA-Z]+', ' ', plain)
plain = plain.replace('{', '').replace('}', '')
return plain, html
2026-06-06 10:18:15 +02:00
def memo_mapper(ctx):
"""Parse RTF memo and extract embedded JSON data."""
2026-06-08 20:33:47 +02:00
data = ctx.get('data', [])
if len(data) != 1:
ctx['data'] = {}
return ctx
row = data[0]
newdata = {'Date': memberdata.datum_parsed(row.get('Eintritt'))}
if row.get('Memotext') and row['Memotext'] != '':
newdata['RTF'] = row['Memotext']
if row.get('Benutzer'):
newdata['Editor'] = row['Benutzer']
# Parse RTF → plain text
if newdata.get('RTF'):
plain, html = _try_parse_rtf(newdata['RTF'])
if html:
newdata['HTML'] = html
newdata['plain'] = plain
# Extract embedded JSON between markers
mark_begin = '-----BEGIN CTEWARD DATA-----'
mark_end = '-----END CTEWARD DATA-----'
dbegin = plain.find(mark_begin)
dend = plain.find(mark_end)
if dbegin >= 0 and dend >= 0:
raw_json = plain[dbegin + len(mark_begin): dend].strip()
try:
import json as _json
newdata['data'] = raw_json
newdata['json'] = _json.loads(raw_json)
except Exception:
pass
ctx['data'] = newdata
return ctx
2026-06-06 10:18:15 +02:00
def memberlist_mapper(ctx):
"""Map a list of members into paginated format."""
2026-06-08 20:33:47 +02:00
results = []
base = ctx['config'].get('base', '')
path = ctx['request'].path
for row in ctx.get('data', []):
newrow = {
'Crewname': row.get('Kurzname'),
'Adressnummer': row.get('AdrNr'),
'Status': memberdata.realstatus(row),
'Eintritt': memberdata.datum_parsed(row.get('Eintritt')),
'Paten': memberdata.patenarray(row.get('Kontaktwoher', '')),
}
if row.get('Nachname'):
newrow['Nachname'] = row['Nachname']
if row.get('Vorname'):
newrow['Vorname'] = row['Vorname']
kurzname = row.get('Kurzname') or ''
if kurzname and kurzname[0] != 'X':
newrow['url'] = base + path.rstrip('*') + kurzname
newrow['Email'] = kurzname + '@c-base.org'
if row.get('Austritt'):
newrow['Austritt'] = memberdata.datum_parsed(row['Austritt'])
if row.get('Telefon3') and row['Telefon3'] != '':
newrow['Ext_Email'] = row['Telefon3']
results.append(newrow)
ctx['data'] = {
'count': len(results),
'next': None,
'prev': None,
'results': results,
}
return ctx
2026-06-06 10:18:15 +02:00
def memberlist_to_ldapcsv_mapper(ctx):
"""Map member list to LDAP CSV export format."""
2026-06-08 20:33:47 +02:00
results = []
for row in ctx.get('data', []):
newrow = {
'Nachname': row.get('Nachname'),
'Vorname': row.get('Vorname'),
'Crewname': row.get('Kurzname'),
'Status': memberdata.realstatus(row),
'externe E-Mail': row.get('Telefon3'),
'Eintritt': memberdata.datum_parsed(row.get('Eintritt')),
'Paten': memberdata.cleanpaten(row.get('Kontaktwoher', '')),
'Weiteres': '',
}
results.append(newrow)
ctx['data'] = results
return ctx
# ── Withdrawal mappers ───────────────────────────────────────────
def _build_withdrawal_row(row, base_url, path_prefix):
"""Build a single withdrawal dict from a raw DB row."""
newrow = {
'Vertragsnummer': int(row['VertragNr']),
'Vertrag': base_url + os.path.normpath(path_prefix + '../contract/') + str(int(row['VertragNr'])),
'BLZ': row.get('Adr_BLZ') or '',
'Konto': row.get('Adr_Konto') or '',
'IBAN': row.get('IBAN') or '',
'BIC': row.get('BIC') or '',
'Mandat': row.get('MandatsNr') or '',
'Betrag': row.get('Betrag_EU', 0) or 0,
'Jahr': row['Jahr'],
'Monat': row['Zeitraum'],
'Datum': memberdata.datum_parsed(row.get('ErstDatum')),
}
Verwendungszweck = []
for field in ('Zweck_1', 'Zweck_2'):
val = row.get(field)
if val and val != '':
Verwendungszweck.append(val)
if Verwendungszweck:
newrow['Verwendungszweck'] = Verwendungszweck
if row.get('GUID'):
newrow['url'] = base_url + path_prefix + row['GUID']
return newrow
2026-06-06 10:18:15 +02:00
def withdrawal_mapper(ctx):
"""Map a single withdrawal record."""
2026-06-08 20:33:47 +02:00
data = ctx.get('data', [])
if len(data) != 1:
ctx['data'] = {}
return ctx
base = ctx['config'].get('base', '')
path = ctx['request'].path
ctx['data'] = _build_withdrawal_row(data[0], base, path)
return ctx
2026-06-06 10:18:15 +02:00
def withdrawallist_mapper(ctx):
"""Map a list of withdrawal records into paginated format."""
2026-06-08 20:33:47 +02:00
results = []
base = ctx['config'].get('base', '')
path = ctx['request'].path
for row in ctx.get('data', []):
newrow = _build_withdrawal_row(row, base, path)
if row.get('GUID'):
clean_path = path.rstrip('*')
newrow['url'] = base + clean_path + row['GUID']
results.append(newrow)
ctx['data'] = {
'count': len(results),
'next': None,
'prev': None,
'results': results,
}
return ctx