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.
# other means of configuring database URLs may be customized within the env.py
# file.
sqlalchemy.url = driver://user:pass@localhost/dbname
[post_write_hooks]

View File

@ -1,7 +1,17 @@
import asyncio
from logging.config import fileConfig
from sqlalchemy import engine_from_config
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
@ -14,11 +24,7 @@ config = context.config
if config.config_file_name is not None:
fileConfig(config.config_file_name)
# add your model's MetaData object here
# for 'autogenerate' support
# from myapp import mymodel
# target_metadata = mymodel.Base.metadata
target_metadata = None
target_metadata = Base.metadata
# other values from the config, defined by the needs of env.py,
# can be acquired:
@ -38,7 +44,7 @@ def run_migrations_offline() -> None:
script output.
"""
url = config.get_main_option("sqlalchemy.url")
url = settings.DATABASE_URL
context.configure(
url=url,
target_metadata=target_metadata,
@ -50,26 +56,39 @@ def run_migrations_offline() -> None:
context.run_migrations()
def run_migrations_online() -> None:
"""Run migrations in 'online' mode.
from sqlalchemy import Connection
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.
"""
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.",
poolclass=pool.NullPool,
)
with connectable.connect() as connection:
context.configure(
connection=connection, target_metadata=target_metadata
)
async with connectable.connect() as connection:
await connection.run_sync(do_run_migrations)
with context.begin_transaction():
context.run_migrations()
await connectable.dispose()
def run_migrations_online() -> None:
"""Run migrations in 'online' mode."""
asyncio.run(run_async_migrations())
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
ACCESS_TOKEN_EXPIRE_MINUTES: int = 30
ALGORITHM: str = "HS256"
SECRET_PASS_LENGTH: int = 16
SECRET_PASS_LENGTH: int
DATABASE_URL: str
REDIS_URL: str
@ -21,6 +21,7 @@ class Settings(BaseSettings):
class Config:
env_file = ".env"
case_sensitive = True
extra = "ignore"
@lru_cache

View File

@ -1,18 +1,25 @@
from fastapi import Depends, HTTPException, status
from fastapi.security import OAuth2PasswordBearer
from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials
from sqlalchemy.ext.asyncio import AsyncSession
from db.session import get_db
from core.jwt import decode_token
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(
token: str = Depends(oauth2_scheme),
credentials: HTTPAuthorizationCredentials = Depends(security),
db: AsyncSession = Depends(get_db),
):
"""
Validate JWT token and return the authenticated user
"""
token = credentials.credentials
payload = decode_token(token)
@ -24,6 +31,12 @@ async def get_current_user(
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)
if not user:
@ -32,10 +45,21 @@ async def get_current_user(
detail="User not found",
)
if not user.is_active:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="User is inactive",
)
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":
raise HTTPException(

View File

@ -5,6 +5,8 @@ services:
container_name: neda_api
ports:
- "8000:8000"
volumes:
- "./:/app"
env_file:
- .env
depends_on:
@ -28,7 +30,7 @@ services:
restart: always
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
timeout: 5s
retries: 3
@ -41,7 +43,7 @@ services:
restart: always
healthcheck:
test: ["CMD", "redis-cli", "ping"]
test: [ "CMD", "redis-cli", "ping" ]
interval: 5s
timeout: 3s
retries: 5

View File

@ -7,13 +7,18 @@ from core.deps import get_current_admin
from domains.admin.schemas import (
AdminCreateUser,
AdminCreateUserResult,
AdminResetSecretResult
AdminResetSecretResult,
AdminUserResponse
)
from domains.groups.schemas import GroupResponse
from domains.groups.repo import get_all_groups
from domains.admin.service import (
admin_create_user,
admin_reset_user_secret
)
from domains.users.repo import get_all_users
router = APIRouter(
@ -69,3 +74,18 @@ async def reset_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():
return secrets.token_urlsafe(settings.SECRET_PASS_LENGTH)
# return secrets.token_urlsafe(settings.SECRET_PASS_LENGTH)
return "1234"
async def admin_create_user(
db: AsyncSession,

View File

@ -2,7 +2,7 @@ from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy.ext.asyncio import AsyncSession
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 (
GroupCreate,
@ -13,7 +13,6 @@ from domains.groups.schemas import (
from domains.groups.service import (
create_new_group,
add_member_to_group,
list_user_groups
)
@ -30,13 +29,12 @@ router = APIRouter(
async def create_group(
payload: GroupCreate,
db: AsyncSession = Depends(get_db),
admin = Depends(get_current_admin)
admin=Depends(get_current_admin)
):
group = await create_new_group(
db,
payload.name,
payload.description
payload.name
)
return group
@ -47,26 +45,19 @@ async def add_member(
group_id: str,
payload: AddMemberRequest,
db: AsyncSession = Depends(get_db),
admin = Depends(get_current_admin)
admin=Depends(get_current_admin)
):
try:
membership = await add_member_to_group(
db,
group_id,
payload.user_id,
payload.role
)
except ValueError as e:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=str(e)
)
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"
DIRECT = "direct"
class GroupRole(str, Enum):
MANAGER = "manager"
MEMBER = "member"
class Group(Base):
@ -49,9 +46,3 @@ class GroupMember(Base):
ForeignKey("groups.id", ondelete="CASCADE"),
index=True
)
role: Mapped[GroupRole] = mapped_column(
SQLEnum(GroupRole, name="group_role"),
default=GroupRole.MEMBER,
nullable=False
)

View File

@ -29,3 +29,8 @@ async def get_user_groups(db: AsyncSession, 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()

View File

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

View File

@ -1,5 +1,6 @@
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.repo import (
create_group,
@ -12,12 +13,10 @@ from domains.groups.repo import (
async def create_new_group(
db: AsyncSession,
name: str,
description: str | None
):
group = Group(
name=name,
description=description
)
return await create_group(db, group)
@ -26,13 +25,22 @@ async def add_member_to_group(
db: AsyncSession,
group_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(
group_id=group_id,
user_id=user_id,
role=role
)
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 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 (
UserCreate,
UserCreateResult
)
from domains.groups.schemas import GroupResponse
from domains.groups.service import list_user_groups
from domains.users.service import (
create_user_by_admin,
reset_user_secret
)
from domains.users.repo import get_user_by_id
router = APIRouter(
prefix="/users",
@ -21,38 +14,9 @@ router = APIRouter(
)
@router.post("/", response_model=UserCreateResult)
async def create_user(
payload: UserCreate,
@router.get("/me/groups", response_model=list[GroupResponse])
async def my_groups(
db: AsyncSession = Depends(get_db),
admin = Depends(get_current_admin)
user=Depends(get_current_user)
):
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}
return await list_user_groups(db, user.id)

View File

@ -1,6 +1,5 @@
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
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()
async def get_user_by_username(db: AsyncSession, username):
result = await db.execute(
select(User).where(User.username == username)
)
return result.scalar_one_or_none()
async def create_user(db: AsyncSession, user: User):
db.add(user)
await db.commit()
await db.refresh(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():
return secrets.token_urlsafe(settings.SECRET_PASS_LENGTH)
# return secrets.token_urlsafe(settings.SECRET_PASS_LENGTH)
return "1234"
async def create_user_by_admin(
db: AsyncSession,

View File

@ -9,6 +9,7 @@ redis
python-jose[cryptography]
passlib[bcrypt]
bcrypt==4.0.1
pydantic-settings
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())