Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

fix: Removed email change from user and profile update, added separate route for recovery email update #945

Merged
merged 15 commits into from
Aug 23, 2024
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
42 changes: 42 additions & 0 deletions api/core/dependencies/email/templates/profile_recovery_email.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
{% extends 'base.html' %}

{% block title %}Recovery Email Verification{% endblock %}
{% block style %}<link rel="stylesheet" href="{{ url_for('email_static', path='css/email-verification.css') }}">{% endblock %}

{% block content %}
<div class="template-main">
<div class="heading">
<p class="template-header">Recovery Email Verification</p>
</div>
<div class="content">
<p class="template-receiver-name">Hi {{ first_name }} {{ last_name }},</p>
<p class="template-message">
You have requested to change the recovery email on your profile.
</p>
<div class="editable-content">
<p>
This link will expire 5 minutes from when this email was been sent. If
you did not make this request, you can ignore this email.
</p>
<p>To change your recovery email, please click the button below:</p>
<a href="{{ link }}"><button
style="
display: inline-block;
padding: 10px 200px;
background-color: orangered;
color: #fff;
text-decoration: none;
border-radius: 10px;
">Change Recovery Email</button></a>
<p>
Or copy this link into your browser:
{{ link }}
</p>
<div class="template-farewell">
<p>Regards,</p>
<p>Boilerplate</p>
</div>
</div>
</div>
</div>
{% endblock %}
6 changes: 4 additions & 2 deletions api/v1/models/profile.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,8 +21,10 @@ class Profile(BaseTableModel):
phone_number = Column(String, nullable=True)
avatar_url = Column(String, nullable=True)
recovery_email = Column(String, nullable=True)
created_at = Column(DateTime, default=func.now())
updated_at = Column(DateTime, default=func.now(), onupdate=func.now())
facebook_link = Column(String, nullable=True)
instagram_link = Column(String, nullable=True)
twitter_link = Column(String, nullable=True)
linkedin_link = Column(String, nullable=True)

user = relationship("User", back_populates="profile")

Expand Down
52 changes: 33 additions & 19 deletions api/v1/routes/profiles.py
Original file line number Diff line number Diff line change
@@ -1,14 +1,21 @@
from fastapi import Depends, APIRouter, Request, logger, status, File, UploadFile, HTTPException
from fastapi import (Depends, APIRouter,
Request,
status, File,
UploadFile, HTTPException,
BackgroundTasks)
from sqlalchemy.orm import Session
import logging
from typing import Annotated
from PIL import Image
from io import BytesIO
from fastapi.responses import JSONResponse
import os

from api.utils.success_response import success_response
from api.v1.models.user import User
from api.v1.schemas.profile import ProfileCreateUpdate
from api.v1.schemas.profile import (ProfileCreateUpdate,
ProfileUpdateResponse,
ProfileRecoveryEmailResponse,
Token)
from api.db.database import get_db
from api.v1.schemas.user import DeactivateUserSchema
from api.v1.services.user import user_service
Expand Down Expand Up @@ -36,7 +43,9 @@ def get_current_user_profile(user_id: str,
)


