fix: improve warning box contrast and add XLSX converter script
Warning summary boxes in the import wizard had white text on a light warning background, making them unreadable. Switch to a light-yellow background with dark text instead. Also adds convert_xlsx.py for converting the LvS member Excel spreadsheet into CSV files for ZIP bundle import, and documents the mandatory version bump rule in CLAUDE.md. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -56,6 +56,7 @@ These caused most bugs in this project. Every contributor must know them:
|
||||
- `make redeploy` = rebuild JS + copy into running container
|
||||
- After redeploy without version bump, restart container (`docker compose restart nextcloud`) and hard-refresh browser
|
||||
- Version bump (`appinfo/info.xml` + `webpack.config.js` DefinePlugin + `main.js` provide) + `occ upgrade` is the reliable way to bust all caches
|
||||
- **Always bump the version when redeploying UI changes.** Without a version bump, Nextcloud serves cached JS/CSS and changes won't reach the browser — even with hard-refresh. Bump all three locations together.
|
||||
|
||||
## Gitea
|
||||
|
||||
|
||||
+1
-1
@@ -5,7 +5,7 @@
|
||||
<name>Mitgliederverwaltung</name>
|
||||
<summary>Mitgliederverwaltung für Pfadfindervereine</summary>
|
||||
<description><![CDATA[Verwaltung von Mitgliedern, Familien, Beiträgen, Lagern und mehr für Pfadfindervereine. Integriert sich in Nextcloud Kalender, Kontakte und Dateien.]]></description>
|
||||
<version>0.1.0</version>
|
||||
<version>0.1.2</version>
|
||||
<licence>agpl</licence>
|
||||
<author>shahondin1624</author>
|
||||
<namespace>Mitgliederverwaltung</namespace>
|
||||
|
||||
+418
@@ -0,0 +1,418 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Convert 'Mitglieder LvS 25.xlsx' into CSV files suitable for ZIP bundle import
|
||||
into the Mitgliederverwaltung Nextcloud plugin.
|
||||
|
||||
Usage:
|
||||
python3 convert_xlsx.py "/path/to/Mitglieder LvS 25.xlsx" [output_dir]
|
||||
|
||||
Output (in output_dir, default ./import_bundle):
|
||||
Stufen.csv, Familien.csv, Mitglieder.csv, Adressen.csv,
|
||||
Telefonnummern.csv, E-Mails.csv, import_bundle.zip
|
||||
"""
|
||||
|
||||
import csv
|
||||
import os
|
||||
import re
|
||||
import sys
|
||||
import zipfile
|
||||
from collections import defaultdict
|
||||
from datetime import datetime
|
||||
|
||||
import openpyxl
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Config
|
||||
# ---------------------------------------------------------------------------
|
||||
STUFEN_MAP = {
|
||||
"W": "Wölflinge",
|
||||
"P": "Pfadfinder",
|
||||
"R": "Rover",
|
||||
}
|
||||
|
||||
STUFEN_META = {
|
||||
"Wölflinge": {"sort_order": 1, "age_min": 7, "age_max": 10, "color": "#ff9800"},
|
||||
"Pfadfinder": {"sort_order": 2, "age_min": 11, "age_max": 16, "color": "#4caf50"},
|
||||
"Rover": {"sort_order": 3, "age_min": 16, "age_max": 25, "color": "#2196f3"},
|
||||
}
|
||||
|
||||
CSV_DELIMITER = ";"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
def fmt_date(val):
|
||||
"""Convert an Excel date value to YYYY-MM-DD string."""
|
||||
if val is None:
|
||||
return ""
|
||||
if isinstance(val, datetime):
|
||||
return val.strftime("%Y-%m-%d")
|
||||
s = str(val).strip()
|
||||
if not s:
|
||||
return ""
|
||||
for fmt in ("%Y-%m-%d %H:%M:%S", "%Y-%m-%d", "%d.%m.%Y", "%d/%m/%Y"):
|
||||
try:
|
||||
return datetime.strptime(s, fmt).strftime("%Y-%m-%d")
|
||||
except ValueError:
|
||||
continue
|
||||
return s
|
||||
|
||||
|
||||
def parse_address(raw):
|
||||
"""
|
||||
Parse 'Straße Nr, PLZ Ort' into (strasse, plz, ort).
|
||||
Handles incomplete addresses gracefully.
|
||||
"""
|
||||
if not raw or str(raw).strip().lower() == "none":
|
||||
return "", "", ""
|
||||
raw = str(raw).strip()
|
||||
# Try: "Street, PLZ City"
|
||||
m = re.match(r"^(.+?),\s*(\d{5})\s+(.+)$", raw)
|
||||
if m:
|
||||
return m.group(1).strip(), m.group(2), m.group(3).strip()
|
||||
# Try: "Street, City" (no PLZ)
|
||||
m = re.match(r"^(.+?),\s*(.+)$", raw)
|
||||
if m:
|
||||
return m.group(1).strip(), "", m.group(2).strip()
|
||||
return raw, "", ""
|
||||
|
||||
|
||||
def clean(val):
|
||||
"""Stringify a cell value, stripping None."""
|
||||
if val is None:
|
||||
return ""
|
||||
return str(val).strip()
|
||||
|
||||
|
||||
def write_csv(path, headers, rows):
|
||||
"""Write a semicolon-delimited UTF-8 CSV with BOM."""
|
||||
with open(path, "w", newline="", encoding="utf-8-sig") as f:
|
||||
w = csv.writer(f, delimiter=CSV_DELIMITER)
|
||||
w.writerow(headers)
|
||||
w.writerows(rows)
|
||||
print(f" {os.path.basename(path)}: {len(rows)} rows")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Readers
|
||||
# ---------------------------------------------------------------------------
|
||||
def read_mitglieder_stamm(ws):
|
||||
"""
|
||||
Parse the 'Mitglieder Stamm' sheet.
|
||||
Returns list of dicts with keys matching column semantics.
|
||||
"""
|
||||
members = []
|
||||
for row in ws.iter_rows(min_row=3, max_row=ws.max_row, values_only=True):
|
||||
nachname = clean(row[1])
|
||||
if not nachname:
|
||||
continue
|
||||
members.append({
|
||||
"nachname": nachname,
|
||||
"vorname": clean(row[2]),
|
||||
"geburtsdatum": fmt_date(row[3]),
|
||||
"stufe_code": clean(row[5]), # W / P / R
|
||||
"adresse_raw": clean(row[6]),
|
||||
"email": clean(row[7]),
|
||||
"telefon": clean(row[8]),
|
||||
"aktiv": clean(row[9]).lower() == "x",
|
||||
"beitrag": clean(row[10]),
|
||||
})
|
||||
return members
|
||||
|
||||
|
||||
def read_kontaktdaten(ws):
|
||||
"""
|
||||
Parse a 'Kontaktdaten …' sheet.
|
||||
Returns dict keyed by (nachname, vorname) → health info dict.
|
||||
"""
|
||||
info = {}
|
||||
# Find header row (contains "Name")
|
||||
header_row = None
|
||||
for r in range(1, min(10, ws.max_row + 1)):
|
||||
cell = ws.cell(r, 2).value
|
||||
if cell and str(cell).strip().lower() == "name":
|
||||
header_row = r
|
||||
break
|
||||
if header_row is None:
|
||||
return info
|
||||
|
||||
for row in ws.iter_rows(min_row=header_row + 1, max_row=ws.max_row, values_only=True):
|
||||
nachname = clean(row[1])
|
||||
vorname = clean(row[2])
|
||||
if not nachname:
|
||||
continue
|
||||
allergien = clean(row[6])
|
||||
if allergien.upper() == "NEIN":
|
||||
allergien = ""
|
||||
krankheiten = clean(row[5])
|
||||
if krankheiten.upper() == "NEIN":
|
||||
krankheiten = ""
|
||||
medis = clean(row[7])
|
||||
if medis.upper() == "NEIN":
|
||||
medis = ""
|
||||
|
||||
notes_parts = []
|
||||
schwimmer = clean(row[4])
|
||||
if schwimmer.upper() == "JA":
|
||||
notes_parts.append("Schwimmer: Ja")
|
||||
elif schwimmer:
|
||||
notes_parts.append(f"Schwimmer: {schwimmer}")
|
||||
if krankheiten:
|
||||
notes_parts.append(f"Krankheiten: {krankheiten}")
|
||||
if medis:
|
||||
notes_parts.append(f"Medikamente: {medis}")
|
||||
|
||||
# Parent contact from right side of sheet (cols K-N, indices 11-13)
|
||||
parent_name = ""
|
||||
parent_phone = ""
|
||||
if len(row) > 12:
|
||||
parent_name = clean(row[12]) # col M: parent Vorname
|
||||
if len(row) > 13:
|
||||
parent_phone = clean(row[13]) # col N: parent Mobil
|
||||
|
||||
info[(nachname, vorname)] = {
|
||||
"allergien": allergien,
|
||||
"notizen": "; ".join(notes_parts) if notes_parts else "",
|
||||
"parent_nachname": clean(row[11]) if len(row) > 11 else "",
|
||||
"parent_vorname": parent_name,
|
||||
"parent_phone": parent_phone,
|
||||
}
|
||||
return info
|
||||
|
||||
|
||||
def read_kontoliste(ws):
|
||||
"""
|
||||
Parse the 'Kontoliste' sheet.
|
||||
Returns list of dicts: kontoinhaber, betrag, iban.
|
||||
"""
|
||||
accounts = []
|
||||
for row in ws.iter_rows(min_row=2, max_row=ws.max_row, values_only=True):
|
||||
inhaber = clean(row[1])
|
||||
if not inhaber:
|
||||
continue
|
||||
accounts.append({
|
||||
"kontoinhaber": inhaber,
|
||||
"betrag": clean(row[2]),
|
||||
"iban": clean(row[3]),
|
||||
})
|
||||
return accounts
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Family grouping
|
||||
# ---------------------------------------------------------------------------
|
||||
def build_families(members, accounts):
|
||||
"""
|
||||
Group members into families by shared last name + address.
|
||||
Match to Kontoliste by last name.
|
||||
Returns:
|
||||
- families: list of {name, kontoinhaber, iban}
|
||||
- member→family_name mapping
|
||||
"""
|
||||
# Group by (nachname, address) to handle same last name at different addresses
|
||||
groups = defaultdict(list)
|
||||
for m in members:
|
||||
key = m["nachname"]
|
||||
groups[key].append(m)
|
||||
|
||||
# Build account lookup: last name → account
|
||||
acct_by_name = {}
|
||||
for a in accounts:
|
||||
# Kontoinhaber is "Nachname Vorname" — extract last name
|
||||
parts = a["kontoinhaber"].replace(",", " ").split()
|
||||
if parts:
|
||||
acct_by_name[parts[0].lower()] = a
|
||||
|
||||
families = []
|
||||
member_family = {} # (nachname, vorname) → family name
|
||||
|
||||
for nachname, group in sorted(groups.items()):
|
||||
family_name = f"Familie {nachname}"
|
||||
acct = acct_by_name.get(nachname.lower(), {})
|
||||
families.append({
|
||||
"name": family_name,
|
||||
"kontoinhaber": acct.get("kontoinhaber", ""),
|
||||
"iban": acct.get("iban", ""),
|
||||
})
|
||||
for m in group:
|
||||
member_family[(m["nachname"], m["vorname"])] = family_name
|
||||
|
||||
return families, member_family
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Main
|
||||
# ---------------------------------------------------------------------------
|
||||
def main():
|
||||
if len(sys.argv) < 2:
|
||||
print(__doc__)
|
||||
sys.exit(1)
|
||||
|
||||
xlsx_path = sys.argv[1]
|
||||
out_dir = sys.argv[2] if len(sys.argv) > 2 else os.path.join(os.path.dirname(xlsx_path), "import_bundle")
|
||||
os.makedirs(out_dir, exist_ok=True)
|
||||
|
||||
print(f"Reading {xlsx_path} ...")
|
||||
wb = openpyxl.load_workbook(xlsx_path, data_only=True)
|
||||
|
||||
# --- Read source sheets ---
|
||||
members = read_mitglieder_stamm(wb["Mitglieder Stamm"])
|
||||
print(f" {len(members)} members from 'Mitglieder Stamm'")
|
||||
|
||||
kontakt_info = {}
|
||||
for sheet_name in wb.sheetnames:
|
||||
if sheet_name.startswith("Kontaktdaten"):
|
||||
ki = read_kontaktdaten(wb[sheet_name])
|
||||
kontakt_info.update(ki)
|
||||
print(f" {len(ki)} entries from '{sheet_name}'")
|
||||
|
||||
accounts = read_kontoliste(wb["Kontoliste"])
|
||||
print(f" {len(accounts)} accounts from 'Kontoliste'")
|
||||
|
||||
families, member_family = build_families(members, accounts)
|
||||
print(f" {len(families)} families grouped")
|
||||
|
||||
print(f"\nWriting CSVs to {out_dir} ...")
|
||||
|
||||
# --- Stufen.csv ---
|
||||
stufen_rows = []
|
||||
for code, name in sorted(STUFEN_MAP.items(), key=lambda x: STUFEN_META[x[1]]["sort_order"]):
|
||||
meta = STUFEN_META[name]
|
||||
stufen_rows.append([
|
||||
name, meta["sort_order"], meta["age_min"], meta["age_max"], meta["color"]
|
||||
])
|
||||
write_csv(
|
||||
os.path.join(out_dir, "Stufen.csv"),
|
||||
["Name", "Sortierung", "Mindestalter", "Hoechstalter", "Farbe"],
|
||||
stufen_rows,
|
||||
)
|
||||
|
||||
# --- Familien.csv ---
|
||||
fam_rows = []
|
||||
for f in families:
|
||||
fam_rows.append([f["name"], f["kontoinhaber"], f["iban"]])
|
||||
write_csv(
|
||||
os.path.join(out_dir, "Familien.csv"),
|
||||
["Name", "Kontoinhaber", "IBAN"],
|
||||
fam_rows,
|
||||
)
|
||||
|
||||
# --- Mitglieder.csv ---
|
||||
mitglieder_rows = []
|
||||
for m in members:
|
||||
key = (m["nachname"], m["vorname"])
|
||||
ki = kontakt_info.get(key, {})
|
||||
stufe_name = STUFEN_MAP.get(m["stufe_code"], "")
|
||||
status = "aktiv" if m["aktiv"] else "inaktiv"
|
||||
family_name = member_family.get(key, "")
|
||||
|
||||
mitglieder_rows.append([
|
||||
m["vorname"],
|
||||
m["nachname"],
|
||||
m["geburtsdatum"],
|
||||
"", # Geschlecht — not in source
|
||||
"mitglied", # Rolle
|
||||
stufe_name,
|
||||
"2025-01-01", # Eintritt — not in source, use reasonable default
|
||||
"", # Austritt
|
||||
status,
|
||||
ki.get("allergien", ""),
|
||||
ki.get("notizen", ""),
|
||||
"", # Zusaetzliche Notizen
|
||||
"", # KV-Typ
|
||||
"", # KV-Name
|
||||
family_name,
|
||||
m["beitrag"], # Eingefrorener Beitragssatz
|
||||
])
|
||||
write_csv(
|
||||
os.path.join(out_dir, "Mitglieder.csv"),
|
||||
[
|
||||
"Vorname", "Nachname", "Geburtsdatum", "Geschlecht", "Rolle",
|
||||
"Stufenname", "Eintritt", "Austritt", "Status", "Allergien",
|
||||
"Notizen", "Zusaetzliche Notizen", "KV-Typ", "KV-Name",
|
||||
"Familienname", "Eingefrorener Beitragssatz",
|
||||
],
|
||||
mitglieder_rows,
|
||||
)
|
||||
|
||||
# --- Adressen.csv ---
|
||||
addr_rows = []
|
||||
for m in members:
|
||||
strasse, plz, ort = parse_address(m["adresse_raw"])
|
||||
if not strasse:
|
||||
continue
|
||||
mitgliedername = f"{m['vorname']} {m['nachname']}"
|
||||
addr_rows.append([
|
||||
mitgliedername, "Hauptadresse", strasse, plz, ort, "Deutschland", "Ja"
|
||||
])
|
||||
write_csv(
|
||||
os.path.join(out_dir, "Adressen.csv"),
|
||||
["Mitgliedername", "Label", "Strasse", "PLZ", "Ort", "Land", "Primaer"],
|
||||
addr_rows,
|
||||
)
|
||||
|
||||
# --- Telefonnummern.csv ---
|
||||
phone_rows = []
|
||||
for m in members:
|
||||
# Member's own phone
|
||||
if m["telefon"]:
|
||||
mitgliedername = f"{m['vorname']} {m['nachname']}"
|
||||
phone_rows.append([mitgliedername, "Mobil", m["telefon"]])
|
||||
|
||||
# Parent phones from Kontaktdaten sheets
|
||||
seen_parent_phones = set()
|
||||
for (nachname, vorname), ki in kontakt_info.items():
|
||||
parent_phone = ki.get("parent_phone", "")
|
||||
if not parent_phone:
|
||||
continue
|
||||
mitgliedername = f"{vorname} {nachname}"
|
||||
# Split "number oder number" into separate entries
|
||||
phones = [p.strip() for p in parent_phone.split("oder")]
|
||||
for i, phone in enumerate(phones):
|
||||
if not phone:
|
||||
continue
|
||||
dedup_key = (mitgliedername, phone)
|
||||
if dedup_key in seen_parent_phones:
|
||||
continue
|
||||
seen_parent_phones.add(dedup_key)
|
||||
label = f"Eltern {i+1}" if len(phones) > 1 else "Eltern"
|
||||
phone_rows.append([mitgliedername, label, phone])
|
||||
|
||||
write_csv(
|
||||
os.path.join(out_dir, "Telefonnummern.csv"),
|
||||
["Mitgliedername", "Label", "Nummer"],
|
||||
phone_rows,
|
||||
)
|
||||
|
||||
# --- E-Mails.csv ---
|
||||
email_rows = []
|
||||
for m in members:
|
||||
if m["email"]:
|
||||
mitgliedername = f"{m['vorname']} {m['nachname']}"
|
||||
email_rows.append([mitgliedername, "Privat", m["email"]])
|
||||
write_csv(
|
||||
os.path.join(out_dir, "E-Mails.csv"),
|
||||
["Mitgliedername", "Label", "E-Mail-Adresse"],
|
||||
email_rows,
|
||||
)
|
||||
|
||||
# --- Create ZIP bundle ---
|
||||
zip_path = os.path.join(out_dir, "import_bundle.zip")
|
||||
csv_files = [
|
||||
"Stufen.csv", "Familien.csv", "Mitglieder.csv",
|
||||
"Adressen.csv", "Telefonnummern.csv", "E-Mails.csv",
|
||||
]
|
||||
with zipfile.ZipFile(zip_path, "w", zipfile.ZIP_DEFLATED) as zf:
|
||||
for name in csv_files:
|
||||
fpath = os.path.join(out_dir, name)
|
||||
if os.path.exists(fpath):
|
||||
zf.write(fpath, name)
|
||||
print(f"\n import_bundle.zip created ({len(csv_files)} files)")
|
||||
|
||||
print(f"\nDone! Import via: Mitgliederverwaltung → Import → ZIP-Bundle hochladen")
|
||||
print(f" {zip_path}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
+1
-1
@@ -31,7 +31,7 @@ app.use(router)
|
||||
// @nextcloud/vue v9 reads appName/appVersion via Vue's inject(),
|
||||
// not via webpack DefinePlugin globals.
|
||||
app.provide('appName', 'mitgliederverwaltung')
|
||||
app.provide('appVersion', '0.1.0')
|
||||
app.provide('appVersion', '0.1.2')
|
||||
|
||||
app.mount('#mitgliederverwaltung')
|
||||
|
||||
|
||||
@@ -947,17 +947,18 @@ async function onExecuteWithMerge() {
|
||||
}
|
||||
|
||||
.import-wizard__summary-item--success {
|
||||
background: var(--color-success);
|
||||
background: var(--color-success, #2d7b41);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.import-wizard__summary-item--warning {
|
||||
background: var(--color-warning);
|
||||
color: white;
|
||||
background: #fef3cd;
|
||||
color: #664d03;
|
||||
border: 1px solid #e9d78a;
|
||||
}
|
||||
|
||||
.import-wizard__summary-item--error {
|
||||
background: var(--color-error);
|
||||
background: var(--color-error, #DB0606);
|
||||
color: white;
|
||||
}
|
||||
|
||||
|
||||
+1
-1
@@ -41,7 +41,7 @@ module.exports = {
|
||||
new VueLoaderPlugin(),
|
||||
new webpack.DefinePlugin({
|
||||
appName: JSON.stringify('mitgliederverwaltung'),
|
||||
appVersion: JSON.stringify('0.1.0'),
|
||||
appVersion: JSON.stringify('0.1.2'),
|
||||
}),
|
||||
new webpack.optimize.LimitChunkCountPlugin({
|
||||
maxChunks: 1,
|
||||
|
||||
Reference in New Issue
Block a user