Compare commits

..

12 Commits

Author SHA1 Message Date
IluaAir
2471c2981f big fix imports 2025-08-08 15:30:17 +03:00
IluaAir
9c20b7690c add tasks repo update uow and manager 2025-08-08 14:04:33 +03:00
IluaAir
6657f55545 ruff check 2025-08-07 00:22:32 +03:00
IluaAir
45ebed40bc simple get all tasks 2025-08-07 00:20:14 +03:00
IluaAir
c8e1863ee6 ruff format 2025-08-07 00:20:05 +03:00
IluaAir
c61db4bc9d casacade delete tasks 2025-08-06 23:58:29 +03:00
IluaAir
93cf7b2d24 event listens for sqlalchemy 2025-08-06 23:38:17 +03:00
IluaAir
acb3eefcbe fix typing 2025-08-04 10:59:23 +03:00
IluaAir
a6c00e20c3 create patch user 2025-08-02 18:59:43 +03:00
IluaAir
0ab17f3a99 add delete user endpoint 2025-08-02 18:22:01 +03:00
IluaAir
22ee887238 del old user or admin 2025-08-02 15:55:16 +03:00
IluaAir
cdee74210e fix user or admin dep 2025-07-29 00:25:34 +03:00
27 changed files with 293 additions and 40 deletions

0
__init__.py Normal file
View File

View File

