From 12fa3c2e8234934dab104a554f5c9f22c9cd241f Mon Sep 17 00:00:00 2001 From: PN Tech Date: Fri, 13 Mar 2026 13:58:14 +0300 Subject: [PATCH 1/3] feat(api): implement user data update functionality --- alembic/versions/1d9dc121301b_user_data.py | 62 ++++++++++++++++++++++ models/group_model.py | 1 + models/user_model.py | 11 ++-- repositories/auth_repository.py | 5 +- routers/app_router.py | 7 ++- routers/auth_router.py | 34 ++++++++++-- schemas/auth_schema.py | 5 ++ services/auth_service.py | 34 +++++++++++- 8 files changed, 147 insertions(+), 12 deletions(-) create mode 100644 alembic/versions/1d9dc121301b_user_data.py 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..a31cf11 100644 --- a/services/auth_service.py +++ b/services/auth_service.py @@ -45,6 +45,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 +512,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 ) ) From dbe44caa8dfa18c1e5a6e2d9776cd2a244489e4d Mon Sep 17 00:00:00 2001 From: PN Tech Date: Fri, 13 Mar 2026 14:05:45 +0300 Subject: [PATCH 2/3] fix(ci): resolve issue causing GitHub Actions workflow failure --- services/auth_service.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/services/auth_service.py b/services/auth_service.py index a31cf11..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 From 5e75da263f784f01031cab19a88c78c0f3d013b6 Mon Sep 17 00:00:00 2001 From: PN Tech Date: Fri, 13 Mar 2026 15:26:09 +0300 Subject: [PATCH 3/3] docs: update README files to reflect changes in new version --- README.md | 17 +++++++++-------- README_RU.md | 17 +++++++++-------- main.py | 3 +-- 3 files changed, 19 insertions(+), 18 deletions(-) diff --git a/README.md b/README.md index 4fc16e8..e652de4 100644 --- a/README.md +++ b/README.md @@ -24,14 +24,15 @@ The system ensures data integrity using PostgreSQL and high performance using Re --- -## 💡 What's New (v1.1.0) - Tags & Filters - -- **Tag Search**: The API now supports searching documents by tags. -- **Advanced Search Filters**: - - `exact_match`: Toggle between exact phrase matching and word-based partial matching. - - `include_pages`: Option to include or exclude document pages from search results. - - `search_fields`: Specify which fields to search in (e.g., `name`, `code`). -- **Optimization**: Performance improvements for search queries and database interactions. +## 💡 What's New + +- **Enhanced User Management**: + - The `PATCH /auth/user/{user_id}` endpoint now supports updating a user's `username` and `department_id`. + - It is now possible to unassign a user from a department by passing `department_id: null` in the request. +- **Stability and Bug Fixes**: + - Resolved lazy-loading errors for related data (e.g., user's department) during login and authentication, improving the reliability of endpoints that return user information. + - Fixed data validation errors in API responses, ensuring a correct and consistent user data structure. + - Addressed a `NameError` related to a missing `UserUpdateSchema` import that occurred in the CI/CD environment. --- diff --git a/README_RU.md b/README_RU.md index 942d519..2dd9418 100644 --- a/README_RU.md +++ b/README_RU.md @@ -24,14 +24,15 @@ Backend API для поиска, хранения и управления тех --- -## 💡 Что нового (v1.1.0) - Tags & Filters - -- **Поиск по тегам**: API теперь поддерживает поиск документов по тегам. -- **Расширенные фильтры поиска**: - - `exact_match`: Переключение между точным совпадением фразы и частичным совпадением по словам. - - `include_pages`: Опция для включения или исключения страниц документов из результатов поиска. - - `search_fields`: Указание полей для поиска (например, `name`, `code`). -- **Оптимизация**: Улучшена производительность поисковых запросов и взаимодействия с базой данных. +## 💡 Что нового + +- **Расширенное управление пользователями**: + - Конечная точка `PATCH /auth/user/{user_id}` теперь позволяет обновлять `username` и `department_id` пользователя. + - Добавлена возможность отвязать пользователя от отдела, передав `department_id: null` в запросе. +- **Повышение стабильности и исправление ошибок**: + - Устранены ошибки ленивой загрузки (`lazy-load`) связанных данных (например, отдела пользователя) при входе в систему и аутентификации, что повысило надежность конечных точек, возвращающих информацию о пользователе. + - Исправлены ошибки валидации данных в ответах API, гарантируя корректную и согласованную структуру данных пользователя. + - Устранена ошибка `NameError`, возникавшая в CI/CD, связанная с отсутствием импорта `UserUpdateSchema`. --- diff --git a/main.py b/main.py index 8ec65ec..7b04506 100644 --- a/main.py +++ b/main.py @@ -35,8 +35,7 @@ async def lifespan(app: FastAPI): allow_origins = [ "http://192.168.0.92", - "http://localhost", - "https://tsvetotron.com/" + "http://localhost" ] app = FastAPI(