From f215f458bca373bb4d85378bdc6c9d33a5310f03 Mon Sep 17 00:00:00 2001 From: roai_linux Date: Fri, 6 Mar 2026 20:01:04 +0330 Subject: [PATCH] update project --- Back/alembic.ini | 1 - Back/alembic/env.py | 55 ++++++++----- .../versions/4413cbcf58c3_create_db.py | 77 +++++++++++++++++++ .../b1f09d977759_change_groups_models.py | 32 ++++++++ Back/core/config.py | 3 +- Back/core/deps.py | 32 +++++++- Back/docker-compose.yml | 8 +- Back/domains/admin/api.py | 24 +++++- Back/domains/admin/service.py | 3 +- Back/domains/groups/api.py | 41 ++++------ Back/domains/groups/models.py | 9 --- Back/domains/groups/repo.py | 5 ++ Back/domains/groups/schemas.py | 5 -- Back/domains/groups/service.py | 16 +++- Back/domains/users/api.py | 52 ++----------- Back/domains/users/repo.py | 9 ++- Back/domains/users/service.py | 3 +- Back/requirements.txt | 1 + Back/scripts/create_admin.py | 36 +++++++++ 19 files changed, 290 insertions(+), 122 deletions(-) create mode 100644 Back/alembic/versions/4413cbcf58c3_create_db.py create mode 100644 Back/alembic/versions/b1f09d977759_change_groups_models.py create mode 100644 Back/scripts/create_admin.py diff --git a/Back/alembic.ini b/Back/alembic.ini index 807ded2..bb20172 100644 --- a/Back/alembic.ini +++ b/Back/alembic.ini @@ -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] diff --git a/Back/alembic/env.py b/Back/alembic/env.py index 36112a3..c091022 100644 --- a/Back/alembic/env.py +++ b/Back/alembic/env.py @@ -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(): diff --git a/Back/alembic/versions/4413cbcf58c3_create_db.py b/Back/alembic/versions/4413cbcf58c3_create_db.py new file mode 100644 index 0000000..2189fa3 --- /dev/null +++ b/Back/alembic/versions/4413cbcf58c3_create_db.py @@ -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 ### diff --git a/Back/alembic/versions/b1f09d977759_change_groups_models.py b/Back/alembic/versions/b1f09d977759_change_groups_models.py new file mode 100644 index 0000000..114c993 --- /dev/null +++ b/Back/alembic/versions/b1f09d977759_change_groups_models.py @@ -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 ### diff --git a/Back/core/config.py b/Back/core/config.py index 9c31241..f9197a2 100755 --- a/Back/core/config.py +++ b/Back/core/config.py @@ -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 diff --git a/Back/core/deps.py b/Back/core/deps.py index e74b48f..42ec796 100644 --- a/Back/core/deps.py +++ b/Back/core/deps.py @@ -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( diff --git a/Back/docker-compose.yml b/Back/docker-compose.yml index 06aac9f..2111053 100755 --- a/Back/docker-compose.yml +++ b/Back/docker-compose.yml @@ -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 @@ -60,4 +62,4 @@ services: volumes: postgres_data: - redis_data: \ No newline at end of file + redis_data: diff --git a/Back/domains/admin/api.py b/Back/domains/admin/api.py index 2963491..257d278 100644 --- a/Back/domains/admin/api.py +++ b/Back/domains/admin/api.py @@ -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( @@ -68,4 +73,19 @@ async def reset_secret( detail="User not found" ) - return {"secret": new_secret} \ No newline at end of file + 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) \ No newline at end of file diff --git a/Back/domains/admin/service.py b/Back/domains/admin/service.py index ad3faef..eea57c6 100644 --- a/Back/domains/admin/service.py +++ b/Back/domains/admin/service.py @@ -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, diff --git a/Back/domains/groups/api.py b/Back/domains/groups/api.py index aa81df3..56c58f9 100644 --- a/Back/domains/groups/api.py +++ b/Back/domains/groups/api.py @@ -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) ): - membership = await add_member_to_group( - db, - group_id, - payload.user_id, - payload.role - ) + try: + membership = await add_member_to_group( + db, + group_id, + payload.user_id, + ) + 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 - ) \ No newline at end of file + return membership \ No newline at end of file diff --git a/Back/domains/groups/models.py b/Back/domains/groups/models.py index b2536d7..8ecd293 100644 --- a/Back/domains/groups/models.py +++ b/Back/domains/groups/models.py @@ -10,9 +10,6 @@ class GroupType(str, Enum): GROUP = "group" DIRECT = "direct" -class GroupRole(str, Enum): - MANAGER = "manager" - MEMBER = "member" class Group(Base): @@ -48,10 +45,4 @@ class GroupMember(Base): group_id: Mapped[uuid.UUID] = mapped_column( ForeignKey("groups.id", ondelete="CASCADE"), index=True - ) - - role: Mapped[GroupRole] = mapped_column( - SQLEnum(GroupRole, name="group_role"), - default=GroupRole.MEMBER, - nullable=False ) \ No newline at end of file diff --git a/Back/domains/groups/repo.py b/Back/domains/groups/repo.py index 0311c12..177bd55 100644 --- a/Back/domains/groups/repo.py +++ b/Back/domains/groups/repo.py @@ -28,4 +28,9 @@ async def get_user_groups(db: AsyncSession, user_id): .join(GroupMember) .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() \ No newline at end of file diff --git a/Back/domains/groups/schemas.py b/Back/domains/groups/schemas.py index 8fedb2c..2ba8963 100644 --- a/Back/domains/groups/schemas.py +++ b/Back/domains/groups/schemas.py @@ -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 \ No newline at end of file diff --git a/Back/domains/groups/service.py b/Back/domains/groups/service.py index e0e387a..ff03b84 100644 --- a/Back/domains/groups/service.py +++ b/Back/domains/groups/service.py @@ -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) diff --git a/Back/domains/users/api.py b/Back/domains/users/api.py index bf01783..b76c9d1 100644 --- a/Back/domains/users/api.py +++ b/Back/domains/users/api.py @@ -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} \ No newline at end of file + return await list_user_groups(db, user.id) \ No newline at end of file diff --git a/Back/domains/users/repo.py b/Back/domains/users/repo.py index 54ffe7b..369ce25 100644 --- a/Back/domains/users/repo.py +++ b/Back/domains/users/repo.py @@ -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 \ No newline at end of file + return user + +async def get_all_users(db: AsyncSession): + result = await db.execute(select(User)) + return result.scalars().all() \ No newline at end of file diff --git a/Back/domains/users/service.py b/Back/domains/users/service.py index 157252f..042f2dc 100644 --- a/Back/domains/users/service.py +++ b/Back/domains/users/service.py @@ -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, diff --git a/Back/requirements.txt b/Back/requirements.txt index d3a24ee..72af1e5 100755 --- a/Back/requirements.txt +++ b/Back/requirements.txt @@ -9,6 +9,7 @@ redis python-jose[cryptography] passlib[bcrypt] +bcrypt==4.0.1 pydantic-settings python-dotenv diff --git a/Back/scripts/create_admin.py b/Back/scripts/create_admin.py new file mode 100644 index 0000000..71ab7c1 --- /dev/null +++ b/Back/scripts/create_admin.py @@ -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()) \ No newline at end of file