Skip to content

Commit d5a5fae

Browse files
authored
Merge pull request #10 from pntech-dev/feat/user-data
feat(api): implement user data update functionality
2 parents 5377593 + dbe44ca commit d5a5fae

File tree

8 files changed

+149
-13
lines changed

8 files changed

+149
-13
lines changed
Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,62 @@
1+
"""user data
2+
3+
Revision ID: 1d9dc121301b
4+
Revises: 1f43c69cbade
5+
Create Date: 2026-03-11 00:22:17.807587
6+
7+
"""
8+
from typing import Sequence, Union
9+
10+
from alembic import op
11+
import sqlalchemy as sa
12+
13+
14+
# revision identifiers, used by Alembic.
15+
revision: str = '1d9dc121301b'
16+
down_revision: Union[str, Sequence[str], None] = '1f43c69cbade'
17+
branch_labels: Union[str, Sequence[str], None] = None
18+
depends_on: Union[str, Sequence[str], None] = None
19+
20+
21+
def upgrade() -> None:
22+
"""Upgrade schema."""
23+
# ### commands auto generated by Alembic - please adjust! ###
24+
op.drop_index(op.f('idx_categories_group_id'), table_name='categories')
25+
op.drop_constraint(op.f('document_files_document_id_fkey'), 'document_files', type_='foreignkey')
26+
op.create_foreign_key(None, 'document_files', 'documents', ['document_id'], ['id'])
27+
op.drop_constraint(op.f('document_tags_tag_id_fkey'), 'document_tags', type_='foreignkey')
28+
op.drop_constraint(op.f('document_tags_document_id_fkey'), 'document_tags', type_='foreignkey')
29+
op.create_foreign_key(None, 'document_tags', 'tags', ['tag_id'], ['id'])
30+
op.create_foreign_key(None, 'document_tags', 'documents', ['document_id'], ['id'])
31+
op.drop_index(op.f('idx_documents_category_id'), table_name='documents')
32+
op.drop_index(op.f('idx_documents_code_trgm'), table_name='documents', postgresql_ops={'code': 'gin_trgm_ops'}, postgresql_using='gin')
33+
op.drop_index(op.f('idx_documents_name_trgm'), table_name='documents', postgresql_ops={'name': 'gin_trgm_ops'}, postgresql_using='gin')
34+
op.drop_index(op.f('idx_pages_designation_trgm'), table_name='pages', postgresql_ops={'designation': 'gin_trgm_ops'}, postgresql_using='gin')
35+
op.drop_index(op.f('idx_pages_document_id'), table_name='pages')
36+
op.drop_index(op.f('idx_pages_name_trgm'), table_name='pages', postgresql_ops={'name': 'gin_trgm_ops'}, postgresql_using='gin')
37+
op.add_column('users', sa.Column('department_id', sa.Integer(), nullable=True))
38+
op.create_foreign_key(None, 'users', 'groups', ['department_id'], ['id'])
39+
op.drop_column('users', 'department')
40+
# ### end Alembic commands ###
41+
42+
43+
def downgrade() -> None:
44+
"""Downgrade schema."""
45+
# ### commands auto generated by Alembic - please adjust! ###
46+
op.add_column('users', sa.Column('department', sa.VARCHAR(length=100), autoincrement=False, nullable=True))
47+
op.drop_constraint(None, 'users', type_='foreignkey')
48+
op.drop_column('users', 'department_id')
49+
op.create_index(op.f('idx_pages_name_trgm'), 'pages', ['name'], unique=False, postgresql_ops={'name': 'gin_trgm_ops'}, postgresql_using='gin')
50+
op.create_index(op.f('idx_pages_document_id'), 'pages', ['document_id'], unique=False)
51+
op.create_index(op.f('idx_pages_designation_trgm'), 'pages', ['designation'], unique=False, postgresql_ops={'designation': 'gin_trgm_ops'}, postgresql_using='gin')
52+
op.create_index(op.f('idx_documents_name_trgm'), 'documents', ['name'], unique=False, postgresql_ops={'name': 'gin_trgm_ops'}, postgresql_using='gin')
53+
op.create_index(op.f('idx_documents_code_trgm'), 'documents', ['code'], unique=False, postgresql_ops={'code': 'gin_trgm_ops'}, postgresql_using='gin')
54+
op.create_index(op.f('idx_documents_category_id'), 'documents', ['category_id'], unique=False)
55+
op.drop_constraint(None, 'document_tags', type_='foreignkey')
56+
op.drop_constraint(None, 'document_tags', type_='foreignkey')
57+
op.create_foreign_key(op.f('document_tags_document_id_fkey'), 'document_tags', 'documents', ['document_id'], ['id'], ondelete='CASCADE')
58+
op.create_foreign_key(op.f('document_tags_tag_id_fkey'), 'document_tags', 'tags', ['tag_id'], ['id'], ondelete='CASCADE')
59+
op.drop_constraint(None, 'document_files', type_='foreignkey')
60+
op.create_foreign_key(op.f('document_files_document_id_fkey'), 'document_files', 'documents', ['document_id'], ['id'], ondelete='CASCADE')
61+
op.create_index(op.f('idx_categories_group_id'), 'categories', ['group_id'], unique=False)
62+
# ### end Alembic commands ###

