Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
62 changes: 62 additions & 0 deletions alembic/versions/1d9dc121301b_user_data.py
Original file line number Diff line number Diff line change
@@ -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 ###
1 change: 1 addition & 0 deletions models/group_model.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
11 changes: 6 additions & 5 deletions models/user_model.py
Original file line number Diff line number Diff line change
@@ -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)
Expand Down
5 changes: 3 additions & 2 deletions repositories/auth_repository.py
Original file line number Diff line number Diff line change
@@ -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

Expand All @@ -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()
Expand All @@ -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()
Expand Down
7 changes: 6 additions & 1 deletion routers/app_router.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
)


# ====================
Expand Down
34 changes: 31 additions & 3 deletions routers/auth_router.py
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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 ==="""
Expand Down
5 changes: 5 additions & 0 deletions schemas/auth_schema.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
37 changes: 35 additions & 2 deletions services/auth_service.py
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,8 @@
RequestPasswordResetSchema,
VerifyResetCodeSchema,
ResetPasswordSchema,
RefreshTokenSchema
RefreshTokenSchema,
UserUpdateSchema
)
from core.config import settings
from core.email_templates import EMAIL_VERIFICATION_TEMPLATE
Expand All @@ -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:
Expand Down Expand Up @@ -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
)
)

Expand Down
Loading