poprawki i zmiany ux

This commit is contained in:
Mateusz Gruszczyński
2026-03-26 09:30:39 +01:00
parent fd0f645251
commit 138059945e
28 changed files with 1000 additions and 225 deletions

View File

@@ -101,7 +101,8 @@ def create_app() -> Flask:
@app.after_request
def append_cors_headers(response):
return _apply_cors(response)
response = _apply_cors(response)
return _apply_cache_headers(response)
@app.errorhandler(HTTPException)
def handle_http_exception(exc: HTTPException):
@@ -123,7 +124,7 @@ def _register_cli_commands(app: Flask) -> None:
auth_service = get_auth_service()
@app.cli.command("create-admin")
@click.option("--username", required=True, help="Login")
@click.option("--username", required=True, help="Username")
@click.option("--password", required=True, hide_input=True, confirmation_prompt=True, help="Password")
@click.option("--display-name", default=None, help="Name")
def create_admin_command(username: str, password: str, display_name: str | None):
@@ -134,14 +135,14 @@ def _register_cli_commands(app: Flask) -> None:
role="admin",
display_name=display_name,
)
click.echo(f"Utworzono konto admina: {user.username}")
click.echo(f"Admin account created: {user.username}")
except ValueError as exc:
raise click.ClickException(str(exc)) from exc
except Exception as exc: # pragma: no cover
raise click.ClickException(f"Cant create admin account: {exc}") from exc
raise click.ClickException(f"Cannot create admin account: {exc}") from exc
@app.cli.command("create-user")
@click.option("--username", required=True, help="Login")
@click.option("--username", required=True, help="Username")
@click.option("--password", required=True, hide_input=True, confirmation_prompt=True, help="Password")
@click.option("--display-name", default=None, help="Name")
def create_user_command(username: str, password: str, display_name: str | None):
@@ -152,23 +153,23 @@ def _register_cli_commands(app: Flask) -> None:
role="user",
display_name=display_name,
)
click.echo(f"Admin created: {user.username}")
click.echo(f"User account created: {user.username}")
except ValueError as exc:
raise click.ClickException(str(exc)) from exc
except Exception as exc: # pragma: no cover
raise click.ClickException(f"Cant create user account: {exc}") from exc
raise click.ClickException(f"Cannot create user account: {exc}") from exc
@app.cli.command("reset-password")
@click.option("--username", required=True, help="Login")
@click.option("--username", required=True, help="Username")
@click.option("--password", required=True, hide_input=True, confirmation_prompt=True, help="Password")
def reset_password_command(username: str, password: str):
try:
user = auth_service.reset_password(username=username, new_password=password)
click.echo(f"Passowrd reseted for: {user.username}")
click.echo(f"Password reset for: {user.username}")
except ValueError as exc:
raise click.ClickException(str(exc)) from exc
except Exception as exc: # pragma: no cover
raise click.ClickException(f"Can't password reset: {exc}") from exc
raise click.ClickException(f"Cannot reset password: {exc}") from exc
def _bootstrap_background_services(debug: bool) -> None:
@@ -191,3 +192,11 @@ def _apply_cors(response):
response.headers["Access-Control-Allow-Methods"] = "GET, POST, OPTIONS, PUT"
response.headers["Access-Control-Allow-Credentials"] = "true"
return response
def _apply_cache_headers(response):
if request.path == "/" or request.path.startswith("/api/"):
response.headers["Cache-Control"] = "no-store, no-cache, must-revalidate, proxy-revalidate"
response.headers["Pragma"] = "no-cache"
response.headers["Expires"] = "0"
return response

View File

@@ -78,3 +78,21 @@ def reset_password(username: str):
return jsonify({"detail": str(exc)}), 403
except ValueError as exc:
return jsonify({"detail": str(exc)}), 400
@auth_blueprint.put("/auth/users/<username>/role")
def update_user_role(username: str):
payload = request.get_json(silent=True) or {}
try:
service.require_admin()
user = service.update_role(username=username, role=payload.get("role", "user"))
return jsonify(to_plain({
"username": user.username,
"display_name": user.display_name,
"role": user.role,
"is_active": user.is_active,
}))
except PermissionError as exc:
return jsonify({"detail": str(exc)}), 403
except ValueError as exc:
return jsonify({"detail": str(exc)}), 400

