From 124cf4d9ffe4ce9555e4501c149c1e68b49c65f5 Mon Sep 17 00:00:00 2001 From: tanshu Date: Wed, 27 Oct 2021 09:27:47 +0530 Subject: [PATCH] Fix: Username unique index was case sensitive and this allowed duplicate names. Feature: Moved temporal products into their own module and reverted the products module --- .../0e326930b8a4_temporal_products_fixed.py | 46 +++ barker/alembic/versions/3609c44430c8_user.py | 31 +++ barker/barker/main.py | 2 + barker/barker/models/product_version.py | 5 + barker/barker/models/user.py | 7 +- barker/barker/routers/product.py | 263 +++++++----------- barker/barker/routers/temporal_product.py | 257 +++++++++++++++++ bookie/src/app/app-routing.module.ts | 7 + bookie/src/app/auth/login/login.component.ts | 2 +- bookie/src/app/core/product.ts | 8 +- bookie/src/app/home/home.component.html | 9 + .../product-detail.component.css | 3 - .../product-detail.component.html | 192 ++++++------- .../product-detail.component.ts | 16 +- .../product-list/product-list.component.css | 20 ++ .../product-list/product-list.component.html | 2 +- .../product-list/product-list.component.ts | 1 - .../app/product/product-resolver.service.ts | 4 +- bookie/src/app/product/product.service.ts | 8 +- .../app/product/products-routing.module.ts | 4 +- bookie/src/app/product/products.module.ts | 2 - .../temporal-product-detail.component.css | 0 .../temporal-product-detail.component.html | 123 ++++++++ .../temporal-product-detail.component.spec.ts | 26 ++ .../temporal-product-detail.component.ts | 154 ++++++++++ ...oral-product-list-resolver.service.spec.ts | 18 ++ .../temporal-product-list-resolver.service.ts | 18 ++ .../temporal-product-list-datasource.ts | 98 +++++++ .../temporal-product-list.component.css | 28 ++ .../temporal-product-list.component.html | 124 +++++++++ .../temporal-product-list.component.spec.ts | 22 ++ .../temporal-product-list.component.ts | 91 ++++++ .../temporal-product-resolver.service.spec.ts | 18 ++ .../temporal-product-resolver.service.ts | 19 ++ .../temporal-product.service.spec.ts | 15 + .../temporal-product.service.ts | 43 +++ .../temporal-products-routing.module.spec.ts | 13 + .../temporal-products-routing.module.ts | 61 ++++ .../temporal-products.module.spec.ts | 13 + .../temporal-products.module.ts | 62 +++++ 40 files changed, 1522 insertions(+), 313 deletions(-) create mode 100644 barker/alembic/versions/0e326930b8a4_temporal_products_fixed.py create mode 100644 barker/alembic/versions/3609c44430c8_user.py create mode 100644 barker/barker/routers/temporal_product.py create mode 100644 bookie/src/app/temporal-product/temporal-product-detail/temporal-product-detail.component.css create mode 100644 bookie/src/app/temporal-product/temporal-product-detail/temporal-product-detail.component.html create mode 100644 bookie/src/app/temporal-product/temporal-product-detail/temporal-product-detail.component.spec.ts create mode 100644 bookie/src/app/temporal-product/temporal-product-detail/temporal-product-detail.component.ts create mode 100644 bookie/src/app/temporal-product/temporal-product-list-resolver.service.spec.ts create mode 100644 bookie/src/app/temporal-product/temporal-product-list-resolver.service.ts create mode 100644 bookie/src/app/temporal-product/temporal-product-list/temporal-product-list-datasource.ts create mode 100644 bookie/src/app/temporal-product/temporal-product-list/temporal-product-list.component.css create mode 100644 bookie/src/app/temporal-product/temporal-product-list/temporal-product-list.component.html create mode 100644 bookie/src/app/temporal-product/temporal-product-list/temporal-product-list.component.spec.ts create mode 100644 bookie/src/app/temporal-product/temporal-product-list/temporal-product-list.component.ts create mode 100644 bookie/src/app/temporal-product/temporal-product-resolver.service.spec.ts create mode 100644 bookie/src/app/temporal-product/temporal-product-resolver.service.ts create mode 100644 bookie/src/app/temporal-product/temporal-product.service.spec.ts create mode 100644 bookie/src/app/temporal-product/temporal-product.service.ts create mode 100644 bookie/src/app/temporal-product/temporal-products-routing.module.spec.ts create mode 100644 bookie/src/app/temporal-product/temporal-products-routing.module.ts create mode 100644 bookie/src/app/temporal-product/temporal-products.module.spec.ts create mode 100644 bookie/src/app/temporal-product/temporal-products.module.ts diff --git a/barker/alembic/versions/0e326930b8a4_temporal_products_fixed.py b/barker/alembic/versions/0e326930b8a4_temporal_products_fixed.py new file mode 100644 index 0000000..832854c --- /dev/null +++ b/barker/alembic/versions/0e326930b8a4_temporal_products_fixed.py @@ -0,0 +1,46 @@ +"""temporal products fixed + +Revision ID: 0e326930b8a4 +Revises: 3609c44430c8 +Create Date: 2021-10-22 09:36:11.746119 + +""" +import sqlalchemy as sa + +from alembic import op +from sqlalchemy import column, func, table, text +from sqlalchemy.dialects import postgresql + + +# revision identifiers, used by Alembic. +revision = "0e326930b8a4" +down_revision = "3609c44430c8" +branch_labels = None +depends_on = None + + +def upgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.create_unique_constraint(op.f("uq_vouchers_bill_id"), "vouchers", ["bill_id", "voucher_type"]) + + prod = table( + "product_versions", + column("id", postgresql.UUID(as_uuid=True)), + column("product_id", postgresql.UUID(as_uuid=True)), + column("valid_from", sa.Date()), + column("valid_till", sa.Date()), + ) + + op.create_exclude_constraint( + "uq_product_versions_product_id", + "product_versions", + (prod.c.product_id, "="), + (func.daterange(prod.c.valid_from, prod.c.valid_till, text("'[]'")), "&&"), + ) + # ### end Alembic commands ### + + +def downgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.drop_constraint(op.f("uq_vouchers_bill_id"), "vouchers", type_="unique") + # ### end Alembic commands ### diff --git a/barker/alembic/versions/3609c44430c8_user.py b/barker/alembic/versions/3609c44430c8_user.py new file mode 100644 index 0000000..9ce01aa --- /dev/null +++ b/barker/alembic/versions/3609c44430c8_user.py @@ -0,0 +1,31 @@ +"""user + +Revision ID: 3609c44430c8 +Revises: 81d94c5223a7 +Create Date: 2021-09-28 09:18:10.666701 + +""" +import sqlalchemy as sa + +from alembic import op + + +# revision identifiers, used by Alembic. +revision = "3609c44430c8" +down_revision = "81d94c5223a7" +branch_labels = None +depends_on = None + + +def upgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.drop_constraint("uq_users_name", "users", type_="unique") + op.create_index("uq_users_name", "users", [sa.text("lower(name)")], unique=True) + # ### end Alembic commands ### + + +def downgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.drop_index("uq_users_name", table_name="users") + op.create_unique_constraint("uq_users_name", "users", ["name"]) + # ### end Alembic commands ### diff --git a/barker/barker/main.py b/barker/barker/main.py index a738174..5d34531 100644 --- a/barker/barker/main.py +++ b/barker/barker/main.py @@ -26,6 +26,7 @@ from .routers import ( settle_option, table, tax, + temporal_product, update_product_prices, user, ) @@ -69,6 +70,7 @@ app.include_router(printer.router, prefix="/api/printers", tags=["printers"]) app.include_router(menu_category.router, prefix="/api/menu-categories", tags=["products"]) app.include_router(product.router, prefix="/api/products", tags=["products"]) +app.include_router(temporal_product.router, prefix="/api/temporal-products", tags=["products"]) app.include_router(device.router, prefix="/api/devices", tags=["devices"]) app.include_router(sale_category.router, prefix="/api/sale-categories", tags=["products"]) app.include_router(header_footer.router, prefix="/api/header-footer", tags=["products"]) diff --git a/barker/barker/models/product_version.py b/barker/barker/models/product_version.py index 21f7f89..fd9478d 100644 --- a/barker/barker/models/product_version.py +++ b/barker/barker/models/product_version.py @@ -61,6 +61,11 @@ class ProductVersion(Base): (units, "="), (func.daterange(valid_from, valid_till, text("'[]'")), "&&"), ), + postgresql.ExcludeConstraint( + (product_id, "="), + (units, "="), + (func.daterange(valid_from, valid_till, text("'[]'")), "&&"), + ), ) def __init__( diff --git a/barker/barker/models/user.py b/barker/barker/models/user.py index dab3122..26a079f 100644 --- a/barker/barker/models/user.py +++ b/barker/barker/models/user.py @@ -5,24 +5,25 @@ from hashlib import md5 from barker.models.login_history import LoginHistory from barker.models.meta import Base from barker.models.user_roles import user_roles -from sqlalchemy import Boolean, Column, Unicode, desc, select, text +from sqlalchemy import Boolean, Column, Index, Unicode, desc, func, select, text from sqlalchemy.dialects.postgresql import UUID from sqlalchemy.orm import Session, relationship, synonym class User(Base): __tablename__ = "users" - id = Column( "id", UUID(as_uuid=True), primary_key=True, server_default=text("gen_random_uuid()"), default=uuid.uuid4 ) - name = Column("name", Unicode(255), unique=True, nullable=False) + name = Column("name", Unicode(255), nullable=False) _password = Column("password", Unicode(60), nullable=False) locked_out = Column("locked_out", Boolean, nullable=False) roles = relationship("Role", secondary=user_roles, order_by="Role.name") login_history = relationship("LoginHistory", order_by=desc(LoginHistory.date), backref="user") + Index("uq_users_name", func.lower(name), unique=True) + def _get_password(self): return self._password diff --git a/barker/barker/routers/product.py b/barker/barker/routers/product.py index 3249dcf..face4f8 100644 --- a/barker/barker/routers/product.py +++ b/barker/barker/routers/product.py @@ -6,19 +6,23 @@ from typing import List, Optional import barker.schemas.product as schemas from fastapi import APIRouter, Depends, HTTPException, Security, status -from sqlalchemy import and_, delete, insert, or_, select, update +from sqlalchemy import and_, insert, or_, select, update from sqlalchemy.exc import SQLAlchemyError from sqlalchemy.orm import Session, contains_eager, joinedload -from sqlalchemy.sql.functions import count +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 ..models.inventory import Inventory +from ..models.kot import Kot from ..models.menu_category import MenuCategory from ..models.modifier_categories_products import modifier_categories_products from ..models.modifier_category import ModifierCategory from ..models.product import Product from ..models.product_version import ProductVersion from ..models.sale_category import SaleCategory +from ..models.voucher import Voucher from ..schemas.user_token import UserToken from . import effective_date @@ -66,7 +70,7 @@ def sort_order( ) -@router.post("", response_model=schemas.Product) +@router.post("", response_model=None) def save( data: schemas.ProductIn, date_: date = Depends(effective_date), @@ -143,18 +147,16 @@ def add_modifiers(product_id: uuid.UUID, menu_category_id: uuid.UUID, date_: dat db.execute(insert(modifier_categories_products).values(product_id=product_id, modifier_category_id=mc)) -@router.put("/{version_id}", response_model=schemas.Product) +@router.put("/{id_}", response_model=None) def update_route( - version_id: uuid.UUID, + id_: uuid.UUID, data: schemas.ProductIn, date_: date = Depends(effective_date), user: UserToken = Security(get_user, scopes=["products"]), ) -> None: try: with SessionFuture() as db: - old: ProductVersion = db.execute(select(ProductVersion).where(ProductVersion.id == version_id)).scalar_one() - id_ = old.product_id - latest: ProductVersion = db.execute( + item: ProductVersion = db.execute( select(ProductVersion) .join(ProductVersion.menu_category) .where( @@ -171,65 +173,42 @@ def update_route( ) ) ).scalar_one() - if version_id != latest.id and "temporal-products" not in user.permissions: - # This should not happen as only someone with this permission should reach here - raise HTTPException( - status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, - detail="Permission error, you cannot edit this product version.", - ) - - if version_id != latest.id: - # Update the old product update by temporal product editor - old.name = data.name - old.units = data.units - old.menu_category_id = data.menu_category.id_ - old.sale_category_id = data.sale_category.id_ - old.price = data.price - old.has_happy_hour = data.has_happy_hour - old.is_not_available = data.is_not_available - old.quantity = data.quantity - db.commit() - return - - if latest.valid_till is not None: + if item.valid_till is not None: # Allow adding a product here splitting the valid from and to, but not implemented right now raise HTTPException( status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="Product has been invalidated", ) - - if latest.valid_from == date_: - # Update the product as it is valid from the the same - latest.name = data.name - latest.units = data.units - latest.menu_category_id = data.menu_category.id_ - latest.sale_category_id = data.sale_category.id_ - latest.price = data.price - latest.has_happy_hour = data.has_happy_hour - latest.is_not_available = data.is_not_available - latest.quantity = data.quantity + if item.valid_from == date_: # Update the product as valid from the the same + item.name = data.name + item.units = data.units + item.menu_category_id = data.menu_category.id_ + item.sale_category_id = data.sale_category.id_ + item.price = data.price + item.has_happy_hour = data.has_happy_hour + item.is_not_available = data.is_not_available + item.quantity = data.quantity db.commit() - return - - # Create a new product version - latest.valid_till = date_ - timedelta(days=1) - product_version = ProductVersion( - product_id=id_, - name=data.name, - units=data.units, - menu_category_id=data.menu_category.id_, - sale_category_id=data.sale_category.id_, - price=data.price, - has_happy_hour=data.has_happy_hour, - is_not_available=data.is_not_available, - quantity=data.quantity, - valid_from=date_, - valid_till=None, - sort_order=latest.sort_order, - ) - db.add(product_version) - db.commit() - return + return None + else: # Create a new version of the product from the new details + item.valid_till = date_ - timedelta(days=1) + product_version = ProductVersion( + product_id=item.product_id, + name=data.name, + units=data.units, + menu_category_id=data.menu_category.id_, + sale_category_id=data.sale_category.id_, + price=data.price, + has_happy_hour=data.has_happy_hour, + is_not_available=data.is_not_available, + quantity=data.quantity, + valid_from=date_, + valid_till=None, + sort_order=item.sort_order, + ) + db.add(product_version) + db.commit() + return None except SQLAlchemyError as e: raise HTTPException( status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, @@ -237,19 +216,15 @@ def update_route( ) -@router.delete("/{version_id}") +@router.delete("/{id_}", response_model=None) def delete_route( - version_id: uuid.UUID, + id_: uuid.UUID, date_: date = Depends(effective_date), user: UserToken = Security(get_user, scopes=["products"]), ) -> None: with SessionFuture() as db: - old: ProductVersion = db.execute(select(ProductVersion).where(ProductVersion.id == version_id)).scalar_one() - id_ = old.product_id - latest: ProductVersion = db.execute( - select(ProductVersion) - .join(ProductVersion.menu_category) - .where( + item: ProductVersion = db.execute( + select(ProductVersion).where( and_( ProductVersion.product_id == id_, or_( @@ -263,82 +238,41 @@ def delete_route( ) ) ).scalar_one() - if version_id != latest.id and "temporal-products" not in user.permissions: - # This should not happen as only someone with this permission should reach here + day = func.date_trunc( + "day", Voucher.date + timedelta(minutes=settings.NEW_DAY_OFFSET_MINUTES - settings.TIMEZONE_OFFSET_MINUTES) + ).label("day") + billed = db.execute( + select(count(Inventory.id)) + .join(Inventory.kot) + .join(Kot.voucher) + .where(Inventory.product_id == id_, day >= date_) + ).scalar_one() + if billed > 0: raise HTTPException( - status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, - detail="Permission error, you cannot delete this product.", + status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, + detail="The cannot delete this product as it was billed", ) - if version_id != latest.id: - # Delete old product, but make sure that no gaps remain - if old.valid_from is not None: - # Set the previous version valid till to item's valid till - db.execute( - delete(ProductVersion) - .where(ProductVersion.id == version_id) - .execution_options(synchronize_session=False) - ) - id_to_update = ( - select(ProductVersion.id) - .where( - ProductVersion.product_id == id_, - ProductVersion.valid_till == old.valid_from - timedelta(days=1), - ) - .scalar_subquery() - ) - db.execute( - update(ProductVersion) - .where(ProductVersion.id == id_to_update) - .values(valid_till=old.valid_till) - .execution_options(synchronize_session=False) - ) - else: - # Set the next version valid from to item's valid from which is None - db.execute( - delete(ProductVersion) - .where(ProductVersion.id == version_id) - .execution_options(synchronize_session=False) - ) - id_to_update = ( - select(ProductVersion.id) - .where( - ProductVersion.product_id == id_, - ProductVersion.valid_from == old.valid_till + timedelta(days=1), - ) - .scalar_subquery() - ) - db.execute( - update(ProductVersion) - .where(ProductVersion.id == id_to_update) - .values(valid_till=old.valid_from) - .execution_options(synchronize_session=False) - ) - db.commit() - return - if latest.valid_from == date_: - db.delete(latest) + if item.valid_from == date_: + db.delete(item) else: - latest.valid_till = date_ - timedelta(days=1) + item.valid_till = date_ - timedelta(days=1) db.commit() - return -@router.get("", response_model=List[schemas.ProductBlank]) +@router.get("", response_model=schemas.ProductBlank) def show_blank( user: UserToken = Security(get_user, scopes=["products"]), -) -> List[schemas.ProductBlank]: - return [ - schemas.ProductBlank( - name="", - units="", - price=0, - hasHappyHour=False, - isNotAvailable=False, - isActive=True, - sortOrder=0, - ) - ] +) -> schemas.ProductBlank: + return schemas.ProductBlank( + name="", + units="", + price=0, + hasHappyHour=False, + isNotAvailable=False, + isActive=True, + sortOrder=0, + ) @router.get("/list", response_model=List[schemas.Product]) @@ -486,44 +420,39 @@ def product_list_of_sale_category(date_: date, db: Session) -> List[schemas.Prod ] -@router.get("/{id_}", response_model=List[schemas.Product]) +@router.get("/{id_}", response_model=schemas.Product) def show_id( id_: uuid.UUID, date_: date = Depends(effective_date), user: UserToken = Security(get_user, scopes=["products"]), -) -> List[schemas.Product]: - query = ( - select(ProductVersion) - .join(ProductVersion.sale_category) - .join(SaleCategory.tax) - .where(ProductVersion.product_id == id_) - ) - if "temporal-products" not in user.permissions: - query = query.where( - or_( - ProductVersion.valid_from == None, # noqa: E711 - ProductVersion.valid_from <= date_, - ), - or_( - ProductVersion.valid_till == None, # noqa: E711 - ProductVersion.valid_till >= date_, - ), - ) +) -> schemas.Product: with SessionFuture() as db: - items = [ - product_info(item) - for item in db.execute( - query.order_by(ProductVersion.valid_till).options( - joinedload(ProductVersion.sale_category, innerjoin=True), - joinedload(ProductVersion.sale_category, SaleCategory.tax, innerjoin=True), - contains_eager(ProductVersion.sale_category), - contains_eager(ProductVersion.sale_category, SaleCategory.tax), + item: ProductVersion = db.execute( + select(ProductVersion) + .join(ProductVersion.sale_category) + .join(SaleCategory.tax) + .where( + and_( + ProductVersion.product_id == id_, + or_( + ProductVersion.valid_from == None, # noqa: E711 + ProductVersion.valid_from <= date_, + ), + or_( + ProductVersion.valid_till == None, # noqa: E711 + ProductVersion.valid_till >= date_, + ), ) ) - .scalars() - .all() - ] - return items + .order_by(ProductVersion.sort_order, ProductVersion.name) + .options( + joinedload(ProductVersion.sale_category, innerjoin=True), + joinedload(ProductVersion.sale_category, SaleCategory.tax, innerjoin=True), + contains_eager(ProductVersion.sale_category), + contains_eager(ProductVersion.sale_category, SaleCategory.tax), + ) + ).scalar_one() + return product_info(item) def query_product_info(item: ProductVersion, happy_hour: bool): diff --git a/barker/barker/routers/temporal_product.py b/barker/barker/routers/temporal_product.py new file mode 100644 index 0000000..13e00ae --- /dev/null +++ b/barker/barker/routers/temporal_product.py @@ -0,0 +1,257 @@ +import uuid + +from datetime import date, timedelta +from typing import List + +import barker.schemas.product as schemas + +from fastapi import APIRouter, HTTPException, Security, status +from sqlalchemy import and_, delete, distinct, or_, select, update +from sqlalchemy.exc import SQLAlchemyError +from sqlalchemy.orm import Session, contains_eager, joinedload +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 ..models.inventory import Inventory +from ..models.kot import Kot +from ..models.menu_category import MenuCategory +from ..models.product import Product +from ..models.product_version import ProductVersion +from ..models.sale_category import SaleCategory +from ..models.voucher import Voucher +from ..schemas.user_token import UserToken + + +router = APIRouter() + + +@router.put("/{version_id}", response_model=None) +def update_route( + version_id: uuid.UUID, + data: schemas.Product, + user: UserToken = Security(get_user, scopes=["temporal-products"]), +) -> None: + try: + with SessionFuture() as db: + old: ProductVersion = db.execute(select(ProductVersion).where(ProductVersion.id == version_id)).scalar_one() + if ( + old.product_id != data.id_ + or old.name != data.name + or old.units != data.units + or old.valid_from != data.valid_from + or old.valid_till != data.valid_till + ): + check_product(old, data, db) + check_inventories(old, data, db) + update_inventories(old, data, db) + old.product_id = data.id_ + old.name = data.name + old.units = data.units + old.menu_category_id = data.menu_category.id_ + old.sale_category_id = data.sale_category.id_ + old.price = data.price + old.has_happy_hour = data.has_happy_hour + old.is_not_available = data.is_not_available + old.quantity = data.quantity + old.valid_from = data.valid_from + old.valid_till = data.valid_till + db.flush() + db.execute( + delete(Product) + .where(~Product.id.in_(select(distinct(ProductVersion.product_id)))) + .execution_options(synchronize_session=False) + ) + db.commit() + return + except SQLAlchemyError as e: + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail=str(e), + ) + + +def check_product(old: ProductVersion, data: schemas.Product, db: Session): + query = select(count(ProductVersion.id)).where(ProductVersion.id != old.id) + if data.valid_from is not None: + query = query.where(ProductVersion.valid_till >= data.valid_from) + if data.valid_till is not None: + query = query.where(ProductVersion.valid_from <= data.valid_till) + query = query.where( + or_( + ProductVersion.product_id == data.id_, + and_(ProductVersion.name == data.name, ProductVersion.units == data.units), + ) + ) + if db.execute(query).scalar_one() > 0: + raise HTTPException( + status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, + detail="Overlapping product exists", + ) + + +def check_inventories(old: ProductVersion, data: schemas.Product, db: Session): + day = func.date_trunc( + "day", Voucher.date + timedelta(minutes=settings.NEW_DAY_OFFSET_MINUTES - settings.TIMEZONE_OFFSET_MINUTES) + ).label("day") + + if data.valid_from is not None and (old.valid_from or date.min) < data.valid_from: + query = select(count(Inventory.id)).where(Inventory.product_id == old.product_id) + if old.valid_from is not None: + query = query.where(day >= old.valid_from) + query = query.where(day < data.valid_from) + if db.execute(query).scalar_one() > 0: + raise HTTPException( + status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, + detail="Changing of validity will orphan inventories", + ) + + if data.valid_till is not None and (old.valid_till or date.max) > data.valid_till: + query = select(count(Inventory.id)).where(Inventory.product_id == old.product_id) + if old.valid_till is not None: + query = query.where(day <= old.valid_till) + query = query.where(day > data.valid_till) + if db.execute(query).scalar_one() > 0: + raise HTTPException( + status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, + detail="Changing of validity will orphan inventories", + ) + + +def update_inventories(old: ProductVersion, data: schemas.Product, db: Session): + if old.product_id != data.id_: + day = func.date_trunc( + "day", Voucher.date + timedelta(minutes=settings.NEW_DAY_OFFSET_MINUTES - settings.TIMEZONE_OFFSET_MINUTES) + ).label("day") + invs = select(Inventory.id).join(Inventory.kot).join(Kot.voucher).where(Inventory.product_id == old.product_id) + if old.valid_from is not None: + invs = invs.where(day >= old.valid_from) + if old.valid_till is not None: + invs = invs.where(day <= old.valid_till) + db.execute( + update(Inventory) + .values(product_id=data.id_) + .where(Inventory.id.in_(invs)) + .execution_options(synchronize_session=False) + ) + + +@router.delete("/{version_id}", response_model=None) +def delete_route( + version_id: uuid.UUID, + user: UserToken = Security(get_user, scopes=["temporal-products"]), +) -> None: + with SessionFuture() as db: + id_ = db.execute( + select(ProductVersion.product_id).where(ProductVersion.id == version_id).group_by(ProductVersion.product_id) + ).scalar_one() + valid_from, valid_till = db.execute( + select(ProductVersion.valid_from, ProductVersion.valid_till).where(ProductVersion.id == version_id) + ).one() + day = func.date_trunc( + "day", Voucher.date + timedelta(minutes=settings.NEW_DAY_OFFSET_MINUTES - settings.TIMEZONE_OFFSET_MINUTES) + ).label("day") + query = select(count(Inventory.id)).join(Inventory.kot).join(Kot.voucher).where(Inventory.product_id == id_) + if valid_from is not None: + query = query.where(day >= valid_from) + if valid_till is not None: + query = query.where(day <= valid_till) + invs = db.execute(query).scalar_one() + if invs > 0: + raise HTTPException( + status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, + detail="The cannot delete this product as it was billed", + ) + + db.execute( + delete(ProductVersion).where(ProductVersion.id == version_id).execution_options(synchronize_session=False) + ) + db.execute( + delete(Product) + .where(~Product.id.in_(select(distinct(ProductVersion.product_id)))) + .execution_options(synchronize_session=False) + ) + db.commit() + return + + +@router.get("/list", response_model=List[List[schemas.Product]]) +def show_list(user: UserToken = Security(get_user, scopes=["temporal-products"])) -> List[List[schemas.Product]]: + with SessionFuture() as db: + return product_list(db) + + +def product_list(db: Session) -> List[List[schemas.Product]]: + dict_ = {} + list_ = ( + db.execute( + select(ProductVersion) + .join(ProductVersion.menu_category) + .join(ProductVersion.sale_category) + .order_by(MenuCategory.sort_order) + .order_by(MenuCategory.name) + .order_by(ProductVersion.sort_order) + .order_by(ProductVersion.name) + .options( + joinedload(ProductVersion.menu_category, innerjoin=True), + joinedload(ProductVersion.sale_category, innerjoin=True), + contains_eager(ProductVersion.menu_category), + contains_eager(ProductVersion.sale_category), + ) + ) + .scalars() + .all() + ) + for item in list_: + if item.product_id not in dict_: + dict_[item.product_id] = [] + dict_[item.product_id].append(product_info(item)) + dict_[item.product_id] = sorted(dict_[item.product_id], key=lambda k: k.valid_from or date.min) + return list(dict_.values()) + + +@router.get("/{version_id}", response_model=schemas.Product) +def show_id( + version_id: uuid.UUID, + user: UserToken = Security(get_user, scopes=["products"]), +) -> schemas.Product: + with SessionFuture() as db: + item = db.execute( + select(ProductVersion) + .join(ProductVersion.menu_category) + .join(ProductVersion.sale_category) + .join(SaleCategory.tax) + .where(ProductVersion.id == version_id) + .order_by(ProductVersion.valid_till) + .options( + joinedload(ProductVersion.menu_category, innerjoin=True), + joinedload(ProductVersion.sale_category, innerjoin=True), + joinedload(ProductVersion.sale_category, SaleCategory.tax, innerjoin=True), + contains_eager(ProductVersion.sale_category), + contains_eager(ProductVersion.sale_category, SaleCategory.tax), + ) + ).scalar_one() + return product_info(item) + + +def product_info(item: ProductVersion) -> schemas.Product: + return schemas.Product( + id=item.product_id, + versionId=item.id, + name=item.name, + units=item.units, + menuCategory=schemas.MenuCategoryLink(id=item.menu_category_id, name=item.menu_category.name, products=[]), + saleCategory=schemas.SaleCategoryLink( + id=item.sale_category_id, + name=item.sale_category.name, + ), + price=item.price, + hasHappyHour=item.has_happy_hour, + isNotAvailable=item.is_not_available, + quantity=item.quantity, + isActive=True, + sortOrder=item.sort_order, + validFrom=item.valid_from, + validTill=item.valid_till, + ) diff --git a/bookie/src/app/app-routing.module.ts b/bookie/src/app/app-routing.module.ts index e0e2180..ffc44ba 100644 --- a/bookie/src/app/app-routing.module.ts +++ b/bookie/src/app/app-routing.module.ts @@ -131,6 +131,13 @@ const routes: Routes = [ path: 'tax-report', loadChildren: () => import('./tax-report/tax-report.module').then((mod) => mod.TaxReportModule), }, + { + path: 'temporal-products', + loadChildren: () => + import('./temporal-product/temporal-products.module').then( + (mod) => mod.TemporalProductsModule, + ), + }, { path: 'update-product-prices', loadChildren: () => diff --git a/bookie/src/app/auth/login/login.component.ts b/bookie/src/app/auth/login/login.component.ts index 5f51e7a..1e55cbd 100644 --- a/bookie/src/app/auth/login/login.component.ts +++ b/bookie/src/app/auth/login/login.component.ts @@ -61,7 +61,7 @@ export class LoginComponent implements OnInit, AfterViewInit { // .pipe(first()) .subscribe( () => { - this.router.navigate([this.returnUrl]); + this.router.navigateByUrl(this.returnUrl); }, (error) => { if (error.status === 401 && error.error.detail === 'Device is not registered') { diff --git a/bookie/src/app/core/product.ts b/bookie/src/app/core/product.ts index 35e8678..326651f 100644 --- a/bookie/src/app/core/product.ts +++ b/bookie/src/app/core/product.ts @@ -6,7 +6,6 @@ import { Tax } from './tax'; export class Product { id: string | undefined; versionId?: string; - code: number; name: string; units: string; menuCategory?: MenuCategory; @@ -21,12 +20,11 @@ export class Product { enabled: boolean; tax: Tax; - validFrom?: string; - validTill?: string; + validFrom: string | null; + validTill: string | null; public constructor(init?: Partial) { this.id = undefined; - this.code = 0; this.name = ''; this.units = ''; this.price = 0; @@ -36,6 +34,8 @@ export class Product { this.isActive = true; this.sortOrder = 0; this.enabled = true; + this.validFrom = null; + this.validTill = null; this.tax = new Tax(); Object.assign(this, init); } diff --git a/bookie/src/app/home/home.component.html b/bookie/src/app/home/home.component.html index 9392473..f96aa48 100644 --- a/bookie/src/app/home/home.component.html +++ b/bookie/src/app/home/home.component.html @@ -102,6 +102,15 @@ >

