feat(portal): read-only Firmensummen für das Buchhaltungsportal (Token-Endpunkt)
GET /portal/summary liefert Resturlaub und Überstundenguthaben als Firmensummen, keine Einzelpersonen und keine Krankheitsdaten. Firmen-Token (SHA-256) wie beim Busylight, Verwaltung unter /companies/me/portal-token, Migration 0049. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
80cee20fe5
commit
f0b436c91f
7 files changed
+397
No files matched your search
@@ -22,6 +22,7 @@ from app.routers import hours_payouts
|
||||
from app.routers import public_stamp
|
||||
from app.routers import ical
|
||||
from app.routers import ha
|
||||
from app.routers import portal
|
||||
from app.routers import reseller, tenants
|
||||
from app.routers import tls_admin
|
||||
|
||||
@@ -142,6 +143,7 @@ app.include_router(time_entries.router, prefix=API_PREFIX)
|
||||
app.include_router(public_stamp.router, prefix=API_PREFIX)
|
||||
app.include_router(ical.router, prefix=API_PREFIX)
|
||||
app.include_router(ha.router, prefix=API_PREFIX)
|
||||
app.include_router(portal.router, prefix=API_PREFIX)
|
||||
app.include_router(absences.router, prefix=API_PREFIX)
|
||||
app.include_router(reports.router, prefix=API_PREFIX)
|
||||
app.include_router(ldap.router, prefix=API_PREFIX)
|
||||
|
||||
@@ -55,6 +55,10 @@ class Company(Base):
|
||||
busylight_pull_token_hash: Mapped[str | None] = mapped_column(String(64), unique=True)
|
||||
busylight_token_created_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
|
||||
|
||||
# Buchhaltungsportal: SHA-256-Hash des Tokens für den read-only Summen-Endpunkt (/portal/summary).
|
||||
portal_token_hash: Mapped[str | None] = mapped_column(String(64), unique=True)
|
||||
portal_token_created_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
|
||||
|
||||
# Öffentliches QR-Stempeln: statischer firmenweiter QR-Code → /stamp?t=<token>.
|
||||
# Mitarbeiter scannt mit Privat-Handy, meldet sich per Personalnummer + PIN an.
|
||||
# Opt-in (default OFF). Token gehasht in DB (SHA-256), Klartext nur beim Rotieren.
|
||||
|
||||
@@ -0,0 +1,159 @@
|
||||
"""Buchhaltungsportal: read-only Summen (Pull-Endpoint + Token-Verwaltung).
|
||||
|
||||
- Pull: GET /portal/summary – Auth via per-Firma Bearer-Token (SHA-256 in DB)
|
||||
- Verwaltung: GET/POST/DELETE /companies/me/portal-token – COMPANY_ADMIN/SUPER_ADMIN
|
||||
|
||||
Liefert nur Firmensummen zu Resturlaub und Überstundenguthaben. Keine Einzelpersonen,
|
||||
keine Krankheits- oder Abwesenheitsdaten.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import secrets
|
||||
from datetime import date, datetime, timezone
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Request
|
||||
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.core.database import get_db
|
||||
from app.core.dependencies import get_client_ip, require_role
|
||||
from app.core.limiter import limiter
|
||||
from app.models.audit_log import AuditLog
|
||||
from app.models.company import Company
|
||||
from app.models.overtime_balance import OvertimeBalance
|
||||
from app.models.user import User, UserRole
|
||||
from app.models.vacation_balance import VacationBalance
|
||||
from app.schemas.portal import (
|
||||
PortalSummary,
|
||||
PortalTokenRotated,
|
||||
PortalTokenStatus,
|
||||
PortalUeberstunden,
|
||||
PortalUrlaub,
|
||||
)
|
||||
from app.services.absence_service import absence_service
|
||||
|
||||
router = APIRouter(tags=["Buchhaltungsportal"])
|
||||
|
||||
_admin_roles = (UserRole.COMPANY_ADMIN, UserRole.SUPER_ADMIN)
|
||||
_pull_bearer = HTTPBearer(auto_error=False)
|
||||
KLEINE_GRUPPE = 3
|
||||
|
||||
|
||||
def _hash_token(token: str) -> str:
|
||||
return hashlib.sha256(token.encode("utf-8")).hexdigest()
|
||||
|
||||
|
||||
@router.get("/companies/me/portal-token", response_model=PortalTokenStatus)
|
||||
async def get_portal_token_status(
|
||||
current_user: User = require_role(*_admin_roles),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
company = await db.get(Company, current_user.company_id)
|
||||
return PortalTokenStatus(configured=company.portal_token_hash is not None, created_at=company.portal_token_created_at)
|
||||
|
||||
|
||||
@router.post("/companies/me/portal-token/rotate", response_model=PortalTokenRotated)
|
||||
async def rotate_portal_token(
|
||||
request: Request,
|
||||
current_user: User = require_role(*_admin_roles),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
company = await db.get(Company, current_user.company_id)
|
||||
token = secrets.token_urlsafe(32)
|
||||
company.portal_token_hash = _hash_token(token)
|
||||
company.portal_token_created_at = datetime.now(timezone.utc)
|
||||
db.add(AuditLog(
|
||||
company_id=company.id,
|
||||
user_id=current_user.id,
|
||||
action="portal_token_rotated",
|
||||
entity_type="company",
|
||||
entity_id=company.id,
|
||||
ip=get_client_ip(request),
|
||||
))
|
||||
await db.commit()
|
||||
return PortalTokenRotated(token=token, created_at=company.portal_token_created_at)
|
||||
|
||||
|
||||
@router.delete("/companies/me/portal-token", status_code=204)
|
||||
async def delete_portal_token(
|
||||
request: Request,
|
||||
current_user: User = require_role(*_admin_roles),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
company = await db.get(Company, current_user.company_id)
|
||||
if company.portal_token_hash is None:
|
||||
return
|
||||
company.portal_token_hash = None
|
||||
company.portal_token_created_at = None
|
||||
db.add(AuditLog(
|
||||
company_id=company.id,
|
||||
user_id=current_user.id,
|
||||
action="portal_token_revoked",
|
||||
entity_type="company",
|
||||
entity_id=company.id,
|
||||
ip=get_client_ip(request),
|
||||
))
|
||||
await db.commit()
|
||||
|
||||
|
||||
async def _company_from_token(credentials: HTTPAuthorizationCredentials | None, db: AsyncSession) -> Company:
|
||||
if credentials is None or not credentials.credentials:
|
||||
raise HTTPException(status_code=401, detail="Missing token")
|
||||
company = await db.scalar(
|
||||
select(Company).where(Company.portal_token_hash == _hash_token(credentials.credentials), Company.is_active.is_(True))
|
||||
)
|
||||
if company is None:
|
||||
raise HTTPException(status_code=401, detail="Invalid token")
|
||||
return company
|
||||
|
||||
|
||||
@router.get("/portal/summary", response_model=PortalSummary)
|
||||
@limiter.limit("30/minute")
|
||||
async def portal_summary(
|
||||
request: Request,
|
||||
year: int | None = None,
|
||||
credentials: HTTPAuthorizationCredentials | None = Depends(_pull_bearer),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
company = await _company_from_token(credentials, db)
|
||||
today = date.today()
|
||||
jahr = year or today.year
|
||||
if not 2000 <= jahr <= 2200:
|
||||
raise HTTPException(status_code=422, detail="Ungültiges Jahr.")
|
||||
|
||||
user_ids = list(await db.scalars(select(User.id).where(User.company_id == company.id, User.is_active.is_(True))))
|
||||
|
||||
balances = list(await db.scalars(
|
||||
select(VacationBalance).where(VacationBalance.user_id.in_(user_ids), VacationBalance.year == jahr)
|
||||
)) if user_ids else []
|
||||
_, verfallen = absence_service._carryover_expired(company, jahr)
|
||||
rest = sum(absence_service.effective_available(b, verfallen) for b in balances)
|
||||
|
||||
overtime = list(await db.scalars(
|
||||
select(OvertimeBalance).where(OvertimeBalance.company_id == company.id, OvertimeBalance.user_id.in_(user_ids))
|
||||
)) if user_ids else []
|
||||
verfuegbar = [float(o.available_hours) for o in overtime]
|
||||
|
||||
return PortalSummary(
|
||||
stichtag=today,
|
||||
firma=company.name,
|
||||
mitarbeiter_aktiv=len(user_ids),
|
||||
kleine_gruppe=len(user_ids) < KLEINE_GRUPPE,
|
||||
urlaub=PortalUrlaub(
|
||||
jahr=jahr,
|
||||
konten=len(balances),
|
||||
ohne_konto=len(user_ids) - len(balances),
|
||||
anspruch_tage=float(sum(b.entitled_days + b.special_days for b in balances)),
|
||||
uebertrag_tage=float(sum(b.carried_over for b in balances)),
|
||||
genommen_tage=float(sum(b.used_days for b in balances)),
|
||||
resttage=float(rest),
|
||||
),
|
||||
ueberstunden=PortalUeberstunden(
|
||||
konten=len(overtime),
|
||||
guthaben_gesamt_h=round(sum(verfuegbar), 2),
|
||||
guthaben_positiv_h=round(sum(v for v in verfuegbar if v > 0), 2),
|
||||
mitarbeiter_mit_guthaben=sum(1 for v in verfuegbar if v > 0),
|
||||
),
|
||||
)
|
||||
@@ -0,0 +1,39 @@
|
||||
from datetime import date, datetime
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
|
||||
class PortalTokenStatus(BaseModel):
|
||||
configured: bool
|
||||
created_at: datetime | None = None
|
||||
|
||||
|
||||
class PortalTokenRotated(BaseModel):
|
||||
token: str = Field(..., description="Klartext-Token, wird nur einmal angezeigt.")
|
||||
created_at: datetime
|
||||
|
||||
|
||||
class PortalUrlaub(BaseModel):
|
||||
jahr: int
|
||||
konten: int = Field(..., description="Aktive Mitarbeiter mit Urlaubskonto im Jahr.")
|
||||
ohne_konto: int
|
||||
anspruch_tage: float
|
||||
uebertrag_tage: float
|
||||
genommen_tage: float
|
||||
resttage: float
|
||||
|
||||
|
||||
class PortalUeberstunden(BaseModel):
|
||||
konten: int
|
||||
guthaben_gesamt_h: float
|
||||
guthaben_positiv_h: float
|
||||
mitarbeiter_mit_guthaben: int
|
||||
|
||||
|
||||
class PortalSummary(BaseModel):
|
||||
stichtag: date
|
||||
firma: str
|
||||
mitarbeiter_aktiv: int
|
||||
kleine_gruppe: bool = Field(..., description="Weniger als drei aktive Mitarbeiter: Summen können Einzelwerte verraten.")
|
||||
urlaub: PortalUrlaub
|
||||
ueberstunden: PortalUeberstunden
|
||||
@@ -0,0 +1,31 @@
|
||||
"""Buchhaltungsportal: companies.portal_token_hash + created_at
|
||||
|
||||
Revision ID: 0049
|
||||
Revises: 0048
|
||||
Create Date: 2026-10-29
|
||||
|
||||
Gehashtes, rotierbares Firmen-Token für den read-only Summen-Endpunkt
|
||||
(/portal/summary). NULL = deaktiviert. Keine RLS-Änderung (nullable Spalten
|
||||
auf bestehender companies-Policy).
|
||||
"""
|
||||
from alembic import op
|
||||
|
||||
revision = "0049"
|
||||
down_revision = "0048"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.execute("ALTER TABLE companies ADD COLUMN IF NOT EXISTS portal_token_hash VARCHAR(64)")
|
||||
op.execute("ALTER TABLE companies ADD COLUMN IF NOT EXISTS portal_token_created_at TIMESTAMPTZ")
|
||||
op.execute(
|
||||
"CREATE UNIQUE INDEX IF NOT EXISTS ix_companies_portal_token_hash "
|
||||
"ON companies (portal_token_hash) WHERE portal_token_hash IS NOT NULL"
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.execute("DROP INDEX IF EXISTS ix_companies_portal_token_hash")
|
||||
op.execute("ALTER TABLE companies DROP COLUMN IF EXISTS portal_token_created_at")
|
||||
op.execute("ALTER TABLE companies DROP COLUMN IF EXISTS portal_token_hash")
|
||||
@@ -0,0 +1,123 @@
|
||||
"""Tests für den Summen-Endpunkt des Buchhaltungsportals (router/portal.py)."""
|
||||
import pytest
|
||||
import pytest_asyncio
|
||||
from datetime import date
|
||||
from httpx import AsyncClient
|
||||
|
||||
|
||||
async def _registriere(client: AsyncClient, name: str, mail: str):
|
||||
resp = await client.post("/api/v1/auth/register", json={
|
||||
"company_name": name,
|
||||
"first_name": "Portal",
|
||||
"last_name": "Admin",
|
||||
"email": mail,
|
||||
"password": "Secret123",
|
||||
})
|
||||
assert resp.status_code == 201, resp.text
|
||||
return {"Authorization": f"Bearer {resp.json()['access_token']}"}
|
||||
|
||||
|
||||
@pytest_asyncio.fixture(scope="session", loop_scope="session")
|
||||
async def pt_headers(client: AsyncClient):
|
||||
return await _registriere(client, "Portal GmbH", "admin@portal-test.de")
|
||||
|
||||
|
||||
@pytest_asyncio.fixture(scope="session", loop_scope="session")
|
||||
async def pt_token(client: AsyncClient, pt_headers):
|
||||
resp = await client.post("/api/v1/companies/me/portal-token/rotate", headers=pt_headers)
|
||||
assert resp.status_code == 200, resp.text
|
||||
return resp.json()["token"]
|
||||
|
||||
|
||||
def _bearer(token: str) -> dict:
|
||||
return {"Authorization": f"Bearer {token}"}
|
||||
|
||||
|
||||
@pytest.mark.asyncio(loop_scope="session")
|
||||
async def test_token_not_configured_by_default(client: AsyncClient):
|
||||
headers = await _registriere(client, "Portal Leer GmbH", "admin@portal-leer.de")
|
||||
r = await client.get("/api/v1/companies/me/portal-token", headers=headers)
|
||||
assert r.status_code == 200
|
||||
assert r.json()["configured"] is False
|
||||
|
||||
|
||||
@pytest.mark.asyncio(loop_scope="session")
|
||||
async def test_summary_requires_valid_token(client: AsyncClient, pt_token):
|
||||
assert (await client.get("/api/v1/portal/summary")).status_code == 401
|
||||
assert (await client.get("/api/v1/portal/summary", headers=_bearer("falsch"))).status_code == 401
|
||||
assert (await client.get("/api/v1/portal/summary", headers=_bearer(pt_token))).status_code == 200
|
||||
|
||||
|
||||
@pytest.mark.asyncio(loop_scope="session")
|
||||
async def test_summary_shape_and_small_group(client: AsyncClient, pt_token):
|
||||
body = (await client.get("/api/v1/portal/summary", headers=_bearer(pt_token))).json()
|
||||
assert body["stichtag"] == str(date.today())
|
||||
assert body["firma"] == "Portal GmbH"
|
||||
assert body["mitarbeiter_aktiv"] >= 1
|
||||
assert body["kleine_gruppe"] is True
|
||||
assert set(body) == {"stichtag", "firma", "mitarbeiter_aktiv", "kleine_gruppe", "urlaub", "ueberstunden"}
|
||||
assert set(body["urlaub"]) == {"jahr", "konten", "ohne_konto", "anspruch_tage", "uebertrag_tage", "genommen_tage", "resttage"}
|
||||
assert set(body["ueberstunden"]) == {"konten", "guthaben_gesamt_h", "guthaben_positiv_h", "mitarbeiter_mit_guthaben"}
|
||||
|
||||
|
||||
@pytest.mark.asyncio(loop_scope="session")
|
||||
async def test_summary_contains_no_sickness_data(client: AsyncClient, pt_token, pt_headers):
|
||||
vorher = (await client.get("/api/v1/portal/summary", headers=_bearer(pt_token))).json()
|
||||
heute = str(date.today())
|
||||
sick = await client.post("/api/v1/absences/quick-sick", json={"start_date": heute, "end_date": heute}, headers=pt_headers)
|
||||
assert sick.status_code in (200, 201), sick.text
|
||||
nachher = (await client.get("/api/v1/portal/summary", headers=_bearer(pt_token))).json()
|
||||
assert nachher["urlaub"]["genommen_tage"] == vorher["urlaub"]["genommen_tage"]
|
||||
assert "krank" not in str(nachher).lower() and "sick" not in str(nachher).lower()
|
||||
|
||||
|
||||
@pytest.mark.asyncio(loop_scope="session")
|
||||
async def test_summary_rejects_invalid_year(client: AsyncClient, pt_token):
|
||||
assert (await client.get("/api/v1/portal/summary?year=1500", headers=_bearer(pt_token))).status_code == 422
|
||||
|
||||
|
||||
@pytest.mark.asyncio(loop_scope="session")
|
||||
async def test_tokens_are_scoped_to_their_company(client: AsyncClient, pt_token):
|
||||
andere = await _registriere(client, "Portal Andere GmbH", "admin@portal-andere.de")
|
||||
token = (await client.post("/api/v1/companies/me/portal-token/rotate", headers=andere)).json()["token"]
|
||||
a = (await client.get("/api/v1/portal/summary", headers=_bearer(pt_token))).json()
|
||||
b = (await client.get("/api/v1/portal/summary", headers=_bearer(token))).json()
|
||||
assert a["firma"] == "Portal GmbH"
|
||||
assert b["firma"] == "Portal Andere GmbH"
|
||||
|
||||
|
||||
@pytest.mark.asyncio(loop_scope="session")
|
||||
async def test_rotate_invalidates_old_token_and_revoke_disables(client: AsyncClient):
|
||||
headers = await _registriere(client, "Portal Rotation GmbH", "admin@portal-rotation.de")
|
||||
alt = (await client.post("/api/v1/companies/me/portal-token/rotate", headers=headers)).json()["token"]
|
||||
neu = (await client.post("/api/v1/companies/me/portal-token/rotate", headers=headers)).json()["token"]
|
||||
assert alt != neu
|
||||
assert (await client.get("/api/v1/portal/summary", headers=_bearer(alt))).status_code == 401
|
||||
assert (await client.get("/api/v1/portal/summary", headers=_bearer(neu))).status_code == 200
|
||||
assert (await client.get("/api/v1/companies/me/portal-token", headers=headers)).json()["configured"] is True
|
||||
assert (await client.delete("/api/v1/companies/me/portal-token", headers=headers)).status_code == 204
|
||||
assert (await client.get("/api/v1/portal/summary", headers=_bearer(neu))).status_code == 401
|
||||
assert (await client.get("/api/v1/companies/me/portal-token", headers=headers)).json()["configured"] is False
|
||||
|
||||
|
||||
@pytest.mark.asyncio(loop_scope="session")
|
||||
async def test_token_management_needs_login(client: AsyncClient):
|
||||
assert (await client.post("/api/v1/companies/me/portal-token/rotate")).status_code in (401, 403)
|
||||
|
||||
|
||||
@pytest.mark.asyncio(loop_scope="session")
|
||||
async def test_summary_matches_own_balances_for_single_user(client: AsyncClient):
|
||||
headers = await _registriere(client, "Portal Summen GmbH", "admin@portal-summen.de")
|
||||
token = (await client.post("/api/v1/companies/me/portal-token/rotate", headers=headers)).json()["token"]
|
||||
jahr = date.today().year
|
||||
konto = (await client.get(f"/api/v1/absences/balance?year={jahr}", headers=headers)).json()
|
||||
assert konto["available_days"] > 0
|
||||
body = (await client.get(f"/api/v1/portal/summary?year={jahr}", headers=_bearer(token))).json()
|
||||
assert body["mitarbeiter_aktiv"] == 1
|
||||
assert body["urlaub"]["konten"] == 1
|
||||
assert body["urlaub"]["ohne_konto"] == 0
|
||||
assert body["urlaub"]["resttage"] == konto["available_days"]
|
||||
assert body["urlaub"]["anspruch_tage"] == konto["entitled_days"] + konto["special_days"]
|
||||
assert body["urlaub"]["genommen_tage"] == konto["used_days"]
|
||||
saldo = (await client.get("/api/v1/absences/overtime-balance", headers=headers)).json()
|
||||
assert body["ueberstunden"]["guthaben_gesamt_h"] == pytest.approx(float(saldo["available_hours"]))
|
||||
+39
@@ -30,6 +30,7 @@ In der Entwicklungsumgebung ist die interaktive Dokumentation unter `/docs` (Swa
|
||||
20. [Sondervertretungen](#sondervertretungen)
|
||||
21. [Admin · TLS](#admin--tls)
|
||||
22. [Home-Assistant-Status](#home-assistant-status)
|
||||
23. [Buchhaltungsportal-Summen](#buchhaltungsportal-summen)
|
||||
|
||||
---
|
||||
|
||||
@@ -1882,3 +1883,41 @@ sensor:
|
||||
- stamped_in_since
|
||||
scan_interval: 60
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Buchhaltungsportal-Summen
|
||||
|
||||
Read-only Firmensummen für das Buchhaltungsportal. Nur Summen zu Resturlaub und Überstundenguthaben, **keine Einzelpersonen und keine Krankheits- oder Abwesenheitsdaten**. Auth über ein Firmen-Token (SHA-256 in `companies.portal_token_hash`, Klartext nur beim Erzeugen), Migration `0049`, Rate-Limit 30/Minute.
|
||||
|
||||
### `GET /companies/me/portal-token`
|
||||
|
||||
Status des Tokens: `{ "configured": true, "created_at": "..." }`. Rolle COMPANY_ADMIN, SUPER_ADMIN.
|
||||
|
||||
### `POST /companies/me/portal-token/rotate`
|
||||
|
||||
Erzeugt oder ersetzt das Token (das alte endet sofort). **Response:** `{ "token": "...", "created_at": "..." }`. Das Token wird nur einmal angezeigt. Audit-Eintrag `portal_token_rotated`.
|
||||
|
||||
### `DELETE /companies/me/portal-token`
|
||||
|
||||
Widerruft das Token (`204`). Audit-Eintrag `portal_token_revoked`.
|
||||
|
||||
### `GET /portal/summary`
|
||||
|
||||
Header `Authorization: Bearer <Token>`. Query: `year` (optional, Standard laufendes Jahr, 2000 bis 2200). `401` bei fehlendem oder falschem Token oder deaktivierter Firma.
|
||||
|
||||
```json
|
||||
{
|
||||
"stichtag": "2026-10-29",
|
||||
"firma": "Muster GmbH",
|
||||
"mitarbeiter_aktiv": 7,
|
||||
"kleine_gruppe": false,
|
||||
"urlaub": { "jahr": 2026, "konten": 7, "ohne_konto": 0, "anspruch_tage": 210, "uebertrag_tage": 12, "genommen_tage": 143, "resttage": 79 },
|
||||
"ueberstunden": { "konten": 7, "guthaben_gesamt_h": 84.5, "guthaben_positiv_h": 96.0, "mitarbeiter_mit_guthaben": 4 }
|
||||
}
|
||||
```
|
||||
|
||||
- `resttage` berücksichtigt den Verfall des Resturlaubs (`effective_available`).
|
||||
- `guthaben_positiv_h` zählt nur positive Salden, das ist die Grundlage für eine Rückstellung.
|
||||
- `kleine_gruppe`: weniger als drei aktive Mitarbeiter, die Summen verraten dann Einzelwerte.
|
||||
- Nur aktive Mitarbeiter, nur vorhandene Urlaubskonten des Jahres (`ohne_konto` zählt die übrigen).
|
||||
Reference in new issue
Block a user