diff --git a/barker/alembic/versions/c123dbf9c659_temporal_products_editing.py b/barker/alembic/versions/c123dbf9c659_temporal_products_editing.py new file mode 100644 index 0000000..f379857 --- /dev/null +++ b/barker/alembic/versions/c123dbf9c659_temporal_products_editing.py @@ -0,0 +1,27 @@ +"""temporal products editing + +Revision ID: c123dbf9c659 +Revises: e5e8acfc6495 +Create Date: 2021-08-06 10:15:57.719313 + +""" +from alembic import op + +# revision identifiers, used by Alembic. +from barker.models.permission import Permission + + +revision = "c123dbf9c659" +down_revision = "e5e8acfc6495" +branch_labels = None +depends_on = None + + +def upgrade(): + op.execute( + Permission.__table__.insert().values(id="eb604d72-8cbc-4fcb-979c-4713eaf34e56", name="Temporal Products") + ) + + +def downgrade(): + pass diff --git a/barker/barker/routers/product.py b/barker/barker/routers/product.py index 0dea117..3249dcf 100644 --- a/barker/barker/routers/product.py +++ b/barker/barker/routers/product.py @@ -6,7 +6,7 @@ from typing import List, Optional import barker.schemas.product as schemas from fastapi import APIRouter, Depends, HTTPException, Security, status -from sqlalchemy import and_, insert, or_, select, update +from sqlalchemy import and_, delete, insert, or_, select, update from sqlalchemy.exc import SQLAlchemyError from sqlalchemy.orm import Session, contains_eager, joinedload from sqlalchemy.sql.functions import count @@ -71,7 +71,7 @@ def save( data: schemas.ProductIn, date_: date = Depends(effective_date), user: UserToken = Security(get_user, scopes=["products"]), -): +) -> None: try: with SessionFuture() as db: item = Product() @@ -93,7 +93,7 @@ def save( db.add(product_version) add_modifiers(item.id, product_version.menu_category_id, date_, db) db.commit() - return product_info(product_version) + return except SQLAlchemyError as e: raise HTTPException( status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, @@ -143,16 +143,18 @@ 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("/{id_}", response_model=schemas.Product) +@router.put("/{version_id}", response_model=schemas.Product) def update_route( - id_: uuid.UUID, + version_id: uuid.UUID, data: schemas.ProductIn, date_: date = Depends(effective_date), user: UserToken = Security(get_user, scopes=["products"]), -) -> schemas.Product: +) -> None: try: with SessionFuture() as db: - item: ProductVersion = db.execute( + 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( @@ -169,42 +171,65 @@ def update_route( ) ) ).scalar_one() - if item.valid_till is not None: + 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: # 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 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 + + 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 db.commit() - return product_info(item) - 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 product_info(product_version) + 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 except SQLAlchemyError as e: raise HTTPException( status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, @@ -212,15 +237,19 @@ def update_route( ) -@router.delete("/{id_}") +@router.delete("/{version_id}") def delete_route( - id_: uuid.UUID, + version_id: uuid.UUID, date_: date = Depends(effective_date), user: UserToken = Security(get_user, scopes=["products"]), -): +) -> None: with SessionFuture() as db: - item: ProductVersion = db.execute( - select(ProductVersion).where( + 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( and_( ProductVersion.product_id == id_, or_( @@ -234,26 +263,82 @@ def delete_route( ) ) ).scalar_one() - if item.valid_from == date_: - db.delete(item) + 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 delete this product.", + ) + + 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) else: - item.valid_till = date_ - timedelta(days=1) + latest.valid_till = date_ - timedelta(days=1) db.commit() + return -@router.get("", response_model=schemas.ProductBlank) +@router.get("", response_model=List[schemas.ProductBlank]) def show_blank( user: UserToken = Security(get_user, scopes=["products"]), -) -> schemas.ProductBlank: - return schemas.ProductBlank( - name="", - units="", - price=0, - hasHappyHour=False, - isNotAvailable=False, - isActive=True, - sortOrder=0, - ) +) -> List[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]) @@ -401,39 +486,44 @@ def product_list_of_sale_category(date_: date, db: Session) -> List[schemas.Prod ] -@router.get("/{id_}", response_model=schemas.Product) +@router.get("/{id_}", response_model=List[schemas.Product]) def show_id( id_: uuid.UUID, date_: date = Depends(effective_date), user: UserToken = Security(get_user, scopes=["products"]), -) -> schemas.Product: +) -> 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_, + ), + ) with SessionFuture() as db: - 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_, - ), + 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), ) ) - .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) + .scalars() + .all() + ] + return items def query_product_info(item: ProductVersion, happy_hour: bool): @@ -459,6 +549,7 @@ def query_product_info(item: ProductVersion, happy_hour: bool): 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=[]), @@ -472,4 +563,6 @@ def product_info(item: ProductVersion) -> schemas.Product: quantity=item.quantity, isActive=True, sortOrder=item.sort_order, + validFrom=item.valid_from, + validTill=item.valid_till, ) diff --git a/barker/barker/schemas/product.py b/barker/barker/schemas/product.py index 7d64ab9..394cb17 100644 --- a/barker/barker/schemas/product.py +++ b/barker/barker/schemas/product.py @@ -1,9 +1,10 @@ import uuid +from datetime import date, datetime from decimal import Decimal from typing import Optional -from pydantic import BaseModel, Field +from pydantic import BaseModel, Field, validator from . import to_camel from .menu_category import MenuCategoryLink @@ -29,10 +30,30 @@ class ProductIn(BaseModel): class Product(ProductIn): id_: uuid.UUID + version_id: uuid.UUID + valid_from: Optional[date] + valid_till: Optional[date] class Config: anystr_strip_whitespace = True alias_generator = to_camel + json_encoders = {date: lambda v: v.strftime("%d-%b-%Y")} + + @validator("valid_from", pre=True) + def parse_valid_from(cls, value): + if value is None: + return None + if isinstance(value, date): + return value + return datetime.strptime(value, "%d-%b-%Y").date() + + @validator("valid_till", pre=True) + def parse_valid_till(cls, value): + if value is None: + return None + if isinstance(value, date): + return value + return datetime.strptime(value, "%d-%b-%Y").date() class ProductBlank(ProductIn): diff --git a/bookie/src/app/core/product.ts b/bookie/src/app/core/product.ts index 4c7b2c6..35e8678 100644 --- a/bookie/src/app/core/product.ts +++ b/bookie/src/app/core/product.ts @@ -5,6 +5,7 @@ import { Tax } from './tax'; export class Product { id: string | undefined; + versionId?: string; code: number; name: string; units: string; @@ -20,6 +21,9 @@ export class Product { enabled: boolean; tax: Tax; + validFrom?: string; + validTill?: string; + public constructor(init?: Partial) { this.id = undefined; this.code = 0; diff --git a/bookie/src/app/product/product-detail/product-detail.component.css b/bookie/src/app/product/product-detail/product-detail.component.css index e69de29..a4b97b3 100644 --- a/bookie/src/app/product/product-detail/product-detail.component.css +++ b/bookie/src/app/product/product-detail/product-detail.component.css @@ -0,0 +1,3 @@ +.mat-radio-button ~ .mat-radio-button { + margin-left: 16px; +} diff --git a/bookie/src/app/product/product-detail/product-detail.component.html b/bookie/src/app/product/product-detail/product-detail.component.html index 9402fb1..c45972e 100644 --- a/bookie/src/app/product/product-detail/product-detail.component.html +++ b/bookie/src/app/product/product-detail/product-detail.component.html @@ -1,95 +1,111 @@ -
- - - Product - - -
-
- - Code - - -
-
- - Name - - - - Units - - -
-
- - Price - - - - Quantity - - -
-
- Has Happy Hour? - Is Not Available? -
-
- - Menu Category - - - {{ mc.name }} - - - - - Sale Category - - - {{ sc.name }} - - - -
-
-
- - - - -
+
+
+ + + 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' }} + + +
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 1e7730a..17bafe0 100644 --- a/bookie/src/app/product/product-detail/product-detail.component.ts +++ b/bookie/src/app/product/product-detail/product-detail.component.ts @@ -1,6 +1,7 @@ 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'; @@ -21,6 +22,7 @@ export class ProductDetailComponent implements OnInit, AfterViewInit { menuCategories: MenuCategory[] = []; saleCategories: SaleCategory[] = []; item: Product = new Product(); + list: Product[] = []; constructor( private route: ActivatedRoute, @@ -47,13 +49,14 @@ export class ProductDetailComponent implements OnInit, AfterViewInit { ngOnInit() { this.route.data.subscribe((value) => { const data = value as { - item: Product; + items: Product[]; menuCategories: MenuCategory[]; saleCategories: SaleCategory[]; }; this.menuCategories = data.menuCategories; this.saleCategories = data.saleCategories; - this.showItem(data.item); + this.list = data.items; + this.showItem(this.list[this.list.length - 1]); }); } @@ -93,7 +96,7 @@ export class ProductDetailComponent implements OnInit, AfterViewInit { } delete() { - this.ser.delete(this.item.id as string).subscribe( + this.ser.delete(this.item.versionId as string).subscribe( () => { this.toaster.show('Success', ''); this.router.navigateByUrl('/products'); @@ -135,4 +138,9 @@ 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-resolver.service.ts b/bookie/src/app/product/product-resolver.service.ts index 4c74b9f..0e5df61 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 2be041d..ee70c2e 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 { @@ -48,16 +48,16 @@ export class ProductService { >; } - save(product: Product): Observable { + save(product: Product): Observable { return this.http .post(`${url}`, product, httpOptions) - .pipe(catchError(this.log.handleError(serviceName, 'save'))) as Observable; + .pipe(catchError(this.log.handleError(serviceName, 'save'))) as Observable; } - update(product: Product): Observable { + update(product: Product): Observable { return this.http - .put(`${url}/${product.id}`, product, httpOptions) - .pipe(catchError(this.log.handleError(serviceName, 'update'))) as Observable; + .put(`${url}/${product.versionId}`, product, httpOptions) + .pipe(catchError(this.log.handleError(serviceName, 'update'))) as Observable; } updateSortOrder(list: Product[]): Observable { @@ -68,17 +68,17 @@ export class ProductService { >; } - saveOrUpdate(product: Product): Observable { - if (!product.id) { + saveOrUpdate(product: Product): Observable { + if (!product.versionId) { return this.save(product); } return this.update(product); } - delete(id: string): Observable { + delete(id: string): Observable { return this.http .delete(`${url}/${id}`, httpOptions) - .pipe(catchError(this.log.handleError(serviceName, 'delete'))) as Observable; + .pipe(catchError(this.log.handleError(serviceName, 'delete'))) as Observable; } balance(id: string, date: string): Observable { diff --git a/bookie/src/app/product/products-routing.module.ts b/bookie/src/app/product/products-routing.module.ts index 8d267de..d766f86 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: { - item: ProductResolver, + items: ProductResolver, menuCategories: MenuCategoryListResolver, saleCategories: SaleCategoryListResolver, }, @@ -45,7 +45,7 @@ const productsRoutes: Routes = [ permission: 'Products', }, resolve: { - item: ProductResolver, + items: ProductResolver, menuCategories: MenuCategoryListResolver, saleCategories: SaleCategoryListResolver, }, diff --git a/bookie/src/app/product/products.module.ts b/bookie/src/app/product/products.module.ts index e9a2a8f..d3746cb 100644 --- a/bookie/src/app/product/products.module.ts +++ b/bookie/src/app/product/products.module.ts @@ -11,6 +11,7 @@ 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'; @@ -35,6 +36,7 @@ import { ProductsRoutingModule } from './products-routing.module'; MatCheckboxModule, ReactiveFormsModule, ProductsRoutingModule, + MatRadioModule, ], declarations: [ProductListComponent, ProductDetailComponent], })