41 Commits

Author SHA1 Message Date
abd87b46b3 refactor(tables): change enum, str to StrEnum 2025-06-09 13:03:33 +05:00
787dc0e8f8 refactor: refactor api project with ruff 2025-06-09 12:12:48 +05:00
2e4e9d1113 feat: add bearer schema to all endpoints with auth 2025-06-09 12:10:40 +05:00
c1d315d6e9 feat: add bearer schema and get_current_user function 2025-06-09 11:51:13 +05:00
TheNoxium
a31758192d fix: model 2025-06-09 02:10:46 +05:00
8965365afc Merge pull request 'VORKOUT-7' (#9) from VORKOUT-7 into master
Reviewed-on: #9
Reviewed-by: cyrussmeat <dr.cyrill@gmail.com>
2025-06-05 16:29:14 +05:00
TheNoxium
c68286f7cc fix:merge error 2025-06-05 14:55:38 +05:00
TheNoxium
58cc23f79b fix: mrege error 2025-06-05 14:41:18 +05:00
TheNoxium
48d52bf903 Merge branch 'master' into VORKOUT-7 2025-06-05 13:04:25 +05:00
TheNoxium
66c60cfc59 fix: name 2025-06-04 12:03:59 +05:00
TheNoxium
c60d19262e fix: model, name 2025-06-03 13:01:10 +05:00
TheNoxium
31236d558f feat: model update 2025-06-02 15:56:44 +05:00
TheNoxium
5094b84675 fix: TypeAdapter 2025-05-30 08:51:49 +05:00
7e1fe6f5c4 fix(api): fix pyproject.toml and poetry.lock files 2025-05-29 15:25:03 +05:00
TheNoxium
320c13183f feat: add TypeAdapter 2025-05-28 14:10:00 +05:00
TheNoxium
98a4692247 feat: pydantic models for swagger 2025-05-26 13:45:40 +05:00
8613cbdaac chore: update the patch version to 0.0.3 2025-05-23 18:08:32 +05:00
5f981e8ce1 Merge pull request 'VORKOUT-13' (#8) from VORKOUT-13 into master
Reviewed-on: #8
Reviewed-by: cyrussmeat <dr.cyrill@gmail.com>
2025-05-23 18:07:39 +05:00
98e425862c refactor(account): remove unused print 2025-05-23 18:05:06 +05:00
TheNoxium
96dbc744d7 feat: add query params 2025-05-23 12:48:09 +05:00
TheNoxium
e47e449a36 feat: add page, limit 2025-05-23 12:41:32 +05:00
86d48d0d1c refactor(base): replace to_camel to to_camel from pydantic 2025-05-21 15:49:23 +05:00
TheNoxium
c3c421f66f feat: get all users 2025-05-21 14:58:06 +05:00
fe91bb7103 feat: add base class for all schemas and to camel case mapper 2025-05-21 12:44:28 +05:00
f97d419467 Merge pull request 'VORKOUT-12' (#7) from VORKOUT-12 into master
Reviewed-on: #7
Reviewed-by: ivan.dev <ivan.dev@heado.ru>
Reviewed-by: cyrussmeat <dr.cyrill@gmail.com>
2025-05-21 11:53:30 +05:00
131102beba feat(makefile): add command to format and check formatting for api 2025-05-20 11:52:46 +05:00
881a72a66c refactor: refactor project with ruff 2025-05-20 11:43:05 +05:00
de06890f6a chore: add ruff to dev dependencies 2025-05-20 11:41:19 +05:00
8191ee3a48 Merge pull request 'feat: added endpoints: auth, pofile, account, keyring' (#5) from VORKOUT-4 into master
Reviewed-on: #5
Reviewed-by: Vladislav Syrochkin <vlad.dev@heado.ru>
Reviewed-by: cyrussmeat <dr.cyrill@gmail.com>
2025-05-15 15:21:33 +05:00
TheNoxium
eb63ebb10f style: deleting unnecessary file and stdoout 2025-05-15 15:15:26 +05:00
TheNoxium
3554d43d15 fix: profile 2025-05-12 21:20:36 +05:00
TheNoxium
19f8236b47 fix: middlewaer acces token auth 2025-04-29 21:16:59 +05:00
f1214e7b5a chore: update the client patch version to 0.0.2 2025-04-29 12:18:52 +05:00
dbe3e3ab86 Merge pull request 'VORKOUT-9' (#6) from VORKOUT-9 into master
Reviewed-on: #6
Reviewed-by: cyrussmeat <dr.cyrill@gmail.com>
2025-04-29 12:15:41 +05:00
TheNoxium
b90b70568c fix: model validation 2025-04-23 17:30:49 +03:00
TheNoxium
23329e7d36 fix: refresh token exipreited 2025-04-23 16:54:30 +03:00
TheNoxium
22e2bca83c fix: name function 2025-04-23 16:48:02 +03:00
TheNoxium
f67ef7f96f fix: account post route 2025-04-23 16:06:33 +03:00
TheNoxium
8271737ce2 fix: makefile:install, auth token logic 2025-04-23 15:36:49 +03:00
TheNoxium
29027bf9f8 fix: name, update values HTTPException 2025-04-18 17:31:37 +05:00
TheNoxium
1333992dc5 feat: added endpoints: auth, pofile, account, keyring 2025-04-17 15:36:52 +05:00
39 changed files with 2223 additions and 313 deletions

View File

@@ -39,12 +39,22 @@ revision:
venv-api:
cd api && \
poetry env activate \
poetry install
install:
make migrate head && \
cd api && \
poetry run python3 api/utils/init.py
poetry run python3 -m api.utils.init
%::
echo $(MESSAGE)
format-api:
cd api && \
poetry run ruff format .
check-api:
cd api && \
poetry run ruff format . --check

View File

@@ -9,9 +9,10 @@ from uvicorn import run
from api.config import get_settings, DefaultSettings
from api.endpoints import list_of_routes
from api.utils.common import get_hostname
from api.services.middleware import MiddlewareAccessTokenValidadtion
logger = logging.getLogger()
logger.setLevel(logging.INFO)
logger.setLevel(logging.DEBUG)
def bind_routes(application: FastAPI, setting: DefaultSettings) -> None:
@@ -32,7 +33,7 @@ def get_app() -> FastAPI:
description=description,
docs_url="/swagger",
openapi_url="/openapi",
version="0.1.0",
version="0.0.2",
)
settings = get_settings()
bind_routes(application, settings)
@@ -44,6 +45,7 @@ app = get_app()
dev_origins = [
"http://localhost:3000",
"http://127.0.0.1:64775",
]
prod_origins = [""]
@@ -78,3 +80,5 @@ app.add_middleware(
allow_methods=["GET", "POST", "OPTIONS", "DELETE", "PUT"],
allow_headers=["*"],
)
app.add_middleware(MiddlewareAccessTokenValidadtion)

View File

@@ -44,6 +44,12 @@ class DefaultSettings(BaseSettings):
REDIS_DB: int = int(environ.get("REDIS_DB", "0"))
REDIS_PASSWORD: str = environ.get("REDIS_PASSWORD", "hackme")
SECRET_KEY: str = environ.get("SECRET_KEY", "secret")
ALGORITHM: str = environ.get("ALGORITHM", "HS256")
ACCESS_TOKEN_EXPIRE_MINUTES: int = int(environ.get("ACCESS_TOKEN_EXPIRE_MINUTES", 600))
REFRESH_TOKEN_EXPIRE_DAYS: int = int(environ.get("REFRESH_TOKEN_EXPIRE_DAYS_LONG", 365))
@cached_property
def database_settings(self) -> dict:
"""Get all settings for connection with database."""

View File

@@ -0,0 +1,138 @@
"""empty message
Revision ID: f1b06efacec0
Revises:
Create Date: 2025-04-23 15:09:14.833213
"""
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 = 'f1b06efacec0'
down_revision: Union[str, None] = None
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('account',
sa.Column('id', sa.Integer().with_variant(mysql.INTEGER(unsigned=True), 'mysql'), autoincrement=True, nullable=False),
sa.Column('name', sa.String(length=100), nullable=False),
sa.Column('login', sa.String(length=100), nullable=False),
sa.Column('email', sa.String(length=100), nullable=True),
sa.Column('bind_tenant_id', sa.String(length=40), nullable=True),
sa.Column('role', sa.Enum('OWNER', 'ADMIN', 'EDITOR', 'VIEWER', name='accountrole'), nullable=False),
sa.Column('meta', sa.JSON(), nullable=True),
sa.Column('creator_id', sa.Integer().with_variant(mysql.INTEGER(unsigned=True), 'mysql'), nullable=True),
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=True),
sa.Column('status', sa.Enum('ACTIVE', 'DISABLED', 'BLOCKED', 'DELETED', name='accountstatus'), nullable=False),
sa.ForeignKeyConstraint(['creator_id'], ['account.id'], ),
sa.PrimaryKeyConstraint('id')
)
op.create_index('idx_login', 'account', ['login'], unique=False)
op.create_index('idx_name', 'account', ['name'], unique=False)
op.create_table('account_keyring',
sa.Column('owner_id', sa.Integer().with_variant(mysql.INTEGER(unsigned=True), 'mysql'), nullable=False),
sa.Column('key_type', sa.Enum('PASSWORD', 'ACCESS_TOKEN', 'REFRESH_TOKEN', 'API_KEY', name='keytype'), nullable=False),
sa.Column('key_id', sa.String(length=40), nullable=False),
sa.Column('key_value', sa.String(length=255), nullable=False),
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=True),
sa.Column('expiry', sa.DateTime(timezone=True), nullable=True),
sa.Column('status', sa.Enum('ACTIVE', 'EXPIRED', 'DELETED', name='keystatus'), nullable=False),
sa.ForeignKeyConstraint(['owner_id'], ['account.id'], ),
sa.PrimaryKeyConstraint('owner_id', 'key_type', 'key_id')
)
op.create_table('list_events',
sa.Column('id', sa.Integer().with_variant(mysql.INTEGER(unsigned=True), 'mysql'), autoincrement=True, nullable=False),
sa.Column('name', sa.String(length=40, collation='latin1_bin'), nullable=False),
sa.Column('title', sa.String(length=64), nullable=False),
sa.Column('creator_id', sa.Integer().with_variant(mysql.INTEGER(unsigned=True), 'mysql'), nullable=False),
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=True),
sa.Column('schema', sa.JSON(), nullable=True),
sa.Column('state', sa.Enum('AUTO', 'DESCRIPTED', name='eventstate'), nullable=False),
sa.Column('status', sa.Enum('ACTIVE', 'DISABLED', 'DELETED', name='eventstatus'), nullable=False),
sa.ForeignKeyConstraint(['creator_id'], ['account.id'], ),
sa.PrimaryKeyConstraint('id'),
sa.UniqueConstraint('name')
)
op.create_table('process_schema',
sa.Column('id', sa.Integer().with_variant(mysql.INTEGER(unsigned=True), 'mysql'), autoincrement=True, nullable=False),
sa.Column('title', sa.String(length=100), nullable=False),
sa.Column('description', sa.Text(), nullable=False),
sa.Column('owner_id', sa.Integer().with_variant(mysql.INTEGER(unsigned=True), 'mysql'), nullable=False),
sa.Column('creator_id', sa.Integer().with_variant(mysql.INTEGER(unsigned=True), 'mysql'), nullable=False),
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=True),
sa.Column('settings', sa.JSON(), nullable=True),
sa.Column('status', sa.Enum('ACTIVE', 'STOPPING', 'STOPPED', 'DELETED', name='processstatus'), nullable=False),
sa.ForeignKeyConstraint(['creator_id'], ['account.id'], ),
sa.ForeignKeyConstraint(['owner_id'], ['account.id'], ),
sa.PrimaryKeyConstraint('id')
)
op.create_index('idx_owner_id', 'process_schema', ['owner_id'], unique=False)
op.create_table('process_version_archive',
sa.Column('id', sa.Integer().with_variant(mysql.INTEGER(unsigned=True), 'mysql'), autoincrement=True, nullable=False),
sa.Column('ps_id', sa.Integer().with_variant(mysql.INTEGER(unsigned=True), 'mysql'), nullable=False),
sa.Column('version', sa.Integer().with_variant(mysql.INTEGER(unsigned=True), 'mysql'), nullable=False),
sa.Column('snapshot', sa.JSON(), nullable=True),
sa.Column('owner_id', sa.Integer().with_variant(mysql.INTEGER(unsigned=True), 'mysql'), nullable=False),
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=True),
sa.Column('is_last', sa.Integer().with_variant(mysql.INTEGER(unsigned=True), 'mysql'), nullable=True),
sa.ForeignKeyConstraint(['owner_id'], ['account.id'], ),
sa.ForeignKeyConstraint(['ps_id'], ['process_schema.id'], ),
sa.PrimaryKeyConstraint('id', 'version')
)
op.create_table('ps_node',
sa.Column('id', sa.Integer().with_variant(mysql.INTEGER(unsigned=True), 'mysql'), autoincrement=True, nullable=False),
sa.Column('ps_id', sa.Integer().with_variant(mysql.INTEGER(unsigned=True), 'mysql'), nullable=False),
sa.Column('node_type', sa.Enum('TYPE1', 'TYPE2', 'TYPE3', name='nodetype'), nullable=False),
sa.Column('settings', sa.JSON(), nullable=True),
sa.Column('creator_id', sa.Integer().with_variant(mysql.INTEGER(unsigned=True), 'mysql'), nullable=False),
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=True),
sa.Column('status', sa.Enum('ACTIVE', 'DISABLED', 'DELETED', name='nodestatus'), nullable=False),
sa.ForeignKeyConstraint(['creator_id'], ['account.id'], ),
sa.ForeignKeyConstraint(['ps_id'], ['process_schema.id'], ),
sa.PrimaryKeyConstraint('id')
)
op.create_index('idx_ps_id', 'ps_node', ['ps_id'], unique=False)
op.create_table('node_link',
sa.Column('id', sa.Integer().with_variant(mysql.INTEGER(unsigned=True), 'mysql'), autoincrement=True, nullable=False),
sa.Column('link_name', sa.String(length=20), nullable=False),
sa.Column('node_id', sa.Integer().with_variant(mysql.INTEGER(unsigned=True), 'mysql'), nullable=False),
sa.Column('next_node_id', sa.Integer().with_variant(mysql.INTEGER(unsigned=True), 'mysql'), nullable=False),
sa.Column('settings', sa.JSON(), nullable=True),
sa.Column('creator_id', sa.Integer().with_variant(mysql.INTEGER(unsigned=True), 'mysql'), nullable=False),
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=True),
sa.Column('status', sa.Enum('ACTIVE', 'STOPPING', 'STOPPED', 'DELETED', name='nodelinkstatus'), nullable=False),
sa.ForeignKeyConstraint(['creator_id'], ['account.id'], ),
sa.ForeignKeyConstraint(['next_node_id'], ['ps_node.id'], ),
sa.ForeignKeyConstraint(['node_id'], ['ps_node.id'], ),
sa.PrimaryKeyConstraint('id')
)
op.create_index('idx_next_node_id', 'node_link', ['next_node_id'], unique=False)
op.create_index('idx_node_id', 'node_link', ['node_id'], unique=False)
# ### end Alembic commands ###
def downgrade() -> None:
"""Downgrade schema."""
# ### commands auto generated by Alembic - please adjust! ###
op.drop_index('idx_node_id', table_name='node_link')
op.drop_index('idx_next_node_id', table_name='node_link')
op.drop_table('node_link')
op.drop_index('idx_ps_id', table_name='ps_node')
op.drop_table('ps_node')
op.drop_table('process_version_archive')
op.drop_index('idx_owner_id', table_name='process_schema')
op.drop_table('process_schema')
op.drop_table('list_events')
op.drop_table('account_keyring')
op.drop_index('idx_name', table_name='account')
op.drop_index('idx_login', table_name='account')
op.drop_table('account')
# ### end Alembic commands ###

