Beispiel #1
0
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
Beispiel #2
0
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")
Beispiel #3
0
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")
Beispiel #4
0
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
Beispiel #5
0
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
Beispiel #6
0
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
Beispiel #7
0
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
Beispiel #8
0
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
Beispiel #9
0
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
Beispiel #10
0
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
Beispiel #11
0
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()
Beispiel #12
0
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