198 files

This commit is contained in:
Home Assistant Version Control
2026-10-11 23:51:59 +00:00
parent 4a64770282
commit 669d229519
198 changed files with 4891 additions and 2815 deletions
@@ -40,6 +40,8 @@ from typing import Any, cast
from homeassistant.core import HomeAssistant
from homeassistant.util import dt as dt_util
from ..const import DEFAULT_BATTERY_LOW_PERCENT
_LOGGER = logging.getLogger(__name__)
# The typical-lifetime table, the household overrides and the learned values
@@ -51,6 +53,7 @@ from .battery_lifetime import (
has_type_forecast,
is_rechargeable_type,
is_shoppable_type,
is_swappable_cell,
lifetime_resolver,
observe_replacements,
table_lifetime_months,
@@ -521,12 +524,23 @@ def get_battery_auto_record_recovery(hass: HomeAssistant) -> bool:
# latched: their binary saying "not low" is the all-clear, exactly as before.
# Sensorless notes (D#162) leave ``low`` only when their forecast re-anchors.
#
# Persisted on the fleet task's Store state ({entity_id: {at, last_replaced}})
# #205: each entry also keeps the low threshold it latched at. A threshold
# lowered since then (the household's battery_low_percent, a Battery Notes
# threshold) is a decision, not a hover: a battery above the NEW threshold
# is released at once instead of waiting for the recovered level, which
# kept 15 % batteries on the list after the floor went from 20 % to 10 %.
#
# Persisted on the fleet task's Store state ({entity_id: {at, last_replaced, threshold}})
# so a restart does not re-open the episode; without a fleet the latch lives
# in memory (the low-count sensor exists before the fleet does).
LOW_LATCH_KEY = "battery_low_latch"
_LATCH_MEMORY_KEY = "battery_fleet_low_latch_memory"
# What a latch from before #205 (no threshold of its own) is taken to have
# latched at: the household default, the floor almost every such latch
# was set under. (A floor lowered long before leaves such a latch released
# once it reads above it; a battery that is really low latches again.)
_LEGACY_LATCH_THRESHOLD = float(DEFAULT_BATTERY_LOW_PERCENT)
def sanitize_low_latch(raw: Any) -> dict[str, dict[str, Any]]:
@@ -556,6 +570,9 @@ def sanitize_low_latch(raw: Any) -> dict[str, dict[str, Any]]:
continue
last = entry.get("last_replaced")
out[key] = {"at": at.strip()[:40], "last_replaced": last[:40] if isinstance(last, str) else None}
threshold = entry.get("threshold")
if isinstance(threshold, (int, float)) and not isinstance(threshold, bool) and 0 <= threshold <= 100:
out[key]["threshold"] = float(threshold)
return out
@@ -587,7 +604,10 @@ def apply_low_latch(
and the fresh reading is STILL low — a new episode);
* latched + unavailable → stays low (no reading is no proof of recovery);
* latched + reading not low → released when the level is above
``recovered`` or a replacement was recorded, else held low.
``recovered`` or a replacement was recorded, else held low;
* latched + the battery's low threshold lowered since it latched
(#205) and the level above the new one → released (not a recovery:
it never reaches ``released_by_level``, nothing is recorded).
``released_by_level`` (#181 follow-up) collects the ids released by their
LEVEL alone — no newer replacement date on the note — which is the
@@ -604,9 +624,15 @@ def apply_low_latch(
last = bat.last_replaced.isoformat() if bat.last_replaced else None
raw_entry = latch.get(bat.entity_id)
entry = raw_entry if isinstance(raw_entry, dict) else None
threshold = float(bat.low_threshold)
if bat.low:
if entry is None or _replaced_since(last, entry):
latch[bat.entity_id] = {"at": now_iso, "last_replaced": last}
latch[bat.entity_id] = {"at": now_iso, "last_replaced": last, "threshold": threshold}
changed = True
elif entry.get("threshold") != threshold:
# Low at the CURRENT threshold: the episode belongs to it
# (a rebound after this is held, as always).
latch[bat.entity_id] = {**entry, "threshold": threshold}
changed = True
continue
if entry is None:
@@ -617,6 +643,15 @@ def apply_low_latch(
del latch[bat.entity_id]
changed = True
continue
latched_at = entry.get("threshold")
if not isinstance(latched_at, (int, float)):
latched_at = max(_LEGACY_LATCH_THRESHOLD, threshold)
latch[bat.entity_id] = {**entry, "threshold": latched_at}
changed = True
if bat.available and bat.level is not None and threshold < latched_at and bat.level > threshold:
del latch[bat.entity_id]
changed = True
continue
bat.low = True
bat.latched = True
known = {b.entity_id for b in batteries}
@@ -1590,6 +1625,24 @@ def discover_battery_types(hass: HomeAssistant) -> OrderedDict[str, int]:
return OrderedDict(sorted(totals.items()))
def discover_rechargeable_cells(hass: HomeAssistant) -> OrderedDict[str, int]:
"""Rechargeable cell types across the fleet → total quantity (#208).
Kept apart from :func:`discover_battery_types` (cells anyone BUYS): a
swappable rechargeable cell ("AAA NiMH", "18650") gets a reusable part
instead — no reorder threshold, no buy link; a swap takes charged cells
and puts the empty ones on the "to charge" pile. A pack built into the
device gets nothing.
"""
totals: OrderedDict[str, int] = OrderedDict()
for bat in read_batteries(hass):
if not is_swappable_cell(bat.battery_type):
continue
t = canonical_type(bat.battery_type)
totals[t] = totals.get(t, 0) + bat.quantity
return OrderedDict(sorted(totals.items()))
__all__ = [
"DEFAULT_HORIZON_DAYS",
"LOW_LATCH_KEY",
@@ -1606,6 +1659,7 @@ __all__ = [
"canonical_type",
"compute_overview",
"discover_battery_types",
"discover_rechargeable_cells",
"fleet_due_without_sensor",
"fleet_excluded_entities",
"fleet_included_entities",
@@ -37,6 +37,8 @@ from ..const import (
from .battery_fleet import (
canonical_type,
discover_battery_types,
discover_rechargeable_cells,
is_rechargeable_type,
lifetime_months,
note_sibling_entity,
read_batteries,
@@ -87,6 +89,9 @@ async def async_setup_battery_fleet(hass: HomeAssistant, language: str | None =
lang = normalize_language_code(language) if language else normalize_language(hass)
types = discover_battery_types(hass) # {TYPE: total_qty}
# #208: rechargeable cells get a reusable part; it starts untracked — a
# device charged in place must not grow a "to charge" pile.
cells = discover_rechargeable_cells(hass)
existing = find_fleet_entry(hass)
if existing is not None:
@@ -97,7 +102,7 @@ async def async_setup_battery_fleet(hass: HomeAssistant, language: str | None =
# the parts deleted since (the start-up reconcile honours them).
if existing.data.get(CONF_OBJECT, {}).get(BATTERY_FLEET_REMOVED_PARTS):
_mutate_fleet_object(hass, lambda obj: obj.pop(BATTERY_FLEET_REMOVED_PARTS, None))
added_pids = _reconcile_type_parts(hass, existing, types, lang)
added_pids = _reconcile_type_parts(hass, existing, {**types, **cells}, lang)
# Track stock at 0 for the parts just added — the CREATE path below
# does, and a part left untracked (stock None) shows no stock line
# and never flags for reorder. Found on a real fleet: types added by
@@ -108,7 +113,8 @@ async def async_setup_battery_fleet(hass: HomeAssistant, language: str | None =
store = getattr(rd, "store", None) if rd else None
if store is not None:
for pid in added_pids:
store.set_part_stock(pid, 0)
if not _is_cell_part(pid):
store.set_part_stock(pid, 0)
await store.async_save()
repaired = await _reconcile_fleet_task(hass, existing, lang)
if migrated and not repaired:
@@ -138,7 +144,7 @@ async def async_setup_battery_fleet(hass: HomeAssistant, language: str | None =
obj[OBJECT_FLAG] = True
new_data[CONF_OBJECT] = obj
parts: dict[str, dict[str, Any]] = {}
for btype, total_qty in types.items():
for btype, total_qty in {**types, **cells}.items():
part = normalize_part(_type_part(btype, total_qty, lang))
parts[part["id"]] = part
new_data[CONF_PARTS] = parts
@@ -149,7 +155,8 @@ async def async_setup_battery_fleet(hass: HomeAssistant, language: str | None =
store = getattr(rd, "store", None) if rd else None
if store is not None:
for pid in parts:
store.set_part_stock(pid, 0)
if not _is_cell_part(pid):
store.set_part_stock(pid, 0)
await store.async_save()
# The single aggregate task, triggered by the global low-count sensor.
@@ -218,8 +225,18 @@ def _type_part(btype: str, total_qty: int, lang: str) -> dict[str, Any]:
"""
from ..templates import localize_template_text
threshold = max(2, total_qty)
name_tpl = localize_template_text("{type} battery", lang) or "{type} battery"
if is_rechargeable_type(btype):
# #208: charged, not bought — a reusable part with no reorder
# threshold and no buy link to nag with.
return {
"id": f"batt_{btype.lower()}",
"name": name_tpl.format(type=btype),
"unit": "pcs",
"auto_buy_task": False,
"reuse": "charge",
}
threshold = max(2, total_qty)
notes_tpl = localize_template_text("Typical service life ~{months} months.", lang) or "Typical service life ~{months} months."
return {
"id": f"batt_{btype.lower()}",
@@ -422,8 +439,9 @@ async def async_consume_type_parts(hass: HomeAssistant, by_type: dict[str, int])
parts = fleet.data.get(CONF_PARTS) or {}
for btype, qty in by_type.items():
pid = f"{FLEET_PART_PREFIX}{btype.lower()}"
if pid in parts:
await async_change_part_stock(hass, fleet, pid, delta=-qty)
# A use (#208): a rechargeable cell's empty one waits to be charged;
# an untracked part (a pack charged in place) is left alone.
if pid in parts and await async_change_part_stock(hass, fleet, pid, delta=-qty, use=True) is not None:
consumed[pid] = qty
return consumed
@@ -716,7 +734,7 @@ async def reconcile_fleet_parts_at_start(hass: HomeAssistant, entry: ConfigEntry
migrated = await migrate_fleet_part_ids(hass, entry)
types = discover_battery_types(hass)
added = _reconcile_type_parts(hass, entry, types, lang)
added = _reconcile_type_parts(hass, entry, {**types, **discover_rechargeable_cells(hass)}, lang)
pruned = False
rd = getattr(entry, "runtime_data", None)
@@ -761,7 +779,8 @@ async def reconcile_fleet_parts_at_start(hass: HomeAssistant, entry: ConfigEntry
if store is not None and (added or pruned):
for pid in added:
store.set_part_stock(pid, 0)
if not _is_cell_part(pid):
store.set_part_stock(pid, 0)
await store.async_save()
return {
"added": added,
@@ -785,6 +804,11 @@ async def reconcile_fleet_parts_at_start(hass: HomeAssistant, entry: ConfigEntry
FLEET_PART_PREFIX = "batt_"
def _is_cell_part(part_id: str) -> bool:
"""A rechargeable cell's part (#208): it starts untracked."""
return part_id.startswith(FLEET_PART_PREFIX) and is_rechargeable_type(part_id[len(FLEET_PART_PREFIX) :])
def canonical_part_id(part_id: str) -> str | None:
"""``batt_<label>`` → ``batt_<canonical label>``; None when the id is not
a fleet type-part id or already canonical."""
@@ -53,8 +53,9 @@ LINKS_NAME = "Links.txt"
BLOB_DIR = "blobs/"
ARCHIVE_VERSION = 2
# Cap a single archive import so a crafted ZIP can't exhaust memory/disk. A
# real documents backup is dominated by the blobs, already capped at 25 MB
# each × 100 docs/object — this is a coarse whole-archive ceiling on top.
# real documents backup is dominated by the blobs, already capped at
# MAX_DOC_BYTES each × 100 docs/object — this is a coarse whole-archive
# ceiling on top.
MAX_ARCHIVE_BYTES = 500 * 1024 * 1024 # 500 MB uncompressed-blob budget
MAX_MANIFEST_BYTES = 16 * 1024 * 1024 # the manifest is metadata only
MAX_ARCHIVE_MEMBERS = 20000 # ceiling on ZIP entry count (blobs cap at 100/obj)
@@ -16,12 +16,13 @@ from __future__ import annotations
import asyncio
import hashlib
import io
import logging
import math
import os
from collections.abc import Callable
from pathlib import Path
from typing import TYPE_CHECKING, Any
from typing import IO, TYPE_CHECKING, Any
from uuid import uuid4
from homeassistant.core import HomeAssistant
@@ -53,8 +54,13 @@ BLOBS_SUBDIR = "blobs"
DOC_CATEGORIES = ("manual", "warranty", "invoice", "spare_parts", "photo", "other")
# Per-file cap — guards against runaway backup growth (docs live in /config and
# are therefore duplicated into every retained backup).
MAX_DOC_BYTES = 25 * 1024 * 1024 # 25 MB
# are therefore duplicated into every retained backup). Was 25 MB; a scanned
# manual is easily 45 MB (#209), and since a file is never held in memory
# whole (copied and hashed in pieces, served from disk) the cap is about
# backups only.
MAX_DOC_BYTES = 100 * 1024 * 1024 # 100 MB
# A stored file is copied and hashed in pieces of this size.
_COPY_CHUNK_BYTES = 1024 * 1024
KIND_FILE = "file"
KIND_WEBLINK = "weblink"
@@ -337,7 +343,7 @@ class DocumentStore:
self,
object_id: str,
*,
content: bytes,
content: bytes | IO[bytes],
filename: str,
mime: str,
title: str | None = None,
@@ -346,12 +352,21 @@ class DocumentStore:
) -> dict[str, Any]:
"""Store an uploaded file (content-addressed + deduped) for an object.
``content`` is the bytes, or a binary file read from where it stands
(the upload view hands over the temporary file aiohttp spooled the
upload into): either way it is copied to disk and hashed in pieces,
so a 100 MB manual never sits in memory whole. Raises
``ValueError("file_too_large")`` past MAX_DOC_BYTES and
``ValueError("too_many_documents")`` at the per-object cap.
Returns the new document dict plus ``deduped`` (the blob already existed)
and ``duplicate_in_object`` (the id of an existing doc on the *same*
object with identical content, if any — the caller surfaces a hint).
"""
if len(content) > MAX_DOC_BYTES:
raise ValueError("file_too_large")
if isinstance(content, bytes):
if len(content) > MAX_DOC_BYTES:
raise ValueError("file_too_large")
content = io.BytesIO(content)
# Per-object document cap — a runaway upload loop must not be able to
# bloat the (single, global) documents store without bound.
@@ -360,7 +375,7 @@ class DocumentStore:
raise ValueError("too_many_documents")
async with self._blob_lock:
digest, wrote_new = await self.hass.async_add_executor_job(self._store_blob_sync, content)
digest, wrote_new, size = await self.hass.async_add_executor_job(self._store_stream_sync, content, MAX_DOC_BYTES)
# Register / adopt the blob and bump its refcount — in the same
# critical section as the write, so a concurrent last-reference
@@ -368,7 +383,7 @@ class DocumentStore:
blob = self.blobs.get(digest)
deduped = blob is not None
if blob is None:
blob = {"size": len(content), "mime": mime, "refcount": 0}
blob = {"size": size, "mime": mime, "refcount": 0}
self.blobs[digest] = blob
blob["refcount"] += 1
@@ -385,7 +400,7 @@ class DocumentStore:
"title": title or filename,
"filename": filename,
"mime": mime,
"size": len(content),
"size": size,
"tags": list(tags or []),
"description": self.clean_description(description),
"task_ids": [],
@@ -413,21 +428,44 @@ class DocumentStore:
def _store_blob_sync(self, content: bytes) -> tuple[str, bool]:
"""Hash content and write the blob if absent. Returns (digest, wrote_new)."""
digest = hashlib.sha256(content).hexdigest()
path = self.blob_path(digest)
if path.exists():
if self.blob_path(digest).exists():
return digest, False
digest, wrote_new, _size = self._store_stream_sync(io.BytesIO(content))
return digest, wrote_new
def _store_stream_sync(self, source: IO[bytes], max_bytes: int | None = None) -> tuple[str, bool, int]:
"""Copy ``source`` into a blob, hashing it on the way, in pieces.
Returns ``(digest, wrote_new, size)``. The copy lands in a temporary
file next to the blobs and is renamed to its digest only when that
blob is absent. Past ``max_bytes`` it stops and raises
``ValueError("file_too_large")``; the temporary file never outlives
the call.
"""
self._blobs_dir.mkdir(parents=True, exist_ok=True)
# A temp name of its own per write: two uploads of the same content
# shared "<digest>.tmp", and the second os.replace found it already
# moved away — a 500 for a perfectly good upload (bug audit
# 2026-09-26). Replacing an identical blob is harmless.
tmp = path.with_name(f"{path.name}.{uuid4().hex}.tmp")
# 2026-09-26). Not a 64-hex name, so the orphan scan never sees it.
tmp = self._blobs_dir / f"upload.{uuid4().hex}.tmp"
hasher = hashlib.sha256()
size = 0
try:
tmp.write_bytes(content)
with tmp.open("wb") as out:
while chunk := source.read(_COPY_CHUNK_BYTES):
size += len(chunk)
if max_bytes is not None and size > max_bytes:
raise ValueError("file_too_large")
hasher.update(chunk)
out.write(chunk)
digest = hasher.hexdigest()
path = self.blob_path(digest)
if path.exists():
return digest, False, size
os.replace(tmp, path) # atomic
return digest, True, size
finally:
tmp.unlink(missing_ok=True)
return digest, True
async def async_add_weblink(
self,
@@ -3,7 +3,7 @@
from __future__ import annotations
import logging
from collections.abc import Mapping
from collections.abc import Iterable, Mapping
from datetime import date, datetime, time, timedelta
from typing import Any
@@ -40,6 +40,7 @@ from ..const import (
MaintenanceStatus,
)
from .aggregate import priority_rank
from .assignees import task_user_ids
from .global_options import get_global_options, global_option
from .i18n import format_text, normalize_language
from .notification_gates import STATUS_ENABLED_KEYS, status_reminder_enabled, task_may_notify
@@ -1337,22 +1338,14 @@ class NotificationManager:
The status-change and lead-reminder paths carried drifting copies of
this block — one logged the per-user fallback, the other was silent.
Without a responsible person, a task shared with people reaches each
of their phones (discussion #110: "shared, no rotation" notified
only the global service, so one partner never heard of it).
"""
target_services: list[str] = []
if responsible_user_id:
user_services = await get_user_notify_services(self.hass, responsible_user_id)
if user_services:
target_services = user_services
_LOGGER.debug(
"Sending notification to user %s services: %s",
responsible_user_id,
user_services,
)
else:
_LOGGER.debug(
"User %s has no notification services, falling back to global",
responsible_user_id,
)
user_ids = [responsible_user_id] if responsible_user_id else task_user_ids(self._task_config(entry_id, task_id))
target_services = await self._user_services(user_ids)
if user_ids:
_LOGGER.debug("Notification for users %s goes to %s", user_ids, target_services or "the global service")
if not target_services and self.notify_service:
target_services = [self.notify_service]
if not target_services:
@@ -1423,6 +1416,16 @@ class NotificationManager:
task: Mapping[str, Any] = (entry.data.get(CONF_TASKS) or {}).get(task_id) or {}
return task
async def _user_services(self, user_ids: Iterable[str]) -> list[str]:
"""Every notify service of these users' phones, each once."""
services: list[str] = []
for user_id in user_ids:
found = await get_user_notify_services(self.hass, user_id)
if not found:
_LOGGER.debug("User %s has no notification services", user_id)
services.extend(s for s in found if s not in services)
return services
def _opt(self, key: str) -> Any:
"""A global setting, or its registry default when unset."""
return global_option(self.hass, key)
@@ -2526,21 +2529,21 @@ class NotificationManager:
self._seeded_entries.discard(entry_id)
self._dirty()
async def async_dismiss_task_notification(self, task_id: str, responsible_user_id: str | None = None) -> None:
async def async_dismiss_task_notification(self, task_id: str, user_ids: Iterable[str] = ()) -> None:
"""Dismiss a task notification on Companion App devices.
``clear_notification`` is a legacy mobile_app service feature; the notify
*entity* model (notify.send_message) has no equivalent, so an entity-only
target can't be dismissed and is simply skipped. Reminders for a task
with a responsible person land on THAT person's devices (see
_resolve_and_send), so those are cleared too (bug audit 2026-09-12).
meant for people (``task_user_ids``: the responsible person, or everyone
it is shared with) land on THEIR devices (see _resolve_and_send), so
those are cleared too (bug audit 2026-09-12).
"""
services = [self.notify_service]
if responsible_user_id:
try:
services.extend(await get_user_notify_services(self.hass, responsible_user_id))
except Exception: # noqa: BLE001 - a lookup failure must not keep the action from finishing
_LOGGER.debug("User notify services unavailable for %s", responsible_user_id, exc_info=True)
try:
services.extend(await self._user_services(user_ids))
except Exception: # noqa: BLE001 - a lookup failure must not keep the action from finishing
_LOGGER.debug("User notify services unavailable for %s", user_ids, exc_info=True)
tag = task_tag(task_id)
for service in dict.fromkeys(s for s in services if s):
domain, _, name = service.partition(".")
@@ -5,6 +5,8 @@ the part model and every rule around it, hass-free so it is trivially
unit-testable:
- normalization/validation of the part dict stored in ``entry.data["parts"]``
- reusable parts (#208): a used one is not gone but waits to be ready again
(charged, sharpened, cleaned, dried, refilled); it still counts as owned
- GTIN validation (GS1 check digit; the GTIN family covers EAN-13/UPC-A/EAN-8)
- edge-triggered stock transitions (``low`` / ``out`` / ``restocked``)
- the **declarative buy-task reconciler**: an auto "Buy {part}" task exists
@@ -46,6 +48,12 @@ MAX_PART_STOCK = 100_000
MAX_CONSUMES_PER_TASK = 10
MAX_CONSUME_QUANTITY = 10_000
# #208: what happens to a used reusable part before it is ready again. A
# part without ``reuse`` is used up as before. Rechargeable cells and
# swappable packs are charged, mower blades sharpened, washable filters
# cleaned, desiccant dried, gas bottles refilled; "restore" covers the rest.
REUSE_KINDS = ("charge", "sharpen", "clean", "dry", "refill", "restore")
# Marker on auto-created buy tasks: {"part_id": "..."} — the reconciler
# exclusively owns tasks carrying it. Detached (popped) from a COMPLETED buy
# task once its part is restocked, so the task survives as a plain done
@@ -255,6 +263,15 @@ def _clean_stock(raw: Any, field: str) -> float | int | None:
return round_qty(v)
def _clean_reuse(raw: Any) -> str | None:
"""One of :data:`REUSE_KINDS`, or None for a part that is used up."""
if raw in (None, "", False):
return None
if raw not in REUSE_KINDS:
raise PartValidationError(f"reuse must be one of {', '.join(REUSE_KINDS)}")
return str(raw)
def normalize_part(raw: Mapping[str, Any]) -> dict[str, Any]:
"""Validate + normalize one part definition (the stored static shape).
@@ -283,6 +300,7 @@ def normalize_part(raw: Mapping[str, Any]) -> dict[str, Any]:
# #98 follow-up: bought in packages, used and stocked in the unit.
"package_size": _clean_package_size(raw.get("package_size")),
"auto_buy_task": bool(raw.get("auto_buy_task")),
"reuse": _clean_reuse(raw.get("reuse")),
# Receipt/datasheet via the refcounted DocumentStore (not inline files).
"doc_id": _clean_str(raw.get("doc_id"), "doc_id", 64) or None,
}
@@ -419,8 +437,21 @@ def map_part_links(
# ── Stock rules ──────────────────────────────────────────────────────────────
def on_hand(part: Mapping[str, Any], stock: float | None, restoring: float | None = None) -> float | None:
"""What the household owns of a part: its stock, and for a reusable part
(#208) also the used ones waiting to be ready again. Charging cells are
not bought again, so buying, the low/out events and the reorder
threshold all read this. None while the stock is not tracked."""
if stock is None:
return None
if part.get("reuse") and restoring:
return round_qty(float(stock) + float(restoring))
return stock
def part_is_low(part: Mapping[str, Any], stock: float | None) -> bool:
"""Tracked stock at/below the reorder threshold."""
"""Tracked stock at/below the reorder threshold (pass :func:`on_hand`
for a reusable part)."""
threshold = part.get("reorder_threshold")
return stock is not None and threshold is not None and stock <= float(threshold)
@@ -24,6 +24,7 @@ from uuid import uuid4
from homeassistant.core import HomeAssistant
from ..const import CONF_SAVED_FILTER_VIEWS, DEFAULT_TASK_PRIORITY, MAX_SAVED_VIEWS, MAX_VIEW_NAME_LENGTH, TaskPriority
from .assignees import task_is_for
from .global_options import get_global_options
# Closed value sets mirrored from the panel's filter controls. Anything outside
@@ -93,7 +94,9 @@ def view_matches_task(filters: Mapping[str, Any], task: Mapping[str, Any]) -> bo
if label and label not in (task.get("labels") or []):
return False
user_id = filters.get("user_id")
if user_id and user_id != "current_user" and task.get("responsible_user_id") != user_id:
# A person's view holds the tasks meant for them: their own, and shared
# ones nobody is responsible for (helpers/assignees, discussion #110).
if user_id and user_id != "current_user" and not task_is_for(task, user_id):
return False
# Tasks without an explicit priority are "normal" everywhere (the model
# default) — the filter must see them the same way.