View File

@@ -15,11 +15,9 @@ from api.config import get_settings
from api.config.default import DbCredentialsSchema
class SessionManager:
engines: Any
def __init__(self, database_uri=get_settings().database_uri) -> None:
self.database_uri = database_uri
self.refresh(database_uri)
@@ -44,9 +42,11 @@ class SessionManager:
pool_size=get_settings().CONNECTION_POOL_SIZE,
max_overflow=get_settings().CONNECTION_OVERFLOW,
)
def get_engine_by_db_uri(self, database_uri) -> AsyncEngine:
return self.engines[database_uri]
@contextlib.asynccontextmanager
async def get_connection(
database_uri=None,
@@ -58,6 +58,7 @@ async def get_connection(
async with engine.connect() as conn:
yield conn
async def get_connection_dep() -> AsyncConnection:
async with get_connection() as conn:
yield conn

130
api/api/db/logic/account.py Normal file
View File

@@ -0,0 +1,130 @@
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 api.db.tables.account import account_table
from api.schemas.account.account import User
from api.schemas.endpoints.account import AllUserResponse, all_user_adapter
async def get_user_accaunt_page(connection: AsyncConnection, page, limit) -> Optional[User]:
"""
Получает список ползовелей заданных значениями page, limit.
"""
first_user = page * limit - (limit)
query = (
select(
account_table.c.id,
account_table.c.name,
account_table.c.login,
account_table.c.email,
account_table.c.bind_tenant_id,
account_table.c.role,
account_table.c.created_at,
account_table.c.status,
)
.order_by(account_table.c.id)
.offset(first_user)
.limit(limit)
)
count_query = select(func.count()).select_from(account_table)
result = await connection.execute(query)
count_result = await connection.execute(count_query)
users_data = result.mappings().all()
total_count = count_result.scalar()
total_pages = math.ceil(total_count / limit)
validated_users = all_user_adapter.validate_python(users_data)
return AllUserResponse(users=validated_users, amount_count=total_count, amount_pages=total_pages)
async def get_user_by_id(connection: AsyncConnection, id: int) -> Optional[User]:
"""
Получает юзера по id.
"""
query = select(account_table).where(account_table.c.id == id)
user_db_cursor = await connection.execute(query)
user_db = user_db_cursor.one_or_none()
if not user_db:
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)
async def get_user_by_login(connection: AsyncConnection, login: str) -> Optional[User]:
"""
Получает юзера по login.
"""
query = select(account_table).where(account_table.c.login == login)
user_db_cursor = await connection.execute(query)
user_db = user_db_cursor.one_or_none()
if not user_db:
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)
async def update_user_by_id(connection: AsyncConnection, update_values, user) -> Optional[User]:
"""
Вносит изменеия в нужное поле таблицы account_table.
"""
await connection.execute(account_table.update().where(account_table.c.id == user.id).values(**update_values))
await connection.commit()
async def create_user(connection: AsyncConnection, user: User, creator_id: int) -> Optional[User]:
"""
Создает нове поле в таблице account_table.
"""
query = insert(account_table).values(
name=user.name,
login=user.login,
email=user.email,
bind_tenant_id=user.bind_tenant_id,
role=user.role.value,
meta=user.meta,
creator_id=creator_id,
created_at=datetime.now(timezone.utc),
status=user.status.value,
)
await connection.execute(query)
await connection.commit()
return user

