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
This commit is contained in:
2021-10-27 09:27:47 +05:30
parent debe0df7b7
commit 124cf4d9ff
40 changed files with 1522 additions and 313 deletions

View File

@ -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 ###

View File

@ -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 ###

View File

@ -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"])

View File

@ -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__(

View File

@ -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

View File

@ -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):

View File

@ -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,
)