Skip to content

Commit

Permalink
Merge pull request #9 from rapid-integration/feature/image-cropping
Browse files Browse the repository at this point in the history
[Feature] Add image cropping and avatar to User
  • Loading branch information
zobweyt authored Aug 5, 2024
2 parents 058011d + 62e1984 commit d6a6d4f
Show file tree
Hide file tree
Showing 9 changed files with 111 additions and 4 deletions.
1 change: 1 addition & 0 deletions backend/.env.example
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ TEMPLATES_PATH=src/emails/templates
ALGORITHM=HS256
SECRET_KEY=KEY
ACCESS_TOKEN_EXPIRE_MINUTES=43200 # 30 days
MAX_AVATAR_SIZE=3_145_728 # 3 MB

VERIFICATION_CODE_MIN=100_000
VERIFICATION_CODE_MAX=999_999
Expand Down
27 changes: 27 additions & 0 deletions backend/migrations/versions/78ba13627e4a_add_avatar_to_user.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
"""add avatar to user
Revision ID: 78ba13627e4a
Revises: afa95c2fb3f3
Create Date: 2024-08-04 20:45:19.041726
"""

import sqlalchemy as sa
from alembic import op

revision = '78ba13627e4a'
down_revision = 'afa95c2fb3f3'
branch_labels = None
depends_on = None


def upgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
op.add_column('user', sa.Column('avatar_url', sa.String(), nullable=True))
# ### end Alembic commands ###


def downgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
op.drop_column('user', 'avatar_url')
# ### end Alembic commands ###
3 changes: 3 additions & 0 deletions backend/requirements/common.txt
Original file line number Diff line number Diff line change
Expand Up @@ -29,3 +29,6 @@ aiofiles

# Redis
redis

# Images
pillow
26 changes: 24 additions & 2 deletions backend/src/api/v1/users/me/routes.py
Original file line number Diff line number Diff line change
@@ -1,13 +1,15 @@
from fastapi import APIRouter, HTTPException, status
from fastapi import APIRouter, File, HTTPException, UploadFile, status

from src.api.deps import Session
from src.api.v1.users.deps import CurrentUser
from src.api.v1.users.me.schemas import CurrentUserResponse
from src.api.v1.users.models import User
from src.api.v1.users.schemas import UserEmail, UserPassword
from src.api.v1.users.service import is_email_registered, update_email, update_password, verify_user
from src.api.v1.users.service import is_email_registered, update_avatar, update_email, update_password, verify_user
from src.api.v1.verification.schemas import Code
from src.api.v1.verification.service import expire_code_if_valid
from src.core.config import settings
from src.core.files import check_file_size, check_is_image, generate_cropped_image

router = APIRouter(prefix="/me")

Expand Down Expand Up @@ -41,3 +43,23 @@ def update_current_user_email(current_user: CurrentUser, schema: UserEmail, sess
def update_current_user_password(current_user: CurrentUser, schema: UserPassword, session: Session) -> User:
update_password(session, current_user, schema.password)
return current_user


@router.patch("/avatar", response_model=CurrentUserResponse)
async def update_current_user_avatar(current_user: CurrentUser, session: Session, file: UploadFile = File(...)):
is_correct_size = await check_file_size(file, settings.MAX_AVATAR_SIZE)
if not is_correct_size:
raise HTTPException(status.HTTP_413_REQUEST_ENTITY_TOO_LARGE,
f"File size exceeded maximum avatar size: {settings.MAX_AVATAR_SIZE} bytes")

if not check_is_image(file):
raise HTTPException(status.HTTP_415_UNSUPPORTED_MEDIA_TYPE,
"Unsupported avatar image type. Make sure you're uploading a correct file")

image_url = generate_cropped_image(file)
if image_url is None:
raise HTTPException(status.HTTP_422_UNPROCESSABLE_ENTITY,
"Exception occurred whilst attempting to crop image. Make sure your file has no defects")

update_avatar(session, current_user, image_url)
return current_user
1 change: 1 addition & 0 deletions backend/src/api/v1/users/me/schemas.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,3 +6,4 @@ class CurrentUserResponse(UserResponse):

id: int
email: str
avatar_url: str
1 change: 1 addition & 0 deletions backend/src/api/v1/users/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,5 +7,6 @@ class User(Base, AuditMixin):
id: Mapped[int] = mapped_column(primary_key=True)
email: Mapped[str] = mapped_column(index=True, unique=True)
password: Mapped[str] = mapped_column()
avatar_url: Mapped[str] = mapped_column(nullable=True)
is_active: Mapped[bool] = mapped_column(default=True)
is_verified: Mapped[bool] = mapped_column(default=False)
10 changes: 10 additions & 0 deletions backend/src/api/v1/users/service.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@

from src.api.v1.users.models import User
from src.api.v1.users.schemas import UserCreate
from src.core.files import delete_file
from src.core.security import get_password_hash


Expand Down Expand Up @@ -42,3 +43,12 @@ def update_password(session: Session, user: User, new_password: str) -> None:
user.password = hashed_password
session.commit()
session.refresh(user)


def update_avatar(session: Session, user: User, new_avatar_url: str) -> None:
if user.avatar_url:
delete_file(user.avatar_url)

user.avatar_url = new_avatar_url
session.commit()
session.refresh(user)
5 changes: 3 additions & 2 deletions backend/src/core/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@
class Settings(BaseSettings):
model_config = SettingsConfigDict(env_file=".env", env_ignore_empty=True, extra="ignore")

DEBUG: bool = True
DEBUG: bool

APP_NAME: str
APP_VERSION: str
Expand All @@ -18,6 +18,7 @@ class Settings(BaseSettings):
ALGORITHM: str
SECRET_KEY: str
ACCESS_TOKEN_EXPIRE_MINUTES: int
MAX_AVATAR_SIZE: int

VERIFICATION_CODE_MIN: int
VERIFICATION_CODE_MAX: int
Expand All @@ -27,7 +28,7 @@ class Settings(BaseSettings):

POSTGRES_USERNAME: str
POSTGRES_PASSWORD: str
POSTGRES_PORT: int = 5432
POSTGRES_PORT: int
POSTGRES_HOST: str
POSTGRES_PATH: str

Expand Down
41 changes: 41 additions & 0 deletions backend/src/core/files.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
import aiofiles
from fastapi import UploadFile
from fastapi.responses import FileResponse
from PIL import Image

from src.core.config import settings

Expand Down Expand Up @@ -43,3 +44,43 @@ async def get_file(filename: str):
filename=filename,
)
return None


def crop_image_to_square(file: UploadFile) -> Image | None:
try:
image = Image.open(file.file)
width, height = image.size
new_side = min(width, height)
left = (width - new_side) / 2
top = (height - new_side) / 2
right = (width + new_side) / 2
bottom = (height + new_side) / 2
cropped_image = image.crop((left, top, right, bottom))
return cropped_image

except Exception:
return None


def generate_cropped_image(file: UploadFile) -> str | None:
cropped_image = crop_image_to_square(file)
if cropped_image:
filename = generate_unique_name(file)
cropped_image.save(os.path.join(settings.UPLOADS_PATH, filename))
return filename
return None


async def check_file_size(file: UploadFile, max_size: int) -> bool:
contents = await file.read()
return len(contents) < max_size


def check_is_image(file: UploadFile) -> bool:
return "image" in file.content_type


def delete_file(filename: str) -> None:
filepath = os.path.join(settings.UPLOADS_PATH, filename)
if os.path.exists(filepath):
os.remove(filepath)

0 comments on commit d6a6d4f

Please sign in to comment.