@@ -30,3 +30,64 @@ dependencies = [
[build-system] [build-system]
requires = ["poetry-core>=2.0.0,<3.0.0"] requires = ["poetry-core>=2.0.0,<3.0.0"]
build-backend = "poetry.core.masonry.api" build-backend = "poetry.core.masonry.api"
[tool.ruff]
# Exclude commonly ignored directories and files.
exclude = [
".bzr",
".direnv",
".eggs",
".git",
".git-rewrite",
".hg",
".ipynb_checkpoints",
".mypy_cache",
".nox",
".pants.d",
".pyenv",
".pytest_cache",
".pytype",
".ruff_cache",
".svn",
".tox",
".venv",
".vscode",
"__pypackages__",
"_build",
"buck-out",
"build",
"dist",
"node_modules",
"site-packages",
"venv",
]
# Set the maximum line length for both linting and formatting.
line-length = 88
# Assume Python 3.9 for compatibility checks.
target-version = "py39"
# Enable preview features for early access to new rules and formatting changes.
preview = true
[tool.ruff.lint]
# Select specific rule groups to enable.
# 'E' for pycodestyle, 'F' for Pyflakes, 'I' for isort, 'B' for flake8-bugbear.
select = [
"E",
"F",
"I",
"B",
"UP", # pyupgrade
"SIM", # flake8-simplify
]
# Ignore specific rules within the selected groups.
ignore = [
"UP035",
"B903",
"B904",
"E501",
"B008",
]
[tool.ruff.format]
# Enable Ruff's formatter.
docstring-code-format = true

0
src/__init__.py Normal file
View File

View File

@@ -1,14 +1,14 @@
from typing import Annotated from typing import Annotated
from fastapi import HTTPException, Depends, Path from fastapi import Depends, HTTPException, Path
from fastapi.security import OAuth2PasswordBearer from fastapi.security import OAuth2PasswordBearer
from jwt import InvalidTokenError from jwt import InvalidTokenError
from src.api.dependacies.db_dep import sessionDep
from src.core.auth_manager import AuthManager from src.core.auth_manager import AuthManager
from src.core.settings import settings from src.core.settings import settings
from src.schemas.auth import TokenData from src.schemas.auth import TokenData
from src.services.users import UserService from src.services.users import UserService
from src.api.dependacies.db_dep import sessionDep
oauth2_scheme = OAuth2PasswordBearer(tokenUrl=f"{settings.api.v1_login_url}/login") oauth2_scheme = OAuth2PasswordBearer(tokenUrl=f"{settings.api.v1_login_url}/login")
@@ -51,14 +51,6 @@ async def get_admin_user(db: sessionDep, current_user: ActiveUser):
AdminUser = Annotated[TokenData, Depends(get_admin_user)] AdminUser = Annotated[TokenData, Depends(get_admin_user)]
async def user_or_admin_path(db: sessionDep, id: int, current_user: ActiveUser):
if current_user.id == id:
return current_user
else:
admin = await get_admin_user(db, current_user)
return admin
async def user_or_admin( async def user_or_admin(
db: sessionDep, current_user: ActiveUser, id: Annotated[int, Path()] db: sessionDep, current_user: ActiveUser, id: Annotated[int, Path()]
): ):

View File

@@ -1,7 +1,8 @@
from fastapi import APIRouter from fastapi import APIRouter
from src.api.v1.auth import router as auth_router from src.api.v1.auth import router as auth_router
from src.api.v1.users import router as users_router
from src.api.v1.tasks import router as tasks_router from src.api.v1.tasks import router as tasks_router
from src.api.v1.users import router as users_router
from src.core.settings import settings from src.core.settings import settings
router = APIRouter(prefix=settings.api.v1.prefix) router = APIRouter(prefix=settings.api.v1.prefix)

View File

@@ -4,8 +4,8 @@ from fastapi import APIRouter, Depends
from fastapi.security import OAuth2PasswordRequestForm from fastapi.security import OAuth2PasswordRequestForm
from src.api.dependacies.db_dep import sessionDep from src.api.dependacies.db_dep import sessionDep
from src.schemas.users import UserRequestADD
from src.core.settings import settings from src.core.settings import settings
from src.schemas.users import UserRequestADD
from src.services.auth import AuthService from src.services.auth import AuthService
router = APIRouter(prefix=settings.api.v1.auth, tags=["Auth"]) router = APIRouter(prefix=settings.api.v1.auth, tags=["Auth"])

View File

@@ -1,10 +1,19 @@
from fastapi import APIRouter from fastapi import APIRouter
from sqlalchemy import select
from src.api.dependacies.db_dep import sessionDep
from src.api.dependacies.user_dep import ActiveUser
from src.models.tasks import TasksORM
router = APIRouter(prefix="/tasks", tags=["Tasks"]) router = APIRouter(prefix="/tasks", tags=["Tasks"])
@router.get("/") @router.get("/")
async def get_tasks(): ... async def get_tasks(db: sessionDep, user: ActiveUser):
query = select(TasksORM.id, TasksORM.description).where(TasksORM.user_id == user.id)
tasks = await db.session.execute(query)
result = tasks.scalars().all()
return result
@router.get("/{task_id}") @router.get("/{task_id}")

View File

@@ -1,8 +1,9 @@
from fastapi import APIRouter from fastapi import APIRouter, Body
from src.api.dependacies.user_dep import ActiveUser, AdminUser, CurrentOrAdmin
from src.api.dependacies.db_dep import sessionDep from src.api.dependacies.db_dep import sessionDep
from src.api.dependacies.user_dep import ActiveUser, AdminUser, CurrentOrAdmin
from src.core.settings import settings from src.core.settings import settings
from src.schemas.users import UserUpdate
from src.services.users import UserService from src.services.users import UserService
router = APIRouter(prefix=settings.api.v1.users, tags=["Users"]) router = APIRouter(prefix=settings.api.v1.users, tags=["Users"])
@@ -23,3 +24,17 @@ async def get_all_users(db: sessionDep, _: AdminUser):
async def get_user_by_id(db: sessionDep, id: int, _: CurrentOrAdmin): async def get_user_by_id(db: sessionDep, id: int, _: CurrentOrAdmin):
user = await UserService(db).get_user_by_filter_or_raise(id=id) user = await UserService(db).get_user_by_filter_or_raise(id=id)
return user return user
@router.patch("/{id}")
async def patch_user(
db: sessionDep, id: int, _: CurrentOrAdmin, user_update: UserUpdate = Body()
):
updated_user = await UserService(db).update_user(id=id, update_data=user_update)
return updated_user
@router.delete("/{id}")
async def delete_user(db: sessionDep, id: int, _: AdminUser):
await UserService(db).delete_user(id)
return {"message": "User deleted successfully"}

View File

@@ -1,4 +1,4 @@
from datetime import timedelta, datetime, timezone from datetime import datetime, timedelta, timezone
import jwt import jwt
from passlib.context import CryptContext from passlib.context import CryptContext

View File

@@ -1,13 +1,22 @@
from datetime import datetime from datetime import datetime
from sqlalchemy import TIMESTAMP, func from sqlalchemy import TIMESTAMP, event, func
from sqlalchemy.ext.asyncio import create_async_engine, async_sessionmaker from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine
from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column
from src.core.settings import settings from src.core.settings import settings
engine = create_async_engine(settings.db.url, echo=True) engine = create_async_engine(settings.db.url, echo=True)
@event.listens_for(engine.sync_engine, "connect")
def set_sqlite_pragma(dbapi_connection, connection_record):
if "sqlite" in settings.db.url:
cursor = dbapi_connection.cursor()
cursor.execute("PRAGMA foreign_keys=ON")
cursor.close()
async_session_maker = async_sessionmaker(bind=engine, expire_on_commit=False) async_session_maker = async_sessionmaker(bind=engine, expire_on_commit=False)

View File

@@ -1,3 +1,4 @@
from src.repository.tasks import TasksRepo
from src.repository.users import UsersRepo from src.repository.users import UsersRepo
@@ -8,6 +9,7 @@ class DBManager:
async def __aenter__(self): async def __aenter__(self):
self.session = self.session_factory() self.session = self.session_factory()
self.user = UsersRepo(self.session) self.user = UsersRepo(self.session)
self.task = TasksRepo(self.session)
return self return self
async def __aexit__(self, exc_type, exc_val, exc_tb): async def __aexit__(self, exc_type, exc_val, exc_tb):

View File

@@ -1,10 +1,12 @@
from typing import Protocol from typing import Protocol
from src.repository.tasks import TasksRepo
from src.repository.users import UsersRepo from src.repository.users import UsersRepo
class IUOWDB(Protocol): class IUOWDB(Protocol):
user: UsersRepo user: UsersRepo
task: TasksRepo
async def __aenter__(self) -> "IUOWDB": ... async def __aenter__(self) -> "IUOWDB": ...

View File

@@ -37,6 +37,7 @@ class AccessToken(BaseSettings):
expire_minutes: int = 15 expire_minutes: int = 15
secret_key: str secret_key: str
algorithm: str = "HS256" algorithm: str = "HS256"
token_type: str = "bearer" # noqa: S105
class Settings(BaseSettings): class Settings(BaseSettings):

View File

@@ -1,8 +1,8 @@
import sys import sys
import uvicorn
from pathlib import Path from pathlib import Path
from fastapi import FastAPI
import uvicorn
from fastapi import FastAPI
sys.path.append(str(Path(__file__).parent.parent)) sys.path.append(str(Path(__file__).parent.parent))
from src.api import router from src.api import router

View File

@@ -1,9 +1,7 @@
from logging.config import fileConfig from logging.config import fileConfig
from sqlalchemy import engine_from_config
from sqlalchemy import pool
from alembic import context from alembic import context
from sqlalchemy import engine_from_config, event, pool
from src.core.database import Base from src.core.database import Base
from src.models import * # noqa from src.models import * # noqa
@@ -67,6 +65,14 @@ def run_migrations_online() -> None:
poolclass=pool.NullPool, poolclass=pool.NullPool,
) )
# Enable foreign keys for SQLite in migrations
@event.listens_for(connectable, "connect")
def set_sqlite_pragma(dbapi_connection, connection_record):
print("⚙️ Enabling PRAGMA foreign_keys=ON for Alembic")
cursor = dbapi_connection.cursor()
cursor.execute("PRAGMA foreign_keys=ON")
cursor.close()
with connectable.connect() as connection: with connectable.connect() as connection:
context.configure(connection=connection, target_metadata=target_metadata) context.configure(connection=connection, target_metadata=target_metadata)

