From d44d86df2829b62db5441904a0644f062923cde3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rapha=C3=ABl=20Bournhonesque?= Date: Thu, 23 Nov 2023 09:55:52 +0100 Subject: [PATCH] feat: allow to upload price of barcode-less products (#53) --- ...b37b190cc_add_prices_category_tag_field.py | 32 +++++++ app/api.py | 12 +++ app/models.py | 3 +- app/schemas.py | 87 +++++++++++++++---- app/tasks.py | 2 + 5 files changed, 120 insertions(+), 16 deletions(-) create mode 100644 alembic/versions/20231123_0828_5acb37b190cc_add_prices_category_tag_field.py diff --git a/alembic/versions/20231123_0828_5acb37b190cc_add_prices_category_tag_field.py b/alembic/versions/20231123_0828_5acb37b190cc_add_prices_category_tag_field.py new file mode 100644 index 00000000..0cc08cdb --- /dev/null +++ b/alembic/versions/20231123_0828_5acb37b190cc_add_prices_category_tag_field.py @@ -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 ### diff --git a/app/api.py b/app/api.py index 4899b052..74698808 100644 --- a/app/api.py +++ b/app/api.py @@ -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 @@ -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) diff --git a/app/models.py b/app/models.py index 934bb15a..34989ea9 100644 --- a/app/models.py +++ b/app/models.py @@ -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") diff --git a/app/schemas.py b/app/schemas.py index e9a80063..588bd9c0 100644 --- a/app/schemas.py +++ b/app/schemas.py @@ -1,4 +1,4 @@ -from datetime import date, datetime +import datetime from typing import Optional from fastapi_filter.contrib.sqlalchemy import Filter @@ -10,6 +10,7 @@ Field, field_serializer, field_validator, + model_validator, ) from sqlalchemy_utils import Currency @@ -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): @@ -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): @@ -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): @@ -102,7 +159,7 @@ class ProofCreate(BaseModel): class ProofBase(ProofCreate): id: int owner: str - created: datetime + created: datetime.datetime class PriceFilter(Filter): diff --git a/app/tasks.py b/app/tasks.py index 5f6a7b46..ed99e048 100644 --- a/app/tasks.py +++ b/app/tasks.py @@ -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)