Compare commits
12 Commits
b9a9e4e094
...
2471c2981f
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2471c2981f | ||
|
|
9c20b7690c | ||
|
|
6657f55545 | ||
|
|
45ebed40bc | ||
|
|
c8e1863ee6 | ||
|
|
c61db4bc9d | ||
|
|
93cf7b2d24 | ||
|
|
acb3eefcbe | ||
|
|
a6c00e20c3 | ||
|
|
0ab17f3a99 | ||
|
|
22ee887238 | ||
|
|
cdee74210e |
0
__init__.py
Normal file
0
__init__.py
Normal 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
0
src/__init__.py
Normal 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()]
|
||||
):
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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"])
|
||||
|
||||
@@ -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}")
|
||||
|
||||
@@ -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"}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
from datetime import timedelta, datetime, timezone
|
||||
from datetime import datetime, timedelta, timezone
|
||||
|
||||
import jwt
|
||||
from passlib.context import CryptContext
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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": ...
|
||||
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"]
|
||||
)
|
||||
@@ -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"]
|
||||
)
|
||||
@@ -1,5 +1,5 @@
|
||||
from src.models.users import UsersORM
|
||||
from src.models.tasks import TasksORM
|
||||
from src.models.users import UsersORM
|
||||
|
||||
__all__ = [
|
||||
"UsersORM",
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
6
src/repository/tasks.py
Normal file
@@ -0,0 +1,6 @@
|
||||
from src.models.tasks import TasksORM
|
||||
from src.repository.base import BaseRepo
|
||||
|
||||
|
||||
class TasksRepo(BaseRepo):
|
||||
model = TasksORM
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
4
src/services/tasks.py
Normal file
@@ -0,0 +1,4 @@
|
||||
from src.services.base import BaseService
|
||||
|
||||
|
||||
class TasksService(BaseService): ...
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user