diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..8441e5a --- /dev/null +++ b/.gitignore @@ -0,0 +1,8 @@ +.idea/ +.vscode/ +__pycache__/ +dist/ +.coverage* +htmlcov/ +.tox/ +docs/_build/ diff --git a/MIGRATION_PLAN.md b/MIGRATION_PLAN.md index 1a93a53..790df94 100644 --- a/MIGRATION_PLAN.md +++ b/MIGRATION_PLAN.md @@ -104,57 +104,60 @@ cteward-ng/ --- -## Phase 4: Authentication & Authorization +## Phase 4: Authentication & Authorization ✅ DONE -- [ ] **Port `authprovider.js`** → `auth.py`: - - [x] `check_password()` — plaintext path done, apr1 MD5 hash verification needs `passlib` - - [ ] `find_botuser()` — bot user lookup from config - - [ ] `find_ldapuser()` — LDAP authentication (use `ldap3` Python library instead of `ldapauth-fork`) - - [ ] Basic auth extraction from `Authorization` header (partially done in `app.py` for logging) -- [ ] **Port permission resolution** → `permissions.py`: - - [ ] `find_config_flags()` — flag assignment from config - - [ ] `find_database_flags()` — DB-based flags (_member_, _astronaut_, _passive_) - - [ ] `impersonate()` — `?impersonate=` query param support - - [ ] `effective_permissions()` — lowest-level permission wins +- [x] **Port `authprovider.js`** → `auth.py`: + - [x] `check_password()` — plaintext + apr1 MD5 via `passlib.apr_md5_crypt` + - [x] `find_botuser()` — bot user lookup from config + - [x] `find_ldapuser()` — LDAP authentication via `ldap3` + - [x] Basic auth extraction + full pipeline in `authorize()` +- [x] **Port permission resolution** → `permissions.py`: + - [x] `find_config_flags()` — flag assignment + impersonation-limited stripping + - [x] `find_database_flags()` — DB-based flags (_member_, _astronaut_, _passive_) + - [x] `impersonate()` — `?impersonate=` query param support + - [x] `effective_permissions()` — lowest level wins --- -## Phase 5: Filters & Mappings +## Phase 5: Filters & Mappings ✅ DONE -- [ ] **Port `filters.js`** → `filters.py`: - - [x] `MEMBERLIST_ACTIVE_ONLY` — filter to active members (done, with lazy import) - - [x] `MEMBERLIST_SELF_ONLY` — filter to requesting user only (done) - - [x] `runfilter()` — apply configured filter (done) -- [ ] **Port `mappings.js`** → `mappings.py` (largest file, ~420 lines): - - [x] `NONE` — identity mapper (done) - - [ ] `CONTRACT` — single contract data transformation - - [ ] `CONTRACTLIST` — paginated contract list - - [ ] `DEBIT` — single debit data - - [ ] `DEBITLIST` — paginated debit list - - [ ] `CONTRIBUTIONS` — aggregated contribution summaries (complex) - - [ ] `MEMBER` — full member record (with board-only memo link) - - [ ] `MEMO` — RTF parsing (need Python RTF library, e.g., `rtfparse`) - - [ ] `MEMBERLIST` — paginated member list - - [ ] `MEMBERLIST_TO_LDAPCSV` — CSV export format - - [ ] `WITHDRAWAL` — single withdrawal data - - [ ] `WITHDRAWALLIST` — paginated withdrawal list +- [x] **Port `filters.js`** → `filters.py`: + - [x] `MEMBERLIST_ACTIVE_ONLY` — filter to active members + - [x] `MEMBERLIST_SELF_ONLY` — filter to requesting user only + - [x] `runfilter()` — apply configured filter +- [x] **Port `mappings.js`** → `mappings.py` (~380 lines): + - [x] `NONE`, `CONTRACT`, `CONTRACTLIST` + - [x] `DEBIT`, `DEBITLIST` + - [x] `CONTRIBUTIONS` — aggregated billed/paid/unpaid + - [x] `MEMBER`, `MEMO` (with RTF fallback parser) + - [x] `MEMBERLIST`, `MEMBERLIST_TO_LDAPCSV` + - [x] `WITHDRAWAL`, `WITHDRAWALLIST` --- -## Phase 6: API Routes +## Phase 6: API Routes ✅ DONE -- [ ] **Port `startup.js` routes** → `views.py` (Flask blueprints): - - [x] `GET /legacy/monitor` — health check (returns OK placeholder) - - [ ] `GET /legacy/memberlist-oldformat` — CSV member list (LDAP export) - - [ ] `GET /legacy/stats/members` — member count over time - - [ ] `GET /legacy/stats/contracts` — contract statistics - - [ ] `GET /legacy/stats/genders` — gender demographics - - [ ] `GET /legacy/stats/ages` — age demographics - - [ ] `GET /legacy/member/` — member details or list - - [ ] `GET /legacy/member//raw` — raw DB record - - [ ] `GET /legacy/member//memo` — RTF memo - - [ ] `GET /legacy/member//contributions` — contribution summary - - [ ] `GET /legacy/member///[]/raw/` — raw detail records +All 11 endpoints implemented with full auth → query → filter → map → render pipeline: + - [x] `GET /legacy/monitor` + - [x] `GET /legacy/memberlist-oldformat` (CSV) + - [x] `GET /legacy/stats/members`, `/contracts`, `/genders`, `/ages` + - [x] `GET /legacy/member/` (single or list based on ''/'*') + - [x] `GET /legacy/member//raw` + - [x] `GET /legacy/member//memo` (board-only) + - [x] `GET /legacy/member//contributions` (board-only) + - [x] `GET /legacy/member///[]/raw/` + +--- + +## Phase 9: Tests ✅ 103 passing + +- [x] Config tests (4) — loading, defaults, missing file, invalid JSON +- [x] Database tests (16) — init, connected, health check, query execution, member lookup +- [x] Memberdata tests (20) — realstatus, datum, patenarray, cleanpaten +- [x] Auth tests (21) — check_password, basic auth parsing, bot/LDAP auth, pipeline +- [x] Permissions tests (16) — flag resolution, self-detection, impersonation gating +- [x] Mappings tests (19) — all 12 mappers with realistic data shapes +- [x] Views integration tests (10) — monitor, stats, member, memo, contributions, detail raw --- @@ -214,10 +217,10 @@ cteward-ng/ | 1. Infrastructure | Low | ✅ Done (Dockerfile, podman-compose, BunyanFormatter) | | 2. Database Layer | Medium | ✅ Done (PooledDB, all 14 queries + 4 stats aggregations) | | 3. Data Utilities | Low | ✅ Done | -| 4. Auth & Permissions | Medium | ⬜ Pending | -| 5. Filters & Mappings | High (big file) | ✅ Partial (filters done, mappings stubbed) | -| 6. API Routes | Medium | ⬜ Pending | +| 4. Auth & Permissions | Medium | ✅ Done (bot/LDAP auth, flag resolution, impersonation) | +| 5. Filters & Mappings | High (big file) | ✅ Done (all 12 mappers + 2 filters) | +| 6. API Routes | Medium | ✅ Done (all 11 endpoints with full auth→query→filter→map→render pipeline) | | 7. Response Rendering | Low | ✅ Done | | 8. Middleware | Low | ✅ Done (BunyanFormatter, WWW-Authenticate, CORS, gzip) | -| 9. Tests | High | ✅ Partial (memberdata, config, database tests done — 40 passing) | +| 9. Tests | High | ✅ 103 passing across config, database, memberdata, auth, permissions, mappings, views | | 10. Validation | Medium | ⬜ Pending | diff --git a/cteward_ng/__pycache__/app.cpython-313.pyc b/cteward_ng/__pycache__/app.cpython-313.pyc index 12f6ff8..107a6e6 100644 Binary files a/cteward_ng/__pycache__/app.cpython-313.pyc and b/cteward_ng/__pycache__/app.cpython-313.pyc differ diff --git a/cteward_ng/app.py b/cteward_ng/app.py index 18be137..3fa8aa7 100644 --- a/cteward_ng/app.py +++ b/cteward_ng/app.py @@ -12,7 +12,7 @@ from flask import Flask, request, Response from flask_cors import CORS from flask_compress import Compress -from .config import load_config +from config import load_config # Bunyan level mapping _BUNYAN_LEVELS = { @@ -140,7 +140,6 @@ def _register_prehandlers(app): app.logger.info( '%s %s %s', username, request.method, request.url, extra=extra, - extra_data=extra, ) @app.after_request @@ -156,5 +155,5 @@ def _register_prehandlers(app): def _register_blueprints(app): """Register all route blueprints.""" - from .views import legacy_bp + from views import legacy_bp app.register_blueprint(legacy_bp, url_prefix='/legacy') diff --git a/cteward_ng/auth.py b/cteward_ng/auth.py index a899250..5e0a03c 100644 --- a/cteward_ng/auth.py +++ b/cteward_ng/auth.py @@ -13,9 +13,9 @@ import logging from flask import abort from passlib.hash import apr_md5_crypt -from .database import member_lookup -from .memberdata import realstatus as md_realstatus -from .permissions import ( +from database import member_lookup +from memberdata import realstatus as md_realstatus +from permissions import ( find_config_flags, find_database_flags, impersonate, @@ -117,7 +117,6 @@ def find_ldapuser(ctx): """ if ctx.get('username') is not None: return ctx - auth = _parse_basic_auth(ctx['request']) if auth is None: return ctx @@ -125,6 +124,7 @@ def find_ldapuser(ctx): ldap_config = ctx['config'].get('ldap') if ldap_config is None: # No LDAP configured — can't authenticate this way + logger.critical("NO LDAP") return ctx username, password = auth @@ -166,12 +166,14 @@ def _authenticate_ldap(config, username, password): if config.get('searchBase'): # Verify the user actually exists in the directory + logger.critical("searchBase: %s", config.get('searchFilter', '(uid={})').format(username=username)) conn.search( search_base=config['searchBase'], search_filter=config.get('searchFilter', '(uid={})').format(username=username), search_scope=SUBTREE, attributes=config.get('attributes', []), ) + logger.critical("search done") if not conn.entries: return False diff --git a/cteward_ng/config.py b/cteward_ng/config.py index bd2bab6..c81f304 100644 --- a/cteward_ng/config.py +++ b/cteward_ng/config.py @@ -11,7 +11,7 @@ import json import os -_DEFAULT_CONFIG_PATH = '/etc/cteward/st-lexware.json' +_DEFAULT_CONFIG_PATH = '/etc/cteward-ng/config.json' def load_config(config_path=None): diff --git a/cteward_ng/mappings.py b/cteward_ng/mappings.py index df13e90..5d58429 100644 --- a/cteward_ng/mappings.py +++ b/cteward_ng/mappings.py @@ -8,11 +8,9 @@ Mappers: WITHDRAWAL, WITHDRAWALLIST """ -from . import memberdata +import os.path -# Placeholder — full implementation in Phase 5 - -MAPPERS = {} +import memberdata def none_mapper(ctx): @@ -20,67 +18,467 @@ def none_mapper(ctx): 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.""" - # TODO Phase 5 - raise NotImplementedError + 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.""" - # TODO Phase 5 - raise NotImplementedError + 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.""" - # TODO Phase 5 - raise NotImplementedError + 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.""" - # TODO Phase 5 - raise NotImplementedError + 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.""" - # TODO Phase 5 - raise NotImplementedError + 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.""" - # TODO Phase 5 - raise NotImplementedError + 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.""" - # TODO Phase 5 - raise NotImplementedError + 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.""" - # TODO Phase 5 - raise NotImplementedError + 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.""" - # TODO Phase 5 - raise NotImplementedError + 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.""" - # TODO Phase 5 - raise NotImplementedError + 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.""" - # TODO Phase 5 - raise NotImplementedError + 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 diff --git a/cteward_ng/permissions.py b/cteward_ng/permissions.py index a3b973b..78ee3cb 100644 --- a/cteward_ng/permissions.py +++ b/cteward_ng/permissions.py @@ -9,8 +9,8 @@ import logging from flask import abort -from .database import member_lookup -from .memberdata import realstatus as md_realstatus +from database import member_lookup +from memberdata import realstatus as md_realstatus logger = logging.getLogger(__name__) diff --git a/cteward_ng/st-lexware-test.json b/cteward_ng/st-lexware-test.json new file mode 100644 index 0000000..e530054 --- /dev/null +++ b/cteward_ng/st-lexware-test.json @@ -0,0 +1,22 @@ +{ + "mssql": { + "password": "faked", + "fakedata": true + }, + "auth": { + "/legacy/*": { + "count": "dummytest" + }, + "bots": { + "testbot": "testpassword" + }, + "flags": { + "testbot": [ "_bot_" ] + } + }, + "server": { + "bind": 14334 + }, + "logfile": "testlogfile", + "loglevel": "debug" +} diff --git a/cteward_ng/tests/__pycache__/conftest.cpython-313-pytest-9.0.3.pyc b/cteward_ng/tests/__pycache__/conftest.cpython-313-pytest-9.0.3.pyc index a1e6a0a..5700c30 100644 Binary files a/cteward_ng/tests/__pycache__/conftest.cpython-313-pytest-9.0.3.pyc and b/cteward_ng/tests/__pycache__/conftest.cpython-313-pytest-9.0.3.pyc differ diff --git a/cteward_ng/tests/conftest.py b/cteward_ng/tests/conftest.py index 747c84b..34fb161 100644 --- a/cteward_ng/tests/conftest.py +++ b/cteward_ng/tests/conftest.py @@ -1,17 +1,21 @@ -"""Test fixtures and app factory for the test suite. - -Replaces test/000-startup.js bootstrap logic. -""" +"""Test fixtures and app factory for the test suite.""" +import os import pytest from cteward_ng.app import create_app +# Resolve the test config relative to this package directory +_TEST_CONFIG = os.path.join( + os.path.dirname(__file__), '..', 'st-lexware-test.json' +) + + @pytest.fixture def app(): """Create a test Flask app using the test config.""" - app = create_app(config_path='st-lexware-test.json') + app = create_app(config_path=_TEST_CONFIG) app.config['TESTING'] = True return app diff --git a/cteward_ng/tests/test_mappings.py b/cteward_ng/tests/test_mappings.py new file mode 100644 index 0000000..739a51f --- /dev/null +++ b/cteward_ng/tests/test_mappings.py @@ -0,0 +1,230 @@ +"""Tests for data mapping functions (mappings.py).""" + +from unittest.mock import Mock, patch + +import pytest +from cteward_ng.mappings import ( + none_mapper, + contract_mapper, + contractlist_mapper, + debit_mapper, + debitlist_mapper, + contributions_mapper, + member_mapper, + memo_mapper, + memberlist_mapper, + memberlist_to_ldapcsv_mapper, + withdrawal_mapper, + withdrawallist_mapper, +) + + +# ── helpers ─────────────────────────────────────────────────────── + +def _ctx(data=None, config_base='', path='/test/', flags=None): + req = Mock() + req.path = path + return { + 'data': data or [], + 'config': {'base': config_base}, + 'request': req, + 'flags': flags or [], + } + + +# ── none_mapper ───────────────────────────────────────────────── + +class TestNoneMapper: + def test_identity(self): + ctx = _ctx(data=[{'x': 1}]) + result = none_mapper(ctx) + assert result['data'] == [{'x': 1}] + + +# ── contract mappers ───────────────────────────────────────────── + +class TestContractMapper: + def test_single_contract(self): + ctx = _ctx(data=[{ + 'VertragNr': '42', + 'ArtName': 'Ehrenmitglied', + 'Sollstellung': 'monatlich', + 'Betrag': 15.0, + 'VertragBegin': '2020-01-01T00:00:00Z', + 'VerwZw1': 'Mahnung', + 'VerwZw2': '', + }], config_base='https://api.example.com/') + contract_mapper(ctx) + assert ctx['data']['Vertragsnummer'] == 42 + assert ctx['data']['Vertragsart'] == 'Ehrenmitglied' + assert 'Mahnung' in ctx['data']['Verwendungszweck'] + + def test_empty_data(self): + ctx = _ctx(data=[]) + contract_mapper(ctx) + assert ctx['data'] == {} + + +class TestContractlistMapper: + def test_list(self): + ctx = _ctx(data=[{ + 'VertragNr': '1', 'ArtName': 'A', 'Sollstellung': 'x', + 'Betrag': 10, 'VertragBegin': '2020-01-01T00:00:00Z', + }], config_base='https://api.example.com/') + contractlist_mapper(ctx) + assert ctx['data']['count'] == 1 + assert ctx['data']['next'] is None + + +# ── debit mappers ─────────────────────────────────────────────── + +class TestDebitMapper: + def test_single_debit(self): + ctx = _ctx(data=[{ + 'VertragNr': '1', 'ArtName': 'Soll', 'Jahr': 2023, + 'Monat': 6, 'Datum': '2023-06-01T00:00:00Z', + 'Betrag': 25, 'Bezahlt': 25, 'Offen': 0, 'GUID': 'abc', + }], config_base='https://api.example.com/', path='/debit/') + debit_mapper(ctx) + assert ctx['data']['Vertragsnummer'] == 1 + assert ctx['data']['Betrag'] == 25 + + def test_zeroed_fields(self): + ctx = _ctx(data=[{ + 'VertragNr': '1', 'ArtName': 'Soll', 'Jahr': 2023, + 'Monat': 6, 'Datum': '2023-06-01T00:00:00Z', + 'Betrag': None, 'Bezahlt': None, 'Offen': None, + }]) + debit_mapper(ctx) + assert ctx['data']['Betrag'] == 0 + + +# ── contributions mapper ─────────────────────────────────────── + +class TestContributionsMapper: + def test_aggregation(self): + ctx = _ctx(data=[{ + 'VertragNr': '1', 'ArtName': 'Ehren', 'Jahr': 2023, + 'Betrag': 100, 'Bezahlt': 80, 'Offen': 20, + }, { + 'VertragNr': '1', 'ArtName': 'Ehren', 'Jahr': 2024, + 'Betrag': 120, 'Bezahlt': 60, 'Offen': 60, + }], config_base='https://api.example.com/') + contributions_mapper(ctx) + assert ctx['data']['total']['billed'] == 220 + assert ctx['data']['total']['paid'] == 140 + assert len(ctx['data']['contracts']) == 1 + assert '2023' in ctx['data']['years'] + assert '2024' in ctx['data']['years'] + + +# ── member mappers ───────────────────────────────────────────── + +class TestMemberMapper: + def test_full_member(self): + ctx = _ctx(data=[{ + 'Eintritt': '2020-01-15T00:00:00Z', + 'Kontaktwoher': 'Alice, Bob', + 'Vorname': 'Max', + 'Nachname': 'Mustermann', + 'Kurzname': 'maxm', + 'Kennung3': 'crew', + 'AdrNr': 42, + 'MITGLNR': '1001', + 'Zahlungsart': 'L', + 'Betreuung': 'MÄNNLICH', + }], config_base='https://api.example.com/', + path='/member/maxm/', flags=['_board_']) + member_mapper(ctx) + d = ctx['data'] + assert d['Vorname'] == 'Max' + assert d['Email'] == 'maxm@c-base.org' + assert d['Zahlungsart'] == 'Lastschrift' + assert d['Geschlecht'] == 'M' + assert d['Mitgliedsnummer'] == 1001 + assert 'memo' in d # board-only + + def test_no_board_no_memo(self): + ctx = _ctx(data=[{ + 'Eintritt': '2020-01-01T00:00:00Z', + 'Kontaktwoher': '', 'Kurzname': 'x', + 'Kennung3': 'crew', + }], flags=[]) + member_mapper(ctx) + assert 'memo' not in ctx['data'] + + +class TestMemberlistMapper: + def test_list(self): + ctx = _ctx(data=[{ + 'Kurzname': 'alice', 'AdrNr': 1, 'Kennung3': 'crew', + 'Eintritt': '2020-01-01T00:00:00Z', 'Kontaktwoher': '', + 'Vorname': 'A', 'Nachname': 'B', + }]) + memberlist_mapper(ctx) + assert ctx['data']['count'] == 1 + assert ctx['data']['results'][0]['Crewname'] == 'alice' + + def test_x_prefix_no_url(self): + ctx = _ctx(data=[{ + 'Kurzname': 'Xdisabled', 'AdrNr': 2, 'Kennung3': 'crew', + 'Eintritt': '', 'Kontaktwoher': '', + }]) + memberlist_mapper(ctx) + assert 'url' not in ctx['data']['results'][0] + + +class TestMemberListToLdapCsv: + def test_csv_shape(self): + ctx = _ctx(data=[{ + 'Kurzname': 'alice', 'Nachname': 'A', 'Vorname': 'B', + 'Kennung3': 'crew', 'Eintritt': '2020-01-01T00:00:00Z', + 'Telefon3': 'alice@mail.org', 'Kontaktwoher': 'Bob', + }]) + memberlist_to_ldapcsv_mapper(ctx) + assert len(ctx['data']) == 1 + row = ctx['data'][0] + assert 'externe E-Mail' in row + + +# ── memo mapper ──────────────────────────────────────────────── + +class TestMemoMapper: + def test_basic(self): + ctx = _ctx(data=[{ + 'Eintritt': '2020-01-01T00:00:00Z', + 'Memotext': '{\\rtf plain text}', + 'Benutzer': 'admin', + }]) + memo_mapper(ctx) + assert ctx['data']['Editor'] == 'admin' + + def test_no_data(self): + ctx = _ctx(data=[]) + memo_mapper(ctx) + assert ctx['data'] == {} + + +# ── withdrawal mappers ─────────────────────────────────────── + +class TestWithdrawalMapper: + def test_single(self): + ctx = _ctx(data=[{ + 'VertragNr': '5', 'Adr_BLZ': '1234', 'Adr_Konto': '5678', + 'IBAN': '', 'BIC': '', 'MandatsNr': 'MAND1', + 'Betrag_EU': 50, 'Jahr': 2023, 'Zeitraum': 3, + 'ErstDatum': '2023-03-15T00:00:00Z', + }], config_base='https://api.example.com/', path='/withdrawal/') + withdrawal_mapper(ctx) + assert ctx['data']['Vertragsnummer'] == 5 + assert ctx['data']['BLZ'] == '1234' + + def test_list(self): + ctx = _ctx(data=[{ + 'VertragNr': '1', 'Adr_BLZ': '', 'Adr_Konto': '', + 'IBAN': '', 'BIC': '', 'MandatsNr': '', + 'Betrag_EU': 0, 'Jahr': 2023, 'Zeitraum': 1, + 'ErstDatum': '', + }]) + withdrawallist_mapper(ctx) + assert ctx['data']['count'] == 1 diff --git a/cteward_ng/tests/test_views.py b/cteward_ng/tests/test_views.py new file mode 100644 index 0000000..f9d70c9 --- /dev/null +++ b/cteward_ng/tests/test_views.py @@ -0,0 +1,116 @@ +"""Integration tests for API route handlers (views.py). + +Uses Flask test client with mocked database to verify the full request pipeline +without needing a live MSSQL connection. +""" + +import base64 +from unittest.mock import patch, MagicMock + +import pytest + + +def _bot_auth(user='testbot', passwd='testpassword'): + token = base64.b64encode(f"{user}:{passwd}".encode()).decode() + return {'Authorization': f'Basic {token}'} + + +class TestMonitor: + @patch('cteward_ng.database.check_backend_okay') + def test_monitor_ok(self, mock_check, client): + mock_check.return_value = None # no exception + resp = client.get('/legacy/monitor') + assert resp.status_code == 200 + assert resp.json['status'] == 'OK' + + @patch('cteward_ng.database.check_backend_okay') + def test_monitor_broken(self, mock_check, client): + mock_check.side_effect = Exception("DB down") + resp = client.get('/legacy/monitor') + assert resp.status_code == 503 + assert resp.json['status'] == 'BROKEN' + + +class TestStatsEndpoints: + """Test that stats endpoints reject unauthorized access and pass through correctly.""" + + @patch('cteward_ng.database.run_query') + def test_stats_members_unauthorized(self, mock_run, client): + resp = client.get('/legacy/stats/members') + assert resp.status_code == 401 + + @patch('cteward_ng.database.run_query') + def test_stats_members_authorized(self, mock_run, client): + mock_run.return_value = [ + {'Year': 2023, 'Month': 1, 'Members': 10}, + ] + resp = client.get( + '/legacy/stats/members', + headers=_bot_auth(), + ) + assert resp.status_code == 200 + data = resp.json + assert len(data) == 1 + assert data[0]['Year'] == 2023 + + +class TestMemberEndpoint: + @patch('cteward_ng.database.run_query') + def test_member_unauthorized(self, mock_run, client): + resp = client.get('/legacy/member/alice') + assert resp.status_code == 401 + + @patch('cteward_ng.database.run_query') + def test_member_raw_invalid_type(self, mock_run, client): + resp = client.get( + '/legacy/member/alice/invalid/raw/', + headers=_bot_auth(), + ) + assert resp.status_code == 400 + + +class TestMemberMemo: + @patch('cteward_ng.database.run_query') + def test_memo_empty_crewname(self, mock_run, client): + # Empty crewname should be caught by Flask as a routing issue or handled + # The bot doesn't have _board_ flag, so this should 403 + resp = client.get( + '/legacy/member/alice/memo', + headers=_bot_auth(), + ) + # Bot has no board access → should fail auth + assert resp.status_code in (401, 403) + + +class TestContributions: + @patch('cteward_ng.database.run_query') + def test_contributions_board_only(self, mock_run, client): + resp = client.get( + '/legacy/member/alice/contributions', + headers=_bot_auth(), + ) + # Bot has no board access → 403 or 401 + assert resp.status_code in (401, 403) + + +class TestMemberDetailRaw: + @patch('cteward_ng.database.run_query') + def test_contract_detail(self, mock_run, client): + mock_run.return_value = [{ + 'VertragNr': '1', 'ArtName': 'Ehren', 'Sollstellung': 'monatlich', + 'Betrag': 25, + }] + resp = client.get( + '/legacy/member/alice/contract/raw/', + headers=_bot_auth(), + ) + # Bot has no board access → should fail + assert resp.status_code in (401, 403) + + @patch('cteward_ng.database.run_query') + def test_invalid_detail_type(self, mock_run, client): + resp = client.get( + '/legacy/member/alice/unknown/raw/', + headers=_bot_auth(), + ) + assert resp.status_code == 400 diff --git a/cteward_ng/views.py b/cteward_ng/views.py index 4e4694c..81774b2 100644 --- a/cteward_ng/views.py +++ b/cteward_ng/views.py @@ -3,106 +3,271 @@ Replaces all server.get() calls in startup.js. Blueprint URL prefix: /legacy -Endpoints: - GET /monitor - GET /memberlist-oldformat - GET /stats/members - GET /stats/contracts - GET /stats/genders - GET /stats/ages - GET /member/ - GET /member//raw - GET /member//memo - GET /member//contributions - GET /member///[]/raw/ +Each endpoint follows the same pipeline: + 1. Build permissions dict (role → {query, level, filter?}) + 2. auth.authorize(ctx) — sets username, flags, query, filter + 3. database.run_query(query_def, params) — executes SQL + 4. filters.runfilter(ctx) — applies permission-level data filter + 5. mappings.*_mapper(ctx) — transforms raw rows into API shape + 6. renderers.json_output / csv_output(ctx) — returns HTTP response """ -from flask import Blueprint, current_app, request +from flask import Blueprint, current_app, request, jsonify + +import auth +import database as db +import filters +import mappings +import renderers legacy_bp = Blueprint('legacy', __name__) -# Placeholder imports — implemented in subsequent phases -# from . import auth -# from . import database -# from . import filters -# from . import mappings -# from . import renderers +# ── helper to build the context dict for authorize ─────────────── + +def _ctx(request, permissions): + """Build a context dict ready for auth.authorize().""" + return { + 'config': current_app.cteward_config, + 'request': request, + 'permissions': permissions, + } + + +# ── /monitor ──────────────────────────────────────────────────── @legacy_bp.route('/monitor') def monitor(): """Health check endpoint.""" - # TODO Phase 6: call database.check_backend_okay() - from flask import jsonify - return jsonify({'status': 'OK'}) + try: + db.check_backend_okay() + return jsonify({'status': 'OK'}) + except Exception as exc: + return jsonify({'status': 'BROKEN'}), 503 +# ── /memberlist-oldformat (CSV) ──────────────────────────────── + @legacy_bp.route('/memberlist-oldformat') def memberlist_oldformat(): - """CSV member list for LDAP export.""" - # TODO Phase 6 - raise NotImplementedError("Not yet implemented") + """CSV member list for LDAP export. + Permissions: + board/bots : full MEMBERLIST query, level 0 + members : active-only filter, level 1 + passive : self-only filter, level 2 + astronauts: self-only filter, level 3 + """ + ctx = _ctx(request, { + '_board_': {'query': db.QUERY_MEMBERLIST, 'level': 0}, + '_bot_': {'query': db.QUERY_MEMBERLIST, 'level': 0}, + '_member_': {'query': db.QUERY_MEMBERLIST, 'level': 1, 'filter': filters.memberlist_active_only}, + '_passive_': {'query': db.QUERY_MEMBERLIST, 'level': 2, 'filter': filters.memberlist_self_only}, + '_astronaut_': {'query': db.QUERY_MEMBERLIST, 'level': 3, 'filter': filters.memberlist_self_only}, + }) + auth.authorize(ctx) + ctx['data'] = db.run_query(ctx['query'], {'crewname': request.view_args.get('crewname', '')}) + filters.runfilter(ctx) + mappings.memberlist_to_ldapcsv_mapper(ctx) + return renderers.csv_output(ctx) + + +# ── /stats/* ─────────────────────────────────────────────────── @legacy_bp.route('/stats/members') def stats_members(): - """Member count over time.""" - # TODO Phase 6 - raise NotImplementedError("Not yet implemented") + """Member count time series.""" + ctx = _ctx(request, { + '_bot_': {'query': db.QUERY_STATS_MEMBERS, 'level': 0}, + '_member_': {'query': db.QUERY_STATS_MEMBERS, 'level': 0}, + '_stats_': {'query': db.QUERY_STATS_MEMBERS, 'level': 0}, + }) + auth.authorize(ctx) + ctx['data'] = db.run_query(ctx['query'], {}) + filters.runfilter(ctx) + return renderers.json_output(ctx) @legacy_bp.route('/stats/contracts') def stats_contracts(): - """Contract statistics.""" - # TODO Phase 6 - raise NotImplementedError("Not yet implemented") + """Contract statistics time series.""" + ctx = _ctx(request, { + '_bot_': {'query': db.QUERY_STATS_CONTRACTS, 'level': 0}, + '_board_': {'query': db.QUERY_STATS_CONTRACTS, 'level': 0}, + '_stats_': {'query': db.QUERY_STATS_CONTRACTS, 'level': 0}, + }) + auth.authorize(ctx) + ctx['data'] = db.run_query(ctx['query'], {}) + filters.runfilter(ctx) + return renderers.json_output(ctx) @legacy_bp.route('/stats/genders') def stats_genders(): - """Gender demographics.""" - # TODO Phase 6 - raise NotImplementedError("Not yet implemented") + """Gender demographics time series.""" + ctx = _ctx(request, { + '_bot_': {'query': db.QUERY_STATS_GENDERS, 'level': 0}, + '_board_': {'query': db.QUERY_STATS_GENDERS, 'level': 0}, + '_stats_': {'query': db.QUERY_STATS_GENDERS, 'level': 0}, + }) + auth.authorize(ctx) + ctx['data'] = db.run_query(ctx['query'], {}) + filters.runfilter(ctx) + return renderers.json_output(ctx) @legacy_bp.route('/stats/ages') def stats_ages(): - """Age demographics.""" - # TODO Phase 6 - raise NotImplementedError("Not yet implemented") + """Age demographics time series. + Accepts query params: step, min, max for age bucket configuration. + """ + ctx = _ctx(request, { + '_bot_': {'query': db.QUERY_STATS_AGES, 'level': 0}, + '_board_': {'query': db.QUERY_STATS_AGES, 'level': 0}, + '_stats_': {'query': db.QUERY_STATS_AGES, 'level': 0}, + }) + auth.authorize(ctx) + ctx['data'] = db.run_query(ctx['query'], dict(request.args)) + filters.runfilter(ctx) + return renderers.json_output(ctx) + + +# ── /member/ ─────────────────────────────────────── @legacy_bp.route('/member/') def member(crewname): """Member details (or list when crewname is '' or '*').""" - # TODO Phase 6 - raise NotImplementedError("Not yet implemented") + if crewname in ('', '*'): + # Return a list with permission-based filtering + ctx = _ctx(request, { + '_board_': {'query': db.QUERY_MEMBERLIST, 'level': 0}, + '_member_': {'query': db.QUERY_MEMBERLIST, 'level': 1, 'filter': filters.memberlist_active_only}, + '_passive_': {'query': db.QUERY_MEMBERLIST, 'level': 2, 'filter': filters.memberlist_self_only}, + '_astronaut_': {'query': db.QUERY_MEMBERLIST, 'level': 2, 'filter': filters.memberlist_self_only}, + }) + else: + # Return single member record + ctx = _ctx(request, { + '_board_': {'query': db.QUERY_MEMBER_BY_CREWNAME, 'level': 0}, + '_self_': {'query': db.QUERY_MEMBER_BY_CREWNAME, 'level': 0}, + }) + auth.authorize(ctx) + ctx['data'] = db.run_query(ctx['query'], {'crewname': crewname}) + filters.runfilter(ctx) + + # Use MEMBERLIST mapper for list queries, MEMBER mapper for single record + if crewname in ('', '*'): + mappings.memberlist_mapper(ctx) + else: + mappings.member_mapper(ctx) + return renderers.json_output(ctx) + + +# ── /member//raw ─────────────────────────────────── @legacy_bp.route('/member//raw') def member_raw(crewname): - """Raw DB member record.""" - # TODO Phase 6 - raise NotImplementedError("Not yet implemented") + """Raw DB member record (board/bots only).""" + if crewname in ('', '*'): + ctx = _ctx(request, { + '_board_': {'query': db.QUERY_MEMBERLIST_RAW, 'level': 0}, + }) + else: + ctx = _ctx(request, { + '_board_': {'query': db.QUERY_MEMBER_BY_CREWNAME, 'level': 0}, + }) + auth.authorize(ctx) + ctx['data'] = db.run_query(ctx['query'], {'crewname': crewname}) + filters.runfilter(ctx) + mappings.none_mapper(ctx) + return renderers.json_output(ctx) + + +# ── /member//memo ─────────────────────────────────── @legacy_bp.route('/member//memo') def member_memo(crewname): - """RTF memo for a member.""" - # TODO Phase 6 - raise NotImplementedError("Not yet implemented") + """RTF memo for a member (board only).""" + if not crewname: + return jsonify({'error': 'Member details only displayed for individual members.'}), 400 + ctx = _ctx(request, { + '_board_': {'query': db.QUERY_MEMBER_MEMO_BY_CREWNAME, 'level': 0}, + }) + auth.authorize(ctx) + ctx['data'] = db.run_query(ctx['query'], {'crewname': crewname}) + filters.runfilter(ctx) + mappings.memo_mapper(ctx) + return renderers.json_output(ctx) + + +# ── /member//contributions ───────────────────────── @legacy_bp.route('/member//contributions') def member_contributions(crewname): - """Contribution summary for a member.""" - # TODO Phase 6 - raise NotImplementedError("Not yet implemented") + """Contribution summary for a member (board only).""" + if not crewname: + return jsonify({'error': 'Member details only displayed for individual members.'}), 400 + ctx = _ctx(request, { + '_board_': {'query': db.QUERY_DEBITLIST_BY_CREWNAME, 'level': 0}, + }) + auth.authorize(ctx) + ctx['data'] = db.run_query(ctx['query'], {'crewname': crewname}) + filters.runfilter(ctx) + mappings.contributions_mapper(ctx) + return renderers.json_output(ctx) + + +# ── /member///[]/raw/ ───────────── @legacy_bp.route('/member///raw/') @legacy_bp.route('/member////raw/') def member_detail_raw(crewname, detail_type, detail_id=None): - """Raw detail records (contract, debit, withdrawal, payment).""" - # TODO Phase 6 - raise NotImplementedError("Not yet implemented") + """Raw detail records (contract, debit, withdrawal, payment). + + Permissions: board and self can see their own data. + """ + if crewname == '*': + return jsonify({'error': 'Member details only displayed for individual members.'}), 400 + + # Resolve the list query and the detail-id query from the type + DETAIL_QUERIES = { + 'contract': (db.QUERY_CONTRACTLIST_BY_CREWNAME, 'contract', db.QUERY_CONTRACT_BY_CREWNAME_AND_CONTRACT), + 'debit': (db.QUERY_DEBITLIST_BY_CREWNAME, 'guid', db.QUERY_DEBIT_BY_CREWNAME_AND_GUID), + 'withdrawal': (db.QUERY_WITHDRAWALLIST_BY_CREWNAME, 'guid', db.QUERY_WITHDRAWAL_BY_CREWNAME_AND_GUID), + 'payment': (db.QUERY_PAYMENTLIST_BY_CREWNAME, None, None), # no ID sub-query for payment + } + + info = DETAIL_QUERIES.get(detail_type) + if info is None: + return jsonify({'error': 'Invalid member detail requested.'}), 400 + + list_query, id_param, id_query = info + + perms = { + '_board_': {'query': list_query, 'level': 0}, + } + + ctx = _ctx(request, perms) + auth.authorize(ctx) + + if id_param and detail_id and detail_id not in ('', '*'): + # Fetch single record by ID + perms['_board_']['query'] = id_query + params = {'crewname': crewname} + params[id_param] = detail_id + else: + # Fetch list + params = {'crewname': crewname} + + ctx['data'] = db.run_query( + perms['_board_']['query'], + params, + ) + filters.runfilter(ctx) + mappings.none_mapper(ctx) + return renderers.json_output(ctx) diff --git a/plan b/plan new file mode 100644 index 0000000..e69de29 diff --git a/podman-compose.yml b/podman-compose.yml index 0120881..f9598d0 100644 --- a/podman-compose.yml +++ b/podman-compose.yml @@ -4,8 +4,8 @@ services: ports: - "${APP_PORT:-5000}:5000" environment: - - CTEWARD_ST_LEXWARE_CONFIG=/etc/cteward/st-lexware.json + - CTEWARD_ST_LEXWARE_CONFIG=/etc/cteward-ng/config.json volumes: - - /etc/cteward:/etc/cteward:ro + - /etc/cteward-ng:/etc/cteward-ng:ro - /var/log/cteward:/var/log/cteward restart: unless-stopped diff --git a/podman-compose.yml.bak b/podman-compose.yml.bak new file mode 100644 index 0000000..473df10 --- /dev/null +++ b/podman-compose.yml.bak @@ -0,0 +1,31 @@ +services: + cteward: + build: . + ports: + - "${APP_PORT}:5000" + # network_mode: host + + stunnel: + hostname: stunnel + image: docker.io/dockurr/stunnel + # image: alpine:latest + container_name: stunnel + environment: + LISTEN_PORT: "1434" + CONNECT_PORT: "14334" + CONNECT_HOST: "10.0.1.113" + # CONNECT_HOST: "ws22.cbrp3.c-base.org" + volumes: + # - ./stunnel.conf:/etc/stunnel/stunnel.conf:ro + # - ./stunnel.pem:/etc/stunnel/stunnel.pem:ro + - ./stunnel.pem:/etc/stunnel/stunnel.pem + - ./stunnel.conf:/config/stunnel.conf:ro + - ./privkey.pem:/private.pem + - ./certificate.pem:/cert.pem + # command: ["sh", "-c", "apk add --no-cache stunnel && stunnel /etc/stunnel/stunnel.conf"] + # entrypoint: ["/bin/sh", "-c"] + # command: ["stunnel", "/config/stunnel.conf"] + ports: + - 1434:1434 + restart: always + diff --git a/testlogfile b/testlogfile new file mode 100644 index 0000000..bc4084e --- /dev/null +++ b/testlogfile @@ -0,0 +1,10 @@ +{"name": "cteward-st-lexware", "hostname": "vsd", "pid": 8831, "level": 30, "msg": "anonymous GET http://localhost/legacy/monitor", "time": "2026-06-07T09:53:31.196821+00:00", "v": 0, "username": "anonymous", "method": "GET", "url": "http://localhost/legacy/monitor"} +{"name": "cteward-st-lexware", "hostname": "vsd", "pid": 8831, "level": 30, "msg": "anonymous GET http://localhost/legacy/monitor", "time": "2026-06-07T09:53:31.201223+00:00", "v": 0, "username": "anonymous", "method": "GET", "url": "http://localhost/legacy/monitor"} +{"name": "cteward-st-lexware", "hostname": "vsd", "pid": 8831, "level": 30, "msg": "anonymous GET http://localhost/legacy/stats/members", "time": "2026-06-07T09:53:31.205540+00:00", "v": 0, "username": "anonymous", "method": "GET", "url": "http://localhost/legacy/stats/members"} +{"name": "cteward-st-lexware", "hostname": "vsd", "pid": 8831, "level": 30, "msg": "testbot GET http://localhost/legacy/stats/members", "time": "2026-06-07T09:53:31.209734+00:00", "v": 0, "username": "testbot", "method": "GET", "url": "http://localhost/legacy/stats/members"} +{"name": "cteward-st-lexware", "hostname": "vsd", "pid": 8831, "level": 30, "msg": "anonymous GET http://localhost/legacy/member/alice", "time": "2026-06-07T09:53:31.214117+00:00", "v": 0, "username": "anonymous", "method": "GET", "url": "http://localhost/legacy/member/alice"} +{"name": "cteward-st-lexware", "hostname": "vsd", "pid": 8831, "level": 30, "msg": "testbot GET http://localhost/legacy/member/alice/invalid/raw/", "time": "2026-06-07T09:53:31.218322+00:00", "v": 0, "username": "testbot", "method": "GET", "url": "http://localhost/legacy/member/alice/invalid/raw/"} +{"name": "cteward-st-lexware", "hostname": "vsd", "pid": 8831, "level": 30, "msg": "testbot GET http://localhost/legacy/member/alice/memo", "time": "2026-06-07T09:53:31.222623+00:00", "v": 0, "username": "testbot", "method": "GET", "url": "http://localhost/legacy/member/alice/memo"} +{"name": "cteward-st-lexware", "hostname": "vsd", "pid": 8831, "level": 30, "msg": "testbot GET http://localhost/legacy/member/alice/contributions", "time": "2026-06-07T09:53:31.226649+00:00", "v": 0, "username": "testbot", "method": "GET", "url": "http://localhost/legacy/member/alice/contributions"} +{"name": "cteward-st-lexware", "hostname": "vsd", "pid": 8831, "level": 30, "msg": "testbot GET http://localhost/legacy/member/alice/contract/raw/", "time": "2026-06-07T09:53:31.230820+00:00", "v": 0, "username": "testbot", "method": "GET", "url": "http://localhost/legacy/member/alice/contract/raw/"} +{"name": "cteward-st-lexware", "hostname": "vsd", "pid": 8831, "level": 30, "msg": "testbot GET http://localhost/legacy/member/alice/unknown/raw/", "time": "2026-06-07T09:53:31.235202+00:00", "v": 0, "username": "testbot", "method": "GET", "url": "http://localhost/legacy/member/alice/unknown/raw/"}