View File

@@ -8,9 +8,8 @@ Create Date: 2025-07-06 00:02:09.254907
from typing import Sequence, Union from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa import sqlalchemy as sa
from alembic import op
# revision identifiers, used by Alembic. # revision identifiers, used by Alembic.
revision: str = "a2fdd0ec4a96" revision: str = "a2fdd0ec4a96"

View File

@@ -0,0 +1,58 @@
"""add_cascade_delete_to_tasks
Revision ID: 197b195208e8
Revises: a2fdd0ec4a96
Create Date: 2025-08-06 23:41:56.778423
"""
from typing import Sequence, Union
import sqlalchemy as sa
from alembic import op
# revision identifiers, used by Alembic.
revision: str = "197b195208e8"
down_revision: Union[str, None] = "a2fdd0ec4a96"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade():
"""Upgrade schema."""
op.execute("PRAGMA foreign_keys=ON")
with op.batch_alter_table("tasks", schema=None) as batch_op:
connection = op.get_bind()
inspector = sa.inspect(connection)
foreign_keys = inspector.get_foreign_keys("tasks")
constraint_name = None
for fk in foreign_keys:
if "user_id" in fk["constrained_columns"]:
constraint_name = fk["name"]
break
if constraint_name:
try: # noqa: SIM105
batch_op.drop_constraint(constraint_name, type_="foreignkey")
except: # noqa E722
pass
batch_op.create_foreign_key(
"fk_tasks_user_id_users", "users", ["user_id"], ["id"], ondelete="CASCADE"
)
def downgrade():
"""Downgrade schema."""
with op.batch_alter_table("tasks", schema=None) as batch_op:
try: # noqa: SIM105
batch_op.drop_constraint("fk_tasks_user_id_users", type_="foreignkey")
except: # noqa E722
pass
batch_op.create_foreign_key(
"fk_tasks_user_id_users", "users", ["user_id"], ["id"]
)