models/group_model.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ class Group(Base):
1111
has_all_docs_search = Column(Boolean, default=False)
1212

1313
categories = relationship('Category', back_populates='group')
14+
users = relationship('User', back_populates='department')
1415

1516
@property
1617
def documents_count(self) -> int:

models/user_model.py

Lines changed: 6 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,18 +1,19 @@
11
from db.base import Base
22
from sqlalchemy.orm import relationship
3-
from sqlalchemy import Column, Integer, String, Boolean
3+
from sqlalchemy import Column, Integer, String, Boolean, ForeignKey
44

55

66
class User(Base):
7-
__tablename__ = "users" # Table name in database
7+
__tablename__ = "users"
88

9-
# Evry column names has the same name as in SQL
109
id = Column(Integer, primary_key=True, index=True)
1110
username = Column(String(50), unique=True, nullable=True)
1211
email = Column(String(100), unique=True, nullable=False)
13-
department = Column(String(100), default=None, nullable=True)
12+
13+
department_id = Column(Integer, ForeignKey('groups.id'), nullable=True)
14+
department = relationship('Group', back_populates='users')
1415

15-
is_active = Column(Boolean, default=False) # Reserving email for a new user
16+
is_active = Column(Boolean, default=False)
1617
is_admin = Column(Boolean, default=False)
1718

1819
password_hash = Column(String, nullable=False)

repositories/auth_repository.py

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
from sqlalchemy import select, update
2+
from sqlalchemy.orm import selectinload
23
from datetime import datetime, timezone
34
from sqlalchemy.ext.asyncio import AsyncSession
45

@@ -21,7 +22,7 @@ async def get_user_by_id(self, user_id: int) -> User | None:
2122
The User object if found, otherwise None.
2223
"""
2324

24-
query = select(User).where(User.id == user_id)
25+
query = select(User).where(User.id == user_id).options(selectinload(User.department))
2526
result = await self.session.execute(query)
2627

2728
return result.scalar_one_or_none()
@@ -41,7 +42,7 @@ async def get_user_by_email(self, email: str) -> User | None:
4142
The User object if found, otherwise None.
4243
"""
4344

44-
query = select(User).where(User.email == email)
45+
query = select(User).where(User.email == email).options(selectinload(User.department))
4546
result = await self.session.execute(query)
4647

4748
return result.scalar_one_or_none()

