Compare commits

...
70 Commits
Author SHA1 Message Date
tanshu 2dfbfd8fcb Version Bump v15.1.6 2026-09-04 15:29:38 +05:30
tanshu 221fadfdfc Fix: Select customer could not edit or create a customer. 2026-09-04 09:59:29 +00:00
tanshu 11b93cb11c Version Bump v15.1.5 2026-09-04 15:17:50 +05:30
tanshu 302a4eb311 Fix: Receive payment refactored. Earlier it would refresh on input. 2026-09-04 09:47:42 +00:00
tanshu 217f636573 Version Bump v15.1.4 2026-09-04 13:44:05 +05:30
tanshu 95ac73bbc4 Updated tracking in template and choose customer fixing. 2026-09-04 08:13:26 +00:00
tanshu 3d86f8706f Version Bump v15.1.3 2026-09-04 09:03:38 +05:30
tanshu 30cbb60efe Choose customer would refresh while fetching customers. 2026-09-04 03:33:17 +00:00
tanshu 227f5b5f74 Version Bump v15.1.2 2026-09-02 18:38:08 +05:30
tanshu 50974c7574 Added a role for printer so that the container can depend on it. 2026-09-02 13:07:50 +00:00
tanshu c8e1ab74b6 Version Bump v15.1.1 2026-09-02 18:36:55 +05:30
tanshu 85ba7b326a Fix: Edge cases where backend and frontend totals are off and you cannot settle the bill. 2026-09-02 13:06:34 +00:00
tanshu 458dfaad50 Version Bump v15.1.0 2026-09-02 09:10:23 +05:30
tanshu 0a9675f270 We have moved from dd-MMM-yyyy to ISO Date. 2026-09-02 03:38:49 +00:00
tanshu 6cf42d3641 Added a handler to send the client errors to the backend. Updated the launch settings to reload the fastapi app on file changes. 2026-09-02 02:35:53 +00:00
tanshu cf680cd254 Version Bump v15.0.4 2026-09-01 07:45:23 +05:30
tanshu 80d21ec31e Minor fixes. Logs should now show the real ip address not the caddy container ip. 2026-09-01 02:15:01 +00:00
tanshu 4377016cda Version Bump v15.0.3 2026-08-31 12:11:38 +05:30
tanshu 67209886b9 current trixie does not have yarn 2026-08-31 06:41:31 +00:00
tanshu 4e1c0d1ec7 Version Bump v15.0.2 2026-08-31 10:02:01 +05:30
tanshu c7412f080d Fix: Newer trixie images did not enable yarn. 2026-08-31 04:31:48 +00:00
tanshu d023089720 Version Bump v15.0.1 2026-08-31 09:55:05 +05:30
tanshu dd02a97ba2 Forgot to add the updated uv.lock 2026-08-31 04:25:00 +00:00
tanshu befd4eb298 Version Bump v15.0.0 2026-08-31 04:23:12 +00:00
tanshu d90c1cb563 Moved from moment to luxon.
Report Printing is not a post not a get.
2026-08-31 04:19:05 +00:00
tanshu e90c80999e Frank now working with netbird and nats + jetstream 2026-08-30 15:57:59 +00:00
tanshu 42331b482d ansible improvements 2026-08-30 14:56:38 +00:00
tanshu 612e2243c0 Working on frand deployement 2026-08-30 10:37:09 +00:00
tanshu 35a3bb9ad2 netbird server works now. linted the yml files 2026-08-30 07:35:08 +00:00
tanshu ae69c2fafe Frank refactoring part 1 2026-08-30 04:41:27 +00:00
tanshu 85a063c1a7 fdsa 2026-08-29 21:09:42 +00:00
tanshu 10448e8b12 fds 2026-08-29 12:19:14 +00:00
tanshu 59bf2c5ca9 dfsa 2026-08-29 10:52:44 +00:00
tanshu 73fba17711 fdsa 2026-08-29 10:49:36 +00:00
tanshu 103983846d fasd 2026-08-28 15:44:19 +00:00
tanshu af07bb5043 fdsa 2026-08-27 12:08:58 +00:00
tanshu 612426b92f fasd 2026-08-27 11:56:52 +00:00
tanshu 730048f239 fda 2026-08-27 11:52:55 +00:00
tanshu ff537edb06 fds 2026-08-27 11:38:38 +00:00
tanshu 311464e593 csdxz 2026-08-27 06:06:30 +00:00
tanshu 43204e8441 fad 2026-08-27 05:38:32 +00:00
tanshu 2a29dc9195 cdsa 2026-08-18 13:13:11 +00:00
tanshu 65527dcbff gdfasg 2026-08-18 12:47:58 +00:00
tanshu cc52670783 mid 2026-08-18 12:28:51 +00:00
tanshu 993f83c786 toSignal via Gemini 2026-08-18 07:55:54 +00:00
tanshu 59eb45da96 Version Bump v14.5.0 2026-08-10 14:04:26 +05:30
tanshu ebe5a6352e Made the version bump more robust. 2026-08-10 08:33:15 +00:00
tanshu 38a8656008 Added proper agents config for mcp servers and skills.
Added the menu category delete route.
Fix: Show blank vouchers in tables
Fix: In rare cases, the old settements can stick around. fixed it.
2026-08-10 08:25:12 +00:00
tanshu 2e5543a2eb Optimizing and updating Dockerfile for build testing, size and cache. 2026-02-22 05:07:43 +00:00
tanshu 34e30efcaf Chore: Ruff linting updated and fixed errors.
Chore: Moved from poetry to uv
2026-02-22 04:23:14 +00:00
tanshu 2b844bc44b Chore: Using FastAPI Exception handlers for Integrity Errors and SQLAlchemyErrors.
Chore: Using Annotation to inject Session in routes.
Hence, have removed all with Session and try / catch
2026-02-21 08:26:29 +00:00
tanshu 325bcae998 Version Bump v14.3.1 2026-02-20 21:58:40 +05:30
tanshu ab8fed17d0 Fix: Product update failed as sku_version_id was not sent on the schema and hence update tried to create a new sku.
Fix: If a product sku was updated and then deleted on the same day, it failed as the valid_till for the sku_version was set to yesterday while the valid_from was set to today.
Fix: Product update also failed as the db.commit was on the loop adding new skus
2026-02-20 16:27:14 +00:00
tanshu dcf1ffad98 Version Bump v14.3.0 2026-02-17 09:45:32 +05:30
tanshu 338d9d63d5 Validation: Making Product name unique ignoring the fraction_units.
Making the sku_version.units unique for a product across stock_keeping_units.

Both respecting the validity
2026-02-17 04:13:11 +00:00
tanshu 9b0da9cb65 Fix: Product sale report was aggregating on the product_version_id 2026-02-14 12:20:09 +00:00
tanshu 4cc2ff2229 Version Bump v14.2.1 2026-02-14 11:30:40 +05:30
tanshu c9fa83e8ac Fix: Accidentally used menu category instead of sale category 2026-02-14 06:00:09 +00:00
tanshu 061dd1310d Version Bump v14.2.0 2026-02-14 05:30:05 +00:00
tanshu a1d6919d9d Beer consumption report can choose menu category for beer 2026-02-14 05:29:11 +00:00
tanshu ecc92795e6 Fix: In product list, if menu category is reset, all products get filtered out as it becomes undefined. 2026-02-12 09:01:12 +00:00
tanshu 535a145742 Fix: Cannot move kot as row.kotId was always undefined. 2026-02-12 08:20:54 +00:00
tanshu 1271e26dd8 Changed Docker CMD to remove redundant run.sh 2026-02-12 04:01:35 +00:00
tanshu 2ae3f3ba7e Dockerfile will kill the process if stuck so that restart policy can restart the container
Gunicorn will kill worker every 10000 requests which can help reclaim memory lost to leaks
2026-02-12 03:52:53 +00:00
tanshu e6a03d33e5 Version Bump v14.1.0 2026-02-11 14:59:50 +05:30
tanshu c7dd6e574d Feature: Roles can include Roles. 2026-02-11 09:28:50 +00:00
tanshu 913820cc29 Dockerfile improvements. 2026-02-11 08:41:12 +00:00
tanshu e3d40d50f0 Fix: Unable to receive payment as the amountVal as always zero. 2026-02-11 08:37:12 +00:00
tanshu b27a5a9211 Committed the poetry.lock 2026-02-11 05:51:10 +00:00
tanshu a955e26b93 Added poetry lockfile to Dockerfile 2026-02-11 05:48:41 +00:00
591 changed files with 18073 additions and 17884 deletions

No files matched your search

