class Product(SearchMixin, SAFRSMixin, db.Model, RoleMixin, SoftDeleteMixin, UpdateMixin): __tablename__ = "products" custom_decorators = [role_policy(default="Sales Executive", view="Shopper"), login_required] id = db.Column(db.Integer, primary_key=True) name = db.Column(db.String) item_number = db.Column(db.String) # relationships product_type_id = db.Column(db.Integer, db.ForeignKey("product_types.id")) product_type = db.relationship("ProductType", foreign_keys=[product_type_id]) distributor_id = db.Column(db.Integer, db.ForeignKey("distributors.id")) distributor = db.relationship("Distributor", foreign_keys=[distributor_id]) product_variants = db.relationship("ProductVariant", cascade="delete") @validates("name") def validate_name(self, key, value): if not value: raise ValidationError("Name is empty") return value @validates("product_type", "product_type_id") def validate_product_type(self, key, value): if not value: setattr(self, f"_{key}_empty", True) if (key == "product_type" and getattr(self, "_product_type_id_empty", False)) or ( key == "product_type_id" and getattr(self, "_product_type_empty", False) ): raise ValidationError("Product type not selected") return value @validates("distributor", "distributor_id") def validate_distributor(self, key, value): if not value: setattr(self, f"_{key}_empty", True) if (key == "distributor" and getattr(self, "_distributor_id_empty", False)) or ( key == "distributor_id" and getattr(self, "_distributor_empty", False) ): raise ValidationError("Distributor not selected") return value
class Role(SAFRSMixin, db.Model, RoleMixin, UpdateMixin): __tablename__ = "roles" custom_decorators = [role_policy(default="Sales Executive", view="Shopper"), login_required] id = db.Column(db.Integer, primary_key=True) name = db.Column(db.String, nullable=False, unique=True) # relationships accounts = db.relationship("Account", cascade="delete")
class ProductType(SAFRSMixin, db.Model, RoleMixin, SoftDeleteMixin, TimestampMixin, UpdateMixin): __tablename__ = "product_types" custom_decorators = [ role_policy(default="Sales Executive", view="Shopper"), login_required ] id = db.Column(db.Integer, primary_key=True) name = db.Column(db.String) # relationships products = db.relationship("Product") product_attributes = db.relationship("ProductAttribute", product_type_attributes, backref="product_types") distributors = db.relationship("Distributor", product_type_distributors, backref="product_types") vendors = db.relationship("Vendor", product_type_vendors, backref="product_types")
class Distributor(SearchMixin, SAFRSMixin, db.Model, RoleMixin, SoftDeleteMixin, TimestampMixin, UpdateMixin): __tablename__ = "distributors" custom_decorators = [ role_policy(default="Sales Executive", view="Shopper"), login_required ] # FIXME: going to have to figure out which of these columns stay id = db.Column(db.Integer, primary_key=True) name = db.Column(db.String, unique=True) email = db.Column(db.String, unique=False) address = db.Column(db.String, unique=False) ow_cost = db.Column(db.Float, default=0.0) campaign_cost = db.Column(db.Float, default=0.0) transaction_cost = db.Column(db.Float, default=0.0) max_sales_count = db.Column(db.Integer) # relationships accounts = db.relationship("Account", cascade="delete") campaigns = db.relationship("Campaign", cascade="delete") products = db.relationship("Product", cascade="delete") @validates("name") def validate_name(self, key, name): if not name or not name.replace(" ", ""): raise ValidationError("Distributor name is not valid") distributor = Distributor.query.filter_by(name=name).one_or_none() if distributor is not None and distributor.id != self.id: raise ValidationError("Distributor name already exists") return name
class CampaignProductVariant(SearchMixin, SAFRSMixin, db.Model, RoleMixin, UpdateMixin): """Represent the set of variants that are part of a live/complete campaign. Notes ----- When a Campaign is in draft mode, it is linked with ProductVariant models that have dynamic links with the rest of the database. This makes everything editable and re-usable between campaigns. When a Campaign is made live, the contents of those related models are copied into this model. This creates a "frozen" version of the model at the moment the campaign went live. """ __tablename__ = "campaign_product_variants" custom_decorators = [ role_policy(default="Sales Executive", view="Shopper"), login_required ] id = db.Column(db.Integer, primary_key=True) bin_id = db.Column(db.Integer, default=None) decorations = db.Column(db.JSON) margin = db.Column(db.Float) vendor_cost = db.Column(db.Float) # from Distributor ow_cost = db.Column(db.Float) # from ProductVariant description = db.Column(db.String) _sku = db.Column("sku", db.String) # from ProductAttributeValues attributes = db.Column(db.JSON) # from Vendor supplier_name = db.Column(db.String) supplier_email = db.Column(db.String) supplier_address = db.Column(db.String) decorator_name = db.Column(db.String) decorator_email = db.Column(db.String) decorator_address = db.Column(db.String) # from Product product_id = db.Column(db.Integer) product_name = db.Column(db.String) # from ProductType product_type_name = db.Column(db.String) # relationships product_variant_id = db.Column(db.Integer, db.ForeignKey("product_variants.id")) product_variant = db.relationship("ProductVariant", foreign_keys=[product_variant_id]) campaign_id = db.Column(db.Integer, db.ForeignKey("campaigns.id")) order_items = db.relationship("OrderItem", cascade="delete") @property def total(self): if not self.campaign: raise ValidationError( f"CampaignProductVariant {self.id} is not associated with a campaign" ) if self.ow_cost: ow_cost = self.ow_cost elif not self.campaign.distributor: raise ValidationError( f"Campaign {self.campaign.id} is not associated with a distributor" ) else: ow_cost = self.campaign.distributor.ow_cost total = self.vendor_cost + self.campaign.bfl_cost + ow_cost if self.decorations: total += sum([obj["cost"] for obj in self.decorations]) return total @property def price(self): divider = 1 - (self.margin / 100) return round(self.total / divider, 2) @property def sku(self): if self._sku: return self._sku elif not self.product_variant: raise ValidationError( f"CampaignProductVariant {self.id} is not associated with a product variant" ) sku = self.product_variant.sku.split("-") if self.decorations: for decoration in self.decorations: sku.append(decoration["location"].upper()) return "-".join(sku) @sku.setter def sku(self, value): self._sku = value def to_dict(self): """Serialize this class into a SAFRS-compatible dictionary. Notes ----- This is where we expose additional fields that are either 1) not in the model or 2) not automatically put in the model by SAFRS because it does not work with custom properties. """ result = SAFRSBase.to_dict(self) result.update({ "price": self.price, "sku": self.sku, "total": self.total }) return result @validates("margin") def validate_margin(self, key, margin): if margin > 100: raise ValidationError("Margin cannot be more than 100") return margin @validates("decorations") def validate_decorations(self, key, value): if isinstance(value, str): value = json.loads(value) if not isinstance(value, list): raise ValidationError("Decorations must be a list") for item in value: if not isinstance(item, dict): raise ValidationError( "Items in decorations list must be dictionaries") if set(item.keys()).difference( {"cost", "location", "logo_description"}): raise ValidationError( f"Invalid keys in decoration: {item.keys()}") return value def populate_from_variant(self): """Copy various pieces of information into this model from its related ProductVariant Notes ----- Call this when finalizing a campaign and moving it out of "draft" mode. """ self.ow_cost = self.campaign.distributor.ow_cost self.description = self.product_variant.description self.sku = self.sku # copy attribute values into JSON attributes = [] for attr_value in self.product_variant.attribute_values: attributes.append({ "name": attr_value.product_attribute.name, "value": attr_value.name }) self.attributes = attributes self.supplier_name = self.product_variant.supplier.name self.supplier_email = self.product_variant.supplier.email self.supplier_address = self.product_variant.supplier.address self.product_id = self.product_variant.product.id self.product_name = self.product_variant.product.name self.product_type_name = self.product_variant.product.product_type.name def populate_from_decorator(self, decorator_id): """Copy data from the Vendor table, representing the decorator of this variant.""" from src.models import Vendor decorator = Vendor.query.get(decorator_id) if decorator is None: raise ValidationError(f"Vendor {decorator_id} does not exist") if not decorator.is_decorator: raise ValidationError(f"Vendor {decorator_id} is not a decorator") self.decorator_name = decorator.name self.decorator_email = decorator.email self.decorator_address = decorator.address
class Campaign(SAFRSMixin, db.Model, RoleMixin, TimestampMixin, UpdateMixin): __tablename__ = "campaigns" custom_decorators = [role_policy(default="Sales Executive", view="Shopper"), login_required] # columns are listed in groups by which page in the campaign creator they are filled in id = db.Column(db.Integer, primary_key=True) _status = db.Column(db.String, default="Active") complete = db.Column(db.Boolean, default=False) name = db.Column(db.String, unique=True) company_name = db.Column(db.String) start_date = db.Column(db.DateTime(timezone=True)) end_date = db.Column(db.DateTime(timezone=True)) storefront_pricing = db.Column(db.Boolean) company_allowance = db.Column(db.Float) company_allowance_personal_pay = db.Column(db.String) items_count_limit = db.Column(db.Integer) price_limit = db.Column(db.Float) email_shopper = db.Column(db.Boolean) checkout_fields = db.Column(db.JSON) departments = db.Column(db.JSON) managers = db.Column(db.JSON) message = db.Column(db.String) bfl_cost = db.Column(db.Float, default=0) distributor_po = db.Column(db.String) pick_pack_partner_message = db.Column(db.String) # relationships created_by_id = db.Column(db.Integer, db.ForeignKey("accounts.id"), nullable=True) created_by = db.relationship("Account", foreign_keys=[created_by_id]) pick_pack_partner_id = db.Column(db.Integer, db.ForeignKey("vendors.id")) pick_pack_partner = db.relationship("Vendor", foreign_keys=[pick_pack_partner_id]) distributor_id = db.Column(db.Integer, db.ForeignKey("distributors.id")) distributor = db.relationship("Distributor", foreign_keys=[distributor_id]) campaign_product_variants = db.relationship( "CampaignProductVariant", cascade="delete", backref="campaign" ) orders = db.relationship("Order", cascade="delete", backref="campaign") locations = db.relationship("Location", cascade="delete", backref="campaign") def __init__(self, *args, **kwargs): # set the default value of created_by_id to the account.id of the currently-logged in user. # if you are creating a Campaign model in e.g., the flask shell, you will have to manually # specify created_by_id in the kwargs, or this will raise (as g.account.id does not exist) if "created_by_id" not in kwargs or kwargs["created_by_id"] is None: kwargs["created_by_id"] = g.account.id super().__init__(*args, **kwargs) @validates("name") def validate_name(self, key, name): if not name or not name.replace(" ", ""): raise ValidationError("Campaign name is not valid") campaign = Campaign.query.filter_by(name=name).one_or_none() if campaign is not None: raise ValidationError("Campaign name already exists") return name @validates("end_date") def validate_end_date(self, key, end_date): if self.start_date and self.start_date > end_date: raise ValidationError("Start date cannot occur before end date") return end_date @hybrid_property def status(self): return self._status
class Account(SearchMixin, SAFRSMixin, db.Model, RoleMixin, UpdateMixin): __tablename__ = "accounts" custom_decorators = [role_policy(default="Sales Executive", view="Shopper"), login_required] exclude_attrs = ["_password"] id = db.Column(db.Integer, primary_key=True) username = db.Column(db.String, nullable=False, unique=True) _password = db.Column(db.String) email = db.Column(db.String) first_name = db.Column(db.String) last_name = db.Column(db.String) active = db.Column(db.Boolean, default=True) reports_enabled = db.Column(db.Boolean, default=True) # FIXME: drop basket from model. will prevent multiple shoppers from using the same account. basket = db.Column(db.JSON, default={}) # relationships role_id = db.Column(db.Integer, db.ForeignKey("roles.id"), nullable=False) role = db.relationship("Role", foreign_keys=[role_id]) distributor_id = db.Column(db.Integer, db.ForeignKey("distributors.id")) distributor = db.relationship("Distributor", foreign_keys=[distributor_id]) campaigns = db.relationship("Campaign", account_campaigns, backref="accounts") orders = db.relationship("Order", cascade="delete", backref="account") order_notes = db.relationship("OrderNote", cascade="delete", backref="account") tokens = db.relationship("Token", cascade="delete", backref="account") @hybrid_property def password(self): return self._password @password.setter def password(self, value): self._password = hash_password(value) @property def fullname(self): return f"{self.first_name} {self.last_name}" @property def campaign(self): return self.campaigns[0] if self.campaigns else None @campaign.setter def campaign(self, value): if value is None: self.campaigns = [] else: self.campaigns = [value] @property def campaign_id(self): return self.campaigns[0].id if self.campaigns else None @campaign_id.setter def campaign_id(self, value): from src.models.campaign import Campaign self.campaign = Campaign.query.get(value) def _s_patch(self, **kwargs): """Patch this model in SAFRS API. Notes ----- Allow PATCH in the SAFRS API to set the password and campaign_id. Both are custom properties which are (apparently) not recognized by the auto-field-generating ability of SAFRS. """ SAFRSBase._s_patch(self, **kwargs) campaign_id = kwargs.pop("campaign_id", None) if campaign_id: self.campaign_id = campaign_id password = kwargs.pop("password", None) if password: self.password = password @classmethod def _s_filter(cls, args): """Filter this model in SAFRS API. Notes ----- This is overloading the method in SearchMixin to allow filtering by Campaign.name. This relationship is a one-to-many (account to campaigns) using a join table. Unlike attributes that are directly on this model, or simple joins, this requires a complicated join using that secondary table in order to filter by Campaign.name. """ from src.models.campaign import Campaign # pop off searching by campaign name, which requires a special join json_args = json.loads(args) for idx, term in enumerate(json_args): if term["field"] == "campaign_name": del json_args[idx] break else: return super()._s_filter(args) query = super()._s_filter(json.dumps(json_args)) query = ( query.join(account_campaigns, account_campaigns.c.account_id == Account.id) .join(Campaign, account_campaigns.c.campaign_id == Campaign.id) .filter(Campaign.name.ilike(f"%{term['value']}%")) ) return query def to_dict(self): """Serialize this class into a SAFRS-compatible dictionary. Notes ----- This is where we expose additional fields that are either 1) not in the model or 2) not automatically put in the model by SAFRS because it does not work with custom properties. """ result = SAFRSBase.to_dict(self) result.update({"campaign_id": self.campaign_id, "password": None}) return result @validates("username") def validate_username(self, key, username): if not username or not username.replace(" ", ""): raise ValidationError("Username is not valid") account = Account.query.filter_by(username=username).one_or_none() if account is not None and account.id != self.id: raise ValidationError("Username already exists") return username @validates("_password") def validate_password(self, key, value): # if nothing was specified, assume the password is to be kept and not changed if value is None: return self._password # password cannot be blank/empty if not value.replace(" ", ""): raise ValidationError("Password can not be empty") return value @validates("role", "role_id") def validate_role(self, key, role, campaign=None, distributor=None): if key == "role_id": from src.models.role import Role self.role = Role.query.get(role) return role # can not run this validator if there is no role if role is None: return if campaign is None: campaign = self.campaign if distributor is None: distributor = self.distributor # raise if there are too many sales execs associated with this distributor. we can only run # this validator if there is a distributor relationship loaded in this model if role.name == "Sales Executive" and distributor: sales_execs = Account.query.filter_by( distributor_id=distributor.id, role_id=role.id ).count() if sales_execs + 1 > distributor.max_sales_count: raise ValidationError( f"You may only create {distributor.max_sales_count} " f"Sales Executive(s) for this distributor" ) # raise if we are putting this user in a campaign that already has a shopper or buyer. we # can only run this validator if a campaign is loaded in this model if role.name in {"Shopper", "Admin Buyer"} and campaign: for account in campaign.accounts: if account.id != self.id and account.role.name == role.name: raise ValidationError( f"Campaign {campaign.id} already has a user with the {role.name} role" ) return role @validates("campaigns") def validate_campaigns(self, key, campaign): # this must trigger revalidation of the user's role in relation to any new campaigns self.validate_role(None, self.role, campaign=campaign) return campaign @validates("distributor", "distributor_id") def validate_distributor(self, key, distributor): if key == "distributor_id": from src.models.distributor import Distributor self.distributor = Distributor.query.get(distributor) return distributor # this triggers re-validation of the user's role in consideration of this distributor self.validate_role(None, self.role, distributor=distributor) return distributor @jsonapi_rpc(http_methods=["GET"]) def get_basket(self): """Return contents of basket as JSON. Removes ProductVariants which are no longer accessible to the user. This can happen if, for example, an item sells out or is removed from a campaign after a user has added it but before they have checked out. Returns ------- `str` containing JSON dict mapping ProductVariant.id to `int` quantity """ from src.models.product_variant import ProductVariant stripped_basket = {} for product_variant_id, quantity in self.basket.items(): if ProductVariant.query.get(product_variant_id): stripped_basket[product_variant_id] = quantity self.basket = stripped_basket db.session.commit() return self.basket @jsonapi_rpc(http_methods=["POST"]) def add_item_to_basket(self, product_variant_id, quantity): from src.models.product_variant import ProductVariant # this just checks that the ProductVariant exists ProductVariant.query.get_or_404(product_variant_id) new_basket = copy.deepcopy(g.account.basket) if quantity == 0: del new_basket[product_variant_id] else: new_basket[product_variant_id] = quantity # abort if new quantity would put cart over the price limit or total item count limit basket_total = 0 basket_count = 0 for basket_pv_id, basket_pv_quantity in new_basket.items(): basket_pv = ProductVariant.query.get_or_404(basket_pv_id) basket_total += basket_pv.price * basket_pv_quantity basket_count += basket_pv_quantity if self.campaign.price_limit and basket_total > self.campaign.price_limit: raise GenericError("Basket total price limit exceeded", 400) if self.campaign.items_count_limit and basket_count > self.campaign.items_count_limit: raise GenericError("Basket contains more items than allowed", 400) self.basket = new_basket db.session.commit() return self.basket @jsonapi_rpc(http_methods=["POST"]) def delete_basket(self): self.basket = {} db.session.commit() return self.basket @jsonapi_rpc(http_methods=["POST"]) def checkout(self): # FIXME: this probably needs to be completely rewritten from src.models import Order, OrderItem from src.plugins import logger, mail basket = request.json.get("basket") logger.info(basket) order = Order( account=g.account, campaign=g.account.campaign, checkout_fields=request.json.get("checkout_fields"), ) basketImgs = {} for item in basket: order_item = OrderItem( product_variant_id=int(item["product_variant_id"]), quantity=int(item["quantity"]) ) basketImgs[int(item["product_variant_id"])] = item["img_url"] db.session.add(order_item) order.order_items.append(order_item) g.account.basket = {} db.session.add(order) db.session.commit() basketSizes = {} for order_item in order.order_items: basketSizes[order_item.product_variant_id] = next( ( x["value"] for x in order_item.product_variant.attributes if x["name"] == "size" or x["name"] == "Size" ), "no size", ) # send confirmation email to shopper if order.checkout_fields.get("Company Email"): try: mail.order_email_to_shopper(order, basketImgs, basketSizes) except Exception as e: print(e) return order
class Order(SearchMixin, SAFRSMixin, db.Model, RoleMixin, TimestampMixin, UpdateMixin): __tablename__ = "orders" custom_decorators = [ role_policy(default="Sales Executive", view="Shopper"), login_required ] id = db.Column(db.Integer, primary_key=True) status = db.Column(db.String, default="processing") checkout_fields = db.Column(db.JSON) # relationships account_id = db.Column(db.Integer, db.ForeignKey("accounts.id")) campaign_id = db.Column(db.Integer, db.ForeignKey("campaigns.id")) order_items = db.relationship("OrderItem", cascade="delete") order_events = db.relationship("OrderEvent", cascade="delete", backref="order") order_notes = db.relationship("OrderNote", cascade="delete", backref="order") @property def total(self): total_sum = 0 for item in self.order_items: total_sum += item.campaign_product_variant.price * item.quantity return round(total_sum, 2) @hybrid_property def fullname(self): if self.checkout_fields.get("First Name") and self.checkout_fields.get( "Last Name"): return ( f'{self.checkout_fields.get("First Name")} {self.checkout_fields.get("Last Name")}' ) @validates("status") def validate_status(self, key, value): # only create an event if the status has changed and the model already exists if self.id and self.status != value: event = OrderEvent(order_id=self.id, note=f'Status changed to "{value}"') db.session.add(event) return value def to_dict(self): """Serialize this class into a SAFRS-compatible dictionary. Notes ----- This is where we expose additional fields that are either 1) not in the model or 2) not automatically put in the model by SAFRS because it does not work with custom properties. """ result = SAFRSBase.to_dict(self) result.update({"total": self.total}) return result
class Vendor(SearchMixin, SAFRSMixin, db.Model, RoleMixin, UpdateMixin): __tablename__ = "vendors" custom_decorators = [ role_policy(default="Sales Executive", view="Shopper"), login_required ] id = db.Column(db.Integer, primary_key=True) name = db.Column(db.String, unique=True) email = db.Column(db.String) address = db.Column(db.String) is_supplier = db.Column(db.Boolean, default=False) is_pick_pack = db.Column(db.Boolean, default=False) is_active = db.Column(db.Boolean, default=True) is_decorator = db.Column(db.Boolean, default=False) # relationships campaigns = db.relationship("Campaign", cascade="delete") @validates("name") def validate_name(self, key, name): if not name or not name.replace(" ", ""): raise ValidationError("Name is not valid") vendor = Vendor.query.filter_by(name=name).one_or_none() if vendor is not None and vendor.id != self.id: raise ValidationError("Vendor name already exists") return name @validates("address") def validate_address(self, key, address): if not address or not address.replace(" ", ""): raise ValidationError("Address is not valid") return address @validates("email") def validate_email(self, key, email): # FIXME: actually validate e-mail addresses if not email or not email.replace(" ", ""): raise ValidationError("E-mail is not valid") return email @validates("product_types") def validate_product_types(self, key, value): if not value: raise ValidationError("You must select at least one product type") return value @validates("is_decorator", "is_pick_pack", "is_supplier") def validate_vendor_role(self, key, value): should_raise = False if value is False: if key == "is_supplier" and self.is_decorator is False and self.is_pick_pack is False: should_raise = True elif key == "is_decorator" and self.is_supplier is False and self.is_pick_pack is False: should_raise = True elif key == "is_pick_pack" and self.is_decorator is False and self.is_supplier is False: should_raise = True if should_raise: raise ValidationError( "You must select one of: decorator, supplier, or pick pack") return value
class OrderItem(SAFRSMixin, db.Model, RoleMixin, UpdateMixin): __tablename__ = "order_items" custom_decorators = [ role_policy(default="Sales Executive", view="Shopper"), login_required ] id = db.Column(db.Integer, primary_key=True) quantity = db.Column(db.Integer) # relationships order_id = db.Column(db.Integer, db.ForeignKey("orders.id")) order = db.relationship("Order", foreign_keys=[order_id]) campaign_product_variant_id = db.Column( db.Integer, db.ForeignKey("campaign_product_variants.id"), nullable=False) campaign_product_variant = db.relationship( "CampaignProductVariant", foreign_keys=[campaign_product_variant_id]) @validates("quantity") def validate_quantity(self, key, quantity, order=None, campaign_product_variant=None): """Validate the quantity of this line item in an order. Parameters ---------- key : `str` The name of the attribute to validate, i.e. quantity. quantity : `int` The quantity of this item in the order. order : `models.Order` Specifying this will use the given Order to validate the quantity. If not given, will use the order relationship within this model, i.e. self.order. campaign_product_variant : `models.CampaignProductVariant` Specifying this will use the given ProductVariant to validate the quantity. If not given, will use the relationship in the model, i.e. self.campaign_product_variant. Returns ------- `int`, the validated quantity """ if order is None: order = self.order if campaign_product_variant is None: campaign_product_variant = self.campaign_product_variant # skip this validator if no quantity was specified (let SQL catch non-nullables) if quantity is None: return quantity # skip this validator if the necessary related models are not set if not order or not campaign_product_variant: return quantity # disallow changes if an order is not processing if order.status != "processing": raise ValidationError( "Cannot update an order that is canceled or shipped") # skip the rest of this validator if neither limit is set if not (order.campaign.price_limit or order.campaign.items_count_limit): return quantity # raise if new quantity would put cart over the price limit or total item count limit basket_total = 0 basket_count = 0 for order_item in order.order_items: if order_item.campaign_product_variant_id == campaign_product_variant.id: continue basket_total += order_item.campaign_product_variant.price * order_item.quantity basket_count += order_item.quantity basket_total += campaign_product_variant.price * quantity basket_count += quantity if order.campaign.price_limit and basket_total > order.campaign.price_limit: raise ValidationError("Basket total price limit exceeded") if order.campaign.items_count_limit and basket_count > order.campaign.items_count_limit: raise ValidationError("Basket contains more items than allowed") return quantity # to validate quantity, two relationships are required: Order and CampaignProductVariant. if # either of those is not present in the model, quantity validation cannot occur. if either of # those model relationships changes, we need to re-validate the quantity, which we trigger here. @validates("order_id", "order") def validate_order(self, key, order): if key == "order_id": from src.models.order import Order self.order = Order.query.get(order) return order self.validate_quantity(None, self.quantity, order=order) return order @validates("campaign_product_variant_id", "campaign_product_variant") def validate_campaign_product_variant(self, key, campaign_product_variant): if key == "campaign_product_variant_id": from src.models.campaign_product_variant import CampaignProductVariant self.campaign_product_variant = CampaignProductVariant.query.get( campaign_product_variant) return campaign_product_variant # if we are changing this line item to another variant, ensure that it has the same # product_id as the previous item. we want to allow switching between variants within a # product--not changing products entirely! if self.campaign_product_variant is not None: if self.campaign_product_variant.product_id != campaign_product_variant.product_id: raise ValidationError( "Can only change to variants of the same product. " "Changing to a different product is currently unsupported." ) if self.campaign_product_variant.campaign_id != campaign_product_variant.campaign_id: raise ValidationError( "The variant you selected was not sold in the campaign associated " "with this order") self.validate_quantity( None, self.quantity, campaign_product_variant=campaign_product_variant) # skip the rest of this validator if order is not set if not self.order: return campaign_product_variant # disallow changes if an order is not processing if self.order.status != "processing": raise ValidationError( "Cannot update an order that is canceled or shipped") return campaign_product_variant
class ProductAttribute(SAFRSMixin, db.Model, RoleMixin, TimestampMixin, UpdateMixin): __tablename__ = "product_attributes" custom_decorators = [role_policy(default="Sales Executive", view="Shopper"), login_required] id = db.Column(db.Integer, primary_key=True) name = db.Column(db.String) distributors_enabled = db.Column(db.Boolean, default=False) sales_execs_enabled = db.Column(db.Boolean, default=False) # FIXME: these fields might be entirely obsolete assoc_product_image = db.Column(db.Boolean, default=False) assoc_added_costs = db.Column(db.Boolean, default=False) assoc_bin_id = db.Column(db.Boolean, default=False) # relationships values = db.relationship( "ProductAttributeValue", order_by="ProductAttributeValue.position", collection_class=safrs_compatible_ordering_list("position"), backref="product_attribute", ) @validates("name") def validate_name(self, key, name): if not name or not name.replace(" ", ""): raise ValidationError("Name is not valid") attribute = ProductAttribute.query.filter_by(name=name).one_or_none() if attribute is not None and attribute.id != self.id: raise ValidationError("Name already exists") return name @jsonapi_rpc(http_methods=["POST"]) def set_values_keep_order(self): """Set this model's ProductAttribute.values relationship, maintaining order of models. Notes ----- Expects data to be in the following format in the request's JSON: { "data": [ { "id": 1, "type": "ProductAttributeValue" }, { "id": 4, "type": "ProductAttributeValue" }, ] } This is the way that it would be specified in JSON:API if the spec (or packages we are using) cared about order. Since they do not, this method will have to suffice. """ from src.models.product_attribute_value import ProductAttributeValue try: args = request.json args = args.get("data", []) except ValueError: abort(400) new_list = SAFRSCompatibleOrderingList("position", reorder_on_append=True) for spec in args: new_list.append(ProductAttributeValue.query.get_or_404(spec["id"])) self.values = new_list db.session.add(self) db.session.commit()
class ProductVariant(SAFRSMixin, db.Model, RoleMixin, UpdateMixin): __tablename__ = "product_variants" custom_decorators = [ role_policy(default="Sales Executive", view="Shopper"), login_required ] id = db.Column(db.Integer, primary_key=True) description = db.Column(db.String) # relationships attribute_values = db.relationship("ProductAttributeValue", product_variant_attribute_values, backref="product_variants") supplier_id = db.Column(db.Integer, db.ForeignKey("vendors.id"), nullable=True) supplier = db.relationship("Vendor", foreign_keys=[supplier_id]) product_id = db.Column(db.Integer, db.ForeignKey("products.id")) product = db.relationship("Product", foreign_keys=[product_id]) @property def sku(self): if not self.product: raise ValidationError( f"ProductVariant {self.id} is not associated with a product") sku = [str(self.product.item_number)] for attr in self.attribute_values: sku.append(attr.name) return "-".join(sku) def to_dict(self): """Serialize this class into a SAFRS-compatible dictionary. Notes ----- This is where we expose additional fields that are either 1) not in the model or 2) not automatically put in the model by SAFRS because it does not work with custom properties. """ result = SAFRSBase.to_dict(self) result.update({"sku": self.sku}) return result @validates("attribute_values") def validate_attribute_values(self, key, value, product=None): """Validate the values of attributes associated with this variant. Notes ----- A variant should have exactly one attribute value for every attribute specified in its corresponding product type. For example, a wearable product type has color and size attributes. Therefore, a variant of a wearable product must have a size (e.g., XXL) and a color (e.g., hot pink). """ if product is None: product = self.product # skip validation if no product is associated with this model if product is None: return value if not isinstance(value, list): value = [value] # is this value for an attribute associated with this product type? expected_attributes = product.product_type.product_attributes for item in value: if item.product_attribute not in expected_attributes: raise ValidationError( f"Variant of type '{product.product_type.name}' cannot have a " f"'{item.product_attribute.name}' attribute") # has a value for this attribute already been associated with this variant? for attr_value in self.attribute_values: if attr_value.id == item.id: continue if item.product_attribute == attr_value.product_attribute: raise ValidationError( f"Must specify only one value for the " f"'{item.product_attribute.name}' attribute") return value @validates("product_id", "product") def validate_product(self, key, product): if key == "product_id": from src.models.product import Product self.product = Product.query.get(product) return product self.validate_attribute_values(None, self.attribute_values, product=product) return product