diff --git a/API/app/application/profile_photos_repository.py b/API/app/application/profile_photos_repository.py new file mode 100644 index 0000000..23dfaac --- /dev/null +++ b/API/app/application/profile_photos_repository.py @@ -0,0 +1,32 @@ +from typing import Optional, Sequence + +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession + +from app.domain.models import ProfilePhoto + + +class ProfilePhotosRepository: + def __init__(self, db: AsyncSession): + self.db = db + + async def get_by_id(self, photo_id: int) -> Optional[ProfilePhoto]: + stmt = select(ProfilePhoto).filter_by(id=photo_id) + result = await self.db.execute(stmt) + return result.scalars().first() + + async def get_by_profile_id(self, profile_id: int) -> Sequence[ProfilePhoto]: + stmt = select(ProfilePhoto).filter_by(profile_id=profile_id) + result = await self.db.execute(stmt) + return result.scalars().all() + + async def create(self, photo: ProfilePhoto) -> ProfilePhoto: + self.db.add(photo) + await self.db.commit() + await self.db.refresh(photo) + return photo + + async def delete(self, photo: ProfilePhoto) -> ProfilePhoto: + await self.db.delete(photo) + await self.db.commit() + return photo diff --git a/API/app/application/projects_repository.py b/API/app/application/projects_repository.py new file mode 100644 index 0000000..e7b3940 --- /dev/null +++ b/API/app/application/projects_repository.py @@ -0,0 +1,37 @@ +from typing import Optional, Sequence + +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession + +from app.domain.models import Project + + +class ProjectsRepository: + def __init__(self, db: AsyncSession): + self.db = db + + async def get_all(self) -> Sequence[Project]: + stmt = select(Project) + result = await self.db.execute(stmt) + return result.scalars().all() + + async def get_by_id(self, project_id: int) -> Optional[Project]: + stmt = select(Project).filter_by(id=project_id) + result = await self.db.execute(stmt) + return result.scalars().first() + + async def create(self, project: Project) -> Project: + self.db.add(project) + await self.db.commit() + await self.db.refresh(project) + return project + + async def update(self, project: Project) -> Project: + await self.db.merge(project) + await self.db.commit() + return project + + async def delete(self, project: Project) -> Project: + await self.db.delete(project) + await self.db.commit() + return project diff --git a/API/app/contollers/profile_photos_router.py b/API/app/contollers/profile_photos_router.py new file mode 100644 index 0000000..1bf3867 --- /dev/null +++ b/API/app/contollers/profile_photos_router.py @@ -0,0 +1,69 @@ +from fastapi import Depends, File, UploadFile, APIRouter +from sqlalchemy.ext.asyncio import AsyncSession +from starlette.responses import FileResponse + +from app.database.session import get_db +from app.domain.entities.profile_photo import ProfilePhotoEntity +from app.infrastructure.dependencies import get_current_user, require_admin +from app.infrastructure.profile_photos_service import ProfilePhotosService + +router = APIRouter() + + +@router.get( + "/profiles/{profile_id}/", + response_model=list[ProfilePhotoEntity], + summary="Get all photos metadata for a profile", + description="Returns metadata of all profile photos for the given profile_id", +) +async def get_photos_by_profile_id( + profile_id: int, + db: AsyncSession = Depends(get_db), +): + service = ProfilePhotosService(db) + return await service.get_photo_file_by_profile_id(profile_id) + + +@router.get( + "/{photo_id}/file", + response_class=FileResponse, + summary="Download photo file by photo ID", + description="Returns the image file for the given photo ID", +) +async def download_photo_file( + photo_id: int, + db: AsyncSession = Depends(get_db), +): + service = ProfilePhotosService(db) + return await service.get_photo_file_by_id(photo_id) + + +@router.post( + "/profiles/{profile_id}/upload", + response_model=ProfilePhotoEntity, + summary="Upload a new photo for a profile", + description="Uploads a new photo file and associates it with the given profile ID", +) +async def upload_photo( + profile_id: int, + file: UploadFile = File(...), + db: AsyncSession = Depends(get_db), + user=Depends(get_current_user), +): + service = ProfilePhotosService(db) + return await service.upload_photo(profile_id, file, user) + + +@router.delete( + "/{photo_id}/", + response_model=ProfilePhotoEntity, + summary="Delete a photo by ID", + description="Deletes a photo and its file from storage", +) +async def delete_photo( + photo_id: int, + db: AsyncSession = Depends(get_db), + user=Depends(get_current_user), +): + service = ProfilePhotosService(db) + return await service.delete_photo(photo_id, user) diff --git a/API/app/contollers/profiles_router.py b/API/app/contollers/profiles_router.py index 40989d3..58d980a 100644 --- a/API/app/contollers/profiles_router.py +++ b/API/app/contollers/profiles_router.py @@ -17,7 +17,7 @@ router = APIRouter() summary='Create a new profile', description='Creates a new profile', ) -async def create_team( +async def create_profile( profile: ProfileEntity, db: AsyncSession = Depends(get_db), user=Depends(require_admin), @@ -32,11 +32,11 @@ async def create_team( summary='Update a profile', description='Updates a profile', ) -async def create_team( +async def update_profile( profile_id: int, profile: ProfileEntity, db: AsyncSession = Depends(get_db), - user=Depends(require_admin), + user=Depends(get_current_user), ): profiles_service = ProfilesService(db) return await profiles_service.update_profile(profile_id, profile, user) @@ -48,7 +48,7 @@ async def create_team( summary='Delete a profile', description='Delete a profile', ) -async def create_team( +async def delete_profile( profile_id: int, db: AsyncSession = Depends(get_db), user=Depends(require_admin), diff --git a/API/app/contollers/teams_router.py b/API/app/contollers/teams_router.py index 802c1e3..d3adb8a 100644 --- a/API/app/contollers/teams_router.py +++ b/API/app/contollers/teams_router.py @@ -46,11 +46,11 @@ async def create_team( summary='Update a team', description='Updates a team', ) -async def create_team( +async def update_team( team_id: int, team: TeamEntity, db: AsyncSession = Depends(get_db), - user=Depends(get_current_user), + user=Depends(require_admin), ): teams_service = TeamsService(db) return await teams_service.update_team(team_id, team) @@ -62,7 +62,7 @@ async def create_team( summary='Delete a team', description='Delete a team', ) -async def create_team( +async def delete_team( team_id: int, db: AsyncSession = Depends(get_db), user=Depends(require_admin), diff --git a/API/app/contollers/users_router.py b/API/app/contollers/users_router.py index 5b360aa..6a0e6a0 100644 --- a/API/app/contollers/users_router.py +++ b/API/app/contollers/users_router.py @@ -5,7 +5,7 @@ from sqlalchemy.ext.asyncio import AsyncSession from app.database.session import get_db from app.domain.entities.user import UserEntity -from app.infrastructure.dependencies import require_admin +from app.infrastructure.dependencies import get_current_user from app.infrastructure.users_service import UsersService router = APIRouter() @@ -17,11 +17,11 @@ router = APIRouter() summary='Change user password', description='Change user password', ) -async def create_team( +async def create_user( user_id: int, new_password: str, db: AsyncSession = Depends(get_db), - user=Depends(require_admin), + user=Depends(get_current_user), ): users_service = UsersService(db) - return await users_service.change_user_password(user_id, new_password) + return await users_service.change_user_password(user_id, new_password, user) diff --git a/API/app/database/migrations/versions/b4056dda0936_0002_добавил_поле_filename_у_фотографий_.py b/API/app/database/migrations/versions/b4056dda0936_0002_добавил_поле_filename_у_фотографий_.py new file mode 100644 index 0000000..141da41 --- /dev/null +++ b/API/app/database/migrations/versions/b4056dda0936_0002_добавил_поле_filename_у_фотографий_.py @@ -0,0 +1,32 @@ +"""0002_добавил_поле_filename_у_фотографий_профиля + +Revision ID: b4056dda0936 +Revises: d53be3a35511 +Create Date: 2025-05-05 21:06:32.080386 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision: str = 'b4056dda0936' +down_revision: Union[str, None] = 'd53be3a35511' +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + """Upgrade schema.""" + # ### commands auto generated by Alembic - please adjust! ### + op.add_column('profile_photos', sa.Column('filename', sa.String(), nullable=False)) + # ### end Alembic commands ### + + +def downgrade() -> None: + """Downgrade schema.""" + # ### commands auto generated by Alembic - please adjust! ### + op.drop_column('profile_photos', 'filename') + # ### end Alembic commands ### diff --git a/API/app/domain/entities/profile_photo.py b/API/app/domain/entities/profile_photo.py new file mode 100644 index 0000000..f01fb2a --- /dev/null +++ b/API/app/domain/entities/profile_photo.py @@ -0,0 +1,8 @@ +from pydantic import BaseModel + + +class ProfilePhotoEntity(BaseModel): + id: int + filename: str + file_path: str + profile_id: int diff --git a/API/app/domain/entities/project.py b/API/app/domain/entities/project.py new file mode 100644 index 0000000..32d53b0 --- /dev/null +++ b/API/app/domain/entities/project.py @@ -0,0 +1,9 @@ +from typing import Optional + +from pydantic import BaseModel + + +class ProjectEntity(BaseModel): + id: Optional[int] = None + description: str + repository_url: Optional[str] = None \ No newline at end of file diff --git a/API/app/domain/models/profile_photos.py b/API/app/domain/models/profile_photos.py index da10867..831ae1b 100644 --- a/API/app/domain/models/profile_photos.py +++ b/API/app/domain/models/profile_photos.py @@ -7,6 +7,7 @@ from app.domain.models.base import AdvancedBaseModel class ProfilePhoto(AdvancedBaseModel): __tablename__ = 'profile_photos' + filename = Column(String, nullable=False) file_path = Column(String, nullable=False) profile_id = Column(Integer, ForeignKey('profiles.id'), nullable=False) diff --git a/API/app/infrastructure/profile_photos_service.py b/API/app/infrastructure/profile_photos_service.py new file mode 100644 index 0000000..d3bcf83 --- /dev/null +++ b/API/app/infrastructure/profile_photos_service.py @@ -0,0 +1,126 @@ +import os +import uuid + +import aiofiles +import magic + +from fastapi import HTTPException, UploadFile, status +from sqlalchemy.ext.asyncio import AsyncSession +from starlette.responses import FileResponse +from werkzeug.utils import secure_filename + +from app.application.profile_photos_repository import ProfilePhotosRepository +from app.application.users_repository import UsersRepository +from app.domain.entities.profile_photo import ProfilePhotoEntity +from app.domain.models import ProfilePhoto, User + + +class ProfilePhotosService: + def __init__(self, db: AsyncSession): + self.profile_photos_repository = ProfilePhotosRepository(db) + self.users_repository = UsersRepository(db) + + async def get_photo_file_by_id(self, photo_id: int) -> FileResponse: + photo = await self.profile_photos_repository.get_by_id(photo_id) + + if not photo: + raise HTTPException(404, "Photo not found") + + if not os.path.exists(photo.file_path): + raise HTTPException(404, "File not found on disk") + + return FileResponse( + photo.file_path, + media_type=self.get_media_type(photo.filename), + filename=photo.filename, + ) + + async def get_photo_file_by_profile_id(self, profile_id: int) -> list[ProfilePhotoEntity]: + photos = await self.profile_photos_repository.get_by_profile_id(profile_id) + return [ + self.model_to_entity(photo) + for photo in photos + ] + + async def upload_photo(self, profile_id: int, file: UploadFile, user: User): + user = await self.users_repository.get_by_id(user.id) + + if profile_id != user.profile_id and user.profile.role.title != 'Администратор': + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="Permission denied", + ) + + self.validate_file_type(file) + + photo = ProfilePhoto( + filename=self.generate_filename(file), + file_path=await self.save_file(file), + profile_id=profile_id + ) + + return self.model_to_entity( + await self.profile_photos_repository.create(photo) + ) + + async def delete_photo(self, photo_id: int, user: User) -> ProfilePhotoEntity: + user = await self.users_repository.get_by_id(user.id) + if user is None: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="The user with this ID was not found", + ) + + photo = await self.profile_photos_repository.get_by_id(photo_id) + if not photo: + raise HTTPException(404, "Photo not found") + + if photo.profile_id != user.profile_id and user.profile.role.title != 'Администратор': + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="Permission denied", + ) + + if os.path.exists(photo.file_path): + os.remove(photo.file_path) + + return self.model_to_entity( + await self.profile_photos_repository.delete(photo) + ) + + async def save_file(self, file: UploadFile, upload_dir: str = "uploads/profile_photos") -> str: + os.makedirs(upload_dir, exist_ok=True) + filename = self.generate_filename(file) + file_path = os.path.join(upload_dir, filename) + + async with aiofiles.open(file_path, 'wb') as out_file: + content = await file.read() + await out_file.write(content) + return file_path + + @staticmethod + def validate_file_type(file: UploadFile): + mime = magic.Magic(mime=True) + file_type = mime.from_buffer(file.file.read(1024)) + file.file.seek(0) + + if file_type not in ["image/jpeg", "image/png"]: + raise HTTPException(400, "Invalid file type") + + @staticmethod + def generate_filename(file: UploadFile): + return secure_filename(f"{uuid.uuid4()}_{file.filename}") + + @staticmethod + def model_to_entity(profile_photo_model: ProfilePhoto) -> ProfilePhotoEntity: + return ProfilePhotoEntity( + id=profile_photo_model.id, + filename=profile_photo_model.filename, + file_path=profile_photo_model.file_path, + profile_id=profile_photo_model.profile_id, + ) + + @staticmethod + def get_media_type(filename: str) -> str: + extension = filename.split('.')[-1].lower() + return f"image/{extension}" if extension in ['jpeg', 'jpg', 'png'] else "application/octet-stream" diff --git a/API/app/infrastructure/profiles_service.py b/API/app/infrastructure/profiles_service.py index 71422d9..8a1e695 100644 --- a/API/app/infrastructure/profiles_service.py +++ b/API/app/infrastructure/profiles_service.py @@ -23,14 +23,14 @@ class ProfilesService: if team is None: raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, - detail="The team with this ID was not found", + detail='The team with this ID was not found', ) role = await self.roles_repository.get_by_id(profile.role_id) if role is None: raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, - detail="The role with this ID was not found", + detail='The role with this ID was not found', ) profile_model = self.entity_to_model(profile) @@ -46,28 +46,28 @@ class ProfilesService: if user is None: raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, - detail="The user with this ID was not found", + detail='The user with this ID was not found', ) profile_model = await self.profiles_repository.get_by_id(profile_id) if profile_model.id != user.profile_id and user.profile.role.title != 'Администратор': raise HTTPException( status_code=status.HTTP_403_FORBIDDEN, - detail="Permission denied", + detail='Permission denied', ) team = await self.teams_repository.get_by_id(profile.team_id) if team is None: raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, - detail="The team with this ID was not found", + detail='The team with this ID was not found', ) role = await self.roles_repository.get_by_id(profile.role_id) if role is None: raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, - detail="The role with this ID was not found", + detail='The role with this ID was not found', ) profile_model.first_name = profile.first_name @@ -88,14 +88,14 @@ class ProfilesService: if user is None: raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, - detail="The user with this ID was not found", + detail='The user with this ID was not found', ) profile_model = await self.profiles_repository.get_by_id(profile_id) if profile_model.id != user.profile_id and user.profile.role.title != 'Администратор': raise HTTPException( status_code=status.HTTP_403_FORBIDDEN, - detail="Permission denied", + detail='Permission denied', ) result = await self.profiles_repository.delete(profile_model) diff --git a/API/app/infrastructure/projects_service.py b/API/app/infrastructure/projects_service.py new file mode 100644 index 0000000..b1e71ff --- /dev/null +++ b/API/app/infrastructure/projects_service.py @@ -0,0 +1,6 @@ +from sqlalchemy.ext.asyncio import AsyncSession + + +class ProjectsRepository: + def __init__(self, db: AsyncSession): + self.projects_repository = ProjectsRepository(db) diff --git a/API/app/infrastructure/users_service.py b/API/app/infrastructure/users_service.py index 9ec57ec..e4fd9bf 100644 --- a/API/app/infrastructure/users_service.py +++ b/API/app/infrastructure/users_service.py @@ -52,7 +52,7 @@ class UsersService: return user_entity - async def change_user_password(self, user_id: int, new_password: str) -> Optional[UserEntity]: + async def change_user_password(self, user_id: int, new_password: str, user: User) -> Optional[UserEntity]: user_model = await self.users_repository.get_by_id(user_id) if user_model is None: @@ -61,6 +61,12 @@ class UsersService: detail="The user with this ID was not found", ) + if user_model.id != user_id and user.profile.role.title != 'Администратор': + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="Permission denied", + ) + if not self.is_strong_password(new_password): raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, diff --git a/API/app/main.py b/API/app/main.py index 714157a..808a764 100644 --- a/API/app/main.py +++ b/API/app/main.py @@ -2,6 +2,7 @@ from fastapi import FastAPI from fastapi.middleware.cors import CORSMiddleware from app.contollers.auth_router import router as auth_router +from app.contollers.profile_photos_router import router as profile_photos_router from app.contollers.profiles_router import router as profiles_router from app.contollers.register_router import router as register_router from app.contollers.teams_router import router as team_router @@ -21,6 +22,7 @@ def start_app(): ) api_app.include_router(auth_router, prefix=f'{settings.PREFIX}/auth', tags=['auth']) + api_app.include_router(profile_photos_router, prefix=f'{settings.PREFIX}/profile_photos', tags=['profile_photos_router']) api_app.include_router(profiles_router, prefix=f'{settings.PREFIX}/profiles', tags=['profiles']) api_app.include_router(register_router, prefix=f'{settings.PREFIX}/register', tags=['register']) api_app.include_router(team_router, prefix=f'{settings.PREFIX}/teams', tags=['teams']) diff --git a/API/req.txt b/API/req.txt index 05da4ad..c02bb8f 100644 Binary files a/API/req.txt and b/API/req.txt differ