Coverage for api/account/services.py: 100.00%
76 statements
« prev ^ index » next coverage.py v7.15.2, created at 2026-09-03 15:30 +0000
« prev ^ index » next coverage.py v7.15.2, created at 2026-09-03 15:30 +0000
1import logging
2from urllib.parse import quote
4import redis
5from sqlalchemy import or_, select
6from sqlalchemy.ext.asyncio import AsyncSession
8from api.auth.services import _request_email_change_verification_email
9from core.config import settings
10from core.security import clear_user_all_sessions, hash_password, verify_password
11from extensions.smtp import SMTPMailer
12from models.users import Users
13from utils.custom_exception import (
14 AuthenticationException,
15 ServerException,
16 SMTPNotConfiguredException,
17)
18from utils.email_templates import EMAIL_VERIFICATION_TEMPLATE
20from .schema import PasswordChange, UserUpdate
22logger = logging.getLogger("account")
25async def get_user_by_id(db: AsyncSession, user_id: str) -> Users | None:
26 """Get user info by id"""
27 result = await db.execute(select(Users).where(Users.id == user_id))
28 return result.scalar_one_or_none()
31async def update_user_profile(
32 db: AsyncSession,
33 user_id: str,
34 user_update: UserUpdate,
35 mailer: SMTPMailer | None = None,
36 redis_client: redis.Redis | None = None,
37) -> tuple[Users, bool] | None:
38 """Update user info (excluding password)"""
39 user = await get_user_by_id(db, user_id)
40 if not user:
41 return None
43 email_change_requested = False
44 new_email = user_update.email
45 if new_email and new_email != user.email:
46 result = await db.execute(
47 select(Users).where(
48 or_(Users.email == new_email, Users.pending_email == new_email), Users.id != user_id
49 )
50 )
51 if result.scalar_one_or_none():
52 raise ValueError("Email already exists")
54 # Defer email change until verification completes.
55 user.pending_email = new_email
56 email_change_requested = True
58 update_data = user_update.model_dump(exclude_unset=True)
59 update_data.pop("email", None)
60 for field, value in update_data.items():
61 setattr(user, field, value)
63 if (
64 email_change_requested
65 and settings.SMTP_ENABLE
66 and mailer
67 and getattr(mailer, "enabled", False)
68 ):
69 should_send = True
70 if redis_client:
71 cooldown_key = f"email_verification_cooldown:{new_email}"
72 remaining_seconds = await redis_client.ttl(cooldown_key)
73 try:
74 remaining_seconds = int(remaining_seconds)
75 except TypeError, ValueError:
76 remaining_seconds = 0
77 if remaining_seconds > 0:
78 should_send = False
80 if should_send:
81 try:
82 token_meta = await _request_email_change_verification_email(db, user, new_email)
83 verification_url = (
84 f"http{'s' if settings.SSL_ENABLE else ''}://"
85 f"{settings.HOSTNAME}:{settings.FRONTEND_PORT}"
86 f"/auth/verify-email?token={quote(token_meta['verification_token'], safe='')}"
87 )
89 user_name = f"{user.first_name} {user.last_name}".strip()
90 app_name = settings.PROJECT_NAME
92 email_content = EMAIL_VERIFICATION_TEMPLATE.render(
93 verification_url=verification_url,
94 user_name=user_name,
95 app_name=app_name,
96 expire_minutes=settings.EMAIL_VERIFICATION_TOKEN_EXPIRE_MINUTES,
97 )
99 mailer.send_text(
100 to_emails=[new_email],
101 subject=email_content["subject"],
102 body=email_content["body"],
103 html_body=email_content.get("html_body"),
104 )
106 if redis_client:
107 await redis_client.setex(
108 cooldown_key, settings.EMAIL_VERIFICATION_COOLDOWN_SECONDS, "1"
109 )
110 except SMTPNotConfiguredException as exc:
111 logger.warning("Skip email change verification send: %s", exc)
113 await db.commit()
114 await db.refresh(user)
115 return user, email_change_requested
118async def change_password(
119 db: AsyncSession,
120 user_id: str,
121 password_change: PasswordChange,
122 redis_client=None,
123 current_session_id: str | None = None,
124) -> bool:
125 """Change user password"""
126 try:
127 user = await get_user_by_id(db, user_id)
128 if not user:
129 return False
131 if not await verify_password(password_change.current_password, user.hash_password):
132 raise AuthenticationException("Current password is incorrect")
134 user.hash_password = await hash_password(password_change.new_password)
135 user.password_reset_required = False
137 if password_change.logout_other_devices and redis_client:
138 await clear_user_all_sessions(
139 db,
140 redis_client,
141 user_id,
142 exclude_session_id=current_session_id,
143 )
145 await db.commit()
147 return True
148 except AuthenticationException:
149 raise
150 except Exception as e:
151 raise ServerException(f"Failed to change password: {e}")