393 files
This commit is contained in:
@@ -23,7 +23,6 @@ from homeassistant.core import HomeAssistant
|
||||
from ..const import (
|
||||
CONF_OBJECT,
|
||||
DOMAIN,
|
||||
MAX_ID_LENGTH,
|
||||
MAX_NAME_LENGTH,
|
||||
MAX_TEXT_LENGTH,
|
||||
MAX_URL_LENGTH,
|
||||
@@ -31,7 +30,7 @@ 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 . import _get_object_entries, _load_object_entry, object_id_for_entry
|
||||
from . import ID_FIELD, _get_object_entries, _load_object_entry, object_id_for_entry
|
||||
from .tasks import _is_safe_url
|
||||
|
||||
if TYPE_CHECKING:
|
||||
@@ -47,7 +46,7 @@ _TAGS_SCHEMA = vol.All(
|
||||
vol.Length(max=_MAX_TAGS),
|
||||
)
|
||||
_TASK_IDS_SCHEMA = vol.All(
|
||||
[vol.All(str, vol.Length(max=MAX_ID_LENGTH))],
|
||||
[ID_FIELD],
|
||||
vol.Length(max=_MAX_TASK_IDS),
|
||||
)
|
||||
# {task_id: page} jump-to-page hints; page 0 clears, >=1 sets (PDFs, #page=N).
|
||||
@@ -67,7 +66,7 @@ def _get_store(hass: HomeAssistant) -> DocumentStore:
|
||||
@websocket_api.websocket_command(
|
||||
{
|
||||
vol.Required("type"): "maintenance_supporter/documents/list",
|
||||
vol.Required("entry_id"): vol.All(str, vol.Length(max=MAX_ID_LENGTH)),
|
||||
vol.Required("entry_id"): ID_FIELD,
|
||||
}
|
||||
)
|
||||
@websocket_api.async_response
|
||||
@@ -109,7 +108,7 @@ async def ws_documents_storage(
|
||||
@websocket_api.websocket_command(
|
||||
{
|
||||
vol.Required("type"): "maintenance_supporter/documents/add_link",
|
||||
vol.Required("entry_id"): vol.All(str, vol.Length(max=MAX_ID_LENGTH)),
|
||||
vol.Required("entry_id"): ID_FIELD,
|
||||
vol.Required("url"): vol.All(str, vol.Length(min=1, max=MAX_URL_LENGTH)),
|
||||
vol.Optional("title"): vol.Any(vol.All(str, vol.Length(max=MAX_NAME_LENGTH)), None),
|
||||
vol.Optional("tags"): _TAGS_SCHEMA,
|
||||
@@ -150,7 +149,7 @@ async def ws_documents_add_link(
|
||||
@websocket_api.websocket_command(
|
||||
{
|
||||
vol.Required("type"): "maintenance_supporter/documents/update",
|
||||
vol.Required("doc_id"): vol.All(str, vol.Length(max=MAX_ID_LENGTH)),
|
||||
vol.Required("doc_id"): ID_FIELD,
|
||||
vol.Optional("title"): vol.All(str, vol.Length(max=MAX_NAME_LENGTH)),
|
||||
vol.Optional("tags"): _TAGS_SCHEMA,
|
||||
vol.Optional("task_ids"): _TASK_IDS_SCHEMA,
|
||||
@@ -192,7 +191,7 @@ async def ws_documents_update(
|
||||
@websocket_api.websocket_command(
|
||||
{
|
||||
vol.Required("type"): "maintenance_supporter/documents/delete",
|
||||
vol.Required("doc_id"): vol.All(str, vol.Length(max=MAX_ID_LENGTH)),
|
||||
vol.Required("doc_id"): ID_FIELD,
|
||||
}
|
||||
)
|
||||
@require_write
|
||||
@@ -216,6 +215,51 @@ async def ws_documents_delete(
|
||||
connection.send_result(msg["id"], {"success": True, "bytes_freed": freed})
|
||||
|
||||
|
||||
@websocket_api.websocket_command(
|
||||
{
|
||||
vol.Required("type"): "maintenance_supporter/documents/discard_upload",
|
||||
vol.Required("entry_id"): ID_FIELD,
|
||||
vol.Required("doc_id"): ID_FIELD,
|
||||
}
|
||||
)
|
||||
@websocket_api.async_response
|
||||
async def ws_documents_discard_upload(
|
||||
hass: HomeAssistant,
|
||||
connection: websocket_api.ActiveConnection,
|
||||
msg: dict[str, Any],
|
||||
) -> None:
|
||||
"""Delete a completion photo that never became part of a record (read tier).
|
||||
|
||||
Every signed-in user may upload a completion photo, but only writers may
|
||||
delete documents — so a photo a household member removed from the
|
||||
dialog again, or whose completion was cancelled, stayed behind as an
|
||||
orphan counting against the object's document cap (bug audit
|
||||
2026-09-27, R SEC-3). This deletes a document only when it belongs to
|
||||
``entry_id``'s object, is tagged exactly ``photo`` and nothing points at
|
||||
it (no history entry of any task, no part, no task link) — anything else
|
||||
answers ``not_found``, whether it exists or not, so the command reveals
|
||||
nothing and can remove nothing that is part of the record.
|
||||
"""
|
||||
from ..helpers.completion_requirements import is_unattached_photo, photo_references
|
||||
|
||||
entry = _load_object_entry(hass, connection, msg)
|
||||
if entry is None:
|
||||
return
|
||||
store = _get_store(hass)
|
||||
doc = store.documents.get(msg["doc_id"])
|
||||
references = photo_references(hass, strict=False)
|
||||
if (
|
||||
doc is None
|
||||
or references is None
|
||||
or doc.get("object_id") != object_id_for_entry(entry)
|
||||
or not is_unattached_photo(doc, msg["doc_id"], references)
|
||||
):
|
||||
connection.send_error(msg["id"], "not_found", "Document not found")
|
||||
return
|
||||
await store.async_remove(msg["doc_id"])
|
||||
connection.send_result(msg["id"], {"success": True})
|
||||
|
||||
|
||||
_SEARCH_MAX_RESULTS = 50
|
||||
#: Field weights for the tolerant matcher — the title is what people remember,
|
||||
#: the file name and tags come next, a URL or MIME rarely.
|
||||
@@ -382,10 +426,17 @@ async def ws_search(
|
||||
by_digest.setdefault(digest, []).append(did)
|
||||
for hit in await store.text_index.async_search(msg["query"], limit=limit * 2):
|
||||
for did in by_digest.get(hit["digest"], []):
|
||||
# The search awaits: a document deleted meanwhile raised a
|
||||
# KeyError here and the whole search failed (bug audit
|
||||
# 2026-09-26) — it simply is no hit any more.
|
||||
live = store.documents.get(did)
|
||||
if live is None:
|
||||
doc_hits.pop(did, None)
|
||||
continue
|
||||
cur = doc_hits.get(did)
|
||||
if cur is None:
|
||||
doc_hits[did] = {
|
||||
**_doc_hit(did, store.documents[did], obj_map),
|
||||
**_doc_hit(did, live, obj_map),
|
||||
"score": hit["score"],
|
||||
"match": "content",
|
||||
"page": hit["page"],
|
||||
@@ -395,15 +446,16 @@ async def ws_search(
|
||||
cur["page"] = hit["page"]
|
||||
cur["snippet"] = hit["snippet"]
|
||||
cur["score"] = max(cur["score"], hit["score"]) + min(cur["score"], hit["score"]) // 4
|
||||
documents = sorted(doc_hits.values(), key=lambda d: -d["score"])[:limit]
|
||||
documents = sorted((hit for did, hit in doc_hits.items() if did in store.documents), key=lambda d: -d["score"])[:limit]
|
||||
|
||||
# History notes across every task of every object.
|
||||
history: list[dict[str, Any]] = []
|
||||
for entry in _get_object_entries(hass):
|
||||
oname = object_name(entry)
|
||||
obj_ref = (entry.data.get(CONF_OBJECT) or {}).get("ref_no")
|
||||
# Not ``obj_ref``: that name is the parsed int of the reference branch above.
|
||||
object_ref = (entry.data.get(CONF_OBJECT) or {}).get("ref_no")
|
||||
for tid, td in merged_tasks(entry).items():
|
||||
td = {**td, "_object_ref": obj_ref}
|
||||
td = {**td, "_object_ref": object_ref}
|
||||
for h in td.get("history") or []:
|
||||
notes = h.get("notes") if isinstance(h, dict) else None
|
||||
if not isinstance(notes, str) or not notes.strip():
|
||||
|
||||
Reference in New Issue
Block a user