87
api/api/db/logic/auth.py Normal file
View File

@@ -0,0 +1,87 @@
from typing import Optional
from sqlalchemy import select, update
from sqlalchemy.ext.asyncio import AsyncConnection
from enum import Enum
from api.db.tables.account import account_table, account_keyring_table, KeyType, KeyStatus
from api.schemas.account.account import User
from api.schemas.account.account_keyring import AccountKeyring
from api.utils.key_id_gen import KeyIdGenerator
from datetime import datetime, timezone
async def get_user(connection: AsyncConnection, login: str) -> Optional[User]:
query = (
select(account_table, account_keyring_table)
.join(account_keyring_table, account_table.c.id == account_keyring_table.c.owner_id)
.where(account_table.c.login == login, account_keyring_table.c.key_type == KeyType.PASSWORD)
)
user_db_cursor = await connection.execute(query)
user_db = user_db_cursor.one_or_none()
if not user_db:
return None, 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
}
password_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_keyring_table.columns
}
user = User.model_validate(user_data)
password = AccountKeyring.model_validate(password_data)
return user, password
async def upgrade_old_refresh_token(connection: AsyncConnection, user, refresh_token) -> Optional[User]:
new_status = KeyStatus.EXPIRED
update_query = (
update(account_keyring_table)
.where(
account_table.c.id == user.id,
account_keyring_table.c.status == KeyStatus.ACTIVE,
account_keyring_table.c.key_type == KeyType.REFRESH_TOKEN,
account_keyring_table.c.key_value == refresh_token,
)
.values(status=new_status)
)
await connection.execute(update_query)
await connection.commit()
async def add_new_refresh_token(
connection: AsyncConnection, new_refresh_token, new_refresh_token_expires_time, user
) -> Optional[User]:
new_refresh_token = account_keyring_table.insert().values(
owner_id=user.id,
key_type=KeyType.REFRESH_TOKEN,
key_id=KeyIdGenerator(),
key_value=new_refresh_token,
created_at=datetime.now(timezone.utc),
expiry=new_refresh_token_expires_time,
status=KeyStatus.ACTIVE,
)
await connection.execute(new_refresh_token)
await connection.commit()

View File

@@ -0,0 +1,69 @@
from typing import Optional
from datetime import datetime, timezone
from enum import Enum
from sqlalchemy import insert, select
from sqlalchemy.ext.asyncio import AsyncConnection
from api.db.tables.account import account_keyring_table
from api.schemas.account.account_keyring import AccountKeyring
async def get_key_by_id(connection: AsyncConnection, key_id: str) -> Optional[AccountKeyring]:
"""
Получает key по key_id.
"""
query = select(account_keyring_table).where(account_keyring_table.c.key_id == key_id)
user_db_cursor = await connection.execute(query)
user_db = user_db_cursor.one_or_none()
if not user_db:
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_keyring_table.columns
}
return AccountKeyring.model_validate(user_data)
async def update_key_by_id(connection: AsyncConnection, update_values, key) -> Optional[AccountKeyring]:
"""
Вносит изменеия в нужное поле таблицы account_keyring_table.
"""
await connection.execute(
account_keyring_table.update().where(account_keyring_table.c.key_id == key.key_id).values(**update_values)
)
await connection.commit()
async def create_key(connection: AsyncConnection, key: AccountKeyring, key_id: int) -> Optional[AccountKeyring]:
"""
Создает нове поле в таблице account_keyring_table).
"""
query = insert(account_keyring_table).values(
owner_id=key.owner_id,
key_type=key.key_type.value,
key_id=key_id,
key_value=key.key_value,
created_at=datetime.now(timezone.utc),
expiry=key.expiry,
status=key.status.value,
)
key.created_at = datetime.now(timezone.utc)
key.key_id = key_id
await connection.execute(query)
await connection.commit()
return key

