"""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 """ import os.path import memberdata def none_mapper(ctx): """Identity mapper — returns context unchanged.""" return ctx # ── 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 def contract_mapper(ctx): """Map a single contract record.""" 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 def contractlist_mapper(ctx): """Map a list of contract records into paginated format.""" 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 def debit_mapper(ctx): """Map a single debit record.""" 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 def debitlist_mapper(ctx): """Map a list of debit records into paginated format.""" 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 # ── Contributions mapper ───────────────────────────────────────── def contributions_mapper(ctx): """Aggregate contributions (billed/paid/unpaid) across contracts and years.""" 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'} def member_mapper(ctx): """Map a single member record with all fields.""" 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 def memo_mapper(ctx): """Parse RTF memo and extract embedded JSON data.""" 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 def memberlist_mapper(ctx): """Map a list of members into paginated format.""" 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 def memberlist_to_ldapcsv_mapper(ctx): """Map member list to LDAP CSV export format.""" 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 def withdrawal_mapper(ctx): """Map a single withdrawal record.""" 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 def withdrawallist_mapper(ctx): """Map a list of withdrawal records into paginated format.""" 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