Compare commits

..

1 Commits

Author SHA1 Message Date
IluaAir
7e9346ec4d add base update one 2025-09-06 13:53:18 +03:00
28 changed files with 197 additions and 445 deletions

View File

@@ -5,7 +5,9 @@ from fastapi import Depends, Query
from fastapi.exceptions import HTTPException from fastapi.exceptions import HTTPException
from pydantic import BaseModel, model_validator from pydantic import BaseModel, model_validator
from src.schemas.tasks import PriorityEnum, StatusEnum from src.schemas.tasks import TaskFilter
TaskFilterDep = Annotated[TaskFilter, Depends()]
class Date(BaseModel): class Date(BaseModel):
@@ -21,21 +23,4 @@ class Date(BaseModel):
return self return self
class Page(BaseModel): DateDep = Annotated[Date, Depends()]
limit: int = Query(default=30, ge=1, le=100)
page: int | None = Query(default=1, ge=1)
class Status(BaseModel):
status: StatusEnum | None = Query(default=None)
class Priority(BaseModel):
priority: PriorityEnum | None = Query(default=None)
class Filters(Date, Status, Priority, Page):
pass
FilterDep = Annotated[Filters, Depends()]

View File

@@ -1,63 +1,86 @@
from typing import Annotated from typing import Annotated
from fastapi import Depends, HTTPException from fastapi import Depends, HTTPException, Path
from fastapi.security import ( from fastapi.security import OAuth2PasswordBearer
HTTPAuthorizationCredentials,
HTTPBearer,
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.tasks import TaskService
http_bearer = HTTPBearer(auto_error=False) from src.services.users import UserService
oauth2_scheme = OAuth2PasswordBearer(tokenUrl=f"{settings.api.v1_login_url}/login") oauth2_scheme = OAuth2PasswordBearer(tokenUrl=f"{settings.api.v1_login_url}/login")
AccessTokenDep = Annotated[HTTPAuthorizationCredentials, Depends(http_bearer)]
async def get_current_user( async def get_current_user(token: Annotated[str, Depends(oauth2_scheme)]):
token: AccessTokenDep, verify_exp: bool = True, check_active: bool = False
):
credentials_exception = HTTPException( credentials_exception = HTTPException(
status_code=401, status_code=401,
detail="Could not validate credentials", detail="Could not validate credentials",
headers={"WWW-Authenticate": "Bearer"}, headers={"WWW-Authenticate": "Bearer"},
) )
try: try:
payload = AuthManager.decode_access_token(token.credentials, verify_exp) payload = AuthManager.decode_access_token(token=token)
if payload is None: if payload is None:
raise credentials_exception raise credentials_exception
user = TokenData(**payload) user = TokenData(**payload)
if check_active and not user.is_active: except InvalidTokenError:
raise HTTPException(status_code=400, detail="Inactive user")
except (InvalidTokenError, AttributeError):
raise credentials_exception raise credentials_exception
return user return user
async def get_current_user_basic(token: AccessTokenDep): CurrentUser = Annotated[TokenData, Depends(get_current_user)]
return await get_current_user(token, verify_exp=True, check_active=False)
async def get_current_active_user(token: AccessTokenDep): def get_current_active_user(
return await get_current_user(token, verify_exp=True, check_active=True) current_user: CurrentUser,
):
if not current_user.is_active:
raise HTTPException(status_code=400, detail="Inactive user")
return current_user
async def get_current_user_for_refresh(token: AccessTokenDep):
return await get_current_user(token, verify_exp=False, check_active=True)
async def get_current_user_for_admin(token: AccessTokenDep):
admin = await get_current_user(token, verify_exp=True, check_active=True)
if not admin.is_superuser:
raise HTTPException(status_code=403, detail="Admin access required")
return admin
CurrentUser = Annotated[TokenData, Depends(get_current_user_basic)]
ActiveUser = Annotated[TokenData, Depends(get_current_active_user)] ActiveUser = Annotated[TokenData, Depends(get_current_active_user)]
RefreshUser = Annotated[TokenData, Depends(get_current_user_for_refresh)]
AdminUser = Annotated[TokenData, Depends(get_current_user_for_admin)]
async def get_admin_user(db: sessionDep, current_user: ActiveUser):
await UserService(db).validate_admin_user(current_user.sub)
return current_user
AdminUser = Annotated[TokenData, Depends(get_admin_user)]
async def user_or_admin(db: sessionDep, current_user: ActiveUser, owner_id: int):
if current_user.id == owner_id:
return current_user
else:
admin = await get_admin_user(db, current_user)
return admin
async def CurrentOrAdminOwner(
db: sessionDep, current_user: ActiveUser, id: Annotated[int, Path()]
):
authorized_user = await user_or_admin(db, current_user, id)
if not authorized_user:
raise HTTPException(status_code=403, detail="Not authorized")
return authorized_user
async def CurrentOrAdminTask(
db: sessionDep,
id: Annotated[int, Path()],
current_user: ActiveUser,
):
task = await TaskService(db).get_task(id)
if not task:
raise HTTPException(status_code=404, detail="Task not found")
return await CurrentOrAdminOwner(db, current_user, task.user_id)
OwnerDep = Annotated[TokenData, Depends(CurrentOrAdminOwner)]
TaskOwnerDep = Annotated[TokenData, Depends(CurrentOrAdminTask)]

View File

@@ -1,19 +1,14 @@
from typing import Annotated from typing import Annotated
from fastapi import APIRouter, Body, Cookie, Depends, Form, HTTPException, Response 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.api.dependacies.user_dep import ActiveUser, RefreshUser, http_bearer
from src.core.settings import settings from src.core.settings import settings
from src.schemas.auth import Token
from src.schemas.users import UserRequestADD from src.schemas.users import UserRequestADD
from src.services.auth import AuthService from src.services.auth import AuthService
from src.services.users import UserService
router = APIRouter( router = APIRouter(prefix=settings.api.v1.auth, tags=["Auth"])
prefix=settings.api.v1.auth, tags=["Auth"], dependencies=[Depends(http_bearer)]
)
@router.post(path="/signup") @router.post(path="/signup")
@@ -22,65 +17,12 @@ async def registration(session: sessionDep, credential: UserRequestADD):
return auth return auth
@router.post(path="/login", response_model=Token) @router.post(path="/login")
async def login( async def login(
session: sessionDep, session: sessionDep,
credential: Annotated[OAuth2PasswordRequestForm, Depends()], credential: Annotated[OAuth2PasswordRequestForm, Depends()],
response: Response,
fingerprint: str = Form(min_length=5),
): ):
result = await AuthService(session).login( access_token = await AuthService(session).login(
credential.username, credential.password, fingerprint=fingerprint credential.username, credential.password
) )
response.set_cookie( return access_token
key="refresh_token",
value=result["refresh_token"],
httponly=True,
samesite="lax",
path=settings.api.v1_login_url,
max_age=60 * 60 * 24 * settings.refresh_token.expire_days,
)
return result
@router.post(path="/refresh", response_model=Token)
async def refresh(
session: sessionDep,
current_user: RefreshUser,
response: Response,
fingerprint: Annotated[str, Body(embed=True)],
refresh_token: Annotated[str | None, Cookie(name="refresh_token")] = None,
):
if refresh_token is None:
raise HTTPException(status_code=401, detail="No refresh token")
result = await AuthService(session).refresh_tokens(
refresh_token, current_user, fingerprint
)
response.set_cookie(
key="refresh_token",
value=result["refresh_token"],
httponly=True,
samesite="lax",
path=settings.api.v1_login_url,
max_age=60 * 60 * 24 * settings.refresh_token.expire_days,
)
return result
@router.get("/me")
async def get_me(session: sessionDep, user: ActiveUser):
cur_user = await UserService(session).get_user_by_filter_or_raise(id=user.id)
return cur_user
@router.post(path="/logout")
async def logout(
session: sessionDep,
response: Response,
refresh_token: Annotated[str | None, Cookie(name="refresh_token")] = None,
):
if refresh_token is None:
raise HTTPException(status_code=401, detail="No refresh token")
await AuthService(session).delete_token(token=refresh_token)
response.delete_cookie(key="refresh_token")
return {"status": "ok"}

View File

@@ -1,20 +1,28 @@
from typing import Annotated from typing import Annotated
from fastapi import APIRouter, Body, Depends, HTTPException from fastapi import APIRouter, Body, Depends
from src.api.dependacies.db_dep import sessionDep from src.api.dependacies.db_dep import sessionDep
from src.api.dependacies.user_dep import ActiveUser from src.api.dependacies.task_dep import TaskFilterDep
from src.api.dependacies.user_dep import ActiveUser, TaskOwnerDep
from src.schemas.tasks import TaskADDRequest, TaskPATCHRequest from src.schemas.tasks import TaskADDRequest, TaskPATCHRequest
from src.services.tasks import TaskService from src.services.tasks import TaskService
from src.services.users import UserService
router = APIRouter(prefix="/tasks", tags=["Tasks"]) router = APIRouter(prefix="/tasks", tags=["Tasks"])
@router.get("/")
async def get_tasks(session: sessionDep, user: ActiveUser, filter: TaskFilterDep):
result = await UserService(session).get_user_with_tasks(
user_id=user.id, **filter.model_dump(exclude_unset=True)
)
return result
@router.get("/{id}") @router.get("/{id}")
async def get_task_id(session: sessionDep, id: int, user: ActiveUser): async def get_task_id(session: sessionDep, id: int, _: TaskOwnerDep):
task = await TaskService(session).get_task(id) task = await TaskService(session).get_task(id)
if task.user_id != user.id and user.is_superuser is False:
raise HTTPException(status_code=403, detail="Forbidden")
return task return task
@@ -34,26 +42,17 @@ async def post_task(
async def patch_task( async def patch_task(
session: sessionDep, session: sessionDep,
id: int, id: int,
user: ActiveUser, _: TaskOwnerDep,
task_data: TaskPATCHRequest = Body(), task_data: TaskPATCHRequest = Body(),
): ):
if user.is_superuser is False: task = await TaskService(session).update_task(id, task_data)
task = await TaskService(session).get_task(id) return task
if task.user_id != user.id:
raise HTTPException(status_code=403, detail="Forbidden")
updated_task = await TaskService(session).update_task(id, task_data)
return updated_task
@router.delete("/{id}") @router.delete("/{id}")
async def delete_task( async def delete_task(
session: sessionDep, session: sessionDep,
id: int, id: int,
user: ActiveUser, _: TaskOwnerDep,
): ):
if user.is_superuser is False:
task = await TaskService(session).get_task(id)
if task.user_id != user.id:
raise HTTPException(status_code=403, detail="Forbidden")
await TaskService(session).delete_task(id) await TaskService(session).delete_task(id)
return {"message": "Task deleted successfully"}

View File

@@ -1,10 +1,10 @@
from fastapi import APIRouter, Body, HTTPException from fastapi import APIRouter, Body
from src.api.dependacies.db_dep import sessionDep from src.api.dependacies.db_dep import sessionDep
from src.api.dependacies.task_dep import FilterDep
from src.api.dependacies.user_dep import ( from src.api.dependacies.user_dep import (
ActiveUser, ActiveUser,
AdminUser, AdminUser,
OwnerDep,
) )
from src.core.settings import settings from src.core.settings import settings
from src.schemas.users import UserUpdate from src.schemas.users import UserUpdate
@@ -13,6 +13,11 @@ from src.services.users import UserService
router = APIRouter(prefix=settings.api.v1.users, tags=["Users"]) router = APIRouter(prefix=settings.api.v1.users, tags=["Users"])
@router.get("/me")
async def get_me(user: ActiveUser):
return user
@router.get("/") @router.get("/")
async def get_all_users(session: sessionDep, _: AdminUser): async def get_all_users(session: sessionDep, _: AdminUser):
users = await UserService(session).get_all_users() users = await UserService(session).get_all_users()
@@ -20,30 +25,18 @@ async def get_all_users(session: sessionDep, _: AdminUser):
@router.get("/{id}") @router.get("/{id}")
async def get_user_by_id(session: sessionDep, id: int, _: AdminUser): async def get_user_by_id(session: sessionDep, id: int, _: OwnerDep):
user = await UserService(session).get_user_by_filter_or_raise(id=id) user = await UserService(session).get_user_by_filter_or_raise(id=id)
return user return user
@router.get("/{id}/tasks")
async def get_user_tasks(
session: sessionDep, id: int, user: ActiveUser, filters: FilterDep
):
if user.id != id and user.is_superuser is False:
raise HTTPException(status_code=403, detail="Forbidden")
tasks = await UserService(session).get_user_with_tasks(id, **filters.model_dump())
return tasks.tasks
@router.patch("/{id}") @router.patch("/{id}")
async def patch_user( async def patch_user(
session: sessionDep, session: sessionDep,
id: int, id: int,
user: ActiveUser, _: OwnerDep,
user_update: UserUpdate = Body(), user_update: UserUpdate = Body(),
): ):
if user.id != id and user.is_superuser is False:
raise HTTPException(status_code=403, detail="Forbidden")
updated_user = await UserService(session).update_user( updated_user = await UserService(session).update_user(
id=id, update_data=user_update id=id, update_data=user_update
) )
@@ -51,6 +44,6 @@ async def patch_user(
@router.delete("/{id}") @router.delete("/{id}")
async def delete_user(session: sessionDep, id: int, user: AdminUser): async def delete_user(session: sessionDep, id: int, _: AdminUser):
await UserService(session).delete_user(id) await UserService(session).delete_user(id)
return {"message": "User deleted successfully"} return {"message": "User deleted successfully"}

View File

@@ -1,4 +1,3 @@
import secrets
from datetime import datetime, timedelta, timezone from datetime import datetime, timedelta, timezone
import jwt import jwt
@@ -36,15 +35,9 @@ class AuthManager:
return encoded_jwt return encoded_jwt
@classmethod @classmethod
def create_refresh_token(cls) -> str: def decode_access_token(cls, token: str) -> dict:
token_hash = secrets.token_urlsafe(32)
return token_hash
@classmethod
def decode_access_token(cls, token: str, verify_exp: bool = True) -> dict:
return jwt.decode( return jwt.decode(
token, token,
settings.access_token.secret_key, settings.access_token.secret_key,
algorithms=[settings.access_token.algorithm], algorithms=[settings.access_token.algorithm],
options={"verify_exp": verify_exp},
) )

View File

@@ -6,7 +6,7 @@ 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=settings.db.echo) engine = create_async_engine(settings.db.url, echo=True)
@event.listens_for(engine.sync_engine, "connect") @event.listens_for(engine.sync_engine, "connect")

View File

@@ -2,7 +2,6 @@ from typing import Any
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker
from src.repository.auth import AuthRepo
from src.repository.tasks import TasksRepo from src.repository.tasks import TasksRepo
from src.repository.users import UsersRepo from src.repository.users import UsersRepo
@@ -15,7 +14,6 @@ class DBManager:
self.session: AsyncSession = self.session_factory() self.session: AsyncSession = self.session_factory()
self.user = UsersRepo(self.session) self.user = UsersRepo(self.session)
self.task = TasksRepo(self.session) self.task = TasksRepo(self.session)
self.auth = AuthRepo(self.session)
return self return self
async def __aexit__(self, exc_type: Any, exc_val: Any, exc_tb: Any) -> None: async def __aexit__(self, exc_type: Any, exc_val: Any, exc_tb: Any) -> None:

View File

@@ -3,16 +3,18 @@ from typing import TYPE_CHECKING, Any, Protocol
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
if TYPE_CHECKING: if TYPE_CHECKING:
from src.repository.auth import AuthRepo
from src.repository.tasks import TasksRepo from src.repository.tasks import TasksRepo
from src.repository.users import UsersRepo from src.repository.users import UsersRepo
class HasId(Protocol):
id: Any
class IUOWDB(Protocol): class IUOWDB(Protocol):
session: AsyncSession session: AsyncSession
user: "UsersRepo" user: 'UsersRepo'
task: "TasksRepo" task: 'TasksRepo'
auth: "AuthRepo"
async def __aenter__(self) -> "IUOWDB": ... async def __aenter__(self) -> "IUOWDB": ...

View File

@@ -28,28 +28,22 @@ class ApiPrefix(BaseModel):
class DbSettings(BaseModel): class DbSettings(BaseModel):
url: str = f"sqlite+aiosqlite:///{DB_PATH}" url: str = f"sqlite+aiosqlite:///{DB_PATH}"
echo: bool = False
class AccessToken(BaseModel): class AccessToken(BaseSettings):
model_config = SettingsConfigDict(
env_file=".env", env_file_encoding="utf-8", env_prefix="ACCESS_TOKEN_"
)
expire_minutes: int expire_minutes: int
secret_key: str secret_key: str
algorithm: str = "HS256" algorithm: str = "HS256"
token_type: str = "bearer" # noqa: S105 token_type: str = "bearer" # noqa: S105
class RefreshToken(BaseModel):
expire_days: int
class Settings(BaseSettings): class Settings(BaseSettings):
api: ApiPrefix = ApiPrefix() api: ApiPrefix = ApiPrefix()
db: DbSettings = DbSettings() db: DbSettings = DbSettings()
access_token: AccessToken access_token: AccessToken = AccessToken() # type: ignore
refresh_token: RefreshToken
model_config = SettingsConfigDict(
env_file=".env", env_file_encoding="utf-8", env_nested_delimiter="__"
)
settings = Settings() # type: ignore settings = Settings()

View File

@@ -1,48 +0,0 @@
"""add refresh token
Revision ID: b879d3502c37
Revises: 4b0f3ea2fd26
Create Date: 2025-09-08 14:56:01.439089
"""
from typing import Sequence, Union
import sqlalchemy as sa
from alembic import op
# revision identifiers, used by Alembic.
revision: str = "b879d3502c37"
down_revision: Union[str, None] = "4b0f3ea2fd26"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
"""Upgrade schema."""
# ### commands auto generated by Alembic - please adjust! ###
op.create_table(
"refresh_tokens",
sa.Column("id", sa.Integer(), nullable=False),
sa.Column("token", sa.String(length=255), nullable=False),
sa.Column("user_id", sa.Integer(), nullable=False),
sa.Column(
"created_at",
sa.TIMESTAMP(timezone=True),
server_default=sa.text("(CURRENT_TIMESTAMP)"),
nullable=False,
),
sa.ForeignKeyConstraint(
["user_id"],
["users.id"],
),
sa.PrimaryKeyConstraint("id"),
)
# ### end Alembic commands ###
def downgrade() -> None:
"""Downgrade schema."""
# ### commands auto generated by Alembic - please adjust! ###
op.drop_table("refresh_tokens")
# ### end Alembic commands ###

View File

@@ -1,45 +0,0 @@
"""expire and fingerprintjs token
Revision ID: 5821f37941a8
Revises: b879d3502c37
Create Date: 2025-09-21 20:16:48.289050
"""
from typing import Sequence, Union
import sqlalchemy as sa
from alembic import op
# revision identifiers, used by Alembic.
revision: str = "5821f37941a8"
down_revision: Union[str, None] = "b879d3502c37"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
"""Upgrade schema."""
# ### commands auto generated by Alembic - please adjust! ###
op.add_column(
"refresh_tokens",
sa.Column("fingerprint", sa.String(length=255), nullable=False),
)
op.add_column(
"refresh_tokens",
sa.Column(
"expired_at",
sa.TIMESTAMP(timezone=True),
server_default=sa.text("(datetime('now', '+7 days'))"),
nullable=False,
),
)
# ### end Alembic commands ###
def downgrade() -> None:
"""Downgrade schema."""
# ### commands auto generated by Alembic - please adjust! ###
op.drop_column("refresh_tokens", "expired_at")
op.drop_column("refresh_tokens", "fingerprint")
# ### end Alembic commands ###

View File

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

View File

@@ -1,21 +0,0 @@
from datetime import datetime
from sqlalchemy import TIMESTAMP, ForeignKey, Integer, String, text
from sqlalchemy.orm import Mapped, mapped_column
from src.core.database import Base
from src.core.settings import settings
class RefreshTokensORM(Base):
__tablename__ = "refresh_tokens"
id: Mapped[int] = mapped_column(Integer, primary_key=True)
token: Mapped[str] = mapped_column(String(255), nullable=False)
user_id: Mapped[int] = mapped_column(Integer, ForeignKey("users.id"))
fingerprint: Mapped[str] = mapped_column(String(255), nullable=False)
expired_at: Mapped[datetime] = mapped_column(
TIMESTAMP(timezone=True),
server_default=text(
f"datetime('now', '+{settings.refresh_token.expire_days} days')"
),
)

View File

@@ -1,6 +0,0 @@
from src.models.tokens import RefreshTokensORM
from src.repository.base import BaseRepo
class AuthRepo(BaseRepo[RefreshTokensORM]):
model: type[RefreshTokensORM] = RefreshTokensORM

View File

@@ -3,7 +3,9 @@ from typing import Any, Generic, Mapping, Sequence, Type, TypeVar
from sqlalchemy import delete, insert, select, update from sqlalchemy import delete, insert, select, update
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
ModelType = TypeVar("ModelType") from src.core.interfaces import HasId
ModelType = TypeVar("ModelType", bound=HasId)
class BaseRepo(Generic[ModelType]): class BaseRepo(Generic[ModelType]):
@@ -43,9 +45,12 @@ class BaseRepo(Generic[ModelType]):
async def delete_one(self, **filter_by) -> None: async def delete_one(self, **filter_by) -> None:
await self.session.execute(delete(self.model).filter_by(**filter_by)) await self.session.execute(delete(self.model).filter_by(**filter_by))
async def update_one(self, data: dict[str, Any], **filter_by: Any) -> ModelType: async def update_one(self, id: int, data: Mapping[str, Any]) -> ModelType:
stmt = ( stmt = (
update(self.model).filter_by(**filter_by).values(data).returning(self.model) update(self.model)
.where(self.model.id == id)
.values(data)
.returning(self.model)
) )
result = await self.session.execute(stmt) result = await self.session.execute(stmt)
model = result.scalar_one() model = result.scalar_one()

View File

@@ -2,5 +2,5 @@ from src.models.tasks import TasksORM
from src.repository.base import BaseRepo from src.repository.base import BaseRepo
class TasksRepo(BaseRepo[TasksORM]): class TasksRepo(BaseRepo):
model: type[TasksORM] = TasksORM model: type[TasksORM] = TasksORM

View File

@@ -1,6 +1,7 @@
from datetime import date from datetime import date
from typing import Optional
from sqlalchemy import func, select from sqlalchemy import func, select, update
from sqlalchemy.orm import selectinload from sqlalchemy.orm import selectinload
from src.models import UsersORM from src.models import UsersORM
@@ -8,24 +9,32 @@ from src.models.tasks import TasksORM
from src.repository.base import BaseRepo from src.repository.base import BaseRepo
class UsersRepo(BaseRepo[UsersORM]): class UsersRepo(BaseRepo):
model: type[UsersORM] = UsersORM model: type[UsersORM] = UsersORM
async def update_one(self, id: int, data: dict) -> UsersORM:
stmt = (
update(self.model)
.where(self.model.id == id)
.values(data)
.returning(self.model)
)
result = await self.session.execute(stmt)
model = result.scalar_one()
return model
async def get_one_with_load( async def get_one_with_load(
self, self,
user_id: int, user_id: int,
status: str | None = None, status: str | None = None,
priority: str | None = None, tasks_limit: Optional[int] = None,
tasks_limit: int | None = None, tasks_offset: Optional[int] = 0,
tasks_offset: int | None = 0, date_to: Optional[date] = None,
date_to: date | None = None, date_from: Optional[date] = None,
date_from: date | None = None,
) -> UsersORM | None: ) -> UsersORM | None:
filters_sq: dict = {"user_id": user_id} filters_sq: dict = {"user_id": user_id}
if status: if status:
filters_sq["status"] = status filters_sq["status"] = status
if priority:
filters_sq["priority"] = priority
tasks_subquery = self._tasks_subquary( tasks_subquery = self._tasks_subquary(
date_from=date_from, date_to=date_to, **filters_sq date_from=date_from, date_to=date_to, **filters_sq
) )

View File

@@ -1,16 +1,12 @@
from pydantic import BaseModel, ConfigDict, Field from pydantic import BaseModel
class Token(BaseModel): class Token(BaseModel):
access_token: str access_token: str
token_type: str token_type: str
model_config = ConfigDict(extra="ignore")
class TokenData(BaseModel): class TokenData(BaseModel):
id: int id: int
sub: str = Field(alias="username") sub: str
is_superuser: bool
is_active: bool is_active: bool
model_config = ConfigDict(populate_by_name=True)

View File

@@ -50,3 +50,11 @@ class Task(TaskADDRequest):
time_spent: int time_spent: int
model_config = ConfigDict(from_attributes=True) model_config = ConfigDict(from_attributes=True)
class TaskFilter(BaseModel):
status: StatusEnum | None = None
limit: int | None = 30
offset: int | None = None
date_from: date | None = None
date_to: date | None = None

View File

@@ -3,7 +3,7 @@ from typing import Annotated
from pydantic import BaseModel, BeforeValidator, ConfigDict, EmailStr from pydantic import BaseModel, BeforeValidator, ConfigDict, EmailStr
from src.schemas.tasks import TaskWithId from src.schemas.tasks import TaskWithId
from src.schemas.validators import ensure_password, ensure_username from src.schemas.validators import ensure_password
class UserUpdate(BaseModel): class UserUpdate(BaseModel):
@@ -19,8 +19,6 @@ class User(BaseModel):
username: str username: str
is_active: bool is_active: bool
is_superuser: bool is_superuser: bool
telegram_id: int | None
avatar_path: str | None
model_config = ConfigDict(from_attributes=True, extra="ignore") model_config = ConfigDict(from_attributes=True, extra="ignore")
@@ -38,7 +36,7 @@ class UserRequest(BaseModel):
class UserRequestADD(BaseModel): class UserRequestADD(BaseModel):
username: Annotated[str, BeforeValidator(ensure_username)] username: str
email: EmailStr | None = None email: EmailStr | None = None
password: Annotated[str, BeforeValidator(ensure_password)] password: Annotated[str, BeforeValidator(ensure_password)]

View File

@@ -1,21 +1,11 @@
from typing import Any from typing import Any
def ensure_password(value: Any) -> str: def ensure_password(value: Any) -> Any:
if not isinstance(value, str): if not isinstance(value, str):
raise TypeError("Password must be a string") raise TypeError("Password must be a string")
value = value.strip()
if len(value) < 8: if len(value) < 8:
raise ValueError("Password must be at least 8 characters") raise ValueError("Password must be at least 8 characters")
elif value.strip() == "": if value.strip() == "":
raise ValueError("Password cannot be empty") raise ValueError("Password cannot be empty")
return value return value
def ensure_username(value: str) -> str:
value = value.strip()
if len(value) < 3:
raise ValueError("Username must be at least 3 characters")
elif value.lower() in ["admin", "moderator", "админ", "модератор"]:
raise ValueError("Login is already taken")
return value

View File

@@ -2,7 +2,7 @@ from fastapi import HTTPException
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 Token
from src.schemas.users import User, UserAdd, UserRequestADD, UserWithHashedPass from src.schemas.users import User, UserAdd, UserRequestADD, UserWithHashedPass
from src.services.base import BaseService from src.services.base import BaseService
@@ -19,50 +19,25 @@ class AuthService(BaseService):
await self.session.commit() await self.session.commit()
return User.model_validate(result) return User.model_validate(result)
async def _tokens_create(self, user_data: TokenData, fingerprint: str): async def login(self, username: str, password: str):
access_token = AuthManager.create_access_token(user_data.model_dump())
refresh_token = AuthManager.create_refresh_token()
await self.session.auth.create_one({
"token": refresh_token,
"user_id": user_data.id,
"fingerprint": fingerprint,
})
return {
"access_token": access_token,
"token_type": settings.access_token.token_type,
"refresh_token": refresh_token,
}
async def login(self, username: str, password: str, fingerprint: str) -> dict:
login_exception = HTTPException(
status_code=401,
detail="Incorrect username or password",
)
result = await self.session.user.get_one_or_none(username=username) result = await self.session.user.get_one_or_none(username=username)
if result is None: if result is None:
raise login_exception raise HTTPException(
status_code=401,
detail="Incorrect username or password",
)
user = UserWithHashedPass.model_validate(result) user = UserWithHashedPass.model_validate(result)
token_data = TokenData.model_validate(user.model_dump())
verify = AuthManager.verify_password( verify = AuthManager.verify_password(
plain_password=password, hashed_password=user.hashed_password plain_password=password, hashed_password=user.hashed_password
) )
if not verify or user.is_active is False: if not verify or user.is_active is False:
raise login_exception raise HTTPException(
tokens = await self._tokens_create(token_data, fingerprint) status_code=401,
await self.session.commit() detail="Incorrect username or password",
return tokens )
access_token = AuthManager.create_access_token(
async def refresh_tokens( data={"id": user.id, "sub": user.username, "is_active": user.is_active}
self, refresh_token: str, user_data: TokenData, fingerprint: str )
) -> dict: return Token(
token_record = await self.session.auth.get_one_or_none(token=refresh_token) access_token=access_token, token_type=settings.access_token.token_type
if not token_record or token_record.user_id != user_data.id: )
raise HTTPException(status_code=401, detail="Invalid refresh token")
token = await self._tokens_create(user_data, fingerprint)
await self.session.auth.delete_one(token=refresh_token)
await self.session.commit()
return token
async def delete_token(self, token: str) -> None:
await self.session.auth.delete_one(token=token)
await self.session.commit()

View File

@@ -1,10 +1,13 @@
from fastapi import HTTPException from fastapi import HTTPException
from src.models.tasks import TasksORM
from src.schemas.tasks import Task, TaskADDRequest, TaskPATCHRequest from src.schemas.tasks import Task, TaskADDRequest, TaskPATCHRequest
from src.services.base import BaseService from src.services.base import BaseService
class TaskService(BaseService): class TaskService(BaseService):
model = TasksORM
async def create_task(self, user_id: int, task_data: TaskADDRequest) -> Task: async def create_task(self, user_id: int, task_data: TaskADDRequest) -> Task:
user = await self.session.user.get_one_or_none(id=user_id) user = await self.session.user.get_one_or_none(id=user_id)
if user is None: if user is None:
@@ -26,13 +29,10 @@ class TaskService(BaseService):
await self.session.commit() await self.session.commit()
async def update_task( async def update_task(
self, self, task_id: int, task_data: TaskPATCHRequest, exclude_unset: bool = True
task_id: int,
task_data: TaskPATCHRequest,
exclude_unset: bool = True,
): ):
task = await self.session.task.update_one( task = await self.session.task.update_one(
data=task_data.model_dump(exclude_unset=exclude_unset), id=task_id id=task_id, data=task_data.model_dump(exclude_unset=exclude_unset)
) )
await self.session.commit() await self.session.commit()
return Task.model_validate(task) return Task.model_validate(task)

View File

@@ -1,3 +1,5 @@
from datetime import date
from fastapi import HTTPException from fastapi import HTTPException
from src.schemas.users import User, UserUpdate, UserWithTasks from src.schemas.users import User, UserUpdate, UserWithTasks
@@ -34,24 +36,27 @@ class UserService(BaseService):
async def update_user(self, id: int, update_data: UserUpdate) -> User: async def update_user(self, id: int, update_data: UserUpdate) -> User:
await self.get_user_by_filter_or_raise(id=id) await self.get_user_by_filter_or_raise(id=id)
user = await self.session.user.update_one( user = await self.session.user.update_one(
data=update_data.model_dump(exclude_unset=True), id=id id=id, data=update_data.model_dump(exclude_unset=True)
) )
await self.session.commit() await self.session.commit()
return User.model_validate(user) return User.model_validate(user)
async def get_user_with_tasks(self, user_id: int, **attrs): async def get_user_with_tasks(
if attrs.get("page") and attrs.get("limit"): self,
tasks_offset = (attrs.get("page", 0) - 1) * attrs.get("limit") user_id: int,
else: status: str | None,
tasks_offset = None limit: int | None,
offset: int | None,
date_to: date | None,
date_from: date | None,
):
user = await self.session.user.get_one_with_load( user = await self.session.user.get_one_with_load(
user_id=user_id, user_id=user_id,
status=attrs.get("status"), status=status,
priority=attrs.get("priority"), tasks_limit=limit,
tasks_limit=attrs.get("limit"), tasks_offset=offset,
tasks_offset=tasks_offset, date_from=date_from,
date_from=attrs.get("date_from"), date_to=date_to,
date_to=attrs.get("date_to"),
) )
if user is None: if user is None:
raise HTTPException(status_code=404, detail="User not found.") raise HTTPException(status_code=404, detail="User not found.")

View File

@@ -1,3 +1,4 @@
import pytest import pytest
from httpx import ASGITransport, AsyncClient from httpx import ASGITransport, AsyncClient
from sqlalchemy import NullPool, insert from sqlalchemy import NullPool, insert

View File

@@ -1,4 +1,3 @@
import pytest
from httpx import AsyncClient from httpx import AsyncClient
from src.core.settings import settings from src.core.settings import settings
@@ -16,30 +15,14 @@ async def test_registration(ac):
assert result.json()["is_active"] assert result.json()["is_active"]
@pytest.mark.parametrize( async def test_login(ac: AsyncClient):
"fingerprint, username,password,expected_status",
[("string", "kot", "P@ssw0rd", 200), ("", "kot", "P@ssw0rd", 422)],
)
async def test_login(
ac: AsyncClient,
fingerprint: str,
username: str,
password: str,
expected_status: int,
):
result = await ac.post( result = await ac.post(
f"{settings.api.v1_login_url}/login", f"{settings.api.v1_login_url}/login",
data={ data={
"fingerprint": fingerprint,
"grant_type": "password", "grant_type": "password",
"username": username, "username": "kot",
"password": password, "password": "P@ssw0rd",
}, },
) )
assert result.status_code == expected_status assert result.status_code == 200
if expected_status == 200: assert result.json().get("access_token")
assert result.json().get("access_token") is not None
else:
json_response = result.json()
if expected_status == 422:
assert "detail" in json_response

View File

@@ -55,32 +55,3 @@ async def test_tasks_user(db: "TestDBManager"):
user_with_tasks = await db.user.get_one_with_load(user_id=admin_user.id) user_with_tasks = await db.user.get_one_with_load(user_id=admin_user.id)
assert user_with_tasks assert user_with_tasks
assert user_with_tasks.tasks assert user_with_tasks.tasks
async def test_tasks_crud(db: "TestDBManager"):
data = {
"title": "test_tasks_crud",
"description": "test",
"due_date": datetime.now(),
"status": "open",
"priority": "medium",
"user_id": 1,
}
task = await db.task.create_one(data)
assert task
assert task.title == data["title"]
assert task.description == data["description"]
assert task.due_date == data["due_date"].date()
assert task.status == data["status"]
assert task.priority == data["priority"]
assert task.user_id == data["user_id"]
assert task.created_at is not None
find_task = await db.task.get_filtered(title=data["title"])
assert find_task
assert find_task[0].title == task.title
data["title"] = "test2"
task = await db.task.update_one(id=task.id, data=data)
assert task.title == data["title"]
await db.task.delete_one(id=task.id)
task = await db.task.get_one_or_none(id=task.id)
assert not task