From 8e12938fe8778d4fa22327ae9dbd1790cbdc7163 Mon Sep 17 00:00:00 2001 From: diegocaspi <55052203+diegocaspi@users.noreply.github.com> Date: Sat, 22 Jun 2024 11:33:42 +0200 Subject: [PATCH] feat: reviews implementation and code description * chore(reviews): init reviews * chore(reviews): almost completed * chore: completed reviews and added comments to all the python code --------- Co-authored-by: Diego Caspi --- app/modules/auth/forms.py | 3 + app/modules/auth/handlers.py | 26 ++++ app/modules/auth/views.py | 18 +++ app/modules/buyers/handlers.py | 8 ++ app/modules/buyers/models.py | 10 ++ app/modules/carts/handlers.py | 50 ++++++- app/modules/carts/models.py | 12 ++ app/modules/carts/views.py | 12 ++ app/modules/orders/handlers.py | 21 ++- app/modules/orders/models.py | 33 +++++ app/modules/orders/views.py | 1 - app/modules/products/forms.py | 4 +- app/modules/products/handlers.py | 79 ++++++++++- app/modules/products/models.py | 3 + app/modules/products/views.py | 14 +- app/modules/reviews/__init__.py | 5 + app/modules/reviews/handlers.py | 95 ++++++++++++++ app/modules/reviews/models.py | 65 +++++++++ app/modules/reviews/views.py | 25 ++++ app/modules/sellers/handlers.py | 14 ++ app/modules/sellers/models.py | 8 +- app/modules/sellers/views.py | 5 + app/modules/shared/consts.py | 11 +- app/modules/shared/forms.py | 3 + app/modules/shared/handlers.py | 10 +- app/modules/shared/proxy.py | 4 + app/modules/shared/utils.py | 10 ++ app/modules/shipments/handlers.py | 4 +- app/modules/shipments/models.py | 7 + app/modules/shipments/views.py | 1 - app/modules/users/handlers.py | 14 ++ app/modules/users/models.py | 13 +- app/modules/users/views.py | 6 + app/templates/layouts/base.html | 8 +- app/templates/products/[guid].html | 26 ++++ app/templates/reviews/review_creation.html | 44 +++++++ app/templates/reviews/review_details.html | 22 ++++ config.py | 2 +- extensions.py | 2 +- factory/populate.py | 13 +- migrations/env.py | 3 + ...5d5552a5_reviews_seller_product_removed.py | 37 ++++++ .../versions/ecdc452f26c7_product_reviews.py | 124 ++++++++++++++++++ 43 files changed, 835 insertions(+), 40 deletions(-) create mode 100644 app/modules/reviews/__init__.py create mode 100644 app/modules/reviews/handlers.py create mode 100644 app/modules/reviews/models.py create mode 100644 app/modules/reviews/views.py create mode 100644 app/templates/reviews/review_creation.html create mode 100644 app/templates/reviews/review_details.html create mode 100644 migrations/versions/c9ce5d5552a5_reviews_seller_product_removed.py create mode 100644 migrations/versions/ecdc452f26c7_product_reviews.py diff --git a/app/modules/auth/forms.py b/app/modules/auth/forms.py index 5d3cf61..df69491 100644 --- a/app/modules/auth/forms.py +++ b/app/modules/auth/forms.py @@ -4,6 +4,9 @@ class LoginForm(FlaskForm): + """ + Form for logging in. + """ email = StringField('Email', validators=[validators.DataRequired(), validators.Email(message="Invalid email format")]) password = PasswordField('Password', validators=[validators.DataRequired()]) remember = BooleanField('Remember me', validators=[validators.Optional()], default=False) diff --git a/app/modules/auth/handlers.py b/app/modules/auth/handlers.py index 8b6f7ad..65406c3 100644 --- a/app/modules/auth/handlers.py +++ b/app/modules/auth/handlers.py @@ -9,16 +9,32 @@ @login_manager.user_loader def load_user(user_guid: str): + """ + Load a user by its guid. This function is used by Flask-Login. + :param user_guid: the guid of the user + :return: the retrieved user or None + """ if not UUID(user_guid): return None return User.query.filter_by(guid=UUID(user_guid)).first() def get_user_by_email(email: str): + """ + Get a user by its email. + :param email: the email of the user + :return: the retrieved user or None + """ return User.query.filter_by(email=email).first() def validate_user(email: str, password: str): + """ + Validate a user by its email and password. Return the user if the credentials are correct, None otherwise. + :param email: the email of the user + :param password: the password of the user + :return: the user if the credentials are correct, None otherwise + """ user = User.query.filter_by(email=email).first() if user and check_password_hash(user.password, password): return user @@ -28,6 +44,16 @@ def validate_user(email: str, password: str): def register_user( email: str, given_name: str, family_name: str, password: str, destination_address: str, card_number: str ): + """ + Register a new user. + :param email: the email of the user + :param given_name: the given name of the user + :param family_name: the family name of the user + :param password: the password of the user + :param destination_address: the destination address of the buyer + :param card_number: the card number of the buyer + :return: the created user + """ user = User(email=email, given_name=given_name, family_name=family_name, password=generate_password_hash(password)) db.session.add(user) buyer = Buyer(destination_address=destination_address, card_number=card_number, user=user) diff --git a/app/modules/auth/views.py b/app/modules/auth/views.py index b0e5109..7c30e81 100644 --- a/app/modules/auth/views.py +++ b/app/modules/auth/views.py @@ -8,9 +8,15 @@ @auth.route("/login", methods=["GET", "POST"]) def login(): + """ + Login view. + :return: The login view. + """ form = LoginForm() + # handling form submission if request.method == "POST": + # validate form input, throw error if invalid if form.validate_on_submit(): subject = validate_user(form.email.data, form.password.data) if not subject: @@ -23,11 +29,17 @@ def login(): return redirect(url_for("home.index_view")) + # render the login view return render_template("auth/login.html", form=form) @auth.route("/signup", methods=["GET", "POST"]) def signup(): + """ + Signup view. + :return: The signup view. + """ + # handling form submission if request.method == "POST": email = request.form.get('email') given_name = request.form.get('given_name') @@ -37,11 +49,13 @@ def signup(): destination_address = request.form.get('destination_address') card_number = request.form.get('card_number') + # extract existing user by email and check if it exists existing_user = get_user_by_email(email) if existing_user: flash("User already exists") return render_template("auth/signup.html") + # check if passwords match if password != password_confirmation: flash("Passwords do not match") return render_template("auth/signup.html") @@ -57,5 +71,9 @@ def signup(): @auth.route("/logout", methods=["POST"]) @login_required def logout(): + """ + Logout view. + :return: Redirect to the login view. + """ logout_user() return redirect(url_for("auth.login")) diff --git a/app/modules/buyers/handlers.py b/app/modules/buyers/handlers.py index a9f095f..835a637 100644 --- a/app/modules/buyers/handlers.py +++ b/app/modules/buyers/handlers.py @@ -3,6 +3,14 @@ def update_buyer(user_id: int, destination_address: str, card_number: str): + """ + Update the destination address and card number of a buyer. + Return the updated buyer if successful, None otherwise. + :param user_id: the user id + :param destination_address: the updated destination address of the buyer + :param card_number: the updated card number of the buyer + :return the updated buyer + """ buyer = Buyer.query.filter_by(user_id=user_id).first() if not buyer: return None diff --git a/app/modules/buyers/models.py b/app/modules/buyers/models.py index ac3a953..f7995e7 100644 --- a/app/modules/buyers/models.py +++ b/app/modules/buyers/models.py @@ -6,12 +6,19 @@ if TYPE_CHECKING: from app.modules.users.models import User from app.modules.carts.models import Cart + from app.modules.products.models import ProductReview + from app.modules.orders.models import OrderReport else: User = "User" Cart = "Cart" + ProductReview = "ProductReview" + OrderReport = "OrderReport" class Buyer(db.Model): + """ + The Buyer model. A buyer is a user that can buy products. + """ __tablename__ = "buyers" id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True, nullable=False, index=True) @@ -22,6 +29,9 @@ class Buyer(db.Model): user: Mapped[User] = db.relationship("User", back_populates="buyers") carts: Mapped[List[Cart]] = db.relationship("Cart", back_populates="buyer") + reviews: Mapped[List[ProductReview]] = db.relationship("ProductReview", back_populates="buyer") + order_reports: Mapped[List[OrderReport]] = db.relationship("OrderReport", back_populates="buyer") + def __repr__(self): return (f"") diff --git a/app/modules/carts/handlers.py b/app/modules/carts/handlers.py index cdfbb0f..389fcfc 100644 --- a/app/modules/carts/handlers.py +++ b/app/modules/carts/handlers.py @@ -2,15 +2,25 @@ from app.modules.carts.models import CartStatus, Cart, ProductReservation from app.modules.products.models import Product -from app.modules.shared.consts import page_size +from app.modules.shared.consts import DEFAULT_PAGE_SIZE from extensions import db def get_cart_by_buyer(buyer_id: int) -> Cart | None: + """ + Get the cart of a buyer by its id. + :param buyer_id: the id of the buyer + :return: the cart of the buyer or None if it doesn't exist + """ return Cart.query.filter_by(owner_buyer_id=buyer_id, status=CartStatus.ACTIVE).first() def get_cart_or_create(buyer_id: int) -> Cart | None: + """ + Get the cart of a buyer by its id. If it doesn't exist, create a new one. + :param buyer_id: the id of the buyer + :return: the cart of the buyer + """ cart = get_cart_by_buyer(buyer_id) if not cart: cart = Cart(owner_buyer_id=buyer_id) @@ -18,7 +28,14 @@ def get_cart_or_create(buyer_id: int) -> Cart | None: return cart -def get_reservation_by_cart(cart: Cart, page: int = 1, per_page: int = page_size) -> QueryPagination: +def get_reservation_by_cart(cart: Cart, page: int = 1, per_page: int = DEFAULT_PAGE_SIZE) -> QueryPagination: + """ + Get the reservations of a cart. The reservations are paginated. + :param cart: the cart to get the reservations from + :param page: the page number + :param per_page: the number of reservations per page + :return: the reservations of the cart + """ return (ProductReservation.query .filter_by(cart=cart, deleted_at=None) .order_by(ProductReservation.created_at) @@ -26,25 +43,44 @@ def get_reservation_by_cart(cart: Cart, page: int = 1, per_page: int = page_size def get_reservation_by_product(buyer_id: int, product: Product) -> (ProductReservation | None, bool): + """ + Get the reservation of a product in a cart. If the product is not in the cart, return None. + :param buyer_id: the id of the buyer + :param product: the product to get the reservation from + :return: the reservation of the product in the cart or None if it doesn't exist, and a boolean indicating if the + reservation has a different sequence than the product + """ cart = get_cart_by_buyer(buyer_id) if not cart: return None, False + # get the reservation of the product in the cart product_reservation = ProductReservation.query.filter_by(product_id=product.id, cart=cart, deleted_at=None).first() if not product_reservation: return None, False + # if the product sequence is different from the reservation sequence, delete the reservation and return None, True if product.sequence != product_reservation.product_sequence: product_reservation.deleted_at = db.func.now() db.session.commit() return None, True + # return the reservation and False return product_reservation, False def update_cart(buyer_id: int, product: Product, quantity: int) -> (Cart | None, Product | None): + """ + Update the cart of a buyer with a product and a quantity. + :param buyer_id: the id of the buyer + :param product: the product to add to the cart + :param quantity: the quantity of the product + :return: the cart of the buyer and the product if the product has a different sequence than the reservation + """ cart = get_cart_or_create(buyer_id) product_reservation = ProductReservation.query.filter_by(product_id=product.id, cart=cart, deleted_at=None).first() + + # if the product is not in the cart, create a new reservation if not product_reservation: product_reservation = ProductReservation( product_id=product.id, @@ -56,26 +92,36 @@ def update_cart(buyer_id: int, product: Product, quantity: int) -> (Cart | None, db.session.commit() return cart, None + # if the product sequence is different from the reservation sequence, delete the reservation and return None, product if product.sequence != product_reservation.product_sequence: product_reservation.deleted_at = db.func.now() # TODO should create a new reservation? db.session.commit() return None, product + # update the quantity of the reservation product_reservation.quantity = quantity db.session.commit() return cart, None def remove_from_cart(buyer_id: int, product: Product) -> Cart | None: + """ + Remove a product from the cart of a buyer. If the product is not in the cart, return None. + :param buyer_id: the id of the buyer + :param product: the product to remove from the cart + :return: the cart of the buyer or None if the product is not in the cart + """ cart = get_cart_by_buyer(buyer_id) if not cart: return None + # get the reservation of the product in the cart product_reservation = ProductReservation.query.filter_by(product_id=product.id, cart=cart, deleted_at=None).first() if not product_reservation: return None + # delete the reservation product_reservation.deleted_at = db.func.now() db.session.commit() return cart diff --git a/app/modules/carts/models.py b/app/modules/carts/models.py index 17bef49..a406804 100644 --- a/app/modules/carts/models.py +++ b/app/modules/carts/models.py @@ -17,11 +17,17 @@ class CartStatus(Enum): + """ + The CartStatus enum. It represents the status of a cart. + """ ACTIVE = "active" FINALIZED = "finalized" class ProductReservation(db.Model): + """ + The ProductReservation model. It represents a reservation of a product in a cart. + """ __tablename__ = "product_reservations" id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True, nullable=False, index=True) @@ -49,6 +55,9 @@ def __repr__(self): class ProductReservationHistory(db.Model): + """ + The ProductReservationHistory model. It represents the history of a reservation of a product in a cart. + """ __tablename__ = "product_reservation_history" id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True, nullable=False, index=True) @@ -67,6 +76,9 @@ def __repr__(self): class Cart(db.Model): + """ + The Cart model. It represents a cart of a buyer. + """ __tablename__ = "carts" id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True, nullable=False, index=True) diff --git a/app/modules/carts/views.py b/app/modules/carts/views.py index 43e6083..e375fdb 100644 --- a/app/modules/carts/views.py +++ b/app/modules/carts/views.py @@ -13,6 +13,11 @@ def validate_product(product_guid: str) -> Product: + """ + Utility function to validate a product by its guid. + :param product_guid: the guid of the product + :return: the product if it exists, otherwise abort with a 404 error + """ try: product_guid = UUID(product_guid) product = get_product_by_guid(product_guid) @@ -27,8 +32,13 @@ def validate_product(product_guid: str) -> Product: @login_required @buyer_required def index_view(): + """ + Cart view. + :return: The cart view. + """ buyer_id = current_user.buyers[0].id + # handling form submission if request.method == 'POST': product_guid = request.form.get('product_guid') quantity = int(request.form.get('quantity')) @@ -48,6 +58,8 @@ def index_view(): page = request.args.get('page', 1, type=int) cart = get_cart_by_buyer(buyer_id) + + # get the reservations of the cart reservations = get_reservation_by_cart(cart, page) return render_template( 'carts/index.html', diff --git a/app/modules/orders/handlers.py b/app/modules/orders/handlers.py index 2da58fb..26e4b10 100644 --- a/app/modules/orders/handlers.py +++ b/app/modules/orders/handlers.py @@ -7,14 +7,14 @@ from flask_sqlalchemy.pagination import QueryPagination from app.modules.carts.models import Cart, ProductReservation, CartStatus -from app.modules.orders.models import BuyerOrder, BuyersOrderStatus, SellerOrder, OrderedProduct -from app.modules.shared.consts import created_orders_ttl, page_size +from app.modules.orders.models import BuyerOrder, BuyersOrderStatus, SellerOrder, OrderedProduct, OrderReport +from app.modules.shared.consts import DEFAULT_CREATED_ORDERS_TTL, DEFAULT_PAGE_SIZE from app.modules.shipments.handlers import create_shipment from app.modules.shipments.models import Shipment from extensions import db -def get_buyer_orders_by_buyer(buyer_id: int, page: int = 1, per_page: int = page_size) -> QueryPagination: +def get_buyer_orders_by_buyer(buyer_id: int, page: int = 1, per_page: int = DEFAULT_PAGE_SIZE) -> QueryPagination: """ Get buyer orders by buyer id. :param buyer_id: the id of the buyer @@ -52,7 +52,7 @@ def get_seller_order_by_guid(guid: UUID, seller_id: int) -> SellerOrder | None: return SellerOrder.query.filter_by(guid=guid, seller_id=seller_id).first() -def get_seller_orders_by_seller(seller_id: int, page: int = 1, per_page: int = page_size) -> QueryPagination: +def get_seller_orders_by_seller(seller_id: int, page: int = 1, per_page: int = DEFAULT_PAGE_SIZE) -> QueryPagination: """ Get seller orders by seller id. :param seller_id: the id of the seller @@ -67,7 +67,7 @@ def get_seller_orders_by_seller(seller_id: int, page: int = 1, per_page: int = p .paginate(page=page, per_page=per_page)) -def get_ordered_products_by_product(product_id: int, page: int = 1, per_page: int = page_size) -> QueryPagination: +def get_ordered_products_by_product(product_id: int, page: int = 1, per_page: int = DEFAULT_PAGE_SIZE) -> QueryPagination: """ Get ordered products by product id. :param product_id: the id of the product @@ -146,7 +146,7 @@ def complete_buyer_order(buyer_order: BuyerOrder) -> (BuyerOrder | None, List[Se :return: the buyer order and the seller orders """ - if buyer_order.created_at.replace(tzinfo=pytz.UTC) < datetime.now(pytz.UTC) - timedelta(seconds=created_orders_ttl): + if buyer_order.created_at.replace(tzinfo=pytz.UTC) < datetime.now(pytz.UTC) - timedelta(seconds=DEFAULT_CREATED_ORDERS_TTL): for r in buyer_order.cart.reservations: r.product.locked_stock -= r.quantity buyer_order.deleted_at = db.func.now() @@ -166,6 +166,8 @@ def complete_buyer_order(buyer_order: BuyerOrder) -> (BuyerOrder | None, List[Se # create seller orders seller_orders = [] + order_reports = [] + for r in reservations: ordered_product = OrderedProduct(product=r.product, quantity=r.quantity) @@ -184,6 +186,13 @@ def complete_buyer_order(buyer_order: BuyerOrder) -> (BuyerOrder | None, List[Se seller_order.ordered_products.append(ordered_product) db.session.commit() + # create order reports + for so in seller_orders: + order_report = OrderReport(buyer_order=buyer_order, buyer=buyer_order.cart.buyer, + seller=so.seller, seller_order=so) + db.session.add(order_report) + order_reports.append(order_report) + db.session.commit() return buyer_order, seller_orders diff --git a/app/modules/orders/models.py b/app/modules/orders/models.py index 567ed05..51e9203 100644 --- a/app/modules/orders/models.py +++ b/app/modules/orders/models.py @@ -6,6 +6,7 @@ from sqlalchemy import UUID from sqlalchemy.orm import Mapped, mapped_column +from app.modules.buyers.models import Buyer from extensions import db if TYPE_CHECKING: @@ -13,10 +14,12 @@ from app.modules.products.models import Product from app.modules.sellers.models import Seller from app.modules.shipments.models import Shipment + from app.modules.reviews.models import ProductReview else: Cart = "Cart" Product = "Product" Seller = "Seller" + ProductReview = "ProductReview" Shipment = "Shipment" @@ -59,6 +62,7 @@ class BuyerOrder(db.Model): cart: Mapped[Cart] = db.relationship("Cart", back_populates="buyer_orders") seller_orders: Mapped[List["SellerOrder"]] = db.relationship("SellerOrder", back_populates="buyer_order") + order_reports: Mapped[List["OrderReport"]] = db.relationship("OrderReport", back_populates="buyer_order") def __repr__(self): return (f"") +class OrderReport(db.Model): + """ + Represents a helper denormalized table to store the orders report. + """ + + __tablename__ = "orders_report" + + buyer_order_id: Mapped[int] = mapped_column(db.Integer, db.ForeignKey("buyers_orders.id"), index=True, nullable=False) + buyer_id: Mapped[int] = mapped_column(db.Integer, db.ForeignKey("buyers.id"), index=True, nullable=False) + seller_id: Mapped[int] = mapped_column(db.Integer, db.ForeignKey("sellers.id"), index=True, nullable=False) + seller_order_id: Mapped[int] = mapped_column(db.Integer, db.ForeignKey("sellers_orders.id"), index=True, nullable=False) + + seller_order: Mapped[SellerOrder] = db.relationship("SellerOrder", back_populates="order_reports") + buyer_order: Mapped[BuyerOrder] = db.relationship("BuyerOrder", back_populates="order_reports") + buyer: Mapped[Buyer] = db.relationship("Buyer", back_populates="order_reports") + seller: Mapped[Seller] = db.relationship("Seller", back_populates="order_reports") + + __table_args__ = ( + db.PrimaryKeyConstraint( + buyer_order_id, buyer_id, seller_order_id, seller_id + ), + ) + + def __repr__(self): + return f"" + + class OrderedProduct(db.Model): """ Represents a product that is part of a seller's order. diff --git a/app/modules/orders/views.py b/app/modules/orders/views.py index 0c4e934..34f8b7e 100644 --- a/app/modules/orders/views.py +++ b/app/modules/orders/views.py @@ -1,4 +1,3 @@ -from typing import List from uuid import UUID from flask import render_template, url_for, redirect, abort, flash, request diff --git a/app/modules/products/forms.py b/app/modules/products/forms.py index e715c80..96d1a0b 100644 --- a/app/modules/products/forms.py +++ b/app/modules/products/forms.py @@ -1,4 +1,3 @@ -from flask import current_app from flask_wtf import FlaskForm from wtforms import validators, widgets from wtforms.fields.choices import SelectField, SelectMultipleField @@ -10,6 +9,9 @@ class SearchForm(FlaskForm): + """ + Represents the search form. Used in the top bar. + """ class Meta: csrf = False diff --git a/app/modules/products/handlers.py b/app/modules/products/handlers.py index 84d0b75..1f1634a 100644 --- a/app/modules/products/handlers.py +++ b/app/modules/products/handlers.py @@ -5,39 +5,74 @@ from app.modules.products.models import Product, ProductCategory, Keyword from app.modules.sellers.models import Seller -from app.modules.shared.consts import page_size -from app.modules.shared.handlers import clean_expired_orders +from app.modules.shared.consts import DEFAULT_PAGE_SIZE from extensions import db separators = "|".join([' ', '.', ',', ';', ':', '-', '!', '?', '\t', '\n']) def get_price_max(): + """ + Get the maximum price of all products. + :return: the maximum price + """ return db.session.query(func.max(Product.price)).scalar() def get_stock_max(): + """ + Get the maximum stock of all products. + :return: the maximum stock + """ return db.session.query(func.max(Product.stock)).scalar() def get_all_product_brands(): + """ + Get all product brands. + :return: the list of product brands + """ return db.session.query(Product.brand).distinct().all() def get_all_product_categories(): + """ + Get all product categories. + :return: the list of product categories + """ return ProductCategory.query.all() def get_product_by_guid(guid: UUID) -> Product | None: + """ + Get a product by its guid. If the product doesn't exist, return None. + :param guid: the guid of the product + :return: the product or None if it doesn't exist + """ return Product.query.filter_by(guid=guid, deleted_at=None).first() -def get_all_products(page: int = 1, per_page: int = page_size, filters=()) -> QueryPagination: +def get_all_products(page: int = 1, per_page: int = DEFAULT_PAGE_SIZE, filters=()) -> QueryPagination: + """ + Get all products paginated. The products are ordered by name. + :param page: the page number + :param per_page: the number of products per page + :param filters: the filters to apply + :return: the products paginated + """ query = Product.query.filter(*filters).order_by(Product.name) return query.paginate(page=page, per_page=per_page) -def get_products_filtered(query_key: str, page: int = 1, per_page: int = page_size, filters=()) -> QueryPagination: +def get_products_filtered(query_key: str, page: int = 1, per_page: int = DEFAULT_PAGE_SIZE, filters=()) -> QueryPagination: + """ + Get products filtered by a query key. The products are paginated. + :param query_key: the query key to filter the products + :param page: the page number + :param per_page: the number of products per page + :param filters: the filters to apply + :return: the products filtered and paginated + """ query = (Product.query.join(Product.keywords) .filter(Keyword.key.ilike(f'%{query_key}%')) .filter(and_(*filters)).order_by(Product.name)) @@ -46,8 +81,16 @@ def get_products_filtered(query_key: str, page: int = 1, per_page: int = page_si def get_seller_products( seller_id: int, show_sold_out: bool = False, - page: int = 1, per_page: int = page_size + page: int = 1, per_page: int = DEFAULT_PAGE_SIZE ) -> QueryPagination: + """ + Get the products of a seller. The products are paginated. If show_sold_out is False, only products with stock > 0 + :param seller_id: the id of the seller + :param show_sold_out: whether to show sold out products + :param page: the page number + :param per_page: the number of products per page + :return: the products of the seller paginated + """ query = Product.query.filter_by(owner_seller_id=seller_id, deleted_at=None).order_by(Product.name) if not show_sold_out: @@ -60,6 +103,18 @@ def create_product( seller_id: int, name: str, price: float, stock: int, categories: list, description: str = None, brand: str = None, is_second_hand: bool = False ) -> Product | None: + """ + Create a product. If the seller doesn't exist, return None. + :param seller_id: the id of the seller + :param name: the name of the product + :param price: the price of the product + :param stock: the stock of the product + :param categories: the categories of the product + :param description: the description of the product + :param brand: the brand of the product + :param is_second_hand: whether the product is second hand + :return: the product or None if the seller doesn't exist + """ seller = Seller.query.filter_by(id=seller_id).first() if not seller: return None @@ -105,6 +160,15 @@ def create_product( def update_product( product: Product, price: float, stock: int, categories: list, description: str ): + """ + Update a product. + :param product: the product to update + :param price: the new price + :param stock: the new stock + :param categories: the new categories, the categories are created if they don't exist + :param description: the new description + :return: the updated product + """ product.price = price product.stock = stock product.description = description @@ -124,6 +188,11 @@ def update_product( def delete_product(product: Product): + """ + Delete a product. The product and its reservations are marked as deleted. + :param product: the product to delete + :return: the deleted product + """ product.sequence += 1 product.deleted_at = db.func.now() for reservation in product.reservations: diff --git a/app/modules/products/models.py b/app/modules/products/models.py index 4905283..85fe850 100644 --- a/app/modules/products/models.py +++ b/app/modules/products/models.py @@ -11,6 +11,7 @@ from extensions import Base if TYPE_CHECKING: + from app.modules.reviews.models import ProductReview from app.modules.sellers.models import Seller from app.modules.carts.models import ProductReservation from app.modules.orders.models import OrderedProduct @@ -18,6 +19,7 @@ Seller = "Seller" ProductReservation = "ProductReservation" OrderedProduct = "OrderedProduct" + ProductReview = "ProductReview" products_categories_association_table = Table( "products_categories_association", @@ -132,6 +134,7 @@ class Product(db.Model): ordered_products: Mapped[List[OrderedProduct]] = db.relationship( "OrderedProduct", back_populates="product" ) + reviews: Mapped[List[ProductReview]] = db.relationship("ProductReview", back_populates="product") def __repr__(self): return (f" Product | None: @@ -62,12 +62,17 @@ def index_view(): @products.route('/', methods=['GET']) @login_required +@buyer_required def product_view(product_guid: str): product = validate_product(product_guid) if not product: return redirect(url_for('products.index_view')) product_reservation, sequence_failed = get_reservation_by_product(current_user.buyers[0].id, product) + can_review = can_be_reviewed(product, current_user.buyers[0]) + review = get_product_review(product, current_user.buyers[0]) + assessment = sum([r.current_rating for r in product.reviews]) / len(product.reviews) if product.reviews else 0 + return render_template( 'products/[guid].html', product=product, @@ -75,7 +80,10 @@ def product_view(product_guid: str): categories=[c.name for c in get_all_product_categories()], product_reservation=product_reservation, sequence_failed=sequence_failed, - is_seller_product=current_user.sellers and product.owner_seller_id == current_user.sellers[0].id + is_seller_product=current_user.sellers and product.owner_seller_id == current_user.sellers[0].id, + can_review=can_review, + review=review, + assessment=assessment ) diff --git a/app/modules/reviews/__init__.py b/app/modules/reviews/__init__.py new file mode 100644 index 0000000..ec1e06d --- /dev/null +++ b/app/modules/reviews/__init__.py @@ -0,0 +1,5 @@ +from flask import Blueprint + +reviews = Blueprint('reviews', __name__, url_prefix="/reviews") + +from app.modules.reviews import views diff --git a/app/modules/reviews/handlers.py b/app/modules/reviews/handlers.py new file mode 100644 index 0000000..6b34e32 --- /dev/null +++ b/app/modules/reviews/handlers.py @@ -0,0 +1,95 @@ +from typing import List + +from app.modules.buyers.models import Buyer +from app.modules.orders.models import OrderReport +from app.modules.products.models import Product +from app.modules.reviews.models import ProductReview + +from app.modules.products.handlers import get_product_by_guid + +from extensions import db + +from uuid import UUID + + +def create_or_update_product_review(product_guid: str, rating: int, message: str, buyer: Buyer): + """ + Create a product review for a product. + :param product_guid: The product guid. + :param rating: The rating. + :param message: The message. + :param buyer: The buyer + :return: The product review. + """ + product_guid = UUID(product_guid) + product = get_product_by_guid(product_guid) + if not product: + raise ValueError("Product not found") + + # if the review already exists, update it + review = get_product_review(product, buyer) + if review: + return update_product_review(review, rating, message) + + # check whether the product can be reviewed + if not can_be_reviewed(product, buyer): + raise ValueError("Product can't be reviewed") + + product_review = ProductReview( + product=product, + current_rating=rating, + current_message=message, + buyer=buyer, + ) + db.session.add(product_review) + db.session.commit() + return product_review + + +def update_product_review(product_review: ProductReview, rating: int, message: str): + """ + Update a product review. + :param product_review: The product review. + :param rating: The rating. + :param message: The message. + :return: The updated product review. + """ + product_review.current_rating = rating + product_review.current_message = message + db.session.commit() + return product_review + + +def get_product_review(product: Product, buyer: Buyer) -> ProductReview | None: + """ + Get a product review for a product. + :param product: The product. + :param buyer: The buyer. + :return: The product review. + """ + return ProductReview.query.filter_by(product=product, buyer=buyer).first() + + +def can_be_reviewed(product: Product, buyer: Buyer) -> bool: + """ + If exists a buyer order + :param product: the product to be reviewed + :param buyer: the buyer + :return: True if the product can be reviewed, False otherwise + """ + + # get all the order reports of the buyer for the product seller + filtered_order_reports: List[OrderReport] = OrderReport.query.filter_by( + buyer_id=buyer.id, + seller_id=product.seller.id + ).all() + + # get the order reports that have a shipment and are delivered + remaining_order_reports: List[OrderReport] = [order_report + for order_report in filtered_order_reports if + order_report.seller_order.shipment is not None and \ + not get_product_review(product, + buyer) and order_report.seller_order.shipment.is_delivered()] + + # check if the product is in any of the remaining order reports + return any([orp for orp in remaining_order_reports if any(filter(lambda op: op.product == product, orp.seller_order.ordered_products))]) diff --git a/app/modules/reviews/models.py b/app/modules/reviews/models.py new file mode 100644 index 0000000..a74d5a9 --- /dev/null +++ b/app/modules/reviews/models.py @@ -0,0 +1,65 @@ +from __future__ import annotations + +from sqlalchemy.orm import Mapped, mapped_column +from sqlalchemy.dialects.postgresql import UUID +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from app.modules.products.models import Product + from app.modules.orders.models import SellerOrder + from app.modules.buyers.models import Buyer +else: + Product = "Product" + SellerOrder = "SellerOrder" + Buyer = "Buyer" + +from extensions import db +import uuid + + +class ProductReviewHistory(db.Model): + """ + Product review history model + Represents a history of a review of a product by a buyer. Contains a rating and a message. + """ + __tablename__ = "product_review_history" + + id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True, nullable=False, index=True) + product_review_id: Mapped[int] = mapped_column(db.Integer, db.ForeignKey("product_reviews.id"), nullable=False) + + rating: Mapped[int] = mapped_column(db.Integer, nullable=False) + message: Mapped[str] = mapped_column(db.String(255), nullable=False) + + created_at: Mapped[str] = mapped_column(db.DateTime, nullable=False, server_default=db.func.now()) + + product_review: Mapped[ProductReview] = db.relationship("ProductReview", back_populates="history") + + def __repr__(self): + return f"" + + +class ProductReview(db.Model): + """ + Product review model + Represents a review of a product by a buyer. Contains a rating and a message. + """ + __tablename__ = "product_reviews" + + id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True, nullable=False, index=True) + guid: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), default=uuid.uuid4, unique=True, nullable=False) + product_id: Mapped[int] = mapped_column(db.Integer, db.ForeignKey("products.id"), nullable=False) + buyer_id: Mapped[int] = mapped_column(db.Integer, db.ForeignKey("buyers.id"), nullable=False) + + current_rating: Mapped[int] = mapped_column(db.Integer, nullable=False) + current_message: Mapped[str] = mapped_column(db.String(255), nullable=False) + + created_at: Mapped[str] = mapped_column(db.DateTime, nullable=False, server_default=db.func.now()) + updated_at: Mapped[str] = mapped_column(db.DateTime, nullable=False, server_default=db.func.now(), onupdate=db.func.now()) + deleted_at: Mapped[str] = mapped_column(db.DateTime, nullable=True) + + product: Mapped[Product] = db.relationship(Product, back_populates="reviews") + buyer: Mapped[Buyer] = db.relationship(Buyer, back_populates="reviews") + history: Mapped[ProductReviewHistory] = db.relationship(ProductReviewHistory, back_populates="product_review") + + def __repr__(self): + return f"" diff --git a/app/modules/reviews/views.py b/app/modules/reviews/views.py new file mode 100644 index 0000000..6a54de9 --- /dev/null +++ b/app/modules/reviews/views.py @@ -0,0 +1,25 @@ +from flask import request, url_for, redirect +from flask_login import current_user + +from app.modules.reviews import reviews +from app.modules.reviews.handlers import create_or_update_product_review +from app.modules.shared.utils import buyer_required + + +@reviews.route("/products/", methods=["POST"]) +@buyer_required +def create_product_review_view(product_guid: str): + """ + Create a product review for a product. + :param product_guid: The product guid. + :return: The product review if created, an error otherwise. + """ + rating = int(request.form["rating"]) + message = request.form["content"] + buyer = current_user.buyers[0] + + try: + review = create_or_update_product_review(product_guid, rating, message, buyer) + return redirect(url_for("products.product_view", product_guid=product_guid)) + except ValueError as e: + return {"error": str(e)}, 400 diff --git a/app/modules/sellers/handlers.py b/app/modules/sellers/handlers.py index 4a971e8..2e71409 100644 --- a/app/modules/sellers/handlers.py +++ b/app/modules/sellers/handlers.py @@ -3,6 +3,13 @@ def create_seller(user_id: int, iban: str, show_soldout_products: bool): + """ + Create a seller. If the seller already exists, return None. + :param user_id: the id of the user + :param iban: the iban of the seller + :param show_soldout_products: whether to show sold out products + :return: the seller or None if it already exists + """ seller = Seller(iban=iban, show_soldout_products=show_soldout_products, user_id=user_id) db.session.add(seller) db.session.commit() @@ -10,6 +17,13 @@ def create_seller(user_id: int, iban: str, show_soldout_products: bool): def update_seller(user_id: int, iban: str, show_soldout_products: bool): + """ + Update a seller. If the seller doesn't exist, return None. + :param user_id: the id of the user + :param iban: the iban of the seller + :param show_soldout_products: whether to show sold out products + :return: the seller or None if it doesn't exist + """ seller = Seller.query.filter_by(user_id=user_id).first() if not seller: return None diff --git a/app/modules/sellers/models.py b/app/modules/sellers/models.py index e2efb09..e5dbe9f 100644 --- a/app/modules/sellers/models.py +++ b/app/modules/sellers/models.py @@ -6,16 +6,20 @@ if TYPE_CHECKING: from app.modules.users.models import User from app.modules.products.models import Product - from app.modules.orders.models import SellerOrder + from app.modules.orders.models import SellerOrder, OrderReport from app.modules.shipments.models import Shipment else: User = "User" Product = "Product" SellerOrder = "SellerOrder" Shipment = "Shipment" + OrderReport = "OrderReport" class Seller(db.Model): + """ + Represents a seller. A seller is a user that sells products. + """ __tablename__ = "sellers" id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True, nullable=False, index=True) @@ -28,5 +32,7 @@ class Seller(db.Model): orders: Mapped[List[SellerOrder]] = db.relationship("SellerOrder", back_populates="seller") shipments: Mapped[List["Shipment"]] = db.relationship("Shipment", back_populates="seller") + order_reports: Mapped[List[OrderReport]] = db.relationship("OrderReport", back_populates="seller") + def __repr__(self): return f"" diff --git a/app/modules/sellers/views.py b/app/modules/sellers/views.py index 58013cd..f63e12f 100644 --- a/app/modules/sellers/views.py +++ b/app/modules/sellers/views.py @@ -10,6 +10,11 @@ @login_required @csrf.exempt def register_view(): + """ + Register a seller. The user must not be a seller. + :return: + """ + # handling form submission if request.method == 'POST': iban = request.form.get('iban') show_sold_products = request.form.get('show_sold_products') == 'on' diff --git a/app/modules/shared/consts.py b/app/modules/shared/consts.py index be389cd..ba575b8 100644 --- a/app/modules/shared/consts.py +++ b/app/modules/shared/consts.py @@ -1,2 +1,9 @@ -page_size = 20 -created_orders_ttl = 2 * 60 # 2 minutes +""" +This file contains the constants used in the shared module. +""" + +# The default page size for paginated results +DEFAULT_PAGE_SIZE = 20 + +# The time-to-live for created orders +DEFAULT_CREATED_ORDERS_TTL = 2 * 60 # 2 minutes diff --git a/app/modules/shared/forms.py b/app/modules/shared/forms.py index dade10e..0b0beb8 100644 --- a/app/modules/shared/forms.py +++ b/app/modules/shared/forms.py @@ -4,4 +4,7 @@ class PaginationForm(FlaskForm): + """ + Represents the pagination form. + """ page = IntegerField("Page", validators=[validators.Optional()], default=1) diff --git a/app/modules/shared/handlers.py b/app/modules/shared/handlers.py index 5889ec3..4b3aad9 100644 --- a/app/modules/shared/handlers.py +++ b/app/modules/shared/handlers.py @@ -1,16 +1,18 @@ from app.modules.orders.models import BuyerOrder, BuyersOrderStatus -from app.modules.shared.consts import created_orders_ttl +from app.modules.shared.consts import DEFAULT_CREATED_ORDERS_TTL from extensions import db def clean_expired_orders() -> bool: - """ remove the buyer orders with status "created" whose creation time is older than timeout and unlock the - products""" + """ + Remove the buyer orders with status "created" whose creation time is older than timeout and unlock the + products + """ invalid_locks = ( BuyerOrder.query .filter_by(status=BuyersOrderStatus.CREATED, deleted_at=None) .filter( - BuyerOrder.created_at < db.func.now() - db.func.make_interval(0, 0, 0, 0, 0, 0, created_orders_ttl)) + BuyerOrder.created_at < db.func.now() - db.func.make_interval(0, 0, 0, 0, 0, 0, DEFAULT_CREATED_ORDERS_TTL)) .all() ) diff --git a/app/modules/shared/proxy.py b/app/modules/shared/proxy.py index 22ea966..27fa648 100644 --- a/app/modules/shared/proxy.py +++ b/app/modules/shared/proxy.py @@ -6,6 +6,10 @@ def _get_search(): + """ + Utility function to get the search form. If the user is authenticated and the search form is not in the context, + :return: the search form + """ if has_request_context() and current_user.is_authenticated: if "_search" not in g: return SearchForm(request.args) diff --git a/app/modules/shared/utils.py b/app/modules/shared/utils.py index afb82b2..ccea527 100644 --- a/app/modules/shared/utils.py +++ b/app/modules/shared/utils.py @@ -5,6 +5,11 @@ def seller_required(f): + """ + Decorator to require a seller. + :param f: the function to decorate + :return: the decorated function + """ @wraps(f) def decorated_function(*args, **kwargs): if not current_user.sellers: @@ -15,6 +20,11 @@ def decorated_function(*args, **kwargs): def buyer_required(f): + """ + Decorator to require a buyer. + :param f: the function to decorate + :return: the decorated function + """ @wraps(f) def decorated_function(*args, **kwargs): if not current_user.buyers: diff --git a/app/modules/shipments/handlers.py b/app/modules/shipments/handlers.py index 5347ce5..a597e48 100644 --- a/app/modules/shipments/handlers.py +++ b/app/modules/shipments/handlers.py @@ -3,7 +3,7 @@ from flask_sqlalchemy.pagination import QueryPagination from app.modules.orders.models import SellerOrder, SellerOrderStatus -from app.modules.shared.consts import page_size +from app.modules.shared.consts import DEFAULT_PAGE_SIZE from app.modules.shipments.models import Shipment, ShipmentStatus from extensions import db @@ -60,7 +60,7 @@ def update_shipment_status(shipment: Shipment) -> Shipment: return shipment -def get_shipments_by_seller(seller_id: int, page: int = 1, per_page: int = page_size) -> QueryPagination: +def get_shipments_by_seller(seller_id: int, page: int = 1, per_page: int = DEFAULT_PAGE_SIZE) -> QueryPagination: """ Get shipments by seller id. :param seller_id: the id of the seller diff --git a/app/modules/shipments/models.py b/app/modules/shipments/models.py index 986d601..8265683 100644 --- a/app/modules/shipments/models.py +++ b/app/modules/shipments/models.py @@ -60,6 +60,13 @@ class Shipment(db.Model): history: Mapped[List["ShipmentHistory"]] = db.relationship("ShipmentHistory", back_populates="shipment") seller: Mapped["Seller"] = db.relationship("Seller", back_populates="shipments") + def is_delivered(self): + """ + Check if the shipment is delivered. + :return: + """ + return self.current_status == ShipmentStatus.DELIVERED + def __repr__(self): return f"" diff --git a/app/modules/shipments/views.py b/app/modules/shipments/views.py index e3eedbe..b312b76 100644 --- a/app/modules/shipments/views.py +++ b/app/modules/shipments/views.py @@ -5,7 +5,6 @@ from app.modules.shared.utils import seller_required from app.modules.shipments import shipments from app.modules.shipments.handlers import get_shipments_by_seller, get_shipment_by_uuid, update_shipment_status -from app.modules.shipments.models import ShipmentStatus @shipments.route("", methods=['GET']) diff --git a/app/modules/users/handlers.py b/app/modules/users/handlers.py index b1033c8..259f852 100644 --- a/app/modules/users/handlers.py +++ b/app/modules/users/handlers.py @@ -5,6 +5,14 @@ def create_user(email: str, given_name: str, family_name: str, password: str): + """ + Create a new user. The password is hashed before storing it. + :param email: the email of the user + :param given_name: the given name of the user + :param family_name: the family name of the user + :param password: the password of the user + :return: the created user + """ user = User(email=email, given_name=given_name, family_name=family_name, password=generate_password_hash(password)) db.session.add(user) db.session.commit() @@ -12,6 +20,12 @@ def create_user(email: str, given_name: str, family_name: str, password: str): def update_user(guid: str, password: str): + """ + Update the password of a user. The password is hashed before storing it. + :param guid: the guid of the user + :param password: the new password + :return: the updated user + """ user = User.query.filter_by(guid=guid).first() if not user: return None diff --git a/app/modules/users/models.py b/app/modules/users/models.py index 66b55a8..a46d0b5 100644 --- a/app/modules/users/models.py +++ b/app/modules/users/models.py @@ -15,6 +15,9 @@ class User(UserMixin, db.Model): + """ + Represents a user. A user can be a buyer or a seller. A user in order to be a seller must be a buyer first. + """ __tablename__ = "users" id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True, nullable=False, index=True) @@ -29,13 +32,19 @@ class User(UserMixin, db.Model): ) deleted_at: Mapped[str] = mapped_column(db.DateTime, nullable=True) - # TODO: uselist=False, glhf w/ that - # https://docs.sqlalchemy.org/en/20/orm/relationship_api.html#sqlalchemy.orm.relationship.params.uselist buyers: Mapped[List[Buyer]] = db.relationship("Buyer", back_populates="user") sellers: Mapped[List[Seller]] = db.relationship("Seller", back_populates="user") def get_id(self): + """ + Get the user guid. Utility function in order to work with Flask-Login. + :return: the user guid + """ return str(self.guid) def __repr__(self): + """ + Get the string representation of the user. + :return: the string representation of the user + """ return f"" diff --git a/app/modules/users/views.py b/app/modules/users/views.py index aa0a861..e995d6f 100644 --- a/app/modules/users/views.py +++ b/app/modules/users/views.py @@ -9,15 +9,21 @@ @users.route('', methods=['PUT']) @login_required def edit_user(): + """ + Edit the user. The user can be a buyer or a seller. + :return: the updated user + """ destination_address = request.json.get('destination_address') card_number = request.json.get('card_number') iban = request.json.get('iban') show_sold_products = request.json.get('show_soldout') + # Update the buyer buyer = update_buyer(current_user.id, destination_address, card_number) if not buyer: return {'message': 'buyer not found'}, 404 + # Update the seller if the user is a seller if current_user.sellers: seller = update_seller(current_user.id, iban, show_sold_products) if not seller: diff --git a/app/templates/layouts/base.html b/app/templates/layouts/base.html index 135ac4c..b3bed8a 100644 --- a/app/templates/layouts/base.html +++ b/app/templates/layouts/base.html @@ -183,11 +183,11 @@ editUser, profile: { destination_address: "{{ current_user.buyers[0].destination_address }}", - card_number: "{{ current_user.buyers[0].card_number }}", + card_number: "{{ current_user.buyers[0].card_number|string }}", {% if current_user.sellers %} iban: "{{ current_user.sellers[0].iban }}", - show_soldout: "{{ current_user.sellers[0].show_soldout_products }}" === "True", + show_soldout: "{{ current_user.sellers[0].show_soldout_products|string }}" === "True", {% endif %} }, originalProfile: { @@ -196,9 +196,9 @@ {% if current_user.sellers %} iban: "{{ current_user.sellers[0].iban }}", - show_soldout: "{{ current_user.sellers[0].show_soldout_products }}" === "True", + show_soldout: "{{ current_user.sellers[0].show_soldout_products|string }}" === "True", {% endif %} - } + }, })); }); diff --git a/app/templates/products/[guid].html b/app/templates/products/[guid].html index fcdd9bf..9596061 100644 --- a/app/templates/products/[guid].html +++ b/app/templates/products/[guid].html @@ -1,5 +1,7 @@ {% extends 'layouts/base.html' %} {% from 'macros/error_message.html' import error_message %} +{% from 'reviews/review_details.html' import review_details_card %} +{% from 'reviews/review_creation.html' import review_creation_form %} {% block title %}{{ product.name }}{% endblock %} @@ -163,6 +165,30 @@