routers/app_router.py

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -48,7 +48,12 @@ async def get_optional_current_user(
4848
user = await repo.get_user_by_id(int(user_id))
4949
if not user:
5050
return None
51-
return UserResponse.model_validate(user)
51+
return UserResponse(
52+
id=user.id,
53+
email=user.email,
54+
username=user.username,
55+
department=user.department.name if user.department else None
56+
)
5257

5358

5459
# ====================

routers/auth_router.py

Lines changed: 31 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,10 @@
1-
from fastapi import APIRouter, Depends
1+
from fastapi import APIRouter, Depends, HTTPException, status
22
from sqlalchemy.ext.asyncio import AsyncSession
33
from fastapi_limiter.depends import RateLimiter
44

55
from schemas import *
66
from db.deps import get_db
7+
from models import User
78
from services import AuthService
89
from utils import get_current_user
910
from core.config import settings
@@ -39,12 +40,39 @@ def rate_limit_strict():
3940

4041
@router.get("/user", response_model=UserResponse)
4142
async def get_user(
42-
user: UserResponse = Depends(get_current_user),
43+
user: User = Depends(get_current_user),
4344
):
4445
"""
4546
Retrieves the currently authenticated user's profile information.
4647
"""
47-
return user
48+
return UserResponse(
49+
id=user.id,
50+
email=user.email,
51+
username=user.username,
52+
department=user.department.name if user.department else None
53+
)
54+
55+
56+
@router.patch("/user/{user_id}", response_model=UserResponse)
57+
async def update_user_data(
58+
data: UserUpdateSchema,
59+
user_id: int,
60+
current_user: User = Depends(get_current_user),
61+
service: AuthService = Depends(get_auth_service),
62+
):
63+
"""
64+
Updates a user's profile information.
65+
66+
- Administrators can update any user.
67+
- Regular users can only update their own profile.
68+
"""
69+
if not current_user.is_admin and current_user.id != user_id:
70+
raise HTTPException(
71+
status_code=status.HTTP_403_FORBIDDEN,
72+
detail="You do not have permission to update this user."
73+
)
74+
75+
return await service.update_user_data(data=data, user_id=user_id)
4876

4977

5078
"""=== Login ==="""

schemas/auth_schema.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,11 @@ class UserResponse(BaseModel):
1313
department: str | None = None
1414

1515

16+
class UserUpdateSchema(BaseModel):
17+
username: str | None = None
18+
department_id: int | None = None
19+
20+
1621
class UserTokenResponse(BaseModel):
1722
access_token: str
1823
refresh_token: str

services/auth_service.py

Lines changed: 35 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,8 @@
2424
RequestPasswordResetSchema,
2525
VerifyResetCodeSchema,
2626
ResetPasswordSchema,
27-
RefreshTokenSchema
27+
RefreshTokenSchema,
28+
UserUpdateSchema
2829
)
2930
from core.config import settings
3031
from core.email_templates import EMAIL_VERIFICATION_TEMPLATE
@@ -45,6 +46,38 @@ def __init__(self, db: AsyncSession) -> None:
4546
# ===============
4647

4748

49+
async def update_user_data(self, data: UserUpdateSchema, user_id: int) -> UserResponse:
50+
# Find the user in the database
51+
user_from_db = await self.repo.get_user_by_id(user_id=user_id)
52+
if not user_from_db:
53+
raise HTTPException(status_code=404, detail="User not found")
54+
55+
update_data = data.model_dump(exclude_unset=True)
56+
57+
# Update username if provided
58+
if 'username' in update_data:
59+
user_from_db.username = data.username
60+
61+
# Update department if provided
62+
if 'department_id' in update_data:
63+
user_from_db.department_id = data.department_id
64+
65+
# Save the updated user
66+
await self.repo.save_user(user=user_from_db)
67+
await self.repo.session.commit()
68+
await self.repo.session.refresh(user_from_db)
69+
70+
# Create response
71+
response = UserResponse(
72+
id=user_from_db.id,
73+
email=user_from_db.email,
74+
username=user_from_db.username,
75+
department=user_from_db.department.name if user_from_db.department else None
76+
)
77+
78+
return response
79+
80+
4881
"""=== Login ==="""
4982

5083
async def login(self, data: LoginSchema) -> UserTokenResponse | None:
@@ -480,7 +513,7 @@ async def _create_token_response(
480513
id=user.id,
481514
email=user.email,
482515
username=user.username,
483-
department=user.department
516+
department=user.department.name if user.department else None
484517
)
485518
)
486519

0 commit comments

Comments
 (0)