Products

+ +

Temporal Products

+
-
- - - Product - - -
-
- - Code - - -
-
- - Name - - - - Units - - -
-
- - Price - - - - Quantity - - -
-
- Has Happy Hour? - Is Not Available? -
-
- - Menu Category - - - {{ mc.name }} - - - - - Sale Category - - - {{ sc.name }} - - - -
-
-
- - - - -
-
-
- - - {{ !!product.validFrom ? product.validFrom : '\u221E' }} - - {{ !!product.validTill ? product.validTill : '\u221E' }} - - -
+
+ + + Product + + +
+
+ + Name + + + + Units + + +
+
+ + Price + + + + Quantity + + +
+
+ Has Happy Hour? + Is Not Available? +
+
+ + Menu Category + + + {{ mc.name }} + + + + + Sale Category + + + {{ sc.name }} + + + +
+
+
+ + + + +
diff --git a/bookie/src/app/product/product-detail/product-detail.component.ts b/bookie/src/app/product/product-detail/product-detail.component.ts index 17bafe0..65be355 100644 --- a/bookie/src/app/product/product-detail/product-detail.component.ts +++ b/bookie/src/app/product/product-detail/product-detail.component.ts @@ -1,7 +1,6 @@ import { AfterViewInit, Component, ElementRef, OnInit, ViewChild } from '@angular/core'; import { FormBuilder, FormGroup } from '@angular/forms'; import { MatDialog } from '@angular/material/dialog'; -import { MatRadioChange } from '@angular/material/radio'; import { ActivatedRoute, Router } from '@angular/router'; import { MenuCategory } from '../../core/menu-category'; @@ -22,7 +21,6 @@ export class ProductDetailComponent implements OnInit, AfterViewInit { menuCategories: MenuCategory[] = []; saleCategories: SaleCategory[] = []; item: Product = new Product(); - list: Product[] = []; constructor( private route: ActivatedRoute, @@ -34,7 +32,6 @@ export class ProductDetailComponent implements OnInit, AfterViewInit { ) { // Create form this.form = this.fb.group({ - code: { value: '', disabled: true }, name: '', units: '', menuCategory: '', @@ -49,21 +46,19 @@ export class ProductDetailComponent implements OnInit, AfterViewInit { ngOnInit() { this.route.data.subscribe((value) => { const data = value as { - items: Product[]; + item: Product; menuCategories: MenuCategory[]; saleCategories: SaleCategory[]; }; this.menuCategories = data.menuCategories; this.saleCategories = data.saleCategories; - this.list = data.items; - this.showItem(this.list[this.list.length - 1]); + this.showItem(data.item); }); } showItem(item: Product) { this.item = item; this.form.setValue({ - code: this.item.code || '(Auto)', name: this.item.name || '', units: this.item.units || '', menuCategory: this.item.menuCategory ? this.item.menuCategory.id : '', @@ -96,7 +91,7 @@ export class ProductDetailComponent implements OnInit, AfterViewInit { } delete() { - this.ser.delete(this.item.versionId as string).subscribe( + this.ser.delete(this.item.id as string).subscribe( () => { this.toaster.show('Success', ''); this.router.navigateByUrl('/products'); @@ -138,9 +133,4 @@ export class ProductDetailComponent implements OnInit, AfterViewInit { this.item.quantity = +formModel.quantity; return this.item; } - - loadProduct($event: MatRadioChange) { - const product = this.list.find((x) => x.versionId === $event.value); - this.showItem(product as Product); - } } diff --git a/bookie/src/app/product/product-list/product-list.component.css b/bookie/src/app/product/product-list/product-list.component.css index a9626b3..cf0b2c1 100644 --- a/bookie/src/app/product/product-list/product-list.component.css +++ b/bookie/src/app/product/product-list/product-list.component.css @@ -2,3 +2,23 @@ display: flex; justify-content: flex-end; } + +.material-icons { + vertical-align: middle; +} + +.mat-column-name { + margin-right: 4px; +} + +.mat-column-price, +.mat-column-menuCategory, +.mat-column-saleCategory, +.mat-column-info { + margin-left: 4px; + margin-right: 4px; +} + +.mat-column-quantity { + margin-left: 4px; +} diff --git a/bookie/src/app/product/product-list/product-list.component.html b/bookie/src/app/product/product-list/product-list.component.html index f07ea19..579ffb6 100644 --- a/bookie/src/app/product/product-list/product-list.component.html +++ b/bookie/src/app/product/product-list/product-list.component.html @@ -105,7 +105,7 @@ - + Quantity {{ diff --git a/bookie/src/app/product/product-list/product-list.component.ts b/bookie/src/app/product/product-list/product-list.component.ts index 01cb211..92c1e4e 100644 --- a/bookie/src/app/product/product-list/product-list.component.ts +++ b/bookie/src/app/product/product-list/product-list.component.ts @@ -111,7 +111,6 @@ export class ProductListComponent implements OnInit { exportCsv() { const headers = { - Code: 'code', Name: 'name', Units: 'units', Price: 'price', diff --git a/bookie/src/app/product/product-resolver.service.ts b/bookie/src/app/product/product-resolver.service.ts index 9a06097..b415381 100644 --- a/bookie/src/app/product/product-resolver.service.ts +++ b/bookie/src/app/product/product-resolver.service.ts @@ -9,10 +9,10 @@ import { ProductService } from './product.service'; @Injectable({ providedIn: 'root', }) -export class ProductResolver implements Resolve { +export class ProductResolver implements Resolve { constructor(private ser: ProductService) {} - resolve(route: ActivatedRouteSnapshot): Observable { + resolve(route: ActivatedRouteSnapshot): Observable { const id = route.paramMap.get('id'); return this.ser.get(id); } diff --git a/bookie/src/app/product/product.service.ts b/bookie/src/app/product/product.service.ts index 0b3422e..026a152 100644 --- a/bookie/src/app/product/product.service.ts +++ b/bookie/src/app/product/product.service.ts @@ -17,11 +17,11 @@ const serviceName = 'ProductService'; export class ProductService { constructor(private http: HttpClient, private log: ErrorLoggerService) {} - get(id: string | null): Observable { + get(id: string | null): Observable { const getUrl: string = id === null ? `${url}` : `${url}/${id}`; return this.http - .get(getUrl) - .pipe(catchError(this.log.handleError(serviceName, `get id=${id}`))) as Observable; + .get(getUrl) + .pipe(catchError(this.log.handleError(serviceName, `get id=${id}`))) as Observable; } list(): Observable { @@ -56,7 +56,7 @@ export class ProductService { update(product: Product): Observable { return this.http - .put(`${url}/${product.versionId}`, product, httpOptions) + .put(`${url}/${product.id}`, product, httpOptions) .pipe(catchError(this.log.handleError(serviceName, 'update'))) as Observable; } diff --git a/bookie/src/app/product/products-routing.module.ts b/bookie/src/app/product/products-routing.module.ts index d766f86..8d267de 100644 --- a/bookie/src/app/product/products-routing.module.ts +++ b/bookie/src/app/product/products-routing.module.ts @@ -32,7 +32,7 @@ const productsRoutes: Routes = [ permission: 'Products', }, resolve: { - items: ProductResolver, + item: ProductResolver, menuCategories: MenuCategoryListResolver, saleCategories: SaleCategoryListResolver, }, @@ -45,7 +45,7 @@ const productsRoutes: Routes = [ permission: 'Products', }, resolve: { - items: ProductResolver, + item: ProductResolver, menuCategories: MenuCategoryListResolver, saleCategories: SaleCategoryListResolver, }, diff --git a/bookie/src/app/product/products.module.ts b/bookie/src/app/product/products.module.ts index d3746cb..e9a2a8f 100644 --- a/bookie/src/app/product/products.module.ts +++ b/bookie/src/app/product/products.module.ts @@ -11,7 +11,6 @@ import { MatOptionModule } from '@angular/material/core'; import { MatIconModule } from '@angular/material/icon'; import { MatInputModule } from '@angular/material/input'; import { MatProgressSpinnerModule } from '@angular/material/progress-spinner'; -import { MatRadioModule } from '@angular/material/radio'; import { MatSelectModule } from '@angular/material/select'; import { MatTableModule } from '@angular/material/table'; @@ -36,7 +35,6 @@ import { ProductsRoutingModule } from './products-routing.module'; MatCheckboxModule, ReactiveFormsModule, ProductsRoutingModule, - MatRadioModule, ], declarations: [ProductListComponent, ProductDetailComponent], }) diff --git a/bookie/src/app/temporal-product/temporal-product-detail/temporal-product-detail.component.css b/bookie/src/app/temporal-product/temporal-product-detail/temporal-product-detail.component.css new file mode 100644 index 0000000..e69de29 diff --git a/bookie/src/app/temporal-product/temporal-product-detail/temporal-product-detail.component.html b/bookie/src/app/temporal-product/temporal-product-detail/temporal-product-detail.component.html new file mode 100644 index 0000000..1d6e5ee --- /dev/null +++ b/bookie/src/app/temporal-product/temporal-product-detail/temporal-product-detail.component.html @@ -0,0 +1,123 @@ +
+ + + Product + + +
+
+ + Product Id + + +
+
+ + Name + + + + Units + + +
+
+ + Price + + + + Quantity + + +
+
+ Has Happy Hour? + Is Not Available? +
+
+ + Menu Category + + + {{ mc.name }} + + + + + Sale Category + + + {{ sc.name }} + + + +
+
+ + + + + + + + + + +
+
+
+ + + + +
+
diff --git a/bookie/src/app/temporal-product/temporal-product-detail/temporal-product-detail.component.spec.ts b/bookie/src/app/temporal-product/temporal-product-detail/temporal-product-detail.component.spec.ts new file mode 100644 index 0000000..b8df4e5 --- /dev/null +++ b/bookie/src/app/temporal-product/temporal-product-detail/temporal-product-detail.component.spec.ts @@ -0,0 +1,26 @@ +import { ComponentFixture, TestBed, waitForAsync } from '@angular/core/testing'; + +import { TemporalProductDetailComponent } from './temporal-product-detail.component'; + +describe('TemporalProductDetailComponent', () => { + let component: TemporalProductDetailComponent; + let fixture: ComponentFixture; + + beforeEach( + waitForAsync(() => { + TestBed.configureTestingModule({ + declarations: [TemporalProductDetailComponent], + }).compileComponents(); + }), + ); + + beforeEach(() => { + fixture = TestBed.createComponent(TemporalProductDetailComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/bookie/src/app/temporal-product/temporal-product-detail/temporal-product-detail.component.ts b/bookie/src/app/temporal-product/temporal-product-detail/temporal-product-detail.component.ts new file mode 100644 index 0000000..b99d0c1 --- /dev/null +++ b/bookie/src/app/temporal-product/temporal-product-detail/temporal-product-detail.component.ts @@ -0,0 +1,154 @@ +import { AfterViewInit, Component, ElementRef, OnInit, ViewChild } from '@angular/core'; +import { FormBuilder, FormGroup } from '@angular/forms'; +import { MatDialog } from '@angular/material/dialog'; +import { ActivatedRoute, Router } from '@angular/router'; +import * as moment from 'moment'; + +import { MenuCategory } from '../../core/menu-category'; +import { Product } from '../../core/product'; +import { SaleCategory } from '../../core/sale-category'; +import { ToasterService } from '../../core/toaster.service'; +import { ConfirmDialogComponent } from '../../shared/confirm-dialog/confirm-dialog.component'; +import { TemporalProductService } from '../temporal-product.service'; + +@Component({ + selector: 'app-product-detail', + templateUrl: './temporal-product-detail.component.html', + styleUrls: ['./temporal-product-detail.component.css'], +}) +export class TemporalProductDetailComponent implements OnInit, AfterViewInit { + @ViewChild('name', { static: true }) nameElement?: ElementRef; + form: FormGroup; + menuCategories: MenuCategory[] = []; + saleCategories: SaleCategory[] = []; + item: Product = new Product(); + + constructor( + private route: ActivatedRoute, + private router: Router, + private dialog: MatDialog, + private fb: FormBuilder, + private toaster: ToasterService, + private ser: TemporalProductService, + ) { + // Create form + this.form = this.fb.group({ + id: '', + name: '', + units: '', + menuCategory: '', + saleCategory: '', + price: '', + hasHappyHour: '', + isNotAvailable: '', + quantity: '', + validFrom: '', + validTill: '', + }); + } + + ngOnInit() { + this.route.data.subscribe((value) => { + const data = value as { + item: Product; + menuCategories: MenuCategory[]; + saleCategories: SaleCategory[]; + }; + this.menuCategories = data.menuCategories; + this.saleCategories = data.saleCategories; + this.showItem(data.item); + }); + } + + showItem(item: Product) { + this.item = item; + this.form.setValue({ + id: this.item.id, + name: this.item.name, + units: this.item.units, + menuCategory: this.item.menuCategory?.id, + saleCategory: this.item.saleCategory?.id, + price: this.item.price, + hasHappyHour: this.item.hasHappyHour, + isNotAvailable: this.item.isNotAvailable, + quantity: this.item.quantity, + validFrom: + this.item.validFrom === null ? '' : moment(this.item.validFrom, 'DD-MMM-YYYY').toDate(), + validTill: + this.item.validTill === null ? '' : moment(this.item.validTill, 'DD-MMM-YYYY').toDate(), + }); + } + + ngAfterViewInit() { + setTimeout(() => { + if (this.nameElement !== undefined) { + this.nameElement.nativeElement.focus(); + } + }, 0); + } + + update() { + this.ser.update(this.getItem()).subscribe( + () => { + this.toaster.show('Success', ''); + this.router.navigateByUrl('/temporal-products'); + }, + (error) => { + this.toaster.show('Error', error); + }, + ); + } + + delete() { + this.ser.delete(this.item.versionId as string).subscribe( + () => { + this.toaster.show('Success', ''); + this.router.navigateByUrl('/temporal-products'); + }, + (error) => { + this.toaster.show('Error', error); + }, + ); + } + + confirmDelete(): void { + const dialogRef = this.dialog.open(ConfirmDialogComponent, { + width: '250px', + data: { title: 'Delete Product?', content: 'Are you sure? This cannot be undone.' }, + }); + + dialogRef.afterClosed().subscribe((result: boolean) => { + if (result) { + this.delete(); + } + }); + } + + getItem(): Product { + const formModel = this.form.value; + this.item.id = formModel.id; + this.item.name = formModel.name; + this.item.units = formModel.units; + if (this.item.menuCategory === null || this.item.menuCategory === undefined) { + this.item.menuCategory = new MenuCategory(); + } + this.item.menuCategory.id = formModel.menuCategory; + if (this.item.saleCategory === null || this.item.saleCategory === undefined) { + this.item.saleCategory = new SaleCategory(); + } + this.item.saleCategory.id = formModel.saleCategory; + this.item.price = +formModel.price; + this.item.hasHappyHour = formModel.hasHappyHour; + this.item.isNotAvailable = formModel.isNotAvailable; + this.item.quantity = +formModel.quantity; + this.item.validFrom = !formModel.validFrom + ? null + : moment(formModel.validFrom).format('DD-MMM-YYYY'); + console.log(formModel.validTill); + this.item.validTill = !formModel.validTill + ? null + : moment(formModel.validTill).format('DD-MMM-YYYY'); + + return this.item; + } +} diff --git a/bookie/src/app/temporal-product/temporal-product-list-resolver.service.spec.ts b/bookie/src/app/temporal-product/temporal-product-list-resolver.service.spec.ts new file mode 100644 index 0000000..250290a --- /dev/null +++ b/bookie/src/app/temporal-product/temporal-product-list-resolver.service.spec.ts @@ -0,0 +1,18 @@ +import { inject, TestBed } from '@angular/core/testing'; + +import { TemporalProductListResolverService } from './temporal-product-list-resolver.service'; + +describe('TemporalProductListResolverService', () => { + beforeEach(() => { + TestBed.configureTestingModule({ + providers: [TemporalProductListResolverService], + }); + }); + + it('should be created', inject( + [TemporalProductListResolverService], + (service: TemporalProductListResolverService) => { + expect(service).toBeTruthy(); + }, + )); +}); diff --git a/bookie/src/app/temporal-product/temporal-product-list-resolver.service.ts b/bookie/src/app/temporal-product/temporal-product-list-resolver.service.ts new file mode 100644 index 0000000..32164b2 --- /dev/null +++ b/bookie/src/app/temporal-product/temporal-product-list-resolver.service.ts @@ -0,0 +1,18 @@ +import { Injectable } from '@angular/core'; +import { Resolve } from '@angular/router'; +import { Observable } from 'rxjs'; + +import { Product } from '../core/product'; + +import { TemporalProductService } from './temporal-product.service'; + +@Injectable({ + providedIn: 'root', +}) +export class TemporalProductListResolverService implements Resolve { + constructor(private ser: TemporalProductService) {} + + resolve(): Observable { + return this.ser.list(); + } +} diff --git a/bookie/src/app/temporal-product/temporal-product-list/temporal-product-list-datasource.ts b/bookie/src/app/temporal-product/temporal-product-list/temporal-product-list-datasource.ts new file mode 100644 index 0000000..f5c640d --- /dev/null +++ b/bookie/src/app/temporal-product/temporal-product-list/temporal-product-list-datasource.ts @@ -0,0 +1,98 @@ +import { DataSource } from '@angular/cdk/collections'; +import { merge, Observable } from 'rxjs'; +import { map, tap } from 'rxjs/operators'; + +import { MenuCategory } from '../../core/menu-category'; +import { Product } from '../../core/product'; +import { SaleCategory } from '../../core/sale-category'; + +export class TemporalProductListDatasource extends DataSource { + public data: Product[][]; + public filteredData: Product[][]; + public search: string; + public menuCategory: string; + public saleCategory: string; + + constructor( + private readonly searchFilter: Observable, + private readonly menuCategoryFilter: Observable, + private readonly saleCategoryFilter: Observable, + private readonly dataObs: Observable, + ) { + super(); + this.data = []; + this.filteredData = []; + this.search = ''; + this.menuCategory = ''; + this.saleCategory = ''; + } + + connect(): Observable { + const dataMutations = [ + this.dataObs.pipe( + tap((x) => { + this.data = x; + }), + ), + this.searchFilter.pipe( + tap((x) => { + this.search = x; + }), + ), + this.menuCategoryFilter.pipe( + tap((x) => { + this.menuCategory = x; + }), + ), + this.saleCategoryFilter.pipe( + tap((x) => { + this.saleCategory = x; + }), + ), + ]; + return merge(...dataMutations).pipe( + map(() => this.getFilteredData(this.data, this.search, this.menuCategory, this.saleCategory)), + tap((x: Product[][]) => { + this.filteredData = x; + }), + map((x: Product[][]) => x.reduce((p, c) => p.concat(c), [])), + ); + } + + disconnect() {} + + private getFilteredData( + data: Product[][], + search: string, + menuCategory: string, + saleCategory: string, + ): Product[][] { + return data.filter( + (o: Product[]) => + o + .filter( + (x: Product) => + search === null || + search === undefined || + search === '' || + `${x.name} ${x.units} ${x.saleCategory?.name} ${x.menuCategory?.name}` + .toLowerCase() + .indexOf(search.toLowerCase()) !== -1, + ) + .filter( + (x) => + menuCategory === null || + menuCategory === undefined || + menuCategory === '' || + (x.menuCategory as MenuCategory).id === menuCategory, + ) + .filter( + (x) => + saleCategory === null || + saleCategory === undefined || + saleCategory === '' || + (x.saleCategory as SaleCategory).id === saleCategory, + ).length > 0, + ); + } +} diff --git a/bookie/src/app/temporal-product/temporal-product-list/temporal-product-list.component.css b/bookie/src/app/temporal-product/temporal-product-list/temporal-product-list.component.css new file mode 100644 index 0000000..39833dc --- /dev/null +++ b/bookie/src/app/temporal-product/temporal-product-list/temporal-product-list.component.css @@ -0,0 +1,28 @@ +.right { + display: flex; + justify-content: flex-end; +} + +.center { + text-align: center; +} + +.material-icons { + vertical-align: middle; +} + +.mat-column-name { + margin-right: 4px; +} + +.mat-column-price, +.mat-column-menuCategory, +.mat-column-saleCategory, +.mat-column-info { + margin-left: 4px; + margin-right: 4px; +} + +.mat-column-quantity { + margin-left: 4px; +} diff --git a/bookie/src/app/temporal-product/temporal-product-list/temporal-product-list.component.html b/bookie/src/app/temporal-product/temporal-product-list/temporal-product-list.component.html new file mode 100644 index 0000000..6eb4fd7 --- /dev/null +++ b/bookie/src/app/temporal-product/temporal-product-list/temporal-product-list.component.html @@ -0,0 +1,124 @@ + + + Temporal Products + + +
+
+ + + + + Menu Category + + -- All Products -- + + {{ mc.name }} + + + + + Sale Category + + -- All Products -- + + {{ mc.name }} + + + +
+
+ + + + Name + + + + + + + + Price + {{ row.price | currency: 'INR' }} + + + + + Menu Category + {{ row.menuCategory.name }} + + + + + Sale Category + {{ row.saleCategory.name }} + + + + + Details + +
    +
  • + Valid From: {{ row.validFrom ?? '∞' }} linear_scale + Till: {{ row.validTill ?? '∞' }} +
  • +
  • + + {{ row.hasHappyHour ? 'sentiment_satisfied_alt' : 'sentiment_dissatisfied' }} + + {{ row.hasHappyHour ? 'Has Happy Hours' : 'No Happy Hours' }} +
  • +
  • + + {{ row.isNotAvailable ? 'pause' : 'play_arrow' }} + + {{ row.isNotAvailable ? 'Is not Available' : 'Is Available' }} +
  • +
+
+
+ + + + Quantity + {{ + row.quantity | number: '1.2-2' + }} + + + + +
+
+
diff --git a/bookie/src/app/temporal-product/temporal-product-list/temporal-product-list.component.spec.ts b/bookie/src/app/temporal-product/temporal-product-list/temporal-product-list.component.spec.ts new file mode 100644 index 0000000..20e34bf --- /dev/null +++ b/bookie/src/app/temporal-product/temporal-product-list/temporal-product-list.component.spec.ts @@ -0,0 +1,22 @@ +import { ComponentFixture, fakeAsync, TestBed } from '@angular/core/testing'; + +import { TemporalProductListComponent } from './temporal-product-list.component'; + +describe('TemporalProductListComponent', () => { + let component: TemporalProductListComponent; + let fixture: ComponentFixture; + + beforeEach(fakeAsync(() => { + TestBed.configureTestingModule({ + declarations: [TemporalProductListComponent], + }).compileComponents(); + + fixture = TestBed.createComponent(TemporalProductListComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + })); + + it('should compile', () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/bookie/src/app/temporal-product/temporal-product-list/temporal-product-list.component.ts b/bookie/src/app/temporal-product/temporal-product-list/temporal-product-list.component.ts new file mode 100644 index 0000000..893b3c8 --- /dev/null +++ b/bookie/src/app/temporal-product/temporal-product-list/temporal-product-list.component.ts @@ -0,0 +1,91 @@ +import { Component, OnInit } from '@angular/core'; +import { FormBuilder, FormControl, FormGroup } from '@angular/forms'; +import { ActivatedRoute } from '@angular/router'; +import { BehaviorSubject } from 'rxjs'; +import { Observable } from 'rxjs'; +import { debounceTime, distinctUntilChanged, startWith } from 'rxjs/operators'; + +import { MenuCategory } from '../../core/menu-category'; +import { Product } from '../../core/product'; +import { SaleCategory } from '../../core/sale-category'; + +import { TemporalProductListDatasource } from './temporal-product-list-datasource'; + +@Component({ + selector: 'app-product-list', + templateUrl: './temporal-product-list.component.html', + styleUrls: ['./temporal-product-list.component.css'], +}) +export class TemporalProductListComponent implements OnInit { + searchFilter: Observable = new Observable(); + menuCategoryFilter: BehaviorSubject = new BehaviorSubject(''); + saleCategoryFilter: BehaviorSubject = new BehaviorSubject(''); + data: BehaviorSubject = new BehaviorSubject([]); + dataSource: TemporalProductListDatasource = new TemporalProductListDatasource( + this.searchFilter, + this.menuCategoryFilter, + this.saleCategoryFilter, + this.data, + ); + + form: FormGroup; + list: Product[][] = []; + menuCategories: MenuCategory[] = []; + saleCategories: SaleCategory[] = []; + /** Columns displayed in the table. Columns IDs can be added, removed, or reordered. */ + displayedColumns: string[] = [ + 'name', + 'price', + 'menuCategory', + 'saleCategory', + 'info', + 'quantity', + ]; + + constructor(private route: ActivatedRoute, private fb: FormBuilder) { + this.form = this.fb.group({ + filter: '', + menuCategory: '', + saleCategory: '', + }); + this.data.subscribe((data: Product[][]) => { + this.list = data; + }); + this.searchFilter = (this.form.get('filter') as FormControl).valueChanges.pipe( + startWith(''), + debounceTime(150), + distinctUntilChanged(), + ); + } + + filterMcOn(val: string) { + this.menuCategoryFilter.next(val); + } + + filterScOn(val: string) { + this.saleCategoryFilter.next(val); + } + + ngOnInit() { + this.dataSource = new TemporalProductListDatasource( + this.searchFilter, + this.menuCategoryFilter, + this.saleCategoryFilter, + this.data, + ); + this.route.data.subscribe((value) => { + const data = value as { + list: Product[][]; + menuCategories: MenuCategory[]; + saleCategories: SaleCategory[]; + }; + this.loadData(data.list, data.menuCategories, data.saleCategories); + }); + } + + loadData(list: Product[][], menuCategories: MenuCategory[], saleCategories: SaleCategory[]) { + this.menuCategories = menuCategories; + this.saleCategories = saleCategories; + this.data.next(list); + } +} diff --git a/bookie/src/app/temporal-product/temporal-product-resolver.service.spec.ts b/bookie/src/app/temporal-product/temporal-product-resolver.service.spec.ts new file mode 100644 index 0000000..23a2956 --- /dev/null +++ b/bookie/src/app/temporal-product/temporal-product-resolver.service.spec.ts @@ -0,0 +1,18 @@ +import { inject, TestBed } from '@angular/core/testing'; + +import { TemporalProductResolverService } from './temporal-product-resolver.service'; + +describe('TemporalProductResolverService', () => { + beforeEach(() => { + TestBed.configureTestingModule({ + providers: [TemporalProductResolverService], + }); + }); + + it('should be created', inject( + [TemporalProductResolverService], + (service: TemporalProductResolverService) => { + expect(service).toBeTruthy(); + }, + )); +}); diff --git a/bookie/src/app/temporal-product/temporal-product-resolver.service.ts b/bookie/src/app/temporal-product/temporal-product-resolver.service.ts new file mode 100644 index 0000000..ba198dd --- /dev/null +++ b/bookie/src/app/temporal-product/temporal-product-resolver.service.ts @@ -0,0 +1,19 @@ +import { Injectable } from '@angular/core'; +import { ActivatedRouteSnapshot, Resolve } from '@angular/router'; +import { Observable } from 'rxjs'; + +import { Product } from '../core/product'; + +import { TemporalProductService } from './temporal-product.service'; + +@Injectable({ + providedIn: 'root', +}) +export class TemporalProductResolverService implements Resolve { + constructor(private ser: TemporalProductService) {} + + resolve(route: ActivatedRouteSnapshot): Observable { + const id = route.paramMap.get('id'); + return this.ser.get(id as string); + } +} diff --git a/bookie/src/app/temporal-product/temporal-product.service.spec.ts b/bookie/src/app/temporal-product/temporal-product.service.spec.ts new file mode 100644 index 0000000..5148aed --- /dev/null +++ b/bookie/src/app/temporal-product/temporal-product.service.spec.ts @@ -0,0 +1,15 @@ +import { inject, TestBed } from '@angular/core/testing'; + +import { TemporalProductService } from './temporal-product.service'; + +describe('TemporalProductService', () => { + beforeEach(() => { + TestBed.configureTestingModule({ + providers: [TemporalProductService], + }); + }); + + it('should be created', inject([TemporalProductService], (service: TemporalProductService) => { + expect(service).toBeTruthy(); + })); +}); diff --git a/bookie/src/app/temporal-product/temporal-product.service.ts b/bookie/src/app/temporal-product/temporal-product.service.ts new file mode 100644 index 0000000..a5b1a94 --- /dev/null +++ b/bookie/src/app/temporal-product/temporal-product.service.ts @@ -0,0 +1,43 @@ +import { HttpClient, HttpHeaders, HttpParams } from '@angular/common/http'; +import { Injectable } from '@angular/core'; +import { Observable } from 'rxjs'; +import { catchError } from 'rxjs/operators'; + +import { ErrorLoggerService } from '../core/error-logger.service'; +import { Product } from '../core/product'; + +const httpOptions = { + headers: new HttpHeaders({ 'Content-Type': 'application/json' }), +}; + +const url = '/api/temporal-products'; +const serviceName = 'ProductService'; + +@Injectable({ providedIn: 'root' }) +export class TemporalProductService { + constructor(private http: HttpClient, private log: ErrorLoggerService) {} + + get(id: string): Observable { + return this.http + .get(`${url}/${id}`) + .pipe(catchError(this.log.handleError(serviceName, `get id=${id}`))) as Observable; + } + + list(): Observable { + return this.http + .get(`${url}/list`) + .pipe(catchError(this.log.handleError(serviceName, 'list'))) as Observable; + } + + update(product: Product): Observable { + return this.http + .put(`${url}/${product.versionId}`, product, httpOptions) + .pipe(catchError(this.log.handleError(serviceName, 'update'))) as Observable; + } + + delete(id: string): Observable { + return this.http + .delete(`${url}/${id}`, httpOptions) + .pipe(catchError(this.log.handleError(serviceName, 'delete'))) as Observable; + } +} diff --git a/bookie/src/app/temporal-product/temporal-products-routing.module.spec.ts b/bookie/src/app/temporal-product/temporal-products-routing.module.spec.ts new file mode 100644 index 0000000..7cf6e18 --- /dev/null +++ b/bookie/src/app/temporal-product/temporal-products-routing.module.spec.ts @@ -0,0 +1,13 @@ +import { TemporalProductsRoutingModule } from './temporal-products-routing.module'; + +describe('TemporalProductsRoutingModule', () => { + let temporalProductsRoutingModule: TemporalProductsRoutingModule; + + beforeEach(() => { + temporalProductsRoutingModule = new TemporalProductsRoutingModule(); + }); + + it('should create an instance', () => { + expect(temporalProductsRoutingModule).toBeTruthy(); + }); +}); diff --git a/bookie/src/app/temporal-product/temporal-products-routing.module.ts b/bookie/src/app/temporal-product/temporal-products-routing.module.ts new file mode 100644 index 0000000..0dfb7df --- /dev/null +++ b/bookie/src/app/temporal-product/temporal-products-routing.module.ts @@ -0,0 +1,61 @@ +import { CommonModule } from '@angular/common'; +import { NgModule } from '@angular/core'; +import { RouterModule, Routes } from '@angular/router'; + +import { AuthGuard } from '../auth/auth-guard.service'; +import { MenuCategoryListResolver } from '../menu-category/menu-category-list-resolver.service'; +import { SaleCategoryListResolver } from '../sale-category/sale-category-list-resolver.service'; + +import { TemporalProductDetailComponent } from './temporal-product-detail/temporal-product-detail.component'; +import { TemporalProductListResolverService } from './temporal-product-list-resolver.service'; +import { TemporalProductListComponent } from './temporal-product-list/temporal-product-list.component'; +import { TemporalProductResolverService } from './temporal-product-resolver.service'; + +const temporalProductsRoutes: Routes = [ + { + path: '', + component: TemporalProductListComponent, + canActivate: [AuthGuard], + data: { + permission: 'Temporal Products', + }, + resolve: { + list: TemporalProductListResolverService, + menuCategories: MenuCategoryListResolver, + saleCategories: SaleCategoryListResolver, + }, + }, + { + path: 'new', + component: TemporalProductDetailComponent, + canActivate: [AuthGuard], + data: { + permission: 'Temporal Products', + }, + resolve: { + item: TemporalProductResolverService, + menuCategories: MenuCategoryListResolver, + saleCategories: SaleCategoryListResolver, + }, + }, + { + path: ':id', + component: TemporalProductDetailComponent, + canActivate: [AuthGuard], + data: { + permission: 'Temporal Products', + }, + resolve: { + item: TemporalProductResolverService, + menuCategories: MenuCategoryListResolver, + saleCategories: SaleCategoryListResolver, + }, + }, +]; + +@NgModule({ + imports: [CommonModule, RouterModule.forChild(temporalProductsRoutes)], + exports: [RouterModule], + providers: [TemporalProductListResolverService, TemporalProductResolverService], +}) +export class TemporalProductsRoutingModule {} diff --git a/bookie/src/app/temporal-product/temporal-products.module.spec.ts b/bookie/src/app/temporal-product/temporal-products.module.spec.ts new file mode 100644 index 0000000..af97f77 --- /dev/null +++ b/bookie/src/app/temporal-product/temporal-products.module.spec.ts @@ -0,0 +1,13 @@ +import { TemporalProductsModule } from './temporal-products.module'; + +describe('TemporalProductsModule', () => { + let temporalProductsModule: TemporalProductsModule; + + beforeEach(() => { + temporalProductsModule = new TemporalProductsModule(); + }); + + it('should create an instance', () => { + expect(temporalProductsModule).toBeTruthy(); + }); +}); diff --git a/bookie/src/app/temporal-product/temporal-products.module.ts b/bookie/src/app/temporal-product/temporal-products.module.ts new file mode 100644 index 0000000..2cc7936 --- /dev/null +++ b/bookie/src/app/temporal-product/temporal-products.module.ts @@ -0,0 +1,62 @@ +import { CommonModule } from '@angular/common'; +import { NgModule } from '@angular/core'; +import { FlexLayoutModule } from '@angular/flex-layout'; +import { ReactiveFormsModule } from '@angular/forms'; +import { MomentDateAdapter } from '@angular/material-moment-adapter'; +import { MatButtonModule } from '@angular/material/button'; +import { MatCardModule } from '@angular/material/card'; +import { MatCheckboxModule } from '@angular/material/checkbox'; +import { + DateAdapter, + MAT_DATE_FORMATS, + MAT_DATE_LOCALE, + MatNativeDateModule, +} from '@angular/material/core'; +import { MatOptionModule } from '@angular/material/core'; +import { MatDatepickerModule } from '@angular/material/datepicker'; +import { MatIconModule } from '@angular/material/icon'; +import { MatInputModule } from '@angular/material/input'; +import { MatProgressSpinnerModule } from '@angular/material/progress-spinner'; +import { MatSelectModule } from '@angular/material/select'; +import { MatTableModule } from '@angular/material/table'; + +import { TemporalProductDetailComponent } from './temporal-product-detail/temporal-product-detail.component'; +import { TemporalProductListComponent } from './temporal-product-list/temporal-product-list.component'; +import { TemporalProductsRoutingModule } from './temporal-products-routing.module'; + +export const MY_FORMATS = { + parse: { + dateInput: 'DD-MMM-YYYY', + }, + display: { + dateInput: 'DD-MMM-YYYY', + monthYearLabel: 'MMM YYYY', + dateA11yLabel: 'DD-MMM-YYYY', + monthYearA11yLabel: 'MMM YYYY', + }, +}; + +@NgModule({ + imports: [ + CommonModule, + FlexLayoutModule, + MatTableModule, + MatCardModule, + MatProgressSpinnerModule, + MatInputModule, + MatButtonModule, + MatIconModule, + MatOptionModule, + MatSelectModule, + MatCheckboxModule, + ReactiveFormsModule, + TemporalProductsRoutingModule, + MatDatepickerModule, + ], + declarations: [TemporalProductListComponent, TemporalProductDetailComponent], + providers: [ + { provide: DateAdapter, useClass: MomentDateAdapter, deps: [MAT_DATE_LOCALE] }, + { provide: MAT_DATE_FORMATS, useValue: MY_FORMATS }, + ], +}) +export class TemporalProductsModule {}