View File

@@ -1,73 +1,67 @@
from sqlalchemy import Table, Column, Integer, String, Enum as SQLAEnum, JSON, ForeignKey, DateTime, Index
import enum
from sqlalchemy import Table, Column, String, Enum as SQLAEnum, JSON, ForeignKey, DateTime, Index
from sqlalchemy.sql import func
from datetime import datetime, timedelta
from enum import Enum, auto
from enum import Enum
from api.db.sql_types import UnsignedInt
from api.db import metadata
class AccountRole(str,Enum):
OWNER = auto()
ADMIN = auto()
EDITOR = auto()
VIEWER = auto()
class AccountRole(enum.StrEnum):
OWNER = "OWNER"
ADMIN = "ADMIN"
EDITOR = "EDITOR"
VIEWER = "VIEWER"
class AccountStatus(str,Enum):
ACTIVE = auto()
DISABLED = auto()
BLOCKED = auto()
DELETED = auto()
class AccountStatus(enum.StrEnum):
ACTIVE = "ACTIVE"
DISABLED = "DISABLED"
BLOCKED = "BLOCKED"
DELETED = "DELETED"
account_table = Table(
'account', metadata,
Column('id', UnsignedInt, primary_key=True, autoincrement=True),
Column('name', String(100), nullable=False),
Column('login', String(100), nullable=False),
Column('email', String(100), nullable=True),
Column('bind_tenant_id', String(40), nullable=True),
Column('role', SQLAEnum(AccountRole), nullable=False),
Column('meta', JSON, default={}),
Column('creator_id', UnsignedInt, ForeignKey('account.id'), nullable=True),
Column('created_at', DateTime(timezone=True), server_default=func.now()),
Column('status', SQLAEnum(AccountStatus), nullable=False),
Index('idx_login', 'login'),
Index('idx_name', 'name'),
"account",
metadata,
Column("id", UnsignedInt, primary_key=True, autoincrement=True),
Column("name", String(100), nullable=False),
Column("login", String(100), nullable=False),
Column("email", String(100), nullable=True),
Column("bind_tenant_id", String(40), nullable=True),
Column("role", SQLAEnum(AccountRole), nullable=False),
Column("meta", JSON, default={}),
Column("creator_id", UnsignedInt, ForeignKey("account.id"), nullable=True),
Column("created_at", DateTime(timezone=True), server_default=func.now()),
Column("status", SQLAEnum(AccountStatus), nullable=False),
Index("idx_login", "login"),
Index("idx_name", "name"),
)
class KeyType(str,Enum):
PASSWORD = auto()
ACCESS_TOKEN = auto()
REFRESH_TOKEN = auto()
API_KEY = auto()
class KeyStatus(str,Enum):
ACTIVE = auto()
EXPIRED = auto()
DELETED = auto()
class KeyType(enum.StrEnum):
PASSWORD = "PASSWORD"
ACCESS_TOKEN = "ACCESS_TOKEN"
REFRESH_TOKEN = "REFRESH_TOKEN"
API_KEY = "API_KEY"
class KeyStatus(enum.StrEnum):
ACTIVE = "ACTIVE"
EXPIRED = "EXPIRED"
DELETED = "DELETED"
account_keyring_table = Table(
'account_keyring', metadata,
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), default=None),
Column('key_value', String(64), 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), )
def set_expiry_for_key_type(key_type: KeyType) -> datetime:
match key_type:
case KeyType.ACCESS_TOKEN:
return datetime.now() + timedelta(hours=1) # 1 hour
case KeyType.REFRESH_TOKEN:
return datetime.now() + timedelta(days=365) # 1 year
case KeyType.API_KEY:
return datetime.max # max datetime
"account_keyring",
metadata,
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("created_at", DateTime(timezone=True), server_default=func.now()),
Column("expiry", DateTime(timezone=True), nullable=True),
Column("status", SQLAEnum(KeyStatus), nullable=False),
)

View File

@@ -1,3 +1,5 @@
import enum
from sqlalchemy import Table, Column, Integer, String, Enum as SQLAEnum, JSON, ForeignKey, DateTime, Index
from sqlalchemy.sql import func
from enum import Enum, auto
@@ -6,24 +8,27 @@ from api.db.sql_types import UnsignedInt
from api.db import metadata
class EventState(str, Enum):
AUTO = auto()
DESCRIPTED = auto()
class EventStatus(str, Enum):
ACTIVE = auto()
DISABLED = auto()
DELETED = auto()
class EventState(enum.StrEnum):
AUTO = "AUTO"
DESCRIPTED = "DESCRIPTED"
class EventStatus(enum.StrEnum):
ACTIVE = "ACTIVE"
DISABLED = "DISABLED"
DELETED = "DELETED"
list_events_table = Table(
'list_events', metadata,
Column('id', UnsignedInt, primary_key=True, autoincrement=True),
Column('name', String(40, collation='latin1_bin'), nullable=False,unique=True),
Column('title', String(64), nullable=False),
Column('creator_id', UnsignedInt, ForeignKey('account.id'), nullable=False),
Column('created_at', DateTime(timezone=True), server_default=func.now()),
Column('schema', JSON, default={}),
Column('state', SQLAEnum(EventState), nullable=False),
Column('status', SQLAEnum(EventStatus), nullable=False),
"list_events",
metadata,
Column("id", UnsignedInt, primary_key=True, autoincrement=True),
Column("name", String(40, collation="latin1_bin"), nullable=False, unique=True),
Column("title", String(64), nullable=False),
Column("creator_id", UnsignedInt, ForeignKey("account.id"), nullable=False),
Column("created_at", DateTime(timezone=True), server_default=func.now()),
Column("schema", JSON, default={}),
Column("state", SQLAEnum(EventState), nullable=False),
Column("status", SQLAEnum(EventStatus), nullable=False),
)

View File

