diff --git a/backend/app/api/__init__.py b/backend/app/api/__init__.py index f92cae4..84ece3b 100644 --- a/backend/app/api/__init__.py +++ b/backend/app/api/__init__.py @@ -1,11 +1,11 @@ from fastapi import APIRouter -from app.api.routes import cart_routes, user_routes, utils_routes, shop - +from app.api.routes import cart_routes, login_routes, shop, user_routes, utils_routes api_router = APIRouter() api_router.include_router(cart_routes.router) api_router.include_router(user_routes.router) api_router.include_router(utils_routes.router) +api_router.include_router(login_routes.router) api_router.include_router(shop.shop_router) diff --git a/backend/app/api/dependencies.py b/backend/app/api/dependencies.py index 69d2174..0108b73 100644 --- a/backend/app/api/dependencies.py +++ b/backend/app/api/dependencies.py @@ -1,23 +1,18 @@ from typing import Annotated -from sqlmodel import Session - -from fastapi import Depends, HTTPException, status -from fastapi.security import OAuth2PasswordBearer import jwt +from fastapi import Depends, HTTPException, status +from fastapi.security import OAuth2PasswordBearer from jwt.exceptions import InvalidTokenError - from pydantic import ValidationError +from sqlmodel import Session -from app.core.config import settings from app.core import security - +from app.core.config import settings from app.database.manager import get_session -from app.database.models.user_model import User - +from app.database.models.user_model import User, UserRole from app.schemas.user_schemas import TokenPayload - reusable_oauth2 = OAuth2PasswordBearer( tokenUrl="/login/access-token" ) @@ -46,3 +41,10 @@ def get_current_user(session: SessionDep, token: TokenDep) -> User: CurrentUser = Annotated[User, Depends(get_current_user)] + +def get_owner_user(current_user: CurrentUser) -> User: + if current_user.user_role != UserRole.OWNER: + raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="You must be an owner") + return current_user + +CurrentOwnerUser = Annotated[User, Depends(get_owner_user)] diff --git a/backend/app/api/routes/login_routes.py b/backend/app/api/routes/login_routes.py index 37c3cea..8525fcb 100644 --- a/backend/app/api/routes/login_routes.py +++ b/backend/app/api/routes/login_routes.py @@ -1,20 +1,16 @@ -from typing import Annotated from datetime import timedelta +from typing import Annotated -from fastapi import APIRouter, HTTPException, Depends +from fastapi import APIRouter, Depends, HTTPException from fastapi.security import OAuth2PasswordRequestForm from app.api.dependencies import SessionDep - from app.core import security from app.core.config import settings - +from app.crud import user_crud from app.schemas.user_schemas import Token -from app.crud import user_crud - - -router = APIRouter(tags=["login"]) +router = APIRouter(tags=["Login"]) @router.post("/login/access-token") diff --git a/backend/app/api/routes/shop/shop_login_routes.py b/backend/app/api/routes/shop/shop_login_routes.py index faafd97..d6203b3 100644 --- a/backend/app/api/routes/shop/shop_login_routes.py +++ b/backend/app/api/routes/shop/shop_login_routes.py @@ -1,23 +1,19 @@ -from typing import Annotated from datetime import timedelta +from typing import Annotated -from fastapi import APIRouter, HTTPException, Depends +from fastapi import APIRouter, Depends, HTTPException from fastapi.security import OAuth2PasswordRequestForm from app.api.dependencies import SessionDep - from app.core import security from app.core.config import settings - +from app.crud import user_crud from app.schemas.user_schemas import Token -from app.crud import user_crud +router = APIRouter(prefix="/login", tags=["Login"]) -router = APIRouter(tags=["Shop-Login"]) - - -@router.post("/login/access-token") +@router.post("/access-token") def login_access_token( session: SessionDep, form_data: Annotated[OAuth2PasswordRequestForm, Depends()] diff --git a/backend/app/api/routes/shop/shop_routes.py b/backend/app/api/routes/shop/shop_routes.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/app/api/routes/shop/shop_user_routes.py b/backend/app/api/routes/shop/shop_user_routes.py index 41b2696..0c06270 100644 --- a/backend/app/api/routes/shop/shop_user_routes.py +++ b/backend/app/api/routes/shop/shop_user_routes.py @@ -3,12 +3,13 @@ from typing import Annotated from fastapi import APIRouter, Body, Path +from app.api.dependencies import SessionDep +from app.crud.user_crud import create_user +from app.database.models.user_model import UserRole from app.schemas.user_schemas import UserRegister - router = APIRouter( - prefix="/user", - tags=["Shop-Users"] + prefix="/user" ) @@ -23,8 +24,8 @@ async def logout(): @router.post("/register", summary="Register new user") -async def register(user_data: UserRegister): - raise NotImplementedError() +async def register(session: SessionDep, user_data: UserRegister, shop_uuid=Annotated[uuid.UUID, Path(title="UUID of the shop")]): + create_user(session, user_data, shop_uuid, UserRole.CUSTOMER) @router.put("/update", summary="Update user details") diff --git a/backend/app/api/routes/user_routes.py b/backend/app/api/routes/user_routes.py index 5085311..200eea3 100644 --- a/backend/app/api/routes/user_routes.py +++ b/backend/app/api/routes/user_routes.py @@ -1,9 +1,16 @@ -from fastapi import APIRouter, Body +import logging +import re -from app.schemas.user_schemas import UserRegister +from fastapi import APIRouter, Body, HTTPException, status +from sqlalchemy.exc import IntegrityError from app.api.dependencies import SessionDep from app.crud import user_crud +from app.database.models.user_model import UserRole +from app.schemas.user_schemas import UserRegister + +logger = logging.getLogger(__name__) + router = APIRouter( prefix="/user", @@ -24,10 +31,26 @@ async def logout(): @router.post("/register", summary="Register new user") async def register(session: SessionDep, user_data: UserRegister): try: - user_crud.create_user(session, user_data) - return {"message": "User registered successfully"} - except BaseException: - return {"message": "An error occurred"} + user_crud.create_user(session, user_data, None, UserRole.OWNER) + return {"detail": "Registered succeesfully"} + except IntegrityError as e: + field_mapping = {"uuid": "email"} # If a UUID is duplicate, it means email is in use + + constraint = e.orig.diag.constraint_name + column_name = re.sub(r"^user_(\w+)_key$", r"\1", constraint) if constraint else None + + if column_name == "uuid": + column_name = "email" + + detail = f"{field_mapping.get(column_name, column_name or 'Entry').capitalize()} already in use" + + raise HTTPException( + status_code=status.HTTP_409_CONFLICT, + detail=detail + ) + except Exception as e: + logger.error(e) + raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="Failed to register") @router.put("/update", summary="Update user details") diff --git a/backend/app/core/errors.py b/backend/app/core/errors.py index 4e4a1b4..b795d34 100644 --- a/backend/app/core/errors.py +++ b/backend/app/core/errors.py @@ -9,5 +9,5 @@ class RepositoryError(SwagShopError): """Generic error occuring in a repository""" -class ServiceError(SwagShopError): +class CrudError(SwagShopError): """Generic error occuring in a service""" diff --git a/backend/app/crud/cart_service.py b/backend/app/crud/cart_service.py deleted file mode 100644 index 5d96ee3..0000000 --- a/backend/app/crud/cart_service.py +++ /dev/null @@ -1,162 +0,0 @@ -from mysql.connector import Error -from typing import Tuple, Union - -from app.extensions import db_connection - - -class CartService: - - @staticmethod - @staticmethod - def update_count( - user_id: str, product_id: int, count: int - ) -> Tuple[Union[dict, str], int]: - """ - Updates count of products in user's cart - - :param user_id: User ID. - :type user_id: str - :param product_id: ID of product to be updated. - :type product_id: int - :param count: New count of products - :type count: int - :return: Tuple containing a dictionary with a token and an HTTP status code. - :rtype: Tuple[Union[dict, str], int] - """ - - try: - if count <= 0: - return CartService.delete_from_cart(user_id, product_id) - - with db_connection.cursor(dictionary=True) as cursor: - cursor.execute( - "update cart_item set count = %s where cart_id = %s and product_id = %s", - (count, user_id, product_id), - ) - db_connection.commit() - - return {"Success": "Successfully added to cart"}, 200 - - except Error as e: - return {"Failed": f"Failed to update item count in cart. Reason: {e}"}, 500 - - @staticmethod - def delete_from_cart(user_id: str, product_id: int) -> Tuple[Union[dict, str], int]: - """ - Completely deletes an item from a user's cart - - :param user_id: User ID. - :type user_id: str - :param product_id: ID of product to be updated. - :type product_id: int - :return: Tuple containing a dictionary with a token and an HTTP status code. - :rtype: Tuple[Union[dict, str], int] - """ - - try: - with db_connection.cursor() as cursor: - cursor.execute( - "delete from cart_item where cart_id = %s and product_id = %s", - (user_id, product_id), - ) - db_connection.commit() - - return {"Success": "Successfully removed item from cart"}, 200 - except Error as e: - return {"Failed": f"Failed to remove item from cart. Reason: {e}"}, 500 - - @staticmethod - def show_cart(user_id: str) -> Tuple[Union[dict, str], int]: - """ - Gives the user the content of their cart - - :param user_id: User ID. - :type user_id: str - :return: Tuple containing a dictionary with a token and an HTTP status code. - :rtype: Tuple[Union[dict, str], int] - """ - - try: - with db_connection.cursor(dictionary=True) as cursor: - cursor.execute( - "select product.name as product_name, count, price_subtotal, date_added from cart_item inner join product on cart_item.product_id = product.id where cart_item.cart_id = %s", - (user_id,), - ) - rows = cursor.fetchall() - - results = [] - - for row in rows: - mid_result = { - "name": row["product_name"], - "count": row["count"], - "price_subtotal": row["price_subtotal"], - "date_added": row["date_added"], - } - - results.append(mid_result) - - return results, 200 - - except Error as e: - return {"Failed": f"Failed to load cart. Reason: {e}"}, 500 - - @staticmethod - def purchase(user_id: str) -> Tuple[Union[dict, str], int]: - """ - "Purchases" the contents of user's cart - - :param user_id: User ID. - :type user_id: str - :return: Tuple containing a dictionary with a token and an HTTP status code. - :rtype: Tuple[Union[dict, str], int] - """ - - try: - with db_connection.cursor(dictionary=True) as cursor: - # get all cart items - cursor.execute( - "select id, product_id, count, price_subtotal from cart_item where cart_id = %s", - (user_id,), - ) - results = cursor.fetchall() - - if len(results) < 1: - return {"Failed": "Failed to purchase. Cart is Empty"}, 400 - - # create a purchase - cursor.execute("insert into purchase(user_id) values (%s)", (user_id,)) - - last_id = cursor.lastrowid - - parsed = [] - ids = [] - - for row in results: - mid_row = ( - last_id, - row["product_id"], - row["count"], - row["price_subtotal"], - ) - - row_id = row["id"] - - parsed.append(mid_row) - ids.append(row_id) - - insert_query = "INSERT INTO purchase_item (purchase_id, product_id, count, price_subtotal) VALUES (%s, %s, %s, %s)" - for row in parsed: - cursor.execute(insert_query, row) - - delete_query = "delete from cart_item where id = %s" - for one_id in ids: - cursor.execute(delete_query, (one_id,)) - - db_connection.commit() - - # clear cart - except Error as e: - return {"msg": f"Failed to load cart. Reason: {e}"}, 500 - - return {"msg": "Successfully purchased"}, 200 diff --git a/backend/app/crud/product/product_create_service.py b/backend/app/crud/product/product_create_service.py deleted file mode 100644 index 2ef070a..0000000 --- a/backend/app/crud/product/product_create_service.py +++ /dev/null @@ -1,30 +0,0 @@ -from mysql.connector import Error as mysqlError - -from app.messages.api_responses import product_responses as response -import app.messages.api_errors as errors - -from app.db import product_db - -from app.models.product_model import Product - - -def create_product(seller_id: str, name: str, price: float): - """ - Creates a new product listing - - :param seller_id: User ID - :type seller_id: str - :param name: New product's name - :type name: str - :param price: New product's price - :type price: float - """ - - product: Product = Product(seller_id=seller_id, name=name, price=price) - try: - product_db.insert_product(product) - - except mysqlError as e: - return errors.UNKNOWN_DATABASE_ERROR(e) - - return response.PRODUCT_LISTING_CREATED_SUCCESSFULLY diff --git a/backend/app/crud/product/product_delete_service.py b/backend/app/crud/product/product_delete_service.py deleted file mode 100644 index cc4e912..0000000 --- a/backend/app/crud/product/product_delete_service.py +++ /dev/null @@ -1,23 +0,0 @@ -from mysql.connector import Error as mysqlError - -from app.messages.api_responses import product_responses as response -import app.messages.api_errors as errors - -from app.db import product_db - -from app.models.product_model import Product - - -def delete_product(seller_id: str, product_id: str): - product: Product = product_db.fetch_product_by_id(product_id) - - if product.seller_id != seller_id: - return response.NOT_OWNER_OF_PRODUCT - - try: - product_db.delete_product(product) - - except mysqlError as e: - return errors.UNKNOWN_DATABASE_ERROR(e) - - return response.PRODUCT_LISTING_CREATED_SUCCESSFULLY diff --git a/backend/app/crud/product/product_helper.py b/backend/app/crud/product/product_helper.py deleted file mode 100644 index 0c02b48..0000000 --- a/backend/app/crud/product/product_helper.py +++ /dev/null @@ -1,9 +0,0 @@ -import imghdr - - -def is_base64_jpg(decoded_string) -> bool: - try: - image_type = imghdr.what(None, decoded_string) - return image_type == "jpeg" - except Exception: - return False diff --git a/backend/app/crud/product/product_info_service.py b/backend/app/crud/product/product_info_service.py deleted file mode 100644 index 5f13688..0000000 --- a/backend/app/crud/product/product_info_service.py +++ /dev/null @@ -1,20 +0,0 @@ -from mysql.connector import Error as mysqlError - -from app.messages.api_responses import product_responses as response -import app.messages.api_errors as errors - -from app.db import product_db - -from app.models.product_model import Product - - -def product_info(product_id: int): - try: - product: Product = product_db.fetch_product_extended_by_id(product_id) - - if product is None: - return response.UNKNOWN_PRODUCT - - return product, 200 - except mysqlError as e: - return errors.UNKNOWN_DATABASE_ERROR(e) diff --git a/backend/app/crud/product/product_list_service.py b/backend/app/crud/product/product_list_service.py deleted file mode 100644 index 0a18252..0000000 --- a/backend/app/crud/product/product_list_service.py +++ /dev/null @@ -1,30 +0,0 @@ -from mysql.connector import Error as mysqlError - -from app.messages.api_responses import product_responses as response -import app.messages.api_errors as errors - -from app.db import product_db - - -def product_list(page: int): - try: - result_products = product_db.fetch_products(page) - - if result_products is None: - return response.SCROLLED_TOO_FAR - - result_obj = [] - for product in result_products: - mid_result = { - "id": product.product_id, - "seller": product.seller_id, - "name": product.name, - "price": product.price, - } - - result_obj.append(mid_result) - - return result_obj, 200 - - except mysqlError as e: - errors.UNKNOWN_DATABASE_ERROR(e) diff --git a/backend/app/crud/shop_crud.py b/backend/app/crud/shop_crud.py new file mode 100644 index 0000000..905ce0d --- /dev/null +++ b/backend/app/crud/shop_crud.py @@ -0,0 +1,11 @@ +from typing import Optional +from uuid import UUID + +from sqlmodel import Session, select + +from app.database.models.shop_model import Shop + +def get_shop_id_from_uuid(session: Session, shop_id: int) -> Optional[UUID]: + stmt = select(Shop).where(Shop.id == shop_id) + db_shop = session.exec(stmt).one_or_none() + return db_shop diff --git a/backend/app/crud/user/delete_service.py b/backend/app/crud/user/delete_service.py deleted file mode 100644 index 43189db..0000000 --- a/backend/app/crud/user/delete_service.py +++ /dev/null @@ -1,32 +0,0 @@ -from typing import Tuple, Union -from mysql.connector import Error as mysqlError - -import app.db.user_db as user_db -from app.models.user_model import User - -import app.messages.api_responses.user_responses as response -import app.messages.api_errors as errors -from app.mail.mail import send_mail - - -from app.messages.mail_responses.user_email import USER_EMAIL_SUCCESSFULLY_DELETED_ACCOUNT - - -def delete_user(user_id: str) -> Tuple[Union[dict, str], int]: - """ - Deletes a user account. - - :param user_id: User ID. - :type user_id: str - :return: Tuple containing a dictionary and an HTTP status code. - :rtype: Tuple[Union[dict, str], int] - """ - - try: - user: User = user_db.fetch_by_id(user_id=user_id) - user_db.delete_user(user) - send_mail(USER_EMAIL_SUCCESSFULLY_DELETED_ACCOUNT, user.email) - except mysqlError as e: - return errors.UNKNOWN_DATABASE_ERROR(e) - - return response.USER_DELETED_SUCCESSFULLY diff --git a/backend/app/crud/user/login_service.py b/backend/app/crud/user/login_service.py deleted file mode 100644 index f06f063..0000000 --- a/backend/app/crud/user/login_service.py +++ /dev/null @@ -1,44 +0,0 @@ -import datetime -from typing import Tuple, Union - -import bcrypt -from mysql.connector import Error as mysqlError -from flask_jwt_extended import create_access_token - -import app.messages.api_responses.user_responses as response -import app.messages.api_errors as errors -from app.db import user_db -from app.mail.mail import send_mail -from app.models.user_model import User -from app.messages.mail_responses.user_email import USER_EMAIL_SUCCESSFULLY_LOGGED_IN - - -def login(username: str, password: str) -> Tuple[Union[dict, str], int]: - """ - Authenticates a user with the provided username and password. - - :param username: User's username. - :type username: str - :param password: User's password. - :type password: str - :return: Tuple containing a dictionary with a token and an HTTP status code. - :rtype: Tuple[Union[dict, str], int] - """ - try: - user: User = user_db.fetch_by_username(username) - - if user is None: - return response.USERNAME_NOT_FOUND - - if not bcrypt.checkpw(password.encode("utf-8"), user.password.encode("utf-8")): - return response.INCORRECT_PASSWORD - - expire = datetime.timedelta(hours=1) - token = create_access_token(identity=user.user_id, expires_delta=expire) - - send_mail(USER_EMAIL_SUCCESSFULLY_LOGGED_IN, user.email) - - return {"token": token}, 200 - - except mysqlError as e: - return errors.UNKNOWN_DATABASE_ERROR(e) diff --git a/backend/app/crud/user/logout_service.py b/backend/app/crud/user/logout_service.py deleted file mode 100644 index 05cf185..0000000 --- a/backend/app/crud/user/logout_service.py +++ /dev/null @@ -1,31 +0,0 @@ -from typing import Tuple, Union - -import app.messages.api_responses.user_responses as response -from app.db import user_db -from app.models.user_model import User -from app.mail.mail import send_mail -from app.services.user import user_helper as helper -from app.messages.mail_responses.user_email import USER_EMAIL_SUCCESSFULLY_LOGGED_OUT - - -def logout(jwt_token, user_id, send_notif: bool) -> Tuple[Union[dict, str], int]: - """ - Logs out a user by invalidating the provided JWT. - - :param jti: JWT ID. - :type jti: str - :param exp: JWT expiration timestamp. - :type exp: int - :return: Tuple containing a dictionary and an HTTP status code. - :rtype: Tuple[Union[dict, str], int] - """ - - jti = jwt_token["jti"] - exp = jwt_token["exp"] - - user: User = user_db.fetch_by_id(user_id) - - helper.invalidate_token(jti, exp) - if send_notif: - send_mail(USER_EMAIL_SUCCESSFULLY_LOGGED_OUT, user.email) - return response.USER_LOGGED_OUT_SUCCESSFULLY diff --git a/backend/app/crud/user/register_service.py b/backend/app/crud/user/register_service.py deleted file mode 100644 index c050caf..0000000 --- a/backend/app/crud/user/register_service.py +++ /dev/null @@ -1,64 +0,0 @@ -import bcrypt -from typing import Tuple, Union -from mysql.connector import Error as mysqlError - -import app.messages.api_responses.user_responses as response -import app.messages.api_errors as errors -from app.db import user_db -from app.mail.mail import send_mail -from app.models.user_model import User -from app.services.user import user_helper as helper -from app.messages.mail_responses.user_email import USER_EMAIL_SUCCESSFULLY_REGISTERED - - -def register( - username: str, displayname: str, email: str, password: str -) -> Tuple[Union[dict, str], int]: - """ - Registers a new user with the provided username, email, and password. - - :param username: User's username. - :type username: str - :param email: User's email address. - :type email: str - :param password: User's password. - :type password: str - :return: Tuple containing a dictionary and an HTTP status code. - :rtype: Tuple[Union[dict, str], int] - """ - - try: - if not helper.verify_username(username): - return response.INVALID_USERNAME_FORMAT - - if not helper.verify_displayname(displayname): - return response.INVALID_DISPLAYNAME_FORMAT - - if not helper.verify_email(email): - return response.INVALID_EMAIL_FORMAT - - if not helper.verify_password(password): - return response.INVALID_PASSWORD_FORMAT - - hashed_password = bcrypt.hashpw(password.encode("utf-8"), bcrypt.gensalt()) - - new_user: User = User( - username=username, - displayname=displayname, - email=email, - password=hashed_password, - ) - - user_db.insert_user(new_user) - - except mysqlError as e: - if "email" in e.msg: - return response.EMAIL_ALREADY_IN_USE - if "username" in e.msg: - return response.USERNAME_ALREADY_IN_USE - - return errors.UNKNOWN_DATABASE_ERROR(e) - - send_mail(USER_EMAIL_SUCCESSFULLY_REGISTERED, new_user.email) - - return response.USER_CREATED_SUCCESSFULLY diff --git a/backend/app/crud/user/update_user_service.py b/backend/app/crud/user/update_user_service.py deleted file mode 100644 index 0e35288..0000000 --- a/backend/app/crud/user/update_user_service.py +++ /dev/null @@ -1,72 +0,0 @@ -import bcrypt -from typing import Tuple, Union -from mysql.connector import Error as mysqlError - -from app.db import user_db -from app.mail.mail import send_mail -from app.models.user_model import User -from app.services.user import user_helper as helper -from app.messages.api_responses import user_responses as response -import app.messages.api_errors as errors -from app.messages.mail_responses.user_email import ( - USER_EMAIL_SUCCESSFULLY_UPDATED_ACCOUNT, -) - - -def update_user( - user_id: str, - new_username: str = None, - new_displayname: str = None, - new_email: str = None, - new_password: str = None, -) -> Tuple[Union[dict, str], int]: - user: User = user_db.fetch_by_id(user_id) - - updated_attributes = [] - - if user is None: - return response.UNKNOWN_ERROR - - if new_username: - if not helper.verify_username(new_username): - return response.INVALID_USERNAME_FORMAT - - user.username = new_username - updated_attributes.append("username") - - if new_displayname: - if not helper.verify_displayname(new_displayname): - return response.INVALID_DISPLAYNAME_FORMAT - - user.displayname = new_displayname - updated_attributes.append("displayname") - - if new_email: - if not helper.verify_email(new_email): - return response.INVALID_EMAIL_FORMAT - - user.email = new_email - updated_attributes.append("email") - - if new_password: - if not helper.verify_password(new_password): - return response.INVALID_PASSWORD_FORMAT - - hashed_password = bcrypt.hashpw(new_password.encode("utf-8"), bcrypt.gensalt()) - - user.password = hashed_password - updated_attributes.append("password") - - try: - user_db.update_user(user) - - except mysqlError as e: - if "username" in e.msg: - return response.USERNAME_ALREADY_IN_USE - if "email" in e.msg: - return response.EMAIL_ALREADY_IN_USE - - return errors.UNKNOWN_DATABASE_ERROR(e) - - send_mail(USER_EMAIL_SUCCESSFULLY_UPDATED_ACCOUNT, user.email) - return response.USER_ACCOUNT_UPDATED_SUCCESSFULLY(updated_attributes) diff --git a/backend/app/crud/user/user_helper.py b/backend/app/crud/user/user_helper.py deleted file mode 100644 index b64b1a4..0000000 --- a/backend/app/crud/user/user_helper.py +++ /dev/null @@ -1,74 +0,0 @@ -import re -from datetime import datetime - -from app.extensions import jwt_redis_blocklist - - -def invalidate_token(jti: str, exp: int): - """ - Invalidates a JWT by adding its JTI to the Redis blocklist. - - :param jti: JWT ID. - :type jti: str - :param exp: JWT expiration timestamp. - :type exp: int - """ - expiration = datetime.fromtimestamp(exp) - now = datetime.now() - - delta = expiration - now - jwt_redis_blocklist.set(jti, "", ex=delta) - - -def verify_email(email: str) -> bool: - """ - Verifies a given email string against a regular expression. - - :param email: Email string. - :type email: str - :return: Boolean indicating whether the email successfully passed the check. - :rtype: bool - """ - email_regex = r"^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$" - return re.match(email_regex, email) and len(email) <= 64 - - -def verify_displayname(displayname: str) -> bool: - """ - Verifies a given display name string against a regular expression. - - :param displayname: Display name string. - :type displayname: str - :return: Boolean indicating whether the display name successfully passed the check. - :rtype: bool - """ - displayname_regex = r"^[a-zA-Z.-_]{1,64}$" - return re.match(displayname_regex, displayname) - - -def verify_username(username: str) -> bool: - """ - Verifies a given username string against a regular expression. - - :param username: Username string. - :type username: str - :return: Boolean indicating whether the username successfully passed the check. - :rtype: bool - """ - username_regex = r"^[a-z]{1,64}$" - return re.match(username_regex, username) - - -def verify_password(password: str) -> bool: - """ - Verifies a given password string against a regular expression. - - :param password: Password string. - :type password: str - :return: Boolean indicating whether the password successfully passed the check. - :rtype: bool - """ - password_regex = ( - r"^(?=.*?[A-Z])(?=.*?[a-z])(?=.*?[0-9])(?=.*?[#?!@$ %^&*-]).{8,64}$" - ) - return re.match(password_regex, password) diff --git a/backend/app/crud/user_crud.py b/backend/app/crud/user_crud.py index a438ee6..ec458e3 100644 --- a/backend/app/crud/user_crud.py +++ b/backend/app/crud/user_crud.py @@ -1,36 +1,58 @@ -import uuid +import logging from typing import Optional +from uuid import UUID + from sqlmodel import Session, select -from app.database.models.user_model import User +from app.core.security import get_password_hash, verify_password +from app.crud.shop_crud import get_shop_id_from_uuid +from app.database.models.user_model import User, UserRole from app.schemas.user_schemas import UserRegister -from app.core.security import verify_password, get_password_hash from app.utils.models import generate_user_uuid5 -def get_user_by_generated_uuid(session: Session, email: str, shop_id: Optional[int]) -> Optional[User]: +logger = logging.getLogger(__name__) + + +def get_user_by_generated_uuid(session: Session, email: str, shop_uuid: Optional[UUID]) -> Optional[User]: + logger.debug("Getting shop id by UUID - %s", shop_uuid) + shop_id = get_shop_id_from_uuid(session, shop_uuid) + logger.debug("Generating user UUID5") user_uuid = generate_user_uuid5(email, shop_id) stmt = select(User).where(User.uuid == user_uuid) + logger.debug("Executing select query") db_user = session.exec(stmt).one_or_none() return db_user -def create_user(session: Session, user_register: UserRegister, shop_id: Optional[int], user_role: str): +def create_user(session: Session, user_register: UserRegister, shop_uuid: Optional[UUID], user_role: UserRole): + logger.debug("Getting shop id by UUID - %s", shop_uuid) + shop_id = get_shop_id_from_uuid(session, shop_uuid) + logger.debug("Generating user UUID5") user_uuid = generate_user_uuid5(user_register.email, shop_id) - password = get_password_hash(user_register.password) + logger.debug("Hashing password") + hashed_password = get_password_hash(user_register.password) new_user = User( uuid=user_uuid, shop_id=shop_id, email=user_register.email, username=user_register.username, - phone_number=user_register.phone_number + phone_number=user_register.phone_number, + user_role=user_role, + password=hashed_password ) + logger.debug("Inserting new user") session.add(new_user) session.commit() -def authenticate(session: Session, email: str, password: str, shop_id: Optional[int]) -> Optional[User]: +def authenticate(session: Session, email: str, password: str, shop_uuid: Optional[int]) -> Optional[User]: + logger.debug("Getting shop id by UUID - %s", shop_uuid) + shop_id = get_shop_id_from_uuid(session, shop_uuid) + logger.debug("Fetching user from db by email - %s", email) db_user = get_user_by_generated_uuid(session, email, shop_id) if db_user is None: + logger.warn("Didn't find User with email=%s for shop=%s", email, shop_uuid) return None if not verify_password(plain_password=password, hashed_password=db_user.password): + logger.warn("Found user with email=%s for shop=%s", email, shop_uuid) return None return db_user diff --git a/backend/app/database/models/base_model.py b/backend/app/database/models/base_model.py deleted file mode 100644 index 3fa6073..0000000 --- a/backend/app/database/models/base_model.py +++ /dev/null @@ -1,5 +0,0 @@ -from sqlalchemy.orm import declarative_base - -Base = declarative_base() - -__all__ = ["Base"] diff --git a/backend/app/database/models/shop_model.py b/backend/app/database/models/shop_model.py index bef6925..2753a38 100644 --- a/backend/app/database/models/shop_model.py +++ b/backend/app/database/models/shop_model.py @@ -1,13 +1,12 @@ -from uuid import UUID -from enum import Enum as PyEnum -from typing import Optional, List from datetime import datetime, time +from enum import Enum as PyEnum +from typing import Optional +from uuid import UUID -from sqlalchemy import Column, CheckConstraint -from sqlalchemy.dialects.postgresql import JSONB - -from sqlmodel import SQLModel, Field, Relationship, Enum from pydantic import BaseModel, constr +from sqlalchemy import CheckConstraint, Column +from sqlalchemy.dialects.postgresql import JSONB +from sqlmodel import Enum, Field, Relationship, SQLModel class ShopStatus(PyEnum): @@ -22,7 +21,7 @@ class ShopLinkEntry(BaseModel): class ShopLinks(BaseModel): - links: List[ShopLinkEntry] + links: list[ShopLinkEntry] class ShopBusinessHourEntry(BaseModel): @@ -32,7 +31,7 @@ class ShopBusinessHourEntry(BaseModel): class ShopBusinessHours(BaseModel): - hours: List[ShopBusinessHourEntry] + hours: list[ShopBusinessHourEntry] class Shop(SQLModel, table=True): @@ -59,8 +58,15 @@ class Shop(SQLModel, table=True): sa_column=Column(JSONB, nullable=False, default=lambda: {}) ) - owner: Optional["User"] = Relationship(back_populates='owned_shops') - registered_users: List["User"] = Relationship(back_populates='registered_shop') + owner: Optional["User"] = Relationship( + back_populates='owned_shop', + sa_relationship_kwargs={"foreign_keys": "[Shop.owner_id]"} + ) + + registered_users: list["User"] = Relationship( + back_populates='registered_shop', + sa_relationship_kwargs={"foreign_keys": "[User.shop_id]"} + ) __table_args__ = ( CheckConstraint("business_hours ? 'hours'", name="check_business_hours_keys"), diff --git a/backend/app/database/models/user_model.py b/backend/app/database/models/user_model.py index 8d8e567..071c762 100644 --- a/backend/app/database/models/user_model.py +++ b/backend/app/database/models/user_model.py @@ -1,17 +1,28 @@ -from typing import Optional, List -from uuid import UUID from datetime import datetime +from enum import Enum as PyEnum +from typing import Optional +from uuid import UUID -from sqlmodel import SQLModel, Field, Relationship +from sqlalchemy import Column +from sqlmodel import Enum, Field, Relationship, SQLModel + + +class UserRole(PyEnum): + OWNER = "owner" + CUSTOMER = "customer" + EMPLOYEE = "employee" + MANAGER = "manager" + ADMIN = "admin" class User(SQLModel, table=True): - __tablename__ = 'user' + __tablename__ = "user" id: int = Field(primary_key=True) uuid: UUID = Field(nullable=False, unique=True) - user_role_id: int = Field(foreign_key="user_role.id", nullable=False) + user_role: UserRole = Field(sa_column=Column(Enum(UserRole), nullable=False)) shop_id: Optional[int] = Field(foreign_key="shop.id") + status: UserRole = Field(sa_column=Column(Enum(UserRole))) username: str = Field(max_length=64, nullable=False, unique=True) email: str = Field(max_length=128, nullable=False, unique=True) password: str = Field(max_length=60, nullable=False) @@ -23,31 +34,29 @@ class User(SQLModel, table=True): updated_at: datetime = Field(default_factory=datetime.utcnow, nullable=False) last_login: Optional[datetime] = Field(default_factory=datetime.utcnow) - owned_shops: List["Shop"] = Relationship(back_populates="owner") - registered_shop: List["Shop"] = Relationship(back_populates="registered_users") + owned_shop: "Shop" = Relationship( + back_populates="owner", + sa_relationship_kwargs={"foreign_keys": "[Shop.owner_id]"} + ) + + registered_shop: Optional["Shop"] = Relationship( + back_populates="registered_users", + sa_relationship_kwargs={"foreign_keys": "[User.shop_id]"} + ) - user_assigned_role: Optional["UserRole"] = Relationship(back_populates="role_users", sa_relationship_kwargs={"foreign_keys": "[User.user_role_id]"}) preferences: Optional["UserPreferences"] = Relationship(back_populates="user") - statistics: Optional["UserStatistics"] = Relationship(back_populates="user_statistics") + statistics: Optional["UserStatistics"] = Relationship(back_populates="user") + class UserPreferences(SQLModel, table=True): - __tablename__ = 'user_preferences' + __tablename__ = "user_preferences" user_id: int = Field(foreign_key="user.id", primary_key=True) user: Optional["User"] = Relationship(back_populates="preferences") -class UserRole(SQLModel, table=True): - __tablename__ = 'user_role' - - id: int = Field(primary_key=True) - name: str = Field(nullable=False, unique=True, max_length=50) - - role_users: List["User"] = Relationship(back_populates="user_assigned_role") - - class UserStatistics(SQLModel, table=True): __tablename__ = "user_statistics" @@ -56,4 +65,5 @@ class UserStatistics(SQLModel, table=True): user: Optional["User"] = Relationship(back_populates="statistics") + __all__ = ["User", "UserPreferences", "UserRole"] diff --git a/backend/app/dependencies.py b/backend/app/dependencies.py deleted file mode 100644 index 9c1aed3..0000000 --- a/backend/app/dependencies.py +++ /dev/null @@ -1,37 +0,0 @@ -from typing import Annotated - -from pydantic import BaseModel - -from fastapi import Header, HTTPException, Request, Body - - -async def get_token_header(x_token: Annotated[str, Header()]): - if x_token != "fake-super-secret-token": - raise HTTPException(status_code=400, detail="X-Token header invalid") - - -class LoginRequest(BaseModel): - username: str - password: str - - -async def get_jsession_id(request: Request): - jsessionid = ( - request.headers.get("JSESSIONID") - or request.cookies.get("JSESSIONID") - or request.query_params.get("jsessionid") - ) - - if not jsessionid: - raise HTTPException(status_code=400, detail="JSESSIONID is required for this operation") - - return jsessionid - - -async def get_credentials(credentials: LoginRequest = Body(...)): - return credentials.dict() - - -async def get_query_token(token: str): - if token != "jessica": - raise HTTPException(status_code=400, detail="No Jessica token provided") diff --git a/backend/app/main.py b/backend/app/main.py index 5454611..0a6ae43 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -1,12 +1,10 @@ from fastapi import FastAPI from fastapi.routing import APIRoute - from starlette.middleware.cors import CORSMiddleware -from app.utils import logger - from app.api import api_router from app.core.config import settings +from app.utils import logger logger.setup_logger() diff --git a/backend/app/schemas/user_schemas.py b/backend/app/schemas/user_schemas.py index fa7acc3..fb7dbf5 100644 --- a/backend/app/schemas/user_schemas.py +++ b/backend/app/schemas/user_schemas.py @@ -1,13 +1,24 @@ -from sqlmodel import Field as SqlModelField, SQLModel -from pydantic import EmailStr, Field +import re +from pydantic import EmailStr, model_validator +from sqlmodel import Field, SQLModel class UserRegister(SQLModel): - username: str = Field(..., min_length=3, max_length=64) - email: EmailStr = Field(...) - phone_number: str = Field(..., min_length=2, max_length=16, pattern=r'^\+[1-9]\d{1,14}$') - password: str = Field(..., min_length=6, max_length=128) - shop_id: int = 0 + username: str = Field(min_length=3, max_length=64) + email: EmailStr = Field() + phone_number: str = Field(min_length=2, max_length=16, schema_extra={"pattern": r'^\+[1-9]\d{1,14}$'}) + password: str = Field(min_length=8, max_length=128, + description="Password must be at least 8 and at most 128 characters long, contain a lower case, upper case letter, a number and a special character (#?!@$ %^&*-)") + + @model_validator(mode="after") + def validate_using_regex(self): + self.__validate_password() + return self + + def __validate_password(self): + password_regex = r"^(?=.*?[A-Z])(?=.*?[a-z])(?=.*?[0-9])(?=.*?[#?!@$ %^&*-]).{8,128}$" + if not re.match(password_regex, self.password): + raise ValueError("Password is too weak") class Token(SQLModel): diff --git a/backend/pyproject.toml b/backend/pyproject.toml index 410290f..3edea10 100644 --- a/backend/pyproject.toml +++ b/backend/pyproject.toml @@ -18,6 +18,24 @@ pydantic-settings = "^2.8.1" sqlmodel = "^0.0.24" psycopg2-binary = "^2.9.10" +[tool.ruff.lint] +select = [ + "E", # pycodestyle errors + "W", # pycodestyle warnings + "F", # pyflakes + "I", # isort + "B", # flake8-bugbear + "C4", # flake8-comprehensions + "UP", # pyupgrade + "ARG001", # unused arguments in functions +] +ignore = [ + "E501", # line too long, handled by black + "B008", # do not perform function calls in argument defaults + "W191", # indentation contains tabs + "B904", # Allow raising exceptions without from e, for HTTPException +] + [tool.poetry.group.dev.dependencies] pylint = "^3.3.4"