diff --git a/backend/app/db/database.py b/backend/app/db/database.py index 1c90b59..388b535 100644 --- a/backend/app/db/database.py +++ b/backend/app/db/database.py @@ -1,4 +1,4 @@ -from sqlalchemy import create_engine +from sqlalchemy.ext.asyncio import create_async_engine, AsyncSession from sqlalchemy.ext.declarative import declarative_base from sqlalchemy.orm import sessionmaker import os @@ -9,11 +9,11 @@ POSTGRES_DB = os.environ.get("POSTGRES_DB", "db") POSTGRES_HOST = os.environ.get("POSTGRES_HOST", "postgres") -SQLALCHEMY_DATABASE_URL = ( - f"postgresql://{POSTGRES_USER}:{POSTGRES_PASSWORD}@{POSTGRES_HOST}/{POSTGRES_DB}" +SQLALCHEMY_DATABASE_URL = f"postgresql+asyncpg://{POSTGRES_USER}:{POSTGRES_PASSWORD}@{POSTGRES_HOST}/{POSTGRES_DB}" + +engine = create_async_engine(SQLALCHEMY_DATABASE_URL) +async_session = sessionmaker( + autocommit=False, class_=AsyncSession, autoflush=False, bind=engine ) -engine = create_engine(SQLALCHEMY_DATABASE_URL) -SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine) - Base = declarative_base() diff --git a/backend/app/dependencies.py b/backend/app/dependencies.py index 254bf63..4917d09 100644 --- a/backend/app/dependencies.py +++ b/backend/app/dependencies.py @@ -1,9 +1,11 @@ -from .db.database import SessionLocal +from sqlalchemy.ext.asyncio import AsyncSession + +from .db.database import async_session -def get_db(): - db = SessionLocal() - try: - yield db - finally: - db.close() +async def get_db() -> AsyncSession: + async with async_session() as session: + try: + yield session + finally: + session.close() diff --git a/backend/app/main.py b/backend/app/main.py index 288d2a9..c34b64e 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -2,7 +2,7 @@ from typing import Union from fastapi import FastAPI, Depends from .db import models -from .db.database import SessionLocal, engine +from .db.database import engine from .dependencies import get_db from .views.auth.api import router as auth_router @@ -10,13 +10,19 @@ from .views.queue.api import router as queue_router from .views.news.api import router as news_router app = FastAPI(dependencies=[Depends(get_db)]) -models.Base.metadata.create_all(bind=engine) + app.include_router(queue_router) app.include_router(auth_router) app.include_router(news_router) +@app.on_event("startup") +async def init_tables(): + async with engine.begin() as conn: + await conn.run_sync(models.Base.metadata.create_all) + + @app.get("/") async def read_root(): return {"message": "OK"} diff --git a/backend/app/views/auth/api.py b/backend/app/views/auth/api.py index d2b4a03..25ca196 100644 --- a/backend/app/views/auth/api.py +++ b/backend/app/views/auth/api.py @@ -27,7 +27,7 @@ async def login_for_access_token( form_data: Annotated[OAuth2PasswordRequestForm, Depends()], db: Annotated[Session, Depends(get_db)], ) -> schemas.Token: - user = services.authenticate_user(db, form_data.username, form_data.password) + user = await services.authenticate_user(db, form_data.username, form_data.password) if not user: raise HTTPException( status_code=status.HTTP_409_CONFLICT, @@ -46,7 +46,7 @@ async def register( user_data: schemas.UserRegister, db: Annotated[Session, Depends(get_db)], ) -> schemas.User: - user = services.get_user_by_username(db, user_data.username) + user = await services.get_user_by_username(db, user_data.username) if user: raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, @@ -59,7 +59,7 @@ async def register( detail="Passwords do not match", headers={"WWW-Authenticate": "Bearer"}, ) - user = services.create_user(db=db, user_data=user_data) + user = await services.create_user(db=db, user_data=user_data) return user diff --git a/backend/app/views/auth/schemas.py b/backend/app/views/auth/schemas.py index 1774fec..2466ade 100644 --- a/backend/app/views/auth/schemas.py +++ b/backend/app/views/auth/schemas.py @@ -27,3 +27,11 @@ class Token(BaseModel): class TokenData(BaseModel): username: Union[str, None] = None + + +class AnonUser(BaseModel): + id: UUID + name: str + + class Config: + from_attributes = True diff --git a/backend/app/views/auth/services.py b/backend/app/views/auth/services.py index c4ebf3e..7687c66 100644 --- a/backend/app/views/auth/services.py +++ b/backend/app/views/auth/services.py @@ -1,6 +1,9 @@ -from fastapi import status, HTTPException, Depends +from fastapi import status, HTTPException, Depends, Header from fastapi.security import OAuth2PasswordBearer -from sqlalchemy.orm import Session + +# from sqlalchemy.orm import Session +from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy.future import select from jose import JWTError, jwt from typing import Annotated, Union from datetime import datetime, timezone, timedelta @@ -24,16 +27,18 @@ def get_password_hash(password) -> str: return pwd_context.hash(password) -def get_user_by_id(db: Session, user_id: uuid.uuid4) -> models.User: - return db.query(models.User).filter(models.User.id == user_id).first() +async def get_user_by_id(db: AsyncSession, user_id: uuid.uuid4) -> models.User: + u = await db.execute(select(models.User).filter(models.User.id == user_id)) + return u.scalar_one_or_none() -def get_user_by_username(db: Session, username: int) -> models.User: - return db.query(models.User).filter(models.User.username == username).first() +async def get_user_by_username(db: AsyncSession, username: int) -> models.User: + u = await db.execute(select(models.User).filter(models.User.username == username)) + return u.scalar_one_or_none() -def authenticate_user(db: Session, username: str, password: str): - user = get_user_by_username(db, username) +async def authenticate_user(db: AsyncSession, username: str, password: str): + user = await get_user_by_username(db, username) if not user: return False if not verify_password(password, user.hashed_password): @@ -54,20 +59,22 @@ def create_access_token(data: dict, expires_delta: Union[timedelta, None] = None return encoded_jwt -def create_user(db: Session, user_data: schemas.UserRegister) -> schemas.UserInDB: +async def create_user( + db: AsyncSession, user_data: schemas.UserRegister +) -> schemas.UserInDB: user = models.User( username=user_data.username, name=user_data.name, hashed_password=get_password_hash(user_data.password), ) db.add(user) - db.commit() + await db.commit() return schemas.UserInDB.model_validate(user) async def get_current_user( token: Annotated[str, Depends(oauth2_scheme)], - db: Annotated[Session, Depends(get_db)], + db: Annotated[AsyncSession, Depends(get_db)], ) -> schemas.UserInDB: credentials_exception = HTTPException( status_code=status.HTTP_401_UNAUTHORIZED, @@ -84,7 +91,7 @@ async def get_current_user( token_data = schemas.TokenData(username=username) except JWTError: raise credentials_exception - user = get_user_by_username(db, username=token_data.username) + user = await get_user_by_username(db, username=token_data.username) if user is None: raise credentials_exception return user @@ -92,7 +99,7 @@ async def get_current_user( async def get_current_user_or_none( token: Annotated[str, Depends(oauth2_scheme)], - db: Annotated[Session, Depends(get_db)], + db: Annotated[AsyncSession, Depends(get_db)], ) -> Union[schemas.UserInDB, None]: try: payload = jwt.decode( @@ -104,7 +111,7 @@ async def get_current_user_or_none( token_data = schemas.TokenData(username=username) except JWTError: return None - user = get_user_by_username(db, username=token_data.username) + user = await get_user_by_username(db, username=token_data.username) return user @@ -114,3 +121,24 @@ async def get_current_active_user( if not current_user.is_active: raise HTTPException(status_code=400, detail="Inactive user") return current_user + + +async def create_anon_user( + db: Annotated[AsyncSession, Depends(get_db)] +) -> schemas.AnonUser: + u = models.AnonymousUser() + db.add(u) + await db.commit() + return schemas.AnonUser.model_validate(u) + + +async def get_anon_user( + db: Annotated[AsyncSession, Depends(get_db)], + device_id: Annotated[Union[str, None], Header()] = None, +) -> schemas.AnonUser: + if device_id: + u = await db.execute( + select(models.AnonymousUser).filter(models.AnonymousUser.id == device_id) + ) + return schemas.AnonUser.model_validate(u.scalar_one_or_none()) + return await create_anon_user(db) diff --git a/backend/app/views/news/api.py b/backend/app/views/news/api.py index 4dd695a..df6e560 100644 --- a/backend/app/views/news/api.py +++ b/backend/app/views/news/api.py @@ -39,7 +39,8 @@ async def create_news( current_user: Annotated[auth_schemas.User, Depends(auth_services.get_current_user)], db: Annotated[Session, Depends(get_db)], ) -> schemas.NewsInDb: - return services.create_news(news=news, current_user=current_user, db=db) + n = await services.create_news(news=news, current_user=current_user, db=db) + return n @router.post("/{news_id}/tap") diff --git a/backend/app/views/news/services.py b/backend/app/views/news/services.py index dce3625..1bbbb1b 100644 --- a/backend/app/views/news/services.py +++ b/backend/app/views/news/services.py @@ -1,6 +1,7 @@ from fastapi import Depends, HTTPException, status from typing import Annotated -from sqlalchemy.orm import Session +from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy.future import select from uuid import UUID from ...dependencies import get_db @@ -12,24 +13,23 @@ from ..auth import schemas as auth_schemas from . import schemas -def get_news( - db: Annotated[Session, Depends(get_db)], +async def get_news( + db: Annotated[AsyncSession, Depends(get_db)], ) -> list[schemas.NewsInDb]: - return [ - schemas.NewsInDb.model_validate(n) - for n in db.query(models.News).order_by(models.News.created.desc()).all() - ] + news = await db.execute(select(models.News).order_by(models.News.created.desc())) + return [schemas.NewsInDb.model_validate(n) for n in news.scalars().all()] -def create_news( +async def create_news( news: schemas.CreateNews, current_user: auth_schemas.UserInDB, - db: Session, + db: AsyncSession, ) -> schemas.NewsInDb: if current_user.username == "admin": n = models.News(title=news.title, content=news.content) db.add(n) - db.commit() + await db.commit() + print(f"\n\n{n.title}\n\n", flush=True) return schemas.NewsInDb.model_validate(n) raise HTTPException( status_code=status.HTTP_401_UNAUTHORIZED, @@ -38,7 +38,7 @@ def create_news( ) -def tap_news(news_id: UUID, db: Session): +async def tap_news(news_id: UUID, db: AsyncSession): n = db.query(models.News).filter(models.News.id == news_id).first() if n: setattr(n, "taps", n.taps + 1) diff --git a/backend/app/views/queue/services.py b/backend/app/views/queue/services.py index 0afcbb3..7da5655 100644 --- a/backend/app/views/queue/services.py +++ b/backend/app/views/queue/services.py @@ -12,13 +12,13 @@ from ..auth import schemas as auth_schemas from . import schemas -def get_user_queues( +async def get_user_queues( current_user: Annotated[auth_schemas.User, Depends(auth_services.get_current_user)] ) -> list[schemas.QueueInDb]: return [schemas.QueueInDb.model_validate(q) for q in current_user.owns_queues] -def create_queue( +async def create_queue( new_queue: schemas.Queue, current_user: auth_schemas.UserInDB, db: Session, @@ -31,7 +31,7 @@ def create_queue( return schemas.QueueInDb.model_validate(q) -def get_detailed_queue( +async def get_detailed_queue( queue_id: UUID, db: Annotated[Session, Depends(get_db)], ) -> schemas.QueueDetail: diff --git a/backend/requirements.txt b/backend/requirements.txt index 126e36c..445dcec 100644 --- a/backend/requirements.txt +++ b/backend/requirements.txt @@ -2,6 +2,6 @@ fastapi[all] uvicorn pydantic sqlalchemy -psycopg2-binary +asyncpg python-jose[cryptography] -passlib[all] \ No newline at end of file +passlib[all] diff --git a/frontend/app/rsbuild.config.ts b/frontend/app/rsbuild.config.ts index 06a2ab0..8543621 100644 --- a/frontend/app/rsbuild.config.ts +++ b/frontend/app/rsbuild.config.ts @@ -8,7 +8,7 @@ export default defineConfig({ port: 3000, }, html: { - favicon: "./static/favicon-32x32.png", + favicon: "./static/android-chrome-512x512.png", title: "queueful!", template: "./static/index.html", },