@@ -1,4 +1,18 @@
from sqlalchemy import Table, Column, Integer, String, Text, Enum as SQLAEnum, JSON, ForeignKey, DateTime, Index, PrimaryKeyConstraint
import enum
from sqlalchemy import (
Table,
Column,
Integer,
String,
Text,
Enum as SQLAEnum,
JSON,
ForeignKey,
DateTime,
Index,
PrimaryKeyConstraint,
)
from sqlalchemy.sql import func
from enum import Enum, auto
@@ -7,78 +21,88 @@ from api.db.sql_types import UnsignedInt
from api.db import metadata
# Определение перечислений для статуса процесса
class ProcessStatus(str, Enum):
ACTIVE = auto()
STOPPING = auto()
STOPPED = auto()
DELETED = auto()
class ProcessStatus(enum.StrEnum):
ACTIVE = "ACTIVE"
STOPPING = "STOPPING"
STOPPED = "STOPPED"
DELETED = "DELETED"
# Определение таблицы process_schema
process_schema_table = Table(
'process_schema', metadata,
Column('id', UnsignedInt, primary_key=True, autoincrement=True),
Column('title', String(100), nullable=False),
Column('description', Text, nullable=False),
Column('owner_id', UnsignedInt, ForeignKey('account.id'), nullable=False),
Column('creator_id', UnsignedInt, ForeignKey('account.id'), nullable=False),
Column('created_at', DateTime(timezone=True), server_default=func.now()),
Column('settings', JSON, default={}),
Column('status', SQLAEnum(ProcessStatus), nullable=False),
Index('idx_owner_id', 'owner_id',)
"process_schema",
metadata,
Column("id", UnsignedInt, primary_key=True, autoincrement=True),
Column("title", String(100), nullable=False),
Column("description", Text, nullable=False),
Column("owner_id", UnsignedInt, ForeignKey("account.id"), nullable=False),
Column("creator_id", UnsignedInt, ForeignKey("account.id"), nullable=False),
Column("created_at", DateTime(timezone=True), server_default=func.now()),
Column("settings", JSON, default={}),
Column("status", SQLAEnum(ProcessStatus), nullable=False),
Index(
"idx_owner_id",
"owner_id",
),
)
process_version_archive_table = Table(
'process_version_archive', metadata,
Column('id', UnsignedInt, autoincrement=True, nullable=False),
Column('ps_id', UnsignedInt, ForeignKey('process_schema.id'), nullable=False),
Column('version', UnsignedInt, default=1, nullable=False),
Column('snapshot', JSON, default={}),
Column('owner_id', UnsignedInt, ForeignKey('account.id'), nullable=False),
Column('created_at', DateTime(timezone=True), server_default=func.now()),
Column('is_last', UnsignedInt, default=0),
PrimaryKeyConstraint('id', 'version') )
class NodeStatus(str, Enum):
ACTIVE = auto()
DISABLED = auto()
DELETED = auto()
class NodeType(Enum):
TYPE1 = 'Type1'
TYPE2 = 'Type2'
TYPE3 = 'Type3'
ps_node_table = Table(
'ps_node', metadata,
Column('id', UnsignedInt, autoincrement=True, primary_key=True, nullable=False),
Column('ps_id', UnsignedInt, ForeignKey('process_schema.id'), nullable=False),
Column('node_type', SQLAEnum(NodeType), nullable=False),
Column('settings', JSON, default={}),
Column('creator_id', UnsignedInt, ForeignKey('account.id'), nullable=False),
Column('created_at', DateTime(timezone=True), server_default=func.now()),
Column('status', SQLAEnum(NodeStatus), nullable=False),
Index('idx_ps_id', 'ps_id')
"process_version_archive",
metadata,
Column("id", UnsignedInt, autoincrement=True, nullable=False),
Column("ps_id", UnsignedInt, ForeignKey("process_schema.id"), nullable=False),
Column("version", UnsignedInt, default=1, nullable=False),
Column("snapshot", JSON, default={}),
Column("owner_id", UnsignedInt, ForeignKey("account.id"), nullable=False),
Column("created_at", DateTime(timezone=True), server_default=func.now()),
Column("is_last", UnsignedInt, default=0),
PrimaryKeyConstraint("id", "version"),
)
class NodeLinkStatus(str, Enum):
ACTIVE = auto()
STOPPING = auto()
STOPPED = auto()
DELETED = auto()
class NodeStatus(enum.StrEnum):
ACTIVE = "ACTIVE"
DISABLED = "DISABLED"
DELETED = "DELETED"
class NodeType(Enum):
TYPE1 = "Type1"
TYPE2 = "Type2"
TYPE3 = "Type3"
ps_node_table = Table(
"ps_node",
metadata,
Column("id", UnsignedInt, autoincrement=True, primary_key=True, nullable=False),
Column("ps_id", UnsignedInt, ForeignKey("process_schema.id"), nullable=False),
Column("node_type", SQLAEnum(NodeType), nullable=False),
Column("settings", JSON, default={}),
Column("creator_id", UnsignedInt, ForeignKey("account.id"), nullable=False),
Column("created_at", DateTime(timezone=True), server_default=func.now()),
Column("status", SQLAEnum(NodeStatus), nullable=False),
Index("idx_ps_id", "ps_id"),
)
class NodeLinkStatus(enum.StrEnum):
ACTIVE = "ACTIVE"
STOPPING = "STOPPING"
STOPPED = "STOPPED"
DELETED = "DELETED"
node_link_table = Table(
'node_link', metadata,
Column('id', UnsignedInt, autoincrement=True, primary_key=True, nullable=False),
Column('link_name', String(20), nullable=False),
Column('node_id', UnsignedInt, ForeignKey('ps_node.id'), nullable=False),
Column('next_node_id', UnsignedInt, ForeignKey('ps_node.id'), nullable=False),
Column('settings', JSON, default={}),
Column('creator_id', UnsignedInt, ForeignKey('account.id'), nullable=False),
Column('created_at', DateTime(timezone=True), server_default=func.now()),
Column('status', SQLAEnum(NodeLinkStatus),nullable=False),
Index('idx_node_id', 'node_id'),
Index('idx_next_node_id', 'next_node_id'))
"node_link",
metadata,
Column("id", UnsignedInt, autoincrement=True, primary_key=True, nullable=False),
Column("link_name", String(20), nullable=False),
Column("node_id", UnsignedInt, ForeignKey("ps_node.id"), nullable=False),
Column("next_node_id", UnsignedInt, ForeignKey("ps_node.id"), nullable=False),
Column("settings", JSON, default={}),
Column("creator_id", UnsignedInt, ForeignKey("account.id"), nullable=False),
Column("created_at", DateTime(timezone=True), server_default=func.now()),
Column("status", SQLAEnum(NodeLinkStatus), nullable=False),
Index("idx_node_id", "node_id"),
Index("idx_next_node_id", "next_node_id"),
)

View File

@@ -1,4 +1,9 @@
list_of_routes = []
from api.endpoints.auth import api_router as auth_router
from api.endpoints.profile import api_router as profile_router
from api.endpoints.account import api_router as account_router
from api.endpoints.keyring import api_router as keyring_router
list_of_routes = [auth_router, profile_router, account_router, keyring_router]
__all__ = [
"list_of_routes",

View File

@@ -0,0 +1,135 @@
from fastapi import (
APIRouter,
Depends,
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,
)
from api.schemas.account.account import User
from api.db.tables.account import AccountStatus
from api.schemas.base import bearer_schema
from api.schemas.endpoints.account import UserUpdate, AllUserResponse
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
api_router = APIRouter(
prefix="/account",
tags=["User accountModel"],
)
@api_router.get("", dependencies=[Depends(bearer_schema)], response_model=AllUserResponse)
async def get_all_account(
page: int = 1,
limit: int = 10,
connection: AsyncConnection = Depends(get_connection_dep),
current_user=Depends(get_current_user),
):
authorize_user = await db_user_role_validation(connection, current_user)
user_list = await get_user_accaunt_page(connection, page, limit)
if user_list is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Accounts not found")
return user_list
@api_router.get("/{user_id}", dependencies=[Depends(bearer_schema)], response_model=User)
async def get_account(
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)
user = await get_user_by_id(connection, user_id)
if user is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Account not found")
return user
@api_router.post("", dependencies=[Depends(bearer_schema)], response_model=User)
async def create_account(
user: UserUpdate, 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
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)
async def update_account(
user_id: int,
user_update: UserUpdate,
connection: AsyncConnection = Depends(get_connection_dep),
current_user=Depends(get_current_user),
):
authorize_user = await db_user_role_validation(connection, current_user)
user = await get_user_by_id(connection, user_id)
if user is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Account not found")
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})
await update_user_by_id(connection, update_values, user)
user = await get_user_by_id(connection, user_id)
return user
@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)
):
authorize_user = await db_user_role_validation(connection, current_user)
user = await get_user_by_id(connection, user_id)
if user is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Account not found")
user_update = UserUpdate(status=AccountStatus.DELETED.value)
update_values = update_user_data_changes(user_update, user)
if update_values is None:
return user
await update_user_by_id(connection, update_values, user)
user = await get_user_by_id(connection, user_id)
return user

