feat(ha): read-only Arbeitszeit-Status für Home Assistant (Token-URL)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
2f75ebc5a7
commit
ed5c52d8b3
6 files changed
+293
No files matched your search
@@ -21,6 +21,7 @@ from app.routers import special_assignments
|
||||
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 reseller, tenants
|
||||
from app.routers import tls_admin
|
||||
|
||||
@@ -140,6 +141,7 @@ app.include_router(companies.router, prefix=API_PREFIX)
|
||||
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(absences.router, prefix=API_PREFIX)
|
||||
app.include_router(reports.router, prefix=API_PREFIX)
|
||||
app.include_router(ldap.router, prefix=API_PREFIX)
|
||||
|
||||
@@ -71,6 +71,8 @@ class User(Base):
|
||||
# (/absences/ical/<token>.ics). Nur eigene Abwesenheiten, kein JWT nötig.
|
||||
ical_token_hash: Mapped[str | None] = mapped_column(Text, unique=True)
|
||||
|
||||
ha_token_hash: Mapped[str | None] = mapped_column(Text, unique=True)
|
||||
|
||||
# Personalnummer (numerisch, eindeutig pro Firma; bleibt nach Deaktivierung reserviert)
|
||||
personnel_number: Mapped[str | None] = mapped_column(String(50))
|
||||
|
||||
|
||||
@@ -0,0 +1,114 @@
|
||||
"""Read-only Arbeitszeit-Status für Home Assistant (REST-Sensor).
|
||||
|
||||
Token-gescoped auf genau einen Nutzer (gehasht in `users.ha_token_hash`), kein JWT,
|
||||
opt-in, rotierbar. Liefert nur die eigenen Zeitdaten des Token-Inhabers.
|
||||
Read-only → RLS-Bypass-Kontext (get_db default) bleibt stabil.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import secrets
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Response
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.core.config import settings
|
||||
from app.core.database import get_db
|
||||
from app.core.dependencies import get_current_user
|
||||
from app.models.time_entry import TimeEntry
|
||||
from app.models.user import User
|
||||
from app.services.time_service import time_service
|
||||
|
||||
router = APIRouter(tags=["Home Assistant"])
|
||||
|
||||
|
||||
def _hash_token(token: str) -> str:
|
||||
return hashlib.sha256(token.encode("utf-8")).hexdigest()
|
||||
|
||||
|
||||
def _secs(t) -> int:
|
||||
return t.hour * 3600 + t.minute * 60 + t.second
|
||||
|
||||
|
||||
def _elapsed_secs(start, now) -> int:
|
||||
diff = _secs(now) - _secs(start)
|
||||
return diff + 86400 if diff < 0 else diff
|
||||
|
||||
|
||||
@router.get("/time/ha/{token}")
|
||||
async def ha_status(token: str, db: AsyncSession = Depends(get_db)) -> dict:
|
||||
user = await db.scalar(select(User).where(User.ha_token_hash == _hash_token(token)))
|
||||
if user is None or not user.is_active:
|
||||
raise HTTPException(status_code=404, detail="Nicht gefunden.")
|
||||
|
||||
now = datetime.now(timezone.utc)
|
||||
now_t = now.time().replace(microsecond=0, tzinfo=None)
|
||||
|
||||
entries = list(
|
||||
await db.scalars(
|
||||
select(TimeEntry)
|
||||
.where(TimeEntry.user_id == user.id, TimeEntry.date == now.date())
|
||||
.order_by(TimeEntry.start_time)
|
||||
)
|
||||
)
|
||||
|
||||
worked_min = 0
|
||||
break_min = 0
|
||||
status = "out"
|
||||
since = None
|
||||
for e in entries:
|
||||
break_min += e.break_minutes
|
||||
if e.end_time is not None:
|
||||
worked_min += e.worked_minutes or 0
|
||||
continue
|
||||
running = _elapsed_secs(e.start_time, now_t) // 60
|
||||
status = "working"
|
||||
since = datetime.combine(e.date, e.start_time, tzinfo=timezone.utc).isoformat()
|
||||
if e.break_start is not None:
|
||||
status = "break"
|
||||
active_pause = _elapsed_secs(e.break_start, now_t) // 60
|
||||
break_min += active_pause
|
||||
running -= active_pause
|
||||
worked_min += max(0, running - e.break_minutes)
|
||||
|
||||
balance = await time_service.get_balance(user.id, user, db)
|
||||
|
||||
return {
|
||||
"status": status,
|
||||
"stamped_in_since": since,
|
||||
"worked_today_min": worked_min,
|
||||
"break_today_min": break_min,
|
||||
"month_worked_h": balance.total_hours_worked,
|
||||
"month_expected_h": balance.expected_hours,
|
||||
"month_balance_h": balance.overtime_hours,
|
||||
"updated_at": now.isoformat(),
|
||||
}
|
||||
|
||||
|
||||
@router.get("/users/me/ha-token")
|
||||
async def ha_token_status(current_user: User = Depends(get_current_user)) -> dict:
|
||||
return {"enabled": current_user.ha_token_hash is not None}
|
||||
|
||||
|
||||
@router.post("/users/me/ha-token")
|
||||
async def create_or_rotate_ha_token(
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
) -> dict:
|
||||
token = secrets.token_urlsafe(32)
|
||||
current_user.ha_token_hash = _hash_token(token)
|
||||
await db.commit()
|
||||
base = settings.frontend_url.rstrip("/")
|
||||
return {"enabled": True, "url": f"{base}/api/v1/time/ha/{token}"}
|
||||
|
||||
|
||||
@router.delete("/users/me/ha-token", status_code=204)
|
||||
async def delete_ha_token(
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
) -> Response:
|
||||
current_user.ha_token_hash = None
|
||||
await db.commit()
|
||||
return Response(status_code=204)
|
||||
@@ -0,0 +1,29 @@
|
||||
"""Home-Assistant-Sensor: users.ha_token_hash
|
||||
|
||||
Revision ID: 0048
|
||||
Revises: 0047
|
||||
Create Date: 2026-10-28
|
||||
|
||||
Gehashtes, rotierbares Token für den read-only Arbeitszeit-Status
|
||||
(/time/ha/<token>). NULL = deaktiviert. Keine RLS-Änderung (nullable Spalte
|
||||
auf bestehender users-Policy).
|
||||
"""
|
||||
from alembic import op
|
||||
|
||||
revision = "0048"
|
||||
down_revision = "0047"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.execute("ALTER TABLE users ADD COLUMN IF NOT EXISTS ha_token_hash TEXT")
|
||||
op.execute(
|
||||
"CREATE UNIQUE INDEX IF NOT EXISTS ix_users_ha_token_hash "
|
||||
"ON users (ha_token_hash) WHERE ha_token_hash IS NOT NULL"
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.execute("DROP INDEX IF EXISTS ix_users_ha_token_hash")
|
||||
op.execute("ALTER TABLE users DROP COLUMN IF EXISTS ha_token_hash")
|
||||
@@ -0,0 +1,61 @@
|
||||
"""Tests für den Home-Assistant-Status-Endpunkt (router/ha.py)."""
|
||||
import pytest
|
||||
import pytest_asyncio
|
||||
from httpx import AsyncClient
|
||||
|
||||
|
||||
@pytest_asyncio.fixture(scope="session", loop_scope="session")
|
||||
async def ha_headers(client: AsyncClient):
|
||||
resp = await client.post("/api/v1/auth/register", json={
|
||||
"company_name": "HA GmbH",
|
||||
"first_name": "Home",
|
||||
"last_name": "Assistant",
|
||||
"email": "admin@hagmbh.de",
|
||||
"password": "Secret123",
|
||||
})
|
||||
assert resp.status_code == 201, resp.text
|
||||
return {"Authorization": f"Bearer {resp.json()['access_token']}"}
|
||||
|
||||
|
||||
@pytest.mark.asyncio(loop_scope="session")
|
||||
async def test_ha_token_disabled_by_default(client: AsyncClient, ha_headers):
|
||||
r = await client.get("/api/v1/users/me/ha-token", headers=ha_headers)
|
||||
assert r.status_code == 200
|
||||
assert r.json()["enabled"] is False
|
||||
|
||||
|
||||
@pytest.mark.asyncio(loop_scope="session")
|
||||
async def test_ha_status_lifecycle(client: AsyncClient, ha_headers):
|
||||
r = await client.post("/api/v1/users/me/ha-token", json={}, headers=ha_headers)
|
||||
assert r.status_code == 200, r.text
|
||||
path = r.json()["url"].split("/api/v1", 1)[1]
|
||||
|
||||
out = await client.get("/api/v1" + path)
|
||||
assert out.status_code == 200
|
||||
body = out.json()
|
||||
assert body["status"] == "out"
|
||||
assert body["worked_today_min"] == 0
|
||||
assert body["stamped_in_since"] is None
|
||||
|
||||
s = await client.post("/api/v1/time/stamp-in", json={}, headers=ha_headers)
|
||||
assert s.status_code == 201, s.text
|
||||
body = (await client.get("/api/v1" + path)).json()
|
||||
assert body["status"] == "working"
|
||||
assert body["stamped_in_since"] is not None
|
||||
|
||||
b = await client.post("/api/v1/time/break-start", json={}, headers=ha_headers)
|
||||
assert b.status_code == 200, b.text
|
||||
assert (await client.get("/api/v1" + path)).json()["status"] == "break"
|
||||
|
||||
await client.post("/api/v1/time/break-end", json={}, headers=ha_headers)
|
||||
await client.post("/api/v1/time/stamp-out", json={}, headers=ha_headers)
|
||||
assert (await client.get("/api/v1" + path)).json()["status"] == "out"
|
||||
|
||||
r2 = await client.post("/api/v1/users/me/ha-token", json={}, headers=ha_headers)
|
||||
new_path = r2.json()["url"].split("/api/v1", 1)[1]
|
||||
assert (await client.get("/api/v1" + path)).status_code == 404
|
||||
assert (await client.get("/api/v1" + new_path)).status_code == 200
|
||||
|
||||
d = await client.delete("/api/v1/users/me/ha-token", headers=ha_headers)
|
||||
assert d.status_code == 204
|
||||
assert (await client.get("/api/v1" + new_path)).status_code == 404
|
||||
@@ -310,6 +310,90 @@ function IcalSection() {
|
||||
)
|
||||
}
|
||||
|
||||
// ── Home Assistant ────────────────────────────────────────────────────────────
|
||||
|
||||
function HomeAssistantSection() {
|
||||
const [enabled, setEnabled] = useState(false)
|
||||
const [url, setUrl] = useState('') // nur direkt nach Erzeugen bekannt
|
||||
const [busy, setBusy] = useState(false)
|
||||
const [copied, setCopied] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
api.get<{ enabled: boolean }>('/users/me/ha-token')
|
||||
.then(r => setEnabled(r.enabled)).catch(() => {})
|
||||
}, [])
|
||||
|
||||
async function createOrRotate() {
|
||||
setBusy(true)
|
||||
try {
|
||||
const r = await api.post<{ enabled: boolean; url: string }>('/users/me/ha-token', {})
|
||||
setEnabled(true); setUrl(r.url); setCopied(false)
|
||||
} finally { setBusy(false) }
|
||||
}
|
||||
|
||||
async function disable() {
|
||||
setBusy(true)
|
||||
try {
|
||||
await api.del('/users/me/ha-token')
|
||||
setEnabled(false); setUrl('')
|
||||
} finally { setBusy(false) }
|
||||
}
|
||||
|
||||
function copy() {
|
||||
navigator.clipboard.writeText(url).then(() => {
|
||||
setCopied(true); setTimeout(() => setCopied(false), 2000)
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
<div className='bg-white rounded-xl shadow-sm border border-gray-200 p-6'>
|
||||
<div className='mb-4'>
|
||||
<h2 className='font-semibold text-gray-800'>Home Assistant</h2>
|
||||
<p className='text-sm text-gray-400 mt-0.5'>
|
||||
Schreibgeschützter Status deiner Arbeitszeit (JSON) für einen REST-Sensor in
|
||||
Home Assistant. Die URL enthält ein geheimes Token – nicht weitergeben.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{url && (
|
||||
<div className='mb-4 rounded-lg border border-blue-200 bg-blue-50 p-3'>
|
||||
<p className='text-xs font-medium text-blue-800 mb-1'>
|
||||
Deine Sensor-URL (wird nur jetzt einmal angezeigt):
|
||||
</p>
|
||||
<div className='flex items-center gap-2'>
|
||||
<input readOnly value={url} onFocus={e => e.target.select()}
|
||||
className='flex-1 border border-blue-300 rounded px-2 py-1.5 text-xs font-mono bg-white' />
|
||||
<button onClick={copy}
|
||||
className='px-3 py-1.5 bg-blue-600 text-white rounded text-xs font-medium hover:bg-blue-700 whitespace-nowrap'>
|
||||
{copied ? 'Kopiert ✓' : 'Kopieren'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!url && enabled && (
|
||||
<p className='mb-4 text-sm text-green-700 font-medium'>
|
||||
✓ Sensor-URL ist aktiv. Aus Sicherheitsgründen wird die URL nicht erneut angezeigt –
|
||||
bei Verlust neu erzeugen (das alte Token wird dabei ungültig).
|
||||
</p>
|
||||
)}
|
||||
|
||||
<div className='flex flex-wrap gap-2'>
|
||||
<button onClick={createOrRotate} disabled={busy}
|
||||
className='py-2 px-4 bg-blue-600 text-white rounded-lg text-sm font-medium hover:bg-blue-700 disabled:opacity-50'>
|
||||
{busy ? '…' : enabled ? 'URL neu erzeugen (rotieren)' : 'Sensor-URL aktivieren'}
|
||||
</button>
|
||||
{enabled && (
|
||||
<button onClick={disable} disabled={busy}
|
||||
className='py-2 px-4 border border-red-300 text-red-600 rounded-lg text-sm font-medium hover:bg-red-50 disabled:opacity-50'>
|
||||
Deaktivieren
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ── Hauptseite ────────────────────────────────────────────────────────────────
|
||||
|
||||
export function ProfilePage() {
|
||||
@@ -458,6 +542,7 @@ export function ProfilePage() {
|
||||
|
||||
{/* Kalender-Abo (iCal) */}
|
||||
<IcalSection />
|
||||
<HomeAssistantSection />
|
||||
|
||||
{/* Mobile App */}
|
||||
<MobileAppSection />
|
||||
|
||||
Reference in new issue
Block a user