Compare commits

..
17 Commits
Author SHA1 Message Date
tanshu dcf1ffad98 Version Bump v14.3.0 2026-02-17 09:45:32 +05:30
tanshu 338d9d63d5 Validation: Making Product name unique ignoring the fraction_units.
Making the sku_version.units unique for a product across stock_keeping_units.

Both respecting the validity
2026-02-17 04:13:11 +00:00
tanshu 9b0da9cb65 Fix: Product sale report was aggregating on the product_version_id 2026-02-14 12:20:09 +00:00
tanshu 4cc2ff2229 Version Bump v14.2.1 2026-02-14 11:30:40 +05:30
tanshu c9fa83e8ac Fix: Accidentally used menu category instead of sale category 2026-02-14 06:00:09 +00:00
tanshu 061dd1310d Version Bump v14.2.0 2026-02-14 05:30:05 +00:00
tanshu a1d6919d9d Beer consumption report can choose menu category for beer 2026-02-14 05:29:11 +00:00
tanshu ecc92795e6 Fix: In product list, if menu category is reset, all products get filtered out as it becomes undefined. 2026-02-12 09:01:12 +00:00
tanshu 535a145742 Fix: Cannot move kot as row.kotId was always undefined. 2026-02-12 08:20:54 +00:00
tanshu 1271e26dd8 Changed Docker CMD to remove redundant run.sh 2026-02-12 04:01:35 +00:00
tanshu 2ae3f3ba7e Dockerfile will kill the process if stuck so that restart policy can restart the container
Gunicorn will kill worker every 10000 requests which can help reclaim memory lost to leaks
2026-02-12 03:52:53 +00:00
tanshu e6a03d33e5 Version Bump v14.1.0 2026-02-11 14:59:50 +05:30
tanshu c7dd6e574d Feature: Roles can include Roles. 2026-02-11 09:28:50 +00:00
tanshu 913820cc29 Dockerfile improvements. 2026-02-11 08:41:12 +00:00
tanshu e3d40d50f0 Fix: Unable to receive payment as the amountVal as always zero. 2026-02-11 08:37:12 +00:00
tanshu b27a5a9211 Committed the poetry.lock 2026-02-11 05:51:10 +00:00
tanshu a955e26b93 Added poetry lockfile to Dockerfile 2026-02-11 05:48:41 +00:00
45 changed files with 4471 additions and 131 deletions
+6 -5
View File
@@ -1,4 +1,4 @@
FROM node:latest AS base
FROM node:lts-bookworm-slim AS base
# Install dependencies only when needed
FROM base AS deps
@@ -36,7 +36,7 @@ RUN curl -sSL https://install.python-poetry.org | POETRY_HOME=/opt/poetry python
WORKDIR /app
COPY barker/pyproject.toml /app/pyproject.toml
COPY barker/pyproject.toml barker/poetry.lock* /app
# Allow installing dev dependencies to run tests
ARG INSTALL_DEV=false
@@ -54,7 +54,8 @@ RUN chmod 777 /app/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"]
+6 -1
View File
@@ -6,7 +6,12 @@
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
-1
View File
@@ -1,4 +1,3 @@
*.pyc
*/__pycache__/
*.egg-info/
poetry.lock
@@ -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 @@
"""inculded roles
Revision ID: 5cb65066be86
Revises: 367ecf7b898f
Create Date: 2026-02-11 08:21:01.679893
"""
import sqlalchemy as sa
from alembic import op
# revision identifiers, used by Alembic.
revision = "5cb65066be86"
down_revision = "367ecf7b898f"
branch_labels = None
depends_on = None
def upgrade():
op.create_table(
"role_includes",
sa.Column("id", sa.Uuid(), server_default=sa.text("gen_random_uuid()"), nullable=False),
sa.Column("role_id", sa.Uuid(), nullable=False),
sa.Column("included_role_id", sa.Uuid(), nullable=False),
sa.CheckConstraint("role_id <> included_role_id", name=op.f("ck_role_includes_no_self_include")),
sa.ForeignKeyConstraint(
["included_role_id"], ["roles.id"], name=op.f("fk_role_includes_included_role_id_roles")
),
sa.ForeignKeyConstraint(["role_id"], ["roles.id"], name=op.f("fk_role_includes_role_id_roles")),
sa.PrimaryKeyConstraint("id", name=op.f("pk_role_includes")),
sa.UniqueConstraint("role_id", "included_role_id", name=op.f("uq_role_includes_role_id")),
sa.Index(op.f("ix_role_includes_role_id"), "role_id"),
sa.Index(op.f("ix_role_includes_included_role_id"), "included_role_id"),
)
def downgrade():
op.drop_table("role_includes")
@@ -0,0 +1,156 @@
"""index
Revision ID: 8260414066d6
Revises: 5cb65066be86
Create Date: 2026-02-13 06:22:39.926120
"""
import sqlalchemy as sa
from alembic import op
# revision identifiers, used by Alembic.
revision = "8260414066d6"
down_revision = "5cb65066be86"
branch_labels = None
depends_on = None
def upgrade():
# 1) Add the column (nullable for backfill)
op.add_column("sku_versions", sa.Column("product_id", sa.UUID(), nullable=True))
# 2) Define lightweight table objects (no autoload; explicit columns only)
sku_versions = sa.Table(
"sku_versions",
sa.MetaData(),
sa.Column("id", sa.UUID(), primary_key=True),
sa.Column("sku_id", sa.UUID(), nullable=False),
sa.Column("product_id", sa.UUID(), nullable=True),
sa.Column("valid_from", sa.Date(), nullable=True),
sa.Column("valid_till", sa.Date(), nullable=True),
sa.Column("units", sa.Unicode(), nullable=False),
)
stock_keeping_units = sa.Table(
"stock_keeping_units",
sa.MetaData(),
sa.Column("id", sa.UUID(), primary_key=True),
sa.Column("product_id", sa.UUID(), nullable=False),
)
# 3) Backfill via SQLAlchemy Core UPDATE..SET..(SELECT ...)
product_id_subq = (
sa.select(stock_keeping_units.c.product_id)
.where(stock_keeping_units.c.id == sku_versions.c.sku_id)
.scalar_subquery()
)
backfill_stmt = (
sa.update(sku_versions).values(product_id=product_id_subq).where(sku_versions.c.product_id.is_(None))
)
op.execute(backfill_stmt)
# 5) Enforce NOT NULL at schema level
op.alter_column("sku_versions", "product_id", nullable=False)
# 6) Trigger keeps product_id in sync when sku_id changes
op.execute(
sa.text(
"""
CREATE OR REPLACE FUNCTION sku_versions_set_product_id()
RETURNS trigger LANGUAGE plpgsql AS $$
BEGIN
SELECT s.product_id INTO NEW.product_id
FROM stock_keeping_units s
WHERE s.id = NEW.sku_id;
IF NEW.product_id IS NULL THEN
RAISE EXCEPTION 'Invalid sku_id %, no product found', NEW.sku_id;
END IF;
RETURN NEW;
END;
$$;
"""
)
)
op.execute(sa.text("DROP TRIGGER IF EXISTS trg_sku_versions_set_product_id ON sku_versions;"))
op.execute(
sa.text(
"""
CREATE TRIGGER trg_sku_versions_set_product_id
BEFORE INSERT OR UPDATE OF sku_id
ON sku_versions
FOR EACH ROW
EXECUTE FUNCTION sku_versions_set_product_id();
"""
)
)
# 7) Exclusion constraint: product_id + units must not overlap in time
# daterange(valid_from, valid_till, '[]') overlap operator &&.
sv = sa.table(
"sku_versions",
sa.column("product_id", sa.UUID()),
sa.column("units", sa.UUID()),
sa.column("valid_from", sa.Date()),
sa.column("valid_till", sa.Date()),
)
op.create_exclude_constraint(
op.f("uq_sku_versions_product_id_units"),
"sku_versions",
(sv.c.product_id, "="),
(sv.c.units, "="),
(sa.func.daterange(sv.c.valid_from, sv.c.valid_till, sa.text("'[]'")), "&&"),
)
prod = sa.table(
"product_versions",
sa.column("id", sa.UUID()),
sa.column("name", sa.Unicode(length=255)),
sa.column("fraction_units", sa.Unicode(length=255)),
sa.column("valid_from", sa.Date()),
sa.column("valid_till", sa.Date()),
)
# Update the exclude constraint on product_versions to only be on the name and drop fraction_units from it
op.drop_constraint("uq_product_versions_name", "product_versions", type_="unique")
op.create_exclude_constraint(
op.f("uq_product_versions_name"),
"product_versions",
(prod.c.name, "="),
(sa.func.daterange(prod.c.valid_from, prod.c.valid_till, sa.text("'[]'")), "&&"),
)
# ### end Alembic commands ###
def downgrade():
op.drop_constraint("uq_sku_versions_product_id_units", "sku_versions", type_="exclude")
# Drop trigger + function
op.execute(sa.text("DROP TRIGGER IF EXISTS trg_sku_versions_set_product_id ON sku_versions;"))
op.execute(sa.text("DROP FUNCTION IF EXISTS sku_versions_set_product_id();"))
# Drop column
op.drop_column("sku_versions", "product_id")
prod = sa.table(
"product_versions",
sa.column("id", sa.UUID()),
sa.column("name", sa.Unicode(length=255)),
sa.column("fraction_units", sa.Unicode(length=255)),
sa.column("valid_from", sa.Date()),
sa.column("valid_till", sa.Date()),
)
op.drop_constraint("uq_product_versions_name", "product_versions", type_="unique")
op.create_exclude_constraint(
"uq_product_versions_name",
"product_versions",
(prod.c.name, "="),
(prod.c.units, "="),
(sa.func.daterange(prod.c.valid_from, prod.c.valid_till, sa.text("'[]'")), "&&"),
)
+1 -1
View File
@@ -1 +1 @@
__version__ = "14.0.1"
__version__ = "14.3.0"
+1 -1
View File
@@ -53,7 +53,7 @@ class Inventory:
type_: Mapped[InventoryType] = mapped_column(
"type", Enum(InventoryType), server_default=text("regular"), nullable=False
)
parent_id: Mapped[uuid.UUID | None] = mapped_column(Uuid, ForeignKey("inventories.id"), nullable=True)
parent_id: Mapped[uuid.UUID | None] = mapped_column(Uuid, ForeignKey("inventories.id"), nullable=True, index=True)
kot: Mapped[Kot] = relationship(back_populates="inventories")
tax: Mapped[Tax] = relationship(back_populates="inventories")
+1 -1
View File
@@ -22,7 +22,7 @@ class InventoryModifier:
__table_args__ = (UniqueConstraint("inventory_id", "modifier_id"),)
id: Mapped[uuid.UUID] = mapped_column(Uuid, primary_key=True, server_default=text("gen_random_uuid()"))
inventory_id: Mapped[uuid.UUID] = mapped_column(Uuid, ForeignKey("inventories.id"), nullable=False)
inventory_id: Mapped[uuid.UUID] = mapped_column(Uuid, ForeignKey("inventories.id"), nullable=False, index=True)
modifier_id: Mapped[uuid.UUID] = mapped_column(Uuid, ForeignKey("modifiers.id"), nullable=False)
price: Mapped[Decimal] = mapped_column(Numeric(precision=15, scale=2), nullable=False)
+18
View File
@@ -8,6 +8,7 @@ from sqlalchemy import Text, Uuid, text
from sqlalchemy.orm import Mapped, mapped_column, relationship
from ..db.base_class import reg
from .role_includes import RoleInclude
from .role_permission import RolePermission
@@ -28,6 +29,23 @@ class Role:
back_populates="roles",
)
included_roles: Mapped[list[Role]] = relationship(
"Role",
secondary=RoleInclude.__table__, # type: ignore[attr-defined]
primaryjoin=(id == RoleInclude.role_id),
secondaryjoin=(id == RoleInclude.included_role_id),
back_populates="included_by_roles",
)
# "included_by_roles" = roles that include THIS role (parents)
included_by_roles: Mapped[list[Role]] = relationship(
"Role",
secondary=RoleInclude.__table__, # type: ignore[attr-defined]
primaryjoin=(id == RoleInclude.included_role_id),
secondaryjoin=(id == RoleInclude.role_id),
back_populates="included_roles",
)
def __init__(self, name: str, id_: uuid.UUID | None = None):
self.name = name
if id_ is not None:
+28
View File
@@ -0,0 +1,28 @@
import uuid
from sqlalchemy import CheckConstraint, ForeignKey, UniqueConstraint, Uuid, text
from sqlalchemy.orm import Mapped, mapped_column
from ..db.base_class import reg
@reg.mapped_as_dataclass(unsafe_hash=True)
class RoleInclude:
"""
A "role includes role" edge:
- role_id (parent/composite role)
- included_role_id (child role that is included)
This lets Role A inherit all permissions of Role B (and B's included roles, recursively).
"""
__tablename__ = "role_includes"
__table_args__ = (
UniqueConstraint("role_id", "included_role_id"),
CheckConstraint("role_id <> included_role_id", name="no_self_include"),
)
id: Mapped[uuid.UUID] = mapped_column(Uuid, primary_key=True, server_default=text("gen_random_uuid()"))
role_id: Mapped[uuid.UUID] = mapped_column(Uuid, ForeignKey("roles.id"), nullable=False, index=True)
included_role_id: Mapped[uuid.UUID] = mapped_column(Uuid, ForeignKey("roles.id"), nullable=False, index=True)
+25 -1
View File
@@ -8,7 +8,7 @@ from typing import TYPE_CHECKING
from sqlalchemy import Boolean, Date, ForeignKey, Numeric, Unicode, Uuid, func, text
from sqlalchemy.dialects import postgresql
from sqlalchemy.orm import Mapped, mapped_column, relationship
from sqlalchemy.orm import Mapped, mapped_column, relationship, validates
from ..db.base_class import reg
@@ -26,6 +26,14 @@ class SkuVersion:
Uuid, primary_key=True, insert_default=uuid.uuid4, server_default=text("gen_random_uuid()")
)
sku_id: Mapped[uuid.UUID] = mapped_column(Uuid, ForeignKey("stock_keeping_units.id"), nullable=False)
# DB column is "product_id", but ORM attribute is private: "_product_id"
_product_id: Mapped[uuid.UUID] = mapped_column(
"product_id",
Uuid,
nullable=False,
repr=False, # hides in dataclass repr
init=False, # not part of __init__ signature (dataclass)
)
units: Mapped[str] = mapped_column(
Unicode, nullable=False
) # Need to have logic in the application to handle unit uniqueness since we don't have product_id here
@@ -56,8 +64,24 @@ 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, value):
"""
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 = "",
+28 -2
View File
@@ -13,6 +13,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
@@ -25,12 +27,36 @@ from ..core.security import (
)
from ..db.session import SessionFuture
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 = [r for r in db.execute(q).scalars().all()]
# match your existing scope normalization
return sorted(set([n.replace(" ", "-").lower() for n in names]))
@router.post("/token", response_model=Token)
def login_for_access_token(
response: Response,
@@ -65,11 +91,11 @@ def login_for_access_token(
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"]
+ list(set([p.name.replace(" ", "-").lower() for r in user.roles for p in r.permissions])),
"scopes": ["authenticated"] + perm_scopes,
"userId": str(user.id),
"lockedOut": user.locked_out,
"ver": __version__.__version__,
+3 -16
View File
@@ -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_onclause, effective_date
router = APIRouter()
@@ -241,7 +241,6 @@ def update_route(
or new_data_sku.has_happy_hour != sku.has_happy_hour
or new_data_sku.menu_category.id_ != sku.menu_category_id
)
print(f"SKU Changed: {sku_changed} for {sku.id}")
sku.sku.sort_order = new_data_sku.sort_order
sku.sku.is_not_available = new_data_sku.is_not_available
if sku_changed:
@@ -471,7 +470,6 @@ def show_term(
) -> list[ProductQuery]:
product_version_onclause = _pv_onclause(date_)
sku_version_onclause = _sv_onclause(date_)
print(f"Fetching products for MenuCategory: {mc}, SaleCategory: {sc}, Date: {date_}")
list_: list[ProductQuery] = []
query = (
select(SkuVersion)
@@ -525,17 +523,6 @@ def show_id(
date_: date = Depends(effective_date),
user: UserToken = Security(get_user, scopes=["products"]),
) -> schemas.Product:
pv_active = and_(
ProductVersion.product_id == id_,
or_(ProductVersion.valid_from == None, ProductVersion.valid_from <= date_), # noqa: E711
or_(ProductVersion.valid_till == None, ProductVersion.valid_till >= date_), # noqa: E711
)
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
)
with SessionFuture() as db:
version: ProductVersion = (
db.execute(
@@ -543,9 +530,9 @@ def show_id(
.join(ProductVersion.sale_category)
.join(ProductVersion.product)
.join(Product.skus)
.join(SkuVersion, onclause=sku_version_onclause)
.join(SkuVersion, onclause=_sv_onclause(date_))
.join(SkuVersion.menu_category)
.where(pv_active)
.where(ProductVersion.product_id == id_, _pv_active(date_))
.order_by(
StockKeepingUnit.sort_order,
SkuVersion.units,
@@ -1,3 +1,5 @@
import uuid
from datetime import date, timedelta
from fastapi import APIRouter, Depends, Security
@@ -16,6 +18,7 @@ from ...models.stock_keeping_unit import StockKeepingUnit
from ...models.voucher import Voucher
from ...models.voucher_type import VoucherType
from ...schemas.beer_consumption_report import BeerConsumptionReport, BeerConsumptionReportItem
from ...schemas.sale_category import SaleCategoryLink
from ...schemas.user_token import UserToken
from .. import _pv_onclause, _sv_onclause
from . import check_audit_permission, report_finish_date, report_start_date
@@ -28,6 +31,7 @@ router = APIRouter()
def beer_consumption(
start_date: date = Depends(report_start_date),
finish_date: date = Depends(report_finish_date),
sc: uuid.UUID | None = None, # Sale Category
r: bool | None = True,
h: bool | None = True,
st: bool | None = True,
@@ -55,6 +59,8 @@ def beer_consumption(
day <= finish_date,
)
)
if sc:
query = query.where(ProductVersion.sale_category_id == sc)
if h is False and r is not False:
query = query.where(Inventory.is_happy_hour == h)
if r is False and h is not False:
@@ -89,6 +95,7 @@ def beer_consumption(
return BeerConsumptionReport(
start_date=start_date,
finish_date=finish_date,
sale_category=SaleCategoryLink(id_=sc, name="") if sc else None,
regular=r,
happy=h,
staff=st,
@@ -53,13 +53,11 @@ def product_sale_report(
start_date: date, finish_date: date, id_: uuid.UUID | None, db: Session
) -> list[ProductSaleReportItem]:
day = func.cast(
Voucher.date + timedelta(minutes=settings.TIMEZONE_OFFSET_MINUTES - settings.NEW_DAY_OFFSET_MINUTES), Date
Kot.date + timedelta(minutes=settings.TIMEZONE_OFFSET_MINUTES - settings.NEW_DAY_OFFSET_MINUTES), Date
).label("day")
product_version_onclause = _pv_onclause(day)
sku_version_onclause = _sv_onclause(day)
query = (
select(
ProductVersion.id,
StockKeepingUnit.id,
ProductVersion.name,
SkuVersion.units,
Voucher.voucher_type,
@@ -69,10 +67,10 @@ def product_sale_report(
.join(Inventory.kot)
.join(Kot.voucher)
.join(Inventory.sku)
.join(SkuVersion, onclause=sku_version_onclause)
.join(SkuVersion, onclause=_sv_onclause(day))
.join(SkuVersion.menu_category)
.join(StockKeepingUnit.product)
.join(ProductVersion, onclause=product_version_onclause)
.join(ProductVersion, onclause=_pv_onclause(day))
.join(ProductVersion.sale_category)
.join(Voucher.food_table)
.where(
@@ -85,7 +83,7 @@ def product_sale_report(
query = query.group_by(
SaleCategory.name,
MenuCategory.name,
ProductVersion.id,
StockKeepingUnit.id,
ProductVersion.name,
SkuVersion.units,
Voucher.voucher_type,
@@ -93,14 +91,14 @@ def product_sale_report(
).order_by(SaleCategory.name, MenuCategory.name, ProductVersion.name, SkuVersion.units)
list_ = db.execute(query).all()
info: list[ProductSaleReportItem] = []
for product_version_id, name, units, v_type, hh, quantity in list_:
for sku_id, name, units, v_type, hh, quantity in list_:
type_ = VoucherType(v_type).name
old = next((i for i in info if i.product_version_id == product_version_id and i.is_happy_hour == hh), None)
old = next((i for i in info if i.sku_id == sku_id and i.is_happy_hour == hh), None)
if old:
old[type_] = old[type_] + quantity
else:
item = ProductSaleReportItem(
product_version_id=product_version_id, name=f"{'H H ' if hh else ''}{name} ({units})", is_happy_hour=hh
sku_id=sku_id, name=f"{'H H ' if hh else ''}{name} ({units})", is_happy_hour=hh
)
item[type_] = quantity
info.append(item)
+1 -1
View File
@@ -60,7 +60,7 @@ def get_sale_report(
def get_sale(start_date: date, finish_date: date, id_: uuid.UUID | None, db: Session) -> list[SaleReportItem]:
day = func.cast(
Voucher.date + timedelta(minutes=settings.TIMEZONE_OFFSET_MINUTES - settings.NEW_DAY_OFFSET_MINUTES), Date
Kot.date + timedelta(minutes=settings.TIMEZONE_OFFSET_MINUTES - settings.NEW_DAY_OFFSET_MINUTES), Date
).label("day")
product_version_onclause = _pv_onclause(day)
query = (
+117 -5
View File
@@ -1,7 +1,7 @@
import uuid
from fastapi import APIRouter, HTTPException, Security, status
from sqlalchemy import select
from sqlalchemy import or_, select
from sqlalchemy.exc import SQLAlchemyError
from sqlalchemy.orm import Session
from sqlalchemy.sql.functions import count
@@ -10,6 +10,7 @@ from ..core.security import get_current_active_user as get_user
from ..db.session import SessionFuture
from ..models.permission import Permission
from ..models.role import Role
from ..models.role_includes import RoleInclude
from ..models.role_permission import RolePermission
from ..models.user_role import UserRole
from ..schemas import role as schemas
@@ -30,6 +31,7 @@ def save(
item = Role(data.name)
db.add(item)
add_permissions(item, data.permissions, db)
add_included_roles(item, data.included_roles, db)
db.commit()
return role_info(item, db)
except SQLAlchemyError as e:
@@ -50,6 +52,7 @@ def update_route(
item: Role = db.execute(select(Role).where(Role.id == id_)).scalar_one()
item.name = data.name
add_permissions(item, data.permissions, db)
add_included_roles(item, data.included_roles, db)
db.commit()
return role_info(item, db)
except SQLAlchemyError as e:
@@ -75,6 +78,18 @@ def delete_route(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail="This Role has permissions and cannot be deleted.",
)
if (
db.execute(
select(count(RoleInclude.id)).where(
or_(RoleInclude.role_id == id_, RoleInclude.included_role_id == id_)
)
).scalar_one()
> 0
):
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail="This Role is used in role composition (includes/being included) and cannot be deleted.",
)
item: Role = db.execute(select(Role).where(Role.id == id_)).scalar_one()
db.delete(item)
db.commit()
@@ -99,6 +114,7 @@ def show_list(
id_=item.id,
name=item.name,
permissions=[p.name for p in sorted(item.permissions, key=lambda p: p.name)],
included_roles=[r.name for r in sorted(item.included_roles, key=lambda r: r.name)],
)
for item in db.execute(select(Role).order_by(Role.name)).scalars().all()
]
@@ -115,6 +131,8 @@ def show_id(
def role_info(item: Role, db: Session) -> schemas.Role:
all_roles = db.execute(select(Role).order_by(Role.name)).scalars().all()
all_perms = db.execute(select(Permission).order_by(Permission.name)).scalars().all()
return schemas.Role(
id_=item.id,
name=item.name,
@@ -124,17 +142,36 @@ def role_info(item: Role, db: Session) -> schemas.Role:
name=p.name,
enabled=p in item.permissions,
)
for p in db.execute(select(Permission).order_by(Permission.name)).scalars().all()
for p in all_perms
],
included_roles=[
schemas.RoleItem(
id_=r.id,
name=r.name,
enabled=r in item.included_roles,
permission_ids=role_effective_permission_ids(r.id, db),
)
for r in all_roles
if r.id != item.id
],
)
def role_blank(db: Session) -> schemas.RoleBlank:
all_roles = db.execute(select(Role).order_by(Role.name)).scalars().all()
all_perms = db.execute(select(Permission).order_by(Permission.name)).scalars().all()
return schemas.RoleBlank(
name="",
permissions=[
PermissionItem(id_=p.id, name=p.name, enabled=False)
for p in db.execute(select(Permission).order_by(Permission.name)).scalars().all()
permissions=[PermissionItem(id_=p.id, name=p.name, enabled=False) for p in all_perms],
included_roles=[
schemas.RoleItem(
id_=r.id,
name=r.name,
enabled=False,
permission_ids=role_effective_permission_ids(r.id, db),
)
for r in all_roles
],
)
@@ -146,3 +183,78 @@ def add_permissions(role: Role, permissions: list[PermissionItem], db: Session)
role.permissions.append(db.execute(select(Permission).where(Permission.id == permission.id_)).scalar_one())
elif not permission.enabled and gp:
role.permissions.remove(gp)
def add_included_roles(role: Role, included_roles: list[schemas.RoleItem], db: Session) -> None:
for inc in included_roles:
if inc.id_ == role.id:
raise HTTPException(status_code=400, detail="A role cannot include itself.")
existing = next((r for r in role.included_roles if r.id == inc.id_), None)
if inc.enabled and existing is None:
# cycle prevention: if role is already reachable from inc.id_, adding would create a cycle
if role_is_reachable_from(start_role_id=inc.id_, target_role_id=role.id, db=db):
raise HTTPException(
status_code=400,
detail="Invalid include: would create a cycle in role hierarchy.",
)
role.included_roles.append(db.execute(select(Role).where(Role.id == inc.id_)).scalar_one())
elif not inc.enabled and existing is not None:
role.included_roles.remove(existing)
def role_is_reachable_from(start_role_id: uuid.UUID, target_role_id: uuid.UUID, db: Session) -> bool:
"""
Returns True if target_role_id is reachable from start_role_id via role_includes edges.
This prevents cycles when adding: role -> included_role.
"""
cte = (
select(RoleInclude.included_role_id.label("role_id"))
.where(RoleInclude.role_id == start_role_id)
.cte("role_tree", recursive=True)
)
cte = cte.union_all(select(RoleInclude.included_role_id).where(RoleInclude.role_id == cte.c.role_id))
q = select(count()).select_from(cte).where(cte.c.role_id == target_role_id)
return db.execute(q).scalar_one() > 0
def role_effective_permission_names(role_id: uuid.UUID, db: Session) -> list[str]:
"""
All permissions for this role INCLUDING inherited permissions via included roles (recursive).
"""
# role closure CTE
roles_cte = select(Role.id.label("role_id")).where(Role.id == role_id).cte("roles_cte", recursive=True)
roles_cte = roles_cte.union_all(
select(RoleInclude.included_role_id).where(RoleInclude.role_id == roles_cte.c.role_id)
)
q = (
select(Permission.name)
.join(RolePermission, RolePermission.permission_id == Permission.id)
.join(roles_cte, roles_cte.c.role_id == RolePermission.role_id)
.distinct()
.order_by(Permission.name)
)
return [r for r in db.execute(q).scalars().all()]
def role_effective_permission_ids(role_id: uuid.UUID, db: Session) -> list[uuid.UUID]:
"""
All permission IDs for this role INCLUDING inherited permissions via included roles (recursive).
"""
roles_cte = select(Role.id.label("role_id")).where(Role.id == role_id).cte("roles_cte", recursive=True)
roles_cte = roles_cte.union_all(
select(RoleInclude.included_role_id).where(RoleInclude.role_id == roles_cte.c.role_id)
)
q = (
select(Permission.id)
.join(RolePermission, RolePermission.permission_id == Permission.id)
.join(roles_cte, roles_cte.c.role_id == RolePermission.role_id)
.distinct()
)
return [r for r in db.execute(q).scalars().all()]
@@ -13,6 +13,7 @@ from pydantic import (
)
from . import Daf, to_camel
from .sale_category import SaleCategoryLink
class BeerConsumptionReportItem(BaseModel):
@@ -56,6 +57,7 @@ class BeerConsumptionReportItem(BaseModel):
class BeerConsumptionReport(BaseModel):
start_date: date
finish_date: date
sale_category: SaleCategoryLink | None
regular: bool | None
happy: bool | None
staff: bool | None
+2 -2
View File
@@ -19,7 +19,7 @@ from .user import UserLink
class ProductSaleReportItem(BaseModel):
product_version_id: uuid.UUID
sku_id: uuid.UUID
name: str
is_happy_hour: bool
@@ -40,7 +40,7 @@ class ProductSaleReportItem(BaseModel):
@model_serializer(mode="plain")
def custom_dump(self) -> dict: # type: ignore
base = {
"productVersionId": str(self.product_version_id),
"skuId": str(self.sku_id),
"name": self.name,
"isHappyHour": self.is_happy_hour,
}
+14 -9
View File
@@ -8,10 +8,21 @@ from . import to_camel
from .permission import PermissionItem
class RoleItem(BaseModel):
id_: uuid.UUID
name: str
enabled: bool
permission_ids: list[uuid.UUID] = []
model_config = ConfigDict(str_strip_whitespace=True, alias_generator=to_camel, populate_by_name=True)
class RoleIn(BaseModel):
name: Annotated[str, Field(min_length=1)]
permissions: list[PermissionItem]
model_config = ConfigDict(str_strip_whitespace=True)
included_roles: list[RoleItem]
model_config = ConfigDict(str_strip_whitespace=True, alias_generator=to_camel, populate_by_name=True)
class Role(RoleIn):
@@ -21,18 +32,12 @@ class Role(RoleIn):
class RoleBlank(RoleIn):
name: str
model_config = ConfigDict(str_strip_whitespace=True)
model_config = ConfigDict(str_strip_whitespace=True, alias_generator=to_camel, populate_by_name=True)
class RoleList(BaseModel):
id_: uuid.UUID
name: str
permissions: list[str]
included_roles: list[str]
model_config = ConfigDict(alias_generator=to_camel, populate_by_name=True, str_strip_whitespace=True)
class RoleItem(BaseModel):
id_: uuid.UUID
name: str
enabled: bool
model_config = ConfigDict(alias_generator=to_camel, populate_by_name=True)
+71 -22
View File
@@ -3,6 +3,9 @@ import multiprocessing
import os
# --- Worker sizing -----------------------------------------------------------
# Prefer explicit WEB_CONCURRENCY in containers. Otherwise fall back to
# WORKERS_PER_CORE * cpu_count(), with a minimum of 2 and optional MAX_WORKERS.
workers_per_core_str = os.getenv("WORKERS_PER_CORE", "1")
max_workers_str = os.getenv("MAX_WORKERS")
use_max_workers = None
@@ -10,12 +13,6 @@ if max_workers_str:
use_max_workers = int(max_workers_str)
web_concurrency_str = os.getenv("WEB_CONCURRENCY", None)
host = os.getenv("HOST", "0.0.0.0")
port = os.getenv("PORT", "9995")
bind_env = os.getenv("BIND", None)
use_loglevel = os.getenv("LOG_LEVEL", "info")
use_bind = bind_env if bind_env else f"{host}:{port}"
cores = multiprocessing.cpu_count()
workers_per_core = float(workers_per_core_str)
default_web_concurrency = workers_per_core * cores
@@ -26,27 +23,47 @@ else:
web_concurrency = max(int(default_web_concurrency), 2)
if use_max_workers:
web_concurrency = min(web_concurrency, use_max_workers)
accesslog_var = os.getenv("ACCESS_LOG", "-")
use_accesslog = accesslog_var or None
errorlog_var = os.getenv("ERROR_LOG", "-")
use_errorlog = errorlog_var or None
graceful_timeout_str = os.getenv("GRACEFUL_TIMEOUT", "120")
timeout_str = os.getenv("TIMEOUT", "120")
keepalive_str = os.getenv("KEEP_ALIVE", "5")
# Gunicorn config variables
loglevel = use_loglevel
# --- Bind / logging ----------------------------------------------------------
host = os.getenv("HOST", "0.0.0.0")
port = os.getenv("PORT", "9995")
bind_env = os.getenv("BIND", None)
use_bind = bind_env if bind_env else f"{host}:{port}"
loglevel = os.getenv("LOG_LEVEL", "info")
accesslog_var = os.getenv("ACCESS_LOG", "-")
accesslog = accesslog_var or None
errorlog_var = os.getenv("ERROR_LOG", "-")
errorlog = errorlog_var or None
worker_tmp_dir = "/dev/shm"
# --- Timeouts / keepalive ----------------------------------------------------
graceful_timeout = int(os.getenv("GRACEFUL_TIMEOUT", "120"))
timeout = int(os.getenv("TIMEOUT", "120"))
keepalive = int(os.getenv("KEEP_ALIVE", "5"))
# --- Robustness knobs (recommended) -----------------------------------------
# Recycle workers gradually to mitigate memory leaks / fragmentation without
# full container restarts. Tune via env if needed.
max_requests = int(os.getenv("MAX_REQUESTS", "10000"))
max_requests_jitter = int(os.getenv("MAX_REQUESTS_JITTER", "1000"))
# Prevent slow clients from holding connections forever (defaults are fine).
# You can tune these via env if you ever need to.
# worker_connections matters only for async worker types; kept here for clarity.
worker_connections = int(os.getenv("WORKER_CONNECTIONS", "1000"))
# Helpful in containerized environments: ensure workers are responsive.
# (Defaults are okay; leaving commented unless you want strict behavior.)
# heartbeat_interval = int(os.getenv("HEARTBEAT_INTERVAL", "30"))
# --- Gunicorn config vars ----------------------------------------------------
workers = web_concurrency
bind = use_bind
errorlog = use_errorlog
worker_tmp_dir = "/dev/shm"
accesslog = use_accesslog
graceful_timeout = int(graceful_timeout_str)
timeout = int(timeout_str)
keepalive = int(keepalive_str)
# For debugging and testing
# --- Debug print (keep if you like) ------------------------------------------
log_data = {
"loglevel": loglevel,
"workers": workers,
@@ -56,10 +73,42 @@ log_data = {
"keepalive": keepalive,
"errorlog": errorlog,
"accesslog": accesslog,
"worker_tmp_dir": worker_tmp_dir,
"max_requests": max_requests,
"max_requests_jitter": max_requests_jitter,
"worker_connections": worker_connections,
# Additional, non-gunicorn variables
"workers_per_core": workers_per_core,
"use_max_workers": use_max_workers,
"host": host,
"port": port,
"cores": cores,
}
print(json.dumps(log_data))
# Variables Gunicorn reads
# (these must be module-level names)
# fmt: off
# Core
loglevel = loglevel
workers = workers
bind = bind
# Logging
accesslog = accesslog
errorlog = errorlog
# Runtime
worker_tmp_dir = worker_tmp_dir
graceful_timeout = graceful_timeout
timeout = timeout
keepalive = keepalive
# Recycling
max_requests = max_requests
max_requests_jitter = max_requests_jitter
# Concurrency (relevant for some worker types; harmless otherwise)
worker_connections = worker_connections
# fmt: on
+3720
View File
File diff suppressed because it is too large Load Diff
+1 -1
View File
@@ -1,6 +1,6 @@
[tool.poetry]
name = "barker"
version = "14.0.1"
version = "14.3.0"
description = "Point of Sale for a restaurant"
authors = ["tanshu <git@tanshu.com>"]
-3
View File
@@ -1,3 +0,0 @@
#!/usr/bin/env bash
set -euo pipefail
gunicorn barker.main:app --worker-class uvicorn.workers.UvicornWorker --config ./gunicorn.conf.py --log-config ./logging.conf
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "bookie",
"version": "14.0.1",
"version": "14.3.0",
"scripts": {
"ng": "ng",
"start": "ng serve",
+1 -1
View File
@@ -1,7 +1,7 @@
export const environment = {
production: true,
ACCESS_TOKEN_REFRESH_MINUTES: 10, // refresh token 10 minutes before expiry
version: '14.0.1',
version: '14.3.0',
};
export const dateFormat = {
@@ -31,6 +31,16 @@
<mat-datepicker-toggle matSuffix [for]="finishDate"></mat-datepicker-toggle>
<mat-datepicker #finishDate></mat-datepicker>
</mat-form-field>
<mat-form-field class="flex-auto">
<mat-label>Sale Category</mat-label>
<mat-select formControlName="saleCategory" (selectionChange)="filterOn($event.value)">
@for (sc of saleCategories; track sc) {
<mat-option [value]="sc.id">
{{ sc.name }}
</mat-option>
}
</mat-select>
</mat-form-field>
<button mat-raised-button class="flex-auto basis-1-5" color="primary" (click)="show()">Show</button>
</div>
<div class="row-container sm:max-lg:flex-col">
@@ -51,7 +61,7 @@
@for (col of info.headers; track col) {
<ng-container matColumnDef="{{ col }}">
<mat-header-cell *matHeaderCellDef class="right">{{ col }}</mat-header-cell>
<mat-cell *matCellDef="let row" class="right">{{ row[col] }}</mat-cell>
<mat-cell *matCellDef="let row" class="right">{{ row[col] | number: '1.2-2' }}</mat-cell>
</ng-container>
}
@@ -1,3 +1,4 @@
import { CommonModule } from '@angular/common';
import { Component, OnInit, inject } from '@angular/core';
import { FormControl, FormGroup, ReactiveFormsModule } from '@angular/forms';
import { MatButtonModule } from '@angular/material/button';
@@ -6,10 +7,12 @@ import { MatDatepickerModule } from '@angular/material/datepicker';
import { MatFormFieldModule } from '@angular/material/form-field';
import { MatIconModule } from '@angular/material/icon';
import { MatInputModule } from '@angular/material/input';
import { MatSelectModule } from '@angular/material/select';
import { MatTableModule } from '@angular/material/table';
import { ActivatedRoute, Router } from '@angular/router';
import moment from 'moment';
import { SaleCategory } from '../core/sale-category';
import { ToCsvService } from '../shared/to-csv.service';
import { BeerSaleExportHeaderInterface } from './beer-sale-export-header-interface';
import { BeerSaleReport } from './beer-sale-report';
@@ -20,13 +23,14 @@ import { BeerSaleReportDataSource } from './beer-sale-report-datasource';
templateUrl: './beer-sale-report.component.html',
styleUrls: ['./beer-sale-report.component.css'],
imports: [
CommonModule,
MatButtonModule,
MatCheckboxModule,
MatDatepickerModule,
MatFormFieldModule,
MatIconModule,
MatInputModule,
MatSelectModule,
MatTableModule,
ReactiveFormsModule,
],
@@ -36,11 +40,13 @@ export class BeerSaleReportComponent implements OnInit {
private router = inject(Router);
private toCsv = inject(ToCsvService);
saleCategories: SaleCategory[] = [];
info: BeerSaleReport = new BeerSaleReport();
dataSource: BeerSaleReportDataSource = new BeerSaleReportDataSource(this.info.data);
form: FormGroup<{
startDate: FormControl<Date>;
finishDate: FormControl<Date>;
saleCategory: FormControl<string>;
regular: FormControl<boolean>;
happy: FormControl<boolean>;
staff: FormControl<boolean>;
@@ -55,6 +61,7 @@ export class BeerSaleReportComponent implements OnInit {
this.form = new FormGroup({
startDate: new FormControl(new Date(), { nonNullable: true }),
finishDate: new FormControl(new Date(), { nonNullable: true }),
saleCategory: new FormControl('', { nonNullable: true }),
regular: new FormControl<boolean>(true, { nonNullable: true }),
happy: new FormControl<boolean>(true, { nonNullable: true }),
staff: new FormControl<boolean>(true, { nonNullable: true }),
@@ -64,12 +71,14 @@ export class BeerSaleReportComponent implements OnInit {
ngOnInit() {
this.route.data.subscribe((value) => {
const data = value as { info: BeerSaleReport };
const data = value as { info: BeerSaleReport; saleCategories: SaleCategory[] };
this.info = data.info;
this.saleCategories = data.saleCategories;
this.displayedColumns = ['date'].concat(this.info.headers);
this.form.setValue({
startDate: moment(this.info.startDate, 'DD-MMM-YYYY').toDate(),
finishDate: moment(this.info.finishDate, 'DD-MMM-YYYY').toDate(),
saleCategory: this.info.saleCategory?.id ?? '',
regular: this.info.regular,
happy: this.info.happy,
staff: this.info.staff,
@@ -85,6 +94,7 @@ export class BeerSaleReportComponent implements OnInit {
queryParams: {
startDate: info.startDate,
finishDate: info.finishDate,
saleCategory: info.saleCategory?.id ?? '',
regular: info.regular,
happy: info.happy,
staff: info.staff,
@@ -93,12 +103,18 @@ export class BeerSaleReportComponent implements OnInit {
});
}
filterOn(id: string) {
const sc = this.saleCategories.find((x) => x.id === id);
this.info.saleCategory = sc ? sc : new SaleCategory({ id });
}
getInfo(): BeerSaleReport {
const formModel = this.form.value;
return new BeerSaleReport({
startDate: moment(formModel.startDate).format('DD-MMM-YYYY'),
finishDate: moment(formModel.finishDate).format('DD-MMM-YYYY'),
saleCategory: this.info.saleCategory,
regular: formModel.regular,
happy: formModel.happy,
staff: formModel.staff,
@@ -7,9 +7,10 @@ import { BeerSaleReportService } from './beer-sale-report.service';
export const beerSaleReportResolver: ResolveFn<BeerSaleReport> = (route) => {
const startDate = route.queryParamMap.get('startDate') ?? null;
const finishDate = route.queryParamMap.get('finishDate') ?? null;
const saleCategory = route.queryParamMap.get('saleCategory') ?? null;
const regular = route.queryParamMap.get('regular') !== 'false';
const happy = route.queryParamMap.get('happy') !== 'false';
const staff = route.queryParamMap.get('staff') !== 'false';
const nc = route.queryParamMap.get('nc') !== 'false';
return inject(BeerSaleReportService).get(startDate, finishDate, regular, happy, staff, nc);
return inject(BeerSaleReportService).get(startDate, finishDate, saleCategory, regular, happy, staff, nc);
};
@@ -1,6 +1,7 @@
import { Routes } from '@angular/router';
import { authGuard } from '../auth/auth-guard.service';
import { saleCategoryListResolver } from '../sale-category/sale-category-list.resolver';
import { BeerSaleReportComponent } from './beer-sale-report.component';
import { beerSaleReportResolver } from './beer-sale-report.resolver';
@@ -14,6 +15,7 @@ export const routes: Routes = [
},
resolve: {
info: beerSaleReportResolver,
saleCategories: saleCategoryListResolver,
},
runGuardsAndResolvers: 'always',
},
@@ -19,6 +19,7 @@ export class BeerSaleReportService {
get(
startDate: string | null,
finishDate: string | null,
saleCategory: string | null,
regular: boolean,
happy: boolean,
staff: boolean,
@@ -31,6 +32,9 @@ export class BeerSaleReportService {
if (finishDate !== null) {
options.params = options.params.set('f', finishDate);
}
if (saleCategory != null) {
options.params = options.params.set('sc', saleCategory);
}
options.params = options.params.set('r', regular);
options.params = options.params.set('h', happy);
options.params = options.params.set('st', staff);
@@ -1,8 +1,10 @@
import { SaleCategory } from '../core/sale-category';
import { BeerSaleReportItem } from './beer-sale-report-item';
export class BeerSaleReport {
startDate: string;
finishDate: string;
saleCategory: SaleCategory;
regular: boolean;
happy: boolean;
staff: boolean;
@@ -13,6 +15,7 @@ export class BeerSaleReport {
public constructor(init?: Partial<BeerSaleReport>) {
this.startDate = '';
this.finishDate = '';
this.saleCategory = new SaleCategory();
this.regular = true;
this.happy = true;
this.staff = true;
@@ -71,7 +71,7 @@ export class ProductListDataSource extends DataSource<Product> {
}),
);
const matchesMenuCategory = menuCategory === '' || skus.some((k) => (k.menuCategory?.id ?? '') === menuCategory);
const matchesMenuCategory = !menuCategory || skus.some((k) => (k.menuCategory?.id ?? '') === menuCategory);
return matchesSearch && matchesMenuCategory;
});
}
@@ -75,9 +75,6 @@ export class ProductListComponent implements OnInit {
this.menuCategoryFilter.subscribe((val) => {
console.log('Menu category filter changed to ', val);
});
this.searchFilter.subscribe((val) => {
console.log('Search filter changed to ', val);
});
}
filterOn(val: string) {
@@ -0,0 +1,8 @@
.two-col {
display: flex;
gap: 16px;
}
.col {
flex: 1;
min-width: 0;
}
@@ -7,12 +7,24 @@
</mat-form-field>
</div>
<div formArrayName="permissions">
@for (p of item.permissions; track p; let i = $index) {
<div class="row-container" [formGroupName]="i">
<mat-checkbox formControlName="permission" class="flex-auto">{{ p.name }}</mat-checkbox>
</div>
}
<div class="two-col">
<div formArrayName="permissions" class="col">
<h3>Permissions</h3>
@for (p of item.permissions; track p; let i = $index) {
<div class="row-container" [formGroupName]="i">
<mat-checkbox formControlName="permission" class="flex-auto">{{ p.name }}</mat-checkbox>
</div>
}
</div>
<div formArrayName="includedRoles" class="col">
<h3>Includes Roles</h3>
@for (r of item.includedRoles; track r; let i = $index) {
<div class="row-container" [formGroupName]="i">
<mat-checkbox formControlName="role" class="flex-auto">{{ r.name }}</mat-checkbox>
</div>
}
</div>
</div>
</form>
@@ -1,4 +1,4 @@
import { AfterViewInit, Component, ElementRef, OnInit, ViewChild, inject } from '@angular/core';
import { AfterViewInit, Component, ElementRef, OnDestroy, OnInit, ViewChild, inject } from '@angular/core';
import { FormArray, FormControl, FormGroup, ReactiveFormsModule } from '@angular/forms';
import { MatButtonModule } from '@angular/material/button';
import { MatCheckboxModule } from '@angular/material/checkbox';
@@ -7,6 +7,7 @@ import { MatFormFieldModule } from '@angular/material/form-field';
import { MatInputModule } from '@angular/material/input';
import { MatSnackBar } from '@angular/material/snack-bar';
import { ActivatedRoute, Router } from '@angular/router';
import { Subject, takeUntil } from 'rxjs';
import { ConfirmDialogComponent } from '../../shared/confirm-dialog/confirm-dialog.component';
import { Role } from '../role';
@@ -18,13 +19,15 @@ import { RoleService } from '../role.service';
styleUrls: ['./role-detail.component.css'],
imports: [MatButtonModule, MatCheckboxModule, MatFormFieldModule, MatInputModule, ReactiveFormsModule],
})
export class RoleDetailComponent implements OnInit, AfterViewInit {
export class RoleDetailComponent implements OnInit, AfterViewInit, OnDestroy {
private route = inject(ActivatedRoute);
private router = inject(Router);
private snackBar = inject(MatSnackBar);
private dialog = inject(MatDialog);
private ser = inject(RoleService);
private destroyed$ = new Subject<void>();
@ViewChild('nameElement', { static: true }) nameElement?: ElementRef;
form: FormGroup<{
name: FormControl<string>;
@@ -33,6 +36,11 @@ export class RoleDetailComponent implements OnInit, AfterViewInit {
permission: FormControl<boolean>;
}>
>;
includedRoles: FormArray<
FormGroup<{
role: FormControl<boolean>;
}>
>;
}>;
item: Role = new Role();
@@ -42,6 +50,7 @@ export class RoleDetailComponent implements OnInit, AfterViewInit {
this.form = new FormGroup({
name: new FormControl<string>('', { nonNullable: true }),
permissions: new FormArray<FormGroup<{ permission: FormControl<boolean> }>>([]),
includedRoles: new FormArray<FormGroup<{ role: FormControl<boolean> }>>([]),
});
}
@@ -51,7 +60,7 @@ export class RoleDetailComponent implements OnInit, AfterViewInit {
this.item = data.item;
this.form.controls.name.setValue(this.item.name);
this.form.controls.permissions.reset();
this.form.controls.permissions.clear();
this.item.permissions.forEach((x) =>
this.form.controls.permissions.push(
new FormGroup({
@@ -59,6 +68,26 @@ export class RoleDetailComponent implements OnInit, AfterViewInit {
}),
),
);
this.form.controls.includedRoles.clear();
this.item.includedRoles.forEach((x) =>
this.form.controls.includedRoles.push(
new FormGroup({
role: new FormControl<boolean>(x.enabled, { nonNullable: true }),
}),
),
);
// Rebind listeners (important when route data changes)
this.destroyed$.next();
// When included roles change, recompute locks
this.form.controls.includedRoles.valueChanges.pipe(takeUntil(this.destroyed$)).subscribe(() => {
this.applyIncludedRolePermissionLocks();
});
// Apply initial locks immediately
this.applyIncludedRolePermissionLocks();
});
}
@@ -70,6 +99,43 @@ export class RoleDetailComponent implements OnInit, AfterViewInit {
}, 0);
}
ngOnDestroy(): void {
this.destroyed$.next();
this.destroyed$.complete();
}
private applyIncludedRolePermissionLocks(): void {
// 1) collect all permission IDs implied by enabled included roles
const implied = new Set<string>();
this.item.includedRoles.forEach((roleOpt, idx) => {
const checked = this.form.controls.includedRoles.at(idx).controls.role.value;
if (checked) {
(roleOpt.permissionIds || []).forEach((pid) => implied.add(pid));
}
});
// 2) apply to permission checkboxes
this.item.permissions.forEach((perm, idx) => {
const ctrl = this.form.controls.permissions.at(idx).controls.permission;
const isImplied = implied.has(perm.id as string);
const isDirect = perm.enabled === true; // from backend: direct assignment
if (isImplied) {
// must be checked + disabled
ctrl.setValue(true, { emitEvent: false });
ctrl.disable({ emitEvent: false });
} else {
// not implied -> enable checkbox
ctrl.enable({ emitEvent: false });
// restore to direct-enabled state (so user sees what is explicitly on the role)
ctrl.setValue(isDirect, { emitEvent: false });
}
});
}
save() {
this.ser.saveOrUpdate(this.getItem()).subscribe({
next: () => {
@@ -110,9 +176,21 @@ export class RoleDetailComponent implements OnInit, AfterViewInit {
getItem(): Role {
const formModel = this.form.value;
this.item.name = formModel.name ?? '';
const array = this.form.controls.permissions;
this.item.permissions.forEach((item, index) => {
item.enabled = array.controls[index].value.permission ?? false;
const permArray = this.form.controls.permissions;
this.item.permissions.forEach((p, index) => {
// If disabled, keep p.enabled as it was originally direct (dont accidentally mark direct)
if (permArray.at(index).controls.permission.disabled) {
// leave p.enabled unchanged
return;
}
p.enabled = permArray.at(index).controls.permission.value;
});
// this.item.permissions.forEach((item, index) => {
// item.enabled = permArray.controls[index].value.permission ?? false;
// });
const includeArray = this.form.controls.includedRoles;
this.item.includedRoles.forEach((r, index) => {
r.enabled = includeArray.controls[index]?.value.role ?? false;
});
return this.item;
}
@@ -14,6 +14,22 @@
>
</ng-container>
<!-- Included Roles Column (NEW) -->
<ng-container matColumnDef="includedRoles">
<mat-header-cell *matHeaderCellDef>Includes</mat-header-cell>
<mat-cell *matCellDef="let row">
@if (row.includedRoles?.length) {
<ul>
@for (r of row.includedRoles; track r) {
<li>{{ r }}</li>
}
</ul>
} @else {
<span>-</span>
}
</mat-cell>
</ng-container>
<!-- Permissions Column -->
<ng-container matColumnDef="permissions">
<mat-header-cell *matHeaderCellDef>Permissions</mat-header-cell>
@@ -19,7 +19,7 @@ export class RoleListComponent implements OnInit {
list: Role[] = [];
dataSource: RoleListDataSource = new RoleListDataSource(this.list);
/** Columns displayed in the table. Columns IDs can be added, removed, or reordered. */
displayedColumns = ['name', 'permissions'];
displayedColumns = ['name', 'includedRoles', 'permissions'];
ngOnInit() {
this.route.data.subscribe((value) => {
+18
View File
@@ -1,14 +1,32 @@
import { Permission } from './permission';
export class RoleItem {
id: string | undefined;
name: string;
enabled: boolean;
permissionIds: string[];
public constructor(init?: Partial<RoleItem>) {
this.id = undefined;
this.name = '';
this.enabled = false;
this.permissionIds = [];
Object.assign(this, init);
}
}
export class Role {
id: string | undefined;
name: string;
permissions: Permission[];
includedRoles: RoleItem[];
public constructor(init?: Partial<Role>) {
this.id = undefined;
this.name = '';
this.permissions = [];
this.includedRoles = [];
Object.assign(this, init);
}
}
+23 -22
View File
@@ -37,8 +37,7 @@ export class BillService {
public discountAmount: Observable<number>;
public hhAmount: Observable<number>;
public taxAmount: Observable<number>;
public amount: Observable<number>;
public amountVal: number;
public amount: BehaviorSubject<number>;
public selection = new SelectionModel<string>(true, []);
private updateTable: boolean;
private allowDeactivate: boolean;
@@ -47,7 +46,6 @@ export class BillService {
constructor() {
this.dataObs = new BehaviorSubject<Kot[]>([]);
this.amountVal = 0;
this.updateTable = true;
this.allowDeactivate = false;
@@ -97,25 +95,28 @@ export class BillService {
}),
);
this.amount = this.dataObs.pipe(
map((kots: Kot[]) => {
return this.math.halfRoundEven(
kots.reduce(
(t, k) =>
k.inventories.reduce(
(a, c) =>
a +
this.math.halfRoundEven(
(c.isHappyHour ? 0 : c.price) * c.quantity * (1 - c.discount) * (1 + c.taxRate),
2,
),
0,
) + t,
0,
),
);
}),
);
this.amount = new BehaviorSubject<number>(0);
this.dataObs
.pipe(
map((kots: Kot[]) => {
return this.math.halfRoundEven(
kots.reduce(
(t, k) =>
k.inventories.reduce(
(a, c) =>
a +
this.math.halfRoundEven(
(c.isHappyHour ? 0 : c.price) * c.quantity * (1 - c.discount) * (1 + c.taxRate),
2,
),
0,
) + t,
0,
),
);
}),
)
.subscribe((value) => this.amount.next(value));
}
displayBill(): void {
@@ -90,7 +90,7 @@
<ng-container matColumnDef="kotActions">
<mat-header-cell *matHeaderCellDef>Quantity</mat-header-cell>
<mat-cell *matCellDef="let row" class="right-align">
<button mat-icon-button (click)="moveKot(row)" [disabled]="!row.kotId">
<button mat-icon-button (click)="moveKot(row)" [disabled]="!row.kot.id">
<mat-icon class="del">open_in_new</mat-icon>
</button>
</mat-cell>
@@ -195,7 +195,7 @@ export class SalesHomeComponent {
if (!this.receivePaymentAllowed()) {
return;
}
const amount = this.bs.amountVal;
const amount = this.bs.amount.value;
const type = this.bs.bill.voucherType;
this.dialog
.open(ReceivePaymentComponent, {
+1 -1
View File
@@ -1,6 +1,6 @@
[tool.poetry]
name = "frank"
version = "14.0.1"
version = "14.3.0"
description = "Point of Sale for a restaurant"
authors = ["tanshu <git@tanshu.com>"]