[rewrite] Major changes to code - switched to postgres, added initial backend docker files
This commit is contained in:
1 parent
c7e20fc935
commit
71e916586e
52 files changed
+783
-537
No files matched your search
@@ -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)
|
||||
@@ -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)]
|
||||
File renamed without changes.
File renamed without changes.
@@ -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
|
||||
)
|
||||
)
|
||||
File renamed without changes.
@@ -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")
|
||||
@@ -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
|
||||
@@ -1,3 +0,0 @@
|
||||
from .config import *
|
||||
|
||||
__all__ = [*config.__all__]
|
||||
+85
-183
@@ -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()
|
||||
@@ -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"""
|
||||
@@ -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"]
|
||||
@@ -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
|
||||
|
||||
|
||||
|
||||
@@ -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()
|
||||
@@ -0,0 +1,6 @@
|
||||
from . import user_model, shop_model
|
||||
|
||||
__all__ = [
|
||||
*user_model.__all__,
|
||||
*shop_model.__all__
|
||||
]
|
||||
File renamed without changes.
File renamed without changes.
+1
@@ -3,6 +3,7 @@ from sqlalchemy.sql import func
|
||||
from sqlalchemy.orm import relationship
|
||||
from .base_model import Base
|
||||
|
||||
|
||||
class Purchase(Base):
|
||||
__tablename__ = "purchase"
|
||||
|
||||
File renamed without changes.
+1
@@ -3,6 +3,7 @@ from sqlalchemy.orm import relationship
|
||||
from sqlalchemy.sql import func
|
||||
from .base_model import Base
|
||||
|
||||
|
||||
class Wishlist(Base):
|
||||
__tablename__ = "wishlist"
|
||||
|
||||
@@ -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"]
|
||||
@@ -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"]
|
||||
File renamed without changes.
@@ -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")
|
||||
raise HTTPException(status_code=400, detail="No Jessica token provided")
|
||||
+19
-32
@@ -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)
|
||||
Whitespace-only changes.
@@ -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"]
|
||||
@@ -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"]
|
||||
@@ -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")
|
||||
@@ -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"]
|
||||
@@ -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])
|
||||
Whitespace-only changes.
@@ -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)
|
||||
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
|
||||
@@ -1,5 +1,6 @@
|
||||
import imghdr
|
||||
|
||||
|
||||
def is_base64_jpg(decoded_string) -> bool:
|
||||
try:
|
||||
image_type = imghdr.what(None, decoded_string)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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
|
||||
Whitespace-only changes.
Reference in new issue
Block a user