last phase & manual fixing
This commit is contained in:
parent
333f25b2be
commit
897f926ce4
18 changed files with 1128 additions and 140 deletions
8
.gitignore
vendored
Normal file
8
.gitignore
vendored
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
.idea/
|
||||
.vscode/
|
||||
__pycache__/
|
||||
dist/
|
||||
.coverage*
|
||||
htmlcov/
|
||||
.tox/
|
||||
docs/_build/
|
||||
|
|
@ -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/<crewname>` — member details or list
|
||||
- [ ] `GET /legacy/member/<crewname>/raw` — raw DB record
|
||||
- [ ] `GET /legacy/member/<crewname>/memo` — RTF memo
|
||||
- [ ] `GET /legacy/member/<crewname>/contributions` — contribution summary
|
||||
- [ ] `GET /legacy/member/<crewname>/<contract|debit|withdrawal|payment>/[<id>]/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/<crewname>` (single or list based on ''/'*')
|
||||
- [x] `GET /legacy/member/<crewname>/raw`
|
||||
- [x] `GET /legacy/member/<crewname>/memo` (board-only)
|
||||
- [x] `GET /legacy/member/<crewname>/contributions` (board-only)
|
||||
- [x] `GET /legacy/member/<crewname>/<contract|debit|withdrawal|payment>/[<id>]/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 |
|
||||
|
|
|
|||
Binary file not shown.
|
|
@ -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')
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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):
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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__)
|
||||
|
||||
|
|
|
|||
22
cteward_ng/st-lexware-test.json
Normal file
22
cteward_ng/st-lexware-test.json
Normal file
|
|
@ -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"
|
||||
}
|
||||
Binary file not shown.
|
|
@ -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
|
||||
|
||||
|
|
|
|||
230
cteward_ng/tests/test_mappings.py
Normal file
230
cteward_ng/tests/test_mappings.py
Normal file
|
|
@ -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
|
||||
116
cteward_ng/tests/test_views.py
Normal file
116
cteward_ng/tests/test_views.py
Normal file
|
|
@ -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
|
||||
|
|
@ -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/<crewname>
|
||||
GET /member/<crewname>/raw
|
||||
GET /member/<crewname>/memo
|
||||
GET /member/<crewname>/contributions
|
||||
GET /member/<crewname>/<contract|debit|withdrawal|payment>/[<id>]/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/<crewname> ───────────────────────────────────────
|
||||
|
||||
@legacy_bp.route('/member/<crewname>')
|
||||
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/<crewname>/raw ───────────────────────────────────
|
||||
|
||||
@legacy_bp.route('/member/<crewname>/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/<crewname>/memo ───────────────────────────────────
|
||||
|
||||
@legacy_bp.route('/member/<crewname>/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/<crewname>/contributions ─────────────────────────
|
||||
|
||||
@legacy_bp.route('/member/<crewname>/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/<crewname>/<detail_type>/[<id>]/raw/ ─────────────
|
||||
|
||||
@legacy_bp.route('/member/<crewname>/<detail_type>/raw/')
|
||||
@legacy_bp.route('/member/<crewname>/<detail_type>/<detail_id>/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)
|
||||
|
|
|
|||
0
plan
Normal file
0
plan
Normal file
|
|
@ -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
|
||||
|
|
|
|||
31
podman-compose.yml.bak
Normal file
31
podman-compose.yml.bak
Normal file
|
|
@ -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
|
||||
|
||||
10
testlogfile
Normal file
10
testlogfile
Normal file
|
|
@ -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/"}
|
||||
Loading…
Add table
Add a link
Reference in a new issue