Skip to content

Commit

Permalink
feat: allow to upload price of barcode-less products (#53)
Browse files Browse the repository at this point in the history
  • Loading branch information
raphael0202 committed Nov 23, 2023
1 parent bb91bfc commit d44d86d
Show file tree
Hide file tree
Showing 5 changed files with 120 additions and 16 deletions.
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
"""add prices.category_tag field
Revision ID: 5acb37b190cc
Revises: 7103fb49908f
Create Date: 2023-11-23 08:28:39.136672
"""
from typing import Sequence, Union

from alembic import op
import sqlalchemy as sa


# revision identifiers, used by Alembic.
revision: str = '5acb37b190cc'
down_revision: Union[str, None] = '7103fb49908f'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None


def upgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
op.add_column('prices', sa.Column('category_tag', sa.String(), nullable=True))
op.create_index(op.f('ix_prices_category_tag'), 'prices', ['category_tag'], unique=False)
# ### end Alembic commands ###


def downgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
op.drop_index(op.f('ix_prices_category_tag'), table_name='prices')
op.drop_column('prices', 'category_tag')
# ### end Alembic commands ###
12 changes: 12 additions & 0 deletions app/api.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@
from fastapi_filter import FilterDepends
from fastapi_pagination import Page, add_pagination
from fastapi_pagination.ext.sqlalchemy import paginate
from openfoodfacts.taxonomy import get_taxonomy
from openfoodfacts.utils import get_logger
from sqlalchemy.orm import Session

Expand Down Expand Up @@ -170,6 +171,17 @@ def create_price(
status_code=status.HTTP_403_FORBIDDEN,
detail="Proof does not belong to current user",
)

if price.category_tag is not None:
# lowercase the category tag to perform the match
price.category_tag = price.category_tag.lower()
category_taxonomy = get_taxonomy("category")
if price.category_tag not in category_taxonomy:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"Invalid category tag: category '{price.category_tag}' does not exist in the taxonomy",
)

db_price = crud.create_price(db, price=price, user=current_user)
background_tasks.add_task(tasks.create_price_product, db, db_price)
background_tasks.add_task(tasks.create_price_location, db, db_price)
Expand Down
3 changes: 2 additions & 1 deletion app/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -89,7 +89,8 @@ class Proof(Base):
class Price(Base):
id = Column(Integer, primary_key=True, index=True)

product_code = Column(String, index=True)
product_code = Column(String, nullable=True, index=True)
category_tag = Column(String, nullable=True, index=True)
product_id: Mapped[int] = mapped_column(ForeignKey("products.id"), nullable=True)
product: Mapped[Product] = relationship(back_populates="prices")

Expand Down
87 changes: 72 additions & 15 deletions app/schemas.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
from datetime import date, datetime
import datetime
from typing import Optional

from fastapi_filter.contrib.sqlalchemy import Filter
Expand All @@ -10,6 +10,7 @@
Field,
field_serializer,
field_validator,
model_validator,
)
from sqlalchemy_utils import Currency

Expand All @@ -36,8 +37,8 @@ class ProductBase(BaseModel):
product_name: str | None
product_quantity: int | None
image_url: AnyHttpUrl | None
created: datetime
updated: datetime | None
created: datetime.datetime
updated: datetime.datetime | None


class LocationCreate(BaseModel):
Expand All @@ -56,20 +57,64 @@ class LocationBase(LocationCreate):
osm_address_country: str | None
osm_lat: float | None
osm_lon: float | None
created: datetime
updated: datetime | None
created: datetime.datetime
updated: datetime.datetime | None


class PriceCreate(BaseModel):
model_config = ConfigDict(from_attributes=True, arbitrary_types_allowed=True)

product_code: str = Field(min_length=1, pattern="^[0-9]+$")
price: float
currency: str | Currency
location_osm_id: int = Field(gt=0)
location_osm_type: LocationOSMType
date: date
proof_id: int | None = None
product_code: str | None = Field(
default=None,
min_length=1,
pattern="^[0-9]+$",
description="barcode (EAN) of the product, as a string.",
examples=["16584958", "1234567890123"],
)
category_tag: str | None = Field(
default=None,
min_length=3,
pattern=r"^[a-z]{2,}:[a-zA-Z\-]+$",
examples=["en:tomatoes", "en:apples"],
description="""ID of the Open Food Facts category of the product for
products without barcode.
This is mostly for raw products such as vegetables or fruits. This
field is exclusive with `product_code`: if this field is set, it means
that the product does not have a barcode.
This ID must be a canonical category ID in the Open Food Facts taxonomy.
If the ID is not valid, the price will be rejected.""",
)
price: float = Field(
gt=0,
description="price of the product, without its currency, taxes included.",
examples=["1.99"],
)
currency: str | Currency = Field(
description="currency of the price, as a string. "
"The currency must be a valid currency code. "
"See https://en.wikipedia.org/wiki/ISO_4217 for a list of valid currency codes.",
examples=["EUR", "USD"],
)
location_osm_id: int = Field(
gt=0,
description="ID of the location in OpenStreetMap: the store where the product was bought.",
examples=[1234567890],
)
location_osm_type: LocationOSMType = Field(
description="type of the OpenStreetMap location object. Stores can be represented as nodes, "
"ways or relations in OpenStreetMap. It is necessary to be able to fetch the correct "
"information about the store using the ID.",
)
date: datetime.date = Field(description="date when the product was bought.")
proof_id: int | None = Field(
default=None,
description="ID of the proof, if any. The proof is a file (receipt or price tag image) "
"uploaded by the user to prove the price of the product. "
"The proof must be uploaded before the price, and the authenticated user must be the "
"owner of the proof.",
examples=[15],
)

@field_validator("currency")
def currency_is_valid(cls, v):
Expand All @@ -84,12 +129,24 @@ def serialize_currency(self, currency: Currency, _info):
return currency.code
return currency

@model_validator(mode="after")
def product_code_and_category_tag_are_exclusive(self):
"""Validator that checks that `product_code` and `category_tag` are
exclusive, and that at least one of them is set."""
if self.product_code is not None and self.category_tag is not None:
raise ValueError(
"`product_code` and `category_tag` are exclusive, you can't set both"
)
if self.product_code is None and self.category_tag is None:
raise ValueError("either `product_code` or `category_tag` must be set")
return self


class PriceBase(PriceCreate):
product_id: int | None
location_id: int | None
# owner: str
created: datetime
created: datetime.datetime


class ProofCreate(BaseModel):
Expand All @@ -102,7 +159,7 @@ class ProofCreate(BaseModel):
class ProofBase(ProofCreate):
id: int
owner: str
created: datetime
created: datetime.datetime


class PriceFilter(Filter):
Expand Down
2 changes: 2 additions & 0 deletions app/tasks.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,8 @@


def create_price_product(db: Session, price: PriceBase):
# The price may not have a product code, if it's the price of a
# barcode-less product
if price.product_code:
# get or create the corresponding product
product = ProductCreate(code=price.product_code)
Expand Down

0 comments on commit d44d86d

Please sign in to comment.