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] **Port `authprovider.js`** → `auth.py`:
|
||||||
- [x] `check_password()` — plaintext path done, apr1 MD5 hash verification needs `passlib`
|
- [x] `check_password()` — plaintext + apr1 MD5 via `passlib.apr_md5_crypt`
|
||||||
- [ ] `find_botuser()` — bot user lookup from config
|
- [x] `find_botuser()` — bot user lookup from config
|
||||||
- [ ] `find_ldapuser()` — LDAP authentication (use `ldap3` Python library instead of `ldapauth-fork`)
|
- [x] `find_ldapuser()` — LDAP authentication via `ldap3`
|
||||||
- [ ] Basic auth extraction from `Authorization` header (partially done in `app.py` for logging)
|
- [x] Basic auth extraction + full pipeline in `authorize()`
|
||||||
- [ ] **Port permission resolution** → `permissions.py`:
|
- [x] **Port permission resolution** → `permissions.py`:
|
||||||
- [ ] `find_config_flags()` — flag assignment from config
|
- [x] `find_config_flags()` — flag assignment + impersonation-limited stripping
|
||||||
- [ ] `find_database_flags()` — DB-based flags (_member_, _astronaut_, _passive_)
|
- [x] `find_database_flags()` — DB-based flags (_member_, _astronaut_, _passive_)
|
||||||
- [ ] `impersonate()` — `?impersonate=` query param support
|
- [x] `impersonate()` — `?impersonate=` query param support
|
||||||
- [ ] `effective_permissions()` — lowest-level permission wins
|
- [x] `effective_permissions()` — lowest level wins
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Phase 5: Filters & Mappings
|
## Phase 5: Filters & Mappings ✅ DONE
|
||||||
|
|
||||||
- [ ] **Port `filters.js`** → `filters.py`:
|
- [x] **Port `filters.js`** → `filters.py`:
|
||||||
- [x] `MEMBERLIST_ACTIVE_ONLY` — filter to active members (done, with lazy import)
|
- [x] `MEMBERLIST_ACTIVE_ONLY` — filter to active members
|
||||||
- [x] `MEMBERLIST_SELF_ONLY` — filter to requesting user only (done)
|
- [x] `MEMBERLIST_SELF_ONLY` — filter to requesting user only
|
||||||
- [x] `runfilter()` — apply configured filter (done)
|
- [x] `runfilter()` — apply configured filter
|
||||||
- [ ] **Port `mappings.js`** → `mappings.py` (largest file, ~420 lines):
|
- [x] **Port `mappings.js`** → `mappings.py` (~380 lines):
|
||||||
- [x] `NONE` — identity mapper (done)
|
- [x] `NONE`, `CONTRACT`, `CONTRACTLIST`
|
||||||
- [ ] `CONTRACT` — single contract data transformation
|
- [x] `DEBIT`, `DEBITLIST`
|
||||||
- [ ] `CONTRACTLIST` — paginated contract list
|
- [x] `CONTRIBUTIONS` — aggregated billed/paid/unpaid
|
||||||
- [ ] `DEBIT` — single debit data
|
- [x] `MEMBER`, `MEMO` (with RTF fallback parser)
|
||||||
- [ ] `DEBITLIST` — paginated debit list
|
- [x] `MEMBERLIST`, `MEMBERLIST_TO_LDAPCSV`
|
||||||
- [ ] `CONTRIBUTIONS` — aggregated contribution summaries (complex)
|
- [x] `WITHDRAWAL`, `WITHDRAWALLIST`
|
||||||
- [ ] `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
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Phase 6: API Routes
|
## Phase 6: API Routes ✅ DONE
|
||||||
|
|
||||||
- [ ] **Port `startup.js` routes** → `views.py` (Flask blueprints):
|
All 11 endpoints implemented with full auth → query → filter → map → render pipeline:
|
||||||
- [x] `GET /legacy/monitor` — health check (returns OK placeholder)
|
- [x] `GET /legacy/monitor`
|
||||||
- [ ] `GET /legacy/memberlist-oldformat` — CSV member list (LDAP export)
|
- [x] `GET /legacy/memberlist-oldformat` (CSV)
|
||||||
- [ ] `GET /legacy/stats/members` — member count over time
|
- [x] `GET /legacy/stats/members`, `/contracts`, `/genders`, `/ages`
|
||||||
- [ ] `GET /legacy/stats/contracts` — contract statistics
|
- [x] `GET /legacy/member/<crewname>` (single or list based on ''/'*')
|
||||||
- [ ] `GET /legacy/stats/genders` — gender demographics
|
- [x] `GET /legacy/member/<crewname>/raw`
|
||||||
- [ ] `GET /legacy/stats/ages` — age demographics
|
- [x] `GET /legacy/member/<crewname>/memo` (board-only)
|
||||||
- [ ] `GET /legacy/member/<crewname>` — member details or list
|
- [x] `GET /legacy/member/<crewname>/contributions` (board-only)
|
||||||
- [ ] `GET /legacy/member/<crewname>/raw` — raw DB record
|
- [x] `GET /legacy/member/<crewname>/<contract|debit|withdrawal|payment>/[<id>]/raw/`
|
||||||
- [ ] `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
|
|
||||||
|
## 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) |
|
| 1. Infrastructure | Low | ✅ Done (Dockerfile, podman-compose, BunyanFormatter) |
|
||||||
| 2. Database Layer | Medium | ✅ Done (PooledDB, all 14 queries + 4 stats aggregations) |
|
| 2. Database Layer | Medium | ✅ Done (PooledDB, all 14 queries + 4 stats aggregations) |
|
||||||
| 3. Data Utilities | Low | ✅ Done |
|
| 3. Data Utilities | Low | ✅ Done |
|
||||||
| 4. Auth & Permissions | Medium | ⬜ Pending |
|
| 4. Auth & Permissions | Medium | ✅ Done (bot/LDAP auth, flag resolution, impersonation) |
|
||||||
| 5. Filters & Mappings | High (big file) | ✅ Partial (filters done, mappings stubbed) |
|
| 5. Filters & Mappings | High (big file) | ✅ Done (all 12 mappers + 2 filters) |
|
||||||
| 6. API Routes | Medium | ⬜ Pending |
|
| 6. API Routes | Medium | ✅ Done (all 11 endpoints with full auth→query→filter→map→render pipeline) |
|
||||||
| 7. Response Rendering | Low | ✅ Done |
|
| 7. Response Rendering | Low | ✅ Done |
|
||||||
| 8. Middleware | Low | ✅ Done (BunyanFormatter, WWW-Authenticate, CORS, gzip) |
|
| 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 |
|
| 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_cors import CORS
|
||||||
from flask_compress import Compress
|
from flask_compress import Compress
|
||||||
|
|
||||||
from .config import load_config
|
from config import load_config
|
||||||
|
|
||||||
# Bunyan level mapping
|
# Bunyan level mapping
|
||||||
_BUNYAN_LEVELS = {
|
_BUNYAN_LEVELS = {
|
||||||
|
|
@ -140,7 +140,6 @@ def _register_prehandlers(app):
|
||||||
app.logger.info(
|
app.logger.info(
|
||||||
'%s %s %s', username, request.method, request.url,
|
'%s %s %s', username, request.method, request.url,
|
||||||
extra=extra,
|
extra=extra,
|
||||||
extra_data=extra,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
@app.after_request
|
@app.after_request
|
||||||
|
|
@ -156,5 +155,5 @@ def _register_prehandlers(app):
|
||||||
|
|
||||||
def _register_blueprints(app):
|
def _register_blueprints(app):
|
||||||
"""Register all route blueprints."""
|
"""Register all route blueprints."""
|
||||||
from .views import legacy_bp
|
from views import legacy_bp
|
||||||
app.register_blueprint(legacy_bp, url_prefix='/legacy')
|
app.register_blueprint(legacy_bp, url_prefix='/legacy')
|
||||||
|
|
|
||||||
|
|
@ -13,9 +13,9 @@ import logging
|
||||||
from flask import abort
|
from flask import abort
|
||||||
from passlib.hash import apr_md5_crypt
|
from passlib.hash import apr_md5_crypt
|
||||||
|
|
||||||
from .database import member_lookup
|
from database import member_lookup
|
||||||
from .memberdata import realstatus as md_realstatus
|
from memberdata import realstatus as md_realstatus
|
||||||
from .permissions import (
|
from permissions import (
|
||||||
find_config_flags,
|
find_config_flags,
|
||||||
find_database_flags,
|
find_database_flags,
|
||||||
impersonate,
|
impersonate,
|
||||||
|
|
@ -117,7 +117,6 @@ def find_ldapuser(ctx):
|
||||||
"""
|
"""
|
||||||
if ctx.get('username') is not None:
|
if ctx.get('username') is not None:
|
||||||
return ctx
|
return ctx
|
||||||
|
|
||||||
auth = _parse_basic_auth(ctx['request'])
|
auth = _parse_basic_auth(ctx['request'])
|
||||||
if auth is None:
|
if auth is None:
|
||||||
return ctx
|
return ctx
|
||||||
|
|
@ -125,6 +124,7 @@ def find_ldapuser(ctx):
|
||||||
ldap_config = ctx['config'].get('ldap')
|
ldap_config = ctx['config'].get('ldap')
|
||||||
if ldap_config is None:
|
if ldap_config is None:
|
||||||
# No LDAP configured — can't authenticate this way
|
# No LDAP configured — can't authenticate this way
|
||||||
|
logger.critical("NO LDAP")
|
||||||
return ctx
|
return ctx
|
||||||
|
|
||||||
username, password = auth
|
username, password = auth
|
||||||
|
|
@ -166,12 +166,14 @@ def _authenticate_ldap(config, username, password):
|
||||||
|
|
||||||
if config.get('searchBase'):
|
if config.get('searchBase'):
|
||||||
# Verify the user actually exists in the directory
|
# Verify the user actually exists in the directory
|
||||||
|
logger.critical("searchBase: %s", config.get('searchFilter', '(uid={})').format(username=username))
|
||||||
conn.search(
|
conn.search(
|
||||||
search_base=config['searchBase'],
|
search_base=config['searchBase'],
|
||||||
search_filter=config.get('searchFilter', '(uid={})').format(username=username),
|
search_filter=config.get('searchFilter', '(uid={})').format(username=username),
|
||||||
search_scope=SUBTREE,
|
search_scope=SUBTREE,
|
||||||
attributes=config.get('attributes', []),
|
attributes=config.get('attributes', []),
|
||||||
)
|
)
|
||||||
|
logger.critical("search done")
|
||||||
if not conn.entries:
|
if not conn.entries:
|
||||||
return False
|
return False
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -11,7 +11,7 @@ import json
|
||||||
import os
|
import os
|
||||||
|
|
||||||
|
|
||||||
_DEFAULT_CONFIG_PATH = '/etc/cteward/st-lexware.json'
|
_DEFAULT_CONFIG_PATH = '/etc/cteward-ng/config.json'
|
||||||
|
|
||||||
|
|
||||||
def load_config(config_path=None):
|
def load_config(config_path=None):
|
||||||
|
|
|
||||||
|
|
@ -8,11 +8,9 @@ Mappers:
|
||||||
WITHDRAWAL, WITHDRAWALLIST
|
WITHDRAWAL, WITHDRAWALLIST
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from . import memberdata
|
import os.path
|
||||||
|
|
||||||
# Placeholder — full implementation in Phase 5
|
import memberdata
|
||||||
|
|
||||||
MAPPERS = {}
|
|
||||||
|
|
||||||
|
|
||||||
def none_mapper(ctx):
|
def none_mapper(ctx):
|
||||||
|
|
@ -20,67 +18,467 @@ def none_mapper(ctx):
|
||||||
return 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):
|
def contract_mapper(ctx):
|
||||||
"""Map a single contract record."""
|
"""Map a single contract record."""
|
||||||
# TODO Phase 5
|
data = ctx.get('data', [])
|
||||||
raise NotImplementedError
|
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):
|
def contractlist_mapper(ctx):
|
||||||
"""Map a list of contract records into paginated format."""
|
"""Map a list of contract records into paginated format."""
|
||||||
# TODO Phase 5
|
results = []
|
||||||
raise NotImplementedError
|
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):
|
def debit_mapper(ctx):
|
||||||
"""Map a single debit record."""
|
"""Map a single debit record."""
|
||||||
# TODO Phase 5
|
data = ctx.get('data', [])
|
||||||
raise NotImplementedError
|
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):
|
def debitlist_mapper(ctx):
|
||||||
"""Map a list of debit records into paginated format."""
|
"""Map a list of debit records into paginated format."""
|
||||||
# TODO Phase 5
|
results = []
|
||||||
raise NotImplementedError
|
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):
|
def contributions_mapper(ctx):
|
||||||
"""Aggregate contributions (billed/paid/unpaid) across contracts and years."""
|
"""Aggregate contributions (billed/paid/unpaid) across contracts and years."""
|
||||||
# TODO Phase 5
|
newdata = {
|
||||||
raise NotImplementedError
|
'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):
|
def member_mapper(ctx):
|
||||||
"""Map a single member record with all fields."""
|
"""Map a single member record with all fields."""
|
||||||
# TODO Phase 5
|
data = ctx.get('data', [])
|
||||||
raise NotImplementedError
|
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):
|
def memo_mapper(ctx):
|
||||||
"""Parse RTF memo and extract embedded JSON data."""
|
"""Parse RTF memo and extract embedded JSON data."""
|
||||||
# TODO Phase 5
|
data = ctx.get('data', [])
|
||||||
raise NotImplementedError
|
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):
|
def memberlist_mapper(ctx):
|
||||||
"""Map a list of members into paginated format."""
|
"""Map a list of members into paginated format."""
|
||||||
# TODO Phase 5
|
results = []
|
||||||
raise NotImplementedError
|
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):
|
def memberlist_to_ldapcsv_mapper(ctx):
|
||||||
"""Map member list to LDAP CSV export format."""
|
"""Map member list to LDAP CSV export format."""
|
||||||
# TODO Phase 5
|
results = []
|
||||||
raise NotImplementedError
|
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):
|
def withdrawal_mapper(ctx):
|
||||||
"""Map a single withdrawal record."""
|
"""Map a single withdrawal record."""
|
||||||
# TODO Phase 5
|
data = ctx.get('data', [])
|
||||||
raise NotImplementedError
|
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):
|
def withdrawallist_mapper(ctx):
|
||||||
"""Map a list of withdrawal records into paginated format."""
|
"""Map a list of withdrawal records into paginated format."""
|
||||||
# TODO Phase 5
|
results = []
|
||||||
raise NotImplementedError
|
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 flask import abort
|
||||||
|
|
||||||
from .database import member_lookup
|
from database import member_lookup
|
||||||
from .memberdata import realstatus as md_realstatus
|
from memberdata import realstatus as md_realstatus
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
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.
|
"""Test fixtures and app factory for the test suite."""
|
||||||
|
|
||||||
Replaces test/000-startup.js bootstrap logic.
|
|
||||||
"""
|
|
||||||
|
|
||||||
|
import os
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
from cteward_ng.app import create_app
|
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
|
@pytest.fixture
|
||||||
def app():
|
def app():
|
||||||
"""Create a test Flask app using the test config."""
|
"""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
|
app.config['TESTING'] = True
|
||||||
return app
|
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.
|
Replaces all server.get() calls in startup.js.
|
||||||
Blueprint URL prefix: /legacy
|
Blueprint URL prefix: /legacy
|
||||||
|
|
||||||
Endpoints:
|
Each endpoint follows the same pipeline:
|
||||||
GET /monitor
|
1. Build permissions dict (role → {query, level, filter?})
|
||||||
GET /memberlist-oldformat
|
2. auth.authorize(ctx) — sets username, flags, query, filter
|
||||||
GET /stats/members
|
3. database.run_query(query_def, params) — executes SQL
|
||||||
GET /stats/contracts
|
4. filters.runfilter(ctx) — applies permission-level data filter
|
||||||
GET /stats/genders
|
5. mappings.*_mapper(ctx) — transforms raw rows into API shape
|
||||||
GET /stats/ages
|
6. renderers.json_output / csv_output(ctx) — returns HTTP response
|
||||||
GET /member/<crewname>
|
|
||||||
GET /member/<crewname>/raw
|
|
||||||
GET /member/<crewname>/memo
|
|
||||||
GET /member/<crewname>/contributions
|
|
||||||
GET /member/<crewname>/<contract|debit|withdrawal|payment>/[<id>]/raw/
|
|
||||||
"""
|
"""
|
||||||
|
|
||||||
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__)
|
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')
|
@legacy_bp.route('/monitor')
|
||||||
def monitor():
|
def monitor():
|
||||||
"""Health check endpoint."""
|
"""Health check endpoint."""
|
||||||
# TODO Phase 6: call database.check_backend_okay()
|
try:
|
||||||
from flask import jsonify
|
db.check_backend_okay()
|
||||||
return jsonify({'status': 'OK'})
|
return jsonify({'status': 'OK'})
|
||||||
|
except Exception as exc:
|
||||||
|
return jsonify({'status': 'BROKEN'}), 503
|
||||||
|
|
||||||
|
|
||||||
|
# ── /memberlist-oldformat (CSV) ────────────────────────────────
|
||||||
|
|
||||||
@legacy_bp.route('/memberlist-oldformat')
|
@legacy_bp.route('/memberlist-oldformat')
|
||||||
def memberlist_oldformat():
|
def memberlist_oldformat():
|
||||||
"""CSV member list for LDAP export."""
|
"""CSV member list for LDAP export.
|
||||||
# TODO Phase 6
|
|
||||||
raise NotImplementedError("Not yet implemented")
|
|
||||||
|
|
||||||
|
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')
|
@legacy_bp.route('/stats/members')
|
||||||
def stats_members():
|
def stats_members():
|
||||||
"""Member count over time."""
|
"""Member count time series."""
|
||||||
# TODO Phase 6
|
ctx = _ctx(request, {
|
||||||
raise NotImplementedError("Not yet implemented")
|
'_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')
|
@legacy_bp.route('/stats/contracts')
|
||||||
def stats_contracts():
|
def stats_contracts():
|
||||||
"""Contract statistics."""
|
"""Contract statistics time series."""
|
||||||
# TODO Phase 6
|
ctx = _ctx(request, {
|
||||||
raise NotImplementedError("Not yet implemented")
|
'_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')
|
@legacy_bp.route('/stats/genders')
|
||||||
def stats_genders():
|
def stats_genders():
|
||||||
"""Gender demographics."""
|
"""Gender demographics time series."""
|
||||||
# TODO Phase 6
|
ctx = _ctx(request, {
|
||||||
raise NotImplementedError("Not yet implemented")
|
'_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')
|
@legacy_bp.route('/stats/ages')
|
||||||
def stats_ages():
|
def stats_ages():
|
||||||
"""Age demographics."""
|
"""Age demographics time series.
|
||||||
# TODO Phase 6
|
|
||||||
raise NotImplementedError("Not yet implemented")
|
|
||||||
|
|
||||||
|
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>')
|
@legacy_bp.route('/member/<crewname>')
|
||||||
def member(crewname):
|
def member(crewname):
|
||||||
"""Member details (or list when crewname is '' or '*')."""
|
"""Member details (or list when crewname is '' or '*')."""
|
||||||
# TODO Phase 6
|
if crewname in ('', '*'):
|
||||||
raise NotImplementedError("Not yet implemented")
|
# 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')
|
@legacy_bp.route('/member/<crewname>/raw')
|
||||||
def member_raw(crewname):
|
def member_raw(crewname):
|
||||||
"""Raw DB member record."""
|
"""Raw DB member record (board/bots only)."""
|
||||||
# TODO Phase 6
|
if crewname in ('', '*'):
|
||||||
raise NotImplementedError("Not yet implemented")
|
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')
|
@legacy_bp.route('/member/<crewname>/memo')
|
||||||
def member_memo(crewname):
|
def member_memo(crewname):
|
||||||
"""RTF memo for a member."""
|
"""RTF memo for a member (board only)."""
|
||||||
# TODO Phase 6
|
if not crewname:
|
||||||
raise NotImplementedError("Not yet implemented")
|
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')
|
@legacy_bp.route('/member/<crewname>/contributions')
|
||||||
def member_contributions(crewname):
|
def member_contributions(crewname):
|
||||||
"""Contribution summary for a member."""
|
"""Contribution summary for a member (board only)."""
|
||||||
# TODO Phase 6
|
if not crewname:
|
||||||
raise NotImplementedError("Not yet implemented")
|
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>/raw/')
|
||||||
@legacy_bp.route('/member/<crewname>/<detail_type>/<detail_id>/raw/')
|
@legacy_bp.route('/member/<crewname>/<detail_type>/<detail_id>/raw/')
|
||||||
def member_detail_raw(crewname, detail_type, detail_id=None):
|
def member_detail_raw(crewname, detail_type, detail_id=None):
|
||||||
"""Raw detail records (contract, debit, withdrawal, payment)."""
|
"""Raw detail records (contract, debit, withdrawal, payment).
|
||||||
# TODO Phase 6
|
|
||||||
raise NotImplementedError("Not yet implemented")
|
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:
|
ports:
|
||||||
- "${APP_PORT:-5000}:5000"
|
- "${APP_PORT:-5000}:5000"
|
||||||
environment:
|
environment:
|
||||||
- CTEWARD_ST_LEXWARE_CONFIG=/etc/cteward/st-lexware.json
|
- CTEWARD_ST_LEXWARE_CONFIG=/etc/cteward-ng/config.json
|
||||||
volumes:
|
volumes:
|
||||||
- /etc/cteward:/etc/cteward:ro
|
- /etc/cteward-ng:/etc/cteward-ng:ro
|
||||||
- /var/log/cteward:/var/log/cteward
|
- /var/log/cteward:/var/log/cteward
|
||||||
restart: unless-stopped
|
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