fix: agent-08 Kiosk-Härtung + 24h-Zeiteintrag-Bug

- fix: worked_minutes nutzt jetzt Sekunden statt Minuten für Overnight-Vergleich
  (end < start statt end <= start) – verhindert 24h-Anzeige bei Schnell-Stempel
  in derselben Minute (z.B. 23:34:46 → 23:34:48)
- fix: _check_arbzg() gleicher Sec-basierter Fix
- fix: KioskDeviceStatus Enum values_callable → kiosk list crasht nicht mehr
- feat: kiosk rotate-key CLI-Kommando (Status→pending, Re-Enrollment)
- feat: Kiosk-Settings in CompanyOut/CompanyUpdate Schema (require_approval,
  track_current_user, heartbeat_interval_sec)
- feat: Kiosk-Terminal-Einstellungsblock in CompanySettingsPage (🖥️)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-05-25 01:42:08 +02:00
parent eae0f6f9b4
commit e83a3fbbdd
7 changed files with 267 additions and 12 deletions
+1 -1
View File
@@ -40,7 +40,7 @@ class KioskDevice(Base):
# ── Ed25519-Auth (löst token_hash + is_active ab) ─────────────────────────
status: Mapped[KioskDeviceStatus] = mapped_column(
Enum(KioskDeviceStatus, name="kioskdevicestatus"),
Enum(KioskDeviceStatus, name="kioskdevicestatus", values_callable=lambda x: [e.value for e in x]),
nullable=False,
default=KioskDeviceStatus.REVOKED,
)
+12 -5
View File
@@ -66,11 +66,18 @@ class TimeEntry(Base):
"""Gearbeitete Minuten (ohne Pausen), None wenn noch offen."""
if self.end_time is None:
return None
start_total = self.start_time.hour * 60 + self.start_time.minute
end_total = self.end_time.hour * 60 + self.end_time.minute
if end_total <= start_total:
end_total += 24 * 60 # overnight shift
return max(0, end_total - start_total - self.break_minutes)
# Sekunden einbeziehen um Sub-Minuten-Einträge korrekt zu behandeln
# (z.B. Einstemp+Ausstemp in derselben Minute → soll 0h zeigen, nicht 24h)
start_secs = (self.start_time.hour * 3600
+ self.start_time.minute * 60
+ self.start_time.second)
end_secs = (self.end_time.hour * 3600
+ self.end_time.minute * 60
+ self.end_time.second)
if end_secs < start_secs:
end_secs += 24 * 3600 # Nachtschicht (Ende am nächsten Tag)
total_mins = (end_secs - start_secs) // 60
return max(0, total_mins - self.break_minutes)
@property
def worked_hours(self) -> float | None:
+6
View File
@@ -24,6 +24,9 @@ class CompanyOut(BaseModel):
mobile_stamping_enabled: bool = True
overtime_overdraft_allowed: bool = True
overtime_warning_threshold_hours: int = 0
kiosk_require_approval: bool = True
kiosk_track_current_user: bool = True
kiosk_heartbeat_interval_sec: int = 30
class CompanyUpdate(BaseModel):
@@ -36,6 +39,9 @@ class CompanyUpdate(BaseModel):
mobile_stamping_enabled: bool | None = None
overtime_overdraft_allowed: bool | None = None
overtime_warning_threshold_hours: int | None = Field(None, ge=0)
kiosk_require_approval: bool | None = None
kiosk_track_current_user: bool | None = None
kiosk_heartbeat_interval_sec: int | None = Field(None, ge=10, le=120)
class DepartmentOut(BaseModel):
+5 -5
View File
@@ -19,11 +19,11 @@ from app.schemas.time_entry import (
def _check_arbzg(start: time, end: time, break_minutes: int) -> list[str]:
"""ArbZG §3 und §4 Prüfung. Gibt Warnungen zurück, blockiert nicht."""
start_mins = start.hour * 60 + start.minute
end_mins = end.hour * 60 + end.minute
if end_mins <= start_mins:
end_mins += 24 * 60 # Nachtschicht
total_mins = end_mins - start_mins
start_secs = start.hour * 3600 + start.minute * 60 + start.second
end_secs = end.hour * 3600 + end.minute * 60 + end.second
if end_secs < start_secs:
end_secs += 24 * 3600 # Nachtschicht
total_mins = (end_secs - start_secs) // 60
worked_mins = total_mins - break_minutes
worked_hours = worked_mins / 60
+78
View File
@@ -451,6 +451,84 @@ def kiosk_revoke(
)
# ── Subcommand: kiosk rotate-key ─────────────────────────────────────────────
@kiosk_app.command("rotate-key")
def kiosk_rotate_key(
device_id: str = typer.Argument(..., help="UUID des Kiosk-Geräts"),
pubkey: Optional[Path] = typer.Option(None, "--pubkey", help="Pfad zur neuen Public-Key-Datei (optional)"),
yes: bool = typer.Option(False, "--yes", "-y", help="Ohne Bestätigung fortfahren"),
):
"""Ed25519-Schlüssel eines Kiosk-Geräts rotieren (Status → pending, Re-Enrollment erforderlich)."""
# Neuen Public Key einlesen falls angegeben
new_pubkey_str: Optional[str] = None
if pubkey is not None:
if not pubkey.exists():
err_console.print(f"[bold red]Fehler:[/bold red] Public-Key-Datei nicht gefunden: {pubkey}")
raise typer.Exit(1)
new_pubkey_str = pubkey.read_text().strip()
if not new_pubkey_str:
err_console.print("[bold red]Fehler:[/bold red] Public-Key-Datei ist leer.")
raise typer.Exit(1)
# Gerät vorab laden für Bestätigungsdialog
if not yes:
async def _peek():
session = await _open_session()
try:
return await _find_device(session, device_id)
finally:
await session.close()
device_peek = _run(_peek())
console.print(
f"[yellow]Achtung:[/yellow] Gerät [bold]{device_peek.name}[/bold] "
f"({str(device_peek.id)[:8]}…) wird auf [yellow]pending[/yellow] gesetzt.\n"
"Der aktuelle Schlüssel wird gelöscht. Re-Enrollment ist erforderlich."
)
confirm = typer.confirm("Schlüssel wirklich rotieren?")
if not confirm:
console.print("[dim]Abgebrochen.[/dim]")
raise typer.Exit(0)
async def _rotate():
session = await _open_session()
try:
device = await _find_device(session, device_id)
device.public_key = new_pubkey_str
device.enrollment_token_hash = None
device.enrollment_expires_at = None
device.status = KioskDeviceStatus.PENDING
await session.commit()
await session.refresh(device)
return device
except Exception:
await session.rollback()
raise
finally:
await session.close()
device = _run(_rotate())
console.print()
if new_pubkey_str:
fingerprint = _pubkey_fingerprint(new_pubkey_str)
console.print(
f"[bold green]✓[/bold green] Schlüssel für Gerät [bold]{device.name}[/bold] "
f"({str(device.id)[:8]}…) rotiert.\n"
f" [bold]Neuer Fingerprint:[/bold] {fingerprint}\n"
f" [bold]Status:[/bold] {_status_icon(device.status)}"
)
else:
console.print(
f"[bold green]✓[/bold green] Public Key für Gerät [bold]{device.name}[/bold] "
f"({str(device.id)[:8]}…) wurde gelöscht.\n"
f" [bold]Status:[/bold] {_status_icon(device.status)}\n"
" [yellow]Hinweis:[/yellow] Bitte Public Key per Enrollment-Flow neu setzen."
)
console.print()
# ── Subcommand: kiosk info ────────────────────────────────────────────────────
@kiosk_app.command("info")