diff --git a/crud.py b/crud.py index 2d6af4f..a8cdc96 100644 --- a/crud.py +++ b/crud.py @@ -1,17 +1,12 @@ from lnbits.db import Database from lnbits.helpers import urlsafe_short_hash +from .helpers import _serialize_inventory_tags from .models import CreateTposData, LnurlCharge, Tpos, TposClean db = Database("ext_tpos") -def _serialize_inventory_tags(tags: list[str] | str | None) -> str | None: - if isinstance(tags, list): - return ",".join([tag for tag in tags if tag]) - return tags - - async def create_tpos(data: CreateTposData) -> Tpos: tpos_id = urlsafe_short_hash() data_dict = data.dict() diff --git a/helpers.py b/helpers.py new file mode 100644 index 0000000..468d553 --- /dev/null +++ b/helpers.py @@ -0,0 +1,66 @@ +import json + +from loguru import logger + + +def _from_csv(value: str | None, separator: str = ",") -> list[str]: + if not value: + return [] + parts = [part.strip() for part in value.split(separator)] + return [part for part in parts if part] + + +def _serialize_inventory_tags(tags: list[str] | str | None) -> str | None: + if isinstance(tags, list): + return ",".join([tag for tag in tags if tag]) + return tags + + +def _inventory_tags_to_list(raw_tags: str | list[str] | None) -> list[str]: + if raw_tags is None: + return [] + if isinstance(raw_tags, list): + return [tag.strip() for tag in raw_tags if tag and tag.strip()] + return [tag.strip() for tag in raw_tags.split(",") if tag and tag.strip()] + + +def _inventory_tags_to_string(raw_tags: str | list[str] | None) -> str | None: + if raw_tags is None: + return None + if isinstance(raw_tags, str): + return raw_tags + return ",".join([tag for tag in raw_tags if tag]) + + +def _first_image(images: str | list[str] | None) -> str | None: + if not images: + return None + if isinstance(images, list): + return _normalize_image(images[0]) if images else None + raw = str(images).strip() + if not raw: + return None + try: + parsed = json.loads(raw) + if isinstance(parsed, list) and parsed: + return _normalize_image(parsed[0]) + except Exception as exc: + logger.exception(f"Exception occurred while parsing image JSON: {exc}") + + if "|||" in raw: + return _normalize_image(raw.split("|||")[0]) + + if "," in raw: + return _normalize_image(raw.split(",")[0]) + return _normalize_image(raw) + + +def _normalize_image(val: str | None) -> str | None: + if not val: + return None + val = str(val).strip() + if not val: + return None + if val.startswith("http") or val.startswith("/api/") or val.startswith("data:"): + return val + return f"/api/v1/assets/{val}/binary" diff --git a/services.py b/services.py new file mode 100644 index 0000000..21cbb20 --- /dev/null +++ b/services.py @@ -0,0 +1,120 @@ +from typing import Any + +import httpx +from lnbits.core.crud import get_wallet +from lnbits.core.models import User +from lnbits.helpers import create_access_token +from lnbits.settings import settings + +from .helpers import _from_csv, _inventory_tags_to_list + + +async def _deduct_inventory_stock(wallet_id: str, inventory_payload: dict) -> None: + wallet = await get_wallet(wallet_id) + if not wallet: + return + inventory_id = inventory_payload.get("inventory_id") + items = inventory_payload.get("items") or [] + if not inventory_id or not items: + return + items_to_update = [] + for item in items: + item_id = item.get("id") + qty = item.get("quantity") or 0 + if not item_id or qty <= 0: + continue + items_to_update.append({"id": item_id, "quantity": int(qty)}) + if not items_to_update: + return + + ids = [item["id"] for item in items_to_update] + quantities = [item["quantity"] for item in items_to_update] + + # Needed to accomodate admin users, as using user ID is not possible + access = create_access_token( + {"sub": "", "usr": wallet.user}, token_expire_minutes=1 + ) + async with httpx.AsyncClient() as client: + await client.patch( + url=f"http://{settings.host}:{settings.port}/inventory/api/v1/items/{inventory_id}/quantities", + headers={"Authorization": f"Bearer {access}"}, + params={"source": "tpos", "ids": ids, "quantities": quantities}, + ) + return + + +async def _get_default_inventory(user_id: str) -> dict[str, Any] | None: + access = create_access_token({"sub": "", "usr": user_id}, token_expire_minutes=1) + async with httpx.AsyncClient() as client: + resp = await client.get( + url=f"http://{settings.host}:{settings.port}/inventory/api/v1", + headers={"Authorization": f"Bearer {access}"}, + ) + inventory = resp.json() + if not inventory: + return None + if isinstance(inventory, list): + inventory = inventory[0] if inventory else None + if not isinstance(inventory, dict): + return None + inventory["tags"] = _inventory_tags_to_list(inventory.get("tags")) + inventory["omit_tags"] = _inventory_tags_to_list(inventory.get("omit_tags")) + return inventory + + +async def _get_inventory_items_for_tpos( + user_id: str, + inventory_id: str, + tags: str | list[str] | None, + omit_tags: str | list[str] | None, +) -> list[Any]: + tag_list = _inventory_tags_to_list(tags) + omit_list = [tag.lower() for tag in _inventory_tags_to_list(omit_tags)] + allowed_tags = [tag.lower() for tag in tag_list] + access = create_access_token({"sub": "", "usr": user_id}, token_expire_minutes=1) + async with httpx.AsyncClient() as client: + resp = await client.get( + url=f"http://{settings.host}:{settings.port}/inventory/api/v1/items/{inventory_id}/paginated", + headers={"Authorization": f"Bearer {access}"}, + params={"limit": 500, "offset": 0, "is_active": True}, + ) + payload = resp.json() + items = payload.get("data", []) if isinstance(payload, dict) else payload + + # item images are a comma separated string; make a list + for item in items: + images = item.get("images") + item["images"] = _from_csv(images) + + def has_allowed_tag(item_tags: str | list[str] | None) -> bool: + # When no tags are configured for this TPoS, show no items + if not tag_list: + return False + item_tag_list = [tag.lower() for tag in _inventory_tags_to_list(item_tags)] + return any(tag in item_tag_list for tag in allowed_tags) + + def has_omit_tag(item_omit_tags: str | list[str] | None) -> bool: + if not omit_list: + return False + item_tag_list = [tag.lower() for tag in _inventory_tags_to_list(item_omit_tags)] + return any(tag in item_tag_list for tag in omit_list) + + filtered = [ + item + for item in items + if has_allowed_tag(item.get("tags")) and not has_omit_tag(item.get("omit_tags")) + ] + # If no items matched the provided tags, fall back to all items minus omitted ones. + if tag_list and not filtered: + filtered = [item for item in items if not has_omit_tag(item.get("omit_tags"))] + + # hide items with no stock when stock tracking is enabled + return [ + item + for item in filtered + if item.get("quantity_in_stock") is None or item.get("quantity_in_stock") > 0 + ] + + +def _inventory_available_for_user(user: User | None) -> bool: + return bool(user and "inventory" in (user.extensions or [])) diff --git a/tasks.py b/tasks.py index 3f81a29..00688ef 100644 --- a/tasks.py +++ b/tasks.py @@ -1,7 +1,5 @@ import asyncio -import httpx -from lnbits.core.crud import get_wallet from lnbits.core.models import Payment from lnbits.core.services import ( create_invoice, @@ -9,44 +7,11 @@ pay_invoice, websocket_updater, ) -from lnbits.helpers import create_access_token -from lnbits.settings import settings from lnbits.tasks import register_invoice_listener from loguru import logger from .crud import get_tpos - - -async def _deduct_inventory_stock(wallet_id: str, inventory_payload: dict) -> None: - wallet = await get_wallet(wallet_id) - if not wallet: - return - inventory_id = inventory_payload.get("inventory_id") - items = inventory_payload.get("items") or [] - if not inventory_id or not items: - return - ids: list[str] = [] - quantities: list[int] = [] - for item in items: - item_id = item.get("id") - qty = item.get("quantity") or 0 - if not item_id or qty <= 0: - continue - ids.append(item_id) - quantities.append(int(qty)) - if not ids: - return - # Needed to accomodate admin users, as using user ID is not possible - access = create_access_token( - {"sub": "", "usr": wallet.user}, token_expire_minutes=1 - ) - async with httpx.AsyncClient() as client: - await client.patch( - url=f"http://{settings.host}:{settings.port}/inventory/api/v1/items/{inventory_id}/quantities", - headers={"Authorization": f"Bearer {access}"}, - params={"source": "tpos", "ids": ids, "quantities": quantities}, - ) - return +from .services import _deduct_inventory_stock async def wait_for_paid_invoices(): diff --git a/views_api.py b/views_api.py index 0db1663..c089524 100644 --- a/views_api.py +++ b/views_api.py @@ -10,14 +10,12 @@ get_user, get_wallet, ) -from lnbits.core.models import CreateInvoice, Payment, User, WalletTypeInfo +from lnbits.core.models import CreateInvoice, Payment, WalletTypeInfo from lnbits.core.services import create_payment_request, websocket_updater from lnbits.decorators import ( require_admin_key, require_invoice_key, ) -from lnbits.helpers import create_access_token -from lnbits.settings import settings from lnurl import LnurlPayResponse from lnurl import decode as decode_lnurl from lnurl import handle as lnurl_handle @@ -29,6 +27,11 @@ get_tposs, update_tpos, ) +from .helpers import ( + _first_image, + _inventory_tags_to_list, + _inventory_tags_to_string, +) from .models import ( CreateTposData, CreateTposInvoice, @@ -38,131 +41,15 @@ TapToPay, Tpos, ) +from .services import ( + _get_default_inventory, + _get_inventory_items_for_tpos, + _inventory_available_for_user, +) tpos_api_router = APIRouter() -def _inventory_tags_to_list(raw_tags: str | list[str] | None) -> list[str]: - if raw_tags is None: - return [] - if isinstance(raw_tags, list): - return [tag.strip() for tag in raw_tags if tag and tag.strip()] - return [tag.strip() for tag in raw_tags.split(",") if tag and tag.strip()] - - -def _inventory_tags_to_string(raw_tags: str | list[str] | None) -> str | None: - if raw_tags is None: - return None - if isinstance(raw_tags, str): - return raw_tags - return ",".join([tag for tag in raw_tags if tag]) - - -def _first_image(images: str | list[str] | None) -> str | None: - if not images: - return None - if isinstance(images, list): - return _normalize_image(images[0]) if images else None - raw = str(images).strip() - if not raw: - return None - try: - parsed = json.loads(raw) - if isinstance(parsed, list) and parsed: - return _normalize_image(parsed[0]) - except Exception: - pass - if "|||" in raw: - return _normalize_image(raw.split("|||")[0]) - if "," in raw: - return _normalize_image(raw.split(",")[0]) - return _normalize_image(raw) - - -def _normalize_image(val: str | None) -> str | None: - if not val: - return None - val = str(val).strip() - if not val: - return None - if val.startswith("http") or val.startswith("/api/") or val.startswith("data:"): - return val - return f"/api/v1/assets/{val}/binary" - - -async def _get_default_inventory(user_id: str) -> dict[str, Any] | None: - access = create_access_token({"sub": "", "usr": user_id}, token_expire_minutes=1) - async with httpx.AsyncClient() as client: - resp = await client.get( - url=f"http://{settings.host}:{settings.port}/inventory/api/v1", - headers={"Authorization": f"Bearer {access}"}, - ) - inventory = resp.json() - if not inventory: - return None - if isinstance(inventory, list): - inventory = inventory[0] if inventory else None - if not isinstance(inventory, dict): - return None - inventory["tags"] = _inventory_tags_to_list(inventory.get("tags")) - inventory["omit_tags"] = _inventory_tags_to_list(inventory.get("omit_tags")) - return inventory - - -async def _get_inventory_items_for_tpos( - user_id: str, - inventory_id: str, - tags: str | list[str] | None, - omit_tags: str | list[str] | None, -) -> list[Any]: - - tag_list = _inventory_tags_to_list(tags) - omit_list = [tag.lower() for tag in _inventory_tags_to_list(omit_tags)] - allowed_tags = [tag.lower() for tag in tag_list] - access = create_access_token({"sub": "", "usr": user_id}, token_expire_minutes=1) - async with httpx.AsyncClient() as client: - resp = await client.get( - url=f"http://{settings.host}:{settings.port}/inventory/api/v1/items/{inventory_id}/paginated", - headers={"Authorization": f"Bearer {access}"}, - params={"limit": 500, "offset": 0, "is_active": True}, - ) - payload = resp.json() - items = payload.get("data", []) if isinstance(payload, dict) else payload - - def has_allowed_tag(item_tags: str | list[str] | None) -> bool: - # When no tags are configured for this TPoS, show no items - if not tag_list: - return False - item_tag_list = [tag.lower() for tag in _inventory_tags_to_list(item_tags)] - return any(tag in item_tag_list for tag in allowed_tags) - - def has_omit_tag(item_omit_tags: str | list[str] | None) -> bool: - if not omit_list: - return False - item_tag_list = [tag.lower() for tag in _inventory_tags_to_list(item_omit_tags)] - return any(tag in item_tag_list for tag in omit_list) - - filtered = [ - item - for item in items - if has_allowed_tag(item.get("tags")) and not has_omit_tag(item.get("omit_tags")) - ] - # If no items matched the provided tags, fall back to all items minus omitted ones. - if tag_list and not filtered: - filtered = [item for item in items if not has_omit_tag(item.get("omit_tags"))] - - # hide items with no stock when stock tracking is enabled - return [ - item - for item in filtered - if item.get("quantity_in_stock") is None or item.get("quantity_in_stock") > 0 - ] - - -def _inventory_available_for_user(user: User | None) -> bool: - return bool(user and "inventory" in (user.extensions or [])) - - @tpos_api_router.get("/api/v1/tposs", status_code=HTTPStatus.OK) async def api_tposs( all_wallets: bool = Query(False),