{% endif %} + {% if product.reviews|length == 0 %} +
+

Latest Reviews

+
+

No reviews yet

+
+
+ {% else %} + +
+

Reviews

+
+ {% for review in product.reviews[:5] %} + {{ review_details_card(review.buyer.user, review) }} + {% endfor %} +
+
+ {% endif %} + {% if can_review or review %} +
+

Your review

+ {{ review_creation_form(product, review) }} +
+ {% endif %} {% endblock %} \ No newline at end of file diff --git a/app/templates/reviews/review_creation.html b/app/templates/reviews/review_creation.html new file mode 100644 index 0000000..237d7ae --- /dev/null +++ b/app/templates/reviews/review_creation.html @@ -0,0 +1,44 @@ +{% macro review_creation_form(product, default_review) -%} +
+ +
+
+ + + {%- if default_review -%} + + {% else %} + + {%- endif %} +
+ +
+ + {%- if default_review -%} + + {%- else -%} + + {%- endif %} +
+ {% if default_review %} + + {% endif %} + +
+
+{%- endmacro %} diff --git a/app/templates/reviews/review_details.html b/app/templates/reviews/review_details.html new file mode 100644 index 0000000..4250687 --- /dev/null +++ b/app/templates/reviews/review_details.html @@ -0,0 +1,22 @@ +{% macro review_details_card(user, review) -%} +
+
+
+
+
+
+

