diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml new file mode 100644 index 0000000..8b162db --- /dev/null +++ b/.pre-commit-config.yaml @@ -0,0 +1,10 @@ +repos: + - repo: https://github.com/psf/black + rev: 22.10.0 + hooks: + - id: black + - repo: https://github.com/astral-sh/ruff-pre-commit + # Ruff version. + rev: v0.0.278 + hooks: + - id: ruff diff --git a/pyproject.toml b/pyproject.toml index b771a71..a908c12 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -7,6 +7,44 @@ name = "retailtwin" authors = [{name = "Guillem Borrell", email = "borrell.guillem@bcg.com"}] readme = "README.md" dynamic = ["version", "description"] +dependencies = [ + "duckdb", + "pydantic", + "typer", + "pyyaml", + "pydantic-settings", + "polars", + "pyarrow", + "sqlalchemy[asyncio] > 2.0.13", + "adbc-driver-postgresql", + "adbc-driver-sqlite", + "prompt_toolkit", + "asyncpg", + "pydantic-settings" +] + [project.urls] Home = "https://github.com/Borrell-Guillem_bcgprod/retailtwin" + +[project.optional-dependencies] +doc = [ + "mkdocs-material", + "pymdown-extensions", + "mkdocstrings[python-legacy]>=0.18", + "mkdocs-gen-files", + "markdown-include", + "mkdocs-with-pdf", + "mkdocs-literate-nav"] + +dev = [ + "black", + "ruff", + "pre-commit" +] + +[project.scripts] +retailtwin = "retailtwin.cli.data:app" +stock = "retailtwin.cli.stock:app" +pos = "retailtwin.cli.pos.main:app" +tasks = "retailtwin.cli.tasks.main:app" \ No newline at end of file diff --git a/src/retailtwin/__init__.py b/src/retailtwin/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/retailtwin/api/README.md b/src/retailtwin/api/README.md new file mode 100644 index 0000000..ab0dfca --- /dev/null +++ b/src/retailtwin/api/README.md @@ -0,0 +1,30 @@ +# Super simple API-based stock terminal + +Run the backend with + +```bash +uvicorn dengfun.retail.api.main:app +``` + +Use Caddy to reverse proxy and the following Caddyfile. Paths are static so you have to run caddy from the root of the package + +``` +:80 { + handle_path /api/v1/* { + reverse_proxy localhost:8000 + } + file_server { + root src/dengfun/retail/api/static + } +} +``` + +If you execute + +```bash +caddy run -c Caddyfile +``` + +You should be able to browse the application at http://127.0.0.1, and reach the api docs at http://127.0.0.1/api/v1/docs + +Caddy can be installed in Windows with Chocolatey \ No newline at end of file diff --git a/src/retailtwin/api/__init__.py b/src/retailtwin/api/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/retailtwin/api/composition.py b/src/retailtwin/api/composition.py new file mode 100644 index 0000000..e69de29 diff --git a/src/retailtwin/api/db.py b/src/retailtwin/api/db.py new file mode 100644 index 0000000..fcfd473 --- /dev/null +++ b/src/retailtwin/api/db.py @@ -0,0 +1,138 @@ +import os +from operator import itemgetter +from sqlalchemy.ext.asyncio import create_async_engine, async_sessionmaker, AsyncSession +from sqlalchemy import select, desc +from dengfun.retail.views import get_inventory_view, get_stores_view +from dengfun.retail.api.settings import Settings + + +async def get_session(): + try: + engine = create_async_engine(Settings().db_uri.unicode_string()) + yield async_sessionmaker(engine, expire_on_commit=False) + except: # noqa + await engine.dispose() + + +def detail_button(batch: int): + return "".join( + [ + f'' + f'' + f"" + ] + ) + + +async def get_inventory( + store: int, + page: int, + pagesize: int, + sortby: str, + root_path: str, + async_session: async_sessionmaker[AsyncSession] = None, +) -> str: + """Get inventory for a given location + + Args: + store (int): Location id + page (int): Page number for pagination + pagesize (int): Page size for pagination + sortby (str): Column name used for sorting + root_path (str): Root path of the API + async_session (async_sessionmaker[AsyncSession], optional): + SQLAlchemy async session. Defaults to None. + + Returns: + str: HTML table rows with the content + """ + output = list() + inventory = get_inventory_view() + + # Selects the column from which to sort by, and if asc or desc + col_reversed = sortby.startswith("-") + col_selector = itemgetter(sortby.strip("-")) + + async with async_session() as session: + stmt = ( + select( + inventory.c[ + "upc", + "batch", + "name", + "package", + "received", + "best_until", + "quantity", + ] + ) + .where(inventory.c.location == store) + .limit(pagesize) + .offset(pagesize * page) + .order_by( + desc(col_selector(inventory.c)) + if col_reversed + else col_selector(inventory.c) + ) + ) + result = await session.execute(stmt) + + query_params = " ".join( + [ + f'js:{{page: "{page + 1}",' + f'pagesize: "{pagesize}",' + f'sortby: "{sortby}",' + 'store: document.getElementById("store").value}' + ] + ) + + # Turn the results in a sequence of HTML table rows + for i, record in enumerate(result.fetchall()): + line = "" + cells = ( + " ".join(f"{it}" for it in record) + + f" {detail_button(record[1])}" + ) + if i == (pagesize - 1): # Handle last row for infinite scrolling + line = " ".join( + [ + f'{cells}', + ] + ) + else: + line = f"{cells}" + + output.append(line) + + return os.linesep.join(output) + + +async def get_stores( + root_path: str, + async_session: async_sessionmaker[AsyncSession] = None, +): + # Build store options + store_rows = [''] + stores = get_stores_view() + async with async_session() as session: + stmt = select(stores) + result = await session.execute(stmt) + for record in result.fetchall(): + store_rows.append(f'') + + # Wrap with the select tag + query_params = ( + 'js:{page: "0", ' + 'pagesize: "20", ' + 'sortby: "upc", ' + 'store: document.getElementById("store").value}' + ) + return ( + f'