diff options
| author | l3wdfut4pwr <l3wdfut4pwr@gmail.com> | 2026-03-17 14:11:45 +0200 |
|---|---|---|
| committer | l3wdfut4pwr <l3wdfut4pwr@gmail.com> | 2026-03-17 14:11:45 +0200 |
| commit | 47fd81a5910eab3483f79d03eedf9307bc81252f (patch) | |
| tree | 3e1ebd1bddcea649eb968e65161555afd4c2898c /app | |
| parent | f01cb6703710b7df4c7c022047cd35e1d5e9c70e (diff) | |
simple registration prototype
Diffstat (limited to 'app')
| -rw-r--r-- | app/auth/register.py | 32 | ||||
| -rw-r--r-- | app/main.py | 82 | ||||
| -rw-r--r-- | app/models/auth.py | 0 | ||||
| -rw-r--r-- | app/models/collections.py | 40 | ||||
| -rw-r--r-- | app/models/image.py | 70 | ||||
| -rw-r--r-- | app/models/integrations.py | 19 | ||||
| -rw-r--r-- | app/models/profile.py | 21 | ||||
| -rw-r--r-- | app/models/upload.py | 0 | ||||
| -rw-r--r-- | app/models/user.py | 23 | ||||
| -rw-r--r-- | app/routes/register.py | 115 | ||||
| -rw-r--r-- | app/routes/routes.py | 6 | ||||
| -rw-r--r-- | app/schemas/auth.py | 0 | ||||
| -rw-r--r-- | app/schemas/user.py | 29 | ||||
| -rw-r--r-- | app/utils/create_tables.py | 8 | ||||
| -rw-r--r-- | app/utils/db.py | 25 | ||||
| -rw-r--r-- | app/utils/hash_cfg.py | 16 | ||||
| -rw-r--r-- | app/utils/logger_cfg.py | 10 |
17 files changed, 493 insertions, 3 deletions
diff --git a/app/auth/register.py b/app/auth/register.py index e69de29..f1c3ec3 100644 --- a/app/auth/register.py +++ b/app/auth/register.py @@ -0,0 +1,32 @@ +from app.models.auth import User +from argon2 import PasswordHasher +from fastapi import APIRouter, Depends, HTTPException, status +from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy.future import select + +from app.schemas.auth import RegisterSchema +from app.utils.db import get_db + +router = APIRouter() +ph = PasswordHasher() + + +@router.post("/register") +async def register(user: RegisterSchema, db: AsyncSession = Depends(get_db)): + result = await db.execute( + select(User).where( + (User.username == user.username) | (User.email == user.email) + ) + ) + existing = result.scalar_one_or_none() + if existing: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="Username or email already exists", + ) + + hashed = ph.hash(user.password) + new_user = User(username=user.username, email=user.email, password_hash=hashed) + db.add(new_user) + await db.commit() + return {"message": "User registered successfully"} diff --git a/app/main.py b/app/main.py index fcb66a0..8b7d6eb 100644 --- a/app/main.py +++ b/app/main.py @@ -1,10 +1,86 @@ +import time + from fastapi import FastAPI +from sqlalchemy import text + +from app.routes.routes import router as api_router +from app.utils.create_tables import init_db +from app.utils.db import engine +from app.utils.logger_cfg import logger + +app_start_time = time.perf_counter() +logger.debug("App start timestamp recorded") app = FastAPI() +logger.info("FastAPI application instance created") +logger.debug("FastAPI instance created successfully") + +app.include_router(api_router, prefix="/api") +logger.info("API router registered with prefix /api") +logger.debug("Router object: %s", api_router) + + +@app.on_event("startup") +async def startup(): + logger.info("Application startup initiated") + logger.debug("Starting database connection attempt") + + try: + async with engine.begin() as conn: + logger.debug("Executing test query: SELECT 1") + result = await conn.execute(text("SELECT 1")) + db_status = result.scalar() + logger.info(f"Database connection successful: {db_status}") + logger.debug("Database test query executed successfully") + + logger.debug("Starting database table initialization") + await init_db() + logger.info("Database initialization completed") + logger.debug("Database tables initialized successfully") + + except Exception as e: + logger.exception(f"Database startup failed: {e}") + + elapsed = time.perf_counter() - app_start_time + logger.success( + f"Application startup completed in {elapsed*1000:.2f} ms ({elapsed:.6f} s)" + ) + logger.debug("Startup complete timestamp recorded") + + +@app.on_event("shutdown") +async def shutdown(): + logger.info("Application shutdown initiated") + logger.debug("Starting engine disposal") + try: + await engine.dispose() + logger.info("Database engine disposed successfully") + logger.debug("Engine disposal finished") + except Exception as e: + logger.exception(f"Error during shutdown: {e}") @app.get("/") -def read_root(): +async def read_root(): + logger.info("Root endpoint accessed") + logger.debug("Processing root endpoint request") + return {"message": "Hello new asyncpg ci!"} + + +@app.get("/check-db") +async def check_db(): + logger.info("Database health check endpoint called") + logger.debug("Starting database health check") + + try: + async with engine.begin() as conn: + logger.debug("Executing test query: SELECT 1") + result = await conn.execute(text("SELECT 1")) + db_result = result.scalar() + logger.info(f"Database health check successful: {db_result}") + logger.debug("Database health check query executed successfully") + return {"db_check": db_result} - msg = "Hello new ci!" - return {"message": msg} + except Exception as e: + logger.exception(f"Database health check failed: {e}") + raise diff --git a/app/models/auth.py b/app/models/auth.py deleted file mode 100644 index e69de29..0000000 --- a/app/models/auth.py +++ /dev/null diff --git a/app/models/collections.py b/app/models/collections.py new file mode 100644 index 0000000..7dc5bb7 --- /dev/null +++ b/app/models/collections.py @@ -0,0 +1,40 @@ +from __future__ import annotations + +from datetime import datetime +from typing import TYPE_CHECKING + +from sqlalchemy import Boolean, DateTime, ForeignKey, Integer, String, Table +from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column, relationship + +Base = DeclarativeBase() + +if TYPE_CHECKING: + from .image import Image + +collection_images = Table( + "collection_images", + Base.metadata, + mapped_column( + "collection_id", Integer, ForeignKey("collections.id"), primary_key=True + ), + mapped_column("image_id", Integer, ForeignKey("images.id"), primary_key=True), +) + + +class Collection(Base): + __tablename__ = "collections" + + id: Mapped[int] = mapped_column(Integer, primary_key=True) + user_id: Mapped[int] = mapped_column(ForeignKey("users.id"), nullable=False) + name: Mapped[str] = mapped_column(String(100), nullable=False) + description: Mapped[str | None] = mapped_column(String(500), nullable=True) + is_private: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False) + created_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), default=datetime.utcnow + ) + + images: Mapped[list["Image"]] = relationship( + "Image", + secondary=collection_images, + back_populates="collections", + ) diff --git a/app/models/image.py b/app/models/image.py new file mode 100644 index 0000000..e139123 --- /dev/null +++ b/app/models/image.py @@ -0,0 +1,70 @@ +from __future__ import annotations + +from datetime import datetime + +from sqlalchemy import ( + Boolean, + Column, + DateTime, + ForeignKey, + Integer, + String, + Table, + func, +) +from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column, relationship + +from .collections import Collection + +Base = DeclarativeBase() + +image_tags = Table( + "image_tags", + Base.metadata, + Column("image_id", Integer, ForeignKey("images.id"), primary_key=True), + Column("tag_id", Integer, ForeignKey("tags.id"), primary_key=True), +) + + +class Tag(Base): + __tablename__ = "tags" + + id: Mapped[int] = mapped_column(Integer, primary_key=True) + name: Mapped[str] = mapped_column(String(50), unique=True, nullable=False) + category: Mapped[str | None] = mapped_column(String(50), nullable=True) + + images: Mapped[list["Image"]] = relationship( + "Image", + secondary=image_tags, + back_populates="tags", + ) + + +class Image(Base): + __tablename__ = "images" + + id: Mapped[int] = mapped_column(Integer, primary_key=True) + image_file: Mapped[str] = mapped_column(String(255), nullable=False) + original_file: Mapped[str] = mapped_column(String(255), nullable=False) + user_id: Mapped[int] = mapped_column(ForeignKey("users.id"), nullable=False) + publication_date: Mapped[datetime] = mapped_column( + DateTime(timezone=True), server_default=func.now() + ) + source: Mapped[str] = mapped_column(String(255), nullable=False) + views: Mapped[int] = mapped_column(Integer, default=0, nullable=False) + likes: Mapped[int] = mapped_column(Integer, default=0, nullable=False) + is_verified: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False) + is_rejected: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False) + + tags: Mapped[list[Tag]] = relationship( + "Tag", + secondary=image_tags, + back_populates="images", + ) + + +collections: Mapped[list["Collection"]] = relationship( + "Collection", + secondary="collection_images", + back_populates="images", +) diff --git a/app/models/integrations.py b/app/models/integrations.py new file mode 100644 index 0000000..4a1ddb9 --- /dev/null +++ b/app/models/integrations.py @@ -0,0 +1,19 @@ +from sqlalchemy import ForeignKey, Integer, String +from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column + +Base = DeclarativeBase() + + +class UserIntegration(Base): + __tablename__ = "user_integrations" + + id: Mapped[int] = mapped_column(Integer, primary_key=True) + user_id: Mapped[int] = mapped_column(ForeignKey("users.id"), nullable=False) + + facebook: Mapped[str | None] = mapped_column(String(255), nullable=True) + pinterest: Mapped[str | None] = mapped_column(String(255), nullable=True) + discord: Mapped[str | None] = mapped_column(String(255), nullable=True) + artstation: Mapped[str | None] = mapped_column(String(255), nullable=True) + x: Mapped[str | None] = mapped_column(String(255), nullable=True) # Twitter/X + behance: Mapped[str | None] = mapped_column(String(255), nullable=True) + instagram: Mapped[str | None] = mapped_column(String(255), nullable=True) diff --git a/app/models/profile.py b/app/models/profile.py new file mode 100644 index 0000000..b19d796 --- /dev/null +++ b/app/models/profile.py @@ -0,0 +1,21 @@ +from sqlalchemy import ForeignKey, Integer, String +from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column + +Base = DeclarativeBase() + + +class Profile(Base): + __tablename__ = "profiles" + + id: Mapped[int] = mapped_column(Integer, primary_key=True) + user_id: Mapped[int] = mapped_column(ForeignKey("users.id"), unique=True) + + avatar_file: Mapped[str | None] = mapped_column(String(255), nullable=True) + banner_file: Mapped[str | None] = mapped_column(String(255), nullable=True) + description: Mapped[str | None] = mapped_column(String(500), nullable=True) + + publications_count: Mapped[int] = mapped_column(Integer, default=0, nullable=False) + collections_count: Mapped[int] = mapped_column(Integer, default=0, nullable=False) + subscriptions_count: Mapped[int] = mapped_column(Integer, default=0, nullable=False) + followers_count: Mapped[int] = mapped_column(Integer, default=0, nullable=False) + following_count: Mapped[int] = mapped_column(Integer, default=0, nullable=False) diff --git a/app/models/upload.py b/app/models/upload.py deleted file mode 100644 index e69de29..0000000 --- a/app/models/upload.py +++ /dev/null diff --git a/app/models/user.py b/app/models/user.py new file mode 100644 index 0000000..de9fa98 --- /dev/null +++ b/app/models/user.py @@ -0,0 +1,23 @@ +from sqlalchemy import Boolean, Integer, String +from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column + + +class Base(DeclarativeBase): + pass + + +class User(Base): + __tablename__ = "users" + + id: Mapped[int] = mapped_column(Integer, primary_key=True) + username: Mapped[str] = mapped_column(String(20), unique=True, nullable=False) + password: Mapped[str | None] = mapped_column(String(255), nullable=True) + email: Mapped[str | None] = mapped_column(String(120), unique=True, nullable=True) + google_id: Mapped[str | None] = mapped_column( + String(255), unique=True, nullable=True + ) + avatar_file: Mapped[str | None] = mapped_column(String(255), nullable=True) + banner_file: Mapped[str | None] = mapped_column(String(255), nullable=True) + premium: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False) + is_banned: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False) + is_moderator: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False) diff --git a/app/routes/register.py b/app/routes/register.py new file mode 100644 index 0000000..0134c56 --- /dev/null +++ b/app/routes/register.py @@ -0,0 +1,115 @@ +import re +from typing import Optional + +from fastapi import APIRouter, Depends, HTTPException +from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy.future import select + +from app.models.user import User +from app.schemas.user import UserCreate, UserRead +from app.utils.db import get_async_session +from app.utils.hash_cfg import hash_password +from app.utils.logger_cfg import logger + +router = APIRouter(prefix="/auth", tags=["auth"]) + + +@router.post("/register", response_model=UserRead) +async def register_user( + user: UserCreate, session: AsyncSession = Depends(get_async_session) +): + logger.debug("Register request received") + + email: Optional[str] = user.email.strip() if user.email else None + + logger.debug("Normalized email value: {}", email) + + logger.info( + "Registration attempt | username={} email={}", + user.username, + email, + ) + + logger.debug("Validating password complexity") + + if not ( + re.search(r"[A-Za-z]", user.password) + and re.search(r"\d", user.password) + and re.search(r"[^\w\s]", user.password) + ): + logger.warning( + "Registration failed | password complexity requirement not met | username={}", + user.username, + ) + + logger.debug("Checking if username already exists") + result = await session.execute(select(User).where(User.username == user.username)) + existing_username = result.scalars().first() + if existing_username: + logger.warning( + "Registration failed | username already exists | username={}", + user.username, + ) + raise HTTPException( + status_code=400, + detail={"field": "username", "message": "Никнейм уже занят."}, + ) + + if email: + logger.debug("Checking if email already exists") + result = await session.execute(select(User).where(User.email == email)) + existing_email = result.scalars().first() + if existing_email: + logger.warning( + "Registration failed | email already exists | email={}", + email, + ) + raise HTTPException( + status_code=400, + detail={"field": "email", "message": "Адрес уже занят."}, + ) + + logger.debug("Starting password hashing") + + hashed_password = hash_password(user.password) + + logger.debug("Password hashing completed") + + logger.debug("Creating new user") + + new_user = User( + username=user.username, + email=email, + password=hashed_password, + ) + + logger.debug("User model created | username={}", user.username) + + logger.debug("Adding user to session") + + session.add(new_user) + + logger.debug("Preparing to commit database transaction") + + await session.commit() + + logger.debug("Transaction committed successfully") + + logger.debug("Refreshing user instance from database") + + await session.refresh(new_user) + + logger.debug( + "User instance refreshed | id={} username={}", + new_user.id, + new_user.username, + ) + + logger.success( + "User successfully registered | id={} username={} email={}", + new_user.id, + new_user.username, + new_user.email, + ) + + return new_user diff --git a/app/routes/routes.py b/app/routes/routes.py index e69de29..7db7ec4 100644 --- a/app/routes/routes.py +++ b/app/routes/routes.py @@ -0,0 +1,6 @@ +from fastapi import APIRouter + +from .register import router as register_router + +router = APIRouter() +router.include_router(register_router) diff --git a/app/schemas/auth.py b/app/schemas/auth.py deleted file mode 100644 index e69de29..0000000 --- a/app/schemas/auth.py +++ /dev/null diff --git a/app/schemas/user.py b/app/schemas/user.py new file mode 100644 index 0000000..7151d54 --- /dev/null +++ b/app/schemas/user.py @@ -0,0 +1,29 @@ +from typing import Optional + +from pydantic import BaseModel, EmailStr, Field + + +class UserCreate(BaseModel): + username: str = Field(..., max_length=32) + email: Optional[EmailStr] = None + password: str = Field(..., min_length=8) + + model_config = { + "extra": "forbid", + } + + +class UserRead(BaseModel): + id: int + username: str + email: Optional[EmailStr] = None + google_id: Optional[str] = None + avatar_file: Optional[str] = None + banner_file: Optional[str] = None + premium: bool + is_banned: bool + is_moderator: bool + + model_config = { + "from_attributes": True, + } diff --git a/app/utils/create_tables.py b/app/utils/create_tables.py new file mode 100644 index 0000000..bb9baf3 --- /dev/null +++ b/app/utils/create_tables.py @@ -0,0 +1,8 @@ +# app/utils/create_tables.py +from app.models.user import Base +from app.utils.db import engine + + +async def init_db(): + async with engine.begin() as conn: + await conn.run_sync(Base.metadata.create_all) diff --git a/app/utils/db.py b/app/utils/db.py new file mode 100644 index 0000000..603ff68 --- /dev/null +++ b/app/utils/db.py @@ -0,0 +1,25 @@ +# app/utils/db.py +import os + +from dotenv import load_dotenv +from sqlalchemy.ext.asyncio import AsyncSession, create_async_engine +from sqlalchemy.orm import DeclarativeBase, sessionmaker + +load_dotenv() + +DATABASE_URL = os.getenv("DATABASE_URL") +if not DATABASE_URL: + raise ValueError("DATABASE_URL не найден в .env!") + +engine = create_async_engine(DATABASE_URL, echo=True, future=True) + +async_session = sessionmaker(bind=engine, class_=AsyncSession, expire_on_commit=False) + + +async def get_async_session() -> AsyncSession: + async with async_session() as session: + yield session + + +class Base(DeclarativeBase): + pass diff --git a/app/utils/hash_cfg.py b/app/utils/hash_cfg.py new file mode 100644 index 0000000..9937c99 --- /dev/null +++ b/app/utils/hash_cfg.py @@ -0,0 +1,16 @@ +from passlib.hash import argon2 + +argon2_hasher = argon2.using( + type="ID", + time_cost=3, + memory_cost=65536, + parallelism=4, +) + + +def hash_password(password: str) -> str: + return argon2_hasher.hash(password) + + +def verify_password(password: str, hashed: str) -> bool: + return argon2_hasher.verify(password, hashed) diff --git a/app/utils/logger_cfg.py b/app/utils/logger_cfg.py new file mode 100644 index 0000000..fa5064f --- /dev/null +++ b/app/utils/logger_cfg.py @@ -0,0 +1,10 @@ +from loguru import logger + +logger.add( + "logs/app.log", + rotation="50 MB", + retention="10 days", + level="DEBUG", + backtrace=True, + diagnose=True, +) |