View File

@@ -0,0 +1,52 @@
"""fix_duplicate_foreign_keys
Revision ID: 4b0f3ea2fd26
Revises: 197b195208e8
Create Date: 2025-08-06 23:54:24.308488
"""
from typing import Sequence, Union # noqa: UP035
import sqlalchemy as sa
from alembic import op
# revision identifiers, used by Alembic.
revision: str = "4b0f3ea2fd26"
down_revision: Union[str, None] = "197b195208e8"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade():
"""Upgrade schema."""
op.execute("PRAGMA foreign_keys=ON")
with op.batch_alter_table("tasks", schema=None) as batch_op:
connection = op.get_bind()
inspector = sa.inspect(connection)
foreign_keys = inspector.get_foreign_keys("tasks")
for fk in foreign_keys:
if "user_id" in fk["constrained_columns"]:
try: # noqa: SIM105
batch_op.drop_constraint(fk["name"], type_="foreignkey")
except: # noqa E722
pass
batch_op.create_foreign_key(
"fk_tasks_user_id_users", "users", ["user_id"], ["id"], ondelete="CASCADE"
)
def downgrade():
"""Downgrade schema."""
with op.batch_alter_table("tasks", schema=None) as batch_op:
try: # noqa: SIM105
batch_op.drop_constraint("fk_tasks_user_id_users", type_="foreignkey")
except: # noqa E722
pass
batch_op.create_foreign_key(
"fk_tasks_user_id_users", "users", ["user_id"], ["id"]
)

View File

@@ -1,5 +1,5 @@
from src.models.users import UsersORM
from src.models.tasks import TasksORM from src.models.tasks import TasksORM
from src.models.users import UsersORM
__all__ = [ __all__ = [
"UsersORM", "UsersORM",

View File

@@ -1,7 +1,7 @@
from datetime import date from datetime import date
from typing import Optional, TYPE_CHECKING from typing import TYPE_CHECKING, Optional
from sqlalchemy import ForeignKey, Text, Date, Enum, String from sqlalchemy import Date, Enum, ForeignKey, String, Text
from sqlalchemy.orm import Mapped, mapped_column, relationship from sqlalchemy.orm import Mapped, mapped_column, relationship
from src.core.database import Base from src.core.database import Base
@@ -16,7 +16,7 @@ priority_enum = Enum("low", "medium", "high", "critical", name="priority_enum")
class TasksORM(Base): class TasksORM(Base):
__tablename__ = "tasks" __tablename__ = "tasks"
id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True) id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True)
user_id: Mapped[int] = mapped_column(ForeignKey("users.id")) user_id: Mapped[int] = mapped_column(ForeignKey("users.id", ondelete="CASCADE"))
title: Mapped[str] = mapped_column(String(100)) title: Mapped[str] = mapped_column(String(100))
description: Mapped[Optional[str]] = mapped_column(Text, nullable=True) description: Mapped[Optional[str]] = mapped_column(Text, nullable=True)
due_date: Mapped[Optional[date]] = mapped_column(Date, nullable=True) due_date: Mapped[Optional[date]] = mapped_column(Date, nullable=True)

View File

@@ -1,6 +1,6 @@
from typing import Optional, TYPE_CHECKING from typing import TYPE_CHECKING, Optional
from sqlalchemy import String, BigInteger, Integer, Boolean from sqlalchemy import BigInteger, Boolean, Integer, String
from sqlalchemy.orm import Mapped, mapped_column, relationship from sqlalchemy.orm import Mapped, mapped_column, relationship
from src.core.database import Base from src.core.database import Base
@@ -23,4 +23,6 @@ class UsersORM(Base):
avatar_path: Mapped[Optional[str]] = mapped_column(String(255), nullable=True) avatar_path: Mapped[Optional[str]] = mapped_column(String(255), nullable=True)
is_active: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True) is_active: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True)
is_superuser: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False) is_superuser: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
tasks: Mapped[list["TasksORM"]] = relationship(back_populates="user") tasks: Mapped[list["TasksORM"]] = relationship(
back_populates="user", cascade="all, delete-orphan"
)