@profile.post('/', status_code=status.HTTP_201_CREATED, response_model=success_response)
@profile.post('/', status_code=status.HTTP_201_CREATED,
response_model=success_response,
include_in_schema=False)
def create_user_profile(
schema: ProfileCreateUpdate,
db: Session = Depends(get_db),
Expand All @@ -55,24 +64,29 @@ def create_user_profile(
return response


@profile.patch("/", status_code=status.HTTP_200_OK, response_model=success_response)
def update_user_profile(
@profile.put("", status_code=status.HTTP_200_OK,
response_model=ProfileUpdateResponse)
async def update_user_profile(
schema: ProfileCreateUpdate,
db: Session = Depends(get_db),
current_user: User = Depends(user_service.get_current_user),
db: Annotated[Session, Depends(get_db)],
current_user: Annotated[User, Depends(user_service.get_current_user)],
background_tasks: BackgroundTasks
):
"""Endpoint to update user profile"""

updated_profile = profile_service.update(db, schema=schema, user_id=current_user.id)

response = success_response(
status_code=status.HTTP_200_OK,
message="User profile updated successfully",
data=updated_profile.to_dict(),
)

return response

return profile_service.update(db,
schema,
current_user,
background_tasks)


@profile.post("/verify-recovery-email", status_code=status.HTTP_200_OK,
response_model=ProfileRecoveryEmailResponse)
async def verify_recovery_email(
token: Token,
db: Annotated[Session, Depends(get_db)],
current_user: Annotated[User, Depends(user_service.get_current_user)],
):
return profile_service.update_recovery_email(current_user, db, token)

@profile.post("/deactivate", status_code=status.HTTP_200_OK)
async def deactivate_account(
Expand Down
6 changes: 3 additions & 3 deletions api/v1/routes/user.py
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@ async def delete_account(request: Request, db: Session = Depends(get_db), curren
message='User deleted successfully',
)

@user_router.patch("/",status_code=status.HTTP_200_OK)
@user_router.patch("",status_code=status.HTTP_200_OK)
def update_current_user(
current_user : Annotated[User , Depends(user_service.get_current_user)],
schema : UserUpdate,
Expand Down Expand Up @@ -91,7 +91,7 @@ def delete_user(
# soft-delete the user
user_service.delete(db=db, id=user_id)

@user_router.get('/', status_code=status.HTTP_200_OK, response_model=AllUsersResponse)
@user_router.get('', status_code=status.HTTP_200_OK, response_model=AllUsersResponse)
async def get_users(
current_user: Annotated[User, Depends(user_service.get_current_super_admin)],
db: Annotated[Session, Depends(get_db)],
Expand Down Expand Up @@ -123,7 +123,7 @@ async def get_users(
}
return user_service.fetch_all(db, page, per_page, **query_params)

@user_router.post("/", status_code=status.HTTP_201_CREATED, response_model=AdminCreateUserResponse)
@user_router.post("", status_code=status.HTTP_201_CREATED, response_model=AdminCreateUserResponse)
def admin_registers_user(
user_request: AdminCreateUser,
current_user: Annotated[User, Depends(user_service.get_current_super_admin)],
Expand Down
138 changes: 129 additions & 9 deletions api/v1/schemas/profile.py
Original file line number Diff line number Diff line change
@@ -1,10 +1,31 @@
from datetime import datetime
from pydantic import BaseModel, EmailStr, field_validator
from typing import Optional
from pydantic import (BaseModel, EmailStr,
model_validator, HttpUrl,
StringConstraints,
ConfigDict)
from typing import Optional, Annotated
from bleach import clean
import dns.resolver
from email_validator import validate_email, EmailNotValidError
import re
from api.v1.schemas.user import UserBase


def validate_mx_record(domain: str):
"""
Validate mx records for email
"""
try:
# Try to resolve the MX record for the domain
mx_records = dns.resolver.resolve(domain, 'MX')
return True if mx_records else False
except dns.resolver.NoAnswer:
return False
except dns.resolver.NXDOMAIN:
return False
except Exception:
return False

class ProfileBase(BaseModel):
"""
Pydantic model for a profile.
Expand Down Expand Up @@ -59,18 +80,117 @@ class ProfileCreateUpdate(BaseModel):
recovery_email (Optional[EmailStr]): The user's recovery email address.
"""

pronouns: Annotated[
Optional[str],
StringConstraints(max_length=20, strip_whitespace=True)
] = None
job_title: Annotated[
Optional[str],
StringConstraints(max_length=60, strip_whitespace=True)
] = None
username: Annotated[
Optional[str],
StringConstraints(max_length=30, strip_whitespace=True)
] = None
department: Annotated[
Optional[str],
StringConstraints(max_length=60, strip_whitespace=True)
] = None
social: Annotated[
Optional[str],
StringConstraints(max_length=60, strip_whitespace=True)
] = None
bio: Annotated[
Optional[str],
StringConstraints(max_length=100, strip_whitespace=True)
] = None
phone_number: Annotated[
Optional[str],
StringConstraints(max_length=14, strip_whitespace=True)
] = None
recovery_email: Optional[EmailStr] = None
avatar_url: Optional[HttpUrl] = None
facebook_link: Optional[HttpUrl] = None
instagram_link: Optional[HttpUrl] = None
twitter_link: Optional[HttpUrl] = None
linkedin_link: Optional[HttpUrl] = None

@model_validator(mode="before")
@classmethod
def phone_number_validator(cls, values: dict):
"""
Validate data
"""
phone_number = values.get('phone_number')
recovery_email = values.get("recovery_email")

if phone_number and not re.match(r"^\+?[1-9]\d{1,14}$", phone_number):
raise ValueError("Please use a valid phone number format")

if len(values) <= 0:
raise ValueError("Cannot update profile with empty field")

for key, value in values.items():
if value:
values[key] = clean(value)
if recovery_email:
try:
recovery_email = validate_email(recovery_email, check_deliverability=True)
if recovery_email.domain.count(".com") > 1:
raise EmailNotValidError("Recovery Email address contains multiple '.com' endings.")
if not validate_mx_record(recovery_email.domain):
raise ValueError('Recovery Email is invalid')
except EmailNotValidError as exc:
raise ValueError(exc) from exc
except Exception as exc:
raise ValueError(exc) from exc

return values

class ProfileData(BaseModel):
"""
Pydantic model for a profile.
"""

pronouns: Optional[str] = None
job_title: Optional[str] = None
username: Optional[str] = None
department: Optional[str] = None
social: Optional[str] = None
bio: Optional[str] = None
phone_number: Optional[str] = None
avatar_url: Optional[str] = None
recovery_email: Optional[EmailStr] = None
avatar_url: Optional[HttpUrl] = None
facebook_link: Optional[HttpUrl] = None
instagram_link: Optional[HttpUrl] = None
twitter_link: Optional[HttpUrl] = None
linkedin_link: Optional[HttpUrl] = None

model_config = ConfigDict(from_attributes=True)

@field_validator("phone_number")
@classmethod
def phone_number_validator(cls, value):
if value and not re.match(r"^\+?[1-9]\d{1,14}$", value):
raise ValueError("Please use a valid phone number format")
return value
class ProfileUpdateResponse(BaseModel):
"""
Schema for profile update response
"""
message: str
status_code: int
data: ProfileData

class ProfileRecoveryEmailResponse(BaseModel):
"""
Schema for recovery_email response
"""
message: str
status_code: int

class Token(BaseModel):
"""
Token schema
"""
token: Annotated[
str,
StringConstraints(
min_length=30,
strip_whitespace=True
)
]
2 changes: 0 additions & 2 deletions api/v1/schemas/user.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,6 @@ def validate_mx_record(domain: str):
try:
# Try to resolve the MX record for the domain
mx_records = dns.resolver.resolve(domain, 'MX')
print('mx_records: ', mx_records.response)
return True if mx_records else False
except dns.resolver.NoAnswer:
return False
Expand Down Expand Up @@ -98,7 +97,6 @@ class UserUpdate(BaseModel):

first_name : Optional[str] = None
last_name : Optional[str] = None
email : Optional[str] = None

class UserData(BaseModel):
"""
Expand Down
Loading
Loading