diff --git a/API/app/application/contest_carousel_photos_repository.py b/API/app/application/contest_carousel_photos_repository.py new file mode 100644 index 0000000..d7a18d1 --- /dev/null +++ b/API/app/application/contest_carousel_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 ContestCarouselPhoto + + +class ContestCarouselPhotosRepository: + def __init__(self, db: AsyncSession): + self.db = db + + async def get_by_id(self, photo_id: int) -> Optional[ContestCarouselPhoto]: + stmt = select(ContestCarouselPhoto).filter_by(id=photo_id) + result = await self.db.execute(stmt) + return result.scalars().first() + + async def get_by_contest_id(self, contest_id: int) -> Sequence[ContestCarouselPhoto]: + stmt = select(ContestCarouselPhoto).filter_by(contest_id=contest_id) + result = await self.db.execute(stmt) + return result.scalars().all() + + async def create(self, photo: ContestCarouselPhoto) -> ContestCarouselPhoto: + self.db.add(photo) + await self.db.commit() + await self.db.refresh(photo) + return photo + + async def delete(self, photo: ContestCarouselPhoto) -> ContestCarouselPhoto: + await self.db.delete(photo) + await self.db.commit() + return photo diff --git a/API/app/contollers/contest_carousel_photos_router.py b/API/app/contollers/contest_carousel_photos_router.py new file mode 100644 index 0000000..0935325 --- /dev/null +++ b/API/app/contollers/contest_carousel_photos_router.py @@ -0,0 +1,67 @@ +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.contest_carousel_photo import ContestCarouselPhotoEntity +from app.infrastructure.contest_carousel_photos_service import ContestCarouselPhotosService + +router = APIRouter() + +@router.get( + "/contests/{contest_id}/", + response_model=list[ContestCarouselPhotoEntity], + summary="Get all carousel photos metadata for a contest", + description="Returns metadata of all carousel photos for the given contest_id", +) +async def get_photos_by_contest_id( + contest_id: int, + db: AsyncSession = Depends(get_db), +): + service = ContestCarouselPhotosService(db) + return await service.get_photos_by_contest_id(contest_id) + + +@router.get( + "/{photo_id}/file", + response_class=FileResponse, + summary="Download carousel photo file by photo ID", + description="Returns the image file for the given carousel photo ID", +) +async def download_photo_file( + photo_id: int, + db: AsyncSession = Depends(get_db), +): + service = ContestCarouselPhotosService(db) + return await service.get_photo_file_by_id(photo_id) + + +@router.post( + "/contests/{contest_id}/upload", + response_model=ContestCarouselPhotoEntity, + summary="Upload a new carousel photo for a contest", + description="Uploads a new photo file and associates it with the given contest ID", +) +async def upload_photo( + contest_id: int, + file: UploadFile = File(...), + db: AsyncSession = Depends(get_db), +): + service = ContestCarouselPhotosService(db) + return await service.upload_photo(contest_id, file) + + +@router.delete( + "/{photo_id}/", + response_model=ContestCarouselPhotoEntity, + summary="Delete a carousel photo by ID", + description="Deletes a carousel photo and its file from storage", +) +async def delete_photo( + photo_id: int, + db: AsyncSession = Depends(get_db), +): + + service = ContestCarouselPhotosService(db) + + return await service.delete_photo(photo_id) diff --git a/API/app/domain/entities/contest_carousel_photo.py b/API/app/domain/entities/contest_carousel_photo.py new file mode 100644 index 0000000..4f4803e --- /dev/null +++ b/API/app/domain/entities/contest_carousel_photo.py @@ -0,0 +1,9 @@ +from pydantic import BaseModel +from typing import Optional + + +class ContestCarouselPhotoEntity(BaseModel): + id: Optional[int] = None + file_path: str + number: int + contest_id: int \ No newline at end of file diff --git a/API/app/infrastructure/contest_carousel_photos_service.py b/API/app/infrastructure/contest_carousel_photos_service.py new file mode 100644 index 0000000..d073f43 --- /dev/null +++ b/API/app/infrastructure/contest_carousel_photos_service.py @@ -0,0 +1,112 @@ +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.contest_carousel_photos_repository import ContestCarouselPhotosRepository +from app.domain.entities.contest_carousel_photo import ContestCarouselPhotoEntity +from app.domain.models import ContestCarouselPhoto + + +class ContestCarouselPhotosService: + def __init__(self, db: AsyncSession): + self.contest_carousel_photos_repository = ContestCarouselPhotosRepository(db) + + async def get_photo_file_by_id(self, photo_id: int) -> FileResponse: + photo = await self.contest_carousel_photos_repository.get_by_id(photo_id) + + if not photo: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Photo not found") + + if not os.path.exists(photo.file_path): + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="File not found on disk") + + return FileResponse( + photo.file_path, + media_type=self.get_media_type(photo.file_path), # Use file_path to infer type + filename=os.path.basename(photo.file_path), # Extract filename from path + ) + + async def get_photos_by_contest_id(self, contest_id: int) -> list[ContestCarouselPhotoEntity]: + photos = await self.contest_carousel_photos_repository.get_by_contest_id(contest_id) + return [ + self.model_to_entity(photo) + for photo in photos + ] + + async def upload_photo(self, contest_id: int, file: UploadFile): # Removed 'user: User' for simplicity, add if needed + self.validate_file_type(file) + + filename = self.generate_filename(file) + file_path = await self.save_file(file, filename=filename) + + photo = ContestCarouselPhoto( + file_path=file_path, + number=0, + contest_id=contest_id + ) + + return self.model_to_entity( + await self.contest_carousel_photos_repository.create(photo) + ) + + async def delete_photo(self, photo_id: int): + photo = await self.contest_carousel_photos_repository.get_by_id(photo_id) + if not photo: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Photo not found") + + if os.path.exists(photo.file_path): + os.remove(photo.file_path) + + return self.model_to_entity( + await self.contest_carousel_photos_repository.delete(photo) + ) + + async def save_file(self, file: UploadFile, filename: str, upload_dir: str = "uploads/contest_carousel_photos") -> str: + os.makedirs(upload_dir, exist_ok=True) + 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): + contents = file.file.read(1024) + file.file.seek(0) + mime = magic.Magic(mime=True) + file_type = mime.from_buffer(contents) + + if file_type not in ["image/jpeg", "image/png"]: + raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Invalid file type. Only JPEG and PNG images are allowed.") + + @staticmethod + def generate_filename(file: UploadFile): + original_filename = secure_filename(file.filename) + return f"{uuid.uuid4()}_{original_filename}" + + @staticmethod + def model_to_entity(photo_model: ContestCarouselPhoto) -> ContestCarouselPhotoEntity: + return ContestCarouselPhotoEntity( + id=photo_model.id, + file_path=photo_model.file_path, + number=photo_model.number, + contest_id=photo_model.contest_id, + ) + + @staticmethod + def get_media_type(file_path: str) -> str: + extension = file_path.split('.')[-1].lower() + if extension in ['jpeg', 'jpg']: + return "image/jpeg" + elif extension == 'png': + return "image/png" + else: + return "application/octet-stream" diff --git a/API/app/main.py b/API/app/main.py index f60e89b..233018e 100644 --- a/API/app/main.py +++ b/API/app/main.py @@ -12,6 +12,7 @@ from app.contollers.rss_router import router as rss_router from app.contollers.teams_router import router as team_router from app.contollers.users_router import router as users_router from app.contollers.contests_router import router as contest_router +from app.contollers.contests_router import router as contest_carousel_photos_router from app.settings import settings @@ -30,13 +31,16 @@ def start_app(): api_app.include_router(profile_photos_router, prefix=f'{settings.PREFIX}/profile_photos', tags=['profile_photos']) api_app.include_router(profiles_router, prefix=f'{settings.PREFIX}/profiles', tags=['profiles']) api_app.include_router(project_files_router, prefix=f'{settings.PREFIX}/project_files', tags=['project_files']) - api_app.include_router(project_members_router, prefix=f'{settings.PREFIX}/project_members', tags=['project_members']) + api_app.include_router(project_members_router, prefix=f'{settings.PREFIX}/project_members', + tags=['project_members']) api_app.include_router(projects_router, prefix=f'{settings.PREFIX}/projects', tags=['projects']) api_app.include_router(register_router, prefix=f'{settings.PREFIX}/register', tags=['register']) api_app.include_router(rss_router, prefix=f'{settings.PREFIX}/rss', tags=['rss_router']) api_app.include_router(team_router, prefix=f'{settings.PREFIX}/teams', tags=['teams']) api_app.include_router(users_router, prefix=f'{settings.PREFIX}/users', tags=['users']) - api_app.include_router(contest_router,prefix=f'{settings.PREFIX}/contests', tags=['contests']) + api_app.include_router(contest_router, prefix=f'{settings.PREFIX}/contests', tags=['contests']) + api_app.include_router(contest_carousel_photos_router, prefix=f'{settings.PREFIX}/contest_carousel_photos', + tags=['contest_carousel_photos']) return api_app diff --git a/WEB/src/api/profiles/profile_photos/deletePhoto.js b/WEB/src/api/profiles/profile_photos/deletePhoto.js new file mode 100644 index 0000000..1f64ce2 --- /dev/null +++ b/WEB/src/api/profiles/profile_photos/deletePhoto.js @@ -0,0 +1,27 @@ +import axios from 'axios' +import CONFIG from '@/core/config.js' + + +const deletePhoto = async (photoId) => { + try { + const token = localStorage.getItem('access_token') + + const response = await axios.delete( + `${CONFIG.BASE_URL}/profile_photos/${photoId}/`, + { + withCredentials: true, + headers: { + Authorization: `Bearer ${token}` + } + } + ) + + return response.data + } catch (error) { + const errorMessage = error.response?.data?.detail || error.message + console.error(`Ошибка удаления фотографии с ID ${photoId}:`, errorMessage) + throw new Error(`Не удалось удалить фотографию: ${errorMessage}`) + } +} + +export default deletePhoto \ No newline at end of file diff --git a/WEB/src/api/profiles/profile_photos/downloadPhotoFile.js b/WEB/src/api/profiles/profile_photos/downloadPhotoFile.js new file mode 100644 index 0000000..c06b06e --- /dev/null +++ b/WEB/src/api/profiles/profile_photos/downloadPhotoFile.js @@ -0,0 +1,28 @@ +import axios from 'axios' +import CONFIG from '@/core/config.js' + + +const downloadPhotoFile = async (photoId) => { + try { + const token = localStorage.getItem('access_token') + + const response = await axios.get( + `${CONFIG.BASE_URL}/profile_photos/${photoId}/file`, + { + withCredentials: true, + headers: { + Authorization: `Bearer ${token}` + }, + responseType: 'blob' + } + ) + + return response.data + } catch (error) { + const errorMessage = error.response?.data?.detail || error.message + console.error(`Ошибка загрузки файла фотографии с ID ${photoId}:`, errorMessage) + throw new Error(`Не удалось загрузить файл фотографии: ${errorMessage}`) + } +} + +export default downloadPhotoFile \ No newline at end of file diff --git a/WEB/src/api/profiles/profile_photos/getPhotoFileById.js b/WEB/src/api/profiles/profile_photos/getPhotoFileById.js new file mode 100644 index 0000000..10b9657 --- /dev/null +++ b/WEB/src/api/profiles/profile_photos/getPhotoFileById.js @@ -0,0 +1,28 @@ +import axios from 'axios' +import CONFIG from '@/core/config.js' + + +const getPhotosByProfileId = async (profileId) => { + try { + const token = localStorage.getItem('access_token') + + const response = await axios.get( + `${CONFIG.BASE_URL}/profile_photos/profiles/${profileId}/`, + { + withCredentials: true, + headers: { + Authorization: `Bearer ${token}` + } + } + ) + + return response.data + } catch (error) { + // Улучшенная обработка ошибок для ясности + const errorMessage = error.response?.data?.detail || error.message + console.error(`Ошибка получения фотографий для профиля ${profileId}:`, errorMessage) + throw new Error(`Не удалось загрузить фотографии: ${errorMessage}`) + } +} + +export default getPhotosByProfileId \ No newline at end of file diff --git a/WEB/src/api/profiles/profile_photos/uploadProfilePhoto.js b/WEB/src/api/profiles/profile_photos/uploadProfilePhoto.js new file mode 100644 index 0000000..c07ec4d --- /dev/null +++ b/WEB/src/api/profiles/profile_photos/uploadProfilePhoto.js @@ -0,0 +1,31 @@ +import axios from 'axios' +import CONFIG from '@/core/config.js' + + +const uploadProfilePhoto = async (profileId, file) => { + try { + const token = localStorage.getItem('access_token') + + const formData = new FormData() + formData.append('file', file) + + const response = await axios.post( + `${CONFIG.BASE_URL}/profile_photos/profiles/${profileId}/upload`, + formData, + { + withCredentials: true, + headers: { + Authorization: `Bearer ${token}`, + } + } + ) + + return response.data + } catch (error) { + const errorMessage = error.response?.data?.detail || error.message + console.error(`Ошибка загрузки фотографии для профиля ${profileId}:`, errorMessage) + throw new Error(`Не удалось загрузить фотографию: ${errorMessage}`) + } +} + +export default uploadProfilePhoto \ No newline at end of file diff --git a/WEB/src/main.js b/WEB/src/main.js index af8923c..ab77447 100644 --- a/WEB/src/main.js +++ b/WEB/src/main.js @@ -39,6 +39,8 @@ import { QItemSection, QItemLabel, QItem, + QImg, + QFile } from 'quasar' @@ -58,7 +60,7 @@ app.use(Quasar, { QSeparator, QCardActions, QDialog, QIcon, QSpace, QAvatar, QTooltip, QBanner, QSlideTransition, QToggle, QList, QSpinnerDots, QCarouselSlide, QCarousel, - QItemSection, QItemLabel, QItem + QItemSection, QItemLabel, QItem, QImg, QFile }, directives: { Ripple diff --git a/WEB/src/pages/AdminPage.vue b/WEB/src/pages/AdminPage.vue index 95cc77a..48b00e2 100644 --- a/WEB/src/pages/AdminPage.vue +++ b/WEB/src/pages/AdminPage.vue @@ -21,7 +21,6 @@ -
@@ -44,7 +43,6 @@
-
@@ -68,7 +66,6 @@ -
@@ -91,7 +88,6 @@
-
@@ -117,7 +113,6 @@ - @@ -143,7 +138,6 @@ - - - -