diff --git a/alembic/versions/1d9dc121301b_user_data.py b/alembic/versions/1d9dc121301b_user_data.py new file mode 100644 index 0000000..50a866b --- /dev/null +++ b/alembic/versions/1d9dc121301b_user_data.py @@ -0,0 +1,62 @@ +"""user data + +Revision ID: 1d9dc121301b +Revises: 1f43c69cbade +Create Date: 2026-03-11 00:22:17.807587 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision: str = '1d9dc121301b' +down_revision: Union[str, Sequence[str], None] = '1f43c69cbade' +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_index(op.f('idx_categories_group_id'), table_name='categories') + op.drop_constraint(op.f('document_files_document_id_fkey'), 'document_files', type_='foreignkey') + op.create_foreign_key(None, 'document_files', 'documents', ['document_id'], ['id']) + op.drop_constraint(op.f('document_tags_tag_id_fkey'), 'document_tags', type_='foreignkey') + op.drop_constraint(op.f('document_tags_document_id_fkey'), 'document_tags', type_='foreignkey') + op.create_foreign_key(None, 'document_tags', 'tags', ['tag_id'], ['id']) + op.create_foreign_key(None, 'document_tags', 'documents', ['document_id'], ['id']) + op.drop_index(op.f('idx_documents_category_id'), table_name='documents') + op.drop_index(op.f('idx_documents_code_trgm'), table_name='documents', postgresql_ops={'code': 'gin_trgm_ops'}, postgresql_using='gin') + op.drop_index(op.f('idx_documents_name_trgm'), table_name='documents', postgresql_ops={'name': 'gin_trgm_ops'}, postgresql_using='gin') + op.drop_index(op.f('idx_pages_designation_trgm'), table_name='pages', postgresql_ops={'designation': 'gin_trgm_ops'}, postgresql_using='gin') + op.drop_index(op.f('idx_pages_document_id'), table_name='pages') + op.drop_index(op.f('idx_pages_name_trgm'), table_name='pages', postgresql_ops={'name': 'gin_trgm_ops'}, postgresql_using='gin') + op.add_column('users', sa.Column('department_id', sa.Integer(), nullable=True)) + op.create_foreign_key(None, 'users', 'groups', ['department_id'], ['id']) + op.drop_column('users', 'department') + # ### end Alembic commands ### + + +def downgrade() -> None: + """Downgrade schema.""" + # ### commands auto generated by Alembic - please adjust! ### + op.add_column('users', sa.Column('department', sa.VARCHAR(length=100), autoincrement=False, nullable=True)) + op.drop_constraint(None, 'users', type_='foreignkey') + op.drop_column('users', 'department_id') + op.create_index(op.f('idx_pages_name_trgm'), 'pages', ['name'], unique=False, postgresql_ops={'name': 'gin_trgm_ops'}, postgresql_using='gin') + op.create_index(op.f('idx_pages_document_id'), 'pages', ['document_id'], unique=False) + op.create_index(op.f('idx_pages_designation_trgm'), 'pages', ['designation'], unique=False, postgresql_ops={'designation': 'gin_trgm_ops'}, postgresql_using='gin') + op.create_index(op.f('idx_documents_name_trgm'), 'documents', ['name'], unique=False, postgresql_ops={'name': 'gin_trgm_ops'}, postgresql_using='gin') + op.create_index(op.f('idx_documents_code_trgm'), 'documents', ['code'], unique=False, postgresql_ops={'code': 'gin_trgm_ops'}, postgresql_using='gin') + op.create_index(op.f('idx_documents_category_id'), 'documents', ['category_id'], unique=False) + op.drop_constraint(None, 'document_tags', type_='foreignkey') + op.drop_constraint(None, 'document_tags', type_='foreignkey') + op.create_foreign_key(op.f('document_tags_document_id_fkey'), 'document_tags', 'documents', ['document_id'], ['id'], ondelete='CASCADE') + op.create_foreign_key(op.f('document_tags_tag_id_fkey'), 'document_tags', 'tags', ['tag_id'], ['id'], ondelete='CASCADE') + op.drop_constraint(None, 'document_files', type_='foreignkey') + op.create_foreign_key(op.f('document_files_document_id_fkey'), 'document_files', 'documents', ['document_id'], ['id'], ondelete='CASCADE') + op.create_index(op.f('idx_categories_group_id'), 'categories', ['group_id'], unique=False) + # ### end Alembic commands ### diff --git a/models/group_model.py b/models/group_model.py index 0dc014e..823c34c 100644 --- a/models/group_model.py +++ b/models/group_model.py @@ -11,6 +11,7 @@ class Group(Base): has_all_docs_search = Column(Boolean, default=False) categories = relationship('Category', back_populates='group') + users = relationship('User', back_populates='department') @property def documents_count(self) -> int: diff --git a/models/user_model.py b/models/user_model.py index e7a0c6c..f71eb82 100644 --- a/models/user_model.py +++ b/models/user_model.py @@ -1,18 +1,19 @@ from db.base import Base from sqlalchemy.orm import relationship -from sqlalchemy import Column, Integer, String, Boolean +from sqlalchemy import Column, Integer, String, Boolean, ForeignKey class User(Base): - __tablename__ = "users" # Table name in database + __tablename__ = "users" - # Evry column names has the same name as in SQL id = Column(Integer, primary_key=True, index=True) username = Column(String(50), unique=True, nullable=True) email = Column(String(100), unique=True, nullable=False) - department = Column(String(100), default=None, nullable=True) + + department_id = Column(Integer, ForeignKey('groups.id'), nullable=True) + department = relationship('Group', back_populates='users') - is_active = Column(Boolean, default=False) # Reserving email for a new user + is_active = Column(Boolean, default=False) is_admin = Column(Boolean, default=False) password_hash = Column(String, nullable=False) diff --git a/repositories/auth_repository.py b/repositories/auth_repository.py index 51441db..519d161 100644 --- a/repositories/auth_repository.py +++ b/repositories/auth_repository.py @@ -1,4 +1,5 @@ from sqlalchemy import select, update +from sqlalchemy.orm import selectinload from datetime import datetime, timezone from sqlalchemy.ext.asyncio import AsyncSession @@ -21,7 +22,7 @@ async def get_user_by_id(self, user_id: int) -> User | None: The User object if found, otherwise None. """ - query = select(User).where(User.id == user_id) + query = select(User).where(User.id == user_id).options(selectinload(User.department)) result = await self.session.execute(query) return result.scalar_one_or_none() @@ -41,7 +42,7 @@ async def get_user_by_email(self, email: str) -> User | None: The User object if found, otherwise None. """ - query = select(User).where(User.email == email) + query = select(User).where(User.email == email).options(selectinload(User.department)) result = await self.session.execute(query) return result.scalar_one_or_none() diff --git a/routers/app_router.py b/routers/app_router.py index f0946d4..d8e63c0 100644 --- a/routers/app_router.py +++ b/routers/app_router.py @@ -48,7 +48,12 @@ async def get_optional_current_user( user = await repo.get_user_by_id(int(user_id)) if not user: return None - return UserResponse.model_validate(user) + return UserResponse( + id=user.id, + email=user.email, + username=user.username, + department=user.department.name if user.department else None + ) # ==================== diff --git a/routers/auth_router.py b/routers/auth_router.py index 28c5a1f..28b3f30 100644 --- a/routers/auth_router.py +++ b/routers/auth_router.py @@ -1,9 +1,10 @@ -from fastapi import APIRouter, Depends +from fastapi import APIRouter, Depends, HTTPException, status from sqlalchemy.ext.asyncio import AsyncSession from fastapi_limiter.depends import RateLimiter from schemas import * from db.deps import get_db +from models import User from services import AuthService from utils import get_current_user from core.config import settings @@ -39,12 +40,39 @@ def rate_limit_strict(): @router.get("/user", response_model=UserResponse) async def get_user( - user: UserResponse = Depends(get_current_user), + user: User = Depends(get_current_user), ): """ Retrieves the currently authenticated user's profile information. """ - return user + return UserResponse( + id=user.id, + email=user.email, + username=user.username, + department=user.department.name if user.department else None + ) + + +@router.patch("/user/{user_id}", response_model=UserResponse) +async def update_user_data( + data: UserUpdateSchema, + user_id: int, + current_user: User = Depends(get_current_user), + service: AuthService = Depends(get_auth_service), +): + """ + Updates a user's profile information. + + - Administrators can update any user. + - Regular users can only update their own profile. + """ + if not current_user.is_admin and current_user.id != user_id: + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="You do not have permission to update this user." + ) + + return await service.update_user_data(data=data, user_id=user_id) """=== Login ===""" diff --git a/schemas/auth_schema.py b/schemas/auth_schema.py index e75b7d7..65d4f8a 100644 --- a/schemas/auth_schema.py +++ b/schemas/auth_schema.py @@ -13,6 +13,11 @@ class UserResponse(BaseModel): department: str | None = None +class UserUpdateSchema(BaseModel): + username: str | None = None + department_id: int | None = None + + class UserTokenResponse(BaseModel): access_token: str refresh_token: str diff --git a/services/auth_service.py b/services/auth_service.py index 143717d..b32f4d8 100644 --- a/services/auth_service.py +++ b/services/auth_service.py @@ -24,7 +24,8 @@ RequestPasswordResetSchema, VerifyResetCodeSchema, ResetPasswordSchema, - RefreshTokenSchema + RefreshTokenSchema, + UserUpdateSchema ) from core.config import settings from core.email_templates import EMAIL_VERIFICATION_TEMPLATE @@ -45,6 +46,38 @@ def __init__(self, db: AsyncSession) -> None: # =============== + async def update_user_data(self, data: UserUpdateSchema, user_id: int) -> UserResponse: + # Find the user in the database + user_from_db = await self.repo.get_user_by_id(user_id=user_id) + if not user_from_db: + raise HTTPException(status_code=404, detail="User not found") + + update_data = data.model_dump(exclude_unset=True) + + # Update username if provided + if 'username' in update_data: + user_from_db.username = data.username + + # Update department if provided + if 'department_id' in update_data: + user_from_db.department_id = data.department_id + + # Save the updated user + await self.repo.save_user(user=user_from_db) + await self.repo.session.commit() + await self.repo.session.refresh(user_from_db) + + # Create response + response = UserResponse( + id=user_from_db.id, + email=user_from_db.email, + username=user_from_db.username, + department=user_from_db.department.name if user_from_db.department else None + ) + + return response + + """=== Login ===""" async def login(self, data: LoginSchema) -> UserTokenResponse | None: @@ -480,7 +513,7 @@ async def _create_token_response( id=user.id, email=user.email, username=user.username, - department=user.department + department=user.department.name if user.department else None ) )