[rewrite] Major changes to code - switched to postgres, added initial backend docker files

This commit is contained in:
Thastertyn 2025-03-10 18:02:57 +01:00
parent c7e20fc935
commit 71e916586e
52 changed files with 783 additions and 537 deletions

51
.env-example Normal file
View File

@ -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

8
.vscode/launch.json vendored
View File

@ -8,7 +8,13 @@
"cwd": "${workspaceFolder}/backend", "cwd": "${workspaceFolder}/backend",
"module": "fastapi", "module": "fastapi",
"args": ["dev", "${cwd}/backend/app/main.py"], "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", "name": "Frontend: Dev",

View File

@ -2,9 +2,9 @@
// #region Backend settings // #region Backend settings
"files.exclude" : { "**/__pycache__/**": true }, // Hide __pycache__ directories "files.exclude" : { "**/__pycache__/**": true }, // Hide __pycache__ directories
"mypy-type-checker.args" : ["--config-file='backend/mypy.ini'"], // Override mypy config "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 "python.analysis.extraPaths" : ["./backend"], // Pylint - fix for import analysis
"pylint.cwd": "${workspaceFolder}/backend", "pylint.cwd" : "${workspaceFolder}/backend",
// #endregion // #endregion
// #region Frontend settings // #region Frontend settings

View File

@ -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=

25
backend/Dockerfile Normal file
View File

@ -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"]

View File

@ -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)

View File

@ -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)]

View File

@ -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
)
)

View File

@ -31,6 +31,7 @@ async def login(login_data: UserLoginSchema):
# ) # )
# return Token(access_token=access_token, token_type="bearer") # return Token(access_token=access_token, token_type="bearer")
@router.delete("/logout", summary="User logout") @router.delete("/logout", summary="User logout")
async def logout(): async def logout():
raise NotImplementedError("logout() needs to be implemented.") raise NotImplementedError("logout() needs to be implemented.")
@ -38,8 +39,11 @@ async def logout():
@router.post("/register", summary="Register new user") @router.post("/register", summary="Register new user")
async def register(user_data: UserRegisterSchema): async def register(user_data: UserRegisterSchema):
create_user(user_data) try:
return {"message": "User registered successfully"} create_user(user_data)
return {"message": "User registered successfully"}
except BaseException:
return {"message": "An error occured"}
@router.put("/update", summary="Update user details") @router.put("/update", summary="Update user details")

View File

@ -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

View File

@ -1,3 +0,0 @@
from .config import *
__all__ = [*config.__all__]

View File

@ -1,205 +1,107 @@
import os import secrets
import warnings
import logging import logging
import re from typing import Annotated, Any, Literal
from typing import Any, Type, Optional, Callable
import dotenv from pydantic import (
AnyUrl,
from .exceptions import ConfigError 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(): def parse_cors(v: Any) -> list[str] | str:
""" if isinstance(v, str) and not v.startswith("["):
Represents a single config field to be parsed from the environment. 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__( class Settings(BaseSettings):
self, model_config = SettingsConfigDict(
env_name: str, env_file="../.env",
data_type: Type[Any] = str, env_ignore_empty=True,
default: Any = None, extra="ignore",
required: bool = False, )
choices: Optional[list[Any]] = None, PORT: int = 8000
min_value: Optional[int] = None, SECRET_KEY: str = secrets.token_urlsafe(32)
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
def load(self): VERBOSITY: Literal[*logging.getLevelNamesMapping().keys()] = "DEBUG"
"""Loads the value from the environment and validates it."""
raw_value = os.environ.get(self.env_name)
if self.required and (raw_value is None or raw_value == ""): # 60 minutes * 24 hours * 8 days = 8 days
raise ConfigError( ACCESS_TOKEN_EXPIRE_MINUTES: int = 60 * 24 * 8
"Field %(env_name)s is required, but was not provided in the environment / .env", FRONTEND_HOST: str
env_name=self.env_name, ENVIRONMENT: Literal["local", "staging", "production"] = "local"
)
self.env_value = self.validate(raw_value) BACKEND_CORS_ORIGINS: Annotated[
list[AnyUrl] | str, BeforeValidator(parse_cors)
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
@computed_field
@property @property
def value(self): def all_cors_origins(self) -> list[str]:
return self.env_value return [str(origin).rstrip("/") for origin in self.BACKEND_CORS_ORIGINS] + [self.FRONTEND_HOST]
@value.setter POSTGRES_SERVER: str
def value(self, new): POSTGRES_PORT: int = 5432
raise TypeError("Cannot modify") POSTGRES_USER: str
POSTGRES_PASSWORD: str = ""
POSTGRES_DB: str = ""
def __parse_to_str(self, value) -> str: @computed_field
"""Parses and validates string values.""" @property
value = str(value) 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): SMTP_TLS: bool = True
raise ConfigError( SMTP_SSL: bool = False
"Value for %(env_name)s - %(value)s does not match the expected pattern: %(regex)s", SMTP_PORT: int = 587
env_name=self.env_name, SMTP_HOST: str | None = None
value=value, SMTP_USER: str | None = None
regex=self.regex.pattern, 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: @computed_field
"""Parses and validates integer values.""" @property
if not str(value).isdigit(): def emails_enabled(self) -> bool:
raise ConfigError( return bool(self.SMTP_HOST and self.EMAILS_FROM_EMAIL)
"Value for %(env_name)s - %(value)s must be a numeric integer",
env_name=self.env_name,
value=value,
)
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: def _check_default_secret(self, var_name: str, value: str | None) -> None:
raise ConfigError( if value == "changethis":
"Value for %(env_name)s - %(value)d is too small. Must be greater than or equal to %(min_value)d", message = f'The value of {var_name} is "changethis", '"for security, please change it, at least for deployments."
env_name=self.env_name, if self.ENVIRONMENT == "local":
value=value, warnings.warn(message, stacklevel=1)
min_value=self.min_value, else:
) raise ValueError(message)
if self.max_value is not None and value > self.max_value: @model_validator(mode="after")
raise ConfigError( def _enforce_non_default_secrets(self) -> Self:
"Value for %(env_name)s - %(value)d is too large. Must be smaller than or equal to %(max_value)d", self._check_default_secret("SECRET_KEY", self.SECRET_KEY)
env_name=self.env_name, self._check_default_secret("POSTGRES_PASSWORD", self.POSTGRES_PASSWORD)
value=value, self._check_default_secret("FIRST_SUPERUSER_PASSWORD", self.FIRST_SUPERUSER_PASSWORD)
max_value=self.max_value,
)
return value return self
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)
class EnvConfig(): settings = Settings()
_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"]

View File

@ -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"""

View File

@ -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"]

View File

@ -5,7 +5,7 @@ import jwt
from passlib.context import CryptContext from passlib.context import CryptContext
from app.core.config import EnvConfig from app.core.config import settings
pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto") pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
@ -14,11 +14,9 @@ ALGORITHM = "HS256"
def create_access_token(subject: str | Any, expires_delta: timedelta) -> str: def create_access_token(subject: str | Any, expires_delta: timedelta) -> str:
config = EnvConfig()
expire = datetime.now(timezone.utc) + expires_delta expire = datetime.now(timezone.utc) + expires_delta
to_encode = {"exp": expire, "sub": str(subject)} 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 return encoded_jwt

View File

@ -1,63 +1,43 @@
import logging import logging
from typing import Generator
from contextlib import contextmanager from contextlib import contextmanager
from typing import Generator
from sqlalchemy.orm import sessionmaker, Session from sqlalchemy.exc import DatabaseError as SqlAlchemyError
from sqlalchemy import create_engine, text from sqlmodel import Session, create_engine, select, SQLModel
from app.core.config import settings
from app.database.exceptions import DatabaseError from app.database.exceptions import DatabaseError
import app.database.models
class DatabaseManager(): logger = logging.getLogger(__name__)
_instance: 'DatabaseManager' = None logger.info("Creating engine")
engine = create_engine(str(settings.SQLALCHEMY_DATABASE_URI))
def __new__(cls, mysql_user: str, mysql_password: str, mysql_host: str, mysql_port: int, mysql_db_name: str): SQLModel.metadata.create_all(engine)
if cls._instance is None:
cls._instance = super().__new__(cls)
return cls._instance
def __init__(self, mysql_user: str, mysql_password: str, mysql_host: str, mysql_port: int, mysql_db_name: str) -> None: def test_connection():
self.logger = logging.getLogger(__name__) logger.debug("Testing database connection")
self.logger.info("Reading database config") try:
self.engine = create_engine('mysql+mysqlconnector://%s:%s@%s:%s/%s' % ( with Session(engine) as session:
mysql_user, session.exec(select(1))
mysql_password, logger.debug("Database connection successful")
mysql_host, except SqlAlchemyError as e:
str(mysql_port), logger.critical("Database connection failed: %s", e)
mysql_db_name), raise DatabaseError("Database connection failed", DatabaseError.CONNECTION_ERROR) from e
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
@classmethod def cleanup() -> None:
@contextmanager logger.debug("Closing connection")
def get_session(cls) -> Generator[Session, None, None]: engine.dispose()
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()
__all__ = ["DatabaseManager"] @contextmanager
def get_session() -> Generator[Session, None, None]:
with Session(engine) as session:
yield session
test_connection()

View File

@ -0,0 +1,6 @@
from . import user_model, shop_model
__all__ = [
*user_model.__all__,
*shop_model.__all__
]

View File

@ -3,6 +3,7 @@ from sqlalchemy.sql import func
from sqlalchemy.orm import relationship from sqlalchemy.orm import relationship
from .base_model import Base from .base_model import Base
class Purchase(Base): class Purchase(Base):
__tablename__ = "purchase" __tablename__ = "purchase"

View File

@ -3,6 +3,7 @@ from sqlalchemy.orm import relationship
from sqlalchemy.sql import func from sqlalchemy.sql import func
from .base_model import Base from .base_model import Base
class Wishlist(Base): class Wishlist(Base):
__tablename__ = "wishlist" __tablename__ = "wishlist"

View File

@ -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"]

View File

@ -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"]

View File

@ -14,6 +14,7 @@ class LoginRequest(BaseModel):
username: str username: str
password: str password: str
async def get_jsession_id(request: Request): async def get_jsession_id(request: Request):
jsessionid = ( jsessionid = (
request.headers.get("JSESSIONID") request.headers.get("JSESSIONID")
@ -26,11 +27,11 @@ async def get_jsession_id(request: Request):
return jsessionid return jsessionid
async def get_credentials(credentials: LoginRequest = Body(...)): async def get_credentials(credentials: LoginRequest = Body(...)):
return credentials.dict() return credentials.dict()
async def get_query_token(token: str): async def get_query_token(token: str):
if token != "jessica": if token != "jessica":
raise HTTPException(status_code=400, detail="No Jessica token provided") raise HTTPException(status_code=400, detail="No Jessica token provided")

View File

@ -1,46 +1,33 @@
from fastapi import FastAPI from fastapi import FastAPI
from app.routes.cart_routes import router as cart_router from fastapi.routing import APIRoute
from app.routes.user_routes import router as user_router
from app.routes.shop_routes import router as shop_router
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)) def custom_generate_unique_id(route: APIRoute) -> str:
config.add_field("mysql_database", EnvConfigField("MYSQL_DATABASE", required=True)) return f"{route.tags[0]}-{route.name}"
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"],
)
app = FastAPI( app = FastAPI(
title="SWAG Shop", 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("/") app.include_router(api_router)
async def root():
return {"message": "Hello World"}

View File

@ -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"]

View File

@ -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"]

View File

@ -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")

View File

@ -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"]

View File

@ -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])

View File

@ -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) username: str = Field(..., min_length=3, max_length=64)
email: EmailStr = Field(...) email: EmailStr = Field(...)
phone_number: str = Field(..., min_length=2, max_length=16, pattern=r'^\+[1-9]\d{1,14}$') 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 shop_id: int = 0
class Config: class Config:
orm_mode = True from_attributes = True
class UserLoginSchema(BaseModel):
class UserLoginSchema(SQLModel):
shop_id: int = 0 shop_id: int = 0
username: str = Field(..., min_length=3, max_length=64) username: str = Field(..., min_length=3, max_length=64)
password: str = Field(..., min_length=6, max_length=128) 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

View File

@ -1,5 +1,6 @@
import imghdr import imghdr
def is_base64_jpg(decoded_string) -> bool: def is_base64_jpg(decoded_string) -> bool:
try: try:
image_type = imghdr.what(None, decoded_string) image_type = imghdr.what(None, decoded_string)

View File

@ -5,6 +5,7 @@ import app.messages.api_errors as errors
from app.db import product_db from app.db import product_db
def product_list(page: int): def product_list(page: int):
try: try:
result_products = product_db.fetch_products(page) result_products = product_db.fetch_products(page)

View File

@ -11,6 +11,7 @@ from app.mail.mail import send_mail
from app.messages.mail_responses.user_email import USER_EMAIL_SUCCESSFULLY_DELETED_ACCOUNT from app.messages.mail_responses.user_email import USER_EMAIL_SUCCESSFULLY_DELETED_ACCOUNT
def delete_user(user_id: str) -> Tuple[Union[dict, str], int]: def delete_user(user_id: str) -> Tuple[Union[dict, str], int]:
""" """
Deletes a user account. Deletes a user account.

View File

@ -13,7 +13,6 @@ from app.models.user_model import User
from app.messages.mail_responses.user_email import USER_EMAIL_SUCCESSFULLY_LOGGED_IN from app.messages.mail_responses.user_email import USER_EMAIL_SUCCESSFULLY_LOGGED_IN
def login(username: str, password: str) -> Tuple[Union[dict, str], int]: def login(username: str, password: str) -> Tuple[Union[dict, str], int]:
""" """
Authenticates a user with the provided username and password. Authenticates a user with the provided username and password.

View File

@ -1,13 +1,12 @@
from app.database.manager import DatabaseManager from app.database.models.user_model import User
from app.models.user_model import User
from app.schemas.user_schemas import UserRegisterSchema from app.schemas.user_schemas import UserRegisterSchema
from app.core.security import get_password_hash
def create_user(user_data: UserRegisterSchema): def create_user(user_data: UserRegisterSchema):
print("Creating user") print("Creating account")
return
with DatabaseManager.get_session() as session: with DatabaseManager.get_session() as session:
user = User( user = User(
username=user_data.username, username=user_data.username,

View File

@ -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

View File

@ -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)

View File

@ -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)

View File

@ -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

228
backend/poetry.lock generated
View File

@ -43,6 +43,20 @@ files = [
{file = "astroid-3.3.8.tar.gz", hash = "sha256:a88c7994f914a4ea8572fac479459f4955eeccc877be3f2d959a33273b0cf40b"}, {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]] [[package]]
name = "bcrypt" name = "bcrypt"
version = "4.2.1" version = "4.2.1"
@ -92,17 +106,6 @@ files = [
{file = "certifi-2025.1.31.tar.gz", hash = "sha256:3d5da6925056f6f18f119200434a4780a94263f10d1c21d032a6f6b2baa20651"}, {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]] [[package]]
name = "click" name = "click"
version = "8.1.8" version = "8.1.8"
@ -143,17 +146,6 @@ files = [
graph = ["objgraph (>=1.7.2)"] graph = ["objgraph (>=1.7.2)"]
profile = ["gprof2dot (>=2022.7.29)"] 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]] [[package]]
name = "dnspython" name = "dnspython"
version = "2.7.0" version = "2.7.0"
@ -234,22 +226,6 @@ uvicorn = {version = ">=0.15.0", extras = ["standard"]}
[package.extras] [package.extras]
standard = ["uvicorn[standard] (>=0.15.0)"] 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]] [[package]]
name = "greenlet" name = "greenlet"
version = "3.1.1" version = "3.1.1"
@ -447,20 +423,6 @@ http2 = ["h2 (>=3,<5)"]
socks = ["socksio (==1.*)"] socks = ["socksio (==1.*)"]
zstd = ["zstandard (>=0.18.0)"] 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]] [[package]]
name = "idna" name = "idna"
version = "3.10" version = "3.10"
@ -633,17 +595,6 @@ files = [
{file = "mysql-connector-2.2.9.tar.gz", hash = "sha256:1733e6ce52a049243de3264f1fbc22a852cb35458c4ad739ba88189285efdf32"}, {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]] [[package]]
name = "passlib" name = "passlib"
version = "1.7.4" 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)"] type = ["mypy (>=1.11.2)"]
[[package]] [[package]]
name = "pre-commit" name = "psycopg2-binary"
version = "4.0.1" version = "2.9.10"
description = "A framework for managing and maintaining multi-language pre-commit hooks." description = "psycopg2 - Python-PostgreSQL Database Adapter"
optional = false optional = false
python-versions = ">=3.9" python-versions = ">=3.8"
files = [ files = [
{file = "pre_commit-4.0.1-py2.py3-none-any.whl", hash = "sha256:efde913840816312445dc98787724647c65473daefe420785f885e8ed9a06878"}, {file = "psycopg2-binary-2.9.10.tar.gz", hash = "sha256:4b3df0e6990aa98acda57d983942eff13d824135fe2250e6522edaa782a06de2"},
{file = "pre_commit-4.0.1.tar.gz", hash = "sha256:80905ac375958c0444c65e9cebebd948b3cdb518f335a091a670a89d652139d2"}, {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] [[package]]
cfgv = ">=2.0.0" name = "pycodestyle"
identify = ">=1.0.0" version = "2.12.1"
nodeenv = ">=0.11.1" description = "Python style guide checker"
pyyaml = ">=5.1" optional = false
virtualenv = ">=20.10.0" 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]] [[package]]
name = "pydantic" name = "pydantic"
@ -830,6 +851,26 @@ files = [
[package.dependencies] [package.dependencies]
typing-extensions = ">=4.6.0,<4.7.0 || >4.7.0" 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]] [[package]]
name = "pygments" name = "pygments"
version = "2.19.1" version = "2.19.1"
@ -1123,6 +1164,21 @@ postgresql-psycopgbinary = ["psycopg[binary] (>=3.0.7)"]
pymysql = ["pymysql"] pymysql = ["pymysql"]
sqlcipher = ["sqlcipher3_binary"] 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]] [[package]]
name = "starlette" name = "starlette"
version = "0.41.3" 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)"] 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)"] 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]] [[package]]
name = "watchfiles" name = "watchfiles"
version = "1.0.4" version = "1.0.4"
@ -1439,4 +1475,4 @@ files = [
[metadata] [metadata]
lock-version = "2.0" lock-version = "2.0"
python-versions = "^3.12" python-versions = "^3.12"
content-hash = "94e26cada399002f42bf182c10ffeabb2a71a62c600beeaed229cf4b16c03504" content-hash = "6c9b9d5fc1e4617313748228ffbb73bc8d8ad82b5ccecbe68e8be0f7f13842bd"

View File

@ -14,12 +14,21 @@ python-dotenv = "^1.0.1"
mysql-connector = "^2.2.9" mysql-connector = "^2.2.9"
passlib = {extras = ["bcrypt"], version = "^1.7.4"} passlib = {extras = ["bcrypt"], version = "^1.7.4"}
pyjwt = "^2.10.1" pyjwt = "^2.10.1"
pydantic-settings = "^2.8.1"
sqlmodel = "^0.0.24"
psycopg2-binary = "^2.9.10"
[tool.poetry.group.dev.dependencies] [tool.poetry.group.dev.dependencies]
pre-commit = "^4.0.1"
pylint = "^3.3.4" pylint = "^3.3.4"
autopep8 = "^2.3.2"
[build-system] [build-system]
requires = ["poetry-core"] requires = ["poetry-core"]
build-backend = "poetry.core.masonry.api" build-backend = "poetry.core.masonry.api"
[tool.autopep8]
max_line_length = 200
ignore = "E501"
in_place = true
recursive = true

68
docker-compose.yml Normal file
View File

@ -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:

View File

@ -8,9 +8,17 @@ export const registerUser = async (userData: {
phone_number: string; phone_number: string;
password: string; password: string;
}) => { }) => {
const response = await axios.post(`${API_BASE_URL}/user/register`, { const response = await axios.post(
...userData, `${API_BASE_URL}/user/register`,
shop_id: 0 // 0 signifies a free account {
}); ...userData,
shop_id: 0
},
{
headers: {
"Content-Type": "application/json"
}
}
);
return response.data; return response.data;
}; };