diff --git a/backend/main.py b/backend/main.py index 9768d26..e6fe71f 100644 --- a/backend/main.py +++ b/backend/main.py @@ -2,7 +2,7 @@ from database import create_tables from fastapi import FastAPI -from routes import good, good_category, login, payment, recipient, basket +from routes import good, good_category, login, payment, recipient, basket, roles @asynccontextmanager @@ -27,6 +27,8 @@ async def lifespan(_: FastAPI): app.include_router(basket.router, prefix="/api/v1/basket", tags=["Корзина"]) app.include_router(payment.router, prefix="/api/v1/payments", tags=["Методы оплаты"]) app.include_router(recipient.router, prefix="/api/v1/recipients", tags=["Получатели"]) + +app.include_router(roles.router, prefix="/api/v1/users", tags=["Пользователи и роли"]) app.include_router(login.router, prefix="/api/v1/auth", tags=["Авторизация"]) diff --git a/backend/models/category.py b/backend/models/category.py index 397b591..f786d77 100644 --- a/backend/models/category.py +++ b/backend/models/category.py @@ -10,6 +10,7 @@ class GoodCategory(Base): id = Column(Integer, primary_key=True, index=True) name = Column(String(255), unique=True, nullable=False) description = Column(Text, nullable=True) + image_url = Column(String(255), nullable=True) parent_id = Column(Integer, ForeignKey("good_categories.id", ondelete="SET NULL"), nullable=True) # Рекурсивная связь для подкатегорий diff --git a/backend/models/good.py b/backend/models/good.py index 55fcf63..667dafb 100644 --- a/backend/models/good.py +++ b/backend/models/good.py @@ -10,5 +10,6 @@ class Goods(Base): name = Column(String(255), nullable=False) description = Column(Text, nullable=True) price = Column(Integer, nullable=False) + image_url = Column(String(255), nullable=True) category_id = Column(Integer, ForeignKey("good_categories.id", ondelete="SET NULL"), nullable=True) diff --git a/backend/models/payment.py b/backend/models/payment.py index a4ed0c0..8fce192 100644 --- a/backend/models/payment.py +++ b/backend/models/payment.py @@ -8,5 +8,5 @@ class PaymentMethods(Base): id = Column(Integer, primary_key=True, index=True) title = Column(String(255), nullable=False) description = Column(Text, nullable=True) - image = Column(String, nullable=True) + image_url = Column(String(255), nullable=True) diff --git a/backend/routes/good_category.py b/backend/routes/good_category.py index 1e62be5..164428d 100644 --- a/backend/routes/good_category.py +++ b/backend/routes/good_category.py @@ -1,9 +1,13 @@ # app/routes/good_category.py +from typing import List + from constants import GOOD_CATEGORY_PAGE_SIZE as PAGE_SIZE, GET_GOOD_CATEGORIES_DESCRIPTION from database import get_db from fastapi import APIRouter, Depends, HTTPException, Query, Request + +from models.good import Goods from models.category import GoodCategory -from schemas import GoodCategoryCreate, GoodCategoryModel +from schemas import GoodCategoryCreate, GoodCategoryModel, GoodModel from responses import GetGoodCategoriesResponse from sqlalchemy import func, select from sqlalchemy.ext.asyncio import AsyncSession @@ -40,8 +44,7 @@ async def get_categories(request: Request, page: int = Query(1, ge=1), } -@router.post("", - response_model=GoodCategoryModel) +@router.post("", response_model=GoodCategoryModel) async def create_category(category: GoodCategoryCreate, db: AsyncSession = Depends(get_db), user_data: dict = Depends(verify_token)): # validation @@ -69,6 +72,17 @@ async def get_category(category_id: int, db: AsyncSession = Depends(get_db)): return category +@router.get("/{category_id}/goods") +async def get_category(category_id: int, db: AsyncSession = Depends(get_db)): + # validation + await validate_category_exists(category_id, db) + + # get category + result = await db.execute(select(Goods).filter(Goods.category_id == category_id)) + goods = result.scalars().all() + return goods + + @router.patch("/{category_id}", response_model=GoodCategoryModel) async def update_category(category_id: int, category: GoodCategoryCreate, db: AsyncSession = Depends(get_db), user_data: dict = Depends(verify_token)): @@ -86,6 +100,7 @@ async def update_category(category_id: int, category: GoodCategoryCreate, db: As db_category.name = category.name db_category.description = category.description db_category.parent_id = category.parent_id + db_category.image_url = category.image_url await db.commit() await db.refresh(db_category) # обновление return db_category diff --git a/backend/routes/roles.py b/backend/routes/roles.py new file mode 100644 index 0000000..fb4c8d9 --- /dev/null +++ b/backend/routes/roles.py @@ -0,0 +1,58 @@ +# app/routes/roles.py +from models.user import UserRole + +from database import get_db, is_testing +from fastapi import APIRouter, Depends, HTTPException, Response, Query +from models.user import OTP, User +from schemas import UserLogin, UserVerify +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession +from utils.auth import generate_access_token, generate_code, generate_refresh_token, verify_token +from validators import validate_admin_only + +router = APIRouter() + + +@router.get("") +async def get_all_users(user_data: dict = Depends(verify_token), db: AsyncSession = Depends(get_db)): + await validate_admin_only(user_data, db) + result = await db.execute(select(User)) + all_users = result.scalars().all() + return all_users + + +@router.post("/{user_id}/set-role") +async def set_user_role(user_id: int, role: UserRole = Query(UserRole.USER), + user_data: dict = Depends(verify_token), db: AsyncSession = Depends(get_db)): + user = await validate_admin_only(user_data, db) + if role not in [UserRole.USER, UserRole.ADMIN, UserRole.SELLER, UserRole.OWNER]: + raise HTTPException(status_code=403, detail="Invalid role.") + if user_id == user.id: + raise HTTPException(status_code=403, detail="Can't give out a role to yourself") + aim = await db.get(User, user_id) + if not aim: + raise HTTPException(status_code=404, detail="User not found.") + if user.role == role: + raise HTTPException(status_code=403, detail=f"Equal credentials or higher ({role.value})") + if user.role == UserRole.ADMIN and role == UserRole.OWNER: + raise HTTPException(status_code=403, detail=f"Equal credentials or higher ({role.value})") + aim.role = role + await db.commit() + await db.refresh(aim) + return aim + + +@router.delete("/{user_id}") +async def delete_user(user_id: int, user_data: dict = Depends(verify_token), db: AsyncSession = Depends(get_db)): + user = await validate_admin_only(user_data, db) + if user_id == user.id: + raise HTTPException(status_code=403, detail="Can't delete yourself") + aim = await db.get(User, user_id) + if not aim: + raise HTTPException(status_code=404, detail="User not found.") + if user.role == aim.role: + raise HTTPException(status_code=403, detail=f"Equal credentials or higher ({user.role})") + if user.role == UserRole.ADMIN and aim.role == UserRole.OWNER: + raise HTTPException(status_code=403, detail=f"Equal credentials or higher ({user.role})") + await db.delete(aim) + await db.commit() \ No newline at end of file diff --git a/backend/schemas.py b/backend/schemas.py index c4fabcb..b5752c3 100644 --- a/backend/schemas.py +++ b/backend/schemas.py @@ -9,6 +9,7 @@ class GoodCategoryCreate(BaseModel): name: str description: Optional[str] = None # Описание parent_id: Optional[int] = None # ID родительской категории + image_url: Optional[str] = None class Config: orm_mode = True @@ -27,6 +28,7 @@ class GoodCreate(BaseModel): name: str description: Optional[str] = None # Описание price: int # Цена + image_url: Optional[str] = None category_id: Optional[int] = None # ID категории class Config: @@ -65,7 +67,7 @@ class Config: class PaymentMethodCreate(BaseModel): title: str description: Optional[str] = None - image: Optional[str] = None + image_url: Optional[str] = None class Config: orm_mode = True diff --git a/backend/validators.py b/backend/validators.py index e25ba2c..41900e6 100644 --- a/backend/validators.py +++ b/backend/validators.py @@ -55,3 +55,12 @@ async def validate_staff_only(user_data: dict, db: AsyncSession = Depends(get_db if db_user.role not in [UserRole.OWNER, UserRole.ADMIN, UserRole.SELLER]: raise HTTPException(status_code=403, detail="Staff only") return db_user + +async def validate_admin_only(user_data: dict, db: AsyncSession = Depends(get_db)) -> User: + """Доступ только для администрации""" + user_email = user_data.get("sub") + result = await db.execute(select(User).filter(User.email == user_email)) + db_user = result.scalars().first() + if db_user.role not in [UserRole.OWNER, UserRole.ADMIN]: + raise HTTPException(status_code=403, detail="Admin only") + return db_user diff --git a/docker-compose.yml b/docker-compose.yml index 720be51..833b57b 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,4 +1,18 @@ services: + minio: + image: minio/minio + container_name: minio + environment: + - MINIO_ROOT_USER=${MINIO_ROOT_USER} + - MINIO_ROOT_PASSWORD=${MINIO_ROOT_PASSWORD} + ports: + - "9000:9000" + volumes: + - minio_data:/data + command: server /data + networks: + - app_network + db: build: context: ./db @@ -40,6 +54,7 @@ services: volumes: postgres_data: + minio_data: networks: app_network: diff --git a/example.env b/example.env index 85ed3cd..6283a28 100644 --- a/example.env +++ b/example.env @@ -4,6 +4,9 @@ POSTGRES_DB=db_name POSTGRES_HOST=host_name POSTGRES_PORT=5432 +MINIO_ROOT_USER=access +MINIO_ROOT_PASSWORD=secret1234 + DATABASE_URL=postgresql://${POSTGRES_USER}:${POSTGRES_PASSWORD}@${POSTGRES_HOST}:${POSTGRES_PORT}/${POSTGRES_DB} DATABASE_URL_TEST=sqlite+aiosqlite:///./test.db