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]
requires = ["poetry-core>=2.0.0,<3.0.0"]
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 fastapi import HTTPException, Depends, Path
from fastapi import Depends, HTTPException, Path
from fastapi.security import OAuth2PasswordBearer
from jwt import InvalidTokenError
from src.api.dependacies.db_dep import sessionDep
from src.core.auth_manager import AuthManager
from src.core.settings import settings
from src.schemas.auth import TokenData
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")
@@ -51,14 +51,6 @@ async def get_admin_user(db: sessionDep, current_user: ActiveUser):
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(
db: sessionDep, current_user: ActiveUser, id: Annotated[int, Path()]
):

View File

@@ -1,7 +1,8 @@
from fastapi import APIRouter
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.users import router as users_router
from src.core.settings import settings
router = APIRouter(prefix=settings.api.v1.prefix)

View File

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

View File

@@ -1,10 +1,19 @@
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.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}")

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.user_dep import ActiveUser, AdminUser, CurrentOrAdmin
from src.core.settings import settings
from src.schemas.users import UserUpdate
from src.services.users import UserService
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):
user = await UserService(db).get_user_by_filter_or_raise(id=id)
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
from passlib.context import CryptContext

View File

@@ -1,13 +1,22 @@
from datetime import datetime
from sqlalchemy import TIMESTAMP, func
from sqlalchemy.ext.asyncio import create_async_engine, async_sessionmaker
from sqlalchemy import TIMESTAMP, event, func
from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine
from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column
from src.core.settings import settings
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)

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,9 +1,7 @@
from logging.config import fileConfig
from sqlalchemy import engine_from_config
from sqlalchemy import pool
from alembic import context
from sqlalchemy import engine_from_config, event, pool
from src.core.database import Base
from src.models import * # noqa
@@ -67,6 +65,14 @@ def run_migrations_online() -> None:
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:
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 alembic import op
import sqlalchemy as sa
from alembic import op
# revision identifiers, used by Alembic.
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.users import UsersORM
__all__ = [
"UsersORM",

View File

@@ -1,7 +1,7 @@
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 src.core.database import Base
@@ -16,7 +16,7 @@ priority_enum = Enum("low", "medium", "high", "critical", name="priority_enum")
class TasksORM(Base):
__tablename__ = "tasks"
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))
description: Mapped[Optional[str]] = mapped_column(Text, 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 src.core.database import Base
@@ -23,4 +23,6 @@ class UsersORM(Base):
avatar_path: Mapped[Optional[str]] = mapped_column(String(255), nullable=True)
is_active: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True)
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.repository.base import BaseRepo
@@ -7,8 +7,22 @@ from src.repository.base import BaseRepo
class UsersRepo(BaseRepo):
model = UsersORM
async def get_all_users(self):
async def get_all_users(self) -> list[UsersORM]:
query = select(self.model)
result = await self.session.execute(query)
models = result.scalars().all()
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 pydantic import BaseModel, EmailStr, ConfigDict, BeforeValidator
from pydantic import BaseModel, BeforeValidator, ConfigDict, EmailStr
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):
id: int
email: EmailStr | None

View File

@@ -1,9 +1,10 @@
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.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):
@@ -37,4 +38,6 @@ class AuthService(BaseService):
access_token = AuthManager.create_access_token(
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 src.schemas.users import User
from src.schemas.users import User, UserUpdate
from src.services.base import BaseService
@@ -26,3 +26,13 @@ class UserService(BaseService):
async def get_all_users(self) -> list[User]:
users = await self.session.user.get_all_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)