Merge branch 'master' into VORKOUT-14

This commit is contained in:
2025-07-04 18:05:45 +05:00
25 changed files with 759 additions and 253 deletions

View File

@@ -0,0 +1,38 @@
"""empty message
Revision ID: 93106fbe7d83
Revises: f1b06efacec0
Create Date: 2025-06-26 16:36:02.270706
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
from sqlalchemy.dialects import mysql
# revision identifiers, used by Alembic.
revision: str = '93106fbe7d83'
down_revision: Union[str, None] = 'f1b06efacec0'
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.alter_column('account_keyring', 'key_value',
existing_type=mysql.VARCHAR(length=255),
type_=sa.String(length=512),
existing_nullable=False)
# ### end Alembic commands ###
def downgrade() -> None:
"""Downgrade schema."""
# ### commands auto generated by Alembic - please adjust! ###
op.alter_column('account_keyring', 'key_value',
existing_type=sa.String(length=512),
type_=mysql.VARCHAR(length=255),
existing_nullable=False)
# ### end Alembic commands ###

View File

@@ -1,19 +1,17 @@
from typing import Optional
import math
from datetime import datetime, timezone
from sqlalchemy import insert, select, func
from sqlalchemy.ext.asyncio import AsyncConnection
from enum import Enum
from typing import Optional
from sqlalchemy import func, insert, select
from sqlalchemy.ext.asyncio import AsyncConnection
from api.db.tables.account import account_table
from api.schemas.account.account import User
from api.schemas.endpoints.account import AllUserResponse, all_user_adapter
from api.schemas.endpoints.account import all_user_adapter, AllUser, AllUserResponse, UserCreate
async def get_user_accaunt_page(connection: AsyncConnection, page, limit) -> Optional[User]:
async def get_user_accaunt_page(connection: AsyncConnection, page, limit) -> Optional[AllUserResponse]:
"""
Получает список ползовелей заданных значениями page, limit.
"""
@@ -47,31 +45,28 @@ async def get_user_accaunt_page(connection: AsyncConnection, page, limit) -> Opt
validated_users = all_user_adapter.validate_python(users_data)
return AllUserResponse(users=validated_users, amount_count=total_count, amount_pages=total_pages)
return AllUserResponse(
users=validated_users,
amount_count=total_count,
amount_pages=total_pages,
current_page=page,
limit=limit,
)
async def get_user_by_id(connection: AsyncConnection, id: int) -> Optional[User]:
async def get_user_by_id(connection: AsyncConnection, user_id: int) -> Optional[AllUser]:
"""
Получает юзера по id.
"""
query = select(account_table).where(account_table.c.id == id)
query = select(account_table).where(account_table.c.id == user_id)
user_db_cursor = await connection.execute(query)
user_db = user_db_cursor.one_or_none()
user = user_db_cursor.mappings().one_or_none()
if not user_db:
if not user:
return None
user_data = {
column.name: (
getattr(user_db, column.name).name
if isinstance(getattr(user_db, column.name), Enum)
else getattr(user_db, column.name)
)
for column in account_table.columns
}
return User.model_validate(user_data)
return AllUser.model_validate(user)
async def get_user_by_login(connection: AsyncConnection, login: str) -> Optional[User]:
@@ -107,7 +102,7 @@ async def update_user_by_id(connection: AsyncConnection, update_values, user) ->
await connection.commit()
async def create_user(connection: AsyncConnection, user: User, creator_id: int) -> Optional[User]:
async def create_user(connection: AsyncConnection, user: UserCreate, creator_id: int) -> Optional[AllUser]:
"""
Создает нове поле в таблице account_table.
"""
@@ -117,14 +112,15 @@ async def create_user(connection: AsyncConnection, user: User, creator_id: int)
email=user.email,
bind_tenant_id=user.bind_tenant_id,
role=user.role.value,
meta=user.meta,
meta=user.meta or {},
creator_id=creator_id,
created_at=datetime.now(timezone.utc),
status=user.status.value,
)
await connection.execute(query)
res = await connection.execute(query)
await connection.commit()
new_user = await get_user_by_id(connection, res.lastrowid)
return user
return new_user

View File

@@ -8,13 +8,14 @@ from api.db.tables.account import account_table, account_keyring_table, KeyType,
from api.schemas.account.account import User
from api.schemas.account.account_keyring import AccountKeyring
from api.schemas.endpoints.account import AllUser
from api.utils.key_id_gen import KeyIdGenerator
from datetime import datetime, timezone
async def get_user(connection: AsyncConnection, login: str) -> Optional[User]:
async def get_user(connection: AsyncConnection, login: str) -> tuple[Optional[AllUser], Optional[AccountKeyring]]:
query = (
select(account_table, account_keyring_table)
.join(account_keyring_table, account_table.c.id == account_keyring_table.c.owner_id)
@@ -45,7 +46,7 @@ async def get_user(connection: AsyncConnection, login: str) -> Optional[User]:
for column in account_keyring_table.columns
}
user = User.model_validate(user_data)
user = AllUser.model_validate(user_data)
password = AccountKeyring.model_validate(password_data)
return user, password

View File

@@ -1,13 +1,14 @@
from typing import Optional
from datetime import datetime, timezone
from datetime import datetime, timedelta, timezone
from enum import Enum
from typing import Optional
from sqlalchemy import insert, select
from sqlalchemy import insert, select, update
from sqlalchemy.dialects.mysql import insert as mysql_insert
from sqlalchemy.ext.asyncio import AsyncConnection
from api.db.tables.account import account_keyring_table
from api.db.tables.account import account_keyring_table, KeyStatus, KeyType
from api.schemas.account.account_keyring import AccountKeyring
from api.utils.hasher import hasher
async def get_key_by_id(connection: AsyncConnection, key_id: str) -> Optional[AccountKeyring]:
@@ -67,3 +68,37 @@ async def create_key(connection: AsyncConnection, key: AccountKeyring, key_id: i
await connection.commit()
return key
async def create_password_key(connection: AsyncConnection, password: str | None, owner_id: int):
if password is None:
password = hasher.generate_password()
hashed_password = hasher.hash_data(password)
stmt = mysql_insert(account_keyring_table).values(
owner_id=owner_id,
key_type=KeyType.PASSWORD.value,
key_id="PASSWORD",
key_value=hashed_password,
created_at=datetime.now(timezone.utc),
expiry=datetime.now(timezone.utc) + timedelta(days=365),
status=KeyStatus.ACTIVE,
)
stmt.on_duplicate_key_update(key_value=hashed_password)
await connection.execute(stmt)
await connection.commit()
async def update_password_key(connection: AsyncConnection, owner_id: int, password: str):
stmt = select(account_keyring_table).where(account_keyring_table.c.owner_id == owner_id)
result = await connection.execute(stmt)
keyring = result.one_or_none()
if not keyring:
await create_password_key(connection, password, owner_id)
else:
stmt = (
update(account_keyring_table)
.values(key_value=hasher.hash_data(password), expiry=datetime.now(timezone.utc) + timedelta(days=365))
.where(account_keyring_table.c.owner_id == owner_id)
)
await connection.execute(stmt)
await connection.commit()

View File

@@ -58,7 +58,7 @@ account_keyring_table = Table(
Column("owner_id", UnsignedInt, ForeignKey("account.id"), primary_key=True, nullable=False),
Column("key_type", SQLAEnum(KeyType), primary_key=True, nullable=False),
Column("key_id", String(40), primary_key=True, default=None),
Column("key_value", String(255), nullable=False),
Column("key_value", String(512), nullable=False),
Column("created_at", DateTime(timezone=True), server_default=func.now()),
Column("expiry", DateTime(timezone=True), nullable=True),
Column("status", SQLAEnum(KeyStatus), nullable=False),

View File

@@ -4,29 +4,24 @@ from fastapi import (
HTTPException,
status,
)
from sqlalchemy.ext.asyncio import AsyncConnection
from api.db.connection.session import get_connection_dep
from api.db.logic.account import (
get_user_by_id,
update_user_by_id,
create_user,
get_user_by_login,
get_user_accaunt_page,
get_user_by_id,
get_user_by_login,
update_user_by_id,
)
from api.schemas.account.account import User
from api.db.logic.keyring import create_password_key, update_password_key
from api.db.tables.account import AccountStatus
from api.schemas.account.account import User
from api.schemas.base import bearer_schema
from api.schemas.endpoints.account import UserUpdate, AllUserResponse
from api.schemas.endpoints.account import AllUser, AllUserResponse, UserCreate, UserUpdate
from api.services.auth import get_current_user
from api.services.user_role_validation import db_user_role_validation
from api.services.update_data_validation import update_user_data_changes
from api.services.user_role_validation import db_user_role_validation
api_router = APIRouter(
prefix="/account",
@@ -51,9 +46,11 @@ async def get_all_account(
return user_list
@api_router.get("/{user_id}", dependencies=[Depends(bearer_schema)], response_model=User)
@api_router.get("/{user_id}", dependencies=[Depends(bearer_schema)], response_model=UserUpdate)
async def get_account(
user_id: int, connection: AsyncConnection = Depends(get_connection_dep), current_user=Depends(get_current_user)
user_id: int,
connection: AsyncConnection = Depends(get_connection_dep),
current_user=Depends(get_current_user),
):
authorize_user = await db_user_role_validation(connection, current_user)
@@ -65,26 +62,27 @@ async def get_account(
return user
@api_router.post("", dependencies=[Depends(bearer_schema)], response_model=User)
@api_router.post("", dependencies=[Depends(bearer_schema)], response_model=AllUser)
async def create_account(
user: UserUpdate, connection: AsyncConnection = Depends(get_connection_dep), current_user=Depends(get_current_user)
user: UserCreate,
connection: AsyncConnection = Depends(get_connection_dep),
current_user=Depends(get_current_user),
):
authorize_user = await db_user_role_validation(connection, current_user)
user_validation = await get_user_by_login(connection, user.login)
if user_validation is None:
await create_user(connection, user, authorize_user.id)
user_new = await get_user_by_login(connection, user.login)
return user_new
new_user = await create_user(connection, user, authorize_user.id)
await create_password_key(connection, user.password, new_user.id)
return new_user
else:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST, detail="An account with this information already exists."
)
@api_router.put("/{user_id}", dependencies=[Depends(bearer_schema)], response_model=User)
@api_router.put("/{user_id}", dependencies=[Depends(bearer_schema)], response_model=UserUpdate)
async def update_account(
user_id: int,
user_update: UserUpdate,
@@ -97,12 +95,15 @@ async def update_account(
if user is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Account not found")
if user_update.password is not None:
await update_password_key(connection, user.id, user_update.password)
update_values = update_user_data_changes(user_update, user)
if update_values is None:
return user
user_update_data = User.model_validate({**user.model_dump(), **update_values})
user_update_data = UserUpdate.model_validate({**user.model_dump(), **update_values})
await update_user_by_id(connection, update_values, user)
@@ -113,7 +114,9 @@ async def update_account(
@api_router.delete("/{user_id}", dependencies=[Depends(bearer_schema)], response_model=User)
async def delete_account(
user_id: int, connection: AsyncConnection = Depends(get_connection_dep), current_user=Depends(get_current_user)
user_id: int,
connection: AsyncConnection = Depends(get_connection_dep),
current_user=Depends(get_current_user),
):
authorize_user = await db_user_role_validation(connection, current_user)

View File

@@ -1,9 +1,9 @@
from typing import Optional, List
from datetime import datetime
from typing import List, Optional
from pydantic import EmailStr, Field, TypeAdapter
from api.db.tables.account import AccountRole, AccountStatus
from api.schemas.base import Base
@@ -11,12 +11,24 @@ class UserUpdate(Base):
name: Optional[str] = Field(None, max_length=100)
login: Optional[str] = Field(None, max_length=100)
email: Optional[EmailStr] = None
password: Optional[str] = None
bind_tenant_id: Optional[str] = Field(None, max_length=40)
role: Optional[AccountRole] = None
meta: Optional[dict] = None
status: Optional[AccountStatus] = None
class UserCreate(Base):
name: str = Field(max_length=100)
login: str = Field(max_length=100)
email: Optional[EmailStr] = None
password: Optional[str] = None
bind_tenant_id: Optional[str] = Field(None, max_length=40)
role: AccountRole
meta: Optional[dict] = None
status: AccountStatus
class AllUser(Base):
id: int
name: str
@@ -32,6 +44,8 @@ class AllUserResponse(Base):
users: List[AllUser]
amount_count: int
amount_pages: int
current_page: int
limit: int
all_user_adapter = TypeAdapter(List[AllUser])

View File

@@ -1,27 +1,25 @@
from fastapi import Request, HTTPException
from typing import Optional
from fastapi import HTTPException, Request
from sqlalchemy.ext.asyncio import AsyncConnection
from api.db.logic.auth import get_user
# # from backend.schemas.users.token import TokenData
from api.schemas.account.account import User
from api.db.tables.account import AccountStatus
from api.utils.hasher import Hasher
from api.schemas.endpoints.account import AllUser
from api.utils.hasher import hasher
async def get_current_user(request: Request) -> Optional[User]:
async def get_current_user(request: Request) -> str | HTTPException:
if not hasattr(request.state, "current_user"):
return HTTPException(status_code=401, detail="Unauthorized")
return request.state.current_user
async def authenticate_user(connection: AsyncConnection, username: str, password: str) -> Optional[User]:
async def authenticate_user(connection: AsyncConnection, username: str, password: str) -> Optional[AllUser]:
sql_user, sql_password = await get_user(connection, username)
if not sql_user or sql_user.status != AccountStatus.ACTIVE:
return None
hasher = Hasher()
if not hasher.verify_data(password, sql_password.key_value):
return None
return sql_user

View File

@@ -1,4 +1,6 @@
import hashlib
import secrets
# Хешер для работы с паролем.
@@ -14,3 +16,10 @@ class Hasher:
def verify_data(self, password: str, hashed: str) -> bool:
# Проверяет пароль путем сравнения его хеша с сохраненным хешем.
return self.hash_data(password) == hashed
@staticmethod
def generate_password() -> str:
return secrets.token_urlsafe(20)
hasher = Hasher()

View File

@@ -1,32 +1,23 @@
import os
import asyncio
import hashlib
import secrets
import os
from api.db.connection.session import get_connection
from api.db.tables.account import account_table, account_keyring_table, AccountRole, KeyType, KeyStatus
from api.db.tables.account import account_keyring_table, account_table, AccountRole, KeyStatus, KeyType
from api.utils.hasher import hasher
from api.utils.key_id_gen import KeyIdGenerator
INIT_LOCK_FILE = "../init.lock"
DEFAULT_LOGIN = "vorkout"
def hash_password(password: str) -> str:
return hashlib.sha256(password.encode()).hexdigest()
def generate_password() -> str:
return secrets.token_urlsafe(20)
async def init():
if os.path.exists(INIT_LOCK_FILE):
print("Sorry, service is already initialized")
return
async with get_connection() as conn:
password = generate_password()
hashed_password = hash_password(password)
password = hasher.generate_password()
hashed_password = hasher.hash_data(password)
create_user_query = account_table.insert().values(
name=DEFAULT_LOGIN,

View File

@@ -1,6 +1,6 @@
[project]
name = "api"
version = "0.0.4"
version = "0.0.5"
description = ""
authors = [{ name = "Vladislav", email = "vlad.dev@heado.ru" }]
readme = "README.md"