Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2dfbfd8fcb | ||
|
|
221fadfdfc | ||
|
|
11b93cb11c | ||
|
|
302a4eb311 | ||
|
|
217f636573 | ||
|
|
95ac73bbc4 | ||
|
|
3d86f8706f | ||
|
|
30cbb60efe | ||
|
|
227f5b5f74 | ||
|
|
50974c7574 | ||
|
|
c8e1ab74b6 | ||
|
|
85ba7b326a | ||
|
|
458dfaad50 | ||
|
|
0a9675f270 | ||
|
|
6cf42d3641 | ||
|
|
cf680cd254 | ||
|
|
80d21ec31e | ||
|
|
4377016cda | ||
|
|
67209886b9 | ||
|
|
4e1c0d1ec7 | ||
|
|
c7412f080d | ||
|
|
d023089720 | ||
|
|
dd02a97ba2 | ||
|
|
befd4eb298 | ||
|
|
d90c1cb563 | ||
|
|
e90c80999e | ||
|
|
42331b482d | ||
|
|
612e2243c0 | ||
|
|
35a3bb9ad2 | ||
|
|
ae69c2fafe | ||
|
|
85a063c1a7 | ||
|
|
10448e8b12 | ||
|
|
59bf2c5ca9 | ||
|
|
73fba17711 | ||
|
|
103983846d | ||
|
|
af07bb5043 | ||
|
|
612426b92f | ||
|
|
730048f239 | ||
|
|
ff537edb06 | ||
|
|
311464e593 | ||
|
|
43204e8441 | ||
|
|
2a29dc9195 | ||
|
|
65527dcbff | ||
|
|
cc52670783 | ||
|
|
993f83c786 | ||
|
|
59eb45da96 | ||
|
|
ebe5a6352e | ||
|
|
38a8656008 | ||
|
|
2e5543a2eb | ||
|
|
34e30efcaf | ||
|
|
2b844bc44b | ||
|
|
325bcae998 | ||
|
|
ab8fed17d0 | ||
|
|
dcf1ffad98 | ||
|
|
338d9d63d5 | ||
|
|
9b0da9cb65 | ||
|
|
4cc2ff2229 | ||
|
|
c9fa83e8ac | ||
|
|
061dd1310d | ||
|
|
a1d6919d9d | ||
|
|
ecc92795e6 | ||
|
|
535a145742 | ||
|
|
1271e26dd8 | ||
|
|
2ae3f3ba7e | ||
|
|
e6a03d33e5 | ||
|
|
c7dd6e574d | ||
|
|
913820cc29 | ||
|
|
e3d40d50f0 | ||
|
|
b27a5a9211 | ||
|
|
a955e26b93 |
No files matched your search
@@ -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"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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`.
|
||||
@@ -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
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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`).
|
||||
@@ -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
|
||||
@@ -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
@@ -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/
|
||||
Vendored
+11
-1
@@ -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"
|
||||
]
|
||||
}
|
||||
Vendored
+22
-3
@@ -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"
|
||||
}
|
||||
]
|
||||
}
|
||||
Vendored
+9
@@ -0,0 +1,9 @@
|
||||
{
|
||||
// For more information, visit: https://angular.dev/ai/mcp
|
||||
"servers": {
|
||||
"angular-cli": {
|
||||
"command": "npx",
|
||||
"args": ["-y", "@angular/cli", "mcp"]
|
||||
}
|
||||
}
|
||||
}
|
||||
Vendored
+77
-23
@@ -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": []
|
||||
}
|
||||
Vendored
+3
@@ -27,6 +27,9 @@
|
||||
"type": "npm",
|
||||
"script": "test",
|
||||
"isBackground": true,
|
||||
"options": {
|
||||
"cwd": "${workspaceFolder}/bookie"
|
||||
},
|
||||
"problemMatcher": {
|
||||
"owner": "typescript",
|
||||
"pattern": "$tsc",
|
||||
|
||||
+46
-19
@@ -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"]
|
||||
@@ -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 \
|
||||
-
|
||||
@@ -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.
|
||||
|
||||
```
|
||||
@@ -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
@@ -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,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 +0,0 @@
|
||||
docker build --file /home/{{ user }}/dockerfile/app/frank.Dockerfile --tag frank:latest /home/{{ user }}/dockerfile/app
|
||||
@@ -1,4 +0,0 @@
|
||||
Host rohan rohan.tanshu.com
|
||||
HostName rohan.tanshu.com
|
||||
IdentityFile ~/.ssh/id_ed25519
|
||||
User tanshu
|
||||
@@ -1,3 +0,0 @@
|
||||
REDIS_HOST=localhost
|
||||
REDIS_PORT=6379
|
||||
QUEUE_NAME={{ queue_name }}
|
||||
@@ -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 +0,0 @@
|
||||
docker run --detach --name frank --env-file=/home/{{ user }}/frank.env --network=host --restart=no --device /dev/usb/lp0:/printer frank:latest
|
||||
@@ -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
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
---
|
||||
collections:
|
||||
- name: community.docker
|
||||
@@ -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"
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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"
|
||||
@@ -1,4 +0,0 @@
|
||||
---
|
||||
printer_port: "/dev/usb/lp0"
|
||||
user: vari
|
||||
queue_name: moz-elante
|
||||
@@ -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"
|
||||
@@ -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"
|
||||
@@ -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,4 +1,3 @@
|
||||
*.pyc
|
||||
*/__pycache__/
|
||||
*.egg-info/
|
||||
poetry.lock
|
||||
@@ -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
|
||||
|
||||
Vendored
+22
@@ -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 @@
|
||||
__version__ = "14.0.1"
|
||||
__version__ = "15.1.6"
|
||||
@@ -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)
|
||||
@@ -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")
|
||||
|
||||
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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()
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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)
|
||||
@@ -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
@@ -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,7 +1,7 @@
|
||||
import enum
|
||||
|
||||
|
||||
class GuestBookType(str, enum.Enum):
|
||||
class GuestBookType(enum.StrEnum):
|
||||
running = "running"
|
||||
printed = "printed"
|
||||
walk_in = "walk_in"
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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,7 +1,7 @@
|
||||
import enum
|
||||
|
||||
|
||||
class InventoryType(str, enum.Enum):
|
||||
class InventoryType(enum.StrEnum):
|
||||
regular = "regular"
|
||||
bundle = "bundle"
|
||||
bundle_item = "bundle_item"
|
||||
@@ -1,6 +1,6 @@
|
||||
import enum
|
||||
|
||||
|
||||
class OverviewStatus(str, enum.Enum):
|
||||
class OverviewStatus(enum.StrEnum):
|
||||
running = "running"
|
||||
printed = "printed"
|
||||
@@ -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:
|
||||
|
||||
@@ -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)
|
||||
@@ -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 = "",
|
||||
|
||||
@@ -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.")
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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)
|
||||
@@ -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:
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
@@ -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()
|
||||
@@ -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},
|
||||
)
|
||||
@@ -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:
|
||||
|
||||
@@ -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]:
|
||||
|
||||
@@ -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"])
|
||||
@@ -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
@@ -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(
|
||||
|
||||
@@ -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()
|
||||
]
|
||||
@@ -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
|
||||
@@ -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)
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
@@ -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 | ||||