+31
View File
@@ -0,0 +1,31 @@
{
"mcpServers": {
"postgres-db": {
"command": "npx",
"args": [
"-y",
"@modelcontextprotocol/server-postgres",
"${POSTGRES_URL}"
],
"env": {
"POSTGRES_URL": "${env:SQLALCHEMY_DATABASE_URI}"
}
},
"filesystem": {
"command": "npx",
"args": [
"-y",
"@modelcontextprotocol/server-filesystem",
"."
]
},
"angular-cli": {
"command": "npx",
"args": [
"-y",
"@angular/cli",
"mcp"
]
}
}
}
+30
View File
@@ -0,0 +1,30 @@
# Project Architecture & Guidelines
## Monorepo Layout
- **Backend**: `./barker` (Python, managed via `uv`)
- **Frontend**: `./bookie` (Angular, managed via `npm`/`ng`)
- **Environment**: Root `.env` file containing shared/database variables.
---
## Execution Rules & Commands
### Backend (`/barker`)
- **Working Directory**: Always execute backend commands within `./barker` OR pass `--directory barker` to `uv`.
- **Package & Script Management**:
- Use `uv` exclusively. Never run bare `pip` or global `python`.
- Run scripts/tests: `uv run python <script>` or `uv run pytest` from inside `./barker`.
- Manage dependencies: `uv add <package>` or `uv remove <package>`.
- **Environment**: The root `.env` file (`../.env` relative to `barker`) contains the database connection string and secret keys.
### Frontend (`/bookie`)
- **Working Directory**: Always execute frontend commands within `./bookie`.
- **Commands**:
- Run dev server: `npm start` or `ng serve`
- Code generation: `ng g c components/<name>` (prefer standalone components)
- Testing: `ng test --watch=false`
- Build: `ng build`
### Database (PostgreSQL)
- Database settings are defined in the root `.env`.
- Inspect schema, run migrations, or check types strictly via database tools or `uv run` scripts inside `./barker`.
+117
View File
@@ -0,0 +1,117 @@
---
trigger: glob
globs: ./bookie/**/**
---
# Persona
You are a dedicated Angular developer who thrives on leveraging the absolute latest features of the framework to build cutting-edge applications. You are currently immersed in Angular v20+, passionately adopting signals for reactive state management, embracing standalone components for streamlined architecture, and utilizing the new control flow for more intuitive template logic. Performance is paramount to you, who constantly seeks to optimize change detection and improve user experience through these modern Angular paradigms. When prompted, assume You are familiar with all the newest APIs and best practices, valuing clean, efficient, and maintainable code.
## Examples
These are modern examples of how to write an Angular 20 component with signals
```ts
import { ChangeDetectionStrategy, Component, signal } from '@angular/core';
@Component({
selector: '{{tag-name}}-root',
templateUrl: '{{tag-name}}.html',
})
export class {{ClassName}} {
protected readonly isServerRunning = signal(true);
toggleServerStatus() {
this.isServerRunning.update(isServerRunning => !isServerRunning);
}
}
```
```css
.container {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
height: 100vh;
button {
margin-top: 10px;
}
}
```
```html
<section class="container">
@if (isServerRunning()) {
<span>Yes, the server is running</span>
} @else {
<span>No, the server is not running</span>
}
<button (click)="toggleServerStatus()">Toggle Server Status</button>
</section>
```
When you update a component, be sure to put the logic in the ts file, the styles in the css file and the html template in the html file.
## Resources
Here are the some links to the essentials for building Angular applications. Use these to get an understanding of how some of the core functionality works
https://angular.dev/essentials/components
https://angular.dev/essentials/signals
https://angular.dev/essentials/templates
https://angular.dev/essentials/dependency-injection
## Best practices & Style guide
Here are the best practices and the style guide information.
### Coding Style guide
Here is a link to the most recent Angular style guide https://angular.dev/style-guide
### TypeScript Best Practices
- Use strict type checking
- Prefer type inference when the type is obvious
- Avoid the `any` type; use `unknown` when type is uncertain
### Angular Best Practices
- Always use standalone components over `NgModules`
- Do NOT set `standalone: true` inside the `@Component`, `@Directive` and `@Pipe` decorators
- Use signals for state management
- Implement lazy loading for feature routes
- Use `NgOptimizedImage` for all static images.
- Do NOT use the `@HostBinding` and `@HostListener` decorators. Put host bindings inside the `host` object of the `@Component` or `@Directive` decorator instead
### Components
- Keep components small and focused on a single responsibility
- Use `input()` signal instead of decorators, learn more here https://angular.dev/guide/components/inputs
- Use `output()` function instead of decorators, learn more here https://angular.dev/guide/components/outputs
- Use `computed()` for derived state learn more about signals here https://angular.dev/guide/signals.
- Prefer inline templates for small components
- Prefer Reactive forms instead of Template-driven ones
- Do NOT use `ngClass`, use `class` bindings instead, for context: https://angular.dev/guide/templates/binding#css-class-and-style-property-bindings
- Do NOT use `ngStyle`, use `style` bindings instead, for context: https://angular.dev/guide/templates/binding#css-class-and-style-property-bindings
### State Management
- Use signals for local component state
- Use `computed()` for derived state
- Keep state transformations pure and predictable
- Do NOT use `mutate` on signals, use `update` or `set` instead
### Templates
- Keep templates simple and avoid complex logic
- Use native control flow (`@if`, `@for`, `@switch`) instead of `*ngIf`, `*ngFor`, `*ngSwitch`
- Use the async pipe to handle observables
- Use built in pipes and import pipes when being used in a template, learn more https://angular.dev/guide/templates/pipes#
### Services
- Design services around a single responsibility
- Use the `providedIn: 'root'` option for singleton services
- Use the `inject()` function instead of constructor injection
+17
View File
@@ -0,0 +1,17 @@
---
name: angular-signals
description: Writes modern Angular code using Standalone Components and Signals. Use whenever generating frontend UI or state logic.
---
# Modern Angular Standards
## Architecture
- NEVER generate or use `NgModule`.
- ALL components, directives, and pipes must be `standalone: true`.
## State Management (Signals)
- Prefer Angular Signals (`signal()`, `computed()`, `effect()`) for synchronous, local component state instead of `BehaviorSubject`.
- Use the new Signal-based inputs and outputs: `input()` and `output()` instead of `@Input()` and `@Output()`.
- Use standard RxJS strictly for asynchronous streams (e.g., HTTP requests, router events).
## Control Flow
- Use the new built-in control flow (`@if`, `@for`, `@switch`) instead of structural directives (`*ngIf`, `*ngFor`).
@@ -0,0 +1,16 @@
---
name: fastapi-pydantic-v2
description: Writes modern FastAPI endpoints using Pydantic v2 syntax and Annotated dependencies. Use whenever generating or refactoring API routes.
---
# FastAPI & Pydantic v2 Standards
## Pydantic v2 Rules
- NEVER use `.dict()` or `.json()`. ALWAYS use `.model_dump()` and `.model_dump_json()`.
- NEVER use `@validator` or `@root_validator`. ALWAYS use `@field_validator` and `@model_validator(mode="before|after")`.
- NEVER use `schema_extra`. Use `json_schema_extra` inside the `model_config` dict.
- Use `pydantic_settings.BaseSettings` for all environment variable configurations.
## FastAPI Rules
- ALWAYS use `typing.Annotated` for dependency injection (e.g., `db: Annotated[AsyncSession, Depends(get_db)]`).
- Do not use `def` for endpoints that perform I/O; always use `async def`.
- Raise `fastapi.HTTPException` for expected client errors instead of returning standard dictionaries.
+19
View File
@@ -0,0 +1,19 @@
---
name: sqlalchemy-async
description: Writes SQLAlchemy 2.0 async database queries using Psycopg3. Use whenever interacting with the PostgreSQL database.
---
# SQLAlchemy 2.0 Async Standards
## Model Definitions
- ALWAYS use SQLAlchemy 2.0 Declarative Base with `Mapped` and `mapped_column`.
- Example: `id: Mapped[int] = mapped_column(primary_key=True)`
- NEVER use the old `Column(Integer, primary_key=True)` syntax.
## Querying
- NEVER use `session.query()`. This is legacy 1.x syntax.
- ALWAYS use `sqlalchemy.select()` combined with `await session.execute(stmt)`.
- Extract results using `.scalars().all()` or `.scalar_one_or_none()`.
## Session Management
- Use `sqlalchemy.ext.asyncio.AsyncSession`.
- Ensure all database I/O is awaited.
+30
View File
@@ -0,0 +1,30 @@
---
name: strict-mypy
description: Enforces strict MyPy type checking conventions matching the project's pyproject.toml configuration. Use whenever writing or modifying Python code.
---
# Strict MyPy Typing Standards
This project uses a highly strict MyPy configuration (`strict = true`). When writing or refactoring Python code, you MUST adhere to the following rules to ensure the code passes type checking:
## 1. Absolute Signature Completeness
- EVERY function and method must have fully typed arguments and a return type (`disallow_untyped_defs`, `disallow_incomplete_defs`).
- ALWAYS specify `-> None` for functions and methods that do not return a value.
- NEVER leave type hints partially defined (e.g., typing only some arguments).
- Decorators must be completely type-hinted using `typing.Callable` or `typing.ParamSpec` (`disallow_untyped_decorators`).
## 2. No Implicit Optionals
- `arg: str = None` is strictly forbidden.
- You MUST explicitly union with None: `arg: str | None = None` (`no_implicit_optional`).
## 3. Strict Generics
- NEVER use bare collections as types.
- ALWAYS specify the inner types: use `list[str]`, `dict[str, int]`, or `tuple[int, ...]` instead of bare `list`, `dict`, or `tuple` (`disallow_any_generics`).
## 4. Restrictions on `Any` and Subclassing
- Do not subclass from untyped external libraries or `Any` (`disallow_subclassing_any`).
- Avoid using external types that have no stubs and resolve to `Any` (`disallow_any_unimported`).
- Refrain from returning `Any` to avoid contaminating the type inference of calling functions (`warn_return_any`).
## 5. Pydantic Strictness
- `pydantic.mypy` is active with strict initialization.
- When instantiating Pydantic models, ensure you are passing exactly the expected typed arguments, as extra or untyped arguments will fail type checks (`init_typed = true`, `init_forbid_extra = true`).
+61
View File
@@ -0,0 +1,61 @@
# Version control & IDEs
.git
.gitignore
.vscode
.idea
.agents
.gemini
# Build artifacts & compiled outputs
frontend
dist
**/dist
out-tsc
**/out-tsc
bazel-out
# Dependencies & framework caches
node_modules
**/node_modules
.angular
**/.angular
.nx
**/.nx
.sass-cache
**/.sass-cache
# Python virtual environments & caches
.venv
**/__pycache__
**/.mypy_cache
**/.ruff_cache
**/.pytest_cache
*.py[cod]
*$py.class
*.egg-info
# Testing & coverage
coverage
**/coverage
.coverage
htmlcov
# Logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
# Environment & local deployment/dev files
.env*
!.env.example
ansible
deploy.sh
lint.sh
version_bump.sh
Makefile
README.md
CHANGES.md
typings
.DS_Store
Thumbs.db
+5 -5
View File
@@ -8,8 +8,9 @@ PROJECT_NAME=barker
MAX_WORKERS=4
REDIS_HOST=localhost
REDIS_PORT=6379
NATS_URL=nats://localhost:4222
SUBJECT="barker.print"
STREAM="barker_print_stream"
# openssl rand -hex 32
SECRET_KEY=611f9393e58f85521d16d9497bfd847ced70f2b99e12a9c6af40b7bd05cc7b1d
@@ -24,6 +25,5 @@ TIMEZONE_OFFSET_MINUTES=330
ALEMBIC_LOG_LEVEL=INFO
ALEMBIC_SQLALCHEMY_LOG_LEVEL=WARN
WORKER_REDIS_HOST=
WORKER_REDIS_PORT=6379
WORKER_QUEUE_NAME=""
FASTAPI_ENV=development
+50 -10
View File
@@ -1,12 +1,52 @@
*.pyc
venv
.project
.pydevproject
*/__pycache__/
# IDEs & System Files
.idea/
*.egg-info/
DB/Data/*.sql
barker/static/
build/
dist/
.DS_Store
Thumbs.db
# Build outputs
frontend/
dist/
**/dist/
out-tsc/
**/out-tsc/
bazel-out/
# Angular & Nx caches
.angular/
**/.angular/
.nx/
**/.nx/
.sass-cache/
# Dependencies
node_modules/
**/node_modules/
# Python & Virtual Environments
.venv/
**/__pycache__/
*.py[cod]
*$py.class
*.egg-info/
.mypy_cache/
**/.mypy_cache/
.ruff_cache/
**/.ruff_cache/
.pytest_cache/
**/.pytest_cache/
# Testing & Coverage
coverage/
**/coverage/
.coverage
htmlcov/
# Logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
# Ignore Pyright-generated stubs
*.pyi
typings/
+11 -1
View File
@@ -1,4 +1,14 @@
{
// For more information, visit: https://go.microsoft.com/fwlink/?linkid=827846
"recommendations": ["angular.ng-template"]
"recommendations": [
"ms-vscode-remote.remote-wsl",
"ms-python.python",
"ms-python.vscode-pylance",
"ms-python.debugpy",
"charliermarsh.ruff",
"docker.docker",
"angular.ng-template",
"dbaeumer.vscode-eslint",
"tombi-toml.tombi"
]
}
+22 -3
View File
@@ -6,19 +6,38 @@
"configurations": [
{
"name": "ng serve",
"type": "pwa-chrome",
"type": "chrome",
"request": "launch",
"preLaunchTask": "npm: start",
"url": "http://localhost:4200/"
},
{
"name": "Python: FastAPI",
"type": "python",
"type": "debugpy",
"request": "launch",
"module": "barker",
"args": [],
"cwd": "${workspaceFolder}/barker",
"justMyCode": true
"justMyCode": true,
"envFile": "${workspaceFolder}/.env"
},
{
"name": "Python: FastAPI (Live Reload)",
"type": "debugpy",
"request": "launch",
"module": "uvicorn",
"args": [
"barker.main:app",
"--host",
"0.0.0.0",
"--port",
"9995",
"--reload"
],
"cwd": "${workspaceFolder}/barker",
"subProcess": true,
"justMyCode": true,
"envFile": "${workspaceFolder}/.env"
}
]
}
+9
View File
@@ -0,0 +1,9 @@
{
// For more information, visit: https://angular.dev/ai/mcp
"servers": {
"angular-cli": {
"command": "npx",
"args": ["-y", "@angular/cli", "mcp"]
}
}
}
+77 -23
View File
@@ -1,25 +1,79 @@
{
"python.defaultInterpreterPath": "/home/tanshu/.cache/pypoetry/virtualenvs/barker-3ndhMgBR-py3.14/bin/python",
"files.exclude": {
"**/__pycache__": true,
"**/.mypy_cache": true,
".idea": true,
"**/node_modules": true,
},
"search.exclude": {
"**/__pycache__": true,
"**/.mypy_cache": true,
".idea": true,
"**/node_modules": true,
"**/package-lock.json": true,
},
"makefile.configureOnOpen": false,
"python-envs.pythonProjects": [],
/* --- Pylance: keep IntelliSense, disable type checking --- */
"python.analysis.typeCheckingMode": "off",
// "python.analysis.diagnosticMode": "openFilesOnly",
/* --- mypy: source of truth for typing --- */
"python.linting.enabled": true,
"python.linting.mypyEnabled": true
"python.defaultInterpreterPath": "barker/.venv/bin/python",
"python.terminal.useEnvFile": true,
"python.venvFolders": [
"barker/.venv",
"frank/.venv",
".venv"
],
"python.venvPath": "${workspaceFolder}/barker",
"python.terminal.activateEnvironment": true,
// Quality-of-life
/* --- Pylance: keep IntelliSense, disable type checking --- */
// "python.analysis.typeCheckingMode": "off",
// "python.analysis.diagnosticMode": "openFilesOnly",
"python.analysis.typeCheckingMode": "basic",
"python.analysis.extraPaths": [
"${workspaceFolder}/barker",
"./barker"
],
"python.autoComplete.extraPaths": [
"${workspaceFolder}/barker",
"./barker"
],
// Ruff (you have ruff installed via uv)
"editor.formatOnSave": true,
"[python]": {
"editor.defaultFormatter": "charliermarsh.ruff"
},
"ruff.enable": true,
"files.eol": "\n",
"eslint.workingDirectories": [
{
"directory": "bookie",
"changeProcessCWD": true
}
],
"makefile.configureOnOpen": false,
"files.exclude": {
"**/__pycache__": true,
"**/.mypy_cache": true,
"**/.ruff_cache": true,
"**/.pytest_cache": true,
".idea": true,
"**/node_modules": true,
"typings": true,
"frontend": true,
"**/.angular": true,
"**/.nx": true,
"**/out-tsc": true,
"**/dist": true,
"**/.sass-cache": true,
"**/coverage": true
},
"search.exclude": {
"**/__pycache__": true,
"**/.mypy_cache": true,
"**/.ruff_cache": true,
"**/.pytest_cache": true,
".idea": true,
"**/node_modules": true,
"**/poetry.lock": true,
"**/package-lock.json": true,
"typings": true,
"frontend/**": true,
"**/.angular/**": true,
"**/.nx/**": true,
"**/out-tsc/**": true,
"**/dist/**": true,
"**/.sass-cache/**": true,
"**/coverage/**": true
},
"editor.codeActionsOnSave": {
"source.fixAll.ruff": "always",
"source.fixAll.eslint": "always"
},
"python-envs.defaultEnvManager": "ms-python.python:venv",
"python-envs.defaultPackageManager": "ms-python.python:pip",
"python-envs.pythonProjects": []
}
+3
View File
@@ -27,6 +27,9 @@
"type": "npm",
"script": "test",
"isBackground": true,
"options": {
"cwd": "${workspaceFolder}/bookie"
},
"problemMatcher": {
"owner": "typescript",
"pattern": "$tsc",
+46 -19
View File
@@ -1,9 +1,13 @@
FROM node:latest AS base
FROM node:lts-trixie-slim AS base
# Install dependencies only when needed
FROM base AS deps
WORKDIR /app
RUN apt-get update && apt-get install -y --no-install-recommends \
python3 make g++ \
&& rm -rf /var/lib/apt/lists/*
# Install dependencies based on the preferred package manager
COPY bookie/package.json bookie/yarn.lock* bookie/package-lock.json* bookie/pnpm-lock.yaml* ./
RUN \
@@ -25,36 +29,59 @@ RUN \
else echo "Lockfile not found." && exit 1; \
fi
FROM python:3.14 AS runner
FROM python:3.14-slim AS runner
LABEL maintainer="Amritanshu <docker@tanshu.com>"
# Install Poetry
RUN curl -sSL https://install.python-poetry.org | POETRY_HOME=/opt/poetry python - && \
cd /usr/local/bin && \
ln -s /opt/poetry/bin/poetry && \
poetry config virtualenvs.create false
RUN apt-get update \
&& apt-get install -y --no-install-recommends curl \
&& rm -rf /var/lib/apt/lists/*
WORKDIR /app
# Install uv
# Ref: https://docs.astral.sh/uv/guides/integration/docker/#installing-uv
COPY --from=ghcr.io/astral-sh/uv:latest /uv /uvx /bin/
COPY barker/pyproject.toml /app/pyproject.toml
# Compile bytecode
# Ref: https://docs.astral.sh/uv/guides/integration/docker/#compiling-bytecode
ENV UV_COMPILE_BYTECODE=1
# Allow installing dev dependencies to run tests
ARG INSTALL_DEV=false
RUN bash -c "if [ $INSTALL_DEV == 'true' ] ; then poetry install --no-root ; else poetry install --no-root --only main ; fi"
# Disable development dependencies
# Ref: https://docs.astral.sh/uv/guides/integration/docker/#installing-a-project
ENV UV_NO_DEV=1
# uv Cache
# Ref: https://docs.astral.sh/uv/guides/integration/docker/#caching
ENV UV_LINK_MODE=copy
WORKDIR /app/
# Place executables in the environment at the front of the path
# Ref: https://docs.astral.sh/uv/guides/integration/docker/#using-the-environment
ENV PATH="/app/.venv/bin:$PATH"
# Install dependencies
# Ref: https://docs.astral.sh/uv/guides/integration/docker/#intermediate-layers
RUN --mount=type=cache,target=/root/.cache/uv \
--mount=type=bind,source=barker/uv.lock,target=uv.lock \
--mount=type=bind,source=barker/pyproject.toml,target=pyproject.toml \
uv sync --locked --no-install-project
COPY /barker ./
COPY --from=builder /frontend/browser /app/static
COPY --from=builder /frontend/browser /app/frontend
# Sync the project
# Ref: https://docs.astral.sh/uv/guides/integration/docker/#intermediate-layers
RUN --mount=type=cache,target=/root/.cache/uv \
uv sync --locked
ENV PYTHONPATH=/app
EXPOSE 80
RUN chmod 777 /app/docker-entrypoint.sh \
&& ln -s /app/docker-entrypoint.sh /usr/local/bin/docker-entrypoint.sh \
&& ln -s /app/docker-entrypoint.sh /
RUN chmod +x /app/docker-entrypoint.sh \
&& ln -s /app/docker-entrypoint.sh /usr/local/bin/docker-entrypoint.sh
ENTRYPOINT ["docker-entrypoint.sh"]
# at the end of your Dockerfile, before CMD or after EXPOSE
HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \
CMD curl -f http://localhost/health || exit 1
# Kill the main process if the healthcheck fails. This will kill the container and restart policy can restart it.
HEALTHCHECK --interval=30s --timeout=5s --start-period=30s --retries=3 \
CMD curl -fsS http://localhost/health || kill -s 15 1
CMD ["poetry", "run", "/app/run.sh"]
CMD ["gunicorn", "barker.main:app", "--worker-class", "uvicorn.workers.UvicornWorker", "--config", "./gunicorn.conf.py", "--log-config", "./logging.conf"]
+20
View File
@@ -4,5 +4,25 @@ build-production: ## Build the production docker image.
--platform linux/amd64,linux/arm64/v8 \
--tag registry.tanshu.com/barker:latest \
$(if $(TAG),--tag registry.tanshu.com/barker:$(TAG)) \
--pull \
--push \
git@git.tanshu.com:tanshu/barker.git
.PHONY: build-check
build-check: ## Multi-arch build without push (compile check)
@docker buildx build \
--platform linux/amd64,linux/arm64/v8 \
--tag barker:test \
--pull \
--progress=plain \
git@git.tanshu.com:tanshu/barker.git
.PHONY: build-check-local
build-check-local: ## Multi-arch build without push (compile check)
@git archive --format=tar HEAD | docker buildx build \
--platform linux/amd64 \
--tag barker:test \
--pull \
--progress=plain \
--load \
-
+29 -95
View File
@@ -1,25 +1,15 @@
# Installation (Ubuntu)
# Installation linux
This project uses **pyenv** for Python version management and **Poetry** for dependency and virtual environment management.
This project uses **uv** for Python version, dependency and virtual environment management.
---
## 1. Install pyenv
## 1. Install uv
### 1.1 Install pyenv (if not already installed)
```bash
curl -fsSL https://pyenv.run | bash
```
### 1.2 Configure shell for pyenv
Add the following to `~/.zshrc`:
### 1.1 Install uv (if not already installed)
```zsh
echo 'export PYENV_ROOT="$HOME/.pyenv"' >> ~/.zshrc
echo '[[ -d $PYENV_ROOT/bin ]] && export PATH="$PYENV_ROOT/bin:$PATH"' >> ~/.zshrc
echo 'eval "$(pyenv init - zsh)"' >> ~/.zshrc
curl -LsSf https://astral.sh/uv/install.sh | sh
```
Reload the shell:
@@ -31,28 +21,18 @@ exec "$SHELL"
Verify:
```zsh
pyenv --version
uv --version
```
### 1.3 Install system dependencies
## 2. Install Python 3.14 using uv
```zsh
sudo apt update; sudo apt install make build-essential libssl-dev zlib1g-dev \
libbz2-dev libreadline-dev libsqlite3-dev curl git \
libncursesw5-dev xz-utils tk-dev libxml2-dev libxmlsec1-dev libffi-dev liblzma-dev
````
---
## 2. Install Python 3.14 using pyenv
```zsh
pyenv install 3.14
uv python install 3.14
```
Set the local Python version for this project:
```zsh
pyenv local 3.14
uv python pin 3.14
```
Verify:
@@ -63,97 +43,51 @@ python --version
---
## 3. Install Poetry
## 3. Install project dependencies
### 3.1 Install Poetry (if not already installed)
### 3.1 Install dependencies
```zsh
curl -sSL https://install.python-poetry.org | python3 -
```
Ensure Poetry is on PATH:
```zsh
export PATH="$HOME/.local/bin:$PATH"
```
Enable tab completion for Zsh
```zsh
mkdir $ZSH_CUSTOM/plugins/poetry
poetry completions zsh > $ZSH_CUSTOM/plugins/poetry/_poetry
```
You must then add poetry to your plugins array in ~/.zshrc:
```zsh
plugins(
poetry
...
)
```
Verify:
```zsh
poetry --version
```
---
## 4. Configure Poetry to use pyenv’s Python
From the project root directory:
```zsh
poetry env use "$(pyenv which python)"
```
Verify the environment:
```zsh
poetry env info
```
---
## 5. Install the project
### 5.1 Install dependencies
```zsh
poetry install
uv sync
```
This will:
* Create a virtual environment
* Create a local `.venv`
* Install all dependencies defined in `pyproject.toml`
* Use Python **3.14** from pyenv
* Generate/update `uv.lock`
* Use Python **3.14**
---
## 6. Activate the environment (optional)
## 4. Activate the environment (optional)
```zsh
eval $(poetry env activate)
source .venv/bin/activate
```
Deactivate:
```zsh
deactivate
```
Or run commands directly:
```zsh
poetry run python your_script.py
uv run python your_script.py
```
---
## 7. Install nvm
## 5. Install nvm
### 7.1 Install nvm (if not already installed)
### 5.1 Install nvm (if not already installed)
```bash
curl -o- https://raw.githubusercontent.com/nvm-sh/nvm/v0.40.3/install.sh | bash
```
### 7.2 Configure shell for pyenv
### 5.2 Configure shell for pyenv
Add the following to `~/.zshrc`:
@@ -179,7 +113,7 @@ nvm --version
## Notes
* Do **not** use system-wide pip for this project.
* All dependencies (including `psycopg`) are managed by Poetry.
* Do **not** install dependencies manually.
* Always use `uv sync` to install or update dependencies.
* Commit both: `pyproject.toml` and `uv.lock`
* PostgreSQL must be running and properly configured before application startup.
```
-179
View File
@@ -1,179 +0,0 @@
#################################################
# DO Community Playbooks: Docker
#################################################
---
- hosts: all
become: true
vars_files:
- vars/pies.yml
tasks:
# - name: Check if LCD Rotated
# shell: grep -c "lcd_rotate=2" /boot/config.txt || true
# register: lcd_rotate_test
# - name: rotate the lcd
# lineinfile:
# dest: /boot/config.txt
# line: lcd_rotate=2
# when: lcd_rotate_test.stdout == "0"
- name: Update the hostname file
replace:
path: /etc/hostname
regexp: '(\s*)raspberrypi(\s+.*)?$'
replace: "\\1{{ hostname }}\\2"
backup: yes
- name: Update the hosts file
replace:
path: /etc/hosts
regexp: '(\s+)raspberrypi(\s+.*)?$'
replace: "\\1{{ hostname }}\\2"
backup: yes
- name: Ensure SSH Directory exists
file:
path: "/home/{{ user }}/.ssh"
state: directory
group: "{{ user }}"
owner: "{{ user }}"
mode: 0700
- name: Ensure authorized_keys file exists
copy:
content: ""
dest: "/home/{{ user }}/.ssh/authorized_keys"
force: no
group: "{{ user }}"
owner: "{{ user }}"
mode: 0644
- name: Check Terminus public key
shell: "grep -c \"{{ terminus_key }}\" /home/{{ user }}/.ssh/authorized_keys || true"
register: terminuskey_test
- name: Add Terminus public key
lineinfile:
dest: "/home/{{ user }}/.ssh/authorized_keys"
line: "{{ terminus_key }}"
when: terminuskey_test.stdout == "0"
- name: Check rohan public key
shell: "grep -c \"{{ rohan_key }}\" /home/{{ user }}/.ssh/authorized_keys || true"
register: rohankey_test
- name: Add Rohan public key
lineinfile:
dest: "/home/{{ user }}/.ssh/authorized_keys"
line: "{{ rohan_key }}"
when: rohankey_test.stdout == "0"
- name: Check Anjin public key
shell: "grep -c \"{{ anjin_key }}\" /home/{{ user }}/.ssh/authorized_keys || true"
register: anjin_key_test
- name: Add Anjin public key
lineinfile:
dest: "/home/{{ user }}/.ssh/authorized_keys"
line: "{{ anjin_key }}"
when: anjin_key_test.stdout == "0"
- name: Update the sshd config file to disable password logins
replace:
path: /etc/ssh/sshd_config
regexp: '(\s+)#PasswordAuthentication yes(\s+.*)?$'
replace: "\\1PasswordAuthentication no\\2"
backup: yes
- name: Check if .ssh config file exists
stat:
path: "/home/{{ user }}/.ssh/config"
register: config_status
- name: No need to upload the .ssh config
when: config_status.stat.exists == true
debug:
msg: No need to upload the .ssh config as it already exists.
- name: Upload the .ssh config file
when: config_status.stat.exists == false
template:
src: "files/config"
dest: "/home/{{ user }}/.ssh/config"
group: "{{ user }}"
owner: "{{ user }}"
mode: 0644
- name: Check if ed25519 key exists
stat:
path: "/home/{{ user }}/.ssh/id_ed25519"
register: key_status
- name: No need to generate new ed25519 key
when: key_status.stat.exists == true
debug:
msg: No need to generate new ed25519 key as it already exists.
- name: Generate new id_ed25519 key
when: key_status.stat.exists == false
become: yes
become_user: "{{ user }}"
shell: ssh-keygen -t ed25519 -q -f "/home/{{ user }}/.ssh/id_ed25519" -C "{{ user }}@{{ hostname }} $(date '+%Y.%m.%d')" -N ""
- name: Install Docker
shell: curl -sSL https://get.docker.com | sh
- name: Install matchbox-keyboard
package:
name: matchbox-keyboard
state: latest
- name: Install python3-docker
package:
name: python3-docker
state: latest
- name: adding user to group docker
user:
name: "{{ user }}"
groups: docker
append: yes
- name: adding user to group lp (line printers)
user:
name: "{{ user }}"
groups: lp
append: yes
- name: install rohan-redis-tunnel systemd unit file
template:
src: "files/rohan-redis-tunnel.service"
dest: "/etc/systemd/system/rohan-redis-tunnel.service"
- name: enable service rohan-redis-tunnel and ensure it is not masked
systemd:
name: rohan-redis-tunnel
enabled: yes
masked: no
- name: Make sure rohan-redis-tunnel service is running
systemd:
state: started
name: rohan-redis-tunnel
- name: install leardal systemd unit file
template:
src: "files/leardal.service"
dest: "/etc/systemd/system/leardal.service"
- name: enable service leardal and ensure it is not masked
systemd:
name: leardal
enabled: yes
masked: no
- name: Make sure leardal service is running
systemd:
state: started
name: leardal
+4 -3
View File
@@ -2,12 +2,13 @@ HOST=0.0.0.0
PORT=80
LOG_LEVEL=WARN
DEBUG=false
SQLALCHEMY_DATABASE_URI=postgresql+psycopg://postgres:123456@postgres:5432/petty{{ name }}
SQLALCHEMY_DATABASE_URI=postgresql+psycopg://postgres:123456@postgres:5432/petty{{ app_name }}
MODULE_NAME=barker.main
PROJECT_NAME=barker
MAX_WORKERS=4
REDIS_HOST=valkey
REDIS_PORT=6379
NATS_URL={{ nats_url }}
SUBJECT=barker.print
STREAM=barker_print_stream
SECRET_KEY={{ secret_key }}
MIDDLEWARE_SECRET_KEY={{ middleware_key }}
ALGORITHM=HS256
+1 -22
View File
@@ -1,24 +1,3 @@
{{ host }} {
# Match and proxy API routes
@apiRoutes {
path_regexp ^/(api|token|refresh|db-image)
}
handle @apiRoutes {
reverse_proxy @apiRoutes {{ host_directory }}:80
}
# Match requests that end with .js, .css, .ico, or .html
@staticFiles {
path_regexp \.(js|css|ico|html)$
}
handle @staticFiles {
rewrite * /static{uri}
reverse_proxy {{ host_directory }}:80
}
# All other frontend routes → /static/index.html
handle {
rewrite * /static/index.html
reverse_proxy {{ host_directory }}:80
}
reverse_proxy {{ host_directory }}:80
}
-1
View File
@@ -1 +0,0 @@
docker build --file /home/{{ user }}/dockerfile/app/frank.Dockerfile --tag frank:latest /home/{{ user }}/dockerfile/app
-4
View File
@@ -1,4 +0,0 @@
Host rohan rohan.tanshu.com
HostName rohan.tanshu.com
IdentityFile ~/.ssh/id_ed25519
User tanshu
-3
View File
@@ -1,3 +0,0 @@
REDIS_HOST=localhost
REDIS_PORT=6379
QUEUE_NAME={{ queue_name }}
-15
View File
@@ -1,15 +0,0 @@
[Unit]
Description=Frank container service
After=docker.service rohan-redis-tunnel.service
Wants=network-online.target docker.socket rohan-redis-tunnel.service
Requires=docker.socket rohan-redis-tunnel.service
PartOf=rohan-redis-tunnel.service
[Service]
Restart=always
RestartSec=3
ExecStart=/usr/bin/docker start -a frank
ExecStop=/usr/bin/docker stop -t 10 frank
[Install]
WantedBy=multi-user.target
-1
View File
@@ -1 +0,0 @@
docker run --detach --name frank --env-file=/home/{{ user }}/frank.env --network=host --restart=no --device /dev/usb/lp0:/printer frank:latest
-16
View File
@@ -1,16 +0,0 @@
[Unit]
Description=Reverse SSH connection
After=network.target
[Service]
Type=simple
User={{ user }}
ExecStart=/usr/bin/ssh -NTg -o ServerAliveInterval=60 -o ExitOnForwardFailure=yes -o ServerAliveCountMax=3 -o StrictHostKeyChecking=no -i /home/{{ user }}/.ssh/id_ed25519 -R {{ ssh_port }}:localhost:22 tanshu@rohan.tanshu.com
# Restart every >2 seconds to avoid StartLimitInterval failure
Restart=always
RestartSec=5s
[Install]
WantedBy=default.target
-39
View File
@@ -1,39 +0,0 @@
server {
listen 80;
server_name {{ http_host }};
# Allow large attachments
client_max_body_size 128M;
location /api {
proxy_set_header Host $host:$server_port;
proxy_set_header X-Scheme $scheme;
proxy_set_header X-Forwarded-For $remote_addr;
proxy_pass http://localhost:{{ host_port }};
}
location /token {
proxy_set_header Host $host:$server_port;
proxy_set_header X-Scheme $scheme;
proxy_set_header X-Forwarded-For $remote_addr;
proxy_pass http://localhost:{{ host_port }};
}
location /refresh {
proxy_set_header Host $host:$server_port;
proxy_set_header X-Scheme $scheme;
proxy_set_header X-Forwarded-For $remote_addr;
proxy_pass http://localhost:{{ host_port }};
}
location /db-image {
proxy_set_header Host $host:$server_port;
proxy_set_header X-Scheme $scheme;
proxy_set_header X-Forwarded-For $remote_addr;
proxy_pass http://localhost:{{ host_port }};
}
location / {
root /var/lib/{{ host_directory }}/frontend;
index index.html index.htm;
try_files $uri $uri/ /index.html =404;
}
}
-15
View File
@@ -1,15 +0,0 @@
[Unit]
Description=Keep ssh tunnel to specified remote host open
After=network.target
[Service]
User={{ user }}
ExecStart=/usr/bin/ssh -NT -o ServerAliveInterval=30 -o ExitOnForwardFailure=yes -o ServerAliveCountMax=2 -o StrictHostKeyChecking=no -i /home/{{ user }}/.ssh/id_ed25519 -L 6379:localhost:6379 tanshu@rohan.tanshu.com
# Restart every >2 seconds to avoid StartLimitInterval failure
RestartSec=5
Restart=always
[Install]
WantedBy=multi-user.target
-82
View File
@@ -1,82 +0,0 @@
#################################################
# DO Community Playbooks: Docker
#################################################
---
- hosts: all
become: true
vars_files:
- vars/frank.yml
tasks:
- name: Copy dockerfile
synchronize: src=app dest="/home/{{ user }}/dockerfile"
- name: Build frank image
docker_image:
name: frank:latest
build:
path: "/home/{{ user }}/dockerfile/app"
dockerfile: "/home/{{ user }}/dockerfile/app/frank.Dockerfile"
pull: yes
state: present
source: build
- name: Check if frank .env file exists
stat: path="/home/{{ user }}/frank.env"
register: status
- name: No need to upload the frank .env file
debug:
msg: "No need to upload the frank .env file as it already exists."
- name: Upload the frank .env file
when: status.stat.exists == false
template:
src: files/frank.env
dest: "/home/{{ user }}/frank.env"
group: "{{ user }}"
owner: "{{ user }}"
mode: 0644
- name: Create frank worker container
docker_container:
name: "frank"
image: frank:latest
state: started
restart_policy: "no"
env_file: "/home/{{ user }}/frank.env"
network_mode: "host"
devices:
- "{{ printer_port }}:/printer"
- name: Copy docker build script
template:
src: files/build-frank.sh
dest: "/home/{{ user }}/dockerfile/build-frank.sh"
group: "{{ user }}"
owner: "{{ user }}"
mode: 0644
- name: Copy docker run script
template:
src: files/frank.sh
dest: "/home/{{ user }}/dockerfile/frank.sh"
group: "{{ user }}"
owner: "{{ user }}"
mode: 0644
- name: install frank systemd unit file
template:
src: "files/frank.service"
dest: "/etc/systemd/system/frank.service"
- name: enable service frank and ensure it is not masked
systemd:
name: frank
enabled: yes
masked: no
- name: Make sure frank service is running
systemd:
state: started
name: frank
+8 -2
View File
@@ -2,11 +2,17 @@
# DO Community Playbooks: Docker
#################################################
---
- hosts: barker
- name: Deploy Barker
hosts: barker
become: true
vars_files:
- vars/default.yml
- "{{ var_file }}"
pre_tasks:
- name: Load per-host vars file from inventory (var_file)
ansible.builtin.include_vars:
file: "{{ var_file }}"
when: var_file is defined
roles:
- network
+3
View File
@@ -0,0 +1,3 @@
---
collections:
- name: community.docker
+10 -10
View File
@@ -1,30 +1,32 @@
---
# Tasks for docker role
- name: Log in to Docker registry
docker_login:
community.docker.docker_login:
registry: "{{ registry }}"
username: "{{ username }}"
password: "{{ password }}"
- name: Pull Barker image
docker_image:
community.docker.docker_image:
name: "{{ image_name }}"
source: pull
state: present
force_source: yes
force_source: true
- name: Ensure Host Directory exists
file:
path: "/var/lib/{{ host_directory }}"
state: directory
ansible.builtin.file:
path: "/var/lib/{{ host_directory }}"
state: directory
mode: "0755"
- name: Upload the .env file
template:
ansible.builtin.template:
src: "files/.env"
dest: "/var/lib/{{ host_directory }}/.env"
mode: "0644"
- name: Create barker container
docker_container:
community.docker.docker_container:
name: "{{ host_directory }}"
image: "{{ image_name }}"
state: started
@@ -32,5 +34,3 @@
env_file: "/var/lib/{{ host_directory }}/.env"
networks:
- name: "{{ docker_network }}"
# volumes:
# - "/var/lib/{{ host_directory }}/frontend:/frontend"
+2 -2
View File
@@ -1,6 +1,6 @@
---
caddy_container: caddy
caddyfile_path: /var/lib/caddy/conf/Caddyfile
caddy_caddyfile_path: /var/lib/caddy/conf/Caddyfile
# snippet_block: |
# {{ host }} {
# reverse_proxy {{ barker_container }}:80
+4 -6
View File
@@ -1,7 +1,5 @@
---
# Handlers for Nginx role
- name: Restart caddy container
docker_container:
name: "{{ caddy_container }}"
state: started
restart: true
- name: Reload Caddy configuration
ansible.builtin.command: "docker exec -w /etc/caddy {{ caddy_container }} caddy reload"
listen: "Reload Caddy"
changed_when: true
+14 -13
View File
@@ -1,26 +1,27 @@
---
- name: Read snippet from template file
set_fact:
snippet_block: "{{ lookup('template', 'files/Caddyfile.j2') }}"
ansible.builtin.set_fact:
caddy_snippet_block: "{{ lookup('template', 'files/Caddyfile.j2') }}"
- name: Read current Caddyfile
ansible.builtin.slurp:
path: "{{ caddyfile_path }}"
register: caddyfile_raw
path: "{{ caddy_caddyfile_path }}"
register: caddy_caddyfile_raw
- name: Decode Caddyfile content
set_fact:
caddyfile_content: "{{ caddyfile_raw['content'] | b64decode }}"
ansible.builtin.set_fact:
caddy_caddyfile_content: "{{ caddy_caddyfile_raw['content'] | b64decode }}"
- name: Check if snippet already exists
set_fact:
snippet_present: "{{ snippet_block in caddyfile_content }}"
ansible.builtin.set_fact:
caddy_snippet_present: "{{ caddy_snippet_block in caddy_caddyfile_content }}"
- name: Add snippet if missing
ansible.builtin.blockinfile:
path: "{{ caddyfile_path }}"
path: "{{ caddy_caddyfile_path }}"
marker: "# {mark} Ansible managed Caddy snippet for {{ host }}"
block: "{{ snippet_block }}"
create: yes
when: not snippet_present
notify: Restart caddy container
block: "{{ caddy_snippet_block }}"
create: true
mode: "0644"
when: not caddy_snippet_present
notify: Reload Caddy configuration
+2 -2
View File
@@ -1,10 +1,10 @@
---
- name: Ensure 'barker' Docker network exists
docker_network:
community.docker.docker_network:
name: "{{ docker_network }}"
state: present
connected:
- caddy
- valkey
- postgres
appends: yes
appends: true
+3 -3
View File
@@ -1,8 +1,8 @@
---
name: chd
app_name: chd
secret_key: d9e4facec94d7e0bf3d63ca03b1d78d834b158627b6593274f7fe27f6aed6db4
middleware_key: 8d5f28b083
host: knox.greatbear.in
host_directory: "barker-{{ name }}"
docker_network: "{{ host_directory}}_net"
host_directory: "barker-{{ app_name }}"
docker_network: "{{ host_directory }}_net"
-4
View File
@@ -1,4 +0,0 @@
---
printer_port: "/dev/usb/lp0"
user: vari
queue_name: moz-elante
+4 -3
View File
@@ -1,8 +1,9 @@
---
name: hin
app_name: hin
secret_key: 029858927beda45d76317729824ae500d0938f04d4ca955f3afa95061c9a9298
middleware_key: c4b0b98ff9
host: knox.mozimo.in
host_directory: "barker-{{ name }}"
docker_network: "{{ host_directory}}_net"
host_directory: "barker-{{ app_name }}"
docker_network: "{{ host_directory }}_net"
nats_url: "nats://clair.lan.tanshu.com:4222"
+3 -3
View File
@@ -1,8 +1,8 @@
---
name: mhl
app_name: mhl
secret_key: 8b7f704ad1bbee3caa683a57440a6a371937fe5f64a7712a6a15bf3165c3c598
middleware_key: 1aa5487223
host: knox.hngmohali.com
host_directory: "barker-{{ name }}"
docker_network: "{{ host_directory}}_net"
host_directory: "barker-{{ app_name }}"
docker_network: "{{ host_directory }}_net"
+3 -3
View File
@@ -1,8 +1,8 @@
---
name: pkl
app_name: pkl
secret_key: bd6e5dee0f3b8a6f0db50f7aa08e91d55b2ae5ab6df126defa37e80602481002
middleware_key: 1d34ef6597
host: knox.hopsngrains.com
host_directory: "barker-{{ name }}"
docker_network: "{{ host_directory}}_net"
host_directory: "barker-{{ app_name }}"
docker_network: "{{ host_directory }}_net"
-1
View File
@@ -1,4 +1,3 @@
*.pyc
*/__pycache__/
*.egg-info/
poetry.lock
+5 -3
View File
@@ -3,16 +3,18 @@
repos:
- repo: https://github.com/astral-sh/ruff-pre-commit
# Ruff version.
rev: v0.12.1
rev: v0.16.2
hooks:
# Run the linter.
- id: ruff
args: [ --fix ]
args:
- --fix
- --config=barker/pyproject.toml
# Run the formatter.
- id: ruff-format
- repo: https://github.com/pre-commit/pre-commit-hooks
rev: v5.0.0
rev: v6.0.0
hooks:
- id: trailing-whitespace
- id: end-of-file-fixer
+22
View File
@@ -0,0 +1,22 @@
{
"python.defaultInterpreterPath": "/home/tanshu/programming/barker/barker/.venv/bin/python",
"python.interpreterPath": "/home/tanshu/programming/barker/barker/.venv/bin/python",
"python.venvFolders": [
".venv"
],
"python.terminal.activateEnvironment": true,
"python.analysis.typeCheckingMode": "basic",
"python.analysis.extraPaths": [
"${workspaceFolder}"
],
"python.autoComplete.extraPaths": [
"${workspaceFolder}"
],
"editor.formatOnSave": true,
"[python]": {
"editor.defaultFormatter": "charliermarsh.ruff"
},
"editor.codeActionsOnSave": {
"source.fixAll.ruff": "always"
}
}
@@ -34,7 +34,7 @@ def upgrade():
)
op.create_exclude_constraint(
"uq_product_versions_product_id",
op.f("uq_product_versions_product_id"),
"product_versions",
(prod.c.product_id, "="),
(func.daterange(prod.c.valid_from, prod.c.valid_till, text("'[]'")), "&&"),
@@ -0,0 +1,40 @@
"""role includes
Revision ID: 5cb65066be86
Revises: 367ecf7b898f
Create Date: 2026-02-11 08:21:01.679893
"""
import sqlalchemy as sa
from alembic import op
# revision identifiers, used by Alembic.
revision = "5cb65066be86"
down_revision = "367ecf7b898f"
branch_labels = None
depends_on = None
def upgrade():
op.create_table(
"role_includes",
sa.Column("id", sa.Uuid(), server_default=sa.text("gen_random_uuid()"), nullable=False),
sa.Column("role_id", sa.Uuid(), nullable=False),
sa.Column("included_role_id", sa.Uuid(), nullable=False),
sa.CheckConstraint("role_id <> included_role_id", name=op.f("ck_role_includes_no_self_include")),
sa.ForeignKeyConstraint(
["included_role_id"], ["roles.id"], name=op.f("fk_role_includes_included_role_id_roles")
),
sa.ForeignKeyConstraint(["role_id"], ["roles.id"], name=op.f("fk_role_includes_role_id_roles")),
sa.PrimaryKeyConstraint("id", name=op.f("pk_role_includes")),
sa.UniqueConstraint("role_id", "included_role_id", name=op.f("uq_role_includes_role_id")),
sa.Index(op.f("ix_role_includes_role_id"), "role_id"),
sa.Index(op.f("ix_role_includes_included_role_id"), "included_role_id"),
)
def downgrade():
op.drop_table("role_includes")
@@ -0,0 +1,156 @@
"""index
Revision ID: 8260414066d6
Revises: 5cb65066be86
Create Date: 2026-02-13 06:22:39.926120
"""
import sqlalchemy as sa
from alembic import op
# revision identifiers, used by Alembic.
revision = "8260414066d6"
down_revision = "5cb65066be86"
branch_labels = None
depends_on = None
def upgrade():
# 1) Add the column (nullable for backfill)
op.add_column("sku_versions", sa.Column("product_id", sa.UUID(), nullable=True))
# 2) Define lightweight table objects (no autoload; explicit columns only)
sku_versions = sa.Table(
"sku_versions",
sa.MetaData(),
sa.Column("id", sa.UUID(), primary_key=True),
sa.Column("sku_id", sa.UUID(), nullable=False),
sa.Column("product_id", sa.UUID(), nullable=True),
sa.Column("valid_from", sa.Date(), nullable=True),
sa.Column("valid_till", sa.Date(), nullable=True),
sa.Column("units", sa.Unicode(), nullable=False),
)
stock_keeping_units = sa.Table(
"stock_keeping_units",
sa.MetaData(),
sa.Column("id", sa.UUID(), primary_key=True),
sa.Column("product_id", sa.UUID(), nullable=False),
)
# 3) Backfill via SQLAlchemy Core UPDATE..SET..(SELECT ...)
product_id_subq = (
sa.select(stock_keeping_units.c.product_id)
.where(stock_keeping_units.c.id == sku_versions.c.sku_id)
.scalar_subquery()
)
backfill_stmt = (
sa.update(sku_versions).values(product_id=product_id_subq).where(sku_versions.c.product_id.is_(None))
)
op.execute(backfill_stmt)
# 5) Enforce NOT NULL at schema level
op.alter_column("sku_versions", "product_id", nullable=False)
# 6) Trigger keeps product_id in sync when sku_id changes
op.execute(
sa.text(
"""
CREATE OR REPLACE FUNCTION sku_versions_set_product_id()
RETURNS trigger LANGUAGE plpgsql AS $$
BEGIN
SELECT s.product_id INTO NEW.product_id
FROM stock_keeping_units s
WHERE s.id = NEW.sku_id;
IF NEW.product_id IS NULL THEN
RAISE EXCEPTION 'Invalid sku_id %, no product found', NEW.sku_id;
END IF;
RETURN NEW;
END;
$$;
"""
)
)
op.execute(sa.text("DROP TRIGGER IF EXISTS trg_sku_versions_set_product_id ON sku_versions;"))
op.execute(
sa.text(
"""
CREATE TRIGGER trg_sku_versions_set_product_id
BEFORE INSERT OR UPDATE OF sku_id
ON sku_versions
FOR EACH ROW
EXECUTE FUNCTION sku_versions_set_product_id();
"""
)
)
# 7) Exclusion constraint: product_id + units must not overlap in time
# daterange(valid_from, valid_till, '[]') overlap operator &&.
sv = sa.table(
"sku_versions",
sa.column("product_id", sa.UUID()),
sa.column("units", sa.UUID()),
sa.column("valid_from", sa.Date()),
sa.column("valid_till", sa.Date()),
)
op.create_exclude_constraint(
op.f("uq_sku_versions_product_id_units"),
"sku_versions",
(sv.c.product_id, "="),
(sv.c.units, "="),
(sa.func.daterange(sv.c.valid_from, sv.c.valid_till, sa.text("'[]'")), "&&"),
)
prod = sa.table(
"product_versions",
sa.column("id", sa.UUID()),
sa.column("name", sa.Unicode(length=255)),
sa.column("fraction_units", sa.Unicode(length=255)),
sa.column("valid_from", sa.Date()),
sa.column("valid_till", sa.Date()),
)
# Update the exclude constraint on product_versions to only be on the name and drop fraction_units from it
op.drop_constraint("uq_product_versions_name", "product_versions", type_="unique")
op.create_exclude_constraint(
op.f("uq_product_versions_name"),
"product_versions",
(prod.c.name, "="),
(sa.func.daterange(prod.c.valid_from, prod.c.valid_till, sa.text("'[]'")), "&&"),
)
# ### end Alembic commands ###
def downgrade():
op.drop_constraint("uq_sku_versions_product_id_units", "sku_versions", type_="exclude")
# Drop trigger + function
op.execute(sa.text("DROP TRIGGER IF EXISTS trg_sku_versions_set_product_id ON sku_versions;"))
op.execute(sa.text("DROP FUNCTION IF EXISTS sku_versions_set_product_id();"))
# Drop column
op.drop_column("sku_versions", "product_id")
prod = sa.table(
"product_versions",
sa.column("id", sa.UUID()),
sa.column("name", sa.Unicode(length=255)),
sa.column("fraction_units", sa.Unicode(length=255)),
sa.column("valid_from", sa.Date()),
sa.column("valid_till", sa.Date()),
)
op.drop_constraint("uq_product_versions_name", "product_versions", type_="unique")
op.create_exclude_constraint(
"uq_product_versions_name",
"product_versions",
(prod.c.name, "="),
(prod.c.units, "="),
(sa.func.daterange(prod.c.valid_from, prod.c.valid_till, sa.text("'[]'")), "&&"),
)
+1 -1
View File
@@ -1 +1 @@
__version__ = "14.0.1"
__version__ = "15.1.6"
-6
View File
@@ -1,6 +0,0 @@
from arq.connections import RedisSettings
from .config import settings as sett
settings = RedisSettings(host=sett.REDIS_HOST, port=sett.REDIS_PORT)
+3 -6
View File
@@ -18,15 +18,12 @@ class Settings(BaseSettings):
LOG_LEVEL: str = "NOTSET"
SQLALCHEMY_DATABASE_URI: str = ""
REDIS_HOST: str = "127.0.0.1"
REDIS_PORT: int = 6379
ALEMBIC_LOG_LEVEL: str = "INFO"
ALEMBIC_SQLALCHEMY_LOG_LEVEL: str = "WARN"
WORKER_REDIS_HOST: str = "127.0.0.1"
WORKER_REDIS_PORT: int = 6379
WORKER_QUEUE_NAME: str = "arq:queue"
NATS_URL: str = "nats://127.0.0.1:4222"
SUBJECT: str = "barker.print"
STREAM: str = "barker_print_stream"
model_config = SettingsConfigDict(case_sensitive=True, env_file=".env")
+6 -9
View File
@@ -1,7 +1,7 @@
import uuid
from datetime import UTC, datetime, timedelta
from typing import Any
from typing import Annotated, Any
import jwt
@@ -60,15 +60,12 @@ def device_allowed(user: UserModel, device_id: uuid.UUID | None, db: Session) ->
device = db.execute(select(Device).where(Device.id == device_id)).scalars().one_or_none()
if device is None:
device = Device.create(db)
allowed = "add-devices" in set([p.name.replace(" ", "-").lower() for r in user.roles for p in r.permissions])
allowed = "add-devices" in {p.name.replace(" ", "-").lower() for r in user.roles for p in r.permissions}
return allowed or device.enabled, device
async def get_current_user(
security_scopes: SecurityScopes,
token: str = Depends(oauth2_scheme),
) -> UserToken:
async def get_current_user(security_scopes: SecurityScopes, token: Annotated[str, Depends(oauth2_scheme)]) -> UserToken:
authenticate_value = f'Bearer scope="{security_scopes.scope_str}"' if security_scopes.scopes else "Bearer"
credentials_exception = HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
@@ -82,11 +79,11 @@ async def get_current_user(
raise credentials_exception
token_scopes = payload.get("scopes", [])
token_data = TokenData(scopes=token_scopes, username=username)
except (PyJWTError, ValidationError):
except PyJWTError, ValidationError:
raise credentials_exception
user = get_user(
username=token_data.username,
id_=payload.get("userId", None),
id_=payload.get("userId", ""),
locked_out=payload.get("lockedOut", True),
scopes=token_scopes,
)
@@ -103,7 +100,7 @@ async def get_current_user(
async def get_current_active_user(
current_user: UserToken = Security(get_current_user, scopes=["authenticated"]),
current_user: Annotated[UserToken, Security(get_current_user, scopes=["authenticated"])],
) -> UserToken:
if current_user.locked_out:
raise HTTPException(status_code=400, detail="Inactive user")
+6 -6
View File
@@ -4,31 +4,31 @@ from typing import Any
def get_date(session: dict[str, Any]) -> str:
if "date" not in session:
session["date"] = date.today().strftime("%d-%b-%Y")
session["date"] = date.today().isoformat()
return session["date"] # type: ignore[no-any-return]
def set_date(date_: str | date, session: dict[str, Any]) -> None:
session["date"] = date_ if isinstance(date_, str) else date_.strftime("%d-%b-%Y")
session["date"] = date_ if isinstance(date_, str) else date_.isoformat()
def get_start_date(session: dict[str, Any]) -> str:
if "start" not in session:
session["start"] = get_first_day(date.today()).strftime("%d-%b-%Y")
session["start"] = get_first_day(date.today()).isoformat()
return session["start"] # type: ignore[no-any-return]
def get_finish_date(session: dict[str, Any]) -> str:
if "finish" not in session:
session["finish"] = get_last_day(date.today()).strftime("%d-%b-%Y")
session["finish"] = get_last_day(date.today()).isoformat()
return session["finish"] # type: ignore[no-any-return]
def set_period(start: str | date | None, finish: str | date | None, session: dict[str, Any]) -> None:
if start is not None:
session["start"] = start if isinstance(start, str) else start.strftime("%d-%b-%Y")
session["start"] = start if isinstance(start, str) else start.isoformat()
if finish is not None:
session["finish"] = finish if isinstance(finish, str) else finish.strftime("%d-%b-%Y")
session["finish"] = finish if isinstance(finish, str) else finish.isoformat()
def get_first_day(dt: date, d_years: int = 0, d_months: int = 0) -> date:
+56
View File
@@ -0,0 +1,56 @@
import asyncio
from nats.js.api import StreamConfig
from taskiq import BrokerMessage
from taskiq_nats import PullBasedJetStreamBroker
from .config import settings
class RoutedJetStreamBroker(PullBasedJetStreamBroker):
async def kick(self, message: BrokerMessage) -> None:
subject = message.labels.get("queue", self.subject)
await self.js.publish(
subject,
payload=message.message,
headers=message.labels,
)
broker = RoutedJetStreamBroker(
settings.NATS_URL,
subject=f"{settings.SUBJECT}.>",
stream_name=f"{settings.STREAM}",
stream_config=StreamConfig(
name=f"{settings.STREAM}",
subjects=[f"{settings.SUBJECT}.>"],
),
)
main_loop: asyncio.AbstractEventLoop | None = None
@broker.task(task_name="nat_print")
async def nat_print(data: str, cut_code: str) -> None:
pass
def enqueue_print_job_sync(printer_name: str, data: str, cut_code: str) -> None:
coro = nat_print.kicker().with_labels(queue=f"{settings.SUBJECT}.{printer_name}").kiq(data, cut_code)
if main_loop is not None and main_loop.is_running():
asyncio.run_coroutine_threadsafe(coro, main_loop)
else:
async def _enqueue() -> None:
await broker.startup()
try:
await coro
finally:
await broker.shutdown()
loop = asyncio.new_event_loop()
try:
loop.run_until_complete(_enqueue())
finally:
loop.close()
+1 -1
View File
@@ -38,7 +38,7 @@ from ..models.stock_keeping_unit import StockKeepingUnit
from ..models.tax import Tax
from ..models.user import User
from ..models.user_role import UserRole
from ..models.voucher import Voucher # noqa
from ..models.voucher import Voucher
from ..models.voucher_type import VoucherType
from .base_class import reg
+216
View File
@@ -0,0 +1,216 @@
from __future__ import annotations
import re
from dataclasses import dataclass
from sqlalchemy.exc import IntegrityError
@dataclass(frozen=True)
class FriendlyDBError:
status: int
code: str
detail: str
constraint: str | None = None
# SQLSTATE → default mapping (Postgres)
_SQLSTATE_DEFAULTS: dict[str, tuple[int, str, str]] = {
"23505": (409, "UNIQUE_VIOLATION", "Already exists."),
"23503": (409, "FOREIGN_KEY_VIOLATION", "Referenced record not found."),
"23514": (400, "CHECK_VIOLATION", "Invalid value."),
"23502": (400, "NOT_NULL_VIOLATION", "Missing required value."),
}
# Constraint-name → friendly mapping for your schema
# Keep this as the "source of truth" for frontend-friendly messages.
_CONSTRAINT_FRIENDLY: dict[str, FriendlyDBError] = {
# --- customers ---
"uq_customers_phone": FriendlyDBError(
409, "CUSTOMER_PHONE_EXISTS", "A customer with this phone number already exists."
),
"ck_customers_phone_not_blank": FriendlyDBError(400, "CUSTOMER_PHONE_BLANK", "Phone number cannot be blank."),
"ck_customers_name_not_blank": FriendlyDBError(400, "CUSTOMER_NAME_BLANK", "Customer name cannot be blank."),
# --- devices / tables / sections / printers / settings ---
"uq_devices_name": FriendlyDBError(409, "DEVICE_NAME_EXISTS", "A device with this name already exists."),
"uq_food_tables_name": FriendlyDBError(409, "FOOD_TABLE_NAME_EXISTS", "A table with this name already exists."),
"uq_sections_name": FriendlyDBError(409, "SECTION_NAME_EXISTS", "A section with this name already exists."),
"uq_printers_name": FriendlyDBError(409, "PRINTER_NAME_EXISTS", "A printer with this name already exists."),
"uq_settings_name": FriendlyDBError(409, "SETTING_NAME_EXISTS", "A setting with this name already exists."),
# --- regimes / taxes / sale_categories ---
"uq_regimes_name": FriendlyDBError(409, "REGIME_NAME_EXISTS", "A regime with this name already exists."),
"uq_regimes_prefix": FriendlyDBError(409, "REGIME_PREFIX_EXISTS", "A regime with this prefix already exists."),
"uq_taxes_name": FriendlyDBError(409, "TAX_NAME_EXISTS", "A tax with this name already exists."),
"uq_sale_categories_name": FriendlyDBError(
409, "SALE_CATEGORY_NAME_EXISTS", "A sale category with this name already exists."
),
# --- roles / permissions / settle_options ---
"uq_roles_name": FriendlyDBError(409, "ROLE_NAME_EXISTS", "A role with this name already exists."),
"uq_permissions_name": FriendlyDBError(
409, "PERMISSION_NAME_EXISTS", "A permission with this name already exists."
),
"uq_settle_options_name": FriendlyDBError(
409, "SETTLE_OPTION_NAME_EXISTS", "A settle option with this name already exists."
),
# --- kots / vouchers / bills / overview / settlements / reprints ---
"uq_kots_code": FriendlyDBError(409, "KOT_CODE_EXISTS", "This KOT code already exists."),
"uq_vouchers_kot_id": FriendlyDBError(409, "VOUCHER_FOR_KOT_EXISTS", "A voucher already exists for this KOT."),
"uq_bills_voucher_id": FriendlyDBError(409, "BILL_FOR_VOUCHER_EXISTS", "A bill already exists for this voucher."),
"uq_bills_regime_id": FriendlyDBError(409, "BILL_REGIME_CONFLICT", "Bill regime conflicts with an existing bill."),
"uq_overview_voucher_id": FriendlyDBError(
409, "OVERVIEW_FOR_VOUCHER_EXISTS", "An overview already exists for this voucher."
),
"uq_overview_food_table_id": FriendlyDBError(
409, "OVERVIEW_FOR_TABLE_EXISTS", "An overview already exists for this table."
),
"uq_overview_guest_book_id": FriendlyDBError(
409, "OVERVIEW_FOR_GUEST_EXISTS", "An overview already exists for this guest entry."
),
"uq_settlements_voucher_id": FriendlyDBError(
409, "SETTLEMENT_FOR_VOUCHER_EXISTS", "A settlement already exists for this voucher."
),
# --- login_history / user_roles / role_includes / role_permissions ---
"uq_login_history_user_id": FriendlyDBError(
409, "LOGIN_HISTORY_EXISTS", "Login history already exists for this user."
),
"uq_user_roles_user_id": FriendlyDBError(
409, "USER_ROLE_EXISTS", "A role assignment already exists for this user."
),
"uq_role_includes_role_id": FriendlyDBError(
409, "ROLE_INCLUDE_EXISTS", "This role-include relation already exists."
),
"uq_role_permissions_permission_id": FriendlyDBError(
409, "ROLE_PERMISSION_EXISTS", "This permission is already assigned."
),
# --- customer_discount ---
"uq_customer_discount_customer_id": FriendlyDBError(
409, "CUSTOMER_DISCOUNT_EXISTS", "Discount is already configured for this customer."
),
# --- inventory_modifiers / inventories ---
"uq_inventory_modifiers_inventory_id": FriendlyDBError(
409, "INVENTORY_MODIFIERS_EXISTS", "Modifiers already exist for this inventory item."
),
"uq_inventories_kot_id": FriendlyDBError(
409, "INVENTORY_FOR_KOT_EXISTS", "Inventory entry already exists for this KOT."
),
# --- menu_categories / modifier_categories / modifiers ---
"uq_menu_categories_name": FriendlyDBError(
409, "MENU_CATEGORY_NAME_EXISTS", "A menu category with this name already exists."
),
"uq_modifier_categories_name": FriendlyDBError(
409, "MODIFIER_CATEGORY_NAME_EXISTS", "A modifier category with this name already exists."
),
"uq_modifiers_name": FriendlyDBError(409, "MODIFIER_NAME_EXISTS", "A modifier with this name already exists."),
# --- exclude constraints (time-range overlaps / duplicates) ---
"uq_bundle_items_bundle_id_item_id": FriendlyDBError(
409, "BUNDLE_ITEM_EXISTS", "This item is already in the bundle."
),
"uq_product_versions_name": FriendlyDBError(
409,
"PRODUCT_VERSION_NAME_OVERLAP",
"Product name overlaps with an existing version in the selected date range.",
),
"uq_product_versions_product_id": FriendlyDBError(
409, "PRODUCT_VERSION_OVERLAP", "Product version overlaps with an existing version in the selected date range."
),
"uq_sku_versions_product_id_units": FriendlyDBError(
409, "SKU_PRODUCT_UNITS_OVERLAP", "This unit already exists for the product in the selected date range."
),
"uq_sku_versions_sku_id_units": FriendlyDBError(
409, "SKU_UNITS_OVERLAP", "This unit already exists for the SKU in the selected date range."
),
"uq_sku_versions_sku_id": FriendlyDBError(
409, "SKU_VERSION_OVERLAP", "SKU version overlaps with an existing version in the selected date range."
),
}
# --- UNIQUE INDEXES (not constraints) ---
_UNIQUE_INDEX_FRIENDLY: dict[str, FriendlyDBError] = {
"uq_users_name": FriendlyDBError(
409,
"USER_NAME_EXISTS",
"A user with this name already exists.",
),
}
# Optional: FK constraint names → friendlier messages (instead of generic “referenced record not found”)
# Add only the ones where you want specific UX.
_FK_FRIENDLY: dict[str, FriendlyDBError] = {
"fk_guest_book_customer_id_customers": FriendlyDBError(409, "CUSTOMER_NOT_FOUND", "Customer does not exist."),
"fk_vouchers_customer_id_customers": FriendlyDBError(409, "CUSTOMER_NOT_FOUND", "Customer does not exist."),
"fk_vouchers_food_table_id_food_tables": FriendlyDBError(409, "FOOD_TABLE_NOT_FOUND", "Food table does not exist."),
"fk_kots_food_table_id_food_tables": FriendlyDBError(409, "FOOD_TABLE_NOT_FOUND", "Food table does not exist."),
"fk_kots_user_id_users": FriendlyDBError(409, "USER_NOT_FOUND", "User does not exist."),
"fk_vouchers_user_id_users": FriendlyDBError(409, "USER_NOT_FOUND", "User does not exist."),
"fk_devices_section_id_sections": FriendlyDBError(409, "SECTION_NOT_FOUND", "Section does not exist."),
"fk_food_tables_section_id_sections": FriendlyDBError(409, "SECTION_NOT_FOUND", "Section does not exist."),
"fk_sku_versions_menu_category_id_menu_categories": FriendlyDBError(
409, "MENU_CATEGORY_NOT_FOUND", "Menu category does not exist."
),
"fk_stock_keeping_units_product_id_products": FriendlyDBError(409, "PRODUCT_NOT_FOUND", "Product does not exist."),
"fk_product_versions_product_id_products": FriendlyDBError(409, "PRODUCT_NOT_FOUND", "Product does not exist."),
"fk_products_sale_category_id_sale_categories": FriendlyDBError(
409, "SALE_CATEGORY_NOT_FOUND", "Sale category does not exist."
),
"fk_customer_discount_sale_category_id_sale_categories": FriendlyDBError(
409, "SALE_CATEGORY_NOT_FOUND", "Sale category does not exist."
),
"fk_sale_categories_tax_id_taxes": FriendlyDBError(409, "TAX_NOT_FOUND", "Tax does not exist."),
"fk_taxes_regime_id_regimes": FriendlyDBError(409, "REGIME_NOT_FOUND", "Regime does not exist."),
}
def _psycopg3_sqlstate(exc: IntegrityError) -> str | None:
orig = getattr(exc, "orig", None)
return getattr(orig, "sqlstate", None) if orig else None
def _psycopg3_constraint_name(exc: IntegrityError) -> str | None:
orig = getattr(exc, "orig", None)
diag = getattr(orig, "diag", None) if orig else None
return getattr(diag, "constraint_name", None) if diag else None
def constraint_to_friendly(exc: IntegrityError) -> FriendlyDBError:
"""
Convert a SQLAlchemy IntegrityError (psycopg3 / Postgres) to a frontend-friendly error.
Precedence:
A) Exact constraint-name mapping (_CONSTRAINT_FRIENDLY)
B) FK-specific mapping (_FK_FRIENDLY)
C) SQLSTATE generic mapping
D) Safe fallback
"""
sqlstate = _psycopg3_sqlstate(exc)
constraint = _psycopg3_constraint_name(exc)
if constraint:
if constraint in _CONSTRAINT_FRIENDLY:
e = _CONSTRAINT_FRIENDLY[constraint]
return FriendlyDBError(e.status, e.code, e.detail, constraint=constraint)
if constraint in _UNIQUE_INDEX_FRIENDLY:
e = _UNIQUE_INDEX_FRIENDLY[constraint]
return FriendlyDBError(e.status, e.code, e.detail, constraint=constraint)
if constraint in _FK_FRIENDLY:
e = _FK_FRIENDLY[constraint]
return FriendlyDBError(e.status, e.code, e.detail, constraint=constraint)
# common pattern: *_not_null constraints → 400 with field hint
if constraint.endswith("_not_null"):
# e.g. "customers_phone_not_null" → "phone"
field = re.sub(r"^.*?_", "", constraint).replace("_not_null", "")
return FriendlyDBError(
400,
"REQUIRED_FIELD_MISSING",
f"'{field}' is required.",
constraint=constraint,
)
if sqlstate is None:
status, code, detail = (409, "INTEGRITY_ERROR", "Conflict.")
else:
status, code, detail = _SQLSTATE_DEFAULTS.get(sqlstate, (409, "INTEGRITY_ERROR", "Conflict."))
return FriendlyDBError(status, code, detail, constraint=constraint)
+18 -2
View File
@@ -1,7 +1,11 @@
import logging
from collections.abc import Generator
from typing import Annotated
from fastapi import Depends
from sqlalchemy import create_engine
from sqlalchemy.orm import sessionmaker
from sqlalchemy.orm import Session, sessionmaker
from ..core.config import settings
@@ -9,4 +13,16 @@ from ..core.config import settings
logging.basicConfig()
logging.getLogger("sqlalchemy.engine").setLevel(settings.LOG_LEVEL)
engine = create_engine(settings.SQLALCHEMY_DATABASE_URI, pool_pre_ping=True)
SessionFuture = sessionmaker(autoflush=False, bind=engine)
SessionFuture = sessionmaker(autoflush=False, bind=engine, autocommit=False)
def get_session() -> Generator[Session]:
with SessionFuture() as session:
try:
yield session
except Exception:
session.rollback()
raise
SessionDep = Annotated[Session, Depends(get_session)]
+52 -5
View File
@@ -1,13 +1,23 @@
import asyncio
import logging
from collections.abc import AsyncIterator
from contextlib import asynccontextmanager
import uvicorn
from fastapi import FastAPI
from fastapi.staticfiles import StaticFiles
from fastapi import FastAPI, Request
from fastapi.responses import JSONResponse
from sqlalchemy.exc import IntegrityError, SQLAlchemyError
from starlette.middleware.sessions import SessionMiddleware
from .core import taskiq
from .core.config import settings
from .db.base import reg # noqa: F401
from .db.friendly_db_error import constraint_to_friendly
from .routers import (
bundle,
client_log,
customer,
customer_discount,
db_settings,
@@ -56,7 +66,39 @@ from .routers.voucher import (
)
app = FastAPI()
@asynccontextmanager
async def lifespan(_app: FastAPI) -> AsyncIterator[None]:
taskiq.main_loop = asyncio.get_running_loop()
await taskiq.broker.startup()
try:
yield
finally:
await taskiq.broker.shutdown()
logger = logging.getLogger(__name__)
app = FastAPI(lifespan=lifespan)
@app.exception_handler(SQLAlchemyError)
async def sqlalchemy_error_handler(_request: Request, exc: SQLAlchemyError) -> JSONResponse:
logger.exception("Database error", exc_info=exc) # log full details server-side
return JSONResponse(
status_code=500,
content={
"detail": "Database error\n" + str(exc),
"code": "DB_ERROR",
},
)
@app.exception_handler(IntegrityError)
async def integrity_error_handler(_request: Request, exc: IntegrityError) -> JSONResponse:
err = constraint_to_friendly(exc)
return JSONResponse(
status_code=err.status,
content={"detail": err.detail, "code": err.code, "constraint": err.constraint},
)
app.add_middleware(SessionMiddleware, secret_key=settings.MIDDLEWARE_SECRET_KEY)
@@ -121,8 +163,13 @@ app.include_router(split.router, prefix="/api", tags=["voucher"])
app.include_router(change.router, prefix="/api/voucher", tags=["voucher"])
app.include_router(health.router, prefix="/health", tags=["health"])
app.mount("/static", StaticFiles(directory="static"), name="static")
app.include_router(client_log.router, prefix="/api/client-logs", tags=["logs"])
app.frontend("/", directory="frontend", check_dir="auto")
def init() -> None:
uvicorn.run(app, host=settings.HOST, port=settings.PORT)
uvicorn.run("barker.main:app", host=settings.HOST, port=settings.PORT, reload=settings.DEBUG)
if __name__ == "__main__":
init()
+1 -1
View File
@@ -1,7 +1,7 @@
import enum
class GuestBookType(str, enum.Enum):
class GuestBookType(enum.StrEnum):
running = "running"
printed = "printed"
walk_in = "walk_in"
+1 -1
View File
@@ -53,7 +53,7 @@ class Inventory:
type_: Mapped[InventoryType] = mapped_column(
"type", Enum(InventoryType), server_default=text("regular"), nullable=False
)
parent_id: Mapped[uuid.UUID | None] = mapped_column(Uuid, ForeignKey("inventories.id"), nullable=True)
parent_id: Mapped[uuid.UUID | None] = mapped_column(Uuid, ForeignKey("inventories.id"), nullable=True, index=True)
kot: Mapped[Kot] = relationship(back_populates="inventories")
tax: Mapped[Tax] = relationship(back_populates="inventories")
+1 -1
View File
@@ -22,7 +22,7 @@ class InventoryModifier:
__table_args__ = (UniqueConstraint("inventory_id", "modifier_id"),)
id: Mapped[uuid.UUID] = mapped_column(Uuid, primary_key=True, server_default=text("gen_random_uuid()"))
inventory_id: Mapped[uuid.UUID] = mapped_column(Uuid, ForeignKey("inventories.id"), nullable=False)
inventory_id: Mapped[uuid.UUID] = mapped_column(Uuid, ForeignKey("inventories.id"), nullable=False, index=True)
modifier_id: Mapped[uuid.UUID] = mapped_column(Uuid, ForeignKey("modifiers.id"), nullable=False)
price: Mapped[Decimal] = mapped_column(Numeric(precision=15, scale=2), nullable=False)
+1 -1
View File
@@ -1,7 +1,7 @@
import enum
class InventoryType(str, enum.Enum):
class InventoryType(enum.StrEnum):
regular = "regular"
bundle = "bundle"
bundle_item = "bundle_item"
+1 -1
View File
@@ -1,6 +1,6 @@
import enum
class OverviewStatus(str, enum.Enum):
class OverviewStatus(enum.StrEnum):
running = "running"
printed = "printed"
+18
View File
@@ -8,6 +8,7 @@ from sqlalchemy import Text, Uuid, text
from sqlalchemy.orm import Mapped, mapped_column, relationship
from ..db.base_class import reg
from .role_includes import RoleInclude
from .role_permission import RolePermission
@@ -28,6 +29,23 @@ class Role:
back_populates="roles",
)
included_roles: Mapped[list[Role]] = relationship(
"Role",
secondary=RoleInclude.__table__, # type: ignore[attr-defined]
primaryjoin=(id == RoleInclude.role_id),
secondaryjoin=(id == RoleInclude.included_role_id),
back_populates="included_by_roles",
)
# "included_by_roles" = roles that include THIS role (parents)
included_by_roles: Mapped[list[Role]] = relationship(
"Role",
secondary=RoleInclude.__table__, # type: ignore[attr-defined]
primaryjoin=(id == RoleInclude.included_role_id),
secondaryjoin=(id == RoleInclude.role_id),
back_populates="included_roles",
)
def __init__(self, name: str, id_: uuid.UUID | None = None):
self.name = name
if id_ is not None:
+28
View File
@@ -0,0 +1,28 @@
import uuid
from sqlalchemy import CheckConstraint, ForeignKey, UniqueConstraint, Uuid, text
from sqlalchemy.orm import Mapped, mapped_column
from ..db.base_class import reg
@reg.mapped_as_dataclass(unsafe_hash=True)
class RoleInclude:
"""
A "role includes role" edge:
- role_id (parent/composite role)
- included_role_id (child role that is included)
This lets Role A inherit all permissions of Role B (and B's included roles, recursively).
"""
__tablename__ = "role_includes"
__table_args__ = (
UniqueConstraint("role_id", "included_role_id"),
CheckConstraint("role_id <> included_role_id", name="no_self_include"),
)
id: Mapped[uuid.UUID] = mapped_column(Uuid, primary_key=True, server_default=text("gen_random_uuid()"))
role_id: Mapped[uuid.UUID] = mapped_column(Uuid, ForeignKey("roles.id"), nullable=False, index=True)
included_role_id: Mapped[uuid.UUID] = mapped_column(Uuid, ForeignKey("roles.id"), nullable=False, index=True)
+29 -1
View File
@@ -8,7 +8,7 @@ from typing import TYPE_CHECKING
from sqlalchemy import Boolean, Date, ForeignKey, Numeric, Unicode, Uuid, func, text
from sqlalchemy.dialects import postgresql
from sqlalchemy.orm import Mapped, mapped_column, relationship
from sqlalchemy.orm import Mapped, mapped_column, relationship, validates
from ..db.base_class import reg
@@ -26,6 +26,14 @@ class SkuVersion:
Uuid, primary_key=True, insert_default=uuid.uuid4, server_default=text("gen_random_uuid()")
)
sku_id: Mapped[uuid.UUID] = mapped_column(Uuid, ForeignKey("stock_keeping_units.id"), nullable=False)
# DB column is "product_id", but ORM attribute is private: "_product_id"
_product_id: Mapped[uuid.UUID] = mapped_column(
"product_id",
Uuid,
nullable=False,
repr=False, # hides in dataclass repr
init=False, # not part of __init__ signature (dataclass)
)
units: Mapped[str] = mapped_column(
Unicode, nullable=False
) # Need to have logic in the application to handle unit uniqueness since we don't have product_id here
@@ -56,8 +64,28 @@ class SkuVersion:
(sku_id, "="),
(func.daterange(valid_from, valid_till, text("'[]'")), "&&"),
),
# product-level uniqueness per time range
postgresql.ExcludeConstraint(
(_product_id, "="),
(units, "="),
(func.daterange(valid_from, valid_till, text("'[]'")), "&&"),
name="uq_sku_versions_product_units_time",
using="gist",
),
)
@validates("_product_id")
def _prevent_writes_to_product_id(
self,
key: str,
value: uuid.UUID,
) -> uuid.UUID:
"""
This column is integrity-only. We never accept app-side writes.
DB trigger sets it.
"""
raise ValueError("product_id is managed by the database and must not be set in application code.")
def __init__(
self,
units: str = "",
+9 -24
View File
@@ -1,16 +1,14 @@
import asyncio
import re
import uuid
from datetime import timedelta
from decimal import Decimal
from arq import ArqRedis, create_pool
from sqlalchemy import Date, func, select
from sqlalchemy.orm import Session
from ..core.arq import settings as redis_settings
from ..core.config import settings
from ..core.taskiq import enqueue_print_job_sync
from ..models.bill import Bill
from ..models.db_setting import DbSetting
from ..models.inventory import Inventory
@@ -38,33 +36,22 @@ def print_bill(voucher_id: uuid.UUID, db: Session) -> None:
.where(SectionPrinter.sale_category_id == None) # noqa: E711
).scalar_one()
regimes = get_regimes(get_inventories(voucher), voucher.voucher_type, db)
regimes = get_regimes(get_inventories(voucher), voucher.voucher_type)
bills: list[str] = []
for regime_id, inventories in regimes:
taxes_dict: dict[str, Decimal] = {}
for i in inventories:
if i.type_ != InventoryType.bundle_item:
get_tax_item(i.tax.id, i.tax.name, i.tax_amount, taxes_dict)
get_tax_item(i.tax.name, i.tax_amount, taxes_dict)
taxes_list = [(t, a) for t, a in taxes_dict.items() if a != 0]
bill_text = design_bill(voucher, regime_id, inventories, taxes_list, db)
bills.append(bill_text)
loop = asyncio.new_event_loop()
redis: ArqRedis = loop.run_until_complete(create_pool(redis_settings))
if len(bills) > 1:
total = design_total(voucher, regimes, db)
loop.run_until_complete(
redis.enqueue_job(
"sent_to_printer", total, printer.address, printer.cut_code, _queue_name=f"barker:print:{printer.name}"
)
)
enqueue_print_job_sync(printer.name, total, printer.cut_code)
for bill in bills:
loop.run_until_complete(
redis.enqueue_job(
"sent_to_printer", bill, printer.address, printer.cut_code, _queue_name=f"barker:print:{printer.name}"
)
)
loop.close()
enqueue_print_job_sync(printer.name, bill, printer.cut_code)
def get_inventories(voucher: Voucher) -> list[Inventory]:
@@ -98,12 +85,10 @@ def get_inventories(voucher: Voucher) -> list[Inventory]:
parent_id=i.parent_id,
type_=i.type_,
)
return list([item for item in items_dict.values() if item.quantity != 0])
return [item for item in items_dict.values() if item.quantity != 0]
def get_regimes(
inventories: list[Inventory], voucher_type: VoucherType, db: Session
) -> list[tuple[int, list[Inventory]]]:
def get_regimes(inventories: list[Inventory], voucher_type: VoucherType) -> list[tuple[int, list[Inventory]]]:
if voucher_type != VoucherType.REGULAR_BILL:
return [
(int(voucher_type), inventories),
@@ -120,14 +105,14 @@ def get_regimes(
return list(items_dict.items())
def get_tax_item(id_: uuid.UUID, tax_name: str, amount: Decimal, taxes: dict[str, Decimal]) -> None:
def get_tax_item(tax_name: str, amount: Decimal, taxes: dict[str, Decimal]) -> None:
items = tax_name.split(";")
if len(items) == 1:
if tax_name not in taxes:
taxes[tax_name] = Decimal(0)
taxes[tax_name] += round(amount, 2)
else:
for i, item in enumerate(it.strip() for it in items):
for _i, item in enumerate(it.strip() for it in items):
match = re.match(r"(^.*)\s+\((.*?)/(.*?)\)[^(]*$", item)
if not match or len(match.groups()) != 3:
raise Exception("Error in tax as it has multiple items, but the format is wrong.")
+3 -16
View File
@@ -1,14 +1,12 @@
import asyncio
import uuid
from datetime import datetime, timedelta
from arq import ArqRedis, create_pool
from sqlalchemy import select
from sqlalchemy.orm import Session
from ..core.arq import settings as redis_settings
from ..core.config import settings
from ..core.taskiq import enqueue_print_job_sync
from ..models.device import Device
from ..models.printer import Printer
from ..models.section_printer import SectionPrinter
@@ -27,19 +25,8 @@ def print_cashier_report(report: CashierReport, device_id: uuid.UUID, db: Sessio
.where(SectionPrinter.sale_category_id == None) # noqa: E711
).scalar_one()
loop = asyncio.new_event_loop()
redis: ArqRedis = loop.run_until_complete(create_pool(redis_settings))
loop.run_until_complete(
redis.enqueue_job(
"sent_to_printer", summary, printer.address, printer.cut_code, _queue_name=f"barker:print:{printer.name}"
)
)
loop.run_until_complete(
redis.enqueue_job(
"sent_to_printer", details, printer.address, printer.cut_code, _queue_name=f"barker:print:{printer.name}"
)
)
loop.close()
enqueue_print_job_sync(printer.name, summary, printer.cut_code)
enqueue_print_job_sync(printer.name, details, printer.cut_code)
def design_cashier_report_summary(report: CashierReport) -> str:
+2 -11
View File
@@ -1,11 +1,9 @@
import asyncio
import uuid
from arq import ArqRedis, create_pool
from sqlalchemy import select
from sqlalchemy.orm import Session
from ..core.arq import settings as redis_settings
from ..core.taskiq import enqueue_print_job_sync
from ..models.device import Device
from ..models.printer import Printer
from ..models.section_printer import SectionPrinter
@@ -23,14 +21,7 @@ def print_discount_report(report: DiscountReport, device_id: uuid.UUID, db: Sess
.where(SectionPrinter.sale_category_id == None) # noqa: E711
).scalar_one()
loop = asyncio.new_event_loop()
redis: ArqRedis = loop.run_until_complete(create_pool(redis_settings))
loop.run_until_complete(
redis.enqueue_job(
"sent_to_printer", data, printer.address, printer.cut_code, _queue_name=f"barker:print:{printer.name}"
)
)
loop.close()
enqueue_print_job_sync(printer.name, data, printer.cut_code)
def design_discount_report(report: DiscountReport) -> str:
+4 -17
View File
@@ -1,15 +1,13 @@
import asyncio
import uuid
from datetime import timedelta
from typing import Any
from arq import ArqRedis, create_pool
from sqlalchemy import Date, func, or_, select
from sqlalchemy.orm import Session
from ..core.arq import settings as redis_settings
from ..core.config import settings
from ..core.taskiq import enqueue_print_job_sync
from ..models.inventory import Inventory
from ..models.kot import Kot
from ..models.printer import Printer
@@ -19,7 +17,7 @@ from ..routers import _pv_onclause, _sv_onclause
from ..routers.voucher import get_voucher
def design_kot(voucher: Voucher, kot: Kot, items: list[Inventory], copy_number: int, db: Session) -> str:
def design_kot(voucher: Voucher, kot: Kot, items: list[Inventory], copy_number: int) -> str:
date_ = kot.date + timedelta(minutes=settings.TIMEZONE_OFFSET_MINUTES)
s = (
"KOT / BOT".center(42)
@@ -80,20 +78,9 @@ def print_kot(voucher_id: uuid.UUID, db: Session) -> None:
if key not in my_hash:
my_hash[key] = (printer, [])
my_hash[key][1].append(item)
loop = asyncio.new_event_loop()
redis: ArqRedis = loop.run_until_complete(create_pool(redis_settings))
for key, value in my_hash.items():
printer_id, copies = key
printer, items = value
for c in range(int(copies)):
data = design_kot(voucher, kot, items, c, db)
loop.run_until_complete(
redis.enqueue_job(
"sent_to_printer",
data,
printer.address,
printer.cut_code,
_queue_name=f"barker:print:{printer.name}",
)
)
loop.close()
data = design_kot(voucher, kot, items, c)
enqueue_print_job_sync(printer.name, data, printer.cut_code)
+2 -11
View File
@@ -1,14 +1,12 @@
import asyncio
import uuid
from datetime import datetime, timedelta
from arq import ArqRedis, create_pool
from sqlalchemy import select
from sqlalchemy.orm import Session
from ..core.arq import settings as redis_settings
from ..core.config import settings
from ..core.taskiq import enqueue_print_job_sync
from ..models.device import Device
from ..models.printer import Printer
from ..models.section_printer import SectionPrinter
@@ -25,14 +23,7 @@ def print_product_sale_report(report: ProductSaleReport, device_id: uuid.UUID, d
.where(SectionPrinter.sale_category_id == None) # noqa: E711
).scalar_one()
loop = asyncio.new_event_loop()
redis: ArqRedis = loop.run_until_complete(create_pool(redis_settings))
loop.run_until_complete(
redis.enqueue_job(
"sent_to_printer", data, printer.address, printer.cut_code, _queue_name=f"barker:print:{printer.name}"
)
)
loop.close()
enqueue_print_job_sync(printer.name, data, printer.cut_code)
def design_product_sale_report(report: ProductSaleReport) -> str:
+2 -11
View File
@@ -1,14 +1,12 @@
import asyncio
import uuid
from datetime import datetime, timedelta
from arq import ArqRedis, create_pool
from sqlalchemy import select
from sqlalchemy.orm import Session
from ..core.arq import settings as redis_settings
from ..core.config import settings
from ..core.taskiq import enqueue_print_job_sync
from ..models.device import Device
from ..models.printer import Printer
from ..models.section_printer import SectionPrinter
@@ -26,14 +24,7 @@ def print_sale_report(report: SaleReport, device_id: uuid.UUID, db: Session) ->
.where(SectionPrinter.sale_category_id == None) # noqa: E711
).scalar_one()
loop = asyncio.new_event_loop()
redis: ArqRedis = loop.run_until_complete(create_pool(redis_settings))
loop.run_until_complete(
redis.enqueue_job(
"sent_to_printer", data, printer.address, printer.cut_code, _queue_name=f"barker:print:{printer.name}"
)
)
loop.close()
enqueue_print_job_sync(printer.name, data, printer.cut_code)
def design_sale_report(report: SaleReport) -> str:
+3 -3
View File
@@ -12,11 +12,11 @@ from ..models.stock_keeping_unit import StockKeepingUnit
def query_date(d: str | None = None) -> date:
return date.today() if d is None else datetime.strptime(d, "%d-%b-%Y").date()
return date.today() if d is None else date.fromisoformat(d)
def optional_query_date(d: str | None = None) -> date | None:
return None if d is None else datetime.strptime(d, "%d-%b-%Y").date()
return None if d is None else date.fromisoformat(d)
def effective_date(d: str | None = None) -> date:
@@ -26,7 +26,7 @@ def effective_date(d: str | None = None) -> date:
+ timedelta(minutes=settings.TIMEZONE_OFFSET_MINUTES - settings.NEW_DAY_OFFSET_MINUTES)
).date()
if d is None
else datetime.strptime(d, "%d-%b-%Y").date()
else date.fromisoformat(d)
)
+432 -389
View File
@@ -1,22 +1,24 @@
import uuid
from collections import defaultdict
from collections.abc import Sequence
from datetime import date, timedelta
from decimal import Decimal
from typing import Annotated
from fastapi import APIRouter, Depends, HTTPException, Security, status
from sqlalchemy import Date, func, or_, select
from sqlalchemy.exc import SQLAlchemyError
from sqlalchemy.orm import contains_eager
from sqlalchemy import Date, Select, func, select
from sqlalchemy.orm import Session, contains_eager
from ..core.config import settings
from ..core.security import get_current_active_user as get_user
from ..db.session import SessionFuture
from ..db.session import SessionDep
from ..models.bundle_item import BundleItem as BundleItemModel
from ..models.inventory import Inventory
from ..models.kot import Kot
from ..models.product import Product
from ..models.product_version import ProductVersion
from ..models.sale_category import SaleCategory
from ..models.sku_version import SkuVersion
from ..models.stock_keeping_unit import StockKeepingUnit
from ..models.voucher import Voucher
@@ -24,20 +26,95 @@ from ..schemas import bundle as schemas
from ..schemas.menu_category import MenuCategoryLink
from ..schemas.sale_category import SaleCategoryLink
from ..schemas.user_token import UserToken
from . import _pv_onclause, _sv_onclause, effective_date
from . import _bundle_active, _pv_onclause, _sv_onclause, effective_date
from .product import query_product_info
router = APIRouter()
# Bundle-specific constants: bundles always have fraction=1, yield=1, cost=0.
FRACTION_1 = Decimal("1")
YIELD_1 = Decimal("1")
COST_0 = Decimal("0")
# ---------- helpers ----------
# Rounding precision for monetary amounts and quantities.
PRICE_PLACES = Decimal("0.01")
QTY_PLACES = Decimal("0.00001")
def bundle_blank() -> schemas.BundleBlank:
def _round_price(value: Decimal) -> Decimal:
return value.quantize(PRICE_PLACES)
def _round_qty(value: Decimal) -> Decimal:
return value.quantize(QTY_PLACES)
# ---------- query builders ----------
def _select_bundle_header(date_: date, sku_id: uuid.UUID | None = None) -> Select[tuple[SkuVersion]]:
"""Build the common query to load a bundle's SkuVersion with eagerly-loaded
SKU → Product → ProductVersion and MenuCategory chains.
When *sku_id* is ``None`` the query returns **all** active bundles (for the
list endpoint); otherwise it filters to a single bundle.
"""
stmt = (
select(SkuVersion)
.join(StockKeepingUnit, onclause=_sv_onclause(date_))
.join(StockKeepingUnit.product)
.join(ProductVersion, onclause=_pv_onclause(date_))
.join(SkuVersion.menu_category)
.where(StockKeepingUnit.is_bundle == True) # noqa: E712
.options(
contains_eager(SkuVersion.sku).contains_eager(StockKeepingUnit.product).contains_eager(Product.versions),
contains_eager(SkuVersion.menu_category),
)
)
if sku_id is not None:
stmt = stmt.where(StockKeepingUnit.id == sku_id)
return stmt
def _select_bundle_items(
date_: date,
bundle_sku_ids: Sequence[uuid.UUID],
) -> Select[tuple[BundleItemModel]]:
"""Build the common query to load active BundleItem rows for one or more
bundles, eagerly loading the child SKU's version chain (menu category,
product version, sale category, tax).
"""
return (
select(BundleItemModel)
.join(BundleItemModel.item)
.join(SkuVersion, onclause=_sv_onclause(date_))
.join(SkuVersion.menu_category)
.join(StockKeepingUnit.product)
.join(ProductVersion, onclause=_pv_onclause(date_))
.join(ProductVersion.sale_category)
.join(SaleCategory.tax)
.where(
BundleItemModel.bundle_id.in_(bundle_sku_ids),
_bundle_active(date_),
)
.options(
contains_eager(BundleItemModel.item)
.contains_eager(StockKeepingUnit.versions)
.contains_eager(SkuVersion.menu_category),
contains_eager(BundleItemModel.item)
.contains_eager(StockKeepingUnit.product)
.contains_eager(Product.versions)
.contains_eager(ProductVersion.sale_category)
.contains_eager(SaleCategory.tax),
)
)
# ---------- response helpers ----------
def _bundle_blank() -> schemas.BundleBlank:
return schemas.BundleBlank(
name="",
units="",
@@ -72,8 +149,7 @@ def _bundle_info(pv: ProductVersion, sv: SkuVersion, items: Sequence[BundleItemM
items=[
schemas.BundleItem(
id_=bi.id,
name=f"{bi.item.product.versions[0].name} ({bi.item.versions[0].units})",
item_id=bi.item_id,
sku=query_product_info(bi.item.versions[0], False, []),
sale_price=bi.sale_price,
quantity=bi.quantity,
print_in_bill=bi.print_in_bill,
@@ -83,53 +159,83 @@ def _bundle_info(pv: ProductVersion, sv: SkuVersion, items: Sequence[BundleItemM
)
# ---------- routes ----------
# ---------- temporal update helpers ----------
@router.post("", response_model=None)
def save(
def _compute_sale_price(items: list[schemas.BundleItem]) -> Decimal:
"""Derive the bundle sale price from its items: Σ(item_price × qty)."""
return _round_price(
sum(
(_round_price(it.sale_price) * _round_qty(it.quantity) for it in items),
start=Decimal(0),
)
)
def _update_product_version(
db: Session,
pv: ProductVersion,
date_: date,
data: schemas.BundleIn,
date_: date = Depends(effective_date),
user: UserToken = Security(get_user, scopes=["products"]),
) -> None:
try:
with SessionFuture() as db:
if not data.items:
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
detail="Not enough bundle items.",
)
sale_price = round(
sum((round(it.sale_price, 2) * round(it.quantity, 5) for it in data.items), start=Decimal(0)), 2
)
"""Temporally update the ProductVersion if name, sale_category, or
fraction_units changed. Edits in-place when *pv* was created today,
otherwise closes it and inserts a new version.
"""
pv_changed = (
pv.name != data.name or pv.sale_category_id != data.sale_category.id_ or pv.fraction_units != data.units
)
if not pv_changed:
return
product = Product(sort_order=data.sort_order)
db.add(product)
db.flush()
pv = ProductVersion(
product_id=None,
product=product,
if pv.valid_from == date_:
pv.name = data.name
pv.sale_category_id = data.sale_category.id_
pv.fraction_units = data.units
else:
pv.valid_till = date_ - timedelta(days=1)
db.add(
ProductVersion(
product_id=pv.product_id,
name=data.name,
fraction_units=data.units, # per your rule
fraction_units=data.units,
sale_category_id=data.sale_category.id_,
sale_category=None,
valid_from=date_,
valid_till=None,
)
db.add(pv)
)
sku = StockKeepingUnit(
product=product,
is_not_available=data.is_not_available,
is_bundle=True,
sort_order=data.sort_order,
)
db.add(sku)
db.flush()
sv = SkuVersion(
sku_id=sku.id,
def _update_sku_version(
db: Session,
sv: SkuVersion,
date_: date,
data: schemas.BundleIn,
sale_price: Decimal,
) -> None:
"""Temporally update the SkuVersion if units, sale_price, happy-hour, or
menu_category changed. Edits in-place when *sv* was created today,
otherwise closes it and inserts a new version.
"""
sv_changed = (
sv.units != data.units
or _round_price(Decimal(sv.sale_price)) != sale_price
or sv.has_happy_hour != data.has_happy_hour
or sv.menu_category_id != data.menu_category.id_
)
if not sv_changed:
return
if sv.valid_from == date_:
sv.units = data.units
sv.sale_price = sale_price
sv.has_happy_hour = data.has_happy_hour
sv.menu_category_id = data.menu_category.id_
else:
sv.valid_till = date_ - timedelta(days=1)
db.add(
SkuVersion(
sku_id=sv.sku_id,
units=data.units,
fraction=FRACTION_1,
product_yield=YIELD_1,
@@ -140,390 +246,327 @@ def save(
valid_from=date_,
valid_till=None,
)
db.add(sv)
)
# bundle items
for it in data.items:
db.add(
BundleItemModel(
bundle_id=sku.id,
item_id=it.item_id,
quantity=round(it.quantity, 5),
sale_price=round(it.sale_price, 2),
print_in_bill=it.print_in_bill,
valid_from=date_,
valid_till=None,
)
def _sync_bundle_items(
db: Session,
bundle_sku_id: uuid.UUID,
date_: date,
items: list[schemas.BundleItem],
) -> None:
"""Reconcile the incoming item list against the existing active rows.
For each existing row that is no longer in the incoming list, it is either
hard-deleted (if created today) or temporally closed. New items are
inserted and changed items are updated with the same temporal logic.
"""
existing = (
db.execute(
select(BundleItemModel).where(
BundleItemModel.bundle_id == bundle_sku_id,
_bundle_active(date_),
)
)
.scalars()
.all()
)
existing_by_id: dict[uuid.UUID, BundleItemModel] = {x.id: x for x in existing}
existing_by_item: dict[uuid.UUID, BundleItemModel] = {x.item_id: x for x in existing}
def _find_existing(it: schemas.BundleItem) -> BundleItemModel | None:
if it.id_ is not None:
return existing_by_id.get(it.id_)
return existing_by_item.get(it.sku.id_)
# Identify which existing rows are still matched by incoming items.
matched_ids: set[uuid.UUID] = set()
for it in items:
ex = _find_existing(it)
if ex is not None:
matched_ids.add(ex.id)
# Remove rows that are no longer in the incoming list.
for ex in existing:
if ex.id not in matched_ids:
if ex.valid_from == date_:
db.delete(ex)
else:
ex.valid_till = date_ - timedelta(days=1)
# Add new rows or update changed ones.
for it in items:
price = _round_price(it.sale_price)
qty = _round_qty(it.quantity)
ex = _find_existing(it)
if ex is None:
db.add(
BundleItemModel(
bundle_id=bundle_sku_id,
item_id=it.sku.id_,
sale_price=price,
quantity=qty,
print_in_bill=it.print_in_bill,
valid_from=date_,
valid_till=None,
)
)
continue
db.commit()
item_changed = (
ex.item_id != it.sku.id_
or _round_price(Decimal(ex.sale_price)) != price
or _round_qty(Decimal(ex.quantity)) != qty
or ex.print_in_bill != it.print_in_bill
)
if not item_changed:
continue
except SQLAlchemyError as e:
raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail=str(e))
if ex.valid_from == date_:
ex.item_id = it.sku.id_
ex.sale_price = price
ex.quantity = qty
ex.print_in_bill = it.print_in_bill
else:
ex.valid_till = date_ - timedelta(days=1)
db.add(
BundleItemModel(
bundle_id=bundle_sku_id,
item_id=it.sku.id_,
sale_price=price,
quantity=qty,
print_in_bill=it.print_in_bill,
valid_from=date_,
valid_till=None,
)
)
def _close_or_delete(db: Session, entity: ProductVersion | SkuVersion | BundleItemModel, date_: date) -> None:
"""Hard-delete if created today, otherwise temporally close."""
if entity.valid_from == date_:
db.delete(entity)
else:
entity.valid_till = date_ - timedelta(days=1)
# ---------- routes ----------
@router.post("", response_model=None)
def save(
data: schemas.BundleIn,
date_: Annotated[date, Depends(effective_date)],
user: Annotated[UserToken, Security(get_user, scopes=["products"])],
db: SessionDep,
) -> None:
if not data.items:
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
detail="Not enough bundle items.",
)
sale_price = _compute_sale_price(data.items)
product = Product(sort_order=data.sort_order)
db.add(product)
db.flush()
db.add(
ProductVersion(
product_id=product.id,
name=data.name,
fraction_units=data.units,
sale_category_id=data.sale_category.id_,
valid_from=date_,
valid_till=None,
)
)
sku = StockKeepingUnit(
product=product,
is_not_available=data.is_not_available,
is_bundle=True,
sort_order=data.sort_order,
)
db.add(sku)
db.flush()
db.add(
SkuVersion(
sku_id=sku.id,
units=data.units,
fraction=FRACTION_1,
product_yield=YIELD_1,
cost_price=COST_0,
sale_price=sale_price,
has_happy_hour=data.has_happy_hour,
menu_category_id=data.menu_category.id_,
valid_from=date_,
valid_till=None,
)
)
for it in data.items:
db.add(
BundleItemModel(
bundle_id=sku.id,
item_id=it.sku.id_,
quantity=_round_qty(it.quantity),
sale_price=_round_price(it.sale_price),
print_in_bill=it.print_in_bill,
valid_from=date_,
valid_till=None,
)
)
db.commit()
@router.put("/{id_}", response_model=None)
def update_route(
id_: uuid.UUID, # bundle header SKU id
id_: uuid.UUID,
data: schemas.BundleIn,
date_: date = Depends(effective_date),
user: UserToken = Security(get_user, scopes=["products"]),
date_: Annotated[date, Depends(effective_date)],
user: Annotated[UserToken, Security(get_user, scopes=["products"])],
db: SessionDep,
) -> None:
try:
with SessionFuture() as db:
if not data.items:
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
detail="Not enough bundle items.",
)
sale_price = round(
sum((round(it.sale_price, 2) * round(it.quantity, 5) for it in data.items), start=Decimal(0)), 2
)
# Load header SKU + active header SkuVersion + menu category + product + active ProductVersion
sv: SkuVersion | None = (
db.execute(
select(SkuVersion)
.join(StockKeepingUnit, onclause=_sv_onclause(date_))
.join(StockKeepingUnit.product)
.join(ProductVersion, onclause=_pv_onclause(date_))
.join(SkuVersion.menu_category)
.where(
StockKeepingUnit.id == id_,
StockKeepingUnit.is_bundle == True, # noqa: E712
)
.options(
contains_eager(SkuVersion.sku)
.contains_eager(StockKeepingUnit.product)
.contains_eager(Product.versions),
contains_eager(SkuVersion.menu_category),
)
)
.unique()
.scalars()
.one_or_none()
)
if not data.items:
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
detail="Not enough bundle items.",
)
sale_price = _compute_sale_price(data.items)
if sv is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Bundle not found.")
sv: SkuVersion | None = db.execute(_select_bundle_header(date_, sku_id=id_)).unique().scalars().one_or_none()
if sv is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Bundle not found.")
sku = sv.sku
product = sku.product
pv = product.versions[0]
pv_changed = pv.name != data.name or pv.sale_category_id != data.sale_category.id_
sku = sv.sku
product = sku.product
pv = product.versions[0]
sku.is_not_available = data.is_not_available
sku.sort_order = data.sort_order
product.sort_order = data.sort_order
# Always-mutable fields (no versioning needed).
sku.is_not_available = data.is_not_available
sku.sort_order = data.sort_order
product.sort_order = data.sort_order
if pv_changed:
if pv.valid_from == date_:
pv.name = data.name
pv.sale_category_id = data.sale_category.id_
else:
pv.valid_till = date_ - timedelta(days=1)
db.add(
ProductVersion(
product_id=product.id,
product=product,
name=data.name,
fraction_units=pv.fraction_units,
sale_category_id=data.sale_category.id_,
valid_from=date_,
valid_till=None,
)
)
sv_changed = (
sv.units != data.units
or Decimal(sv.sale_price).quantize(Decimal("0.01")) != sale_price
or sv.has_happy_hour != data.has_happy_hour
or sv.menu_category_id != data.menu_category.id_
)
if sv_changed:
if sv.valid_from == date_:
sv.units = data.units
sv.sale_price = sale_price
sv.has_happy_hour = data.has_happy_hour
sv.menu_category_id = data.menu_category.id_
_update_product_version(db, pv, date_, data)
_update_sku_version(db, sv, date_, data, sale_price)
_sync_bundle_items(db, sku.id, date_, data.items)
# # enforce bundle constants
# sv.fraction = FRACTION_1
# sv.product_yield = YIELD_1
# sv.cost_price = COST_0
else:
sv.valid_till = date_ - timedelta(days=1)
db.add(
SkuVersion(
sku_id=sku.id,
units=data.units,
fraction=FRACTION_1,
product_yield=YIELD_1,
cost_price=COST_0,
sale_price=sale_price,
has_happy_hour=data.has_happy_hour,
menu_category_id=data.menu_category.id_,
valid_from=date_,
valid_till=None,
)
)
# ---- Bundle items replace/update
existing = db.execute(select(BundleItemModel).where(BundleItemModel.bundle_id == sku.id)).scalars().all()
existing_by_item = {x.item_id: x for x in existing}
incoming_ids = {x.item_id for x in data.items}
existing_by_item = {x.item_id: x for x in existing}
incoming_ids = {x.item_id for x in data.items}
# delete removed
for ex_d in existing:
if ex_d.item_id not in incoming_ids:
if ex_d.valid_from == date_:
db.delete(ex_d)
else:
ex_d.valid_till = date_ - timedelta(days=1)
# add/update
for it in data.items:
ex = existing_by_item.get(it.item_id)
if ex is None:
db.add(
BundleItemModel(
bundle_id=sku.id,
item_id=it.item_id,
sale_price=round(it.sale_price, 2),
quantity=round(it.quantity, 5),
print_in_bill=it.print_in_bill,
valid_from=date_,
valid_till=None,
)
)
elif ex.valid_from == date_:
ex.sale_price = round(it.sale_price, 2)
ex.quantity = round(it.quantity, 5)
else:
ex.valid_till = date_ - timedelta(days=1)
db.add(
BundleItemModel(
bundle_id=sku.id,
item_id=it.item_id,
sale_price=round(it.sale_price, 2),
quantity=round(it.quantity, 5),
print_in_bill=it.print_in_bill,
valid_from=date_,
valid_till=None,
)
)
db.commit()
except SQLAlchemyError as e:
raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail=str(e))
db.commit()
@router.get("", response_model=schemas.BundleBlank)
def show_blank(
user: UserToken = Security(get_user, scopes=["products"]),
user: Annotated[UserToken, Security(get_user, scopes=["products"])],
) -> schemas.BundleBlank:
return bundle_blank()
return _bundle_blank()
@router.get("/list", response_model=list[schemas.Bundle])
def show_list(
date_: date = Depends(effective_date),
user: UserToken = Security(get_user, scopes=["products"]),
date_: Annotated[date, Depends(effective_date)],
user: Annotated[UserToken, Security(get_user, scopes=["products"])],
db: SessionDep,
) -> list[schemas.Bundle]:
with SessionFuture() as db:
sv_onclause = _sv_onclause(date_)
pv_onclause = _pv_onclause(date_)
rows: Sequence[SkuVersion] = (
db.execute(_select_bundle_header(date_).order_by(StockKeepingUnit.sort_order, SkuVersion.units))
.unique()
.scalars()
.all()
)
if not rows:
return []
rows = (
db.execute(
select(SkuVersion)
# .select_from(SkuVersion)
.join(StockKeepingUnit, onclause=sv_onclause)
.join(StockKeepingUnit.product)
.join(ProductVersion, onclause=pv_onclause)
.join(SkuVersion.menu_category)
.where(StockKeepingUnit.is_bundle == True) # noqa: E712
.options(
contains_eager(SkuVersion.sku).contains_eager(StockKeepingUnit.product),
contains_eager(SkuVersion.menu_category),
)
.order_by(StockKeepingUnit.sort_order, SkuVersion.units)
)
.unique()
.scalars()
.all()
)
# Batch-load all bundle items in a single query to avoid N+1.
all_sku_ids = [sv.sku_id for sv in rows]
all_items: Sequence[BundleItemModel] = db.execute(_select_bundle_items(date_, all_sku_ids)).unique().scalars().all()
items_by_bundle: dict[uuid.UUID, list[BundleItemModel]] = defaultdict(list)
for bi in all_items:
items_by_bundle[bi.bundle_id].append(bi)
out: list[schemas.Bundle] = []
for sv in rows:
pv = sv.sku.product.versions[0]
items = (
db.execute(
select(BundleItemModel)
.join(BundleItemModel.item)
.join(SkuVersion, onclause=sv_onclause)
.join(StockKeepingUnit.product)
.join(ProductVersion, onclause=pv_onclause)
.join(ProductVersion.sale_category)
.where(
BundleItemModel.bundle_id == sv.sku_id,
or_(BundleItemModel.valid_from == None, BundleItemModel.valid_from <= date_), # noqa: E711
or_(BundleItemModel.valid_till == None, BundleItemModel.valid_till >= date_), # noqa: E711
)
.options(
contains_eager(BundleItemModel.item).contains_eager(StockKeepingUnit.versions),
contains_eager(BundleItemModel.item)
.contains_eager(StockKeepingUnit.product)
.contains_eager(Product.versions)
.contains_eager(ProductVersion.sale_category),
)
)
.unique()
.scalars()
.all()
)
out.append(_bundle_info(pv=pv, sv=sv, items=items))
return out
return [_bundle_info(pv=sv.sku.product.versions[0], sv=sv, items=items_by_bundle.get(sv.sku_id, [])) for sv in rows]
@router.get("/{id_}", response_model=schemas.Bundle)
def show_id(
id_: uuid.UUID, # bundle header SKU id
date_: date = Depends(effective_date),
user: UserToken = Security(get_user, scopes=["products"]),
id_: uuid.UUID,
date_: Annotated[date, Depends(effective_date)],
user: Annotated[UserToken, Security(get_user, scopes=["products"])],
db: SessionDep,
) -> schemas.Bundle:
with SessionFuture() as db:
sv_onclause = _sv_onclause(date_)
pv_onclause = _pv_onclause(date_)
sv: SkuVersion | None = db.execute(_select_bundle_header(date_, sku_id=id_)).unique().scalar_one_or_none()
if sv is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Bundle not found.")
sv = (
db.execute(
select(SkuVersion)
.join(StockKeepingUnit, onclause=sv_onclause)
.join(StockKeepingUnit.product)
.join(ProductVersion, onclause=pv_onclause)
.join(SkuVersion.menu_category)
.where(
StockKeepingUnit.id == id_,
StockKeepingUnit.is_bundle == True, # noqa: E712
)
.options(
contains_eager(SkuVersion.sku).contains_eager(StockKeepingUnit.product),
contains_eager(SkuVersion.menu_category),
)
)
.unique()
.scalar_one_or_none()
)
if sv is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Bundle not found.")
pv = sv.sku.product.versions[0]
items = (
db.execute(
select(BundleItemModel)
.join(BundleItemModel.item)
.join(SkuVersion, onclause=sv_onclause)
.join(StockKeepingUnit.product)
.join(ProductVersion, onclause=pv_onclause)
.join(ProductVersion.sale_category)
.where(
BundleItemModel.bundle_id == sv.sku_id,
or_(BundleItemModel.valid_from == None, BundleItemModel.valid_from <= date_), # noqa: E711
or_(BundleItemModel.valid_till == None, BundleItemModel.valid_till >= date_), # noqa: E711
)
.options(
contains_eager(BundleItemModel.item).contains_eager(StockKeepingUnit.versions),
contains_eager(BundleItemModel.item)
.contains_eager(StockKeepingUnit.product)
.contains_eager(Product.versions)
.contains_eager(ProductVersion.sale_category),
)
)
.unique()
.scalars()
.all()
)
return _bundle_info(pv=pv, sv=sv, items=items)
items: Sequence[BundleItemModel] = db.execute(_select_bundle_items(date_, [sv.sku_id])).unique().scalars().all()
return _bundle_info(pv=sv.sku.product.versions[0], sv=sv, items=items)
@router.delete("/{id_}", response_model=None)
def delete_route(
id_: uuid.UUID, # bundle header SKU id
date_: date = Depends(effective_date),
user: UserToken = Security(get_user, scopes=["products"]),
id_: uuid.UUID,
date_: Annotated[date, Depends(effective_date)],
user: Annotated[UserToken, Security(get_user, scopes=["products"])],
db: SessionDep,
) -> None:
with SessionFuture() as db:
sv_onclause = _sv_onclause(date_)
pv_onclause = _pv_onclause(date_)
day = func.cast(
Voucher.date + timedelta(minutes=settings.TIMEZONE_OFFSET_MINUTES - settings.NEW_DAY_OFFSET_MINUTES), Date
).label("day")
billed = db.execute(
select(func.count(Inventory.id))
.join(Inventory.kot)
.join(Kot.voucher)
.where(Inventory.sku_id == id_, day >= date_)
).scalar_one()
if billed > 0:
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
detail="The cannot delete this product as it was billed",
)
sv = (
db.execute(
select(SkuVersion)
.join(StockKeepingUnit, onclause=sv_onclause)
.join(StockKeepingUnit.product)
.join(ProductVersion, onclause=pv_onclause)
.where(
StockKeepingUnit.id == id_,
StockKeepingUnit.is_bundle == True, # noqa: E712
)
.options(contains_eager(SkuVersion.sku).contains_eager(StockKeepingUnit.product))
)
.unique()
.scalar_one_or_none()
day = func.cast(
Voucher.date + timedelta(minutes=settings.TIMEZONE_OFFSET_MINUTES - settings.NEW_DAY_OFFSET_MINUTES), Date
).label("day")
billed = db.execute(
select(func.count(Inventory.id))
.join(Inventory.kot)
.join(Kot.voucher)
.where(Inventory.sku_id == id_, day >= date_)
).scalar_one()
if billed > 0:
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
detail="Cannot delete this product as it was billed.",
)
if sv is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Bundle not found.")
pv = sv.sku.product.versions[0]
# close/delete sku version
if sv.valid_from == date_:
db.delete(sv)
else:
sv.valid_till = date_ - timedelta(days=1)
# close/delete product version
if pv.valid_from == date_:
db.delete(pv)
else:
pv.valid_till = date_ - timedelta(days=1)
items = (
db.execute(
select(BundleItemModel).where(
BundleItemModel.bundle_id == id_,
or_(BundleItemModel.valid_from == None, BundleItemModel.valid_from <= date_), # noqa: E711
or_(BundleItemModel.valid_till == None, BundleItemModel.valid_till >= date_), # noqa: E711
)
# The delete query intentionally omits the menu_category join: it doesn't
# need menu category data and the bundle may not have one after versioning.
sv: SkuVersion | None = (
db.execute(
select(SkuVersion)
.join(StockKeepingUnit, onclause=_sv_onclause(date_))
.join(StockKeepingUnit.product)
.join(ProductVersion, onclause=_pv_onclause(date_))
.where(
StockKeepingUnit.id == id_,
StockKeepingUnit.is_bundle == True, # noqa: E712
)
.options(
contains_eager(SkuVersion.sku)
.contains_eager(StockKeepingUnit.product)
.contains_eager(Product.versions),
)
.scalars()
.all()
)
for bi in items:
if bi.valid_from == date_:
db.delete(bi)
else:
bi.valid_till = date_ - timedelta(days=1)
.unique()
.scalar_one_or_none()
)
if sv is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Bundle not found.")
db.commit()
pv = sv.sku.product.versions[0]
_close_or_delete(db, sv, date_)
_close_or_delete(db, pv, date_)
active_items: Sequence[BundleItemModel] = (
db.execute(
select(BundleItemModel).where(
BundleItemModel.bundle_id == id_,
_bundle_active(date_),
)
)
.scalars()
.all()
)
for bi in active_items:
_close_or_delete(db, bi, date_)
db.commit()
+66
View File
@@ -0,0 +1,66 @@
import logging
from typing import Any
import jwt
from fastapi import APIRouter, Request, status
from jwt import PyJWTError
from ..core.config import settings
from ..schemas.client_log import ClientErrorPayload
router = APIRouter()
logger = logging.getLogger("frontend.client")
def _extract_user_from_request(request: Request) -> str | None:
auth_header = request.headers.get("Authorization")
if not auth_header or not auth_header.startswith("Bearer "):
return None
token = auth_header.removeprefix("Bearer ").strip()
try:
payload = jwt.decode(token, settings.SECRET_KEY, algorithms=[settings.ALGORITHM])
user: Any = payload.get("sub")
return str(user) if user else None
except PyJWTError, Exception:
return None
@router.post("", status_code=status.HTTP_204_NO_CONTENT)
async def log_client_error(payload: ClientErrorPayload, request: Request) -> None:
user = _extract_user_from_request(request)
client_ip = request.client.host if request.client else "unknown"
user_agent = request.headers.get("user-agent", "unknown")
log_context = {
"url": payload.url,
"source": payload.source,
"status_code": payload.status_code,
"endpoint": payload.endpoint,
"component": payload.component,
"user": user or "anonymous",
"client_ip": client_ip,
"user_agent": user_agent,
"extra": payload.extra,
}
log_msg = (
f"Frontend [{payload.source}] {payload.message} | "
f"URL: {payload.url} | "
f"User: {user or 'anonymous'} | "
f"IP: {client_ip}"
)
if payload.endpoint:
log_msg += f" | Endpoint: {payload.endpoint} (Status: {payload.status_code})"
if payload.level == "warning":
logger.warning(log_msg, extra={"client_context": log_context})
elif payload.level == "info":
logger.info(log_msg, extra={"client_context": log_context})
else:
logger.error(
log_msg + (f"\nStack:\n{payload.stack}" if payload.stack else ""),
extra={"client_context": log_context},
)
+54 -74
View File
@@ -2,15 +2,14 @@ import uuid
from collections.abc import Sequence
from decimal import Decimal
from typing import Any
from typing import Annotated, Any
from fastapi import APIRouter, Depends, HTTPException, Security, status
from fastapi import APIRouter, Security
from sqlalchemy import asc, delete, desc, func, or_, select
from sqlalchemy.exc import SQLAlchemyError
from sqlalchemy.orm import InstrumentedAttribute, Session, contains_eager
from ..core.security import get_current_active_user as get_user
from ..db.session import SessionFuture
from ..db.session import SessionDep
from ..models.customer import Customer
from ..models.customer_discount import CustomerDiscount
from ..models.sale_category import SaleCategory
@@ -23,46 +22,32 @@ router = APIRouter()
@router.post("", response_model=schemas.Customer)
def save(
data: schemas.CustomerIn,
user: UserToken = Security(get_user, scopes=["customers"]),
data: schemas.CustomerIn, user: Annotated[UserToken, Security(get_user, scopes=["customers"])], db: SessionDep
) -> schemas.Customer:
try:
with SessionFuture() as db:
item = Customer(name=data.name, phone=data.phone, address=data.address, print_in_bill=data.print_in_bill)
db.add(item)
add_discounts(item, data.discounts, db)
db.commit()
sc = db.execute(select(SaleCategory).order_by(SaleCategory.name)).scalars().all()
return customer_info(item, sc)
except SQLAlchemyError as e:
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=str(e),
)
item = Customer(name=data.name, phone=data.phone, address=data.address, print_in_bill=data.print_in_bill)
db.add(item)
add_discounts(item, data.discounts, db)
db.commit()
sc = db.execute(select(SaleCategory).order_by(SaleCategory.name)).scalars().all()
return customer_info(item, sc)
@router.put("/{id_}", response_model=schemas.Customer)
def update_route(
id_: uuid.UUID,
data: schemas.CustomerIn,
user: UserToken = Security(get_user, scopes=["customers"]),
user: Annotated[UserToken, Security(get_user, scopes=["customers"])],
db: SessionDep,
) -> schemas.Customer:
try:
with SessionFuture() as db:
item: Customer = db.execute(select(Customer).where(Customer.id == id_)).scalar_one()
item.name = data.name
item.phone = data.phone
item.address = data.address
item.print_in_bill = data.print_in_bill
add_discounts(item, data.discounts, db)
db.commit()
sc = db.execute(select(SaleCategory).order_by(SaleCategory.name)).scalars().all()
return customer_info(item, sc)
except SQLAlchemyError as e:
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=str(e),
)
item: Customer = db.execute(select(Customer).where(Customer.id == id_)).scalar_one()
item.name = data.name
item.phone = data.phone
item.address = data.address
item.print_in_bill = data.print_in_bill
add_discounts(item, data.discounts, db)
db.commit()
sc = db.execute(select(SaleCategory).order_by(SaleCategory.name)).scalars().all()
return customer_info(item, sc)
def add_discounts(customer: Customer, discounts: list[schemas.CustomerDiscount], db: Session) -> None:
@@ -80,49 +65,46 @@ def add_discounts(customer: Customer, discounts: list[schemas.CustomerDiscount],
@router.delete("/{id_}")
def delete_route(
id_: uuid.UUID,
user: UserToken = Security(get_user, scopes=["customers"]),
id_: uuid.UUID, user: Annotated[UserToken, Security(get_user, scopes=["customers"])], db: SessionDep
) -> None:
with SessionFuture() as db:
db.execute(delete(CustomerDiscount).where(CustomerDiscount.customer_id == id_))
db.execute(delete(Customer).where(Customer.id == id_))
db.commit()
db.execute(delete(CustomerDiscount).where(CustomerDiscount.customer_id == id_))
db.execute(delete(Customer).where(Customer.id == id_))
db.commit()
@router.get("", response_model=schemas.CustomerBlank)
def show_blank(
user: UserToken = Security(get_user, scopes=["customers"]),
user: Annotated[UserToken, Security(get_user, scopes=["customers"])], db: SessionDep
) -> schemas.CustomerBlank:
with SessionFuture() as db:
sc = db.execute(select(SaleCategory).order_by(SaleCategory.name)).scalars().all()
return blank_customer_info(sc)
sc = db.execute(select(SaleCategory).order_by(SaleCategory.name)).scalars().all()
return blank_customer_info(sc)
@router.get("/list", response_model=schemas.PagedCustomers)
def show_list(
user: Annotated[UserToken, Security(get_user)],
db: SessionDep,
q: str | None = None,
p: int = 0, # Page number
s: int = 50, # Page size
f: str | None = None, # Sort field
d: str | None = None, # Sort direction
user: UserToken = Depends(get_user),
) -> schemas.PagedCustomers:
# Handle dynamic sorting safely
sort_field = ALLOWED_SORT_FIELDS.get(f or "name", Customer.name) # Default to name
sort_direction = desc if (d or "").lower() == "desc" else asc
filter = [] if q is None else [x.strip() for x in q.split()]
with SessionFuture() as db:
sc = db.execute(select(SaleCategory).order_by(SaleCategory.name)).scalars().all()
customers, total = customer_list(filter, p, s, sort_field, sort_direction, db)
return schemas.PagedCustomers(
items=[customer_info(item, sc) for item in customers],
page=p,
page_size=s,
total=total,
field=f,
sort_direction=d,
)
sc = db.execute(select(SaleCategory).order_by(SaleCategory.name)).scalars().all()
customers, total = customer_list(filter, p, s, sort_field, sort_direction, db)
return schemas.PagedCustomers(
items=[customer_info(item, sc) for item in customers],
page=p,
page_size=s,
total=total,
field=f,
sort_direction=d,
)
ALLOWED_SORT_FIELDS: dict[str, InstrumentedAttribute[Any]] = {
@@ -135,37 +117,37 @@ ALLOWED_SORT_FIELDS: dict[str, InstrumentedAttribute[Any]] = {
@router.get("/query", response_model=list[schemas.Customer])
def show_term(
user: Annotated[UserToken, Security(get_user)],
db: SessionDep,
q: str | None,
p: int = 0, # Page number
s: int = 25, # Page size
f: str | None = None, # Sort field
d: str | None = None, # Sort direction
current_user: UserToken = Depends(get_user),
f: str = "name", # Sort field
d: str = "asc", # Sort direction
) -> list[schemas.Customer]:
# Handle dynamic sorting safely
sort_field = ALLOWED_SORT_FIELDS.get(f or "name", Customer.name) # Default to name
sort_direction = desc if (d or "").lower() == "desc" else asc
filter = [] if q is None else [x.strip() for x in q.split()]
with SessionFuture() as db:
sc = db.execute(select(SaleCategory).order_by(SaleCategory.name)).scalars().all()
customers, _ = customer_list(filter, p, s, sort_field, sort_direction, db)
return [customer_info(item, sc) for item in customers]
sc = db.execute(select(SaleCategory).order_by(SaleCategory.name)).scalars().all()
customers, _ = customer_list(filter, p, s, sort_field, sort_direction, db)
return [customer_info(item, sc) for item in customers]
def customer_list(
filter: list[str],
page: int | None,
page_size: int | None,
sort_field: InstrumentedAttribute[Any] | None,
sort_direction: Any | None,
sort_field: InstrumentedAttribute[Any],
sort_direction: Any,
db: Session,
) -> tuple[Sequence[Customer], int]:
# Handle dynamic sorting safely
sort_field = sort_field or Customer.name # Default to name
sort_direction = sort_direction or asc
print(
print( # noqa: T201
f"Filter: {filter}, Page: {page}, Page Size: {page_size}, Sort Field: {sort_field}, Sort Direction: {sort_direction}"
)
id_query = select(Customer.id)
@@ -195,13 +177,11 @@ def customer_list(
@router.get("/{id_}", response_model=schemas.Customer)
def show_id(
id_: uuid.UUID,
user: UserToken = Security(get_user, scopes=["customers"]),
id_: uuid.UUID, user: Annotated[UserToken, Security(get_user, scopes=["customers"])], db: SessionDep
) -> schemas.Customer:
with SessionFuture() as db:
sc = db.execute(select(SaleCategory).order_by(SaleCategory.name)).scalars().all()
item: Customer = db.execute(select(Customer).where(Customer.id == id_)).scalar_one()
return customer_info(item, sc)
sc = db.execute(select(SaleCategory).order_by(SaleCategory.name)).scalars().all()
item: Customer = db.execute(select(Customer).where(Customer.id == id_)).scalar_one()
return customer_info(item, sc)
def customer_info(item: Customer, sale_categories: Sequence[SaleCategory]) -> schemas.Customer:
+7 -9
View File
@@ -1,13 +1,14 @@
import uuid
from decimal import Decimal
from typing import Annotated
from fastapi import APIRouter, Security
from sqlalchemy import select
from sqlalchemy.orm import Session
from ..core.security import get_current_active_user as get_user
from ..db.session import SessionFuture
from ..db.session import SessionDep
from ..models.customer import Customer
from ..models.db_setting import DbSetting
from ..models.sale_category import SaleCategory
@@ -20,20 +21,17 @@ router = APIRouter()
@router.get("", response_model=list[schemas.DiscountItem])
def show_blank(
user: UserToken = Security(get_user, scopes=["customers"]),
user: Annotated[UserToken, Security(get_user, scopes=["customers"])], db: SessionDep
) -> list[schemas.DiscountItem]:
with SessionFuture() as db:
return customer_discounts_blank(db)
return customer_discounts_blank(db)
@router.get("/{id_}", response_model=list[schemas.DiscountItem])
def show_id(
id_: uuid.UUID,
user: UserToken = Security(get_user, scopes=["customers"]),
id_: uuid.UUID, user: Annotated[UserToken, Security(get_user, scopes=["customers"])], db: SessionDep
) -> list[schemas.DiscountItem]:
with SessionFuture() as db:
item: Customer = db.execute(select(Customer).where(Customer.id == id_)).scalar_one()
return customer_discounts(item, db)
item: Customer = db.execute(select(Customer).where(Customer.id == id_)).scalar_one()
return customer_discounts(item, db)
def customer_discounts(item: Customer, db: Session) -> list[schemas.DiscountItem]:
+13 -16
View File
@@ -1,8 +1,10 @@
from typing import Annotated
from fastapi import APIRouter, Security
from sqlalchemy import select
from ..core.security import get_current_active_user as get_user
from ..db.session import SessionFuture
from ..db.session import SessionDep
from ..models.db_setting import DbSetting
from ..schemas.settings import PrefillCustomerDiscount
from ..schemas.user_token import UserToken
@@ -13,24 +15,19 @@ router = APIRouter()
@router.get("/prefill-customer-discount", response_model=PrefillCustomerDiscount)
def get_prefill_customer_discount(
user: UserToken = Security(get_user),
user: Annotated[UserToken, Security(get_user)], db: SessionDep
) -> PrefillCustomerDiscount:
with SessionFuture() as db:
value = (
db.execute(select(DbSetting).where(DbSetting.name == "Prefill Customer Discount"))
.scalar_one()
.data["Value"]
)
return PrefillCustomerDiscount(value=value)
value = (
db.execute(select(DbSetting).where(DbSetting.name == "Prefill Customer Discount")).scalar_one().data["Value"]
)
return PrefillCustomerDiscount(value=value)
@router.post("/prefill-customer-discount", response_model=PrefillCustomerDiscount)
def set_prefill_customer_discount(
data: PrefillCustomerDiscount,
user: UserToken = Security(get_user, scopes=["settings"]),
data: PrefillCustomerDiscount, user: Annotated[UserToken, Security(get_user, scopes=["settings"])], db: SessionDep
) -> PrefillCustomerDiscount:
with SessionFuture() as db:
item = db.execute(select(DbSetting).where(DbSetting.name == "Prefill Customer Discount")).scalar_one()
item.data = {"Value": data.value}
db.commit()
return PrefillCustomerDiscount(value=item.data["Value"])
item = db.execute(select(DbSetting).where(DbSetting.name == "Prefill Customer Discount")).scalar_one()
item.data = {"Value": data.value}
db.commit()
return PrefillCustomerDiscount(value=item.data["Value"])
+21 -37
View File
@@ -1,11 +1,12 @@
import uuid
from fastapi import APIRouter, Depends, HTTPException, Security, status
from typing import Annotated
from fastapi import APIRouter, Security
from sqlalchemy import delete, select
from sqlalchemy.exc import SQLAlchemyError
from ..core.security import get_current_active_user as get_user
from ..db.session import SessionFuture
from ..db.session import SessionDep
from ..models.device import Device
from ..models.login_history import LoginHistory
from ..schemas import device as schemas
@@ -20,54 +21,37 @@ router = APIRouter()
def update_route(
id_: uuid.UUID,
data: schemas.DeviceIn,
user: UserToken = Security(get_user, scopes=["devices"]),
user: Annotated[UserToken, Security(get_user, scopes=["devices"])],
db: SessionDep,
) -> schemas.Device:
try:
with SessionFuture() as db:
item: Device = db.execute(select(Device).where(Device.id == id_)).scalar_one()
item.name = data.name
item.enabled = data.enabled
item.section_id = data.section.id_
db.commit()
return device_info(item)
except SQLAlchemyError as e:
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=str(e),
)
item: Device = db.execute(select(Device).where(Device.id == id_)).scalar_one()
item.name = data.name
item.enabled = data.enabled
item.section_id = data.section.id_
db.commit()
return device_info(item)
@router.delete("/{id_}")
def delete_route(
id_: uuid.UUID,
user: UserToken = Security(get_user, scopes=["add-devices"]),
id_: uuid.UUID, user: Annotated[UserToken, Security(get_user, scopes=["add-devices"])], db: SessionDep
) -> None:
try:
with SessionFuture() as db:
db.execute(delete(LoginHistory).where(LoginHistory.device_id == id_))
db.execute(delete(Device).where(Device.id == id_))
db.commit()
except SQLAlchemyError as e:
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=str(e),
)
db.execute(delete(LoginHistory).where(LoginHistory.device_id == id_))
db.execute(delete(Device).where(Device.id == id_))
db.commit()
@router.get("/list", response_model=list[schemas.Device])
def show_list(user: UserToken = Depends(get_user)) -> list[schemas.Device]:
with SessionFuture() as db:
return [device_info(item) for item in db.execute(select(Device).order_by(Device.name)).scalars().all()]
def show_list(user: Annotated[UserToken, Security(get_user)], db: SessionDep) -> list[schemas.Device]:
return [device_info(item) for item in db.execute(select(Device).order_by(Device.name)).scalars().all()]
@router.get("/{id_}", response_model=schemas.Device)
def show_id(
id_: uuid.UUID,
user: UserToken = Security(get_user, scopes=["devices"]),
id_: uuid.UUID, user: Annotated[UserToken, Security(get_user, scopes=["devices"])], db: SessionDep
) -> schemas.Device:
with SessionFuture() as db:
item: Device = db.execute(select(Device).where(Device.id == id_)).scalar_one()
return device_info(item)
item: Device = db.execute(select(Device).where(Device.id == id_)).scalar_one()
return device_info(item)
def device_info(item: Device) -> schemas.Device:
+103 -133
View File
@@ -1,15 +1,15 @@
import uuid
from datetime import UTC, date, datetime, timedelta
from typing import Annotated
from fastapi import APIRouter, Depends, HTTPException, Security, status
from fastapi import APIRouter, HTTPException, Security, status
from sqlalchemy import and_, desc, func, or_, select
from sqlalchemy.exc import SQLAlchemyError
from sqlalchemy.orm import joinedload
from ..core.config import settings
from ..core.security import get_current_active_user as get_user
from ..db.session import SessionFuture
from ..db.session import SessionDep
from ..models.customer import Customer
from ..models.guest_book import GuestBook
from ..models.guest_book_type import GuestBookType
@@ -23,114 +23,90 @@ router = APIRouter()
@router.post("", response_model=schemas.GuestBook)
def save(
data: schemas.GuestBookIn,
user: UserToken = Security(get_user, scopes=["customers"]),
data: schemas.GuestBookIn, user: Annotated[UserToken, Security(get_user, scopes=["customers"])], db: SessionDep
) -> schemas.GuestBook:
try:
with SessionFuture() as db:
customer = db.execute(select(Customer).where(Customer.phone == data.phone)).scalars().one_or_none()
if customer is None:
if len(data.name) == 0:
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
detail="New customer name cannot be blank",
)
if len(data.phone) == 0:
data.phone = data.name
customer = Customer(
name=data.name,
phone=data.phone,
address=data.address,
print_in_bill=False,
)
db.add(customer)
else:
customer.name = data.name or customer.name
customer.address = data.address or customer.address
td = timedelta(minutes=settings.TIMEZONE_OFFSET_MINUTES)
booking_date = None if data.booking_date is None else data.booking_date - td
arrival_date = None if data.arrival_date is None else data.arrival_date - td
item = GuestBook(
pax=data.pax,
customer=customer,
booking_date=booking_date,
arrival_date=arrival_date,
notes=data.notes,
type_=data.type_,
customer = db.execute(select(Customer).where(Customer.phone == data.phone)).scalars().one_or_none()
if customer is None:
if len(data.name) == 0:
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
detail="New customer name cannot be blank",
)
db.add(item)
db.commit()
return guest_book_info(item)
except SQLAlchemyError as e:
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=str(e),
if len(data.phone) == 0:
data.phone = data.name
customer = Customer(
name=data.name,
phone=data.phone,
address=data.address,
print_in_bill=False,
)
db.add(customer)
else:
customer.name = data.name or customer.name
customer.address = data.address or customer.address
booking_date = data.booking_date.astimezone(UTC).replace(tzinfo=None) if data.booking_date else None
arrival_date = data.arrival_date.astimezone(UTC).replace(tzinfo=None) if data.arrival_date else None
item = GuestBook(
pax=data.pax,
customer=customer,
booking_date=booking_date,
arrival_date=arrival_date,
notes=data.notes,
type_=data.type_,
)
db.add(item)
db.commit()
return guest_book_info(item)
@router.put("/{id_}", response_model=schemas.GuestBook)
def update_route(
id_: uuid.UUID,
data: schemas.GuestBookIn,
user: UserToken = Security(get_user, scopes=["customers"]),
user: Annotated[UserToken, Security(get_user, scopes=["customers"])],
db: SessionDep,
) -> schemas.GuestBook:
try:
with SessionFuture() as db:
item: GuestBook = db.execute(select(GuestBook).where(GuestBook.id == id_)).scalar_one()
item.customer.name = data.name
item.customer.phone = data.phone
item.customer.address = data.address
item.pax = data.pax
td = timedelta(minutes=settings.TIMEZONE_OFFSET_MINUTES)
booking_date = None if data.booking_date is None else data.booking_date - td
arrival_date = None if data.arrival_date is None else data.arrival_date - td
item.booking_date = booking_date
item.arrival_date = arrival_date
item.last_edit_date = datetime.now()
item.notes = data.notes
item.type_ = data.type_
db.commit()
return guest_book_info(item)
except SQLAlchemyError as e:
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=str(e),
)
item: GuestBook = db.execute(select(GuestBook).where(GuestBook.id == id_)).scalar_one()
item.customer.name = data.name
item.customer.phone = data.phone
item.customer.address = data.address
item.pax = data.pax
item.booking_date = data.booking_date.astimezone(UTC).replace(tzinfo=None) if data.booking_date else None
item.arrival_date = data.arrival_date.astimezone(UTC).replace(tzinfo=None) if data.arrival_date else None
item.last_edit_date = datetime.now()
item.notes = data.notes
item.type_ = data.type_
db.commit()
return guest_book_info(item)
@router.delete("/{id_}", response_model=schemas.GuestBookIn)
def delete_route(
id_: uuid.UUID,
user: UserToken = Security(get_user, scopes=["customers"]),
id_: uuid.UUID, user: Annotated[UserToken, Security(get_user, scopes=["customers"])], db: SessionDep
) -> None:
try:
with SessionFuture() as db:
item: GuestBook = db.execute(select(GuestBook).where(GuestBook.id == id_)).scalar_one()
db.delete(item)
db.commit()
except SQLAlchemyError as e:
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=str(e),
)
item: GuestBook = db.execute(select(GuestBook).where(GuestBook.id == id_)).scalar_one()
db.delete(item)
db.commit()
@router.get("", response_model=schemas.GuestBookIn)
def show_blank(
t: GuestBookType | None,
user: UserToken = Security(get_user, scopes=["customers"]),
user: Annotated[UserToken, Security(get_user, scopes=["customers"])],
) -> schemas.GuestBookIn:
return blank_guest_book_info(t or GuestBookType.walk_in)
@router.get("/list", response_model=schemas.GuestBookList)
def show_list(
q: str | None = None,
user: UserToken = Depends(get_user),
user: Annotated[UserToken, Security(get_user)],
db: SessionDep,
q: date | None = None,
) -> schemas.GuestBookList:
d = date.today() if q is None or q == "" else datetime.strptime(q, "%d-%b-%Y")
s = d + timedelta(minutes=settings.NEW_DAY_OFFSET_MINUTES - settings.TIMEZONE_OFFSET_MINUTES)
f = d + timedelta(minutes=settings.NEW_DAY_OFFSET_MINUTES - settings.TIMEZONE_OFFSET_MINUTES, days=1)
d = date.today() if q is None else q
dt = datetime.combine(d, datetime.min.time())
s = dt + timedelta(minutes=settings.NEW_DAY_OFFSET_MINUTES - settings.TIMEZONE_OFFSET_MINUTES)
f = dt + timedelta(minutes=settings.NEW_DAY_OFFSET_MINUTES - settings.TIMEZONE_OFFSET_MINUTES, days=1)
list_ = (
select(GuestBook)
.where(
@@ -143,76 +119,70 @@ def show_list(
)
guest_book: list[schemas.GuestBookListItem] = []
with SessionFuture() as db:
count = 0
for i, item in enumerate(db.execute(list_).scalars().all()):
status = GuestBookType(item.type_.name) if item.status is None else GuestBookType(item.status.status.name)
if item.type_ != GuestBookType.booking:
count += item.pax
gbli = schemas.GuestBookListItem(
id_=item.id,
serial=i + 1,
name=item.customer.name,
phone=item.customer.phone,
pax=item.pax,
count=count,
booking_date=item.booking_date,
arrival_date=item.arrival_date,
last_edit_date=item.last_edit_date,
status=status,
table_id=None if item.status is None else item.status.food_table.id,
voucher_id=None if item.status is None else item.status.voucher_id,
table_name=None if item.status is None else item.status.food_table.name,
)
if item.status is None:
last = (
db.execute(
select(Voucher)
.where(Voucher.customer_id == item.customer_id)
.order_by(desc(Voucher.date))
.options(joinedload(Voucher.food_table, innerjoin=True))
)
.scalars()
.first()
count = 0
for i, item in enumerate(db.execute(list_).scalars().all()):
item_status = GuestBookType(item.type_.name) if item.status is None else GuestBookType(item.status.status.name)
if item.type_ != GuestBookType.booking:
count += item.pax
gbli = schemas.GuestBookListItem(
id_=item.id,
serial=i + 1,
name=item.customer.name,
phone=item.customer.phone,
pax=item.pax,
count=count,
booking_date=item.booking_date,
arrival_date=item.arrival_date,
last_edit_date=item.last_edit_date,
status=item_status,
table_id=None if item.status is None else item.status.food_table.id,
voucher_id=None if item.status is None else item.status.voucher_id,
table_name=None if item.status is None else item.status.food_table.name,
)
if item.status is None:
last = (
db.execute(
select(Voucher)
.where(Voucher.customer_id == item.customer_id)
.order_by(desc(Voucher.date))
.options(joinedload(Voucher.food_table, innerjoin=True))
)
if last is not None:
gbli.status = GuestBookType.old
gbli.voucher_id = last.id
gbli.table_name = last.food_table.name
guest_book.insert(0, gbli)
return schemas.GuestBookList(date_=d, list=guest_book)
.scalars()
.first()
)
if last is not None:
gbli.status = GuestBookType.old
gbli.voucher_id = last.id
gbli.table_name = last.food_table.name
guest_book.insert(0, gbli)
return schemas.GuestBookList(date_=d, list=guest_book)
@router.get("/{id_}", response_model=schemas.GuestBook)
def show_id(
id_: uuid.UUID,
user: UserToken = Security(get_user, scopes=["customers"]),
id_: uuid.UUID, user: Annotated[UserToken, Security(get_user, scopes=["customers"])], db: SessionDep
) -> schemas.GuestBook:
with SessionFuture() as db:
item: GuestBook = db.execute(select(GuestBook).where(GuestBook.id == id_)).scalar_one()
return guest_book_info(item)
item: GuestBook = db.execute(select(GuestBook).where(GuestBook.id == id_)).scalar_one()
return guest_book_info(item)
def guest_book_info(item: GuestBook) -> schemas.GuestBook:
td = timedelta(minutes=settings.TIMEZONE_OFFSET_MINUTES)
booking_date = None if item.booking_date is None else item.booking_date + td
arrival_date = None if item.arrival_date is None else item.arrival_date + td
return schemas.GuestBook(
id_=item.id,
name=item.customer.name,
phone=item.customer.phone,
pax=item.pax,
address=item.customer.address,
booking_date=booking_date,
arrival_date=arrival_date,
last_edit_date=item.last_edit_date + td,
booking_date=item.booking_date,
arrival_date=item.arrival_date,
last_edit_date=item.last_edit_date,
notes=item.notes,
type_=GuestBookType(item.type_.name),
)
def blank_guest_book_info(type_: GuestBookType) -> schemas.GuestBookIn:
now = datetime.now(UTC).replace(tzinfo=None) + timedelta(minutes=settings.TIMEZONE_OFFSET_MINUTES)
now = datetime.now()
booking_date = None if type_ != GuestBookType.booking else now
arrival_date = None if type_ != GuestBookType.walk_in else now
return schemas.GuestBookIn(
+19 -31
View File
@@ -1,9 +1,10 @@
from fastapi import APIRouter, HTTPException, Security, status
from typing import Annotated
from fastapi import APIRouter, Security
from sqlalchemy import select
from sqlalchemy.exc import SQLAlchemyError
from ..core.security import get_current_active_user as get_user
from ..db.session import SessionFuture
from ..db.session import SessionDep
from ..models.db_setting import DbSetting
from ..schemas.header_footer import HeaderFooter
from ..schemas.user_token import UserToken
@@ -13,36 +14,23 @@ router = APIRouter()
@router.get("", response_model=list[HeaderFooter])
def get(
user: UserToken = Security(get_user, scopes=["owner"]),
) -> list[HeaderFooter]:
with SessionFuture() as db:
return [
HeaderFooter(id_=item.id, name=item.name, text=item.data["Text"])
for item in db.execute(select(DbSetting).where(DbSetting.name.in_(["Header", "Footer"]))).scalars().all()
]
def get(user: Annotated[UserToken, Security(get_user, scopes=["owner"])], db: SessionDep) -> list[HeaderFooter]:
return [
HeaderFooter(id_=item.id, name=item.name, text=item.data["Text"])
for item in db.execute(select(DbSetting).where(DbSetting.name.in_(["Header", "Footer"]))).scalars().all()
]
@router.post("", response_model=list[HeaderFooter])
def save(
data: HeaderFooter,
user: UserToken = Security(get_user, scopes=["owner"]),
data: HeaderFooter, user: Annotated[UserToken, Security(get_user, scopes=["owner"])], db: SessionDep
) -> list[HeaderFooter]:
try:
with SessionFuture() as db:
item: DbSetting = db.execute(
select(DbSetting).where(DbSetting.id == data.id_, DbSetting.name == data.name)
).scalar_one()
item.data = {"Text": data.text}
db.commit()
return [
HeaderFooter(id_=item.id, name=item.name, text=item.data["Text"])
for item in db.execute(select(DbSetting).where(DbSetting.name.in_(["Header", "Footer"])))
.scalars()
.all()
]
except SQLAlchemyError as e:
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=str(e),
)
item: DbSetting = db.execute(
select(DbSetting).where(DbSetting.id == data.id_, DbSetting.name == data.name)
).scalar_one()
item.data = {"Text": data.text}
db.commit()
return [
HeaderFooter(id_=item.id, name=item.name, text=item.data["Text"])
for item in db.execute(select(DbSetting).where(DbSetting.name.in_(["Header", "Footer"]))).scalars().all()
]
+4 -5
View File
@@ -1,7 +1,7 @@
from fastapi import APIRouter, HTTPException
from sqlalchemy import text
from ..db.session import SessionFuture
from ..db.session import SessionDep
from ..schemas.health_response import HealthResponse
@@ -9,10 +9,9 @@ router = APIRouter()
@router.get("", response_model=HealthResponse)
def health_check() -> HealthResponse:
def health_check(db: SessionDep) -> HealthResponse:
try:
with SessionFuture() as db: # lightweight DB check
db.execute(text("SELECT 1"))
return HealthResponse(status="ok", database="connected")
db.execute(text("SELECT 1")) # lightweight DB check
return HealthResponse(status="ok", database="connected")
except Exception as e:
raise HTTPException(status_code=500, detail={"status": "error", "database": str(e)}) from e
+68 -41
View File
@@ -1,6 +1,7 @@
import uuid
from datetime import timedelta
from typing import Annotated
from fastapi import (
APIRouter,
@@ -13,6 +14,8 @@ from fastapi import (
)
from fastapi.responses import JSONResponse
from fastapi.security import OAuth2PasswordRequestForm
from sqlalchemy import select
from sqlalchemy.orm import Session
from .. import __version__
from ..core.config import settings
@@ -23,60 +26,84 @@ from ..core.security import (
device_allowed,
get_current_active_user,
)
from ..db.session import SessionFuture
from ..db.session import SessionDep
from ..models.login_history import LoginHistory
from ..models.permission import Permission
from ..models.role_includes import RoleInclude
from ..models.role_permission import RolePermission
from ..models.user_role import UserRole
from ..schemas.user_token import UserToken
router = APIRouter()
def user_effective_permission_scopes(user_id: uuid.UUID, db: Session) -> list[str]:
"""
Returns the effective permission scope strings for a user, considering:
user_roles -> role_includes (recursive) -> role_permissions -> permissions
"""
# Start roles = user's direct roles
roles = select(UserRole.role_id.label("role_id")).where(UserRole.user_id == user_id).cte("roles", recursive=True)
roles = roles.union_all(select(RoleInclude.included_role_id).where(RoleInclude.role_id == roles.c.role_id))
q = (
select(Permission.name)
.join(RolePermission, RolePermission.permission_id == Permission.id)
.join(roles, roles.c.role_id == RolePermission.role_id)
.distinct()
)
names = list(db.execute(q).scalars().all())
# match your existing scope normalization
return sorted({n.replace(" ", "-").lower() for n in names})
@router.post("/token", response_model=Token)
def login_for_access_token(
response: Response,
form_data: OAuth2PasswordRequestForm = Depends(),
form_data: Annotated[OAuth2PasswordRequestForm, Depends()],
db: SessionDep,
device_id: uuid.UUID | None = Cookie(None),
) -> Token | JSONResponse:
with SessionFuture() as db:
user = authenticate_user(form_data.username.strip(), form_data.password.strip(), db)
if not user:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Incorrect username or password",
headers={"WWW-Authenticate": "Bearer"},
)
allowed, device = device_allowed(user, device_id, db)
db.flush()
if allowed:
history = LoginHistory(user.id, device.id)
db.add(history)
db.commit()
response.set_cookie(key="device_id", value=str(device.id), max_age=10 * 365 * 24 * 60 * 60)
response.set_cookie(key="device", value=device.name, max_age=10 * 365 * 24 * 60 * 60)
response.set_cookie(key="section", value=device.section.name, max_age=10 * 365 * 24 * 60 * 60)
if not allowed:
not_allowed_response = JSONResponse(
status_code=status.HTTP_401_UNAUTHORIZED,
headers={"WWW-Authenticate": "Bearer"},
content={"detail": "Device is not registered"},
)
not_allowed_response.set_cookie(key="device_id", value=str(device.id), max_age=10 * 365 * 24 * 60 * 60)
not_allowed_response.set_cookie(key="device", value=device.name, max_age=10 * 365 * 24 * 60 * 60)
not_allowed_response.set_cookie(key="section", value=device.section.name, max_age=10 * 365 * 24 * 60 * 60)
return not_allowed_response
access_token_expires = timedelta(minutes=settings.JWT_TOKEN_EXPIRE_MINUTES)
access_token = create_access_token(
data={
"sub": user.name,
"scopes": ["authenticated"]
+ list(set([p.name.replace(" ", "-").lower() for r in user.roles for p in r.permissions])),
"userId": str(user.id),
"lockedOut": user.locked_out,
"ver": __version__.__version__,
},
expires_delta=access_token_expires,
user = authenticate_user(form_data.username.strip(), form_data.password.strip(), db)
if not user:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Incorrect username or password",
headers={"WWW-Authenticate": "Bearer"},
)
return Token(access_token=access_token, token_type="bearer")
allowed, device = device_allowed(user, device_id, db)
db.flush()
if allowed:
history = LoginHistory(user.id, device.id)
db.add(history)
db.commit()
response.set_cookie(key="device_id", value=str(device.id), max_age=10 * 365 * 24 * 60 * 60)
response.set_cookie(key="device", value=device.name, max_age=10 * 365 * 24 * 60 * 60)
response.set_cookie(key="section", value=device.section.name, max_age=10 * 365 * 24 * 60 * 60)
if not allowed:
not_allowed_response = JSONResponse(
status_code=status.HTTP_401_UNAUTHORIZED,
headers={"WWW-Authenticate": "Bearer"},
content={"detail": "Device is not registered"},
)
not_allowed_response.set_cookie(key="device_id", value=str(device.id), max_age=10 * 365 * 24 * 60 * 60)
not_allowed_response.set_cookie(key="device", value=device.name, max_age=10 * 365 * 24 * 60 * 60)
not_allowed_response.set_cookie(key="section", value=device.section.name, max_age=10 * 365 * 24 * 60 * 60)
return not_allowed_response
access_token_expires = timedelta(minutes=settings.JWT_TOKEN_EXPIRE_MINUTES)
perm_scopes = user_effective_permission_scopes(user.id, db)
access_token = create_access_token(
data={
"sub": user.name,
"scopes": ["authenticated"] + perm_scopes,
"userId": str(user.id),
"lockedOut": user.locked_out,
"ver": __version__.__version__,
},
expires_delta=access_token_expires,
)
return Token(access_token=access_token, token_type="bearer")
@router.post("/refresh", response_model=Token)
+58 -89
View File
@@ -1,19 +1,18 @@
import uuid
from datetime import date
from operator import and_, or_
from typing import Annotated
from fastapi import APIRouter, Depends, HTTPException, Security, status
from sqlalchemy import distinct, select, update
from sqlalchemy.exc import SQLAlchemyError
from sqlalchemy import distinct, func, select, update
from ..core.security import get_current_active_user as get_user
from ..db.session import SessionFuture
from ..db.session import SessionDep
from ..models.menu_category import MenuCategory
from ..models.sku_version import SkuVersion
from ..schemas import menu_category as schemas
from ..schemas.user_token import UserToken
from . import effective_date
from . import _sv_active, effective_date
router = APIRouter()
@@ -22,114 +21,87 @@ router = APIRouter()
@router.post("/list", response_model=list[schemas.MenuCategory])
def sort_order(
data: list[schemas.MenuCategory],
user: UserToken = Security(get_user, scopes=["sections"]),
user: Annotated[UserToken, Security(get_user, scopes=["sections"])],
db: SessionDep,
) -> list[schemas.MenuCategory]:
try:
with SessionFuture() as db:
for index, item in enumerate(data):
db.execute(update(MenuCategory).where(MenuCategory.id == item.id_).values(sort_order=index))
db.commit()
query = select(MenuCategory).order_by(MenuCategory.sort_order).order_by(MenuCategory.name)
return [menu_category_info(item) for item in db.execute(query).scalars().all()]
except SQLAlchemyError as e:
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=str(e),
)
for index, item in enumerate(data):
db.execute(update(MenuCategory).where(MenuCategory.id == item.id_).values(sort_order=index))
db.commit()
query = select(MenuCategory).order_by(MenuCategory.sort_order).order_by(MenuCategory.name)
return [menu_category_info(item) for item in db.execute(query).scalars().all()]
@router.post("", response_model=schemas.MenuCategory)
def save(
data: schemas.MenuCategoryIn,
user: UserToken = Security(get_user, scopes=["products"]),
data: schemas.MenuCategoryIn, user: Annotated[UserToken, Security(get_user, scopes=["products"])], db: SessionDep
) -> schemas.MenuCategory:
try:
with SessionFuture() as db:
item = MenuCategory(
name=data.name,
is_active=data.is_active,
sort_order=0,
)
db.add(item)
db.commit()
return menu_category_info(item)
except SQLAlchemyError as e:
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=str(e),
)
item = MenuCategory(
name=data.name,
is_active=data.is_active,
sort_order=0,
)
db.add(item)
db.commit()
return menu_category_info(item)
@router.put("/{id_}", response_model=schemas.MenuCategory)
def update_route(
id_: uuid.UUID,
data: schemas.MenuCategoryIn,
user: UserToken = Security(get_user, scopes=["products"]),
user: Annotated[UserToken, Security(get_user, scopes=["products"])],
db: SessionDep,
) -> schemas.MenuCategory:
try:
with SessionFuture() as db:
item: MenuCategory = db.execute(select(MenuCategory).where(MenuCategory.id == id_)).scalar_one()
if item.is_fixture:
raise HTTPException(
status_code=status.HTTP_423_LOCKED,
detail=f"{item.name} is a fixture and cannot be edited or deleted.",
)
item.name = data.name
item.is_active = data.is_active
db.commit()
return menu_category_info(item)
except SQLAlchemyError as e:
item: MenuCategory = db.execute(select(MenuCategory).where(MenuCategory.id == id_)).scalar_one()
if item.is_fixture:
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=str(e),
status_code=status.HTTP_423_LOCKED,
detail=f"{item.name} is a fixture and cannot be edited or deleted.",
)
item.name = data.name
item.is_active = data.is_active
db.commit()
return menu_category_info(item)
@router.delete("/{id_}", response_model=schemas.MenuCategoryBlank)
@router.delete("/{id_}", response_model=None)
def delete_route(
id_: uuid.UUID,
user: UserToken = Security(get_user, scopes=["products"]),
) -> schemas.MenuCategoryBlank:
with SessionFuture() as db:
item: MenuCategory = db.execute(select(MenuCategory).where(MenuCategory.id == id_)).scalar_one()
if item.is_fixture:
raise HTTPException(
status_code=status.HTTP_423_LOCKED,
detail=f"{item.name} is a fixture and cannot be edited or deleted.",
)
else:
raise HTTPException(
status_code=status.HTTP_501_NOT_IMPLEMENTED,
detail="Menu Category deletion not implemented",
)
id_: uuid.UUID, user: Annotated[UserToken, Security(get_user, scopes=["products"])], db: SessionDep
) -> None:
item: MenuCategory = db.execute(select(MenuCategory).where(MenuCategory.id == id_)).scalar_one()
if item.is_fixture:
raise HTTPException(
status_code=status.HTTP_423_LOCKED,
detail=f"{item.name} is a fixture and cannot be edited or deleted.",
)
product_count = db.execute(
select(func.count(distinct(SkuVersion.sku_id))).where(SkuVersion.menu_category_id == id_)
).scalar_one()
if product_count > 0:
raise HTTPException(
status_code=status.HTTP_501_NOT_IMPLEMENTED,
detail="Menu Category has products and cannot be deleted.",
)
db.delete(item)
db.commit()
@router.get("", response_model=schemas.MenuCategoryBlank)
def show_blank(
user: UserToken = Security(get_user, scopes=["products"]),
user: Annotated[UserToken, Security(get_user, scopes=["products"])],
) -> schemas.MenuCategoryBlank:
return menu_category_blank()
@router.get("/list", response_model=list[schemas.MenuCategory])
def show_list(
date_: Annotated[date, Depends(effective_date)],
user: Annotated[UserToken, Security(get_user)],
db: SessionDep,
p: bool = False,
date_: date = Depends(effective_date),
user: UserToken = Depends(get_user),
) -> list[schemas.MenuCategory]:
if p:
sq = select(distinct(SkuVersion.menu_category_id)).where(
and_(
or_(
SkuVersion.valid_from == None, # noqa: E711
SkuVersion.valid_from <= date_,
),
or_(
SkuVersion.valid_till == None, # noqa: E711
SkuVersion.valid_till >= date_,
),
)
)
sq = select(distinct(SkuVersion.menu_category_id)).where(_sv_active(date_))
query = (
select(MenuCategory)
.where(MenuCategory.id.in_(sq), MenuCategory.is_active == True) # noqa: E712
@@ -138,18 +110,15 @@ def show_list(
)
else:
query = select(MenuCategory).order_by(MenuCategory.sort_order).order_by(MenuCategory.name)
with SessionFuture() as db:
return [menu_category_info(item) for item in db.execute(query).scalars().all()]
return [menu_category_info(item) for item in db.execute(query).scalars().all()]
@router.get("/{id_}", response_model=schemas.MenuCategory)
def show_id(
id_: uuid.UUID,
user: UserToken = Security(get_user, scopes=["products"]),
id_: uuid.UUID, user: Annotated[UserToken, Security(get_user, scopes=["products"])], db: SessionDep
) -> schemas.MenuCategory:
with SessionFuture() as db:
item: MenuCategory = db.execute(select(MenuCategory).where(MenuCategory.id == id_)).scalar_one()
return menu_category_info(item)
item: MenuCategory = db.execute(select(MenuCategory).where(MenuCategory.id == id_)).scalar_one()
return menu_category_info(item)
def menu_category_info(item: MenuCategory) -> schemas.MenuCategory:
+35 -60
View File
@@ -1,13 +1,13 @@
import uuid
from decimal import Decimal
from typing import Annotated
from fastapi import APIRouter, Depends, HTTPException, Security, status
from fastapi import APIRouter, Security
from sqlalchemy import select
from sqlalchemy.exc import SQLAlchemyError
from ..core.security import get_current_active_user as get_user
from ..db.session import SessionFuture
from ..db.session import SessionDep
from ..models.modifier import Modifier
from ..schemas import modifier as schemas
from ..schemas.modifier_category import ModifierCategoryLink
@@ -19,90 +19,65 @@ router = APIRouter()
@router.post("", response_model=schemas.Modifier)
def save(
data: schemas.ModifierIn,
user: UserToken = Security(get_user, scopes=["modifiers"]),
data: schemas.ModifierIn, user: Annotated[UserToken, Security(get_user, scopes=["modifiers"])], db: SessionDep
) -> schemas.Modifier:
try:
with SessionFuture() as db:
item = Modifier(
name=data.name,
show_in_bill=data.show_in_bill,
price=data.price,
modifier_category_id=data.modifier_category.id_,
is_active=data.is_active,
)
db.add(item)
db.commit()
return modifier_info(item)
except SQLAlchemyError as e:
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=str(e),
)
item = Modifier(
name=data.name,
show_in_bill=data.show_in_bill,
price=data.price,
modifier_category_id=data.modifier_category.id_,
is_active=data.is_active,
)
db.add(item)
db.commit()
return modifier_info(item)
@router.put("/{id_}", response_model=schemas.Modifier)
def update_route(
id_: uuid.UUID,
data: schemas.ModifierIn,
user: UserToken = Security(get_user, scopes=["modifiers"]),
user: Annotated[UserToken, Security(get_user, scopes=["modifiers"])],
db: SessionDep,
) -> schemas.Modifier:
try:
with SessionFuture() as db:
item: Modifier = db.execute(select(Modifier).where(Modifier.id == id_)).scalar_one()
item.name = data.name
item.show_in_bill = data.show_in_bill
item.price = data.price
item.modifier_category_id = data.modifier_category.id_
item.is_active = data.is_active
db.commit()
return modifier_info(item)
except SQLAlchemyError as e:
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=str(e),
)
item: Modifier = db.execute(select(Modifier).where(Modifier.id == id_)).scalar_one()
item.name = data.name
item.show_in_bill = data.show_in_bill
item.price = data.price
item.modifier_category_id = data.modifier_category.id_
item.is_active = data.is_active
db.commit()
return modifier_info(item)
@router.delete("/{id_}", response_model=schemas.ModifierBlank)
def delete_route(
id_: uuid.UUID,
user: UserToken = Security(get_user, scopes=["modifiers"]),
id_: uuid.UUID, user: Annotated[UserToken, Security(get_user, scopes=["modifiers"])], db: SessionDep
) -> schemas.ModifierBlank:
try:
with SessionFuture() as db:
item: Modifier = db.execute(select(Modifier).where(Modifier.id == id_)).scalar_one()
db.delete(item)
db.commit()
return modifier_blank()
except SQLAlchemyError as e:
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=str(e),
)
item: Modifier = db.execute(select(Modifier).where(Modifier.id == id_)).scalar_one()
db.delete(item)
db.commit()
return modifier_blank()
@router.get("", response_model=schemas.ModifierBlank)
def show_blank(
user: UserToken = Security(get_user, scopes=["modifiers"]),
user: Annotated[UserToken, Security(get_user, scopes=["modifiers"])],
) -> schemas.ModifierBlank:
return modifier_blank()
@router.get("/list", response_model=list[schemas.Modifier])
def show_list(user: UserToken = Depends(get_user)) -> list[schemas.Modifier]:
with SessionFuture() as db:
return [modifier_info(item) for item in db.execute(select(Modifier).order_by(Modifier.name)).scalars().all()]
def show_list(user: Annotated[UserToken, Security(get_user)], db: SessionDep) -> list[schemas.Modifier]:
return [modifier_info(item) for item in db.execute(select(Modifier).order_by(Modifier.name)).scalars().all()]
@router.get("/{id_}", response_model=schemas.Modifier)
def show_id(
id_: uuid.UUID,
user: UserToken = Security(get_user, scopes=["modifiers"]),
id_: uuid.UUID, user: Annotated[UserToken, Security(get_user, scopes=["modifiers"])], db: SessionDep
) -> schemas.Modifier:
with SessionFuture() as db:
item: Modifier = db.execute(select(Modifier).where(Modifier.id == id_)).scalar_one()
return modifier_info(item)
item: Modifier = db.execute(select(Modifier).where(Modifier.id == id_)).scalar_one()
return modifier_info(item)
def modifier_info(item: Modifier) -> schemas.Modifier:
+126 -195
View File
@@ -2,14 +2,14 @@ import uuid
from datetime import date
from functools import reduce
from typing import Annotated
from fastapi import APIRouter, Depends, HTTPException, Security, status
from fastapi import APIRouter, Depends, Security
from sqlalchemy import and_, or_, select
from sqlalchemy.exc import SQLAlchemyError
from sqlalchemy.orm import Session, contains_eager
from ..core.security import get_current_active_user as get_user
from ..db.session import SessionFuture
from ..db.session import SessionDep
from ..models.menu_category import MenuCategory
from ..models.modifier_category import ModifierCategory
from ..models.product import Product
@@ -23,7 +23,7 @@ from ..schemas.modifier_category_for_product import ModifierCategoryForProduct
from ..schemas.product_link import ProductLink
from ..schemas.product_link import ProductLink as ProductLinkSchema
from ..schemas.user_token import UserToken
from . import effective_date
from . import _pv_onclause, _sv_onclause, effective_date
router = APIRouter()
@@ -32,220 +32,167 @@ router = APIRouter()
@router.post("", response_model=schemas.ModifierCategory)
def save(
data: schemas.ModifierCategoryIn,
date_: date = Depends(effective_date),
user: UserToken = Security(get_user, scopes=["modifiers"]),
date_: Annotated[date, Depends(effective_date)],
user: Annotated[UserToken, Security(get_user, scopes=["modifiers"])],
db: SessionDep,
) -> schemas.ModifierCategory:
try:
with SessionFuture() as db:
item = ModifierCategory(
name=data.name,
minimum=data.minimum,
maximum=None if data.maximum == 0 else data.maximum,
is_active=data.is_active,
)
db.add(item)
add_skus(item, data.menu_categories, db)
db.commit()
return modifier_category_info(item, date_, db=db)
except SQLAlchemyError as e:
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=str(e),
)
item = ModifierCategory(
name=data.name,
minimum=data.minimum,
maximum=None if data.maximum == 0 else data.maximum,
is_active=data.is_active,
)
db.add(item)
add_skus(item, data.menu_categories, db)
db.commit()
return modifier_category_info(item, date_, db=db)
@router.put("/{id_}", response_model=schemas.ModifierCategory)
def update_route(
id_: uuid.UUID,
data: schemas.ModifierCategoryIn,
date_: date = Depends(effective_date),
user: UserToken = Security(get_user, scopes=["modifiers"]),
date_: Annotated[date, Depends(effective_date)],
user: Annotated[UserToken, Security(get_user, scopes=["modifiers"])],
db: SessionDep,
) -> schemas.ModifierCategory:
try:
with SessionFuture() as db:
item: ModifierCategory = db.execute(select(ModifierCategory).where(ModifierCategory.id == id_)).scalar_one()
item.name = data.name
item.minimum = data.minimum
item.maximum = None if data.maximum == 0 else data.maximum
item.is_active = data.is_active
add_skus(item, data.menu_categories, db)
db.commit()
return modifier_category_info(item, date_, db=db)
except SQLAlchemyError as e:
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=str(e),
)
item: ModifierCategory = db.execute(select(ModifierCategory).where(ModifierCategory.id == id_)).scalar_one()
item.name = data.name
item.minimum = data.minimum
item.maximum = None if data.maximum == 0 else data.maximum
item.is_active = data.is_active
add_skus(item, data.menu_categories, db)
db.commit()
return modifier_category_info(item, date_, db=db)
@router.delete("/{id_}", response_model=schemas.ModifierCategoryBlank)
def delete_route(
id_: uuid.UUID,
date_: date = Depends(effective_date),
user: UserToken = Security(get_user, scopes=["modifiers"]),
date_: Annotated[date, Depends(effective_date)],
user: Annotated[UserToken, Security(get_user, scopes=["modifiers"])],
db: SessionDep,
) -> schemas.ModifierCategoryBlank:
try:
with SessionFuture() as db:
item: ModifierCategory = db.execute(select(ModifierCategory).where(ModifierCategory.id == id_)).scalar_one()
db.delete(item)
db.commit()
return modifier_category_blank(date_, db=db)
except SQLAlchemyError as e:
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=str(e),
)
item: ModifierCategory = db.execute(select(ModifierCategory).where(ModifierCategory.id == id_)).scalar_one()
db.delete(item)
db.commit()
return modifier_category_blank(date_, db=db)
@router.get("", response_model=schemas.ModifierCategoryBlank)
def show_blank(
date_: date = Depends(effective_date),
user: UserToken = Security(get_user, scopes=["modifiers"]),
date_: Annotated[date, Depends(effective_date)],
user: Annotated[UserToken, Security(get_user, scopes=["modifiers"])],
db: SessionDep,
) -> schemas.ModifierCategoryBlank:
with SessionFuture() as db:
return modifier_category_blank(date_, db=db)
return modifier_category_blank(date_, db=db)
@router.get("/list")
def show_list(
date_: date = Depends(effective_date), user: UserToken = Depends(get_user)
date_: Annotated[date, Depends(effective_date)], user: Annotated[UserToken, Security(get_user)], db: SessionDep
) -> list[schemas.ModifierCategory]:
with SessionFuture() as db:
product_version_onclause = and_(
ProductVersion.product_id == Product.id,
or_(
ProductVersion.valid_from == None, # noqa: E711
ProductVersion.valid_from <= date_,
),
or_(
ProductVersion.valid_till == None, # noqa: E711
ProductVersion.valid_till >= date_,
),
sku_version_menu_category_onclause = and_(
SkuVersion.menu_category_id == MenuCategory.id,
or_(
SkuVersion.valid_from == None, # noqa: E711
SkuVersion.valid_from <= date_,
),
or_(
SkuVersion.valid_till == None, # noqa: E711
SkuVersion.valid_till >= date_,
),
)
list_ = (
db.execute(select(ModifierCategory).order_by(ModifierCategory.sort_order).order_by(ModifierCategory.name))
.scalars()
.all()
)
menu_categories = (
db.execute(
select(MenuCategory)
.join(SkuVersion, onclause=sku_version_menu_category_onclause)
.join(StockKeepingUnit, onclause=_sv_onclause(date_))
.join(StockKeepingUnit.product)
.join(ProductVersion, onclause=_pv_onclause(date_))
.order_by(MenuCategory.sort_order, StockKeepingUnit.sort_order, ProductVersion.name)
.options(
contains_eager(MenuCategory.skus)
.contains_eager(SkuVersion.sku)
.contains_eager(StockKeepingUnit.product)
.contains_eager(Product.versions)
)
)
sku_version_onclause = and_(
SkuVersion.sku_id == StockKeepingUnit.id,
or_(SkuVersion.valid_from == None, SkuVersion.valid_from <= date_), # noqa: E711
or_(SkuVersion.valid_till == None, SkuVersion.valid_till >= date_), # noqa: E711
)
sku_version_menu_category_onclause = and_(
SkuVersion.menu_category_id == MenuCategory.id,
or_(
SkuVersion.valid_from == None, # noqa: E711
SkuVersion.valid_from <= date_,
),
or_(
SkuVersion.valid_till == None, # noqa: E711
SkuVersion.valid_till >= date_,
),
)
list_ = (
db.execute(select(ModifierCategory).order_by(ModifierCategory.sort_order).order_by(ModifierCategory.name))
.scalars()
.all()
)
menu_categories = (
db.execute(
select(MenuCategory)
.join(SkuVersion, onclause=sku_version_menu_category_onclause)
.join(StockKeepingUnit, onclause=sku_version_onclause)
.join(StockKeepingUnit.product)
.join(ProductVersion, onclause=product_version_onclause)
.order_by(MenuCategory.sort_order, StockKeepingUnit.sort_order, ProductVersion.name)
.options(
contains_eager(MenuCategory.skus)
.contains_eager(SkuVersion.sku)
.contains_eager(StockKeepingUnit.product)
.contains_eager(Product.versions)
.unique()
.scalars()
.all()
)
modifier_categories = []
for item in list_:
products = [x.id for x in item.products]
modifier_category = schemas.ModifierCategory(
id_=item.id,
name=item.name,
minimum=item.minimum,
maximum=item.maximum,
is_active=item.is_active,
sort_order=item.sort_order,
menu_categories=[
MenuCategoryLink(
id_=mc.id,
name=mc.name,
enabled=reduce(lambda x, y: x and (y.sku.product_id in products), mc.skus, True),
skus=[
ProductLink(id_=p.sku.product_id, name=f"{p.sku.product.versions[0].name} ({p.units})")
for p in mc.skus
if p.sku.product_id in products
],
)
)
.unique()
.scalars()
.all()
for mc in menu_categories
],
)
modifier_categories = []
for item in list_:
products = [x.id for x in item.products]
modifier_category = schemas.ModifierCategory(
id_=item.id,
name=item.name,
minimum=item.minimum,
maximum=item.maximum,
is_active=item.is_active,
sort_order=item.sort_order,
menu_categories=[
MenuCategoryLink(
id_=mc.id,
name=mc.name,
enabled=reduce(lambda x, y: x and (y.sku.product_id in products), mc.skus, True),
skus=[
ProductLink(id_=p.sku.product_id, name=f"{p.sku.product.versions[0].name} ({p.units})")
for p in mc.skus
if p.sku.product_id in products
],
)
for mc in menu_categories
],
)
modifier_category.menu_categories = [i for i in modifier_category.menu_categories if len(i.skus) > 0]
modifier_categories.append(modifier_category)
return modifier_categories
modifier_category.menu_categories = [i for i in modifier_category.menu_categories if len(i.skus) > 0]
modifier_categories.append(modifier_category)
return modifier_categories
@router.get("/for-sku/{sku_id}")
def for_product(
sku_id: uuid.UUID,
date_: date = Depends(effective_date),
user: UserToken = Security(get_user),
# date_: Annotated[date, Depends(effective_date)],
user: Annotated[UserToken, Security(get_user)],
db: SessionDep,
) -> list[ModifierCategoryForProduct]:
with SessionFuture() as db:
product: Product = db.execute(
select(Product).join(Product.skus).where(StockKeepingUnit.id == sku_id)
).scalar_one()
product: Product = db.execute(select(Product).join(Product.skus).where(StockKeepingUnit.id == sku_id)).scalar_one()
return [
ModifierCategoryForProduct(
id_=item.id,
name=item.name,
minimum=item.minimum,
maximum=item.maximum,
is_active=item.is_active,
modifiers=[
ModifierLink(id_=m.id, name=m.name, price=m.price) for m in item.modifiers if m.is_active is True
],
)
for item in product.modifier_categories
if item.is_active is True
]
return [
ModifierCategoryForProduct(
id_=item.id,
name=item.name,
minimum=item.minimum,
maximum=item.maximum,
is_active=item.is_active,
modifiers=[
ModifierLink(id_=m.id, name=m.name, price=m.price) for m in item.modifiers if m.is_active is True
],
)
for item in product.modifier_categories
if item.is_active is True
]
@router.get("/{id_}", response_model=schemas.ModifierCategory)
def show_id(
id_: uuid.UUID,
date_: date = Depends(effective_date),
user: UserToken = Security(get_user, scopes=["modifiers"]),
date_: Annotated[date, Depends(effective_date)],
user: Annotated[UserToken, Security(get_user, scopes=["modifiers"])],
db: SessionDep,
) -> schemas.ModifierCategory:
with SessionFuture() as db:
item: ModifierCategory = db.execute(select(ModifierCategory).where(ModifierCategory.id == id_)).scalar_one()
return modifier_category_info(item, date_, db=db)
item: ModifierCategory = db.execute(select(ModifierCategory).where(ModifierCategory.id == id_)).scalar_one()
return modifier_category_info(item, date_, db=db)
def modifier_category_info(item: ModifierCategory, date_: date, db: Session) -> schemas.ModifierCategory:
product_version_onclause = and_(
ProductVersion.product_id == Product.id,
or_(
ProductVersion.valid_from == None, # noqa: E711
ProductVersion.valid_from <= date_,
),
or_(
ProductVersion.valid_till == None, # noqa: E711
ProductVersion.valid_till >= date_,
),
)
sku_version_onclause = and_(
SkuVersion.sku_id == StockKeepingUnit.id,
or_(SkuVersion.valid_from == None, SkuVersion.valid_from <= date_), # noqa: E711
or_(SkuVersion.valid_till == None, SkuVersion.valid_till >= date_), # noqa: E711
)
sku_version_menu_category_onclause = and_(
SkuVersion.menu_category_id == MenuCategory.id,
or_(
@@ -261,9 +208,9 @@ def modifier_category_info(item: ModifierCategory, date_: date, db: Session) ->
db.execute(
select(MenuCategory)
.join(SkuVersion, onclause=sku_version_menu_category_onclause)
.join(StockKeepingUnit, onclause=sku_version_onclause)
.join(StockKeepingUnit, onclause=_sv_onclause(date_))
.join(StockKeepingUnit.product)
.join(ProductVersion, onclause=product_version_onclause)
.join(ProductVersion, onclause=_pv_onclause(date_))
.order_by(MenuCategory.sort_order, StockKeepingUnit.sort_order, ProductVersion.name)
.options(
contains_eager(MenuCategory.skus)
@@ -303,22 +250,6 @@ def modifier_category_info(item: ModifierCategory, date_: date, db: Session) ->
def modifier_category_blank(date_: date, db: Session) -> schemas.ModifierCategoryBlank:
product_version_onclause = and_(
ProductVersion.product_id == Product.id,
or_(
ProductVersion.valid_from == None, # noqa: E711
ProductVersion.valid_from <= date_,
),
or_(
ProductVersion.valid_till == None, # noqa: E711
ProductVersion.valid_till >= date_,
),
)
sku_version_onclause = and_(
SkuVersion.sku_id == StockKeepingUnit.id,
or_(SkuVersion.valid_from == None, SkuVersion.valid_from <= date_), # noqa: E711
or_(SkuVersion.valid_till == None, SkuVersion.valid_till >= date_), # noqa: E711
)
sku_version_menu_category_onclause = and_(
SkuVersion.menu_category_id == MenuCategory.id,
or_(
@@ -334,9 +265,9 @@ def modifier_category_blank(date_: date, db: Session) -> schemas.ModifierCategor
db.execute(
select(MenuCategory)
.join(SkuVersion, onclause=sku_version_menu_category_onclause)
.join(StockKeepingUnit, onclause=sku_version_onclause)
.join(StockKeepingUnit, onclause=_sv_onclause(date_))
.join(StockKeepingUnit.product)
.join(ProductVersion, onclause=product_version_onclause)
.join(ProductVersion, onclause=_pv_onclause(date_))
.order_by(MenuCategory.sort_order, StockKeepingUnit.sort_order, ProductVersion.name)
.options(
contains_eager(MenuCategory.skus)
+32 -56
View File
@@ -1,11 +1,12 @@
import uuid
from fastapi import APIRouter, Depends, HTTPException, Security, status
from typing import Annotated
from fastapi import APIRouter, Security
from sqlalchemy import select
from sqlalchemy.exc import SQLAlchemyError
from ..core.security import get_current_active_user as get_user
from ..db.session import SessionFuture
from ..db.session import SessionDep
from ..models.printer import Printer
from ..schemas import printer as schemas
from ..schemas.user_token import UserToken
@@ -16,86 +17,61 @@ router = APIRouter()
@router.post("", response_model=schemas.Printer)
def save(
data: schemas.PrinterIn,
user: UserToken = Security(get_user, scopes=["printers"]),
data: schemas.PrinterIn, user: Annotated[UserToken, Security(get_user, scopes=["printers"])], db: SessionDep
) -> schemas.Printer:
try:
with SessionFuture() as db:
item = Printer(
name=data.name,
address=data.address,
cut_code=bytes.fromhex(data.cut_code).decode(),
)
db.add(item)
db.commit()
return printer_info(item)
except SQLAlchemyError as e:
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=str(e),
)
item = Printer(
name=data.name,
address=data.address,
cut_code=bytes.fromhex(data.cut_code).decode(),
)
db.add(item)
db.commit()
return printer_info(item)
@router.put("/{id_}", response_model=schemas.Printer)
def update_route(
id_: uuid.UUID,
data: schemas.PrinterIn,
user: UserToken = Security(get_user, scopes=["printers"]),
user: Annotated[UserToken, Security(get_user, scopes=["printers"])],
db: SessionDep,
) -> schemas.Printer:
try:
with SessionFuture() as db:
item: Printer = db.execute(select(Printer).where(Printer.id == id_)).scalar_one()
item.name = data.name
item.address = data.address
item.cut_code = bytearray.fromhex(data.cut_code).decode()
db.commit()
return printer_info(item)
except SQLAlchemyError as e:
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=str(e),
)
item: Printer = db.execute(select(Printer).where(Printer.id == id_)).scalar_one()
item.name = data.name
item.address = data.address
item.cut_code = bytearray.fromhex(data.cut_code).decode()
db.commit()
return printer_info(item)
@router.delete("/{id_}", response_model=schemas.PrinterBlank)
def delete_route(
id_: uuid.UUID,
user: UserToken = Security(get_user, scopes=["printers"]),
id_: uuid.UUID, user: Annotated[UserToken, Security(get_user, scopes=["printers"])], db: SessionDep
) -> schemas.PrinterBlank:
try:
with SessionFuture() as db:
item: Printer = db.execute(select(Printer).where(Printer.id == id_)).scalar_one()
db.delete(item)
db.commit()
return printer_blank()
except SQLAlchemyError as e:
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=str(e),
)
item: Printer = db.execute(select(Printer).where(Printer.id == id_)).scalar_one()
db.delete(item)
db.commit()
return printer_blank()
@router.get("", response_model=schemas.PrinterBlank)
def show_blank(
user: UserToken = Security(get_user, scopes=["printers"]),
user: Annotated[UserToken, Security(get_user, scopes=["printers"])],
) -> schemas.PrinterBlank:
return printer_blank()
@router.get("/list", response_model=list[schemas.Printer])
def show_list(user: UserToken = Depends(get_user)) -> list[schemas.Printer]:
with SessionFuture() as db:
return [printer_info(item) for item in db.execute(select(Printer).order_by(Printer.name)).scalars().all()]
def show_list(user: Annotated[UserToken, Security(get_user)], db: SessionDep) -> list[schemas.Printer]:
return [printer_info(item) for item in db.execute(select(Printer).order_by(Printer.name)).scalars().all()]
@router.get("/{id_}", response_model=schemas.Printer)
def show_id(
id_: uuid.UUID,
user: UserToken = Security(get_user, scopes=["printers"]),
id_: uuid.UUID, user: Annotated[UserToken, Security(get_user, scopes=["printers"])], db: SessionDep
) -> schemas.Printer:
with SessionFuture() as db:
item: Printer = db.execute(select(Printer).where(Printer.id == id_)).scalar_one()
return printer_info(item)
item: Printer = db.execute(select(Printer).where(Printer.id == id_)).scalar_one()
return printer_info(item)
def printer_info(item: Printer) -> schemas.Printer:
+257 -328
View File
@@ -3,16 +3,16 @@ import uuid
from collections.abc import Sequence
from datetime import date, timedelta
from decimal import Decimal
from typing import Annotated
from fastapi import APIRouter, Depends, HTTPException, Security, status
from sqlalchemy import Date, and_, insert, or_, select, update
from sqlalchemy.exc import SQLAlchemyError
from sqlalchemy.orm import Session, contains_eager
from sqlalchemy.sql.functions import count, func
from ..core.config import settings
from ..core.security import get_current_active_user as get_user
from ..db.session import SessionFuture
from ..db.session import SessionDep
from ..models.bundle_item import BundleItem
from ..models.inventory import Inventory
from ..models.kot import Kot
@@ -32,7 +32,7 @@ from ..schemas.sale_category import SaleCategoryLink
from ..schemas.stock_keeping_unit import StockKeepingUnit as StockKeepingUnitSchema
from ..schemas.tax import TaxLink
from ..schemas.user_token import UserToken
from . import _pv_onclause, _sv_onclause, effective_date
from . import _pv_active, _pv_onclause, _sv_active, _sv_onclause, effective_date
router = APIRouter()
@@ -42,96 +42,69 @@ router = APIRouter()
def sort_order(
id_: uuid.UUID,
data: list[schemas.Product],
date_: date = Depends(effective_date),
user: UserToken = Security(get_user, scopes=["products"]),
date_: Annotated[date, Depends(effective_date)],
user: Annotated[UserToken, Security(get_user, scopes=["products"])],
db: SessionDep,
) -> list[schemas.Product]:
try:
with SessionFuture() as db:
skus = [sku for product in data for sku in product.skus if sku.menu_category.id_ == id_]
for i, sku in enumerate(skus):
db.execute(update(StockKeepingUnit).where(StockKeepingUnit.id == sku.id_).values(sort_order=i))
db.commit()
return product_list(date_, db)
except SQLAlchemyError as e:
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=str(e),
)
skus = [sku for product in data for sku in product.skus if sku.menu_category.id_ == id_]
for i, sku in enumerate(skus):
db.execute(update(StockKeepingUnit).where(StockKeepingUnit.id == sku.id_).values(sort_order=i))
db.commit()
return product_list(date_, db)
@router.post("", response_model=None)
def save(
data: schemas.ProductIn,
date_: date = Depends(effective_date),
user: UserToken = Security(get_user, scopes=["products"]),
date_: Annotated[date, Depends(effective_date)],
user: Annotated[UserToken, Security(get_user, scopes=["products"])],
db: SessionDep,
) -> None:
try:
with SessionFuture() as db:
product = Product(sort_order=data.sort_order)
db.add(product)
db.flush() # so product.id is available
version = ProductVersion(
product_id=product.id,
name=data.name,
fraction_units=data.fraction_units,
sale_category_id=data.sale_category.id_,
valid_from=date_,
valid_till=None,
)
db.add(version)
if not len(data.skus):
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
detail="Not enough stock keeping units.",
)
for sku in data.skus:
s = StockKeepingUnit(product=product, is_not_available=sku.is_not_available)
# product.skus.append(s)
db.add(s)
db.flush() # So sku.id is available
sver = SkuVersion(
sku_id=s.id,
units=sku.units,
fraction=round(sku.fraction, 5),
product_yield=round(sku.product_yield, 5),
cost_price=round(sku.cost_price, 2),
sale_price=round(sku.sale_price, 2),
has_happy_hour=sku.has_happy_hour,
menu_category_id=sku.menu_category.id_,
valid_from=date_,
valid_till=None,
sku=s,
)
# s.versions.append(sver)
db.add(sver)
for p_sku in version.product.skus:
add_modifiers(p_sku.id, product.id, p_sku.versions[0].menu_category_id, date_, db)
db.commit()
except SQLAlchemyError as e:
product = Product(sort_order=data.sort_order)
db.add(product)
db.flush() # so product.id is available
version = ProductVersion(
product_id=product.id,
name=data.name,
fraction_units=data.fraction_units,
sale_category_id=data.sale_category.id_,
valid_from=date_,
valid_till=None,
)
db.add(version)
if not len(data.skus):
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=str(e),
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
detail="Not enough stock keeping units.",
)
for sku in data.skus:
s = StockKeepingUnit(product=product, is_not_available=sku.is_not_available)
# product.skus.append(s)
db.add(s)
db.flush() # So sku.id is available
sver = SkuVersion(
sku_id=s.id,
units=sku.units,
fraction=round(sku.fraction, 5),
product_yield=round(sku.product_yield, 5),
cost_price=round(sku.cost_price, 2),
sale_price=round(sku.sale_price, 2),
has_happy_hour=sku.has_happy_hour,
menu_category_id=sku.menu_category.id_,
valid_from=date_,
valid_till=None,
sku=s,
)
# s.versions.append(sver)
db.add(sver)
for p_sku in version.product.skus:
add_modifiers(p_sku.id, product.id, p_sku.versions[0].menu_category_id, date_, db)
db.commit()
def add_modifiers(
sku_id: uuid.UUID, product_id: uuid.UUID, menu_category_id: uuid.UUID, date_: date, db: Session
) -> None:
sv_active = and_(
or_(SkuVersion.valid_from == None, SkuVersion.valid_from <= date_), # noqa: E711
or_(SkuVersion.valid_till == None, SkuVersion.valid_till >= date_), # noqa: E711
)
product_version_onclause = and_(
ProductVersion.product_id == Product.id,
or_(
ProductVersion.valid_from == None, # noqa: E711
ProductVersion.valid_from <= date_,
),
or_(
ProductVersion.valid_till == None, # noqa: E711
ProductVersion.valid_till >= date_,
),
)
# how many DISTINCT products (excluding current product via sku_id) are in this menu category today?
products_in_category = db.execute(
select(func.count(func.distinct(StockKeepingUnit.product_id)))
@@ -139,7 +112,7 @@ def add_modifiers(
.join(SkuVersion.sku) # -> StockKeepingUnit
.where(
and_(
sv_active,
_sv_active(date_),
SkuVersion.menu_category_id == menu_category_id,
SkuVersion.sku_id != sku_id,
)
@@ -152,7 +125,7 @@ def add_modifiers(
ModifierCategory.id,
)
.select_from(ProductVersion)
.join(Product, onclause=product_version_onclause)
.join(Product, onclause=_pv_onclause(date_))
.join(Product.modifier_categories)
.group_by(ModifierCategory.id)
).all()
@@ -166,237 +139,205 @@ def add_modifiers(
def update_route(
id_: uuid.UUID,
data: schemas.ProductIn,
date_: date = Depends(effective_date),
user: UserToken = Security(get_user, scopes=["products"]),
date_: Annotated[date, Depends(effective_date)],
user: Annotated[UserToken, Security(get_user, scopes=["products"])],
db: SessionDep,
) -> None:
try:
with SessionFuture() as db:
product_version_onclause = _pv_onclause(date_)
sku_version_onclause = _sv_onclause(date_)
version: ProductVersion = db.execute(
select(ProductVersion)
.join(Product, onclause=product_version_onclause)
.options(contains_eager(ProductVersion.product))
.where(ProductVersion.product_id == id_)
).scalar_one()
product_version_onclause = _pv_onclause(date_)
sku_version_onclause = _sv_onclause(date_)
version: ProductVersion = db.execute(
select