Review by {{ user.given_name }} {{ user.family_name }}

+ at {{ review.updated_at.strftime('%Y-%m-%d %H:%M') }} UTC +
+
+
+ {{ review.current_rating }}/5 +
+
+
+

{{ review.current_message }}

+
+
+
+
+{%- endmacro %} diff --git a/config.py b/config.py index 3d8e186..97dfb37 100644 --- a/config.py +++ b/config.py @@ -26,7 +26,7 @@ class Prod(object): "isolation_level": "REPEATABLE READ", } - BLUEPRINTS = ["home", "auth", "users", "buyers", "sellers", "products", "carts", "orders", "shipments"] + BLUEPRINTS = ["home", "auth", "users", "buyers", "sellers", "products", "carts", "reviews", "orders", "shipments"] EXTENSIONS = [ 'extensions.db', 'extensions.login_manager', diff --git a/extensions.py b/extensions.py index f58dbef..fd0c0b8 100644 --- a/extensions.py +++ b/extensions.py @@ -16,7 +16,7 @@ class Base(DeclarativeBase): login_manager = LoginManager() login_manager.login_view = "auth.login" -migrate = Migrate() +migrate = Migrate(compare_type=True) csrf = CSRFProtect() cors = CORS() diff --git a/factory/populate.py b/factory/populate.py index 08bd351..6932c01 100644 --- a/factory/populate.py +++ b/factory/populate.py @@ -9,23 +9,28 @@ from app.modules.buyers.models import Buyer from app.modules.products.models import ProductCategory, Product +from app.modules.reviews.models import ProductReview from app.modules.sellers.models import Seller from app.modules.users.models import User def create_seller(faker, user: User): return Seller(iban=faker.iban(), show_soldout_products=faker.boolean(), - user_id=user.id) + user=user) def create_user(faker: Faker): - return User(id=random.randint(100, 99999999), email=faker.safe_email(), given_name=faker.first_name(), + email = faker.safe_email() + password = faker.password(length=8, special_chars=False) + print(f"Email: {email}, Password: {password}") + + return User(email=email, given_name=faker.first_name(), family_name=faker.last_name(), - password=generate_password_hash(faker.password(length=8, special_chars=False))) + password=generate_password_hash(password)) def create_buyer(faker, user): - return Buyer(destination_address=faker.address(), card_number=faker.credit_card_number(), + return Buyer(destination_address=faker.address(), card_number=str(faker.credit_card_number()), user=user) diff --git a/migrations/env.py b/migrations/env.py index 4d6ae6f..f4fd0e2 100644 --- a/migrations/env.py +++ b/migrations/env.py @@ -5,6 +5,7 @@ from flask import current_app + from alembic import context # this is the Alembic Config object, which provides @@ -16,6 +17,8 @@ fileConfig(config.config_file_name) logger = logging.getLogger('alembic.env') +from app.modules.reviews.models import * + def get_engine(): try: diff --git a/migrations/versions/c9ce5d5552a5_reviews_seller_product_removed.py b/migrations/versions/c9ce5d5552a5_reviews_seller_product_removed.py new file mode 100644 index 0000000..96c738a --- /dev/null +++ b/migrations/versions/c9ce5d5552a5_reviews_seller_product_removed.py @@ -0,0 +1,37 @@ +"""reviews seller product removed + +Revision ID: c9ce5d5552a5 +Revises: ecdc452f26c7 +Create Date: 2024-06-21 18:18:35.633585 + +""" +from alembic import op +import sqlalchemy as sa +from sqlalchemy.dialects import postgresql + +# revision identifiers, used by Alembic. +revision = 'c9ce5d5552a5' +down_revision = 'ecdc452f26c7' +branch_labels = None +depends_on = None + + +def upgrade(): + # ### commands auto generated by Alembic - please adjust! ### + with op.batch_alter_table('product_reviews', schema=None) as batch_op: + batch_op.drop_index('ix_product_reviews_seller_order_id') + batch_op.drop_constraint('product_reviews_seller_order_id_fkey', type_='foreignkey') + batch_op.drop_column('seller_order_id') + + # ### end Alembic commands ### + + +def downgrade(): + # ### commands auto generated by Alembic - please adjust! ### + + with op.batch_alter_table('product_reviews', schema=None) as batch_op: + batch_op.add_column(sa.Column('seller_order_id', sa.INTEGER(), autoincrement=False, nullable=False)) + batch_op.create_foreign_key('product_reviews_seller_order_id_fkey', 'sellers_orders', ['seller_order_id'], ['id']) + batch_op.create_index('ix_product_reviews_seller_order_id', ['seller_order_id'], unique=False) + + # ### end Alembic commands ### diff --git a/migrations/versions/ecdc452f26c7_product_reviews.py b/migrations/versions/ecdc452f26c7_product_reviews.py new file mode 100644 index 0000000..211a6c0 --- /dev/null +++ b/migrations/versions/ecdc452f26c7_product_reviews.py @@ -0,0 +1,124 @@ +"""product reviews + +Revision ID: ecdc452f26c7 +Revises: 8a0ac983e5a3 +Create Date: 2024-06-20 18:16:15.854149 + +""" +from alembic import op +import sqlalchemy as sa +from sqlalchemy.dialects import postgresql + +# revision identifiers, used by Alembic. +revision = 'ecdc452f26c7' +down_revision = '8a0ac983e5a3' +branch_labels = None +depends_on = None + + +create_trigger_fun = """ +CREATE OR REPLACE FUNCTION update_product_reviews_history() + RETURNS TRIGGER AS $$ +BEGIN + INSERT INTO product_review_history(product_review_id, rating, message) + VALUES (NEW.id, NEW.current_rating, NEW.current_message); + RETURN NEW; +END; +$$ LANGUAGE plpgsql; +""" + +create_trigger = """ +CREATE TRIGGER after_update_product_reviews_history + AFTER INSERT OR UPDATE OF current_rating, current_message ON product_reviews + FOR EACH ROW EXECUTE FUNCTION update_product_reviews_history(); +""" + +drop_trigger = """ +DROP TRIGGER after_update_product_reviews_history ON product_reviews; +""" + +drop_trigger_fun = """ +DROP FUNCTION update_product_reviews_history; +""" + + +def upgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.create_table('orders_report', + sa.Column('buyer_order_id', sa.Integer(), nullable=False), + sa.Column('buyer_id', sa.Integer(), nullable=False), + sa.Column('seller_id', sa.Integer(), nullable=False), + sa.Column('seller_order_id', sa.Integer(), nullable=False), + sa.ForeignKeyConstraint(['buyer_id'], ['buyers.id'], ), + sa.ForeignKeyConstraint(['buyer_order_id'], ['buyers_orders.id'], ), + sa.ForeignKeyConstraint(['seller_id'], ['sellers.id'], ), + sa.ForeignKeyConstraint(['seller_order_id'], ['sellers_orders.id'], ), + sa.PrimaryKeyConstraint('buyer_order_id', 'buyer_id', 'seller_order_id', 'seller_id') + ) + with op.batch_alter_table('orders_report', schema=None) as batch_op: + batch_op.create_index(batch_op.f('ix_orders_report_buyer_id'), ['buyer_id'], unique=False) + batch_op.create_index(batch_op.f('ix_orders_report_buyer_order_id'), ['buyer_order_id'], unique=False) + batch_op.create_index(batch_op.f('ix_orders_report_seller_id'), ['seller_id'], unique=False) + batch_op.create_index(batch_op.f('ix_orders_report_seller_order_id'), ['seller_order_id'], unique=False) + + op.create_table('product_reviews', + sa.Column('id', sa.Integer(), autoincrement=True, nullable=False), + sa.Column('guid', sa.UUID(), nullable=False), + sa.Column('product_id', sa.Integer(), nullable=False), + sa.Column('seller_order_id', sa.Integer(), nullable=False), + sa.Column('buyer_id', sa.Integer(), nullable=False), + sa.Column('current_rating', sa.Integer(), nullable=False), + sa.Column('current_message', sa.String(length=255), nullable=False), + sa.Column('created_at', sa.DateTime(), server_default=sa.text('now()'), nullable=False), + sa.Column('updated_at', sa.DateTime(), server_default=sa.text('now()'), nullable=False), + sa.Column('deleted_at', sa.DateTime(), nullable=True), + sa.ForeignKeyConstraint(['buyer_id'], ['buyers.id'], ), + sa.ForeignKeyConstraint(['product_id'], ['products.id'], ), + sa.ForeignKeyConstraint(['seller_order_id'], ['sellers_orders.id'], ), + sa.PrimaryKeyConstraint('id'), + sa.UniqueConstraint('guid') + ) + with op.batch_alter_table('product_reviews', schema=None) as batch_op: + batch_op.create_index(batch_op.f('ix_product_reviews_id'), ['id'], unique=False) + batch_op.create_index(batch_op.f('ix_product_reviews_seller_order_id'), ['seller_order_id'], unique=False) + + op.create_table('product_review_history', + sa.Column('id', sa.Integer(), autoincrement=True, nullable=False), + sa.Column('product_review_id', sa.Integer(), nullable=False), + sa.Column('rating', sa.Integer(), nullable=False), + sa.Column('message', sa.String(length=255), nullable=False), + sa.Column('created_at', sa.DateTime(), server_default=sa.text('now()'), nullable=False), + sa.ForeignKeyConstraint(['product_review_id'], ['product_reviews.id'], ), + sa.PrimaryKeyConstraint('id') + ) + with op.batch_alter_table('product_review_history', schema=None) as batch_op: + batch_op.create_index(batch_op.f('ix_product_review_history_id'), ['id'], unique=False) + + op.execute(create_trigger_fun) + op.execute(create_trigger) + + # ### end Alembic commands ### + + +def downgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.execute(drop_trigger) + op.execute(drop_trigger_fun) + + with op.batch_alter_table('product_review_history', schema=None) as batch_op: + batch_op.drop_index(batch_op.f('ix_product_review_history_id')) + + op.drop_table('product_review_history') + with op.batch_alter_table('product_reviews', schema=None) as batch_op: + batch_op.drop_index(batch_op.f('ix_product_reviews_seller_order_id')) + batch_op.drop_index(batch_op.f('ix_product_reviews_id')) + + op.drop_table('product_reviews') + with op.batch_alter_table('orders_report', schema=None) as batch_op: + batch_op.drop_index(batch_op.f('ix_orders_report_seller_order_id')) + batch_op.drop_index(batch_op.f('ix_orders_report_seller_id')) + batch_op.drop_index(batch_op.f('ix_orders_report_buyer_order_id')) + batch_op.drop_index(batch_op.f('ix_orders_report_buyer_id')) + + op.drop_table('orders_report') + # ### end Alembic commands ###