115
api/api/endpoints/auth.py Normal file
View File

@@ -0,0 +1,115 @@
from datetime import datetime, timedelta, timezone
from fastapi import (
APIRouter,
Depends,
HTTPException,
Request,
Response,
status,
)
from loguru import logger
from fastapi_jwt_auth import AuthJWT
from pydantic import BaseModel
from sqlalchemy.ext.asyncio import AsyncConnection
from api.config import get_settings
from api.db.connection.session import get_connection_dep
from api.services.auth import authenticate_user
from api.db.logic.auth import add_new_refresh_token, upgrade_old_refresh_token
from api.schemas.endpoints.auth import Auth, Access
api_router = APIRouter(
prefix="/auth",
tags=["User auth"],
)
class Settings(BaseModel):
authjwt_secret_key: str = get_settings().SECRET_KEY
# Configure application to store and get JWT from cookies
authjwt_token_location: set = {"headers", "cookies"}
authjwt_cookie_domain: str = get_settings().DOMAIN
# Only allow JWT cookies to be sent over https
authjwt_cookie_secure: bool = get_settings().ENV == "prod"
# Enable csrf double submit protection. default is True
authjwt_cookie_csrf_protect: bool = False
authjwt_cookie_samesite: str = "lax"
@AuthJWT.load_config
def get_config():
return Settings()
@api_router.post("", response_model=Access)
async def login_for_access_token(
user: Auth,
response: Response,
connection: AsyncConnection = Depends(get_connection_dep),
Authorize: AuthJWT = Depends(),
):
"""Авторизирует, выставляет токены в куки."""
user = await authenticate_user(connection, user.login, user.password)
# print("login_for_access_token", user)
if not user:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Incorrect username or password",
# headers={"WWW-Authenticate": "Bearer"},
)
access_token_expires = timedelta(minutes=get_settings().ACCESS_TOKEN_EXPIRE_MINUTES)
refresh_token_expires = timedelta(days=get_settings().REFRESH_TOKEN_EXPIRE_DAYS)
logger.debug(f"refresh_token_expires {refresh_token_expires}")
access_token = Authorize.create_access_token(subject=user.login, expires_time=access_token_expires)
refresh_token = Authorize.create_refresh_token(subject=user.login, expires_time=refresh_token_expires)
refresh_token_expires_time = datetime.now(timezone.utc) + refresh_token_expires
await add_new_refresh_token(connection, refresh_token, refresh_token_expires_time, user)
Authorize.set_refresh_cookies(refresh_token)
return Access(access_token=access_token)
@api_router.post("/refresh", response_model=Access)
async def refresh(
request: Request, connection: AsyncConnection = Depends(get_connection_dep), Authorize: AuthJWT = Depends()
):
refresh_token = request.cookies.get("refresh_token_cookie")
# print("Refresh Token:", refresh_token)
if not refresh_token:
raise HTTPException(status_code=401, detail="Refresh token is missing")
try:
Authorize.jwt_refresh_token_required()
current_user = Authorize.get_jwt_subject()
except Exception as e:
await upgrade_old_refresh_token(connection, current_user, refresh_token)
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Invalid refresh token",
)
access_token_expires = timedelta(minutes=get_settings().ACCESS_TOKEN_EXPIRE_MINUTES)
new_access_token = Authorize.create_access_token(subject=current_user, expires_time=access_token_expires)
return Access(access_token=new_access_token)

View File

@@ -0,0 +1,128 @@
from fastapi import (
APIRouter,
Body,
Depends,
Form,
HTTPException,
Response,
status,
)
from sqlalchemy.ext.asyncio import AsyncConnection
from api.db.connection.session import get_connection_dep
from api.db.logic.keyring import get_key_by_id, create_key, update_key_by_id
from api.db.tables.account import KeyStatus
from api.schemas.base import bearer_schema
from api.schemas.endpoints.account_keyring import AccountKeyringUpdate
from api.schemas.account.account_keyring import AccountKeyring
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_key_data_changes
api_router = APIRouter(
prefix="/keyring",
tags=["User KeyringModel"],
)
@api_router.get("/{user_id}/{key_id}", dependencies=[Depends(bearer_schema)], response_model=AccountKeyring)
async def get_keyring(
key_id: str, connection: AsyncConnection = Depends(get_connection_dep), current_user=Depends(get_current_user)
):
authorize_user = await db_user_role_validation(connection, current_user)
keyring = await get_key_by_id(connection, key_id)
if keyring is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Key not found")
return keyring
@api_router.post("/{user_id}/{key_id}", dependencies=[Depends(bearer_schema)], response_model=AccountKeyring)
async def create_keyring(
user_id: int,
key_id: str,
key: AccountKeyringUpdate,
connection: AsyncConnection = Depends(get_connection_dep),
current_user=Depends(get_current_user),
):
authorize_user = await db_user_role_validation(connection, current_user)
keyring = await get_key_by_id(connection, key_id)
if keyring is None:
keyring_new = await create_key(
connection,
key,
key_id,
)
return keyring_new
else:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST, detail="An keyring with this information already exists."
)
@api_router.put("/{user_id}/{key_id}", dependencies=[Depends(bearer_schema)], response_model=AccountKeyring)
async def update_keyring(
user_id: int,
key_id: str,
keyring_update: AccountKeyringUpdate,
connection: AsyncConnection = Depends(get_connection_dep),
current_user=Depends(get_current_user),
):
authorize_user = await db_user_role_validation(connection, current_user)
keyring = await get_key_by_id(connection, key_id)
if keyring is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="keyring not found")
update_values = update_key_data_changes(keyring_update, keyring)
if update_values is None:
return keyring
keyring_update_data = AccountKeyring.model_validate({**keyring.model_dump(), **update_values})
await update_key_by_id(connection, update_values, keyring)
keyring = await get_key_by_id(connection, key_id)
return keyring
@api_router.delete("/{user_id}/{key_id}", dependencies=[Depends(bearer_schema)], response_model=AccountKeyring)
async def delete_keyring(
user_id: int,
key_id: str,
connection: AsyncConnection = Depends(get_connection_dep),
current_user=Depends(get_current_user),
):
authorize_user = await db_user_role_validation(connection, current_user)
keyring = await get_key_by_id(connection, key_id)
if keyring is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="keyring not found")
keyring_update = AccountKeyringUpdate(status=KeyStatus.DELETED.value)
update_values = update_key_data_changes(keyring_update, keyring)
if update_values is None:
return keyring
await update_key_by_id(connection, update_values, keyring)
keyring = await get_key_by_id(connection, key_id)
return keyring

View File