View File

@@ -25,11 +25,11 @@ def _resolve_kiosk_mode(requested_mode: str, require_write_access: bool = False)
if normalized_mode == "public":
if require_write_access and auth_service.enabled and session.get("auth_role") != "admin":
raise PermissionError("Brak uprawnien do edycji publicznego kiosku")
raise PermissionError("You do not have permission to edit the public kiosk")
return "public", "public"
if normalized_mode != "private":
raise ValueError("Mode musi byc jednym z: public, private")
raise ValueError("Mode must be one of: public, private")
if not auth_service.enabled:
return "private", "private"

View File

@@ -55,9 +55,9 @@ class AuthService:
self._login_legacy_user(username, password)
else:
if not user.is_active:
raise ValueError("Konto jest nieaktywne")
raise ValueError("Account is inactive")
if not check_password_hash(user.password_hash, password):
raise ValueError("Niepoprawny login lub haslo")
raise ValueError("Invalid username or password")
self._set_session(user.username, user.display_name, user.role)
return self.status()
@@ -84,7 +84,7 @@ class AuthService:
if not self.enabled:
return
if session.get(SESSION_ROLE_KEY) != "admin":
raise PermissionError("Brak uprawnien administratora")
raise PermissionError("Administrator permissions are required")
def configure_app(self, app) -> None:
max_age = int(self.settings.auth["session_max_age_seconds"])
@@ -101,7 +101,7 @@ class AuthService:
clean_password = self._validate_password(password)
resolved_display_name = (display_name or normalized_username).strip()
if not resolved_display_name:
raise ValueError("Display name nie moze byc pusty")
raise ValueError("Display name cannot be empty")
return self.user_repository.upsert_user(
username=normalized_username,
password_hash=generate_password_hash(clean_password),
@@ -118,16 +118,33 @@ class AuthService:
generate_password_hash(clean_password),
)
if user is None:
raise ValueError(f"Uzytkownik '{normalized_username}' nie istnieje")
raise ValueError(f"User '{normalized_username}' does not exist")
return user
def update_role(self, *, username: str, role: str) -> AuthUser:
normalized_username = self._normalize_username(username)
normalized_role = self._normalize_role(role)
user = self.user_repository.get_by_username(normalized_username)
if user is None:
raise ValueError(f"User '{normalized_username}' does not exist")
if user.role == normalized_role:
return user
if user.role == 'admin' and normalized_role != 'admin' and self.user_repository.count_admin_users() <= 1:
raise ValueError('At least one active admin user must remain')
updated = self.user_repository.update_role(normalized_username, normalized_role)
if updated is None:
raise ValueError(f"User '{normalized_username}' does not exist")
if session.get(SESSION_USER_KEY) == updated.username:
session[SESSION_ROLE_KEY] = updated.role
return updated
def _login_legacy_user(self, username: str, password: str) -> None:
expected_username = self.settings.auth["username"]
expected_password = self.settings.auth["password"]
expected_password_hash = self.settings.auth.get("password_hash")
if username != expected_username:
raise ValueError("Niepoprawny login lub haslo")
raise ValueError("Invalid username or password")
if expected_password_hash:
password_ok = check_password_hash(expected_password_hash, password)
@@ -135,7 +152,7 @@ class AuthService:
password_ok = password == expected_password
if not password_ok:
raise ValueError("Niepoprawny login lub haslo")
raise ValueError("Invalid username or password")
self._set_session(
expected_username,
@@ -153,19 +170,19 @@ class AuthService:
def _normalize_username(self, username: str) -> str:
normalized = (username or "").strip()
if not normalized:
raise ValueError("Username nie moze byc pusty")
raise ValueError("Username cannot be empty")
return normalized
def _normalize_role(self, role: str) -> str:
normalized = (role or "").strip().lower()
if normalized not in VALID_ROLES:
raise ValueError("Rola musi byc jedna z: admin, user")
raise ValueError("Role must be one of: admin, user")
return normalized
def _validate_password(self, password: str) -> str:
clean_password = password or ""
if len(clean_password) < 8:
raise ValueError("Haslo musi miec co najmniej 8 znakow")
raise ValueError("Password must be at least 8 characters long")
return clean_password

View File

@@ -82,7 +82,7 @@ class HistoricalSyncService:
with self._state_lock:
self._state.running = False
self._state.state = "idle"
self._state.message = "Brak brakujacych dni do importu."
self._state.message = "There are no missing days to import."
self._state.finished_at = datetime.utcnow()
self._refresh_coverage(lock_held=True)
self._refresh_available_bounds(lock_held=True)
@@ -92,7 +92,7 @@ class HistoricalSyncService:
resolved_start, resolved_end = resolved
total_days = (resolved_end - resolved_start).days + 1
total_chunks = max(ceil(total_days / chunk_days), 1)
start_message = "Start importu archiwalnego" if not auto else "Start automatycznej synchronizacji archiwum"
start_message = "Historical import started" if not auto else "Automatic historical sync started"
with self._state_lock:
if self._worker and self._worker.is_alive():
@@ -138,18 +138,18 @@ class HistoricalSyncService:
self._record_event(
level="info",
title="Uruchomiono zadanie",
message=f"Zakres {resolved_start.isoformat()} -> {resolved_end.isoformat()}, chunk {chunk_days} dni",
title="Job started",
message=f"Range {resolved_start.isoformat()} -> {resolved_end.isoformat()}, chunk size {chunk_days} days",
)
return self.status()
def cancel(self) -> HistoricalImportStatus:
self._cancel_event.set()
with self._state_lock:
self._state.message = "Anulowanie zadania..."
self._state.message = "Cancelling job..."
self._refresh_runtime_metrics(lock_held=True)
snapshot = copy.deepcopy(self._state)
self._record_event(level="warn", title="Anulowanie", message="Uzytkownik poprosil o zatrzymanie zadania.")
self._record_event(level="warn", title="Cancellation requested", message="The user requested the job to stop.")
return snapshot
def run_blocking(
@@ -185,8 +185,8 @@ class HistoricalSyncService:
)
self._record_event(
level="info",
title="Uruchomiono zadanie",
message=f"Zakres {resolved_start.isoformat()} -> {resolved_end.isoformat()}, chunk {chunk_days} dni",
title="Job started",
message=f"Range {resolved_start.isoformat()} -> {resolved_end.isoformat()}, chunk size {chunk_days} days",
)
self._run_worker(
start_date=resolved_start,
@@ -239,8 +239,8 @@ class HistoricalSyncService:
chunk_start = start_date
while chunk_start <= end_date:
if self._cancel_event.is_set():
self._record_event(level="warn", title="Anulowano", message="Import archiwalny anulowany przez uzytkownika.")
self._finish("cancelled", running=False, message="Import archiwalny anulowany przez uzytkownika.")
self._record_event(level="warn", title="Cancelled", message="Historical import was cancelled by the user.")
self._finish("cancelled", running=False, message="Historical import was cancelled by the user.")
return
chunk_index += 1
@@ -259,10 +259,10 @@ class HistoricalSyncService:
skipped_days=skipped,
energy_kwh=energy_kwh,
state="cancelled",
note="Chunk zatrzymany podczas przetwarzania",
note="Chunk stopped during processing",
)
self._record_event(level="warn", title="Anulowano", message="Import archiwalny anulowany przez uzytkownika.")
self._finish("cancelled", running=False, message="Import archiwalny anulowany przez uzytkownika.")
self._record_event(level="warn", title="Cancelled", message="Historical import was cancelled by the user.")
self._finish("cancelled", running=False, message="Historical import was cancelled by the user.")
return
self._close_chunk(
@@ -271,23 +271,23 @@ class HistoricalSyncService:
skipped_days=skipped,
energy_kwh=energy_kwh,
state="completed",
note=f"Chunk zakonczony: import {imported}, pominiete {skipped}",
note=f"Chunk completed: imported {imported}, skipped {skipped}",
)
self._record_event(
level="success",
title=f"Chunk {chunk_index}/{total_chunks} zakonczony",
message=f"Zakres {chunk_start.isoformat()} -> {chunk_end.isoformat()}, import {imported}, pominiete {skipped}, energia {energy_kwh:.2f} kWh",
title=f"Chunk {chunk_index}/{total_chunks} completed",
message=f"Range {chunk_start.isoformat()} -> {chunk_end.isoformat()}, imported {imported}, skipped {skipped}, energy {energy_kwh:.2f} kWh",
chunk_index=chunk_index,
)
chunk_start = chunk_end + timedelta(days=1)
final_message = "Synchronizacja archiwalna zakonczona" if auto else "Import archiwalny zakonczony"
self._record_event(level="success", title="Zakonczono", message=final_message)
final_message = "Historical synchronization completed" if auto else "Historical import completed"
self._record_event(level="success", title="Completed", message=final_message)
self._finish("completed", running=False, message=final_message)
except Exception as exc:
logger.exception("Historical import failed")
self._record_event(level="error", title="Blad importu", message=str(exc))
self._finish("failed", running=False, message="Import archiwalny zakonczyl sie bledem.", last_error=str(exc))
self._record_event(level="error", title="Import error", message=str(exc))
self._finish("failed", running=False, message="Historical import finished with an error.", last_error=str(exc))
def _process_chunk(self, *, chunk_index: int, start_day: date, end_day: date, force: bool) -> tuple[int, int, float, bool]:
imported_days = 0
@@ -303,9 +303,9 @@ class HistoricalSyncService:
self._advance_day(
day,
imported=False,
message=f"Pominieto {day.isoformat()} - dzien juz istnieje w cache",
message=f"Skipped {day.isoformat()} - day already exists in cache",
level="warn",
title="Pominieto dzien",
title="Day skipped",
chunk_index=chunk_index,
)
continue
@@ -316,9 +316,9 @@ class HistoricalSyncService:
self._advance_day(
day,
imported=False,
message=f"Pominieto {day.isoformat()} - brak probek w InfluxDB",
message=f"Skipped {day.isoformat()} - no samples in InfluxDB",
level="warn",
title="Brak probek",
title="No samples",
chunk_index=chunk_index,
)
continue
@@ -336,9 +336,9 @@ class HistoricalSyncService:
self._advance_day(
day,
imported=True,
message=f"Zaimportowano {day.isoformat()} ({total:.2f} kWh)",
message=f"Imported {day.isoformat()} ({total:.2f} kWh)",
level="success",
title="Zaimportowano dzien",
title="Day imported",
chunk_index=chunk_index,
energy_kwh=total,
)
@@ -366,7 +366,7 @@ class HistoricalSyncService:
self._state.message = message
self._refresh_coverage(lock_held=True)
self._refresh_runtime_metrics(lock_held=True)
suffix = f" Energia: {energy_kwh:.2f} kWh." if imported and energy_kwh is not None else ""
suffix = f" Energy: {energy_kwh:.2f} kWh." if imported and energy_kwh is not None else ""
self._record_event(
level=level,
title=title,
@@ -383,19 +383,19 @@ class HistoricalSyncService:
end_date=chunk_end,
state="running",
started_at=datetime.utcnow(),
note=f"Aktywny chunk {chunk_start.isoformat()} -> {chunk_end.isoformat()}",
note=f"Active chunk {chunk_start.isoformat()} -> {chunk_end.isoformat()}",
)
with self._state_lock:
self._state.current_chunk_start = chunk_start
self._state.current_chunk_end = chunk_end
self._state.active_chunk_index = chunk_index
self._state.message = f"Przetwarzanie zakresu {chunk_start.isoformat()} -> {chunk_end.isoformat()}"
self._state.message = f"Processing range {chunk_start.isoformat()} -> {chunk_end.isoformat()}"
self._upsert_chunk_locked(chunk)
self._refresh_runtime_metrics(lock_held=True)
self._record_event(
level="info",
title=f"Chunk {chunk_index}/{total_chunks}",
message=f"Start zakresu {chunk_start.isoformat()} -> {chunk_end.isoformat()}",
message=f"Starting range {chunk_start.isoformat()} -> {chunk_end.isoformat()}",
chunk_index=chunk_index,
)

View File

@@ -77,7 +77,7 @@ class KioskSettingsService:
return normalized
if normalized.startswith(USER_MODE_PREFIX) and len(normalized) > len(USER_MODE_PREFIX):
return normalized
raise ValueError("Mode musi byc jednym z: public, private")
raise ValueError("Mode must be one of: public, private")
def _normalize_widgets(self, widgets: Any) -> list[str]:
if not isinstance(widgets, list):

View File

@@ -50,34 +50,34 @@ class RealtimeService:
inverter_temp = to_float(_value(latest, "inverter_temp"))
hero_cards = [
self._hero_card("ac_power", ac_power, subtitle="Aktualna moc AC"),
self._hero_card("dc_power_total", total_dc_power, label="Moc DC laczna", unit="W", subtitle="Suma stringow DC"),
self._hero_card("energy_today", energy_today, label="Energia dzis", unit="kWh", subtitle="Liczona z danych Influx"),
self._hero_card("energy_total", total_energy, label="Energia laczna", unit="kWh", subtitle="Licznik calkowity"),
self._hero_card("ac_power", ac_power, subtitle="Current AC power"),
self._hero_card("dc_power_total", total_dc_power, label="Total DC power", unit="W", subtitle="Sum of DC strings"),
self._hero_card("energy_today", energy_today, label="Energy today", unit="kWh", subtitle="Calculated from Influx data"),
self._hero_card("energy_total", total_energy, label="Total energy", unit="kWh", subtitle="Lifetime counter"),
]
if inverter_temp is not None:
hero_cards.append(self._hero_card("inverter_temp", inverter_temp, label="Temp. falownika", unit="°C", subtitle="Sensor opcjonalny"))
hero_cards.append(self._hero_card("inverter_temp", inverter_temp, label="Inverter temperature", unit="°C", subtitle="Optional sensor"))
kpis = {
"energy_today": custom_metric_value("energy_today", "Energia dzis", energy_today, unit="kWh", precision=2, status="ok"),
"energy_yesterday": custom_metric_value("energy_yesterday", "Energia wczoraj", energy_yesterday, unit="kWh", precision=2, status="ok"),
"energy_today": custom_metric_value("energy_today", "Energy today", energy_today, unit="kWh", precision=2, status="ok"),
"energy_yesterday": custom_metric_value("energy_yesterday", "Energy yesterday", energy_yesterday, unit="kWh", precision=2, status="ok"),
"energy_total": custom_metric_value(
"energy_total",
"Energia laczna",
"Total energy",
total_energy,
unit="kWh",
precision=2,
timestamp=_timestamp(latest, "energy_total"),
status="ok",
),
"dc_power_total": custom_metric_value("dc_power_total", "Moc DC laczna", total_dc_power, unit="W", precision=0, status="ok"),
"dc_power_total": custom_metric_value("dc_power_total", "Total DC power", total_dc_power, unit="W", precision=0, status="ok"),
}
comparison = compare_delta_pct(energy_today, energy_yesterday)
if comparison is not None:
kpis["today_vs_yesterday"] = custom_metric_value(
"today_vs_yesterday",
"Dzis vs wczoraj",
"Today vs yesterday",
comparison,
unit="%",
precision=2,
@@ -97,7 +97,7 @@ class RealtimeService:
status.append(
custom_metric_value(
"data_refresh",
"Ostatni odczyt energii",
"Last energy reading",
_timestamp(latest, "energy_total").isoformat() if _timestamp(latest, "energy_total") else None,
status="ok" if _timestamp(latest, "energy_total") else "neutral",
kind="text",
@@ -144,7 +144,7 @@ class RealtimeService:
series.append(
{
"metric_id": metric.id,
"label": metric.label if slot != "power" else group["label"],
"label": f"{group['label']} power" if slot == "power" else f"{group['label']} voltage" if slot == "voltage" else metric.label,
"unit": metric.unit,
"points": self.influx.gauge_history(metric, window.start, window.end, interval=interval, aggregate="mean"),
}

View File

@@ -108,6 +108,23 @@ class SQLiteAuthUserRepository:
return None
return self.get_by_username(username)
def update_role(self, username: str, role: str) -> AuthUser | None:
now = datetime.utcnow().isoformat()
with self.connect() as conn:
cursor = conn.execute(
"UPDATE auth_users SET role = ?, updated_at = ? WHERE username = ?",
(role, now, username),
)
if cursor.rowcount == 0:
return None
return self.get_by_username(username)
def count_admin_users(self) -> int:
with self.connect() as conn:
row = conn.execute(
"SELECT COUNT(*) AS count FROM auth_users WHERE role = 'admin' AND is_active = 1"
).fetchone()
return int(row['count']) if row is not None else 0
def list_users(self) -> list[AuthUser]:
with self.connect() as conn: