198 files
This commit is contained in:
@@ -397,7 +397,7 @@ def _build_object_response(
|
||||
from ..const import CONF_PART_SEARCH_URL_TEMPLATE, CONF_PARTS
|
||||
from ..helpers.global_options import get_global_options
|
||||
from ..helpers.i18n import normalize_language
|
||||
from ..helpers.parts import part_is_low, resolve_shopping_url
|
||||
from ..helpers.parts import on_hand, part_is_low, resolve_shopping_url
|
||||
|
||||
part_store = get_store(hass, entry.entry_id)
|
||||
search_template = get_global_options(hass).get(CONF_PART_SEARCH_URL_TEMPLATE)
|
||||
@@ -405,11 +405,14 @@ def _build_object_response(
|
||||
parts_payload = []
|
||||
for part in (entry.data.get(CONF_PARTS) or {}).values():
|
||||
stock = part_store.get_part_stock(part["id"]) if part_store else None
|
||||
# #208: used items of a reusable part waiting to be ready again.
|
||||
restoring = part_store.get_part_restoring(part["id"]) if part_store else 0
|
||||
parts_payload.append(
|
||||
{
|
||||
**part,
|
||||
"stock": stock,
|
||||
"is_low": part_is_low(part, stock),
|
||||
"restoring": restoring,
|
||||
"is_low": part_is_low(part, on_hand(part, stock, restoring)),
|
||||
"shopping_url": resolve_shopping_url(part, search_template, lang, hass.config.country),
|
||||
}
|
||||
)
|
||||
@@ -809,6 +812,7 @@ def async_register_commands(hass: HomeAssistant) -> None:
|
||||
from .parts import (
|
||||
ws_create_part,
|
||||
ws_delete_part,
|
||||
ws_part_ready_again,
|
||||
ws_parts_overview,
|
||||
ws_restock_part,
|
||||
ws_update_part,
|
||||
@@ -898,6 +902,7 @@ def async_register_commands(hass: HomeAssistant) -> None:
|
||||
websocket_api.async_register_command(hass, ws_delete_part)
|
||||
websocket_api.async_register_command(hass, ws_parts_overview)
|
||||
websocket_api.async_register_command(hass, ws_restock_part)
|
||||
websocket_api.async_register_command(hass, ws_part_ready_again)
|
||||
websocket_api.async_register_command(hass, ws_update_history_entry)
|
||||
websocket_api.async_register_command(hass, ws_delete_history_entry)
|
||||
websocket_api.async_register_command(hass, ws_get_templates)
|
||||
|
||||
@@ -40,6 +40,33 @@ from ..helpers.permissions import require_write
|
||||
from ..helpers.ws_errors import send_translated_error
|
||||
|
||||
|
||||
def _charging(hass: HomeAssistant, fleet: Any) -> list[dict[str, Any]]:
|
||||
"""The fleet's counted reusable parts: ready and waiting (#208)."""
|
||||
if fleet is None:
|
||||
return []
|
||||
from ..const import CONF_PARTS
|
||||
from ..helpers.aggregate import get_store
|
||||
|
||||
store = get_store(hass, fleet.entry_id)
|
||||
if store is None:
|
||||
return []
|
||||
rows: list[dict[str, Any]] = []
|
||||
for part_id, part in sorted((fleet.data.get(CONF_PARTS) or {}).items()):
|
||||
stock = store.get_part_stock(part_id)
|
||||
if not part.get("reuse") or stock is None:
|
||||
continue
|
||||
rows.append(
|
||||
{
|
||||
"part_id": part_id,
|
||||
"name": part.get("name"),
|
||||
"reuse": part["reuse"],
|
||||
"stock": stock,
|
||||
"restoring": store.get_part_restoring(part_id),
|
||||
}
|
||||
)
|
||||
return rows
|
||||
|
||||
|
||||
@websocket_api.websocket_command({vol.Required("type"): f"{DOMAIN}/battery_fleet/overview"})
|
||||
@websocket_api.async_response
|
||||
async def ws_battery_fleet_overview(hass: HomeAssistant, connection: websocket_api.ActiveConnection, msg: dict[str, Any]) -> None:
|
||||
@@ -73,6 +100,11 @@ async def ws_battery_fleet_overview(hass: HomeAssistant, connection: websocket_a
|
||||
"needs_now": dict(ov.needs_now),
|
||||
"needs_soon": dict(ov.needs_soon),
|
||||
"types": ov.types,
|
||||
# #208: the fleet's reusable parts (rechargeable cells) with how
|
||||
# many are charged and how many wait to be charged — only those
|
||||
# whose spares are counted (an untracked part is a device charged
|
||||
# in place, or nobody counts).
|
||||
"charging": _charging(hass, fleet),
|
||||
# #135 follow-up: the roster's track-self-charging toggle state.
|
||||
"track_self_charging": fleet_track_self_charging(hass),
|
||||
# D#162: whether a passed forecast on a sensorless note counts as
|
||||
|
||||
@@ -30,9 +30,9 @@ from ..const import (
|
||||
from ..helpers.aggregate import object_name
|
||||
from ..helpers.permissions import require_write
|
||||
from ..helpers.search_match import query_tokens, score_fields, snippet
|
||||
from ..helpers.url_safety import is_safe_link
|
||||
from ..helpers.ws_errors import send_translated_error
|
||||
from . import ID_FIELD, _get_object_entries, _load_object_entry, object_id_for_entry
|
||||
from .tasks import _is_safe_url
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from ..helpers.documents import DocumentStore
|
||||
@@ -129,11 +129,11 @@ async def ws_documents_add_link(
|
||||
return
|
||||
|
||||
url = msg["url"].strip()
|
||||
# Web-links must be absolute http/https — a relative or scheme-less URL is
|
||||
# meaningless as a stored external reference (and _is_safe_url treats those
|
||||
# as "safe", so the explicit prefix check is required here).
|
||||
if not url.lower().startswith(("http://", "https://")) or not _is_safe_url(url):
|
||||
send_translated_error(connection, msg["id"], "invalid_url", "Only http/https URLs are allowed", translation_key="unsafe_url")
|
||||
# Web-links must be absolute (http(s) or an app link, #207) — a relative
|
||||
# or scheme-less URL is meaningless as a stored external reference (and
|
||||
# _is_safe_url treats those as "safe", so is_safe_link asks for a scheme).
|
||||
if not is_safe_link(url):
|
||||
send_translated_error(connection, msg["id"], "invalid_url", "This link is not allowed: links that run scripts or open local files (javascript:, data:, file: and the like) are refused.", translation_key="unsafe_url")
|
||||
return
|
||||
|
||||
title = msg.get("title")
|
||||
|
||||
@@ -1134,6 +1134,10 @@ async def ws_import_json(
|
||||
stock = part_entry.get("stock")
|
||||
if isinstance(stock, (int, float)) and not isinstance(stock, bool) and stock >= 0:
|
||||
pdata["stock"] = stock
|
||||
# #208: the used ones waiting go with the stock.
|
||||
waiting = part_entry.get("restoring")
|
||||
if pdata.get("reuse") and isinstance(waiting, (int, float)) and not isinstance(waiting, bool) and waiting > 0:
|
||||
pdata["restoring"] = waiting
|
||||
import_parts[new_id] = pdata
|
||||
if old_id:
|
||||
part_id_map[old_id] = new_id
|
||||
|
||||
@@ -336,7 +336,7 @@ async def ws_create_object(
|
||||
# v1.4.0 (#43): documentation_url
|
||||
documentation_url = (msg.get("documentation_url") or "").strip() or None
|
||||
if documentation_url and not _is_safe_url(documentation_url):
|
||||
send_translated_error(connection, msg["id"], "invalid_url", "Only http/https URLs are allowed", translation_key="unsafe_url")
|
||||
send_translated_error(connection, msg["id"], "invalid_url", "This link is not allowed: links that run scripts or open local files (javascript:, data:, file: and the like) are refused.", translation_key="unsafe_url")
|
||||
return
|
||||
|
||||
# v1.4.10 (#46): notes (free-form, may contain newlines)
|
||||
@@ -432,7 +432,7 @@ async def ws_update_object(
|
||||
stripped = (msg["documentation_url"] or "").strip()
|
||||
msg["documentation_url"] = stripped or None
|
||||
if msg["documentation_url"] and not _is_safe_url(msg["documentation_url"]):
|
||||
send_translated_error(connection, msg["id"], "invalid_url", "Only http/https URLs are allowed", translation_key="unsafe_url")
|
||||
send_translated_error(connection, msg["id"], "invalid_url", "This link is not allowed: links that run scripts or open local files (javascript:, data:, file: and the like) are refused.", translation_key="unsafe_url")
|
||||
return
|
||||
|
||||
# v1.4.10 (#46): notes — strip but keep newlines, empty -> None
|
||||
@@ -1060,6 +1060,7 @@ async def ws_replace_object(
|
||||
stock = src_store.get_part_stock(old_pid)
|
||||
if stock is not None:
|
||||
new_store.set_part_stock(new_pid, stock)
|
||||
new_store.set_part_restoring(new_pid, src_store.get_part_restoring(old_pid))
|
||||
await new_store.async_save()
|
||||
# Objects BORROWING the predecessor's pool (#111) now draw on the
|
||||
# successor's shelf. They kept linking to the archived predecessor —
|
||||
|
||||
@@ -14,6 +14,7 @@ from ..helpers.aggregate import get_store, object_name
|
||||
from ..helpers.parts import (
|
||||
MAX_PART_STOCK,
|
||||
MAX_PARTS_PER_OBJECT,
|
||||
REUSE_KINDS,
|
||||
PartValidationError,
|
||||
normalize_part,
|
||||
)
|
||||
@@ -50,9 +51,13 @@ _PART_FIELDS_SCHEMA: VolDictType = {
|
||||
vol.Optional("package_size"): vol.Any(int, float, None),
|
||||
vol.Optional("auto_buy_task"): bool,
|
||||
vol.Optional("doc_id"): vol.Any(ID_FIELD, None),
|
||||
# #208: what readies a used one again (charge, sharpen, ...); None = used up.
|
||||
vol.Optional("reuse"): vol.Any(vol.In(REUSE_KINDS), None),
|
||||
# Initial / edited stock travels WITH the definition for dialog simplicity,
|
||||
# but is stored in the per-entry Store (dynamic), not entry.data.
|
||||
# but is stored in the per-entry Store (dynamic), not entry.data — as does
|
||||
# the count of used ones waiting (#208).
|
||||
vol.Optional("stock"): vol.Any(int, float, None),
|
||||
vol.Optional("restoring"): vol.Any(vol.All(vol.Any(int, float), vol.Range(min=0, max=MAX_PART_STOCK)), None),
|
||||
}
|
||||
|
||||
|
||||
@@ -90,9 +95,11 @@ async def ws_create_part(
|
||||
|
||||
stock = msg.get("stock")
|
||||
if stock is not None:
|
||||
from ..parts_runtime import async_change_part_stock
|
||||
from ..parts_runtime import async_change_part_stock, async_set_part_restoring
|
||||
|
||||
await async_change_part_stock(hass, entry, part["id"], absolute=float(stock))
|
||||
if part.get("reuse") and msg.get("restoring"):
|
||||
await async_set_part_restoring(hass, entry, part["id"], float(msg["restoring"]))
|
||||
# Reload so the part's stock sensor appears (entities are created at setup).
|
||||
await hass.config_entries.async_reload(entry.entry_id)
|
||||
connection.send_result(msg["id"], {"part_id": part["id"]})
|
||||
@@ -132,8 +139,14 @@ async def ws_update_part(
|
||||
parts[part["id"]] = part
|
||||
_persist_parts(hass, entry, parts)
|
||||
|
||||
from ..parts_runtime import async_change_part_stock, schedule_buy_task_reconcile
|
||||
from ..parts_runtime import async_change_part_stock, async_set_part_restoring, schedule_buy_task_reconcile
|
||||
|
||||
# #208: the waiting pile goes with the part's reuse — set when given, and
|
||||
# gone when the part is no longer reusable.
|
||||
if not part.get("reuse") and existing.get("reuse"):
|
||||
await async_set_part_restoring(hass, entry, part["id"], 0)
|
||||
elif part.get("reuse") and msg.get("restoring") is not None:
|
||||
await async_set_part_restoring(hass, entry, part["id"], float(msg["restoring"]))
|
||||
if "stock" in msg:
|
||||
stock = msg.get("stock")
|
||||
if stock is None:
|
||||
@@ -151,7 +164,7 @@ async def ws_update_part(
|
||||
|
||||
|
||||
# str() of a voluptuous marker is its key name (Marker.__str__ -> str(schema)).
|
||||
_PART_FIELD_KEYS = {str(k) for k in _PART_FIELDS_SCHEMA if str(k) != "stock"}
|
||||
_PART_FIELD_KEYS = {str(k) for k in _PART_FIELDS_SCHEMA if str(k) not in ("stock", "restoring")}
|
||||
|
||||
|
||||
@websocket_api.websocket_command(
|
||||
@@ -271,6 +284,36 @@ async def ws_restock_part(
|
||||
connection.send_result(msg["id"], {"stock": new})
|
||||
|
||||
|
||||
@websocket_api.websocket_command(
|
||||
{
|
||||
vol.Required("type"): "maintenance_supporter/part/ready_again",
|
||||
vol.Required("entry_id"): ID_FIELD,
|
||||
vol.Required("part_id"): ID_FIELD,
|
||||
# How many are ready again; all waiting ones when left out.
|
||||
vol.Optional("quantity"): vol.All(vol.Any(int, float), vol.Coerce(float), vol.Range(min=0, max=MAX_PART_STOCK)),
|
||||
}
|
||||
)
|
||||
@require_write
|
||||
@websocket_api.async_response
|
||||
async def ws_part_ready_again(
|
||||
hass: HomeAssistant,
|
||||
connection: websocket_api.ActiveConnection,
|
||||
msg: dict[str, Any],
|
||||
) -> None:
|
||||
"""Used items of a reusable part are ready again (#208): charged,
|
||||
sharpened, cleaned, dried or refilled — back from the pile to the stock."""
|
||||
entry = _load_object_entry(hass, connection, msg)
|
||||
if entry is None:
|
||||
return
|
||||
from ..parts_runtime import async_part_ready_again
|
||||
|
||||
result = await async_part_ready_again(hass, entry, msg["part_id"], msg.get("quantity"))
|
||||
if result is None:
|
||||
connection.send_error(msg["id"], "not_found", "No reusable part with a tracked stock")
|
||||
return
|
||||
connection.send_result(msg["id"], result)
|
||||
|
||||
|
||||
@websocket_api.websocket_command({vol.Required("type"): "maintenance_supporter/parts/overview"})
|
||||
@websocket_api.async_response
|
||||
async def ws_parts_overview(
|
||||
@@ -284,7 +327,7 @@ async def ws_parts_overview(
|
||||
every consuming task — the object's own tasks and pooled #111 links from
|
||||
other objects. Read-only; the per-object CRUD stays on part/*.
|
||||
"""
|
||||
from ..helpers.parts import part_is_low
|
||||
from ..helpers.parts import on_hand, part_is_low
|
||||
from . import _get_merged_tasks, _get_object_entries
|
||||
|
||||
entries = _get_object_entries(hass)
|
||||
@@ -317,6 +360,7 @@ async def ws_parts_overview(
|
||||
store = get_store(hass, e.entry_id)
|
||||
for part_id, part in parts.items():
|
||||
stock = store.get_part_stock(part_id) if store is not None else None
|
||||
restoring = store.get_part_restoring(part_id) if store is not None else 0
|
||||
rows.append(
|
||||
{
|
||||
**part,
|
||||
@@ -324,7 +368,8 @@ async def ws_parts_overview(
|
||||
"entry_id": e.entry_id,
|
||||
"object_name": names.get(e.entry_id),
|
||||
"stock": stock,
|
||||
"low": part_is_low(part, stock),
|
||||
"restoring": restoring,
|
||||
"low": part_is_low(part, on_hand(part, stock, restoring)),
|
||||
"consumers": consumers.get((e.entry_id, part_id), []),
|
||||
}
|
||||
)
|
||||
|
||||
@@ -41,7 +41,6 @@ from .tasks_persist import (
|
||||
async_persist_task,
|
||||
)
|
||||
from .tasks_validation import (
|
||||
_SAFE_URL_SCHEMES,
|
||||
_TRIGGER_ALLOWED_KEYS,
|
||||
_TRIGGER_REQUIRED_FIELDS,
|
||||
_VALID_TRIGGER_TYPES,
|
||||
@@ -52,7 +51,6 @@ from .tasks_validation import (
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
"_SAFE_URL_SCHEMES",
|
||||
"_TRIGGER_ALLOWED_KEYS",
|
||||
"_TRIGGER_REQUIRED_FIELDS",
|
||||
"_VALID_TRIGGER_TYPES",
|
||||
|
||||
@@ -487,7 +487,7 @@ async def ws_create_task(
|
||||
task_data["notes"] = msg["notes"]
|
||||
if msg.get("documentation_url") is not None:
|
||||
if not _is_safe_url(msg["documentation_url"]):
|
||||
send_translated_error(connection, msg["id"], "invalid_url", "Only http/https URLs are allowed", translation_key="unsafe_url")
|
||||
send_translated_error(connection, msg["id"], "invalid_url", "This link is not allowed: links that run scripts or open local files (javascript:, data:, file: and the like) are refused.", translation_key="unsafe_url")
|
||||
return
|
||||
task_data["documentation_url"] = msg["documentation_url"]
|
||||
if msg.get("responsible_user_id") is not None:
|
||||
@@ -703,7 +703,7 @@ async def ws_update_task(
|
||||
|
||||
# Validate documentation_url if provided
|
||||
if "documentation_url" in msg and not _is_safe_url(msg["documentation_url"]):
|
||||
send_translated_error(connection, msg["id"], "invalid_url", "Only http/https URLs are allowed", translation_key="unsafe_url")
|
||||
send_translated_error(connection, msg["id"], "invalid_url", "This link is not allowed: links that run scripts or open local files (javascript:, data:, file: and the like) are refused.", translation_key="unsafe_url")
|
||||
return
|
||||
|
||||
# D#183: mirror targets — same sanitize + own-platform refusal as create;
|
||||
|
||||
@@ -20,7 +20,6 @@ from ..const import (
|
||||
)
|
||||
from ..helpers.aggregate import object_name
|
||||
from ..helpers.trigger_fallback import threshold_limits_overlap
|
||||
from ..helpers.url_safety import _SAFE_URL_SCHEMES as _SAFE_URL_SCHEMES
|
||||
from ..helpers.url_safety import is_safe_url
|
||||
from . import (
|
||||
_get_object_entries,
|
||||
|
||||
@@ -14,6 +14,7 @@ from ..const import (
|
||||
MAX_META_LENGTH,
|
||||
)
|
||||
from ..helpers.aggregate import get_coordinator_data, get_runtime_data, object_name
|
||||
from ..helpers.assignees import task_is_for
|
||||
from ..helpers.entry_tasks import write_task
|
||||
from ..helpers.permissions import require_write, user_may_write
|
||||
from . import (
|
||||
@@ -134,7 +135,8 @@ async def ws_tasks_by_user(
|
||||
connection: websocket_api.ActiveConnection,
|
||||
msg: dict[str, Any],
|
||||
) -> None:
|
||||
"""Return all tasks assigned to a specific user across all objects."""
|
||||
"""Return all tasks meant for a user across all objects: theirs, and
|
||||
shared ones nobody is responsible for (helpers/assignees)."""
|
||||
user_id = msg["user_id"]
|
||||
# Authorization: a user may query their OWN assignments; querying another
|
||||
# user's requires write access (admin or operator) — prevents a plain user
|
||||
@@ -151,7 +153,7 @@ async def ws_tasks_by_user(
|
||||
obj_name = object_name(entry)
|
||||
|
||||
for tid, tdata in tasks_data.items():
|
||||
if tdata.get("responsible_user_id") == user_id:
|
||||
if task_is_for(tdata, user_id):
|
||||
task_summary = _build_task_summary(hass, tid, tdata, ct_tasks.get(tid))
|
||||
task_summary["object_name"] = obj_name
|
||||
task_summary["entry_id"] = entry.entry_id
|
||||
|
||||
Reference in New Issue
Block a user