Edit README

This commit is contained in:
2026-01-09 20:38:53 +03:00
parent 7a906fa824
commit 57a825b866
16 changed files with 2996 additions and 20 deletions
+139
View File
@@ -0,0 +1,139 @@
from contextlib import asynccontextmanager
from typing import AsyncIterator
import logging
from aiobotocore.session import get_session
from types_aiobotocore_s3 import S3Client as S3ClientAnnotated
from botocore.exceptions import ClientError
from fastapi import HTTPException, status
from app.core.config import settings
log = logging.getLogger(__name__)
class S3Client:
def __init__(
self,
access_key: str,
secret_key: str,
endpoint_url: str,
bucket_name: str
):
self.config = dict(
aws_access_key_id=access_key,
aws_secret_access_key=secret_key,
endpoint_url=endpoint_url
)
self.endpoint_url = endpoint_url
self.bucket_name = bucket_name
@asynccontextmanager
async def _get_client(self) -> AsyncIterator[S3ClientAnnotated]:
session = get_session()
async with session.create_client("s3", **self.config) as raw_client:
client: S3ClientAnnotated = raw_client
yield client
async def upload_file(
self,
file: bytes,
object_name: str,
content_type: str
) -> str:
log.info("Uploading file to S3", extra={"object_name": object_name, "content_type": content_type})
try:
async with self._get_client() as client:
await client.put_object(
Bucket=self.bucket_name,
Key=object_name,
Body=file,
ContentType=content_type
)
log.info("File uploaded to S3 successfully", extra={"object_name": object_name})
except ClientError as e:
log.error(f"S3 upload error: {str(e)}", extra={"object_name": object_name})
raise HTTPException(status.HTTP_503_SERVICE_UNAVAILABLE, detail="S3 upload error")
except Exception as e:
log.error(f"Unexpected S3 error: {str(e)}", extra={"object_name": object_name})
raise HTTPException(status.HTTP_503_SERVICE_UNAVAILABLE, detail="Unexpected S3 error")
return f"{self.endpoint_url}/{self.bucket_name}/{object_name}"
async def download_file(
self,
object_name: str
) -> bytes:
log.info("Downloading file from S3", extra={"object_name": object_name})
try:
async with self._get_client() as client:
response = await client.get_object(
Bucket=self.bucket_name,
Key=object_name
)
log.info("File downloaded from S3 successfully", extra={"object_name": object_name})
return await response["Body"].read()
except ClientError as ex:
log.error(f"S3 download error: {str(ex)}", extra={"object_name": object_name})
raise HTTPException(status.HTTP_503_SERVICE_UNAVAILABLE, detail="S3 download error")
except Exception as e:
log.error(f"Unexpected S3 error: {str(e)}", extra={"object_name": object_name})
raise HTTPException(status.HTTP_503_SERVICE_UNAVAILABLE, detail="Unexpected S3 error")
async def delete_file(self, object_name: str) -> None:
log.info("Deleting file from S3", extra={"object_name": object_name})
try:
async with self._get_client() as client:
await client.delete_object(
Bucket=self.bucket_name,
Key=object_name
)
log.info("File deleted from S3 successfully", extra={"object_name": object_name})
except ClientError as e:
log.error(f"S3 delete error: {str(e)}", extra={"object_name": object_name})
raise HTTPException(status.HTTP_503_SERVICE_UNAVAILABLE, detail="S3 delete error")
except Exception as e:
log.error(f"Unexpected S3 error: {str(e)}", extra={"object_name": object_name})
raise HTTPException(status.HTTP_503_SERVICE_UNAVAILABLE, detail="Unexpected S3 error")
async def delete_files(self, object_names: list[str]) -> None:
if not object_names:
return
log.info("Deleting multiple files from S3", extra={"count": len(object_names)})
try:
async with self._get_client() as client:
delete_payload = {
"Objects": [{'Key': name} for name in object_names],
"Quiet": True
}
await client.delete_objects(
Bucket=self.bucket_name,
Delete=delete_payload
)
log.info("Files deleted from S3 successfully", extra={"count": len(object_names)})
except ClientError as e:
log.error(f"S3 batch delete error: {str(e)}", extra={"count": len(object_names)})
raise HTTPException(
status.HTTP_503_SERVICE_UNAVAILABLE,
detail="S3 batch delete error"
)
except Exception as e:
log.error(f"Unexpected S3 error: {str(e)}", extra={"count": len(object_names)})
raise HTTPException(
status.HTTP_503_SERVICE_UNAVAILABLE,
detail="Unexpected S3 error"
)
s3_client = S3Client(
access_key=settings.S3_ACCESS_KEY_ID,
secret_key=settings.S3_SECRET_ACCESS_KEY,
endpoint_url=settings.S3_URL,
bucket_name=settings.S3_BUCKET_NAME
)
+5
View File
@@ -28,6 +28,11 @@ class Settings(BaseSettings):
SMTP_PORT: int
SMTP_EMAIL: str
SMTP_PASS: str
S3_URL: str
S3_ACCESS_KEY_ID: str
S3_SECRET_ACCESS_KEY: str
S3_BUCKET_NAME: str
DB_HOST: str
DB_PORT: int
+14 -2
View File
@@ -1,7 +1,7 @@
from typing import Dict
import logging
from fastapi import APIRouter, Response, Depends
from fastapi import APIRouter, Response, Depends, UploadFile, File
from app.users.schemas import User, UserUpdate
from app.users.service import UserService
@@ -36,4 +36,16 @@ async def delete_current_user(response: Response, user: UserModel = Depends(get_
await AuthService.abort_all_sessions(user.id)
await UserService.delete_user(user.id)
return {"status": True, "message": "User successfully deleted"}
return {"status": True, "message": "User successfully deleted"}
@router.post("/me/avatar")
async def upload_avatar(
avatar: UploadFile = File(...),
user: UserModel = Depends(get_current_verified_user)
) -> User:
return await UserService.upload_avatar(user, avatar)
@router.delete('/me/avatar')
async def delete_avatar(user: UserModel = Depends(get_current_verified_user)) -> Dict:
await UserService.delete_avatar(user)
return {"status": True, "message": "Avatar successfully deleted"}
+56 -2
View File
@@ -3,11 +3,13 @@ import uuid
from datetime import timedelta
from typing import List
from fastapi import HTTPException, status
from fastapi import HTTPException, status, UploadFile
from sqlalchemy import or_
from sqlalchemy.orm.sync import update
from app.utils.hash_password import hash_password, verify_password
from app.services.redis_service import EmailTokenStorage, ChangePasswordTokenStorage
from app.core.S3_client import s3_client
from app.core.exceptions import InvalidTokenException, TokenExpiredException, UserNotFoundException
from app.users.models import UserModel
from app.users.dao import UserDAO
@@ -221,4 +223,56 @@ class UserService:
obj_in={"hashed_password": hash_password(new_password)}
)
await session.commit()
log.info("Successfully reset password", extra={"user_id": user_id})
log.info("Successfully reset password", extra={"user_id": user_id})
@classmethod
async def upload_avatar(cls, user: UserModel, avatar: UploadFile) -> User:
async with async_session_maker() as session:
allowed_types = ["image/jpeg", "image/png", "image/gif"]
if not avatar.content_type in allowed_types:
log.warning("Using not allowed type photo", extra={"user_id": user.id})
raise HTTPException(status.HTTP_400_BAD_REQUEST, detail="allowed type png and jpeg")
if user.avatar_url is not None:
await cls.delete_avatar(user)
type: str = avatar.filename.split(".")[-1]
object_name: str = f"avatar_{user.id}_{uuid.uuid4()}.{type}"
url = await s3_client.upload_file(
file=avatar.file.read(),
object_name=object_name,
content_type=avatar.content_type
)
update_user = await UserDAO.update(
session,
UserModel.id==user.id,
obj_in={"avatar_url": url}
)
await session.commit()
log.info("Successfully upload avatar", extra={"user_id": user.id, "avatar_url": url})
return update_user
@classmethod
async def delete_avatar(cls, user: UserModel):
async with async_session_maker() as session:
if user.avatar_url is None:
log.warning("Avatar is none", extra={"user_id": user.id})
return
avatar_name = user.avatar_url.split("/")[-1]
await s3_client.delete_file(avatar_name)
await UserDAO.update(
session,
UserModel.id==user.id,
obj_in={"avatar_url": None}
)
log.info("Avatar successfully deleted", extra={"user_id": user.id})
await session.commit()
+1499 -1
View File
File diff suppressed because one or more lines are too long
+3 -1
View File
@@ -19,7 +19,9 @@ dependencies = [
"python-jose (>=3.5.0,<4.0.0)",
"celery (>=5.6.1,<6.0.0)",
"bcrypt (<4.1)",
"pytest (>=9.0.2,<10.0.0)"
"pytest (>=9.0.2,<10.0.0)",
"aiobotocore (>=3.1.0,<4.0.0)",
"types-aiobotocore[essential] (>=3.1.0,<4.0.0)"
]