diff --git a/auth/jwt.py b/auth/jwt.py new file mode 100644 index 0000000..3e7a172 --- /dev/null +++ b/auth/jwt.py @@ -0,0 +1,33 @@ +from typing import Annotated + +from fastapi import HTTPException, Depends +from fastapi.security import OAuth2PasswordBearer +from sqlalchemy import select +from starlette import status + +from backend import config +from database import User +from jose import jwt + +from database.base import DatabaseDependency + +oauth2_scheme = OAuth2PasswordBearer("") +ALGORITHM = "HS256" + + +def generate_jwt_token(user: User): + return jwt.encode({'sub': user.id}, settings.SECRET_KEY, algorithm=ALGORITHM) + + +def require_jwt_sub(token: Annotated[str, Depends(oauth2_scheme)]): + payload = jwt.decode(token, settings.SECRET_KEY, algorithms=[ALGORITHM]) + user_id = payload.get("sub") + if not user_id: + raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail='Invalid authorization credentials') + return payload + + +async def get_current_user(db_session: DatabaseDependency, user_id: Annotated[int, Depends(require_jwt_sub)]) -> User: + user = await db_session.scalar(select(User).where(User.id == user_id)) + if user: + return user diff --git a/auth/telegram.py b/auth/telegram.py index 63845da..cbf2b1b 100644 --- a/auth/telegram.py +++ b/auth/telegram.py @@ -1,8 +1,7 @@ import hmac import hashlib -import os -import settings +from backend import config def _generate_hash(telegram_data: dict): diff --git a/schemas/auth/__init__.py b/backend/__init__.py similarity index 100% rename from schemas/auth/__init__.py rename to backend/__init__.py diff --git a/settings.py b/backend/config.py similarity index 88% rename from settings.py rename to backend/config.py index 7993a5f..c91018b 100644 --- a/settings.py +++ b/backend/config.py @@ -13,3 +13,5 @@ PG_HOST = os.environ.get('PG_HOST') # Telegram TELEGRAM_BOT_TOKEN = os.environ.get('TELEGRAM_BOT_TOKEN') + +SECRET_KEY = os.environ.get('SECRET_KEY') diff --git a/backend/session.py b/backend/session.py new file mode 100644 index 0000000..2d328ac --- /dev/null +++ b/backend/session.py @@ -0,0 +1,19 @@ +from sqlalchemy.ext.asyncio import create_async_engine, AsyncSession +from sqlalchemy.orm import sessionmaker + +from .config import PG_DATABASE, PG_HOST, PG_PASSWORD, PG_LOGIN + +DATABASE_URL = f'postgresql+asyncpg://{PG_LOGIN}:{PG_PASSWORD}@{PG_HOST}/{PG_DATABASE}' +engine = create_async_engine(DATABASE_URL) +session_maker = sessionmaker( + engine, + class_=AsyncSession, + expire_on_commit=False, + autocommit=False, + autoflush=False +) + + +async def get_session() -> AsyncSession: + async with session_maker() as session: + yield session diff --git a/database/__init__.py b/database/__init__.py deleted file mode 100644 index 498c42e..0000000 --- a/database/__init__.py +++ /dev/null @@ -1 +0,0 @@ -from .models.basic import * diff --git a/database/base.py b/database/base.py deleted file mode 100644 index 64a73f5..0000000 --- a/database/base.py +++ /dev/null @@ -1,24 +0,0 @@ -from typing import Annotated - -from fastapi import Depends -from sqlalchemy.ext.asyncio import create_async_engine, AsyncSession -from sqlalchemy.orm import sessionmaker, declarative_base - -from settings import PG_LOGIN, PG_PASSWORD, PG_HOST, PG_DATABASE - -DATABASE_URL = f'postgresql+asyncpg://{PG_LOGIN}:{PG_PASSWORD}@{PG_HOST}/{PG_DATABASE}' -engine = create_async_engine(DATABASE_URL) -session_maker = sessionmaker(engine, - class_=AsyncSession, - expire_on_commit=False, - autocommit=False, - autoflush=False) -BaseModel = declarative_base() - - -async def get_session() -> AsyncSession: - async with session_maker() as session: - yield session - - -DatabaseDependency = Annotated[AsyncSession, Depends(get_session)] diff --git a/database/models/__init__.py b/database/models/__init__.py deleted file mode 100644 index b02f55f..0000000 --- a/database/models/__init__.py +++ /dev/null @@ -1 +0,0 @@ -from .basic import User diff --git a/main.py b/main.py index bca948f..cce9b2b 100644 --- a/main.py +++ b/main.py @@ -1,28 +1,14 @@ -from typing import Annotated +from fastapi import FastAPI -from fastapi import FastAPI, Depends -from sqlalchemy import select -from sqlalchemy.ext.asyncio import AsyncSession - -from database.base import get_session -from database.models import User import routers app = FastAPI() routers_list = [ - routers.auth_router + routers.auth_router, + routers.deal_router ] for router in routers_list: app.include_router(router) -@app.get("/") -async def root(db_session: Annotated[AsyncSession, Depends(get_session)]): - user: User = await db_session.scalar(select(User).where(User.id == 1)) - return {"message": user.login} - - -@app.get("/hello/{name}") -async def say_hello(name: str): - return {"message": f"Hello {name}"} diff --git a/migrations/env.py b/migrations/env.py index 16f7f02..cdbcaed 100644 --- a/migrations/env.py +++ b/migrations/env.py @@ -1,15 +1,13 @@ import asyncio +import backend.config as settings from logging.config import fileConfig -from sqlalchemy import pool from sqlalchemy.engine import Connection -from sqlalchemy.ext.asyncio import async_engine_from_config from alembic import context -import settings -from database.base import engine, BaseModel -from database.models import * +from backend.session import engine +from models import BaseModel # this is the Alembic Config object, which provides # access to the values within the .ini file in use. diff --git a/models/__init__.py b/models/__init__.py new file mode 100644 index 0000000..d83de2e --- /dev/null +++ b/models/__init__.py @@ -0,0 +1,2 @@ +from .auth import * +from .deal import * diff --git a/database/models/basic.py b/models/auth.py similarity index 75% rename from database/models/basic.py rename to models/auth.py index deac34c..5670c29 100644 --- a/database/models/basic.py +++ b/models/auth.py @@ -1,6 +1,6 @@ -from sqlalchemy import Column, Integer, String, BigInteger, Boolean +from sqlalchemy import Column, Integer, BigInteger, String, Boolean -from ..base import BaseModel +from models.base import BaseModel class User(BaseModel): diff --git a/models/base.py b/models/base.py new file mode 100644 index 0000000..ebdfe6c --- /dev/null +++ b/models/base.py @@ -0,0 +1,3 @@ +from sqlalchemy.orm import declarative_base + +BaseModel = declarative_base() diff --git a/models/deal.py b/models/deal.py new file mode 100644 index 0000000..ece109c --- /dev/null +++ b/models/deal.py @@ -0,0 +1,41 @@ +from enum import IntEnum, unique + +from sqlalchemy import Column, Integer, String, DateTime, ForeignKey +from sqlalchemy.orm import relationship + +from models.base import BaseModel + + +@unique +class DealStatus(IntEnum): + AWAITING_ACCEPTANCE = 0 + PACKAGING = 1 + AWAITING_SHIPMENT = 2 + AWAITING_PAYMENT = 3 + COMPLETED = 4 + CANCELLED = 5 + + +class Deal(BaseModel): + __tablename__ = 'deals' + id = Column(Integer, autoincrement=True, primary_key=True, index=True) + name = Column(String, nullable=False, comment='Название сделки') + created_at = Column(DateTime, nullable=False, comment='Дата создания') + current_status = Column(Integer, nullable=False, comment='Текущий статус') + + status_history = relationship('DealStatusHistory', back_populates='deal') + + +class DealStatusHistory(BaseModel): + __tablename__ = 'deals_status_history' + id = Column(Integer, autoincrement=True, primary_key=True, index=True) + + deal_id = Column(Integer, ForeignKey('deals.id'), nullable=False, comment='ID сделки') + deal = relationship('Deal', back_populates='status_history') + + user_id = Column(Integer, ForeignKey('users.id'), nullable=False) + user = relationship('User') + + changed_at = Column(DateTime, nullable=False, comment='Дата и время когда произошла смена статуса') + from_status = Column(Integer, nullable=False, comment='Предыдущий статус') + to_status = Column(Integer, nullable=False, comment='Новый статус') diff --git a/routers/__init__.py b/routers/__init__.py index 582cb2c..cec341a 100644 --- a/routers/__init__.py +++ b/routers/__init__.py @@ -1 +1,3 @@ from .auth import auth_router +from .deal import deal_router + diff --git a/routers/auth.py b/routers/auth.py index 387be65..1f99985 100644 --- a/routers/auth.py +++ b/routers/auth.py @@ -1,12 +1,11 @@ -from fastapi import APIRouter -from sqlalchemy import select, insert +from typing import Annotated -import database -from database import User -from database.base import DatabaseDependency -from schemas.auth.requests import * -from auth.telegram import telegram_authorize -from schemas.auth.responses import AuthLoginResponse +from fastapi import APIRouter, Depends +from sqlalchemy.ext.asyncio import AsyncSession + +from backend.session import get_session +from schemas.auth import AuthLoginRequest, AuthLoginResponse +from services.auth import AuthService auth_router = APIRouter( prefix='/auth', @@ -15,6 +14,5 @@ auth_router = APIRouter( @auth_router.post('/login', response_model=AuthLoginResponse) -async def login(request: AuthLoginRequest, db_session: DatabaseDependency): - existing_user: User = await db_session.scalar(select(User).where(User.telegram_id == request.id)) - return AuthLoginResponse(ok=True, jwt_token="dasdasd") +async def login(request: AuthLoginRequest, session: Annotated[AsyncSession, Depends(get_session)]): + return await AuthService(session).authenticate(request) diff --git a/routers/deal.py b/routers/deal.py new file mode 100644 index 0000000..b781dde --- /dev/null +++ b/routers/deal.py @@ -0,0 +1,32 @@ +from typing import Annotated + +from fastapi import APIRouter, Depends +from sqlalchemy.ext.asyncio import AsyncSession + +from backend.session import get_session +from models import User +from schemas.deal import * +from services.auth import get_current_user +from services.deal import DealService + +deal_router = APIRouter( + prefix='/deal', + tags=['deal'] +) + + +@deal_router.post('/create') +async def create( + request: DealCreateRequest, + session: Annotated[AsyncSession, Depends(get_session)] +): + return await DealService(session).create(request) + + +@deal_router.post('/changeStatus', response_model=DealChangeStatusResponse) +async def change_status( + request: DealChangeStatusRequest, + session: Annotated[AsyncSession, Depends(get_session)], + user: Annotated[User, Depends(get_current_user)] +): + return await DealService(session).change_status(request, user) diff --git a/schemas/auth.py b/schemas/auth.py new file mode 100644 index 0000000..69e62f6 --- /dev/null +++ b/schemas/auth.py @@ -0,0 +1,13 @@ +from schemas.base import CustomModel + + +class AuthLoginRequest(CustomModel): + auth_date: int + first_name: str + hash: str + id: int + photo_url: str + + +class AuthLoginResponse(CustomModel): + access_token: str diff --git a/schemas/auth/requests.py b/schemas/auth/requests.py deleted file mode 100644 index e97dcd6..0000000 --- a/schemas/auth/requests.py +++ /dev/null @@ -1,9 +0,0 @@ -from pydantic import BaseModel - - -class AuthLoginRequest(BaseModel): - auth_date: int - first_name: str - hash: str - id: int - photo_url: str diff --git a/schemas/auth/responses.py b/schemas/auth/responses.py deleted file mode 100644 index e7b87eb..0000000 --- a/schemas/auth/responses.py +++ /dev/null @@ -1,6 +0,0 @@ -from pydantic import BaseModel - - -class AuthLoginResponse(BaseModel): - jwt_token: str - ok: bool diff --git a/schemas/base.py b/schemas/base.py index e69de29..e125939 100644 --- a/schemas/base.py +++ b/schemas/base.py @@ -0,0 +1,5 @@ +from pydantic import BaseModel + + +class CustomModel(BaseModel): + pass diff --git a/schemas/deal.py b/schemas/deal.py new file mode 100644 index 0000000..f053b2b --- /dev/null +++ b/schemas/deal.py @@ -0,0 +1,18 @@ +from schemas.base import CustomModel + + +class DealChangeStatusRequest(CustomModel): + deal_id: int + new_status: int + + +class DealChangeStatusResponse(CustomModel): + ok: bool + + +class DealCreateRequest(CustomModel): + name: str + + +class DealCreateResponse(CustomModel): + ok: bool diff --git a/services/__init__.py b/services/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/services/auth.py b/services/auth.py new file mode 100644 index 0000000..d794363 --- /dev/null +++ b/services/auth.py @@ -0,0 +1,56 @@ +from typing import Union, Annotated + +from fastapi import Depends, HTTPException +from fastapi.security import OAuth2PasswordBearer, HTTPBearer, HTTPAuthorizationCredentials +from jose import jwt, JWTError +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession +from starlette import status + +import backend.config +from backend.session import get_session +from models import User +from services.base import BaseService +from schemas.auth import * + +oauth2_schema = HTTPBearer() +algorithm = 'HS256' + + +async def get_current_user(session: Annotated[AsyncSession, Depends(get_session)], + token: Annotated[HTTPAuthorizationCredentials, Depends(oauth2_schema)]) -> User | None: + if not token.credentials: + raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail='Invalid token') + try: + payload = jwt.decode(token.credentials, backend.config.SECRET_KEY, algorithms=[algorithm]) + user_id = payload.get('sub') + if not user_id: + raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail='Invalid credentials') + user_id = int(user_id) + + user = await session.get(User, user_id) + if not user: + raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail='Invalid credentials') + return user + except JWTError as e: + print(e) + raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail='Invalid token') + + +class AuthService(BaseService): + @staticmethod + def _generate_jwt_token(user: User) -> str: + payload = { + 'sub': str(user.id) + } + return jwt.encode(payload, backend.config.SECRET_KEY, algorithm=algorithm) + + async def authenticate(self, request: AuthLoginRequest): + user: Union[User, None] = await self.session.scalar(select(User).where(User.telegram_id == request.id)) + if not user: + user = User(telegram_id=request.id, + is_admin=False) + self.session.add(user) + await self.session.commit() + access_token = self._generate_jwt_token(user) + return AuthLoginResponse(access_token=access_token) diff --git a/services/base.py b/services/base.py new file mode 100644 index 0000000..bee8f65 --- /dev/null +++ b/services/base.py @@ -0,0 +1,8 @@ +from sqlalchemy.ext.asyncio import AsyncSession + + +class BaseService: + session: AsyncSession + + def __init__(self, session: AsyncSession): + self.session = session diff --git a/services/deal.py b/services/deal.py new file mode 100644 index 0000000..4f145c6 --- /dev/null +++ b/services/deal.py @@ -0,0 +1,42 @@ +import datetime + +from sqlalchemy import select + +from models import User +from models.deal import * +from schemas.deal import * +from services.base import BaseService + + +class DealService(BaseService): + + async def _get_deal_by_id(self, deal_id) -> Deal: + return await self.session.get(Deal, deal_id) + + async def create(self, request: DealCreateRequest) -> DealCreateResponse: + deal = Deal( + name=request.name, + created_at=datetime.datetime.now(), + current_status=DealStatus.AWAITING_ACCEPTANCE + ) + self.session.add(deal) + await self.session.commit() + return DealCreateResponse(ok=True) + + async def change_status(self, request: DealChangeStatusRequest, user: User) -> DealChangeStatusResponse: + # Changing current status + deal = await self._get_deal_by_id(request.deal_id) + from_status = deal.current_status + deal.current_status = request.new_status + + # Append status history + status_change = DealStatusHistory( + deal_id=request.deal_id, + user_id=user.id, + changed_at=datetime.datetime.now(), + from_status=from_status, + to_status=request.new_status + ) + self.session.add(status_change) + await self.session.commit() + return DealChangeStatusResponse(ok=True) diff --git a/test/ockho.py b/test/ockho.py new file mode 100644 index 0000000..74b0018 --- /dev/null +++ b/test/ockho.py @@ -0,0 +1,9 @@ +from typing import Annotated + + +def get_v(): + return 123 + + +ockho: Annotated[int, get_v] +print(ockho)