From 71e916586e07bc4d539c66823859bcb8b0d1bbcf Mon Sep 17 00:00:00 2001 From: Thastertyn Date: Mon, 10 Mar 2025 18:02:57 +0100 Subject: [PATCH] [rewrite] Major changes to code - switched to postgres, added initial backend docker files --- .env-example | 51 ++++ .vscode/launch.json | 8 +- .vscode/settings.json | 4 +- backend/.env.example | 22 -- backend/Dockerfile | 25 ++ backend/app/api/__init__.py | 10 + backend/app/api/dependencies.py | 48 ++++ .../{dependencies => api/routes}/__init__.py | 0 backend/app/{ => api}/routes/cart_routes.py | 0 backend/app/api/routes/login_routes.py | 37 +++ backend/app/{ => api}/routes/shop_routes.py | 0 backend/app/{ => api}/routes/user_routes.py | 8 +- backend/app/api/routes/utils_routes.py | 20 ++ backend/app/core/__init__.py | 3 - backend/app/core/config.py | 268 ++++++------------ backend/app/core/errors.py | 13 + backend/app/core/exceptions.py | 12 - backend/app/core/security.py | 6 +- backend/app/database/manager.py | 78 ++--- backend/app/database/models/__init__.py | 6 + .../app/{ => database}/models/base_model.py | 0 .../models/disabled/cart_model.py | 0 .../models/disabled/purchase_model.py | 1 + .../models/disabled/user_preferences_model.py | 0 .../models/disabled/wishlist_model.py | 1 + backend/app/database/models/shop_model.py | 69 +++++ backend/app/database/models/user_model.py | 59 ++++ .../repositories/user_repository.py} | 0 backend/app/dependencies.py | 5 +- backend/app/main.py | 51 ++-- backend/app/models/__init__.py | 0 backend/app/models/shop_model.py | 30 -- backend/app/models/user_model.py | 32 --- backend/app/models/user_preferences.py | 11 - backend/app/models/user_role_model.py | 16 -- backend/app/models/user_statistics_model.py | 12 - backend/app/routes/__init__.py | 0 backend/app/schemas/user_schemas.py | 21 +- .../app/services/product/product_helper.py | 1 + .../services/product/product_list_service.py | 1 + backend/app/services/user/delete_service.py | 1 + backend/app/services/user/login_service.py | 1 - backend/app/services/user_service.py | 7 +- .../app/utils/database_exception_catcher.py | 13 - backend/app/utils/logger.py | 13 + backend/app/utils/models.py | 6 + backend/app/utils/propagate.py | 27 ++ backend/app/utils/route_exception_catcher.py | 0 backend/poetry.lock | 228 ++++++++------- backend/pyproject.toml | 11 +- docker-compose.yml | 68 +++++ frontend/src/lib/api.ts | 16 +- 52 files changed, 783 insertions(+), 537 deletions(-) create mode 100644 .env-example delete mode 100644 backend/.env.example create mode 100644 backend/Dockerfile create mode 100644 backend/app/api/__init__.py create mode 100644 backend/app/api/dependencies.py rename backend/app/{dependencies => api/routes}/__init__.py (100%) rename backend/app/{ => api}/routes/cart_routes.py (100%) create mode 100644 backend/app/api/routes/login_routes.py rename backend/app/{ => api}/routes/shop_routes.py (100%) rename backend/app/{ => api}/routes/user_routes.py (89%) create mode 100644 backend/app/api/routes/utils_routes.py create mode 100644 backend/app/core/errors.py delete mode 100644 backend/app/core/exceptions.py create mode 100644 backend/app/database/models/__init__.py rename backend/app/{ => database}/models/base_model.py (100%) rename backend/app/{ => database}/models/disabled/cart_model.py (100%) rename backend/app/{ => database}/models/disabled/purchase_model.py (99%) rename backend/app/{ => database}/models/disabled/user_preferences_model.py (100%) rename backend/app/{ => database}/models/disabled/wishlist_model.py (99%) create mode 100644 backend/app/database/models/shop_model.py create mode 100644 backend/app/database/models/user_model.py rename backend/app/{dependencies/user_depencencies.py => database/repositories/user_repository.py} (100%) delete mode 100644 backend/app/models/__init__.py delete mode 100644 backend/app/models/shop_model.py delete mode 100644 backend/app/models/user_model.py delete mode 100644 backend/app/models/user_preferences.py delete mode 100644 backend/app/models/user_role_model.py delete mode 100644 backend/app/models/user_statistics_model.py delete mode 100644 backend/app/routes/__init__.py delete mode 100644 backend/app/utils/database_exception_catcher.py create mode 100644 backend/app/utils/logger.py create mode 100644 backend/app/utils/models.py create mode 100644 backend/app/utils/propagate.py delete mode 100644 backend/app/utils/route_exception_catcher.py create mode 100644 docker-compose.yml diff --git a/.env-example b/.env-example new file mode 100644 index 0000000..dcebda3 --- /dev/null +++ b/.env-example @@ -0,0 +1,51 @@ +# Port for the app to run on +# PORT=31714 + +# Secret key used for cryptographic operations. Must be a long, random string. +SECRET_KEY= + +# Token expiration time in minutes. Default is 8 days (60 min * 24 hours * 8 days). +# ACCESS_TOKEN_EXPIRE_MINUTES=11520 + +# The environment in which the application is running. +# Options: local, staging, production +ENVIRONMENT=local + +# The frontend host that interacts with the backend. +FRONTEND_HOST= + +# CORS origins allowed to access the backend. +# Multiple values should be comma-separated, e.g., "http://localhost,http://example.com" +BACKEND_CORS_ORIGINS= + +# MySQL database configuration +POSTGRES_SERVER= +POSTGRES_PORT=3306 +POSTGRES_USER= +POSTGRES_PASSWORD= +POSTGRES_DB= + +# SMTP configuration for sending emails +SMTP_HOST= +SMTP_PORT=587 +SMTP_USER= +SMTP_PASSWORD= +SMTP_TLS=True # Use TLS for email security +SMTP_SSL=False # Set to True if using SSL instead of TLS + +# Email sender information +EMAILS_FROM_EMAIL= +EMAILS_FROM_NAME= + +# Expiration time for password reset tokens (in hours) +# EMAIL_RESET_TOKEN_EXPIRE_HOURS=48 + +# A test user email used for automated email testing +EMAIL_TEST_USER=test@example.com + +# Superuser credentials for the first administrator +FIRST_SUPERUSER= +FIRST_SUPERUSER_PASSWORD= + +DOCKER_IMAGE_BACKEND=backend +DOCKER_IMAGE_FRONTEND=frontend \ No newline at end of file diff --git a/.vscode/launch.json b/.vscode/launch.json index 7a00dd6..a027fed 100644 --- a/.vscode/launch.json +++ b/.vscode/launch.json @@ -8,7 +8,13 @@ "cwd": "${workspaceFolder}/backend", "module": "fastapi", "args": ["dev", "${cwd}/backend/app/main.py"], - "console": "internalConsole" + "console": "internalConsole", + "serverReadyAction":{ + "action": "openExternally", + "killOnServerStop": false, + "pattern": "Application startup complete.", + "uriFormat": "http://localhost:8000/docs" + } }, { "name": "Frontend: Dev", diff --git a/.vscode/settings.json b/.vscode/settings.json index 9982655..2cdc63c 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -2,9 +2,9 @@ // #region Backend settings "files.exclude" : { "**/__pycache__/**": true }, // Hide __pycache__ directories "mypy-type-checker.args" : ["--config-file='backend/mypy.ini'"], // Override mypy config - "python.defaultInterpreterPath": "./backend/.venv/bin/python", // Use venv by default + "python.defaultInterpreterPath" : "./backend/.venv/bin/python", // Use venv by default "python.analysis.extraPaths" : ["./backend"], // Pylint - fix for import analysis - "pylint.cwd": "${workspaceFolder}/backend", + "pylint.cwd" : "${workspaceFolder}/backend", // #endregion // #region Frontend settings diff --git a/backend/.env.example b/backend/.env.example deleted file mode 100644 index 31554d1..0000000 --- a/backend/.env.example +++ /dev/null @@ -1,22 +0,0 @@ -# TODO Fill me up - -HOST= -PORT= - -MYSQL_USER= -MYSQL_DATABASE= -MYSQL_HOST= -MYSQL_PORT= -MYSQL_PASSWORD= - -REDIS_HOST= -REDIS_PORT= - -JWT_SECRET_KEY= - -MAIL_SERVER= -MAIL_PORT= -MAIL_USERNAME= -MAIL_PASSWORD= -MAIL_USE_TLS= -MAIL_DEFAULT_SENDER= \ No newline at end of file diff --git a/backend/Dockerfile b/backend/Dockerfile new file mode 100644 index 0000000..46f65b9 --- /dev/null +++ b/backend/Dockerfile @@ -0,0 +1,25 @@ +# Base Image +FROM python:3.13 + +RUN pip install poetry + +# Environment variables +ENV PYTHONUNBUFFERED=1 + +# Set working directory +WORKDIR /app/ + +# Copy dependency files first to leverage caching +COPY pyproject.toml poetry.lock /app/ + +# Copy the rest of the application +COPY ./app /app/app + +# Ensure dependencies are installed correctly +RUN poetry install --no-interaction --no-ansi --without dev + +# Expose port for FastAPI +EXPOSE 8000 + +# Command to run the app +CMD ["poetry", "run", "uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000", "--workers", "4"] diff --git a/backend/app/api/__init__.py b/backend/app/api/__init__.py new file mode 100644 index 0000000..6ba8f42 --- /dev/null +++ b/backend/app/api/__init__.py @@ -0,0 +1,10 @@ +from fastapi import APIRouter + +from app.api.routes import cart_routes, shop_routes, user_routes, utils_routes + +api_router = APIRouter() + +api_router.include_router(cart_routes.router) +api_router.include_router(shop_routes.router) +api_router.include_router(user_routes.router) +api_router.include_router(utils_routes.router) diff --git a/backend/app/api/dependencies.py b/backend/app/api/dependencies.py new file mode 100644 index 0000000..69d2174 --- /dev/null +++ b/backend/app/api/dependencies.py @@ -0,0 +1,48 @@ +from typing import Annotated +from sqlmodel import Session + +from fastapi import Depends, HTTPException, status +from fastapi.security import OAuth2PasswordBearer + +import jwt +from jwt.exceptions import InvalidTokenError + +from pydantic import ValidationError + +from app.core.config import settings +from app.core import security + +from app.database.manager import get_session +from app.database.models.user_model import User + +from app.schemas.user_schemas import TokenPayload + + +reusable_oauth2 = OAuth2PasswordBearer( + tokenUrl="/login/access-token" +) + +SessionDep = Annotated[Session, Depends(get_session)] +TokenDep = Annotated[str, Depends(reusable_oauth2)] + + +def get_current_user(session: SessionDep, token: TokenDep) -> User: + try: + payload = jwt.decode( + token, settings.SECRET_KEY, algorithms=[security.ALGORITHM] + ) + token_data = TokenPayload(**payload) + except (InvalidTokenError, ValidationError): + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="Could not validate credentials", + ) + user = session.get(User, {"uuid": token_data.sub}) + if not user: + raise HTTPException(status_code=404, detail="User not found") + if not user.is_active: + raise HTTPException(status_code=400, detail="Inactive user") + return user + + +CurrentUser = Annotated[User, Depends(get_current_user)] diff --git a/backend/app/dependencies/__init__.py b/backend/app/api/routes/__init__.py similarity index 100% rename from backend/app/dependencies/__init__.py rename to backend/app/api/routes/__init__.py diff --git a/backend/app/routes/cart_routes.py b/backend/app/api/routes/cart_routes.py similarity index 100% rename from backend/app/routes/cart_routes.py rename to backend/app/api/routes/cart_routes.py diff --git a/backend/app/api/routes/login_routes.py b/backend/app/api/routes/login_routes.py new file mode 100644 index 0000000..a9dd21f --- /dev/null +++ b/backend/app/api/routes/login_routes.py @@ -0,0 +1,37 @@ +from typing import Annotated +from datetime import timedelta + +from fastapi import APIRouter, HTTPException, Depends +from fastapi.security import OAuth2PasswordRequestForm + +from app.api.dependencies import SessionDep + +from app.schemas.user_schemas import Token + +from app.core import security +from app.core.config import settings + +router = APIRouter(tags=["login"]) + + +@router.post("/login/access-token") +def login_access_token( + session: SessionDep, form_data: Annotated[OAuth2PasswordRequestForm, Depends()] +) -> Token: + """ + OAuth2 compatible token login, get an access token for future requests + """ + user = None + # user = crud.authenticate( + # session=session, email=form_data.username, password=form_data.password + # ) + if not user: + raise HTTPException(status_code=400, detail="Incorrect email or password") + elif not user: + raise HTTPException(status_code=400, detail="Inactive user") + access_token_expires = timedelta(minutes=settings.ACCESS_TOKEN_EXPIRE_MINUTES) + return Token( + access_token=security.create_access_token( + user.id, expires_delta=access_token_expires + ) + ) diff --git a/backend/app/routes/shop_routes.py b/backend/app/api/routes/shop_routes.py similarity index 100% rename from backend/app/routes/shop_routes.py rename to backend/app/api/routes/shop_routes.py diff --git a/backend/app/routes/user_routes.py b/backend/app/api/routes/user_routes.py similarity index 89% rename from backend/app/routes/user_routes.py rename to backend/app/api/routes/user_routes.py index b49436f..92cfef0 100644 --- a/backend/app/routes/user_routes.py +++ b/backend/app/api/routes/user_routes.py @@ -31,6 +31,7 @@ async def login(login_data: UserLoginSchema): # ) # return Token(access_token=access_token, token_type="bearer") + @router.delete("/logout", summary="User logout") async def logout(): raise NotImplementedError("logout() needs to be implemented.") @@ -38,8 +39,11 @@ async def logout(): @router.post("/register", summary="Register new user") async def register(user_data: UserRegisterSchema): - create_user(user_data) - return {"message": "User registered successfully"} + try: + create_user(user_data) + return {"message": "User registered successfully"} + except BaseException: + return {"message": "An error occured"} @router.put("/update", summary="Update user details") diff --git a/backend/app/api/routes/utils_routes.py b/backend/app/api/routes/utils_routes.py new file mode 100644 index 0000000..9a920f9 --- /dev/null +++ b/backend/app/api/routes/utils_routes.py @@ -0,0 +1,20 @@ +from sqlmodel import select + +from fastapi import APIRouter +from app.api.dependencies import SessionDep + +router = APIRouter(prefix="/utils", tags=["utils"]) + + +@router.get("/health-check/") +async def health_check() -> bool: + return True + +@router.get("/test-db/") +async def test_db(session: SessionDep) -> bool: + try: + with session: + session.exec(select(1)) + return True + except Exception: + return False diff --git a/backend/app/core/__init__.py b/backend/app/core/__init__.py index 0670c23..e69de29 100644 --- a/backend/app/core/__init__.py +++ b/backend/app/core/__init__.py @@ -1,3 +0,0 @@ -from .config import * - -__all__ = [*config.__all__] diff --git a/backend/app/core/config.py b/backend/app/core/config.py index 95c250d..9c872ae 100644 --- a/backend/app/core/config.py +++ b/backend/app/core/config.py @@ -1,205 +1,107 @@ -import os +import secrets +import warnings import logging -import re -from typing import Any, Type, Optional, Callable +from typing import Annotated, Any, Literal -import dotenv - -from .exceptions import ConfigError +from pydantic import ( + AnyUrl, + BeforeValidator, + EmailStr, + PostgresDsn, + computed_field, + model_validator, +) +from pydantic_core import MultiHostUrl +from pydantic_settings import BaseSettings, SettingsConfigDict +from typing_extensions import Self -class EnvConfigField(): - """ - Represents a single config field to be parsed from the environment. +def parse_cors(v: Any) -> list[str] | str: + if isinstance(v, str) and not v.startswith("["): + return [i.strip() for i in v.split(",")] + elif isinstance(v, list | str): + return v + raise ValueError(v) - :param env_name: Name of the environment variable - :param data_type: Expected data type (int, str, etc.) - :param default: Default value if not set in environment - :param required: Whether the variable is required - :param choices: List of allowed values (for enums) - :param min_value: Minimum value (for integers) - :param max_value: Maximum value (for integers) - :param regex: Regex pattern (for string validation) - :param parser: Custom parser function for complex validation - """ - def __init__( - self, - env_name: str, - data_type: Type[Any] = str, - default: Any = None, - required: bool = False, - choices: Optional[list[Any]] = None, - min_value: Optional[int] = None, - max_value: Optional[int] = None, - regex: Optional[str] = None, - parser: Optional[Callable[[str], Any]] = None, - ): - self.env_value = None - self.env_name = env_name - self.data_type = data_type - self.default = default - self.required = required - self.choices = choices - self.min_value = min_value - self.max_value = max_value - self.regex = re.compile(regex) if regex else None - self.parser = parser +class Settings(BaseSettings): + model_config = SettingsConfigDict( + env_file="../.env", + env_ignore_empty=True, + extra="ignore", + ) + PORT: int = 8000 + SECRET_KEY: str = secrets.token_urlsafe(32) - def load(self): - """Loads the value from the environment and validates it.""" - raw_value = os.environ.get(self.env_name) + VERBOSITY: Literal[*logging.getLevelNamesMapping().keys()] = "DEBUG" - if self.required and (raw_value is None or raw_value == ""): - raise ConfigError( - "Field %(env_name)s is required, but was not provided in the environment / .env", - env_name=self.env_name, - ) + # 60 minutes * 24 hours * 8 days = 8 days + ACCESS_TOKEN_EXPIRE_MINUTES: int = 60 * 24 * 8 + FRONTEND_HOST: str + ENVIRONMENT: Literal["local", "staging", "production"] = "local" - self.env_value = self.validate(raw_value) - - def validate(self, value: Any) -> Any: - """Validates the value based on provided constraints.""" - - # Use the default value if available and skip validation - if (value is None or value == "") and self.default is not None: - return self.default - - # Use custom parser if provided - if self.parser: - value = self.parser(value) - - # Convert value to expected data type - if self.data_type == str: - value = self.__parse_to_str(value) - elif self.data_type == int: - value = self.__parse_to_int(value) - elif self.data_type == bool: - value = self.__parse_to_bool(value) - - # Validate allowed choices - if self.choices and value not in self.choices: - raise ConfigError( - "Value for %(env_name)s - %(value)s is not one of the following allowed values: %(allowed_values)s", - env_name=self.env_name, - value=value, - allowed_values=", ".join(map(str, self.choices)), - ) - - return value + BACKEND_CORS_ORIGINS: Annotated[ + list[AnyUrl] | str, BeforeValidator(parse_cors) + ] = [] + @computed_field @property - def value(self): - return self.env_value + def all_cors_origins(self) -> list[str]: + return [str(origin).rstrip("/") for origin in self.BACKEND_CORS_ORIGINS] + [self.FRONTEND_HOST] - @value.setter - def value(self, new): - raise TypeError("Cannot modify") + POSTGRES_SERVER: str + POSTGRES_PORT: int = 5432 + POSTGRES_USER: str + POSTGRES_PASSWORD: str = "" + POSTGRES_DB: str = "" - def __parse_to_str(self, value) -> str: - """Parses and validates string values.""" - value = str(value) + @computed_field + @property + def SQLALCHEMY_DATABASE_URI(self) -> PostgresDsn: + return MultiHostUrl.build( + scheme='postgresql+psycopg2', + username=self.POSTGRES_USER, + password=self.POSTGRES_PASSWORD, + host=self.POSTGRES_SERVER, + port=self.POSTGRES_PORT, + path=self.POSTGRES_DB, + ) - if self.regex and not self.regex.match(value): - raise ConfigError( - "Value for %(env_name)s - %(value)s does not match the expected pattern: %(regex)s", - env_name=self.env_name, - value=value, - regex=self.regex.pattern, - ) + SMTP_TLS: bool = True + SMTP_SSL: bool = False + SMTP_PORT: int = 587 + SMTP_HOST: str | None = None + SMTP_USER: str | None = None + SMTP_PASSWORD: str | None = None + EMAILS_FROM_EMAIL: EmailStr | None = None + EMAILS_FROM_NAME: EmailStr | None = None - return value + EMAIL_RESET_TOKEN_EXPIRE_HOURS: int = 48 - def __parse_to_int(self, value) -> int: - """Parses and validates integer values.""" - if not str(value).isdigit(): - raise ConfigError( - "Value for %(env_name)s - %(value)s must be a numeric integer", - env_name=self.env_name, - value=value, - ) + @computed_field + @property + def emails_enabled(self) -> bool: + return bool(self.SMTP_HOST and self.EMAILS_FROM_EMAIL) - value = int(value) + EMAIL_TEST_USER: EmailStr = "test@example.com" + FIRST_SUPERUSER: EmailStr + FIRST_SUPERUSER_PASSWORD: str - if self.min_value is not None and value < self.min_value: - raise ConfigError( - "Value for %(env_name)s - %(value)d is too small. Must be greater than or equal to %(min_value)d", - env_name=self.env_name, - value=value, - min_value=self.min_value, - ) + def _check_default_secret(self, var_name: str, value: str | None) -> None: + if value == "changethis": + message = f'The value of {var_name} is "changethis", '"for security, please change it, at least for deployments." + if self.ENVIRONMENT == "local": + warnings.warn(message, stacklevel=1) + else: + raise ValueError(message) - if self.max_value is not None and value > self.max_value: - raise ConfigError( - "Value for %(env_name)s - %(value)d is too large. Must be smaller than or equal to %(max_value)d", - env_name=self.env_name, - value=value, - max_value=self.max_value, - ) + @model_validator(mode="after") + def _enforce_non_default_secrets(self) -> Self: + self._check_default_secret("SECRET_KEY", self.SECRET_KEY) + self._check_default_secret("POSTGRES_PASSWORD", self.POSTGRES_PASSWORD) + self._check_default_secret("FIRST_SUPERUSER_PASSWORD", self.FIRST_SUPERUSER_PASSWORD) - return value - - def __parse_to_bool(self, value): - value = str(value).lower() - - true_values = ["1", "true", "yes"] - false_values = ["0", "false", "no"] - - if value in true_values: - return True - if value in false_values: - return False - - raise ConfigError("Value for %(env_name)s - %(value)s cannot be converted to a true / false value", - env_name=self.env_name, - value=value) + return self -class EnvConfig(): - - _instance = None - _initialized = False - - def __new__(cls): - print("Called __new__") - if cls._instance is None: - cls._instance = super().__new__(cls) - return cls._instance - - def __init__(self): - if EnvConfig._initialized: - return - - self.logger = logging.getLogger(__name__) - - self.fields = {} - - EnvConfig._initialized = True - - def add_field(self, name: str, field: EnvConfigField): - if name in self.fields: - return - - self.fields[name] = field - - def load_config(self): - dotenv.load_dotenv() - - for field in self.fields.values(): - try: - field.load() - except ConfigError as e: - self.logger.error("Configuration error: %s", e) - raise - - def __getitem__(self, key: str): - return self.fields[key].value - - def __contains__(self, key: str) -> bool: - return key in self.fields - - def to_dict(self): - return {attr: getattr(self, attr) for attr in self.fields} - - -__all__ = ["EnvConfig"] +settings = Settings() diff --git a/backend/app/core/errors.py b/backend/app/core/errors.py new file mode 100644 index 0000000..4e4a1b4 --- /dev/null +++ b/backend/app/core/errors.py @@ -0,0 +1,13 @@ +class SwagShopError(Exception): + """ + Generic error to be raised throughout the entire app. + All other custom errors extend this class + """ + + +class RepositoryError(SwagShopError): + """Generic error occuring in a repository""" + + +class ServiceError(SwagShopError): + """Generic error occuring in a service""" diff --git a/backend/app/core/exceptions.py b/backend/app/core/exceptions.py deleted file mode 100644 index b61337a..0000000 --- a/backend/app/core/exceptions.py +++ /dev/null @@ -1,12 +0,0 @@ -class SwagShopError(Exception): - def __init__(self, message): - super().__init__(message) - self.message = message - -class ConfigError(SwagShopError): - def __init__(self, message, **kwargs): - formatted_message = message % kwargs - super().__init__(formatted_message) - self.message = formatted_message - -__all__ = ["SwagShopError", "ConfigError"] diff --git a/backend/app/core/security.py b/backend/app/core/security.py index e79026a..f2d1548 100644 --- a/backend/app/core/security.py +++ b/backend/app/core/security.py @@ -5,7 +5,7 @@ import jwt from passlib.context import CryptContext -from app.core.config import EnvConfig +from app.core.config import settings pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto") @@ -14,11 +14,9 @@ ALGORITHM = "HS256" def create_access_token(subject: str | Any, expires_delta: timedelta) -> str: - config = EnvConfig() - expire = datetime.now(timezone.utc) + expires_delta to_encode = {"exp": expire, "sub": str(subject)} - encoded_jwt = jwt.encode(to_encode, config["secret_key"], algorithm=ALGORITHM) + encoded_jwt = jwt.encode(to_encode, settings.SECRET_KEY, algorithm=ALGORITHM) return encoded_jwt diff --git a/backend/app/database/manager.py b/backend/app/database/manager.py index aeec73d..a1b3699 100644 --- a/backend/app/database/manager.py +++ b/backend/app/database/manager.py @@ -1,63 +1,43 @@ import logging -from typing import Generator from contextlib import contextmanager +from typing import Generator -from sqlalchemy.orm import sessionmaker, Session -from sqlalchemy import create_engine, text +from sqlalchemy.exc import DatabaseError as SqlAlchemyError +from sqlmodel import Session, create_engine, select, SQLModel + +from app.core.config import settings from app.database.exceptions import DatabaseError +import app.database.models -class DatabaseManager(): +logger = logging.getLogger(__name__) - _instance: 'DatabaseManager' = None - - def __new__(cls, mysql_user: str, mysql_password: str, mysql_host: str, mysql_port: int, mysql_db_name: str): - if cls._instance is None: - cls._instance = super().__new__(cls) - return cls._instance +logger.info("Creating engine") +engine = create_engine(str(settings.SQLALCHEMY_DATABASE_URI)) +SQLModel.metadata.create_all(engine) - def __init__(self, mysql_user: str, mysql_password: str, mysql_host: str, mysql_port: int, mysql_db_name: str) -> None: - self.logger = logging.getLogger(__name__) - self.logger.info("Reading database config") - self.engine = create_engine('mysql+mysqlconnector://%s:%s@%s:%s/%s' % ( - mysql_user, - mysql_password, - mysql_host, - str(mysql_port), - mysql_db_name), - pool_pre_ping=True) - self.test_connection() - self.Session = sessionmaker(bind=self.engine) # NOSONAR - - def cleanup(self) -> None: - self.logger.debug("Closing connection") - self.engine.dispose() - - def test_connection(self): - self.logger.debug("Testing database connection") - try: - with self.engine.connect() as connection: - connection.execute(text("select 1")) - self.logger.debug("Database connection successful") - except DatabaseError as e: - self.logger.critical("Database connection failed: %s", e) - raise DatabaseError("Database connection failed", DatabaseError.CONNECTION_ERROR) from e +def test_connection(): + logger.debug("Testing database connection") + try: + with Session(engine) as session: + session.exec(select(1)) + logger.debug("Database connection successful") + except SqlAlchemyError as e: + logger.critical("Database connection failed: %s", e) + raise DatabaseError("Database connection failed", DatabaseError.CONNECTION_ERROR) from e - @classmethod - @contextmanager - def get_session(cls) -> Generator[Session, None, None]: - session = cls._instance.Session() - try: - yield session - except Exception as e: - session.rollback() - cls._instance.logger.error("Transaction failed: %s", e) - raise - finally: - session.close() +def cleanup() -> None: + logger.debug("Closing connection") + engine.dispose() -__all__ = ["DatabaseManager"] +@contextmanager +def get_session() -> Generator[Session, None, None]: + with Session(engine) as session: + yield session + + +test_connection() diff --git a/backend/app/database/models/__init__.py b/backend/app/database/models/__init__.py new file mode 100644 index 0000000..32b49e9 --- /dev/null +++ b/backend/app/database/models/__init__.py @@ -0,0 +1,6 @@ +from . import user_model, shop_model + +__all__ = [ + *user_model.__all__, + *shop_model.__all__ +] diff --git a/backend/app/models/base_model.py b/backend/app/database/models/base_model.py similarity index 100% rename from backend/app/models/base_model.py rename to backend/app/database/models/base_model.py diff --git a/backend/app/models/disabled/cart_model.py b/backend/app/database/models/disabled/cart_model.py similarity index 100% rename from backend/app/models/disabled/cart_model.py rename to backend/app/database/models/disabled/cart_model.py diff --git a/backend/app/models/disabled/purchase_model.py b/backend/app/database/models/disabled/purchase_model.py similarity index 99% rename from backend/app/models/disabled/purchase_model.py rename to backend/app/database/models/disabled/purchase_model.py index 16e1702..4241bf3 100644 --- a/backend/app/models/disabled/purchase_model.py +++ b/backend/app/database/models/disabled/purchase_model.py @@ -3,6 +3,7 @@ from sqlalchemy.sql import func from sqlalchemy.orm import relationship from .base_model import Base + class Purchase(Base): __tablename__ = "purchase" diff --git a/backend/app/models/disabled/user_preferences_model.py b/backend/app/database/models/disabled/user_preferences_model.py similarity index 100% rename from backend/app/models/disabled/user_preferences_model.py rename to backend/app/database/models/disabled/user_preferences_model.py diff --git a/backend/app/models/disabled/wishlist_model.py b/backend/app/database/models/disabled/wishlist_model.py similarity index 99% rename from backend/app/models/disabled/wishlist_model.py rename to backend/app/database/models/disabled/wishlist_model.py index b24af05..8e6dc71 100644 --- a/backend/app/models/disabled/wishlist_model.py +++ b/backend/app/database/models/disabled/wishlist_model.py @@ -3,6 +3,7 @@ from sqlalchemy.orm import relationship from sqlalchemy.sql import func from .base_model import Base + class Wishlist(Base): __tablename__ = "wishlist" diff --git a/backend/app/database/models/shop_model.py b/backend/app/database/models/shop_model.py new file mode 100644 index 0000000..c2188ee --- /dev/null +++ b/backend/app/database/models/shop_model.py @@ -0,0 +1,69 @@ +from enum import Enum as PyEnum +from typing import Optional, List +from datetime import datetime, time + +from sqlalchemy import Column, CheckConstraint +from sqlalchemy.dialects.postgresql import JSONB + +from sqlmodel import SQLModel, Field, Relationship, Enum +from pydantic import BaseModel, constr + + +class ShopStatus(PyEnum): + ACTIVE = "active" + INACTIVE = "inactive" + SUSPENDED = "suspended" + + +class ShopLinkEntry(BaseModel): + name: constr(strip_whitespace=True, min_length=1) + url: constr(strip_whitespace=True, min_length=1) + + +class ShopLinks(BaseModel): + links: List[ShopLinkEntry] + + +class ShopBusinessHourEntry(BaseModel): + day: constr(strip_whitespace=True, min_length=1) + open_time: time + close_time: time + + +class ShopBusinessHours(BaseModel): + hours: List[ShopBusinessHourEntry] + + +class Shop(SQLModel, table=True): + __tablename__ = 'shop' + + id: Optional[int] = Field(default=None, primary_key=True) + owner_id: int = Field(foreign_key='user.id', nullable=False) + name: str = Field(max_length=100, nullable=False, unique=True) + description: str = Field(max_length=500, nullable=False) + created_at: datetime = Field(default_factory=datetime.utcnow, nullable=False) + updated_at: datetime = Field(default_factory=datetime.utcnow, nullable=False) + status: ShopStatus = Field(sa_column=Column(Enum(ShopStatus))) + logo: Optional[str] = Field(max_length=100) + contact_email: str = Field(max_length=128, nullable=False, unique=True) + phone_number: str = Field(max_length=15, nullable=False) + address: str = Field(nullable=False) + currency: str = Field(max_length=3, nullable=False) + + business_hours: ShopBusinessHours = Field( + sa_column=Column(JSONB, nullable=False, default=lambda: {}) + ) + links: ShopLinks = Field( + 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') + + __table_args__ = ( + CheckConstraint("business_hours ? 'hours'", name="check_business_hours_keys"), + CheckConstraint("links ? 'links'", name="check_links_keys"), + ) + + +__all__ = ["Shop", "ShopBusinessHours", "ShopLinks", "ShopStatus"] diff --git a/backend/app/database/models/user_model.py b/backend/app/database/models/user_model.py new file mode 100644 index 0000000..1eafb88 --- /dev/null +++ b/backend/app/database/models/user_model.py @@ -0,0 +1,59 @@ +from typing import Optional, List +from uuid import UUID +from datetime import datetime + +from sqlmodel import SQLModel, Field, Relationship + + +class User(SQLModel, table=True): + __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) + shop_id: Optional[int] = Field(foreign_key="shop.id") + 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) + first_name: Optional[str] = Field(max_length=64) + last_name: Optional[str] = Field(max_length=64) + phone_number: str = Field(max_length=15, nullable=False) + profile_picture: Optional[str] = Field(max_length=100) + created_at: datetime = Field(default_factory=datetime.utcnow, nullable=False) + 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") + + role: Optional["UserRole"] = Relationship(back_populates="users") + preferences: Optional["UserPreferences"] = Relationship(back_populates="user") + statistics: Optional["UserStatistics"] = Relationship(back_populates="user_statistics") + + +class UserPreferences(SQLModel, table=True): + __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) + + users = Relationship(back_populates="role") + + +class UserStatistics(SQLModel, table=True): + __tablename__ = "user_statistics" + + user_id: int = Field(foreign_key="user.id", primary_key=True) + total_spend: Optional[float] = Field(default=None) + + user: Optional["User"] = Relationship(back_populates="statistics") + +__all__ = ["User", "UserPreferences", "UserRole"] diff --git a/backend/app/dependencies/user_depencencies.py b/backend/app/database/repositories/user_repository.py similarity index 100% rename from backend/app/dependencies/user_depencencies.py rename to backend/app/database/repositories/user_repository.py diff --git a/backend/app/dependencies.py b/backend/app/dependencies.py index 8716ea3..9c1aed3 100644 --- a/backend/app/dependencies.py +++ b/backend/app/dependencies.py @@ -14,6 +14,7 @@ class LoginRequest(BaseModel): username: str password: str + async def get_jsession_id(request: Request): jsessionid = ( request.headers.get("JSESSIONID") @@ -26,11 +27,11 @@ async def get_jsession_id(request: Request): 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") \ No newline at end of file + raise HTTPException(status_code=400, detail="No Jessica token provided") diff --git a/backend/app/main.py b/backend/app/main.py index 90d38bd..5454611 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -1,46 +1,33 @@ from fastapi import FastAPI -from app.routes.cart_routes import router as cart_router -from app.routes.user_routes import router as user_router -from app.routes.shop_routes import router as shop_router +from fastapi.routing import APIRoute -from app.core.config import EnvConfig, EnvConfigField +from starlette.middleware.cors import CORSMiddleware -from app.database.manager import DatabaseManager +from app.utils import logger +from app.api import api_router +from app.core.config import settings -config = EnvConfig() +logger.setup_logger() -config.add_field("mysql_user", EnvConfigField("MYSQL_USER", required=True)) -config.add_field("mysql_database", EnvConfigField("MYSQL_DATABASE", required=True)) -config.add_field("mysql_host", EnvConfigField("MYSQL_HOST", required=True)) -config.add_field("mysql_port", EnvConfigField("MYSQL_PORT", default=3306, data_type=int, required=False)) -config.add_field("mysql_password", EnvConfigField("MYSQL_PASSWORD", required=True)) - -config.load_config() - - -config2 = EnvConfig() - - -DatabaseManager( - mysql_user=config["mysql_user"], - mysql_password=config["mysql_password"], - mysql_host=config["mysql_host"], - mysql_port=config["mysql_port"], - mysql_db_name=config["mysql_database"], -) +def custom_generate_unique_id(route: APIRoute) -> str: + return f"{route.tags[0]}-{route.name}" app = FastAPI( title="SWAG Shop", - version="0.0.1" + version="0.0.1", + generate_unique_id_function=custom_generate_unique_id ) -app.include_router(user_router) -app.include_router(cart_router) -app.include_router(shop_router) +if settings.all_cors_origins: + app.add_middleware( + CORSMiddleware, + allow_origins=settings.all_cors_origins, + allow_credentials=True, + allow_methods=["*"], + allow_headers=["*"], + ) -@app.get("/") -async def root(): - return {"message": "Hello World"} +app.include_router(api_router) diff --git a/backend/app/models/__init__.py b/backend/app/models/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/backend/app/models/shop_model.py b/backend/app/models/shop_model.py deleted file mode 100644 index f784c4c..0000000 --- a/backend/app/models/shop_model.py +++ /dev/null @@ -1,30 +0,0 @@ -from sqlalchemy import ForeignKey, Column, Integer, JSON, TIMESTAMP, String, Enum, Text -from sqlalchemy.orm import relationship - -from .base_model import Base - - -class Shop(Base): - __tablename__ = 'shop' - - id = Column(Integer, primary_key=True) - owner_id = Column(Integer, ForeignKey('user.id'), nullable=False) - name = Column(String(100), unique=True, nullable=False) - description = Column(String(500), nullable=False) - created_at = Column(TIMESTAMP, default='CURRENT_TIMESTAMP') - updated_at = Column(TIMESTAMP, default='CURRENT_TIMESTAMP') - status = Column(Enum('active', 'inactive', 'suspended'), nullable=False) - logo = Column(String(100)) - contact_email = Column(String(128), unique=True, nullable=False) - phone_number = Column(String(15), nullable=False) - address = Column(Text, nullable=False) - currency = Column(String(3), nullable=False) - business_hours = Column(JSON, nullable=False) - links = Column(JSON, nullable=False) - - owner = relationship('User', back_populates='owned_shops') - registered_users = relationship('User', back_populates='registered_shop') - # products = relationship('Product', back_populates='shop') - - -__all__ = ["Shop"] diff --git a/backend/app/models/user_model.py b/backend/app/models/user_model.py deleted file mode 100644 index f54411b..0000000 --- a/backend/app/models/user_model.py +++ /dev/null @@ -1,32 +0,0 @@ -from sqlalchemy import Column, String, Integer, ForeignKey, TIMESTAMP -from sqlalchemy.dialects.mysql import INTEGER -from sqlalchemy.orm import relationship -from .base_model import Base - - -class User(Base): - __tablename__ = 'user' - - id = Column(INTEGER(unsigned=True), primary_key=True, autoincrement=True) - user_role_id = Column(INTEGER(unsigned=True), ForeignKey('user_role.id'), nullable=False) - shop_id = Column(Integer, ForeignKey('shop.id'), nullable=True) - username = Column(String(64), nullable=False, unique=True) - email = Column(String(128), nullable=False, unique=True) - password = Column(String(60), nullable=False) - first_name = Column(String(64), nullable=True) - last_name = Column(String(64), nullable=True) - phone_number = Column(String(15), nullable=False) - profile_picture = Column(String(100), nullable=True) - created_at = Column(TIMESTAMP, nullable=False, server_default="CURRENT_TIMESTAMP") - updated_at = Column(TIMESTAMP, nullable=False, server_default="CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP") - last_login = Column(TIMESTAMP, nullable=True, server_default="CURRENT_TIMESTAMP") - - owned_shops = relationship("Shop", back_populates="owner") - registered_shop = relationship("Shop", back_populates="registered_users") - - role = relationship("UserRole", back_populates="users") - preferences = relationship("UserPreferences", uselist=False, back_populates="user") - statistics = relationship("UserStatistics", uselist=False, back_populates="user_statistics") - - -__all__ = ["User"] diff --git a/backend/app/models/user_preferences.py b/backend/app/models/user_preferences.py deleted file mode 100644 index 50df982..0000000 --- a/backend/app/models/user_preferences.py +++ /dev/null @@ -1,11 +0,0 @@ -from sqlalchemy import Column, ForeignKey -from sqlalchemy.dialects.mysql import INTEGER -from sqlalchemy.orm import relationship -from .base_model import Base - -class UserPreferences(Base): - __tablename__ = 'user_preferences' - - user_id = Column(INTEGER(unsigned=True), ForeignKey('user.id'), primary_key=True) - - user = relationship("User", back_populates="preferences") \ No newline at end of file diff --git a/backend/app/models/user_role_model.py b/backend/app/models/user_role_model.py deleted file mode 100644 index 4a1e01c..0000000 --- a/backend/app/models/user_role_model.py +++ /dev/null @@ -1,16 +0,0 @@ -from sqlalchemy import Column, String -from sqlalchemy.dialects.mysql import INTEGER -from sqlalchemy.orm import relationship -from .base_model import Base - - -class UserRole(Base): - __tablename__ = 'user_role' - - id = Column(INTEGER(unsigned=True), primary_key=True, autoincrement=True) - name = Column(String(45), nullable=False, unique=True) - - users = relationship("User", back_populates="role") - - -__all__ = ["UserRole"] diff --git a/backend/app/models/user_statistics_model.py b/backend/app/models/user_statistics_model.py deleted file mode 100644 index d1980b2..0000000 --- a/backend/app/models/user_statistics_model.py +++ /dev/null @@ -1,12 +0,0 @@ -from sqlalchemy import Column, Float, ForeignKey -from sqlalchemy.dialects.mysql import INTEGER -from sqlalchemy.orm import relationship -from .base_model import Base - -class UserStatistics(Base): - __tablename__ = "user_statistics" - - user_id = Column(INTEGER(unsigned=True), ForeignKey("user.id", ondelete="CASCADE"), primary_key=True) - total_spend = Column(Float, nullable=True) - - user = relationship("User", back_populates="user_statistics", foreign_keys=[user_id]) diff --git a/backend/app/routes/__init__.py b/backend/app/routes/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/backend/app/schemas/user_schemas.py b/backend/app/schemas/user_schemas.py index f31afea..d63b6ee 100644 --- a/backend/app/schemas/user_schemas.py +++ b/backend/app/schemas/user_schemas.py @@ -1,7 +1,8 @@ -from pydantic import BaseModel, EmailStr, Field +from sqlmodel import Field as SqlModelField, SQLModel +from pydantic import EmailStr, Field -class UserRegisterSchema(BaseModel): +class UserRegisterSchema(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}$') @@ -9,9 +10,19 @@ class UserRegisterSchema(BaseModel): shop_id: int = 0 class Config: - orm_mode = True + from_attributes = True -class UserLoginSchema(BaseModel): + +class UserLoginSchema(SQLModel): shop_id: int = 0 username: str = Field(..., min_length=3, max_length=64) - password: str = Field(..., min_length=6, max_length=128) \ No newline at end of file + password: str = Field(..., min_length=6, max_length=128) + + +class Token(SQLModel): + access_token: str + token_type: str = "bearer" + + +class TokenPayload(SQLModel): + sub: str | None = None diff --git a/backend/app/services/product/product_helper.py b/backend/app/services/product/product_helper.py index 55bc48e..0c02b48 100644 --- a/backend/app/services/product/product_helper.py +++ b/backend/app/services/product/product_helper.py @@ -1,5 +1,6 @@ import imghdr + def is_base64_jpg(decoded_string) -> bool: try: image_type = imghdr.what(None, decoded_string) diff --git a/backend/app/services/product/product_list_service.py b/backend/app/services/product/product_list_service.py index ad0ee77..0a18252 100644 --- a/backend/app/services/product/product_list_service.py +++ b/backend/app/services/product/product_list_service.py @@ -5,6 +5,7 @@ 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) diff --git a/backend/app/services/user/delete_service.py b/backend/app/services/user/delete_service.py index ecee684..43189db 100644 --- a/backend/app/services/user/delete_service.py +++ b/backend/app/services/user/delete_service.py @@ -11,6 +11,7 @@ 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. diff --git a/backend/app/services/user/login_service.py b/backend/app/services/user/login_service.py index c57b01b..f06f063 100644 --- a/backend/app/services/user/login_service.py +++ b/backend/app/services/user/login_service.py @@ -13,7 +13,6 @@ 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. diff --git a/backend/app/services/user_service.py b/backend/app/services/user_service.py index 1eee34c..98b490a 100644 --- a/backend/app/services/user_service.py +++ b/backend/app/services/user_service.py @@ -1,13 +1,12 @@ -from app.database.manager import DatabaseManager -from app.models.user_model import User +from app.database.models.user_model import User from app.schemas.user_schemas import UserRegisterSchema +from app.core.security import get_password_hash def create_user(user_data: UserRegisterSchema): - print("Creating user") - return + print("Creating account") with DatabaseManager.get_session() as session: user = User( username=user_data.username, diff --git a/backend/app/utils/database_exception_catcher.py b/backend/app/utils/database_exception_catcher.py deleted file mode 100644 index 3d85954..0000000 --- a/backend/app/utils/database_exception_catcher.py +++ /dev/null @@ -1,13 +0,0 @@ -from functools import wraps -from sqlalchemy.exc import DatabaseError as SqlAlchemyDatabaseError -from app.database.exceptions import DatabaseError - - -def handle_database_errors(func): - @wraps(func) - def wrapper(*args, **kwargs): - try: - return func(*args, **kwargs) - except SqlAlchemyDatabaseError as e: - raise DatabaseError(str(e), -1) from e - return wrapper diff --git a/backend/app/utils/logger.py b/backend/app/utils/logger.py new file mode 100644 index 0000000..c4c3d94 --- /dev/null +++ b/backend/app/utils/logger.py @@ -0,0 +1,13 @@ +import sys +import logging + +from app.core.config import settings + +def setup_logger(): + verbosity = settings.VERBOSITY + if verbosity == "DEBUG": + log_format = "[ %(name)s / %(levelname)s ] - %(filename)s:%(lineno)d - %(message)s" + else: + log_format = "[ %(levelname)s ] - %(message)s" + + logging.basicConfig(level=verbosity, format=log_format, stream=sys.stdout) diff --git a/backend/app/utils/models.py b/backend/app/utils/models.py new file mode 100644 index 0000000..c214504 --- /dev/null +++ b/backend/app/utils/models.py @@ -0,0 +1,6 @@ +from typing import Optional +from uuid import uuid5, UUID, NAMESPACE_DNS + +def generate_user_uuid5(email: str, shop_id: Optional[int]) -> UUID: + unique_string = f"{email}-{shop_id}" if shop_id else email + return uuid5(NAMESPACE_DNS, unique_string) diff --git a/backend/app/utils/propagate.py b/backend/app/utils/propagate.py new file mode 100644 index 0000000..bfddc24 --- /dev/null +++ b/backend/app/utils/propagate.py @@ -0,0 +1,27 @@ +from functools import wraps + +from fastapi import HTTPException + +from sqlalchemy.exc import DatabaseError as SqlAlchemyDatabaseError + +from app.core.errors import RepositoryError, ServiceError + + +def propagate_db_error_to_service(func): + @wraps(func) + def wrapper(*args, **kwargs): + try: + return func(*args, **kwargs) + except (SqlAlchemyDatabaseError, RepositoryError) as e: + raise ServiceError(str(e)) from e + return wrapper + + +def propagate_service_errors_to_http_errors(func): + @wraps(func) + def wrapper(*args, **kwargs): + try: + return func(*args, **kwargs) + except ServiceError as e: + raise HTTPException(status_code=500, detail=str(e)) from e + return wrapper diff --git a/backend/app/utils/route_exception_catcher.py b/backend/app/utils/route_exception_catcher.py deleted file mode 100644 index e69de29..0000000 diff --git a/backend/poetry.lock b/backend/poetry.lock index 0a16553..a582d74 100644 --- a/backend/poetry.lock +++ b/backend/poetry.lock @@ -43,6 +43,20 @@ files = [ {file = "astroid-3.3.8.tar.gz", hash = "sha256:a88c7994f914a4ea8572fac479459f4955eeccc877be3f2d959a33273b0cf40b"}, ] +[[package]] +name = "autopep8" +version = "2.3.2" +description = "A tool that automatically formats Python code to conform to the PEP 8 style guide" +optional = false +python-versions = ">=3.9" +files = [ + {file = "autopep8-2.3.2-py2.py3-none-any.whl", hash = "sha256:ce8ad498672c845a0c3de2629c15b635ec2b05ef8177a6e7c91c74f3e9b51128"}, + {file = "autopep8-2.3.2.tar.gz", hash = "sha256:89440a4f969197b69a995e4ce0661b031f455a9f776d2c5ba3dbd83466931758"}, +] + +[package.dependencies] +pycodestyle = ">=2.12.0" + [[package]] name = "bcrypt" version = "4.2.1" @@ -92,17 +106,6 @@ files = [ {file = "certifi-2025.1.31.tar.gz", hash = "sha256:3d5da6925056f6f18f119200434a4780a94263f10d1c21d032a6f6b2baa20651"}, ] -[[package]] -name = "cfgv" -version = "3.4.0" -description = "Validate configuration and produce human readable error messages." -optional = false -python-versions = ">=3.8" -files = [ - {file = "cfgv-3.4.0-py2.py3-none-any.whl", hash = "sha256:b7265b1f29fd3316bfcd2b330d63d024f2bfd8bcb8b0272f8e19a504856c48f9"}, - {file = "cfgv-3.4.0.tar.gz", hash = "sha256:e52591d4c5f5dead8e0f673fb16db7949d2cfb3f7da4582893288f0ded8fe560"}, -] - [[package]] name = "click" version = "8.1.8" @@ -143,17 +146,6 @@ files = [ graph = ["objgraph (>=1.7.2)"] profile = ["gprof2dot (>=2022.7.29)"] -[[package]] -name = "distlib" -version = "0.3.9" -description = "Distribution utilities" -optional = false -python-versions = "*" -files = [ - {file = "distlib-0.3.9-py2.py3-none-any.whl", hash = "sha256:47f8c22fd27c27e25a65601af709b38e4f0a45ea4fc2e710f65755fa8caaaf87"}, - {file = "distlib-0.3.9.tar.gz", hash = "sha256:a60f20dea646b8a33f3e7772f74dc0b2d0772d2837ee1342a00645c81edf9403"}, -] - [[package]] name = "dnspython" version = "2.7.0" @@ -234,22 +226,6 @@ uvicorn = {version = ">=0.15.0", extras = ["standard"]} [package.extras] standard = ["uvicorn[standard] (>=0.15.0)"] -[[package]] -name = "filelock" -version = "3.16.1" -description = "A platform independent file lock." -optional = false -python-versions = ">=3.8" -files = [ - {file = "filelock-3.16.1-py3-none-any.whl", hash = "sha256:2082e5703d51fbf98ea75855d9d5527e33d8ff23099bec374a134febee6946b0"}, - {file = "filelock-3.16.1.tar.gz", hash = "sha256:c249fbfcd5db47e5e2d6d62198e565475ee65e4831e2561c8e313fa7eb961435"}, -] - -[package.extras] -docs = ["furo (>=2024.8.6)", "sphinx (>=8.0.2)", "sphinx-autodoc-typehints (>=2.4.1)"] -testing = ["covdefaults (>=2.3)", "coverage (>=7.6.1)", "diff-cover (>=9.2)", "pytest (>=8.3.3)", "pytest-asyncio (>=0.24)", "pytest-cov (>=5)", "pytest-mock (>=3.14)", "pytest-timeout (>=2.3.1)", "virtualenv (>=20.26.4)"] -typing = ["typing-extensions (>=4.12.2)"] - [[package]] name = "greenlet" version = "3.1.1" @@ -447,20 +423,6 @@ http2 = ["h2 (>=3,<5)"] socks = ["socksio (==1.*)"] zstd = ["zstandard (>=0.18.0)"] -[[package]] -name = "identify" -version = "2.6.5" -description = "File identification library for Python" -optional = false -python-versions = ">=3.9" -files = [ - {file = "identify-2.6.5-py2.py3-none-any.whl", hash = "sha256:14181a47091eb75b337af4c23078c9d09225cd4c48929f521f3bf16b09d02566"}, - {file = "identify-2.6.5.tar.gz", hash = "sha256:c10b33f250e5bba374fae86fb57f3adcebf1161bce7cdf92031915fd480c13bc"}, -] - -[package.extras] -license = ["ukkonen"] - [[package]] name = "idna" version = "3.10" @@ -633,17 +595,6 @@ files = [ {file = "mysql-connector-2.2.9.tar.gz", hash = "sha256:1733e6ce52a049243de3264f1fbc22a852cb35458c4ad739ba88189285efdf32"}, ] -[[package]] -name = "nodeenv" -version = "1.9.1" -description = "Node.js virtual environment builder" -optional = false -python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,!=3.5.*,!=3.6.*,>=2.7" -files = [ - {file = "nodeenv-1.9.1-py2.py3-none-any.whl", hash = "sha256:ba11c9782d29c27c70ffbdda2d7415098754709be8a7056d79a737cd901155c9"}, - {file = "nodeenv-1.9.1.tar.gz", hash = "sha256:6ec12890a2dab7946721edbfbcd91f3319c6ccc9aec47be7c7e6b7011ee6645f"}, -] - [[package]] name = "passlib" version = "1.7.4" @@ -681,22 +632,92 @@ test = ["appdirs (==1.4.4)", "covdefaults (>=2.3)", "pytest (>=8.3.2)", "pytest- type = ["mypy (>=1.11.2)"] [[package]] -name = "pre-commit" -version = "4.0.1" -description = "A framework for managing and maintaining multi-language pre-commit hooks." +name = "psycopg2-binary" +version = "2.9.10" +description = "psycopg2 - Python-PostgreSQL Database Adapter" optional = false -python-versions = ">=3.9" +python-versions = ">=3.8" files = [ - {file = "pre_commit-4.0.1-py2.py3-none-any.whl", hash = "sha256:efde913840816312445dc98787724647c65473daefe420785f885e8ed9a06878"}, - {file = "pre_commit-4.0.1.tar.gz", hash = "sha256:80905ac375958c0444c65e9cebebd948b3cdb518f335a091a670a89d652139d2"}, + {file = "psycopg2-binary-2.9.10.tar.gz", hash = "sha256:4b3df0e6990aa98acda57d983942eff13d824135fe2250e6522edaa782a06de2"}, + {file = "psycopg2_binary-2.9.10-cp310-cp310-macosx_12_0_x86_64.whl", hash = "sha256:0ea8e3d0ae83564f2fc554955d327fa081d065c8ca5cc6d2abb643e2c9c1200f"}, + {file = "psycopg2_binary-2.9.10-cp310-cp310-macosx_14_0_arm64.whl", hash = "sha256:3e9c76f0ac6f92ecfc79516a8034a544926430f7b080ec5a0537bca389ee0906"}, + {file = "psycopg2_binary-2.9.10-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2ad26b467a405c798aaa1458ba09d7e2b6e5f96b1ce0ac15d82fd9f95dc38a92"}, + {file = "psycopg2_binary-2.9.10-cp310-cp310-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:270934a475a0e4b6925b5f804e3809dd5f90f8613621d062848dd82f9cd62007"}, + {file = "psycopg2_binary-2.9.10-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:48b338f08d93e7be4ab2b5f1dbe69dc5e9ef07170fe1f86514422076d9c010d0"}, + {file = "psycopg2_binary-2.9.10-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7f4152f8f76d2023aac16285576a9ecd2b11a9895373a1f10fd9db54b3ff06b4"}, + {file = "psycopg2_binary-2.9.10-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:32581b3020c72d7a421009ee1c6bf4a131ef5f0a968fab2e2de0c9d2bb4577f1"}, + {file = "psycopg2_binary-2.9.10-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:2ce3e21dc3437b1d960521eca599d57408a695a0d3c26797ea0f72e834c7ffe5"}, + {file = "psycopg2_binary-2.9.10-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:e984839e75e0b60cfe75e351db53d6db750b00de45644c5d1f7ee5d1f34a1ce5"}, + {file = "psycopg2_binary-2.9.10-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:3c4745a90b78e51d9ba06e2088a2fe0c693ae19cc8cb051ccda44e8df8a6eb53"}, + {file = "psycopg2_binary-2.9.10-cp310-cp310-win32.whl", hash = "sha256:e5720a5d25e3b99cd0dc5c8a440570469ff82659bb09431c1439b92caf184d3b"}, + {file = "psycopg2_binary-2.9.10-cp310-cp310-win_amd64.whl", hash = "sha256:3c18f74eb4386bf35e92ab2354a12c17e5eb4d9798e4c0ad3a00783eae7cd9f1"}, + {file = "psycopg2_binary-2.9.10-cp311-cp311-macosx_12_0_x86_64.whl", hash = "sha256:04392983d0bb89a8717772a193cfaac58871321e3ec69514e1c4e0d4957b5aff"}, + {file = "psycopg2_binary-2.9.10-cp311-cp311-macosx_14_0_arm64.whl", hash = "sha256:1a6784f0ce3fec4edc64e985865c17778514325074adf5ad8f80636cd029ef7c"}, + {file = "psycopg2_binary-2.9.10-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b5f86c56eeb91dc3135b3fd8a95dc7ae14c538a2f3ad77a19645cf55bab1799c"}, + {file = "psycopg2_binary-2.9.10-cp311-cp311-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:2b3d2491d4d78b6b14f76881905c7a8a8abcf974aad4a8a0b065273a0ed7a2cb"}, + {file = "psycopg2_binary-2.9.10-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:2286791ececda3a723d1910441c793be44625d86d1a4e79942751197f4d30341"}, + {file = "psycopg2_binary-2.9.10-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:512d29bb12608891e349af6a0cccedce51677725a921c07dba6342beaf576f9a"}, + {file = "psycopg2_binary-2.9.10-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:5a507320c58903967ef7384355a4da7ff3f28132d679aeb23572753cbf2ec10b"}, + {file = "psycopg2_binary-2.9.10-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:6d4fa1079cab9018f4d0bd2db307beaa612b0d13ba73b5c6304b9fe2fb441ff7"}, + {file = "psycopg2_binary-2.9.10-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:851485a42dbb0bdc1edcdabdb8557c09c9655dfa2ca0460ff210522e073e319e"}, + {file = "psycopg2_binary-2.9.10-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:35958ec9e46432d9076286dda67942ed6d968b9c3a6a2fd62b48939d1d78bf68"}, + {file = "psycopg2_binary-2.9.10-cp311-cp311-win32.whl", hash = "sha256:ecced182e935529727401b24d76634a357c71c9275b356efafd8a2a91ec07392"}, + {file = "psycopg2_binary-2.9.10-cp311-cp311-win_amd64.whl", hash = "sha256:ee0e8c683a7ff25d23b55b11161c2663d4b099770f6085ff0a20d4505778d6b4"}, + {file = "psycopg2_binary-2.9.10-cp312-cp312-macosx_12_0_x86_64.whl", hash = "sha256:880845dfe1f85d9d5f7c412efea7a08946a46894537e4e5d091732eb1d34d9a0"}, + {file = "psycopg2_binary-2.9.10-cp312-cp312-macosx_14_0_arm64.whl", hash = "sha256:9440fa522a79356aaa482aa4ba500b65f28e5d0e63b801abf6aa152a29bd842a"}, + {file = "psycopg2_binary-2.9.10-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e3923c1d9870c49a2d44f795df0c889a22380d36ef92440ff618ec315757e539"}, + {file = "psycopg2_binary-2.9.10-cp312-cp312-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:7b2c956c028ea5de47ff3a8d6b3cc3330ab45cf0b7c3da35a2d6ff8420896526"}, + {file = "psycopg2_binary-2.9.10-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f758ed67cab30b9a8d2833609513ce4d3bd027641673d4ebc9c067e4d208eec1"}, + {file = "psycopg2_binary-2.9.10-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8cd9b4f2cfab88ed4a9106192de509464b75a906462fb846b936eabe45c2063e"}, + {file = "psycopg2_binary-2.9.10-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:6dc08420625b5a20b53551c50deae6e231e6371194fa0651dbe0fb206452ae1f"}, + {file = "psycopg2_binary-2.9.10-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:d7cd730dfa7c36dbe8724426bf5612798734bff2d3c3857f36f2733f5bfc7c00"}, + {file = "psycopg2_binary-2.9.10-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:155e69561d54d02b3c3209545fb08938e27889ff5a10c19de8d23eb5a41be8a5"}, + {file = "psycopg2_binary-2.9.10-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:c3cc28a6fd5a4a26224007712e79b81dbaee2ffb90ff406256158ec4d7b52b47"}, + {file = "psycopg2_binary-2.9.10-cp312-cp312-win32.whl", hash = "sha256:ec8a77f521a17506a24a5f626cb2aee7850f9b69a0afe704586f63a464f3cd64"}, + {file = "psycopg2_binary-2.9.10-cp312-cp312-win_amd64.whl", hash = "sha256:18c5ee682b9c6dd3696dad6e54cc7ff3a1a9020df6a5c0f861ef8bfd338c3ca0"}, + {file = "psycopg2_binary-2.9.10-cp313-cp313-macosx_12_0_x86_64.whl", hash = "sha256:26540d4a9a4e2b096f1ff9cce51253d0504dca5a85872c7f7be23be5a53eb18d"}, + {file = "psycopg2_binary-2.9.10-cp313-cp313-macosx_14_0_arm64.whl", hash = "sha256:e217ce4d37667df0bc1c397fdcd8de5e81018ef305aed9415c3b093faaeb10fb"}, + {file = "psycopg2_binary-2.9.10-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:245159e7ab20a71d989da00f280ca57da7641fa2cdcf71749c193cea540a74f7"}, + {file = "psycopg2_binary-2.9.10-cp313-cp313-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:3c4ded1a24b20021ebe677b7b08ad10bf09aac197d6943bfe6fec70ac4e4690d"}, + {file = "psycopg2_binary-2.9.10-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:3abb691ff9e57d4a93355f60d4f4c1dd2d68326c968e7db17ea96df3c023ef73"}, + {file = "psycopg2_binary-2.9.10-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8608c078134f0b3cbd9f89b34bd60a943b23fd33cc5f065e8d5f840061bd0673"}, + {file = "psycopg2_binary-2.9.10-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:230eeae2d71594103cd5b93fd29d1ace6420d0b86f4778739cb1a5a32f607d1f"}, + {file = "psycopg2_binary-2.9.10-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:bb89f0a835bcfc1d42ccd5f41f04870c1b936d8507c6df12b7737febc40f0909"}, + {file = "psycopg2_binary-2.9.10-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:f0c2d907a1e102526dd2986df638343388b94c33860ff3bbe1384130828714b1"}, + {file = "psycopg2_binary-2.9.10-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:f8157bed2f51db683f31306aa497311b560f2265998122abe1dce6428bd86567"}, + {file = "psycopg2_binary-2.9.10-cp313-cp313-win_amd64.whl", hash = "sha256:27422aa5f11fbcd9b18da48373eb67081243662f9b46e6fd07c3eb46e4535142"}, + {file = "psycopg2_binary-2.9.10-cp38-cp38-macosx_12_0_x86_64.whl", hash = "sha256:eb09aa7f9cecb45027683bb55aebaaf45a0df8bf6de68801a6afdc7947bb09d4"}, + {file = "psycopg2_binary-2.9.10-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b73d6d7f0ccdad7bc43e6d34273f70d587ef62f824d7261c4ae9b8b1b6af90e8"}, + {file = "psycopg2_binary-2.9.10-cp38-cp38-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ce5ab4bf46a211a8e924d307c1b1fcda82368586a19d0a24f8ae166f5c784864"}, + {file = "psycopg2_binary-2.9.10-cp38-cp38-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:056470c3dc57904bbf63d6f534988bafc4e970ffd50f6271fc4ee7daad9498a5"}, + {file = "psycopg2_binary-2.9.10-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:73aa0e31fa4bb82578f3a6c74a73c273367727de397a7a0f07bd83cbea696baa"}, + {file = "psycopg2_binary-2.9.10-cp38-cp38-musllinux_1_2_aarch64.whl", hash = "sha256:8de718c0e1c4b982a54b41779667242bc630b2197948405b7bd8ce16bcecac92"}, + {file = "psycopg2_binary-2.9.10-cp38-cp38-musllinux_1_2_i686.whl", hash = "sha256:5c370b1e4975df846b0277b4deba86419ca77dbc25047f535b0bb03d1a544d44"}, + {file = "psycopg2_binary-2.9.10-cp38-cp38-musllinux_1_2_ppc64le.whl", hash = "sha256:ffe8ed017e4ed70f68b7b371d84b7d4a790368db9203dfc2d222febd3a9c8863"}, + {file = "psycopg2_binary-2.9.10-cp38-cp38-musllinux_1_2_x86_64.whl", hash = "sha256:8aecc5e80c63f7459a1a2ab2c64df952051df196294d9f739933a9f6687e86b3"}, + {file = "psycopg2_binary-2.9.10-cp39-cp39-macosx_12_0_x86_64.whl", hash = "sha256:7a813c8bdbaaaab1f078014b9b0b13f5de757e2b5d9be6403639b298a04d218b"}, + {file = "psycopg2_binary-2.9.10-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d00924255d7fc916ef66e4bf22f354a940c67179ad3fd7067d7a0a9c84d2fbfc"}, + {file = "psycopg2_binary-2.9.10-cp39-cp39-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:7559bce4b505762d737172556a4e6ea8a9998ecac1e39b5233465093e8cee697"}, + {file = "psycopg2_binary-2.9.10-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:e8b58f0a96e7a1e341fc894f62c1177a7c83febebb5ff9123b579418fdc8a481"}, + {file = "psycopg2_binary-2.9.10-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6b269105e59ac96aba877c1707c600ae55711d9dcd3fc4b5012e4af68e30c648"}, + {file = "psycopg2_binary-2.9.10-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:79625966e176dc97ddabc142351e0409e28acf4660b88d1cf6adb876d20c490d"}, + {file = "psycopg2_binary-2.9.10-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:8aabf1c1a04584c168984ac678a668094d831f152859d06e055288fa515e4d30"}, + {file = "psycopg2_binary-2.9.10-cp39-cp39-musllinux_1_2_ppc64le.whl", hash = "sha256:19721ac03892001ee8fdd11507e6a2e01f4e37014def96379411ca99d78aeb2c"}, + {file = "psycopg2_binary-2.9.10-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:7f5d859928e635fa3ce3477704acee0f667b3a3d3e4bb109f2b18d4005f38287"}, + {file = "psycopg2_binary-2.9.10-cp39-cp39-win32.whl", hash = "sha256:3216ccf953b3f267691c90c6fe742e45d890d8272326b4a8b20850a03d05b7b8"}, + {file = "psycopg2_binary-2.9.10-cp39-cp39-win_amd64.whl", hash = "sha256:30e34c4e97964805f715206c7b789d54a78b70f3ff19fbe590104b71c45600e5"}, ] -[package.dependencies] -cfgv = ">=2.0.0" -identify = ">=1.0.0" -nodeenv = ">=0.11.1" -pyyaml = ">=5.1" -virtualenv = ">=20.10.0" +[[package]] +name = "pycodestyle" +version = "2.12.1" +description = "Python style guide checker" +optional = false +python-versions = ">=3.8" +files = [ + {file = "pycodestyle-2.12.1-py2.py3-none-any.whl", hash = "sha256:46f0fb92069a7c28ab7bb558f05bfc0110dac69a0cd23c61ea0040283a9d78b3"}, + {file = "pycodestyle-2.12.1.tar.gz", hash = "sha256:6838eae08bbce4f6accd5d5572075c63626a15ee3e6f842df996bf62f6d73521"}, +] [[package]] name = "pydantic" @@ -830,6 +851,26 @@ files = [ [package.dependencies] typing-extensions = ">=4.6.0,<4.7.0 || >4.7.0" +[[package]] +name = "pydantic-settings" +version = "2.8.1" +description = "Settings management using Pydantic" +optional = false +python-versions = ">=3.8" +files = [ + {file = "pydantic_settings-2.8.1-py3-none-any.whl", hash = "sha256:81942d5ac3d905f7f3ee1a70df5dfb62d5569c12f51a5a647defc1c3d9ee2e9c"}, + {file = "pydantic_settings-2.8.1.tar.gz", hash = "sha256:d5c663dfbe9db9d5e1c646b2e161da12f0d734d422ee56f567d0ea2cee4e8585"}, +] + +[package.dependencies] +pydantic = ">=2.7.0" +python-dotenv = ">=0.21.0" + +[package.extras] +azure-key-vault = ["azure-identity (>=1.16.0)", "azure-keyvault-secrets (>=4.8.0)"] +toml = ["tomli (>=2.0.1)"] +yaml = ["pyyaml (>=6.0.1)"] + [[package]] name = "pygments" version = "2.19.1" @@ -1123,6 +1164,21 @@ postgresql-psycopgbinary = ["psycopg[binary] (>=3.0.7)"] pymysql = ["pymysql"] sqlcipher = ["sqlcipher3_binary"] +[[package]] +name = "sqlmodel" +version = "0.0.24" +description = "SQLModel, SQL databases in Python, designed for simplicity, compatibility, and robustness." +optional = false +python-versions = ">=3.7" +files = [ + {file = "sqlmodel-0.0.24-py3-none-any.whl", hash = "sha256:6778852f09370908985b667d6a3ab92910d0d5ec88adcaf23dbc242715ff7193"}, + {file = "sqlmodel-0.0.24.tar.gz", hash = "sha256:cc5c7613c1a5533c9c7867e1aab2fd489a76c9e8a061984da11b4e613c182423"}, +] + +[package.dependencies] +pydantic = ">=1.10.13,<3.0.0" +SQLAlchemy = ">=2.0.14,<2.1.0" + [[package]] name = "starlette" version = "0.41.3" @@ -1255,26 +1311,6 @@ dev = ["Cython (>=3.0,<4.0)", "setuptools (>=60)"] docs = ["Sphinx (>=4.1.2,<4.2.0)", "sphinx-rtd-theme (>=0.5.2,<0.6.0)", "sphinxcontrib-asyncio (>=0.3.0,<0.4.0)"] test = ["aiohttp (>=3.10.5)", "flake8 (>=5.0,<6.0)", "mypy (>=0.800)", "psutil", "pyOpenSSL (>=23.0.0,<23.1.0)", "pycodestyle (>=2.9.0,<2.10.0)"] -[[package]] -name = "virtualenv" -version = "20.29.1" -description = "Virtual Python Environment builder" -optional = false -python-versions = ">=3.8" -files = [ - {file = "virtualenv-20.29.1-py3-none-any.whl", hash = "sha256:4e4cb403c0b0da39e13b46b1b2476e505cb0046b25f242bee80f62bf990b2779"}, - {file = "virtualenv-20.29.1.tar.gz", hash = "sha256:b8b8970138d32fb606192cb97f6cd4bb644fa486be9308fb9b63f81091b5dc35"}, -] - -[package.dependencies] -distlib = ">=0.3.7,<1" -filelock = ">=3.12.2,<4" -platformdirs = ">=3.9.1,<5" - -[package.extras] -docs = ["furo (>=2023.7.26)", "proselint (>=0.13)", "sphinx (>=7.1.2,!=7.3)", "sphinx-argparse (>=0.4)", "sphinxcontrib-towncrier (>=0.2.1a0)", "towncrier (>=23.6)"] -test = ["covdefaults (>=2.3)", "coverage (>=7.2.7)", "coverage-enable-subprocess (>=1)", "flaky (>=3.7)", "packaging (>=23.1)", "pytest (>=7.4)", "pytest-env (>=0.8.2)", "pytest-freezer (>=0.4.8)", "pytest-mock (>=3.11.1)", "pytest-randomly (>=3.12)", "pytest-timeout (>=2.1)", "setuptools (>=68)", "time-machine (>=2.10)"] - [[package]] name = "watchfiles" version = "1.0.4" @@ -1439,4 +1475,4 @@ files = [ [metadata] lock-version = "2.0" python-versions = "^3.12" -content-hash = "94e26cada399002f42bf182c10ffeabb2a71a62c600beeaed229cf4b16c03504" +content-hash = "6c9b9d5fc1e4617313748228ffbb73bc8d8ad82b5ccecbe68e8be0f7f13842bd" diff --git a/backend/pyproject.toml b/backend/pyproject.toml index 5643017..410290f 100644 --- a/backend/pyproject.toml +++ b/backend/pyproject.toml @@ -14,12 +14,21 @@ python-dotenv = "^1.0.1" mysql-connector = "^2.2.9" passlib = {extras = ["bcrypt"], version = "^1.7.4"} pyjwt = "^2.10.1" +pydantic-settings = "^2.8.1" +sqlmodel = "^0.0.24" +psycopg2-binary = "^2.9.10" [tool.poetry.group.dev.dependencies] -pre-commit = "^4.0.1" pylint = "^3.3.4" +autopep8 = "^2.3.2" [build-system] requires = ["poetry-core"] build-backend = "poetry.core.masonry.api" + +[tool.autopep8] +max_line_length = 200 +ignore = "E501" +in_place = true +recursive = true \ No newline at end of file diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..bbfe518 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,68 @@ +services: + + db: + image: postgres:12 + restart: always + healthcheck: + test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER} -d ${POSTGRES_DB}"] + interval: 10s + retries: 5 + start_period: 30s + timeout: 10s + volumes: + - app-db-data:/var/lib/postgresql/data/pgdata + env_file: + - .env + environment: + - PGDATA=/var/lib/postgresql/data/pgdata + - POSTGRES_PASSWORD=${POSTGRES_PASSWORD?Variable not set} + - POSTGRES_USER=${POSTGRES_USER?Variable not set} + - POSTGRES_DB=${POSTGRES_DB?Variable not set} + + adminer: + image: adminer + restart: always + networks: + - default + depends_on: + - db + environment: + - ADMINER_DESIGN=pepa-linha-dark + + backend: + image: '${DOCKER_IMAGE_BACKEND?Variable not set}:${TAG-latest}' + restart: always + networks: + - default + depends_on: + db: + condition: service_healthy + env_file: + - .env + environment: + - FRONTEND_HOST=${FRONTEND_HOST?Variable not set} + - ENVIRONMENT=${ENVIRONMENT} + - BACKEND_CORS_ORIGINS=${BACKEND_CORS_ORIGINS} + - SECRET_KEY=${SECRET_KEY?Variable not set} + - FIRST_SUPERUSER=${FIRST_SUPERUSER?Variable not set} + - FIRST_SUPERUSER_PASSWORD=${FIRST_SUPERUSER_PASSWORD?Variable not set} + - SMTP_HOST=${SMTP_HOST} + - SMTP_USER=${SMTP_USER} + - SMTP_PASSWORD=${SMTP_PASSWORD} + - EMAILS_FROM_EMAIL=${EMAILS_FROM_EMAIL} + - MYSQL_SERVER=db + - MYSQL_PORT=${MYSQL_PORT} + - MYSQL_DB=${MYSQL_DB} + - MYSQL_USER=${MYSQL_USER?Variable not set} + + healthcheck: + test: ["CMD", "curl", "-f", "http://localhost:8000/utils/health-check/"] + interval: 10s + timeout: 5s + retries: 5 + + build: + context: ./backend + +volumes: + app-db-data: \ No newline at end of file diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts index 1f99ab2..47ec047 100644 --- a/frontend/src/lib/api.ts +++ b/frontend/src/lib/api.ts @@ -8,9 +8,17 @@ export const registerUser = async (userData: { phone_number: string; password: string; }) => { - const response = await axios.post(`${API_BASE_URL}/user/register`, { - ...userData, - shop_id: 0 // 0 signifies a free account - }); + const response = await axios.post( + `${API_BASE_URL}/user/register`, + { + ...userData, + shop_id: 0 + }, + { + headers: { + "Content-Type": "application/json" + } + } + ); return response.data; };