@@ -0,0 +1,65 @@
from fastapi import (
APIRouter,
Body,
Depends,
Form,
HTTPException,
Request,
Response,
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, get_user_by_login
from api.schemas.base import bearer_schema
from api.services.auth import get_current_user
from api.services.update_data_validation import update_user_data_changes
from api.schemas.endpoints.account import UserUpdate
from api.schemas.account.account import User
api_router = APIRouter(
prefix="/profile",
tags=["User accountModel"],
)
@api_router.get("", dependencies=[Depends(bearer_schema)], response_model=User)
async def get_profile(
connection: AsyncConnection = Depends(get_connection_dep), current_user=Depends(get_current_user)
):
user = await get_user_by_login(connection, current_user)
if user is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Account not found")
return user
@api_router.put("", dependencies=[Depends(bearer_schema)], response_model=User)
async def update_profile(
user_updata: UserUpdate,
connection: AsyncConnection = Depends(get_connection_dep),
current_user=Depends(get_current_user),
):
user = await get_user_by_login(connection, current_user)
if user is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Account not found")
if user_updata.role == None and user_updata.login == None:
update_values = update_user_data_changes(user_updata, user)
if update_values is None:
return user
await update_user_by_id(connection, update_values, user)
user = await get_user_by_id(connection, user.id)
return user
else:
raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail="Bad body")

View File

@@ -1,30 +1,20 @@
import datetime
from enum import Enum
from datetime import datetime
from typing import Optional
from pydantic import EmailStr, Field
from api.db.tables.account import AccountRole, AccountStatus
from pydantic import BaseModel, EmailStr, Field
from api.schemas.base import Base
class Role(Enum):
OWNER = "Owner"
ADMIN = "Admin"
EDITOR = "Editor"
VIEWER = "Viewer"
class Status(Enum):
ACTIVE = "Active"
DISABLED = "Disabled"
BLOCKED = "Blocked"
DELETED = "Deleted"
class User(BaseModel):
id: int
class User(Base):
id: Optional[int] = None
name: str = Field(..., max_length=100)
login: str = Field(..., max_length=100)
email: EmailStr = Field(..., max_length=100)
bind_tenant_id: str = Field(..., max_length=40)
role: Role
email: Optional[EmailStr] = Field(None, max_length=100) # Электронная почта (может быть None)
bind_tenant_id: Optional[str] = Field(None, max_length=40)
role: AccountRole
meta: dict
creator_id: int
is_active: bool
creator_id: Optional[int] = None
created_at: datetime
status: Status
status: AccountStatus

View File

@@ -1,27 +1,17 @@
import datetime
from enum import Enum
from pydantic import BaseModel, Field
from typing import Optional
from pydantic import Field
from datetime import datetime
from api.db.tables.account import KeyType, KeyStatus
from api.schemas.base import Base
class Type(Enum):
PASSWORD = "password"
ACCESS_TOKEN = "access_token"
REFRESH_TOKEN = "refresh_token"
API_KEY = "api_key"
class Status(Enum):
ACTIVE = "Active"
EXPIRED = "Expired"
DELETED = "Deleted"
class AccountKeyring(BaseModel):
class AccountKeyring(Base):
owner_id: int
key_type: Type
key_id: str = Field(..., max_length=40)
key_value: str = Field(..., max_length=64)
key_type: KeyType
key_id: Optional[str] = Field(None, max_length=40)
key_value: str = Field(..., max_length=255)
created_at: datetime
expiry: datetime
status: Status
expiry: Optional[datetime] = None
status: KeyStatus

14
api/api/schemas/base.py Normal file
View File

@@ -0,0 +1,14 @@
from fastapi.security import HTTPBearer
from pydantic import BaseModel, ConfigDict
from pydantic.alias_generators import to_camel
bearer_schema = HTTPBearer() # схема для авторизации в swagger
class Base(BaseModel):
model_config = ConfigDict(
from_attributes=True,
alias_generator=to_camel,
populate_by_name=True,
)

View File

View File

@@ -0,0 +1,40 @@
from typing import Optional, List
from datetime import datetime
from pydantic import EmailStr, Field, TypeAdapter
from api.db.tables.account import AccountRole, AccountStatus
from api.schemas.base import Base
class UserUpdate(Base):
id: Optional[int] = None
name: Optional[str] = Field(None, max_length=100)
login: Optional[str] = Field(None, max_length=100)
email: Optional[EmailStr] = None
bind_tenant_id: Optional[str] = Field(None, max_length=40)
role: Optional[AccountRole] = None
meta: Optional[dict] = None
creator_id: Optional[int] = None
created_at: Optional[datetime] = None
status: Optional[AccountStatus] = None
class AllUser(Base):
id: int
name: str
login: str
email: Optional[EmailStr] = None
bind_tenant_id: Optional[str] = None
role: AccountRole
created_at: datetime
status: AccountStatus
class AllUserResponse(Base):
users: List[AllUser]
amount_count: int
amount_pages: int
all_user_adapter = TypeAdapter(List[AllUser])

View File

@@ -0,0 +1,17 @@
import datetime
from typing import Optional
from pydantic import Field
from datetime import datetime
from api.db.tables.account import KeyType, KeyStatus
from api.schemas.base import Base
class AccountKeyringUpdate(Base):
owner_id: Optional[int] = None
key_type: Optional[KeyType] = None
key_id: Optional[str] = Field(None, max_length=40)
key_value: Optional[str] = Field(None, max_length=255)
created_at: Optional[datetime] = None
expiry: Optional[datetime] = None
status: Optional[KeyStatus] = None

View File

@@ -0,0 +1,16 @@
from api.schemas.base import Base
# Таблица для получения информации из запроса
class Auth(Base):
login: str
password: str
class Refresh(Base):
refresh_token: str
class Access(Base):
access_token: str

View File

@@ -1,8 +1,10 @@
from pydantic import BaseModel, Field
from pydantic import Field
from typing import Dict, Any
from datetime import datetime
from enum import Enum
from api.schemas.base import Base
class State(Enum):
AUTO = "Auto"
@@ -14,7 +16,8 @@ class Status(Enum):
DISABLED = "Disabled"
DELETED = "Deleted"
class ListEvent(BaseModel):
class ListEvent(Base):
id: int
name: str = Field(..., max_length=40)
title: str = Field(..., max_length=64)

View File

@@ -1,15 +1,19 @@
from pydantic import BaseModel, Field, conint
from pydantic import Field
from typing import Dict, Any
from datetime import datetime
from enum import Enum
from api.schemas.base import Base
class Status(Enum):
ACTIVE = "Active"
STOPPING = "Stopping"
STOPPED = "Stopped"
DELETED = "Deleted"
class MyModel(BaseModel):
class MyModel(Base):
id: int
link_name: str = Field(..., max_length=20)
node_id: int

View File

@@ -1,15 +1,19 @@
from pydantic import BaseModel, Field
from pydantic import Field
from typing import Dict, Any
from datetime import datetime
from enum import Enum
from api.schemas.base import Base
class Status(Enum):
ACTIVE = "Active"
STOPPING = "Stopping"
STOPPED = "Stopped"
DELETED = "Deleted"
class ProcessSchema(BaseModel):
class ProcessSchema(Base):
id: int
title: str = Field(..., max_length=100)
description: str

View File

@@ -1,8 +1,10 @@
from pydantic import BaseModel, Field
from typing import Dict, Any
from datetime import datetime
class ProcessStatusSchema(BaseModel):
from api.schemas.base import Base
class ProcessStatusSchema(Base):
id: int
version: int
snapshot: Dict[str, Any]

View File