6
src/repository/tasks.py Normal file
View File

@@ -0,0 +1,6 @@
from src.models.tasks import TasksORM
from src.repository.base import BaseRepo
class TasksRepo(BaseRepo):
model = TasksORM

View File

@@ -1,4 +1,4 @@
from sqlalchemy import select from sqlalchemy import delete, select, update
from src.models import UsersORM from src.models import UsersORM
from src.repository.base import BaseRepo from src.repository.base import BaseRepo
@@ -7,8 +7,22 @@ from src.repository.base import BaseRepo
class UsersRepo(BaseRepo): class UsersRepo(BaseRepo):
model = UsersORM model = UsersORM
async def get_all_users(self): async def get_all_users(self) -> list[UsersORM]:
query = select(self.model) query = select(self.model)
result = await self.session.execute(query) result = await self.session.execute(query)
models = result.scalars().all() models = result.scalars().all()
return models return models
async def delete_one(self, id: int) -> None:
await self.session.execute(delete(self.model).where(self.model.id == id))
async def update_one(self, id: int, data: dict) -> UsersORM:
stmt = (
update(self.model)
.where(self.model.id == id)
.values(data.model_dump(exclude_unset=True))
.returning(self.model)
)
result = await self.session.execute(stmt)
model = result.scalar_one()
return model

View File

@@ -1,10 +1,17 @@
from typing import Annotated from typing import Annotated
from pydantic import BaseModel, EmailStr, ConfigDict, BeforeValidator from pydantic import BaseModel, BeforeValidator, ConfigDict, EmailStr
from src.schemas.validators import ensure_password from src.schemas.validators import ensure_password
class UserUpdate(BaseModel):
email: EmailStr | None = None
username: str | None = None
is_active: bool | None = None
model_config = ConfigDict(from_attributes=True, extra="ignore")
class User(BaseModel): class User(BaseModel):
id: int id: int
email: EmailStr | None email: EmailStr | None

View File

@@ -1,9 +1,10 @@
from fastapi import HTTPException from fastapi import HTTPException
from src.schemas.auth import Token
from src.schemas.users import UserRequestADD, User, UserAdd, UserWithHashedPass
from src.services.base import BaseService
from src.core.auth_manager import AuthManager from src.core.auth_manager import AuthManager
from src.core.settings import settings
from src.schemas.auth import Token
from src.schemas.users import User, UserAdd, UserRequestADD, UserWithHashedPass
from src.services.base import BaseService
class AuthService(BaseService): class AuthService(BaseService):
@@ -37,4 +38,6 @@ class AuthService(BaseService):
access_token = AuthManager.create_access_token( access_token = AuthManager.create_access_token(
data={"id": user.id, "sub": user.username, "is_active": user.is_active} data={"id": user.id, "sub": user.username, "is_active": user.is_active}
) )
return Token(access_token=access_token, token_type="bearer") return Token(
access_token=access_token, token_type=settings.access_token.token_type
)

4
src/services/tasks.py Normal file
View File

@@ -0,0 +1,4 @@
from src.services.base import BaseService
class TasksService(BaseService): ...

View File

@@ -1,6 +1,6 @@
from fastapi import HTTPException from fastapi import HTTPException
from src.schemas.users import User from src.schemas.users import User, UserUpdate
from src.services.base import BaseService from src.services.base import BaseService
@@ -26,3 +26,13 @@ class UserService(BaseService):
async def get_all_users(self) -> list[User]: async def get_all_users(self) -> list[User]:
users = await self.session.user.get_all_users() users = await self.session.user.get_all_users()
return [User.model_validate(user) for user in users] return [User.model_validate(user) for user in users]
async def delete_user(self, id: int) -> None:
await self.session.user.delete_one(id=id)
await self.session.commit()
async def update_user(self, id: int, update_data: UserUpdate) -> User:
await self.get_user_by_filter_or_raise(id=id)
user = await self.session.user.update_one(id=id, data=update_data)
await self.session.commit()
return User.model_validate(user)