update project

This commit is contained in:
roai_linux 2026-03-06 20:01:04 +03:30
parent 1f6250eea0
commit f215f458bc
19 changed files with 290 additions and 122 deletions

View File

@ -86,7 +86,6 @@ path_separator = os
# database URL. This is consumed by the user-maintained env.py script only. # database URL. This is consumed by the user-maintained env.py script only.
# other means of configuring database URLs may be customized within the env.py # other means of configuring database URLs may be customized within the env.py
# file. # file.
sqlalchemy.url = driver://user:pass@localhost/dbname
[post_write_hooks] [post_write_hooks]

View File

@ -1,7 +1,17 @@
import asyncio
from logging.config import fileConfig from logging.config import fileConfig
from sqlalchemy import engine_from_config
from sqlalchemy import pool from sqlalchemy import pool
from sqlalchemy.ext.asyncio import async_engine_from_config, AsyncConnection
from alembic import context
from core.config import settings
from core.config import settings
from db.base import Base
import domains.users.models
import domains.groups.models
from alembic import context from alembic import context
@ -14,11 +24,7 @@ config = context.config
if config.config_file_name is not None: if config.config_file_name is not None:
fileConfig(config.config_file_name) fileConfig(config.config_file_name)
# add your model's MetaData object here target_metadata = Base.metadata
# for 'autogenerate' support
# from myapp import mymodel
# target_metadata = mymodel.Base.metadata
target_metadata = None
# other values from the config, defined by the needs of env.py, # other values from the config, defined by the needs of env.py,
# can be acquired: # can be acquired:
@ -38,7 +44,7 @@ def run_migrations_offline() -> None:
script output. script output.
""" """
url = config.get_main_option("sqlalchemy.url") url = settings.DATABASE_URL
context.configure( context.configure(
url=url, url=url,
target_metadata=target_metadata, target_metadata=target_metadata,
@ -50,26 +56,39 @@ def run_migrations_offline() -> None:
context.run_migrations() context.run_migrations()
def run_migrations_online() -> None: from sqlalchemy import Connection
"""Run migrations in 'online' mode.
In this scenario we need to create an Engine def do_run_migrations(connection: Connection) -> None:
context.configure(connection=connection, target_metadata=target_metadata)
with context.begin_transaction():
context.run_migrations()
async def run_async_migrations() -> None:
"""In this scenario we need to create an Engine
and associate a connection with the context. and associate a connection with the context.
""" """
connectable = engine_from_config(
config.get_section(config.config_ini_section, {}), configuration = config.get_section(config.config_ini_section, {})
configuration["sqlalchemy.url"] = settings.DATABASE_URL
connectable = async_engine_from_config(
configuration,
prefix="sqlalchemy.", prefix="sqlalchemy.",
poolclass=pool.NullPool, poolclass=pool.NullPool,
) )
with connectable.connect() as connection: async with connectable.connect() as connection:
context.configure( await connection.run_sync(do_run_migrations)
connection=connection, target_metadata=target_metadata
)
with context.begin_transaction(): await connectable.dispose()
context.run_migrations()
def run_migrations_online() -> None:
"""Run migrations in 'online' mode."""
asyncio.run(run_async_migrations())
if context.is_offline_mode(): if context.is_offline_mode():

View File

@ -0,0 +1,77 @@
"""create db
Revision ID: 4413cbcf58c3
Revises:
Create Date: 2026-03-06 12:26:21.920942
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = '4413cbcf58c3'
down_revision: Union[str, Sequence[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('groups',
sa.Column('name', sa.String(length=100), nullable=False),
sa.Column('type', sa.Enum('GROUP', 'DIRECT', name='group_type'), nullable=False),
sa.Column('is_active', sa.Boolean(), nullable=False),
sa.Column('id', sa.UUID(), nullable=False),
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
sa.Column('updated_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
sa.PrimaryKeyConstraint('id')
)
op.create_index(op.f('ix_groups_is_active'), 'groups', ['is_active'], unique=False)
op.create_index(op.f('ix_groups_name'), 'groups', ['name'], unique=False)
op.create_table('users',
sa.Column('username', sa.String(length=50), nullable=False),
sa.Column('secret_hash', sa.String(length=255), nullable=False),
sa.Column('role', sa.Enum('ADMIN', 'GROUP_MANAGER', 'MEMBER', name='user_role'), nullable=False),
sa.Column('is_active', sa.Boolean(), nullable=False),
sa.Column('id', sa.UUID(), nullable=False),
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
sa.Column('updated_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
sa.PrimaryKeyConstraint('id')
)
op.create_index(op.f('ix_users_is_active'), 'users', ['is_active'], unique=False)
op.create_index(op.f('ix_users_role'), 'users', ['role'], unique=False)
op.create_index(op.f('ix_users_username'), 'users', ['username'], unique=True)
op.create_table('group_members',
sa.Column('user_id', sa.UUID(), nullable=False),
sa.Column('group_id', sa.UUID(), nullable=False),
sa.Column('role', sa.Enum('MANAGER', 'MEMBER', name='group_role'), nullable=False),
sa.Column('id', sa.UUID(), nullable=False),
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
sa.Column('updated_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
sa.ForeignKeyConstraint(['group_id'], ['groups.id'], ondelete='CASCADE'),
sa.ForeignKeyConstraint(['user_id'], ['users.id'], ondelete='CASCADE'),
sa.PrimaryKeyConstraint('id')
)
op.create_index(op.f('ix_group_members_group_id'), 'group_members', ['group_id'], unique=False)
op.create_index(op.f('ix_group_members_user_id'), 'group_members', ['user_id'], unique=False)
# ### end Alembic commands ###
def downgrade() -> None:
"""Downgrade schema."""
# ### commands auto generated by Alembic - please adjust! ###
op.drop_index(op.f('ix_group_members_user_id'), table_name='group_members')
op.drop_index(op.f('ix_group_members_group_id'), table_name='group_members')
op.drop_table('group_members')
op.drop_index(op.f('ix_users_username'), table_name='users')
op.drop_index(op.f('ix_users_role'), table_name='users')
op.drop_index(op.f('ix_users_is_active'), table_name='users')
op.drop_table('users')
op.drop_index(op.f('ix_groups_name'), table_name='groups')
op.drop_index(op.f('ix_groups_is_active'), table_name='groups')
op.drop_table('groups')
# ### end Alembic commands ###

View File

@ -0,0 +1,32 @@
"""change groups models
Revision ID: b1f09d977759
Revises: 4413cbcf58c3
Create Date: 2026-03-06 16:21:31.915163
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
from sqlalchemy.dialects import postgresql
# revision identifiers, used by Alembic.
revision: str = 'b1f09d977759'
down_revision: Union[str, Sequence[str], None] = '4413cbcf58c3'
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.drop_column('group_members', 'role')
# ### end Alembic commands ###
def downgrade() -> None:
"""Downgrade schema."""
# ### commands auto generated by Alembic - please adjust! ###
op.add_column('group_members', sa.Column('role', postgresql.ENUM('MANAGER', 'MEMBER', name='group_role'), autoincrement=False, nullable=False))
# ### end Alembic commands ###

View File

@ -9,7 +9,7 @@ class Settings(BaseSettings):
SECRET_KEY: str SECRET_KEY: str
ACCESS_TOKEN_EXPIRE_MINUTES: int = 30 ACCESS_TOKEN_EXPIRE_MINUTES: int = 30
ALGORITHM: str = "HS256" ALGORITHM: str = "HS256"
SECRET_PASS_LENGTH: int = 16 SECRET_PASS_LENGTH: int
DATABASE_URL: str DATABASE_URL: str
REDIS_URL: str REDIS_URL: str
@ -21,6 +21,7 @@ class Settings(BaseSettings):
class Config: class Config:
env_file = ".env" env_file = ".env"
case_sensitive = True case_sensitive = True
extra = "ignore"
@lru_cache @lru_cache

View File

@ -1,18 +1,25 @@
from fastapi import Depends, HTTPException, status from fastapi import Depends, HTTPException, status
from fastapi.security import OAuth2PasswordBearer from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from db.session import get_db from db.session import get_db
from core.jwt import decode_token from core.jwt import decode_token
from domains.users.repo import get_user_by_id from domains.users.repo import get_user_by_id
oauth2_scheme = OAuth2PasswordBearer(tokenUrl="/auth/login")
# Bearer authentication scheme
security = HTTPBearer()
async def get_current_user( async def get_current_user(
token: str = Depends(oauth2_scheme), credentials: HTTPAuthorizationCredentials = Depends(security),
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db),
): ):
"""
Validate JWT token and return the authenticated user
"""
token = credentials.credentials
payload = decode_token(token) payload = decode_token(token)
@ -24,6 +31,12 @@ async def get_current_user(
user_id = payload.get("sub") user_id = payload.get("sub")
if not user_id:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Invalid token payload",
)
user = await get_user_by_id(db, user_id) user = await get_user_by_id(db, user_id)
if not user: if not user:
@ -32,10 +45,21 @@ async def get_current_user(
detail="User not found", detail="User not found",
) )
if not user.is_active:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="User is inactive",
)
return user return user
async def get_current_admin(user=Depends(get_current_user)): async def get_current_admin(
user=Depends(get_current_user)
):
"""
Ensure the authenticated user is an admin
"""
if user.role != "admin": if user.role != "admin":
raise HTTPException( raise HTTPException(

View File

@ -5,6 +5,8 @@ services:
container_name: neda_api container_name: neda_api
ports: ports:
- "8000:8000" - "8000:8000"
volumes:
- "./:/app"
env_file: env_file:
- .env - .env
depends_on: depends_on:
@ -28,7 +30,7 @@ services:
restart: always restart: always
healthcheck: healthcheck:
test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER} -d ${POSTGRES_DB}"] test: [ "CMD-SHELL", "pg_isready -U ${POSTGRES_USER} -d ${POSTGRES_DB}" ]
interval: 5s interval: 5s
timeout: 5s timeout: 5s
retries: 3 retries: 3
@ -41,7 +43,7 @@ services:
restart: always restart: always
healthcheck: healthcheck:
test: ["CMD", "redis-cli", "ping"] test: [ "CMD", "redis-cli", "ping" ]
interval: 5s interval: 5s
timeout: 3s timeout: 3s
retries: 5 retries: 5
@ -60,4 +62,4 @@ services:
volumes: volumes:
postgres_data: postgres_data:
redis_data: redis_data:

View File

@ -7,13 +7,18 @@ from core.deps import get_current_admin
from domains.admin.schemas import ( from domains.admin.schemas import (
AdminCreateUser, AdminCreateUser,
AdminCreateUserResult, AdminCreateUserResult,
AdminResetSecretResult AdminResetSecretResult,
AdminUserResponse
) )
from domains.groups.schemas import GroupResponse
from domains.groups.repo import get_all_groups
from domains.admin.service import ( from domains.admin.service import (
admin_create_user, admin_create_user,
admin_reset_user_secret admin_reset_user_secret
) )
from domains.users.repo import get_all_users
router = APIRouter( router = APIRouter(
@ -68,4 +73,19 @@ async def reset_secret(
detail="User not found" detail="User not found"
) )
return {"secret": new_secret} return {"secret": new_secret}
@router.get("/users", response_model=list[AdminUserResponse])
async def list_users(
db: AsyncSession = Depends(get_db),
admin=Depends(get_current_admin)
):
return await get_all_users(db)
@router.get("/groups", response_model=list[GroupResponse])
async def list_groups(
db: AsyncSession = Depends(get_db),
admin=Depends(get_current_admin)
):
return await get_all_groups(db)

View File

@ -14,7 +14,8 @@ from core.config import settings
def generate_user_secret(): def generate_user_secret():
return secrets.token_urlsafe(settings.SECRET_PASS_LENGTH) # return secrets.token_urlsafe(settings.SECRET_PASS_LENGTH)
return "1234"
async def admin_create_user( async def admin_create_user(
db: AsyncSession, db: AsyncSession,

View File

@ -2,7 +2,7 @@ from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from db.session import get_db from db.session import get_db
from core.deps import get_current_admin, get_current_user from core.deps import get_current_admin
from domains.groups.schemas import ( from domains.groups.schemas import (
GroupCreate, GroupCreate,
@ -13,7 +13,6 @@ from domains.groups.schemas import (
from domains.groups.service import ( from domains.groups.service import (
create_new_group, create_new_group,
add_member_to_group, add_member_to_group,
list_user_groups
) )
@ -30,13 +29,12 @@ router = APIRouter(
async def create_group( async def create_group(
payload: GroupCreate, payload: GroupCreate,
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db),
admin = Depends(get_current_admin) admin=Depends(get_current_admin)
): ):
group = await create_new_group( group = await create_new_group(
db, db,
payload.name, payload.name
payload.description
) )
return group return group
@ -47,26 +45,19 @@ async def add_member(
group_id: str, group_id: str,
payload: AddMemberRequest, payload: AddMemberRequest,
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db),
admin = Depends(get_current_admin) admin=Depends(get_current_admin)
): ):
membership = await add_member_to_group( try:
db, membership = await add_member_to_group(
group_id, db,
payload.user_id, group_id,
payload.role payload.user_id,
) )
except ValueError as e:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=str(e)
)
return membership return membership
@router.get("/me", response_model=list[GroupResponse])
async def my_groups(
db: AsyncSession = Depends(get_db),
user = Depends(get_current_user)
):
return await list_user_groups(
db,
user.id
)

View File

@ -10,9 +10,6 @@ class GroupType(str, Enum):
GROUP = "group" GROUP = "group"
DIRECT = "direct" DIRECT = "direct"
class GroupRole(str, Enum):
MANAGER = "manager"
MEMBER = "member"
class Group(Base): class Group(Base):
@ -48,10 +45,4 @@ class GroupMember(Base):
group_id: Mapped[uuid.UUID] = mapped_column( group_id: Mapped[uuid.UUID] = mapped_column(
ForeignKey("groups.id", ondelete="CASCADE"), ForeignKey("groups.id", ondelete="CASCADE"),
index=True index=True
)
role: Mapped[GroupRole] = mapped_column(
SQLEnum(GroupRole, name="group_role"),
default=GroupRole.MEMBER,
nullable=False
) )

View File

@ -28,4 +28,9 @@ async def get_user_groups(db: AsyncSession, user_id):
.join(GroupMember) .join(GroupMember)
.where(GroupMember.user_id == user_id) .where(GroupMember.user_id == user_id)
) )
return result.scalars().all()
async def get_all_groups(db: AsyncSession):
result = await db.execute(select(Group))
return result.scalars().all() return result.scalars().all()

View File

@ -1,17 +1,14 @@
import uuid import uuid
from pydantic import BaseModel from pydantic import BaseModel
from domains.groups.models import GroupRole
class GroupCreate(BaseModel): class GroupCreate(BaseModel):
name: str name: str
description: str | None = None
class GroupResponse(BaseModel): class GroupResponse(BaseModel):
id: uuid.UUID id: uuid.UUID
name: str name: str
description: str | None
is_active: bool is_active: bool
class Config: class Config:
@ -19,12 +16,10 @@ class GroupResponse(BaseModel):
class AddMemberRequest(BaseModel): class AddMemberRequest(BaseModel):
user_id: uuid.UUID user_id: uuid.UUID
role: GroupRole = GroupRole.MEMBER
class GroupMemberResponse(BaseModel): class GroupMemberResponse(BaseModel):
user_id: uuid.UUID user_id: uuid.UUID
group_id: uuid.UUID group_id: uuid.UUID
role: GroupRole
class Config: class Config:
from_attributes = True from_attributes = True

View File

@ -1,5 +1,6 @@
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from domains.users.repo import get_user_by_id
from domains.groups.models import Group, GroupMember from domains.groups.models import Group, GroupMember
from domains.groups.repo import ( from domains.groups.repo import (
create_group, create_group,
@ -12,12 +13,10 @@ from domains.groups.repo import (
async def create_new_group( async def create_new_group(
db: AsyncSession, db: AsyncSession,
name: str, name: str,
description: str | None
): ):
group = Group( group = Group(
name=name, name=name,
description=description
) )
return await create_group(db, group) return await create_group(db, group)
@ -26,13 +25,22 @@ async def add_member_to_group(
db: AsyncSession, db: AsyncSession,
group_id, group_id,
user_id, user_id,
role
): ):
# 1. Check if group exists
group = await get_group_by_id(db, group_id)
if not group:
raise ValueError("Group not found")
# 2. Check if user exists
user = await get_user_by_id(db, user_id)
if not user:
raise ValueError("User not found")
# TODO: Check if already a member to avoid duplicate constraint if any (optional)
membership = GroupMember( membership = GroupMember(
group_id=group_id, group_id=group_id,
user_id=user_id, user_id=user_id,
role=role
) )
return await add_group_member(db, membership) return await add_group_member(db, membership)

View File

@ -1,19 +1,12 @@
from fastapi import APIRouter, Depends, HTTPException, status from fastapi import APIRouter, Depends
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from db.session import get_db from db.session import get_db
from core.deps import get_current_admin from core.deps import get_current_admin, get_current_user
from domains.users.schemas import ( from domains.groups.schemas import GroupResponse
UserCreate, from domains.groups.service import list_user_groups
UserCreateResult
)
from domains.users.service import (
create_user_by_admin,
reset_user_secret
)
from domains.users.repo import get_user_by_id
router = APIRouter( router = APIRouter(
prefix="/users", prefix="/users",
@ -21,38 +14,9 @@ router = APIRouter(
) )
@router.post("/", response_model=UserCreateResult) @router.get("/me/groups", response_model=list[GroupResponse])
async def create_user( async def my_groups(
payload: UserCreate,
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db),
admin = Depends(get_current_admin) user=Depends(get_current_user)
): ):
return await list_user_groups(db, user.id)
user, secret = await create_user_by_admin(
db,
payload.username,
payload.role
)
return {
"user": user,
"secret": secret
}
@router.post("/{user_id}/reset-secret")
async def reset_secret(
user_id: str,
db: AsyncSession = Depends(get_db),
admin = Depends(get_current_admin)
):
user = await get_user_by_id(db, user_id)
if not user:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="User not found"
)
new_secret = await reset_user_secret(db, user)
return {"secret": new_secret}

View File

@ -1,6 +1,5 @@
from sqlalchemy import select from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from domains.users.models import User from domains.users.models import User
@ -10,16 +9,18 @@ async def get_user_by_id(db: AsyncSession, user_id):
) )
return result.scalar_one_or_none() return result.scalar_one_or_none()
async def get_user_by_username(db: AsyncSession, username): async def get_user_by_username(db: AsyncSession, username):
result = await db.execute( result = await db.execute(
select(User).where(User.username == username) select(User).where(User.username == username)
) )
return result.scalar_one_or_none() return result.scalar_one_or_none()
async def create_user(db: AsyncSession, user: User): async def create_user(db: AsyncSession, user: User):
db.add(user) db.add(user)
await db.commit() await db.commit()
await db.refresh(user) await db.refresh(user)
return user return user
async def get_all_users(db: AsyncSession):
result = await db.execute(select(User))
return result.scalars().all()

View File

@ -7,7 +7,8 @@ from core.config import settings
def generate_user_secret(): def generate_user_secret():
return secrets.token_urlsafe(settings.SECRET_PASS_LENGTH) # return secrets.token_urlsafe(settings.SECRET_PASS_LENGTH)
return "1234"
async def create_user_by_admin( async def create_user_by_admin(
db: AsyncSession, db: AsyncSession,

View File

@ -9,6 +9,7 @@ redis
python-jose[cryptography] python-jose[cryptography]
passlib[bcrypt] passlib[bcrypt]
bcrypt==4.0.1
pydantic-settings pydantic-settings
python-dotenv python-dotenv

View File

@ -0,0 +1,36 @@
import asyncio
import secrets
from sqlalchemy.ext.asyncio import AsyncSession
from db.session import AsyncSessionLocal
from core.security import hash_password
from domains.users.models import User, UserRole
async def create_admin():
username = input("Admin username: ")
secret = "1234"
async with AsyncSessionLocal() as db:
user = User(
username=username,
role=UserRole.ADMIN,
secret_hash=hash_password(secret),
)
db.add(user)
await db.commit()
print("\nAdmin created successfully\n")
print("Username:", username)
print("Secret:", secret)
print("\nSave this secret!\n")
if __name__ == "__main__":
asyncio.run(create_admin())