@@ -1,19 +1,21 @@
from pydantic import BaseModel
from datetime import datetime
from typing import Dict, Any
from enum import Enum
from api.schemas.base import Base
class NodeType(Enum):
pass
class Status(Enum):
ACTIVE = "Active"
DISABLED = "Disabled"
DELETED = "Deleted"
class Ps_Node(BaseModel):
class Ps_Node(Base):
id: int
ps_id: int
node_type: NodeType

27
api/api/services/auth.py Normal file
View File

@@ -0,0 +1,27 @@
from fastapi import Request, HTTPException
from typing import Optional
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
async def get_current_user(request: Request) -> Optional[User]:
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]:
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

@@ -0,0 +1,61 @@
from starlette.middleware.base import BaseHTTPMiddleware
from fastapi import (
Request,
status,
)
from fastapi.responses import JSONResponse
from api.config import get_settings
import re
from re import escape
from fastapi_jwt_auth import AuthJWT
class MiddlewareAccessTokenValidadtion(BaseHTTPMiddleware):
def __init__(self, app):
super().__init__(app)
self.prefix = escape(get_settings().PATH_PREFIX)
self.excluded_routes = [
re.compile(r"^" + re.escape(self.prefix) + r"/auth/refresh/?$"),
re.compile(r"^" + re.escape(self.prefix) + r"/auth/?$"),
]
async def dispatch(self, request: Request, call_next):
if request.method in ["GET", "POST", "PUT", "DELETE"]:
if any(pattern.match(request.url.path) for pattern in self.excluded_routes):
return await call_next(request)
else:
auth_header = request.headers.get("Authorization")
if not auth_header:
return JSONResponse(
status_code=status.HTTP_401_UNAUTHORIZED,
content={"detail": "Missing authorization header."},
headers={"WWW-Authenticate": "Bearer"},
)
token = auth_header.split(" ")[1]
Authorize = AuthJWT(request)
try:
current_user = Authorize.get_jwt_subject()
request.state.current_user = current_user
return await call_next(request)
except Exception:
return JSONResponse(
status_code=status.HTTP_401_UNAUTHORIZED,
content={"detail": "The access token is invalid or expired."},
headers={"WWW-Authenticate": "Bearer"},
)
# async with get_connection() as connection:
# authorize_user = await get_user_login(connection, current_user)
# print(authorize_user)
# if authorize_user is None :
# return JSONResponse(
# status_code=status.HTTP_404_NOT_FOUND ,
# detail="User not found.")

View File

@@ -0,0 +1,74 @@
from enum import Enum
from typing import Optional
from api.schemas.endpoints.account import UserUpdate
from api.db.tables.account import KeyType, KeyStatus
from api.schemas.endpoints.account_keyring import AccountKeyringUpdate
from api.db.tables.account import AccountRole, AccountStatus
def update_user_data_changes(update_data: UserUpdate, user) -> Optional[dict]:
"""
Сравнивает данные для обновления с текущими значениями пользователя.
Возвращает:
- None, если нет изменений
- Словарь {поле: новое_значение} для измененных полей
"""
update_values = {}
changes = {}
for field, value in update_data.model_dump(exclude_unset=True).items():
if value is None:
continue
if isinstance(value, (AccountRole, AccountStatus)):
update_values[field] = value.value
else:
update_values[field] = value
for field, new_value in update_values.items():
if not hasattr(user, field):
continue
current_value = getattr(user, field)
if isinstance(current_value, Enum):
current_value = current_value.value
if current_value != new_value:
changes[field] = new_value
return changes if changes else None
def update_key_data_changes(update_data: AccountKeyringUpdate, key) -> Optional[dict]:
"""
Сравнивает данные для обновления с текущими значениями пользователя.
Возвращает:
- None, если нет изменений
- Словарь {поле: новое_значение} для измененных полей
"""
update_values = {}
changes = {}
for field, value in update_data.model_dump(exclude_unset=True).items():
if value is None:
continue
if isinstance(value, (KeyType, KeyStatus)):
update_values[field] = value.value
else:
update_values[field] = value
for field, new_value in update_values.items():
if not hasattr(key, field):
continue
current_value = getattr(key, field)
if isinstance(current_value, Enum):
current_value = current_value.value
if current_value != new_value:
changes[field] = new_value
return changes if changes else None

View File

@@ -0,0 +1,13 @@
from fastapi import (
HTTPException,
status,
)
from api.db.logic.account import get_user_by_login
from api.db.tables.account import AccountRole
async def db_user_role_validation(connection, current_user):
authorize_user = await get_user_by_login(connection, current_user)
if authorize_user.role not in {AccountRole.OWNER, AccountRole.ADMIN}:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="You do not have enough permissions")
return authorize_user

16
api/api/utils/hasher.py Normal file
View File

@@ -0,0 +1,16 @@
import hashlib
# Хешер для работы с паролем.
class Hasher:
def __init__(self):
pass
def hash_data(self, password: str) -> str:
# Хеширует пароль с использованием SHA-256.
return hashlib.sha256(password.encode()).hexdigest()
def verify_data(self, password: str, hashed: str) -> bool:
# Проверяет пароль путем сравнения его хеша с сохраненным хешем.
return self.hash_data(password) == hashed

View File

@@ -5,6 +5,7 @@ import secrets
from api.db.connection.session import get_connection
from api.db.tables.account import account_table, account_keyring_table, AccountRole, KeyType, KeyStatus
from api.utils.key_id_gen import KeyIdGenerator
INIT_LOCK_FILE = "../init.lock"
DEFAULT_LOGIN = "vorkout"
@@ -39,6 +40,7 @@ async def init():
create_key_query = account_keyring_table.insert().values(
owner_id=user_id,
key_type=KeyType.PASSWORD,
key_id=KeyIdGenerator(),
key_value=hashed_password,
status=KeyStatus.ACTIVE,
)

View File

@@ -0,0 +1,10 @@
import random
from datetime import datetime
# Генератор key_id для таблицы account_keyring
def KeyIdGenerator():
random_number = random.randint(1000, 9999)
result = f"{datetime.now().strftime('%Y-%m-%d')}-{random_number}"
return result

917
api/poetry.lock generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,6 +1,6 @@
[project]
name = "api"
version = "0.0.2"
version = "0.0.3"
description = ""
authors = [
{name = "Vladislav",email = "vlad.dev@heado.ru"}
@@ -11,14 +11,24 @@ dependencies = [
"sqlalchemy[pymysql,aiomysql] (>=2.0.39,<3.0.0)",
"alembic (>=1.15.1,<2.0.0)",
"aio-pika (>=9.5.5,<10.0.0)",
"fastapi[standart] (>=0.115.11,<0.116.0)",
"fastapi[standard] (>=0.115.11,<0.116.0)",
"uvicorn (>=0.34.0,<0.35.0)",
"loguru (>=0.7.3,<0.8.0)",
"pydantic-settings (>=2.8.1,<3.0.0)",
"cryptography (>=44.0.2,<45.0.0)",
"pydantic[email] (>=2.11.3,<3.0.0)",
"python-multipart (>=0.0.20,<0.0.21)",
"fastapi-jwt-auth @ git+https://github.com/vvpreo/fastapi-jwt-auth",
]
[build-system]
requires = ["poetry-core>=2.0.0,<3.0.0"]
build-backend = "poetry.core.masonry.api"
[tool.poetry.group.dev.dependencies]
ruff = "^0.11.10"
[tool.ruff]
line-length = 120
extend-exclude = ["alembic"]

View File

@@ -1,6 +1,6 @@
{
"name": "client",
"version": "0.0.1",
"version": "0.0.2",
"private": true,
"dependencies": {
"@ant-design/icons": "^5.6.1",