mirror of
https://github.com/Donkie/Spoolman.git
synced 2026-10-23 02:22:20 -05:00
* Add filtering and sorting for custom fields * Fix tests and make code postgres compatible * Fix bugs and expand test coverage for custom field filter/sort * trigger CI * fix: address extra field filter review feedback * fix: clean up custom field table state handling * docs: update extra field table view description * fix: narrow boolean custom field filter syntax * Expand test coverage for all custom field types/entities and fix range filter/sort - Add comprehensive tests covering all 9 field types (text, integer, float, boolean, single-choice, multi-choice, datetime, integer_range, float_range) for filter and sort on spool, filament, and vendor entities - Add invalid-filter 400 tests for float, integer_range, and float_range - Fix integer_range/float_range filter to use LIKE pattern matching against Python's deterministic json.dumps output instead of fragile .contains() - Add integer_range/float_range sort support via a @compiles helper (_JsonArrayFirstElement) that emits CAST(col AS JSON)->>0 on PostgreSQL and JSON_EXTRACT(col, '$[0]') on SQLite/MariaDB - Add logger and __all__ to extra_fields.py (aligns with PR #893) - All tests verified passing on postgres, sqlite, and mariadb * Add filter UI for all custom field types and fix range filters - Add text input filter dropdown for text fields (substring search) - Add range input filter dropdowns (min/max) for integer, float, integer_range, and float_range fields - Add datetime picker filter dropdown for datetime fields - Fix boolean "No" filter to use empty value so it correctly matches unset/false fields - Fix integer_range/float_range filter to use numeric comparisons instead of LIKE-based exact matching (stored_min >= filter_min, stored_max <= filter_max) - Add range filter support for integer/float fields (min:max format) - Add _JsonArraySecondElement cross-database helper for extracting second JSON array element * Range filter for integer/float fields and fix range_field filter semantics Backend: - Add _JsonArraySecondElement cross-database helper (mirrors _JsonArrayFirstElement) - integer_range/float_range filter now uses numeric comparisons: stored_min >= filter_min and stored_max <= filter_max (was LIKE exact match) - integer/float filter now supports min:max range format in addition to exact match: stored_value >= min and/or stored_value <= max Frontend: - integer and float fields now use a range filter dropdown (min/max inputs) instead of a single exact-value input Tests: - Fix integer_range/float_range spool and vendor tests broken by new >= semantics (filter values updated to discriminate between test entries) - Add range filter tests (min only, max only, both) for integer and float fields * Replace 12 copy-pasted numeric field tests with 4 parametrized tests Introduce a _create_entity helper and @pytest.mark.parametrize("entity_type", ["spool", "filament", "vendor"]) to run each numeric field type test against all three entity types from a single test function. - Removes 12 individual tests (integer/float/integer_range/float_range × 3 entities) - Adds 4 parametrized tests covering the same ground plus the previously missing min-only / max-only range filter cases for filament and vendor - Uses try/finally for cleanup so entities are always deleted even on assertion failure * Add datetime range filter and restore integer/float range filter support - Backend: add datetime range filter using '|' separator (ISO dates contain ':') - Backend: restore integer/float range filter support (lost during attribution rewrite) - Frontend: replace single DateTimePicker with From/To range pickers for datetime fields * Use local timezone in filter picker; add range filter tests The DateTimeRangeFilterDropdown was initializing pickers with dayjs.utc(), causing them to display and accept UTC times directly. The entry form shows local time and converts to UTC, so the filter was inconsistent: entering the same displayed time would produce a filter value 2h offset from what was stored (in UTC+2), making exact boundaries fail unexpectedly. Fix: initialize picker values with dayjs() (local mode) so the filter picker behaves the same as the entry form — shows local times and converts to UTC. Tests: replace 3 separate entity-specific datetime tests with one parametrized test covering spool/filament/vendor, and add range filter cases (start|, |end, start|end). * Replace all entity-specific tests with parametrized tests Replace 16 separate spool/filament/vendor tests for text, boolean, single-choice, multi-choice, and empty-filter with 5 parametrized tests that run against all three entity types. Each test is now defined once and executed 3 times, eliminating copy-paste and ensuring consistent coverage across all entity types. Parametrize invalid-filter-value 400 tests across all entity types The non-numeric-value error uses "Invalid integer/float range filter value" while the missing-colon error uses "Invalid range filter value". Both contain "range filter value", so use that as the assertion substring. * Align custom filter dropdowns with Ant Design choice filter style All three custom filter dropdowns (text, number range, datetime range) now match the built-in choice filter footer: a border-top separator, Reset as a link button on the left (disabled when no filter is active), and OK as a primary button on the right. * fix(fields): correct custom-field filter edge cases across databases - Float exact-match filters now compare numerically (cast to Float) instead of by JSON string, so float fields stored as integer JSON (e.g. "2") or non-canonical decimals (e.g. "2.50") match an equivalent filter. - integer_range/float_range filter and sort no longer return HTTP 500 on CockroachDB: the PostgreSQL '->>' JSON extraction is now also registered for the cockroachdb dialect, which previously fell back to the unsupported JSON_EXTRACT function. - Text exact-match and single-choice equality use ensure_ascii=False so non-ASCII values match how the frontend's JSON.stringify stores them. Co-authored-by: Dieter Blomme <dieterblomme@gmail.com> * test(fields): expand custom-field filter/sort coverage Add tests covering filter composition (multiple extra fields, extra + built-in, filter one field / sort another), pagination total-count, unknown-field handling, text case-insensitivity/substring/non-ASCII, numeric multi-value OR, boolean empty semantics and token rejection, multi-choice substring collisions, empty filters on numeric fields, null-bounded ranges, and additional invalid-value 400 paths. Verified against sqlite, postgres, mariadb and cockroachdb. Co-authored-by: Dieter Blomme <dieterblomme@gmail.com> * style(client): format column.tsx with prettier The custom-field filter dropdowns' Reset buttons were written as one-line onClick handlers that prettier wraps across multiple lines. Apply prettier so the CI style check passes. Co-authored-by: Dieter Blomme <dieterblomme@gmail.com> * fix(fields): decode JSON scalars in-DB for text/choice/datetime custom-field filters Match text, single-choice and datetime custom-field filters against the database-decoded JSON scalar (new _JsonScalarText cross-dialect helper: #>> '{}' on postgres/cockroachdb, JSON_UNQUOTE(JSON_EXTRACT) on mysql, json_extract on sqlite) instead of reconstructing the exact JSON serialization the client wrote. This decouples matching from json.dumps/JSON.stringify encoding quirks (non-ASCII escaping, surrounding quotes). Also escape LIKE wildcards so '%' and '_' in text and multi-choice queries match literally rather than acting as wildcards. Verified against sqlite, postgres, mariadb and cockroachdb. Co-authored-by: Dieter Blomme <dieterblomme@gmail.com> * test(fields): cover LIKE-wildcard escaping in custom-field filters Add parametrized tests (spool/filament/vendor) asserting that '%' and '_' are matched literally in text and multi-choice filters, that a literal '/' (the internal LIKE escape char) matches literally, and that single-choice equality handles a '%' value exactly. Co-authored-by: Dieter Blomme <dieterblomme@gmail.com> --------- Co-authored-by: Donkie <daniel.cf.hultgren@gmail.com> Co-authored-by: akira69 <akira69@gmail.com>
499 lines
17 KiB
Python
499 lines
17 KiB
Python
"""Helper functions for interacting with spool database objects."""
|
|
|
|
import logging
|
|
from collections.abc import Sequence
|
|
from datetime import datetime, timezone
|
|
|
|
import sqlalchemy
|
|
from sqlalchemy import case, func
|
|
from sqlalchemy.exc import NoResultFound
|
|
from sqlalchemy.ext.asyncio import AsyncSession
|
|
from sqlalchemy.orm import contains_eager, joinedload
|
|
from sqlalchemy.sql.functions import coalesce
|
|
|
|
from spoolman.api.v1.models import EventType, Spool, SpoolEvent
|
|
from spoolman.database import filament, models
|
|
from spoolman.database.extra_field_query import apply_extra_field_filters_and_sort
|
|
from spoolman.database.utils import (
|
|
SortOrder,
|
|
add_where_clause_int,
|
|
add_where_clause_int_opt,
|
|
add_where_clause_str,
|
|
add_where_clause_str_opt,
|
|
parse_nested_field,
|
|
)
|
|
from spoolman.exceptions import ItemCreateError, ItemNotFoundError, SpoolMeasureError
|
|
from spoolman.extra_field_registry import EntityType
|
|
from spoolman.math import weight_from_length
|
|
from spoolman.ws import websocket_manager
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
|
|
def utc_timezone_naive(dt: datetime) -> datetime:
|
|
"""Convert a datetime object to UTC and remove timezone info."""
|
|
return dt.astimezone(tz=timezone.utc).replace(tzinfo=None)
|
|
|
|
|
|
async def create(
|
|
*,
|
|
db: AsyncSession,
|
|
filament_id: int,
|
|
remaining_weight: float | None = None,
|
|
initial_weight: float | None = None,
|
|
spool_weight: float | None = None,
|
|
used_weight: float | None = None,
|
|
first_used: datetime | None = None,
|
|
last_used: datetime | None = None,
|
|
price: float | None = None,
|
|
location: str | None = None,
|
|
lot_nr: str | None = None,
|
|
comment: str | None = None,
|
|
archived: bool = False,
|
|
extra: dict[str, str] | None = None,
|
|
) -> models.Spool:
|
|
"""Add a new spool to the database. Leave weight empty to assume full spool."""
|
|
filament_item = await filament.get_by_id(db, filament_id)
|
|
|
|
# Set spool_weight to spool_weight if spool_weight is not null and spool_weight not provided
|
|
if spool_weight is None and filament_item.spool_weight is not None:
|
|
spool_weight = filament_item.spool_weight
|
|
|
|
# Calculate initial_weight if not provided
|
|
if initial_weight is None and filament_item.weight is not None:
|
|
initial_weight = filament_item.weight
|
|
|
|
if used_weight is None:
|
|
if remaining_weight is not None:
|
|
if initial_weight is None or initial_weight == 0:
|
|
raise ItemCreateError(
|
|
"remaining_weight can only be used if the initial_weight is "
|
|
"defined or the filament has a weight set.",
|
|
)
|
|
used_weight = max(initial_weight - remaining_weight, 0)
|
|
else:
|
|
used_weight = 0
|
|
|
|
# Convert datetime values to UTC and remove timezone info
|
|
if first_used is not None:
|
|
first_used = utc_timezone_naive(first_used)
|
|
if last_used is not None:
|
|
last_used = utc_timezone_naive(last_used)
|
|
|
|
spool = models.Spool(
|
|
filament=filament_item,
|
|
registered=datetime.utcnow().replace(microsecond=0),
|
|
initial_weight=initial_weight,
|
|
spool_weight=spool_weight,
|
|
used_weight=used_weight,
|
|
price=price,
|
|
first_used=first_used,
|
|
last_used=last_used,
|
|
location=location,
|
|
lot_nr=lot_nr,
|
|
comment=comment,
|
|
archived=archived,
|
|
extra=[models.SpoolField(key=k, value=v) for k, v in (extra or {}).items()],
|
|
)
|
|
db.add(spool)
|
|
await db.commit()
|
|
await spool_changed(spool, EventType.ADDED)
|
|
return spool
|
|
|
|
|
|
async def get_by_id(db: AsyncSession, spool_id: int) -> models.Spool:
|
|
"""Get a spool object from the database by the unique ID."""
|
|
spool = await db.get(
|
|
models.Spool,
|
|
spool_id,
|
|
options=[joinedload("*")], # Load all nested objects as well
|
|
)
|
|
if spool is None:
|
|
raise ItemNotFoundError(f"No spool with ID {spool_id} found.")
|
|
return spool
|
|
|
|
|
|
async def find( # noqa: C901, PLR0912
|
|
*,
|
|
db: AsyncSession,
|
|
filament_name: str | None = None,
|
|
filament_id: int | Sequence[int] | None = None,
|
|
filament_material: str | None = None,
|
|
vendor_name: str | None = None,
|
|
vendor_id: int | Sequence[int] | None = None,
|
|
location: str | None = None,
|
|
lot_nr: str | None = None,
|
|
allow_archived: bool = False,
|
|
extra_field_filters: dict[str, str] | None = None,
|
|
sort_by: dict[str, SortOrder] | None = None,
|
|
limit: int | None = None,
|
|
offset: int = 0,
|
|
) -> tuple[list[models.Spool], int]:
|
|
"""Find a list of spool objects by search criteria.
|
|
|
|
Sort by a field by passing a dict with the field name as key and the sort order as value.
|
|
The field name can contain nested fields, e.g. filament.name.
|
|
|
|
Returns a tuple containing the list of items and the total count of matching items.
|
|
"""
|
|
stmt = (
|
|
sqlalchemy.select(models.Spool)
|
|
.join(models.Spool.filament, isouter=True)
|
|
.join(models.Filament.vendor, isouter=True)
|
|
.options(contains_eager(models.Spool.filament).contains_eager(models.Filament.vendor))
|
|
)
|
|
|
|
stmt = add_where_clause_int(stmt, models.Spool.filament_id, filament_id)
|
|
stmt = add_where_clause_int_opt(stmt, models.Filament.vendor_id, vendor_id)
|
|
stmt = add_where_clause_str(stmt, models.Vendor.name, vendor_name)
|
|
stmt = add_where_clause_str_opt(stmt, models.Filament.name, filament_name)
|
|
stmt = add_where_clause_str_opt(stmt, models.Filament.material, filament_material)
|
|
stmt = add_where_clause_str_opt(stmt, models.Spool.location, location)
|
|
stmt = add_where_clause_str_opt(stmt, models.Spool.lot_nr, lot_nr)
|
|
|
|
if not allow_archived:
|
|
# Since the archived field is nullable, and default is false, we need to check for both false or null
|
|
stmt = stmt.where(
|
|
sqlalchemy.or_(
|
|
models.Spool.archived.is_(False),
|
|
models.Spool.archived.is_(None),
|
|
),
|
|
)
|
|
|
|
total_count = None
|
|
|
|
stmt = await apply_extra_field_filters_and_sort(
|
|
db=db,
|
|
stmt=stmt,
|
|
base_obj=models.Spool,
|
|
entity_type=EntityType.spool,
|
|
extra_field_filters=extra_field_filters,
|
|
sort_by=sort_by,
|
|
)
|
|
|
|
if sort_by is not None:
|
|
for fieldstr, order in sort_by.items():
|
|
# Check if this is a custom field sort
|
|
if fieldstr.startswith("extra."):
|
|
continue
|
|
|
|
sorts = []
|
|
if fieldstr == "remaining_weight":
|
|
sorts.append(
|
|
coalesce(models.Spool.initial_weight, models.Filament.weight) - models.Spool.used_weight,
|
|
)
|
|
elif fieldstr == "remaining_length":
|
|
# Simplified weight -> length formula. Absolute value is not correct but the proportionality
|
|
# is still kept, which means the sort order is correct.
|
|
sorts.append(
|
|
(coalesce(models.Spool.initial_weight, models.Filament.weight) - models.Spool.used_weight)
|
|
/ models.Filament.density
|
|
/ (models.Filament.diameter * models.Filament.diameter),
|
|
)
|
|
elif fieldstr == "used_length":
|
|
sorts.append(
|
|
models.Spool.used_weight
|
|
/ models.Filament.density
|
|
/ (models.Filament.diameter * models.Filament.diameter),
|
|
)
|
|
elif fieldstr == "filament.combined_name":
|
|
sorts.append(models.Vendor.name)
|
|
sorts.append(models.Filament.name)
|
|
elif fieldstr == "price":
|
|
sorts.append(coalesce(models.Spool.price, models.Filament.price))
|
|
else:
|
|
sorts.append(parse_nested_field(models.Spool, fieldstr))
|
|
|
|
if order == SortOrder.ASC:
|
|
stmt = stmt.order_by(*(f.asc() for f in sorts))
|
|
elif order == SortOrder.DESC:
|
|
stmt = stmt.order_by(*(f.desc() for f in sorts))
|
|
|
|
if limit is not None:
|
|
total_count_stmt = stmt.with_only_columns(func.count(), maintain_column_froms=True).order_by(None)
|
|
total_count = (await db.execute(total_count_stmt)).scalar()
|
|
stmt = stmt.offset(offset).limit(limit)
|
|
|
|
rows = await db.execute(
|
|
stmt,
|
|
execution_options={"populate_existing": True},
|
|
)
|
|
result = list(rows.unique().scalars().all())
|
|
if total_count is None:
|
|
total_count = len(result)
|
|
|
|
return result, total_count
|
|
|
|
|
|
async def update(
|
|
*,
|
|
db: AsyncSession,
|
|
spool_id: int,
|
|
data: dict,
|
|
) -> models.Spool:
|
|
"""Update the fields of a spool object."""
|
|
spool = await get_by_id(db, spool_id)
|
|
for k, v in data.items():
|
|
if k == "filament_id":
|
|
spool.filament = await filament.get_by_id(db, v)
|
|
# If there is no initial_weight, calculate it from the filament weight
|
|
if spool.initial_weight is None and spool.filament.weight is not None:
|
|
spool.initial_weight = spool.filament.weight
|
|
|
|
elif k == "remaining_weight":
|
|
if spool.initial_weight is None:
|
|
raise ItemCreateError("remaining_weight can only be used if initial_weight is set.")
|
|
spool.used_weight = max(spool.initial_weight - v, 0)
|
|
elif isinstance(v, datetime):
|
|
setattr(spool, k, utc_timezone_naive(v))
|
|
elif k == "extra":
|
|
spool.extra = [f for f in spool.extra if f.key not in v]
|
|
spool.extra.extend([models.SpoolField(key=k, value=v) for k, v in v.items()])
|
|
else:
|
|
setattr(spool, k, v)
|
|
await db.commit()
|
|
await spool_changed(spool, EventType.UPDATED)
|
|
return spool
|
|
|
|
|
|
async def delete(db: AsyncSession, spool_id: int) -> None:
|
|
"""Delete a spool object."""
|
|
spool = await get_by_id(db, spool_id)
|
|
await db.delete(spool)
|
|
# Commit before notifying so the deletion is durable and visible to subsequent
|
|
# requests; post-commit notification must be the last, infallible step.
|
|
await db.commit()
|
|
await spool_changed(spool, EventType.DELETED)
|
|
|
|
|
|
async def clear_extra_field(db: AsyncSession, key: str) -> None:
|
|
"""Delete all extra fields with a specific key."""
|
|
await db.execute(
|
|
sqlalchemy.delete(models.SpoolField).where(models.SpoolField.key == key),
|
|
)
|
|
await db.commit()
|
|
|
|
|
|
async def use_weight_safe(db: AsyncSession, spool_id: int, weight: float) -> None:
|
|
"""Consume filament from a spool by weight in a way that is safe against race conditions.
|
|
|
|
Args:
|
|
db (AsyncSession): Database session
|
|
spool_id (int): Spool ID
|
|
weight (float): Filament weight to consume, in grams
|
|
|
|
"""
|
|
await db.execute(
|
|
sqlalchemy.update(models.Spool)
|
|
.where(models.Spool.id == spool_id)
|
|
.values(
|
|
used_weight=case(
|
|
(models.Spool.used_weight + weight >= 0.0, models.Spool.used_weight + weight),
|
|
else_=0.0, # Set used_weight to 0 if the result would be negative
|
|
),
|
|
),
|
|
)
|
|
|
|
|
|
async def use_weight(db: AsyncSession, spool_id: int, weight: float) -> models.Spool:
|
|
"""Consume filament from a spool by weight.
|
|
|
|
Increases the used_weight attribute of the spool.
|
|
Updates the first_used and last_used attributes where appropriate.
|
|
|
|
Args:
|
|
db (AsyncSession): Database session
|
|
spool_id (int): Spool ID
|
|
weight (float): Filament weight to consume, in grams
|
|
|
|
Returns:
|
|
models.Spool: Updated spool object
|
|
|
|
"""
|
|
await use_weight_safe(db, spool_id, weight)
|
|
|
|
spool = await get_by_id(db, spool_id)
|
|
|
|
if spool.first_used is None:
|
|
spool.first_used = datetime.utcnow().replace(microsecond=0)
|
|
spool.last_used = datetime.utcnow().replace(microsecond=0)
|
|
|
|
await db.commit()
|
|
await spool_changed(spool, EventType.UPDATED)
|
|
return spool
|
|
|
|
|
|
async def use_length(db: AsyncSession, spool_id: int, length: float) -> models.Spool:
|
|
"""Consume filament from a spool by length.
|
|
|
|
Increases the used_weight attribute of the spool.
|
|
Updates the first_used and last_used attributes where appropriate.
|
|
|
|
Args:
|
|
db (AsyncSession): Database session
|
|
spool_id (int): Spool ID
|
|
length (float): Length of filament to consume, in mm
|
|
|
|
Returns:
|
|
models.Spool: Updated spool object
|
|
|
|
"""
|
|
# Get filament diameter and density
|
|
result = await db.execute(
|
|
sqlalchemy.select(models.Filament.diameter, models.Filament.density)
|
|
.join(models.Spool, models.Spool.filament_id == models.Filament.id)
|
|
.where(models.Spool.id == spool_id),
|
|
)
|
|
try:
|
|
filament_info = result.one()
|
|
except NoResultFound as exc:
|
|
raise ItemNotFoundError("Filament not found for spool.") from exc
|
|
|
|
# Calculate and use weight
|
|
weight = weight_from_length(
|
|
length=length,
|
|
diameter=filament_info[0],
|
|
density=filament_info[1],
|
|
)
|
|
await use_weight_safe(db, spool_id, weight)
|
|
|
|
# Get spool with new weight and update first_used and last_used
|
|
spool = await get_by_id(db, spool_id)
|
|
|
|
if spool.first_used is None:
|
|
spool.first_used = datetime.utcnow().replace(microsecond=0)
|
|
spool.last_used = datetime.utcnow().replace(microsecond=0)
|
|
|
|
await db.commit()
|
|
await spool_changed(spool, EventType.UPDATED)
|
|
return spool
|
|
|
|
|
|
async def measure(db: AsyncSession, spool_id: int, weight: float) -> models.Spool:
|
|
"""Record usage based on current gross weight of spool.
|
|
|
|
Increases the used_weight attribute of the spool.
|
|
Updates the first_used and last_used attributes where appropriate.
|
|
|
|
Args:
|
|
db (AsyncSession): Database session
|
|
spool_id (int): Spool ID
|
|
weight (float): Length of filament to consume, in mm
|
|
|
|
Returns:
|
|
models.Spool: Updated spool object
|
|
|
|
"""
|
|
spool_result = await db.execute(
|
|
sqlalchemy.select(models.Spool.initial_weight, models.Spool.used_weight, models.Spool.spool_weight).where(
|
|
models.Spool.id == spool_id,
|
|
),
|
|
)
|
|
|
|
try:
|
|
spool_info = spool_result.one()
|
|
except NoResultFound as exc:
|
|
raise SpoolMeasureError("Spool not found.") from exc
|
|
|
|
initial_weight = spool_info[0]
|
|
spool_weight = spool_info[2]
|
|
if initial_weight is None or initial_weight == 0 or spool_weight is None or spool_weight == 0:
|
|
# Get filament weight and spool_weight
|
|
result = await db.execute(
|
|
sqlalchemy.select(models.Filament.weight, models.Filament.spool_weight)
|
|
.join(models.Spool, models.Spool.filament_id == models.Filament.id)
|
|
.where(models.Spool.id == spool_id),
|
|
)
|
|
try:
|
|
filament_info = result.one()
|
|
except NoResultFound as exc:
|
|
raise ItemNotFoundError("Filament not found for spool.") from exc
|
|
|
|
if spool_weight is None or spool_weight == 0:
|
|
spool_weight = filament_info[1]
|
|
|
|
if initial_weight is None or initial_weight == 0:
|
|
initial_weight = filament_info[0] if filament_info[0] is not None else 0
|
|
|
|
if initial_weight is None or initial_weight == 0:
|
|
raise SpoolMeasureError("Initial weight is not set.")
|
|
|
|
initial_gross_weight = initial_weight + spool_weight
|
|
|
|
# if the measurement is greater than the initial weight, set the initial weight to the measurement
|
|
if weight > initial_gross_weight:
|
|
return await reset_initial_weight(db, spool_id, weight - spool_weight)
|
|
|
|
# Calculate the current net weight
|
|
current_use = initial_gross_weight - spool_info[1]
|
|
|
|
# Calculate the weight used since last measure
|
|
weight_to_use = current_use - weight
|
|
|
|
# If the measured weight is less than the empty weight, use the rest of the spool
|
|
if (initial_gross_weight - weight_to_use) < spool_weight:
|
|
weight_to_use = current_use - spool_weight
|
|
|
|
return await use_weight(db, spool_id, weight_to_use)
|
|
|
|
|
|
async def find_locations(
|
|
*,
|
|
db: AsyncSession,
|
|
) -> list[str]:
|
|
"""Find a list of spool locations by searching for distinct values in the spool table."""
|
|
stmt = sqlalchemy.select(models.Spool.location).distinct()
|
|
rows = await db.execute(stmt)
|
|
return [row[0] for row in rows.all() if row[0] is not None]
|
|
|
|
|
|
async def find_lot_numbers(
|
|
*,
|
|
db: AsyncSession,
|
|
) -> list[str]:
|
|
"""Find a list of spool lot numbers by searching for distinct values in the spool table."""
|
|
stmt = sqlalchemy.select(models.Spool.lot_nr).distinct()
|
|
rows = await db.execute(stmt)
|
|
return [row[0] for row in rows.all() if row[0] is not None]
|
|
|
|
|
|
async def spool_changed(spool: models.Spool, typ: EventType) -> None:
|
|
"""Notify websocket clients that a spool has changed."""
|
|
try:
|
|
await websocket_manager.send(
|
|
("spool", str(spool.id)),
|
|
SpoolEvent(
|
|
type=typ,
|
|
resource="spool",
|
|
date=datetime.utcnow(),
|
|
payload=Spool.from_db(spool),
|
|
),
|
|
)
|
|
except Exception:
|
|
# Important to have a catch-all here since we don't want to stop the call if this fails.
|
|
logger.exception("Failed to send websocket message")
|
|
|
|
|
|
async def reset_initial_weight(db: AsyncSession, spool_id: int, weight: float) -> models.Spool:
|
|
"""Reset inital weight to new weight and used_weight to 0."""
|
|
spool = await get_by_id(db, spool_id)
|
|
|
|
spool.initial_weight = weight
|
|
spool.used_weight = 0
|
|
await db.commit()
|
|
await spool_changed(spool, EventType.UPDATED)
|
|
return spool
|
|
|
|
|
|
async def rename_location(
|
|
*,
|
|
db: AsyncSession,
|
|
current_name: str,
|
|
new_name: str,
|
|
) -> None:
|
|
"""Rename all spools with the current location name to the new name."""
|
|
await db.execute(
|
|
sqlalchemy.update(models.Spool).where(models.Spool.location == current_name).values(location=new_name),
|
|
)
|
|
await db.commit()
|