summaryrefslogtreecommitdiff
path: root/app
diff options
context:
space:
mode:
Diffstat (limited to 'app')
-rw-r--r--app/auth/register.py32
-rw-r--r--app/main.py82
-rw-r--r--app/models/auth.py0
-rw-r--r--app/models/collections.py40
-rw-r--r--app/models/image.py70
-rw-r--r--app/models/integrations.py19
-rw-r--r--app/models/profile.py21
-rw-r--r--app/models/upload.py0
-rw-r--r--app/models/user.py23
-rw-r--r--app/routes/register.py115
-rw-r--r--app/routes/routes.py6
-rw-r--r--app/schemas/auth.py0
-rw-r--r--app/schemas/user.py29
-rw-r--r--app/utils/create_tables.py8
-rw-r--r--app/utils/db.py25
-rw-r--r--app/utils/hash_cfg.py16
-rw-r--r--app/utils/logger_cfg.py10
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,
+)