From 897f926ce46fa01f3f891000c84d7aead0aa0654 Mon Sep 17 00:00:00 2001 From: smile Date: Mon, 8 Jun 2026 20:33:47 +0200 Subject: [PATCH] last phase & manual fixing --- .gitignore | 8 + MIGRATION_PLAN.md | 95 ++-- cteward_ng/__pycache__/app.cpython-313.pyc | Bin 7440 -> 7423 bytes cteward_ng/app.py | 5 +- cteward_ng/auth.py | 10 +- cteward_ng/config.py | 2 +- cteward_ng/mappings.py | 450 +++++++++++++++++- cteward_ng/permissions.py | 4 +- cteward_ng/st-lexware-test.json | 22 + .../conftest.cpython-313-pytest-9.0.3.pyc | Bin 1029 -> 1202 bytes cteward_ng/tests/conftest.py | 14 +- cteward_ng/tests/test_mappings.py | 230 +++++++++ cteward_ng/tests/test_views.py | 116 +++++ cteward_ng/views.py | 267 +++++++++-- plan | 0 podman-compose.yml | 4 +- podman-compose.yml.bak | 31 ++ testlogfile | 10 + 18 files changed, 1128 insertions(+), 140 deletions(-) create mode 100644 .gitignore create mode 100644 cteward_ng/st-lexware-test.json create mode 100644 cteward_ng/tests/test_mappings.py create mode 100644 cteward_ng/tests/test_views.py create mode 100644 plan create mode 100644 podman-compose.yml.bak create mode 100644 testlogfile 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 12f6ff83ba35f89df32a3808c765efffa2beeff0..107a6e64edb842150a8bfd53c6ac9ed8f3a04045 100644 GIT binary patch delta 206 zcmbPW_1}{BGcPX}0}yoRt7dK6$Qvcf#R23A1M%l~lM6*(vIH|*vQADAQ)Xwp#hO}C zQZ#v*SQDe)WJz&%jt{I1oD!cHm^WvOmoYLnPJSd2!8mcUwd7>BTA==-mdRHnjrrMG zxjr*9ut|Ji0lvA2oP?VWhqRCVg4AKdbFJ=M~ m3JOK)Ab~&_F=sQMv>GGh^vR|&=XgaKB|aN4Gujks0;K^a%{D6l delta 223 zcmexwIl+qeGcPX}0}%M$QO>%ukvB?|ixbEb2I9{jCKrmnWD8{sX0~LT94DqM%yf%2 zwW6db@fH`Dicd)_ncN}P#27f4Til)dhPccJb_Py~PYf)Zqr}S?8Ji|wl!#!QG+9e> zGFu(cz@pa4hb4{qIas+qGc&MBd|(2yK8Q?amD+3F1(d!eRFs;YSzMA@6rYq+np#kl znOCC8R1^%-36d{n0ul-eMd~1dKo~K1^Is`7M#dSFRb= 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 a1e6a0a3035d152c3b7a94f46ec5876a508abe29..5700c30e87a0b646f472673fe5d1e4d1f1859988 100644 GIT binary patch delta 689 zcmZqW*u<&+nU|M~0SG#*RkMtk7#JRdI55BpWqkfLQC+W|$(y5yBbYIm*_+dgtB6a1 zA%;Cj45k!82T3B>FcuD#(lB`h9m8kJ2;l{@1hbklL)b<9F&sWY3J7H|Rxq0sgDI3# zBpAbiRSi3m8liM1O^zzViE9k?UV`-bX)@m8N-j!GEJ=+|EGVdA($lLFDK62?Nv$YP zEK1caNi8nX%PP*#o2OuyFXkdszKro{vV;(~=Qz%0a3rq|_2Q#Y! zIiUs46k-5mFH`$q~OO_KP19GB55jT*$ z#g?3tnVMHp1kxcs`4y8C8xN4BDLk2rIhL6zzj$&Qvs^t3$Qid-vhp+YZn39i7Ud=8 zrrzR+k59|YNsW&$Vh0(-o|airQd*P>wjXTVN`@kkSrB_TY;yBcN^?@}iWGoiAO{rZ z0f`UHjEsyo8O)zEFtji=J`2bi0A%id#{d8T delta 536 zcmdnQ*~+2*nU|M~0SKHgD`%ZxWMFs<;=lkCl<~Q4qPkuMsn=_f3>gJ{EgLD+@gOq~x7E~5-0;RzOH;_@R z1SA?5zOXTHi(XJPzsO}Vc>zu@a - 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/"}