mirror of
https://git.hiabuto.net/C3MA/CCMA.git
synced 2026-07-01 19:26:53 +02:00
feat: initialize CCMA member administration
This commit is contained in:
@@ -0,0 +1,3 @@
|
||||
from ccma.domain.models import ContributionData, Event, Member
|
||||
|
||||
__all__ = ["ContributionData", "Event", "Member"]
|
||||
@@ -0,0 +1,162 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import locale
|
||||
import os
|
||||
import re
|
||||
from datetime import date, datetime
|
||||
|
||||
|
||||
class DateValidationError(ValueError):
|
||||
pass
|
||||
|
||||
|
||||
def setup_system_locale() -> None:
|
||||
try:
|
||||
locale.setlocale(locale.LC_TIME, "")
|
||||
except locale.Error:
|
||||
pass
|
||||
|
||||
|
||||
def system_date_pattern() -> str:
|
||||
try:
|
||||
system_pattern = locale.nl_langinfo(locale.D_FMT)
|
||||
except (AttributeError, ValueError):
|
||||
system_pattern = ""
|
||||
year_position = system_pattern.find("%Y")
|
||||
day_position = system_pattern.find("%d")
|
||||
month_position = system_pattern.find("%m")
|
||||
if (
|
||||
year_position >= 0
|
||||
and day_position >= 0
|
||||
and month_position >= 0
|
||||
and year_position < min(day_position, month_position)
|
||||
):
|
||||
return "%Y-%m-%d"
|
||||
if day_position >= 0 and month_position >= 0 and day_position < month_position:
|
||||
return "%d.%m.%Y"
|
||||
locale_hint = " ".join(
|
||||
filter(
|
||||
None,
|
||||
(
|
||||
os.environ.get("LC_TIME"),
|
||||
os.environ.get("LANGUAGE"),
|
||||
os.environ.get("LANG"),
|
||||
),
|
||||
)
|
||||
).lower()
|
||||
day_first_languages = ("de", "at", "ch", "fr", "it", "es", "pt", "nl", "pl", "cs")
|
||||
return "%d.%m.%Y" if locale_hint.startswith(day_first_languages) else "%Y-%m-%d"
|
||||
|
||||
|
||||
def date_input_hint() -> str:
|
||||
return "DD.MM.YYYY" if system_date_pattern() == "%d.%m.%Y" else "YYYY-MM-DD"
|
||||
|
||||
|
||||
def parse_date_input(value: str, field_name: str, *, allow_empty: bool = True) -> date | None:
|
||||
text = value.strip()
|
||||
if not text:
|
||||
if allow_empty:
|
||||
return None
|
||||
raise DateValidationError(f"{field_name} ist erforderlich.")
|
||||
patterns = (system_date_pattern(), "%Y-%m-%d", "%d.%m.%Y")
|
||||
for pattern in dict.fromkeys(patterns):
|
||||
expected = r"\d{2}\.\d{2}\.\d{4}" if pattern == "%d.%m.%Y" else r"\d{4}-\d{2}-\d{2}"
|
||||
if not re.fullmatch(expected, text):
|
||||
continue
|
||||
try:
|
||||
return datetime.strptime(text, pattern).date()
|
||||
except ValueError:
|
||||
continue
|
||||
formats = date_input_hint()
|
||||
if formats != "YYYY-MM-DD":
|
||||
formats += " oder YYYY-MM-DD"
|
||||
raise DateValidationError(f"{field_name} muss ein gültiges Datum im Format {formats} sein.")
|
||||
|
||||
|
||||
def normalize_date_input(value: str, field_name: str) -> str:
|
||||
parsed = parse_date_input(value, field_name)
|
||||
return parsed.isoformat() if parsed else ""
|
||||
|
||||
|
||||
def format_date_for_display(value: str) -> str:
|
||||
if not value.strip():
|
||||
return ""
|
||||
parsed = parse_iso_date(value, "Datum")
|
||||
return parsed.strftime(system_date_pattern()) if parsed else ""
|
||||
|
||||
|
||||
def parse_iso_date(value: str, field_name: str, *, allow_empty: bool = True) -> date | None:
|
||||
text = value.strip()
|
||||
if not text:
|
||||
if allow_empty:
|
||||
return None
|
||||
raise DateValidationError(f"{field_name} ist erforderlich.")
|
||||
if not re.fullmatch(r"\d{4}-\d{2}-\d{2}", text):
|
||||
raise DateValidationError(f"{field_name} muss das Format JJJJ-MM-TT haben.")
|
||||
try:
|
||||
return date.fromisoformat(text)
|
||||
except ValueError as exc:
|
||||
raise DateValidationError(f"{field_name} ist kein gültiges Kalenderdatum.") from exc
|
||||
|
||||
|
||||
def validate_birth_date(value: str, *, today: date | None = None) -> date | None:
|
||||
parsed = parse_iso_date(value, "Geburtsdatum")
|
||||
if parsed is None:
|
||||
return None
|
||||
reference = today or date.today()
|
||||
_validate_birth_date_value(parsed, reference)
|
||||
return parsed
|
||||
|
||||
|
||||
def validate_member_dates(
|
||||
*,
|
||||
birth_date: str,
|
||||
accepted_at: str = "",
|
||||
membership_started_at: str = "",
|
||||
today: date | None = None,
|
||||
) -> None:
|
||||
reference = today or date.today()
|
||||
birth = validate_birth_date(birth_date, today=reference)
|
||||
accepted = _validate_not_future(accepted_at, "Aufnahmebeschluss", reference)
|
||||
started = _validate_not_future(membership_started_at, "Mitglied seit", reference)
|
||||
if birth and accepted and accepted < birth:
|
||||
raise DateValidationError("Aufnahmebeschluss darf nicht vor dem Geburtsdatum liegen.")
|
||||
if birth and started and started < birth:
|
||||
raise DateValidationError("Mitgliedschaft darf nicht vor dem Geburtsdatum beginnen.")
|
||||
if accepted and started and started < accepted:
|
||||
raise DateValidationError("Mitgliedschaft darf nicht vor dem Aufnahmebeschluss beginnen.")
|
||||
|
||||
|
||||
def calculate_age(birth_date: date, on_date: date | None = None) -> int:
|
||||
reference = on_date or date.today()
|
||||
return (
|
||||
reference.year
|
||||
- birth_date.year
|
||||
- ((reference.month, reference.day) < (birth_date.month, birth_date.day))
|
||||
)
|
||||
|
||||
|
||||
def age_label(value: str, *, today: date | None = None) -> str:
|
||||
if not value.strip():
|
||||
return "Alter: —"
|
||||
try:
|
||||
parsed = parse_date_input(value, "Geburtsdatum")
|
||||
if parsed:
|
||||
_validate_birth_date_value(parsed, today or date.today())
|
||||
except DateValidationError:
|
||||
return "UNGÜLTIGES DATUM"
|
||||
return f"Alter: {calculate_age(parsed, today)} Jahre" if parsed else "Alter: —"
|
||||
|
||||
|
||||
def _validate_not_future(value: str, field_name: str, reference: date) -> date | None:
|
||||
parsed = parse_iso_date(value, field_name)
|
||||
if parsed and parsed > reference:
|
||||
raise DateValidationError(f"{field_name} darf nicht in der Zukunft liegen.")
|
||||
return parsed
|
||||
|
||||
|
||||
def _validate_birth_date_value(parsed: date, reference: date) -> None:
|
||||
if parsed > reference:
|
||||
raise DateValidationError("Geburtsdatum darf nicht in der Zukunft liegen.")
|
||||
if calculate_age(parsed, reference) > 120:
|
||||
raise DateValidationError("Geburtsdatum ist unplausibel: Das berechnete Alter liegt über 120.")
|
||||
@@ -0,0 +1,169 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import date, datetime
|
||||
from decimal import Decimal
|
||||
from typing import Any
|
||||
|
||||
|
||||
def _iso_now() -> str:
|
||||
return datetime.now().astimezone().isoformat(timespec="seconds")
|
||||
|
||||
|
||||
MEMBERSHIP_STATUS_LABELS = {
|
||||
"application": "ANTRAG",
|
||||
"accepted_pending_payment": "ANGENOMMEN / ZAHLUNG OFFEN",
|
||||
"active": "AKTIV",
|
||||
"suspended_contribution": "RUHEND / BEITRAG",
|
||||
"resigned_end_of_year": "AUSTRITT ZUM JAHRESENDE",
|
||||
"honorary": "EHRENMITGLIED",
|
||||
"ended": "BEENDET",
|
||||
}
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class Member:
|
||||
member_id: str
|
||||
member_number: str
|
||||
first_name: str
|
||||
last_name: str
|
||||
email: str = ""
|
||||
birth_date: str = ""
|
||||
status: str = "application"
|
||||
accepted_at: str = ""
|
||||
membership_started_at: str = ""
|
||||
payment_frequency: str = "annual"
|
||||
contribution_rule_id: str = "standard-2022"
|
||||
honorary: bool = False
|
||||
notes: str = ""
|
||||
created_at: str = field(default_factory=_iso_now)
|
||||
updated_at: str = field(default_factory=_iso_now)
|
||||
schema_version: int = 1
|
||||
|
||||
@property
|
||||
def display_name(self) -> str:
|
||||
return " ".join(part for part in (self.first_name, self.last_name) if part).strip()
|
||||
|
||||
def to_dict(self) -> dict[str, Any]:
|
||||
return {
|
||||
"schema_version": self.schema_version,
|
||||
"member_id": self.member_id,
|
||||
"member_number": self.member_number,
|
||||
"person": {
|
||||
"first_name": self.first_name,
|
||||
"last_name": self.last_name,
|
||||
"birth_date": self.birth_date,
|
||||
"email": self.email,
|
||||
},
|
||||
"membership": {
|
||||
"status": self.status,
|
||||
"accepted_at": self.accepted_at,
|
||||
"started_at": self.membership_started_at,
|
||||
"honorary": self.honorary,
|
||||
},
|
||||
"contribution_profile": {
|
||||
"rule_id": self.contribution_rule_id,
|
||||
"payment_frequency": self.payment_frequency,
|
||||
},
|
||||
"notes": self.notes,
|
||||
"created_at": self.created_at,
|
||||
"updated_at": self.updated_at,
|
||||
}
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, data: dict[str, Any]) -> Member:
|
||||
person = data.get("person") or {}
|
||||
membership = data.get("membership") or {}
|
||||
contribution = data.get("contribution_profile") or {}
|
||||
return cls(
|
||||
schema_version=int(data.get("schema_version", 1)),
|
||||
member_id=str(data["member_id"]),
|
||||
member_number=str(data.get("member_number", "")),
|
||||
first_name=str(person.get("first_name", "")),
|
||||
last_name=str(person.get("last_name", "")),
|
||||
email=str(person.get("email", "")),
|
||||
birth_date=str(person.get("birth_date", "")),
|
||||
status=str(membership.get("status", "application")),
|
||||
accepted_at=str(membership.get("accepted_at", "")),
|
||||
membership_started_at=str(membership.get("started_at", "")),
|
||||
honorary=bool(membership.get("honorary", False)),
|
||||
contribution_rule_id=str(contribution.get("rule_id", "standard-2022")),
|
||||
payment_frequency=str(contribution.get("payment_frequency", "annual")),
|
||||
notes=str(data.get("notes", "")),
|
||||
created_at=str(data.get("created_at", _iso_now())),
|
||||
updated_at=str(data.get("updated_at", _iso_now())),
|
||||
)
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class Event:
|
||||
event_id: str
|
||||
timestamp: str
|
||||
event_type: str
|
||||
summary: str
|
||||
actor_type: str = "system"
|
||||
actor_name: str = "CCMA"
|
||||
references: dict[str, str] = field(default_factory=dict)
|
||||
data: dict[str, Any] = field(default_factory=dict)
|
||||
|
||||
def to_dict(self) -> dict[str, Any]:
|
||||
return {
|
||||
"schema_version": 1,
|
||||
"event_id": self.event_id,
|
||||
"timestamp": self.timestamp,
|
||||
"type": self.event_type,
|
||||
"actor": {"type": self.actor_type, "name": self.actor_name},
|
||||
"summary": self.summary,
|
||||
"references": self.references,
|
||||
"data": self.data,
|
||||
}
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, data: dict[str, Any]) -> Event:
|
||||
actor = data.get("actor") or {}
|
||||
return cls(
|
||||
event_id=str(data["event_id"]),
|
||||
timestamp=str(data["timestamp"]),
|
||||
event_type=str(data.get("type", "unknown")),
|
||||
summary=str(data.get("summary", "")),
|
||||
actor_type=str(actor.get("type", "system")),
|
||||
actor_name=str(actor.get("name", "CCMA")),
|
||||
references=dict(data.get("references") or {}),
|
||||
data=dict(data.get("data") or {}),
|
||||
)
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class ContributionData:
|
||||
claims: list[dict[str, Any]] = field(default_factory=list)
|
||||
payments: list[dict[str, Any]] = field(default_factory=list)
|
||||
schema_version: int = 1
|
||||
|
||||
def to_dict(self) -> dict[str, Any]:
|
||||
return {
|
||||
"schema_version": self.schema_version,
|
||||
"claims": self.claims,
|
||||
"payments": self.payments,
|
||||
}
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, data: dict[str, Any]) -> ContributionData:
|
||||
return cls(
|
||||
schema_version=int(data.get("schema_version", 1)),
|
||||
claims=list(data.get("claims") or []),
|
||||
payments=list(data.get("payments") or []),
|
||||
)
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class HousekeeperFinding:
|
||||
severity: str
|
||||
member_id: str
|
||||
code: str
|
||||
title: str
|
||||
detail: str
|
||||
due_date: date | None = None
|
||||
|
||||
|
||||
def money(value: str | int | float | Decimal) -> Decimal:
|
||||
return Decimal(str(value)).quantize(Decimal("0.01"))
|
||||
Reference in New Issue
Block a user