сделал редактирование файлов у конкурсов и проектов

This commit is contained in:
Мельников Данил 2025-06-03 11:18:15 +05:00
parent 03338413c1
commit 56725b681b
24 changed files with 1049 additions and 41 deletions

View File

@ -0,0 +1,32 @@
from typing import Optional, Sequence
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.domain.models import ContestFile
class ContestFilesRepository:
def __init__(self, db: AsyncSession):
self.db = db
async def get_by_id(self, file_id: int) -> Optional[ContestFile]:
stmt = select(ContestFile).filter_by(id=file_id)
result = await self.db.execute(stmt)
return result.scalars().first()
async def get_by_contest_id(self, contest_id: int) -> Sequence[ContestFile]:
stmt = select(ContestFile).filter_by(contest_id=contest_id)
result = await self.db.execute(stmt)
return result.scalars().all()
async def create(self, contest_file: ContestFile) -> ContestFile:
self.db.add(contest_file)
await self.db.commit()
await self.db.refresh(contest_file)
return contest_file
async def delete(self, contest_file: ContestFile) -> ContestFile:
await self.db.delete(contest_file)
await self.db.commit()
return contest_file

View File

@ -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.contest_file import ContestFileEntity
from app.infrastructure.dependencies import require_admin
from app.infrastructure.contest_files_service import ContestFilesService
router = APIRouter()
@router.get(
"/contests/{contest_id}/",
response_model=list[ContestFileEntity],
summary="Get all contest files",
description="Returns metadata of all files uploaded for the specified contest."
)
async def get_files_by_contest_id(
contest_id: int,
db: AsyncSession = Depends(get_db),
):
service = ContestFilesService(db)
return await service.get_files_by_contest_id(contest_id)
@router.get(
"/{file_id}/file",
response_class=FileResponse,
summary="Download contest file by ID",
description="Returns the file for the specified file ID."
)
async def download_contest_file(
file_id: int,
db: AsyncSession = Depends(get_db),
):
service = ContestFilesService(db)
return await service.get_file_by_id(file_id)
@router.post(
"/contests/{contest_id}/upload",
response_model=ContestFileEntity,
summary="Upload a new file for the contest",
description="Uploads a new file and associates it with the specified contest."
)
async def upload_contest_file(
contest_id: int,
file: UploadFile = File(...),
db: AsyncSession = Depends(get_db),
user=Depends(require_admin),
):
service = ContestFilesService(db)
return await service.upload_file(contest_id, file, user)
@router.delete(
"/{file_id}/",
response_model=ContestFileEntity,
summary="Delete a contest file by ID",
description="Deletes the file and its database entry."
)
async def delete_contest_file(
file_id: int,
db: AsyncSession = Depends(get_db),
user=Depends(require_admin),
):
service = ContestFilesService(db)
return await service.delete_file(file_id, user)

View File

@ -4,7 +4,7 @@ from starlette.responses import FileResponse
from app.database.session import get_db
from app.domain.entities.project_file import ProjectFileEntity
from app.infrastructure.dependencies import get_current_user, require_admin
from app.infrastructure.dependencies import require_admin
from app.infrastructure.project_files_service import ProjectFilesService
router = APIRouter()

View File

@ -0,0 +1,32 @@
"""0005_добавил_поле_filename_уайлов_конкурсов
Revision ID: de2777da99c9
Revises: e53896c51cf8
Create Date: 2025-06-03 10:23:08.013605
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = 'de2777da99c9'
down_revision: Union[str, None] = 'e53896c51cf8'
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('contest_files', 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('contest_files', 'filename')
# ### end Alembic commands ###

View File

@ -0,0 +1,10 @@
from typing import Optional
from pydantic import BaseModel
class ContestFileEntity(BaseModel):
id: Optional[int] = None
filename: str
file_path: str
contest_id: int

View File

@ -1,8 +1,10 @@
from typing import Optional
from pydantic import BaseModel
class ProfilePhotoEntity(BaseModel):
id: int
id: Optional[int] = None
filename: str
file_path: str
profile_id: int

View File

@ -1,8 +1,10 @@
from typing import Optional
from pydantic import BaseModel
class ProjectFileEntity(BaseModel):
id: int
id: Optional[int] = None
filename: str
file_path: str
project_id: int

View File

@ -7,6 +7,7 @@ from app.domain.models.base import AdvancedBaseModel
class ContestFile(AdvancedBaseModel):
__tablename__ = 'contest_files'
filename = Column(String, nullable=False)
file_path = Column(String, nullable=False)
contest_id = Column(Integer, ForeignKey('contests.id'), nullable=False)

View File

@ -29,8 +29,8 @@ class ContestCarouselPhotosService:
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
media_type=self.get_media_type(photo.file_path),
filename=os.path.basename(photo.file_path),
)
async def get_photos_by_contest_id(self, contest_id: int) -> list[ContestCarouselPhotoEntity]:
@ -40,7 +40,7 @@ class ContestCarouselPhotosService:
for photo in photos
]
async def upload_photo(self, contest_id: int, file: UploadFile): # Removed 'user: User' for simplicity, add if needed
async def upload_photo(self, contest_id: int, file: UploadFile):
self.validate_file_type(file)
filename = self.generate_filename(file)
@ -89,8 +89,7 @@ class ContestCarouselPhotosService:
@staticmethod
def generate_filename(file: UploadFile):
original_filename = secure_filename(file.filename)
return f"{uuid.uuid4()}_{original_filename}"
return secure_filename(f"{uuid.uuid4()}_{file.filename}")
@staticmethod
def model_to_entity(photo_model: ContestCarouselPhoto) -> ContestCarouselPhotoEntity:

View File

@ -0,0 +1,134 @@
import os
import uuid
import aiofiles
import magic
from fastapi import HTTPException, UploadFile
from sqlalchemy.ext.asyncio import AsyncSession
from starlette.responses import FileResponse
from werkzeug.utils import secure_filename
from app.application.contest_files_repository import ContestFilesRepository
from app.application.contests_repository import ContestsRepository
from app.domain.entities.contest_file import ContestFileEntity
from app.domain.models import ContestFile, User
class ContestFilesService:
def __init__(self, db: AsyncSession):
self.contest_files_repository = ContestFilesRepository(db)
self.contests_repository = ContestsRepository(db)
async def get_file_by_id(self, file_id: int) -> FileResponse:
contest_file = await self.contest_files_repository.get_by_id(file_id)
if not contest_file:
raise HTTPException(404, "Файл не найден")
if not os.path.exists(contest_file.file_path):
raise HTTPException(404, "Файл не найден на диске")
return FileResponse(
contest_file.file_path,
media_type=self.get_media_type(contest_file.file_path),
filename=os.path.basename(contest_file.file_path),
)
async def get_files_by_contest_id(self, contest_id: int) -> list[ContestFileEntity]:
files = await self.contest_files_repository.get_by_contest_id(contest_id)
return [self.model_to_entity(file) for file in files]
async def upload_file(self, contest_id: int, file: UploadFile, user: User) -> ContestFileEntity:
contest = await self.contests_repository.get_by_id(contest_id)
if not contest:
raise HTTPException(404, "Конкурс не найден")
self.validate_file_type(file)
file_path = await self.save_file(file)
contest_file = ContestFile(
filename=file.filename,
file_path=file_path,
contest_id=contest_id,
)
return self.model_to_entity(
await self.contest_files_repository.create(contest_file)
)
async def delete_file(self, file_id: int, user: User) -> ContestFileEntity:
contest_file = await self.contest_files_repository.get_by_id(file_id)
if not contest_file:
raise HTTPException(404, "Файл не найден")
if os.path.exists(contest_file.file_path):
os.remove(contest_file.file_path)
return self.model_to_entity(
await self.contest_files_repository.delete(contest_file)
)
async def save_file(self, file: UploadFile, upload_dir: str = "uploads/contest_files") -> 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)
allowed_types = [
"application/pdf",
"image/jpeg",
"image/png",
"application/zip",
"application/msword",
"application/vnd.openxmlformats-officedocument.wordprocessingml.document",
"application/vnd.ms-excel",
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
"application/vnd.ms-powerpoint",
"application/vnd.openxmlformats-officedocument.presentationml.presentation",
"text/plain",
]
if file_type not in allowed_types:
raise HTTPException(400, f"Недопустимый тип файла: {file_type}")
@staticmethod
def generate_filename(file: UploadFile) -> str:
return secure_filename(f"{uuid.uuid4()}_{file.filename}")
@staticmethod
def model_to_entity(contest_file_model: ContestFile) -> ContestFileEntity:
return ContestFileEntity(
id=contest_file_model.id,
filename=contest_file_model.filename,
file_path=contest_file_model.file_path,
contest_id=contest_file_model.contest_id,
)
@staticmethod
def get_media_type(filename: str) -> str:
extension = filename.split('.')[-1].lower()
if extension in ['jpeg', 'jpg', 'png']:
return f"image/{extension}"
if extension == 'pdf':
return "application/pdf"
if extension in ['zip']:
return "application/zip"
if extension in ['doc', 'docx']:
return "application/msword"
if extension in ['xls', 'xlsx']:
return "application/vnd.ms-excel"
if extension in ['ppt', 'pptx']:
return "application/vnd.ms-powerpoint"
if extension in ['txt']:
return "text/plain"
return "application/octet-stream"

View File

@ -13,6 +13,7 @@ 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.contest_carousel_photos_router import router as contest_carousel_photos_router
from app.contollers.contest_files_router import router as contest_files_router
from app.settings import settings
@ -41,6 +42,7 @@ def start_app():
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'])
api_app.include_router(contest_files_router, prefix=f'{settings.PREFIX}/contest_files', tags=['contest_files'])
return api_app

View File

@ -0,0 +1,27 @@
import axios from 'axios'
import CONFIG from '@/core/config.js'
const deleteContestCarouselPhoto = async (photoId) => {
try {
const token = localStorage.getItem('access_token')
const response = await axios.delete(
`${CONFIG.BASE_URL}/contest_carousel_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 deleteContestCarouselPhoto

View File

@ -0,0 +1,28 @@
import axios from 'axios'
import CONFIG from '@/core/config.js'
const downloadContestCarouselPhotoFile = async (photoId) => {
try {
const token = localStorage.getItem('access_token')
const response = await axios.get(
`${CONFIG.BASE_URL}/contest_carousel_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 downloadContestCarouselPhotoFile

View File

@ -0,0 +1,27 @@
import axios from 'axios'
import CONFIG from '@/core/config.js'
const getContestCarouselPhotosByContestId = async (contestId) => {
try {
const token = localStorage.getItem('access_token')
const response = await axios.get(
`${CONFIG.BASE_URL}/contest_carousel_photos/contests/${contestId}/`,
{
withCredentials: true,
headers: {
Authorization: `Bearer ${token}`
}
}
)
return response.data
} catch (error) {
const errorMessage = error.response?.data?.detail || error.message
console.error(`Ошибка получения фотографий карусели конкурса для конкурса ${contestId}:`, errorMessage)
throw new Error(`Не удалось загрузить фотографии карусели конкурса: ${errorMessage}`)
}
}
export default getContestCarouselPhotosByContestId

View File

@ -0,0 +1,33 @@
import axios from 'axios'
import CONFIG from '@/core/config.js'
const uploadContestCarouselPhoto = async (contestId, file) => {
try {
const token = localStorage.getItem('access_token')
const formData = new FormData()
formData.append('file', file)
formData.append('contest_id', contestId);
const response = await axios.post(
`${CONFIG.BASE_URL}/contest_carousel_photos/contests/${contestId}/upload`,
formData,
{
withCredentials: true,
headers: {
Authorization: `Bearer ${token}`,
}
}
)
return response.data
} catch (error) {
const errorMessage = error.response?.data?.detail || error.message
console.error(`Ошибка загрузки фотографии в карусель конкурса с ID ${contestId}:`, errorMessage)
throw new Error(`Не удалось загрузить фотографию в карусель конкурса: ${errorMessage}`)
}
}
export default uploadContestCarouselPhoto

View File

@ -0,0 +1,31 @@
import axios from "axios";
import CONFIG from "@/core/config.js";
const deleteContestFile = async (fileId) => {
try {
const token = localStorage.getItem('access_token'); // Получаем токен из localStorage
const response = await axios.delete(
`${CONFIG.BASE_URL}/contest_files/${fileId}/`,
{
withCredentials: true,
headers: {
Authorization: `Bearer ${token}`,
},
}
);
return response.data;
} catch (error) {
const errorMessage = error.response?.data?.detail || error.message;
console.error(`Ошибка удаления файла конкурса с ID ${fileId}:`, errorMessage);
if (error.response?.status === 401) {
throw new Error("Недостаточно прав для удаления файла (401)");
}
if (error.response?.status === 404) {
throw new Error("Файл не найден (404)");
}
throw new Error(errorMessage);
}
};
export default deleteContestFile;

View File

@ -0,0 +1,39 @@
import axios from "axios";
import CONFIG from "@/core/config.js";
const downloadContestFile = async (fileId) => {
try {
const response = await axios.get(`${CONFIG.BASE_URL}/contest_files/${fileId}/file`, {
responseType: 'blob',
withCredentials: true,
});
const url = window.URL.createObjectURL(new Blob([response.data]));
const link = document.createElement('a');
link.href = url;
const contentDisposition = response.headers['content-disposition'];
let filename = 'download';
if (contentDisposition) {
const filenameMatch = contentDisposition.match(/filename="(.+)"/);
if (filenameMatch && filenameMatch[1]) {
filename = filenameMatch[1];
}
}
link.setAttribute('download', filename);
document.body.appendChild(link);
link.click();
link.remove();
window.URL.revokeObjectURL(url);
return filename;
} catch (error) {
if (error.response?.status === 401) {
throw new Error("Нет доступа для скачивания файла (401)");
}
if (error.response?.status === 404) {
throw new Error("Файл не найден (404)");
}
throw new Error(error.message);
}
};
export default downloadContestFile;

View File

@ -0,0 +1,21 @@
import axios from "axios";
import CONFIG from "@/core/config.js";
const getContestFiles = async (contestId) => {
try {
const response = await axios.get(`${CONFIG.BASE_URL}/contest_files/contests/${contestId}/`, {
withCredentials: true,
});
return response.data;
} catch (error) {
if (error.response?.status === 401) {
throw new Error("Нет доступа к файлам конкурса (401)");
}
if (error.response?.status === 404) {
throw new Error("Конкурс не найден (404)");
}
throw new Error(error.message);
}
};
export default getContestFiles;

View File

@ -0,0 +1,38 @@
import axios from "axios";
import CONFIG from "@/core/config.js";
const uploadContestFile = async (contestId, file) => {
try {
const token = localStorage.getItem('access_token');
const formData = new FormData();
formData.append("file", file);
const response = await axios.post(
`${CONFIG.BASE_URL}/contest_files/contests/${contestId}/upload`,
formData,
{
headers: {
"Content-Type": "multipart/form-data",
Authorization: `Bearer ${token}`,
},
withCredentials: true,
}
);
return response.data;
} catch (error) {
const errorMessage = error.response?.data?.detail || error.message;
if (error.response?.status === 401) {
throw new Error("Недостаточно прав для загрузки файла (401)");
}
if (error.response?.status === 400) {
throw new Error(`Ошибка загрузки: ${errorMessage}`);
}
if (error.response?.status === 404) {
throw new Error("Конкурс не найден (404)");
}
throw new Error(errorMessage);
}
};
export default uploadContestFile;

View File

@ -0,0 +1,30 @@
import axios from 'axios';
import CONFIG from '@/core/config.js';
const deleteProjectFile = async (fileId) => {
try {
const token = localStorage.getItem('access_token');
const response = await axios.delete(
`${CONFIG.BASE_URL}/project_files/${fileId}/`,
{
headers: {
Authorization: `Bearer ${token}`,
},
withCredentials: true,
}
);
return response.data;
} catch (error) {
const errorMessage = error.response?.data?.detail || error.message;
console.error(`Ошибка удаления файла проекта с ID ${fileId}:`, errorMessage);
if (error.response?.status === 401) {
throw new Error("Недостаточно прав для удаления файла (401)");
}
if (error.response?.status === 404) {
throw new Error("Файл не найден (404)");
}
throw new Error(errorMessage);
}
};
export default deleteProjectFile;

View File

@ -0,0 +1,51 @@
import axios from 'axios';
import CONFIG from '@/core/config.js';
const downloadProjectFile = async (fileId) => {
try {
const token = localStorage.getItem('access_token');
const response = await axios.get(
`${CONFIG.BASE_URL}/project_files/${fileId}/download`,
{
headers: {
Authorization: `Bearer ${token}`,
},
responseType: 'blob',
withCredentials: true,
}
);
// Создаем ссылку для скачивания
const url = window.URL.createObjectURL(new Blob([response.data]));
const link = document.createElement('a');
link.href = url;
const contentDisposition = response.headers['content-disposition'];
let filename = `project_file_${fileId}`;
if (contentDisposition) {
const filenameMatch = contentDisposition.match(/filename="([^"]+)"/);
if (filenameMatch && filenameMatch[1]) {
filename = decodeURIComponent(filenameMatch[1]);
}
}
link.setAttribute('download', filename);
document.body.appendChild(link);
link.click();
link.remove();
window.URL.revokeObjectURL(url);
return { success: true, filename: filename };
} catch (error) {
const errorMessage = error.response?.data?.detail || error.message;
console.error(`Ошибка скачивания файла проекта с ID ${fileId}:`, errorMessage);
if (error.response?.status === 401) {
throw new Error("Недостаточно прав для скачивания файла (401)");
}
if (error.response?.status === 404) {
throw new Error("Файл не найден (404)");
}
throw new Error(errorMessage);
}
};
export default downloadProjectFile;

View File

@ -0,0 +1,24 @@
import axios from 'axios';
import CONFIG from '@/core/config.js';
const getProjectFiles = async (projectId) => {
try {
const token = localStorage.getItem('access_token');
const response = await axios.get(
`${CONFIG.BASE_URL}/project_files/projects/${projectId}/`,
{
headers: {
Authorization: `Bearer ${token}`,
},
withCredentials: true,
}
);
return response.data;
} catch (error) {
const errorMessage = error.response?.data?.detail || error.message;
console.error(`Ошибка загрузки файлов проекта с ID ${projectId}:`, errorMessage);
throw new Error(`Не удалось загрузить файлы проекта: ${errorMessage}`);
}
};
export default getProjectFiles;

View File

@ -0,0 +1,39 @@
import axios from 'axios';
import CONFIG from '@/core/config.js';
const uploadProjectFile = async (projectId, file) => {
try {
const token = localStorage.getItem('access_token');
const formData = new FormData();
formData.append("file", file);
const response = await axios.post(
`${CONFIG.BASE_URL}/project_files/projects/${projectId}/upload`,
formData,
{
headers: {
"Content-Type": "multipart/form-data",
Authorization: `Bearer ${token}`,
},
withCredentials: true,
}
);
return response.data;
} catch (error) {
const errorMessage = error.response?.data?.detail || error.message;
console.error(`Ошибка загрузки файла проекта с ID ${projectId}:`, errorMessage);
if (error.response?.status === 401) {
throw new Error("Недостаточно прав для загрузки файла (401)");
}
if (error.response?.status === 400) {
throw new Error(`Ошибка загрузки: ${errorMessage}`);
}
if (error.response?.status === 404) {
throw new Error("Проект не найден (404)");
}
throw new Error(errorMessage);
}
};
export default uploadProjectFile;

View File

@ -168,7 +168,7 @@
<div v-else class="q-gutter-md q-mb-md row wrap justify-center">
<q-card v-for="photo in profilePhotos" :key="photo.id" class="col-auto" style="width: 120px; height: 120px; position: relative;">
<q-img
:src="getPhotoUrl(photo.id)"
:src="getPhotoUrl(photo.id, 'profile')"
alt="Profile Photo"
style="width: 100%; height: 100%; object-fit: cover;"
>
@ -179,7 +179,7 @@
round
dense
size="sm"
@click="confirmDeletePhoto(photo.id)"
@click="confirmDeletePhoto(photo.id, 'profile')"
/>
</div>
</q-img>
@ -206,7 +206,7 @@
label="Загрузить фото"
color="primary"
class="q-mt-sm full-width"
@click="uploadNewPhoto"
@click="uploadNewProfilePhoto"
:loading="uploadingPhoto"
/>
</template>
@ -221,11 +221,127 @@
<q-input v-model="dialogData.title" label="Название конкурса" dense autofocus clearable />
<q-input v-model="dialogData.description" label="Описание" dense clearable type="textarea" class="q-mt-sm" />
<q-input v-model="dialogData.web_url" label="URL сайта" dense clearable class="q-mt-sm" />
<q-input v-model="dialogData.photo" label="Фото" dense clearable class="q-mt-sm" />
<q-input v-model="dialogData.results" label="Результаты" dense clearable class="q-mt-sm" />
<q-toggle v-model="dialogData.is_win" label="Победа (Да/Нет)" dense class="q-mt-sm" />
<q-input v-model="dialogData.project_id" label="Проект" dense clearable class="q-mt-sm" />
<q-input v-model="dialogData.status_id" label="Статус" dense clearable class="q-mt-sm" />
<q-separator class="q-my-md" />
<div class="text-h6 q-mb-sm">Фотографии карусели конкурса</div>
<div v-if="loadingContestPhotos" class="text-center q-py-md">
<q-spinner-dots color="primary" size="2em" />
<div>Загрузка фотографий...</div>
</div>
<div v-else-if="contestPhotos.length === 0" class="text-center q-py-md text-grey-7">
Пока нет фотографий.
</div>
<div v-else class="q-gutter-md q-mb-md row wrap justify-center">
<q-card v-for="photo in contestPhotos" :key="photo.id" class="col-auto" style="width: 120px; height: 120px; position: relative;">
<q-img
:src="getPhotoUrl(photo.id, 'contest')"
alt="Contest Photo"
style="width: 100%; height: 100%; object-fit: cover;"
>
<div class="absolute-bottom text-right q-pa-xs">
<q-btn
icon="delete"
color="negative"
round
dense
size="sm"
@click="confirmDeletePhoto(photo.id, 'contest')"
/>
</div>
</q-img>
</q-card>
</div>
<q-file
v-model="newContestPhotoFile"
label="Выберите фото для загрузки"
outlined
dense
clearable
accept="image/*"
@update:model-value="handleNewContestPhotoSelected"
class="q-mt-sm"
>
<template v-slot:append>
<q-icon v-if="newContestPhotoFile" name="check" color="positive" />
<q-icon name="photo" />
</template>
</q-file>
<q-btn
v-if="newContestPhotoFile"
label="Загрузить фото"
color="primary"
class="q-mt-sm full-width"
@click="uploadNewContestPhoto"
:loading="uploadingPhoto"
/>
<q-separator class="q-my-md" />
<div class="text-h6 q-mb-sm">Файлы конкурса</div>
<div v-if="loadingContestFiles" class="text-center q-py-md">
<q-spinner-dots color="primary" size="2em" />
<div>Загрузка файлов...</div>
</div>
<div v-else-if="contestFiles.length === 0" class="text-center q-py-md text-grey-7">
Пока нет файлов.
</div>
<q-list v-else bordered separator class="rounded-borders">
<q-item v-for="fileItem in contestFiles" :key="fileItem.id" clickable v-ripple>
<q-item-section>
<q-item-label>{{ fileItem.filename }}</q-item-label>
<q-item-label caption>{{ fileItem.file_path }}</q-item-label>
</q-item-section>
<q-item-section side>
<div class="q-gutter-xs">
<q-btn
icon="download"
color="primary"
round
dense
size="sm"
@click.stop="downloadExistingContestFile(fileItem.id)"
/>
<q-btn
icon="delete"
color="negative"
round
dense
size="sm"
@click.stop="confirmDeleteContestFile(fileItem.id)"
/>
</div>
</q-item-section>
</q-item>
</q-list>
<q-file
v-model="newContestFile"
label="Выберите файл для загрузки"
outlined
dense
clearable
@update:model-value="handleNewContestFileSelected"
class="q-mt-sm"
>
<template v-slot:append>
<q-icon v-if="newContestFile" name="check" color="positive" />
<q-icon name="attach_file" />
</template>
</q-file>
<q-btn
v-if="newContestFile"
label="Загрузить файл"
color="primary"
class="q-mt-sm full-width"
@click="uploadNewContestFile"
:loading="uploadingFile"
/>
</template>
<template v-else>
@ -289,9 +405,8 @@
<script setup>
import { ref, watch, onMounted, onBeforeUnmount } from 'vue'
import { Notify, useQuasar } from 'quasar' // Импортируем useQuasar для QDialog
import { Notify, useQuasar } from 'quasar'
// Импорты API для управления данными
import fetchTeams from '@/api/teams/getTeams.js'
import updateTeam from '@/api/teams/updateTeam.js'
import deleteTeamById from '@/api/teams/deleteTeam.js'
@ -312,17 +427,33 @@ import updateContest from '@/api/contests/updateContest.js'
import deleteContestById from '@/api/contests/deleteContest.js'
import createContest from '@/api/contests/createContest.js'
import router from "@/router/index.js"
import CONFIG from '@/core/config.js' // Убедитесь, что у вас есть этот файл конфигурации
import CONFIG from '@/core/config.js'
// --- Импорты для работы с фотографиями профиля ---
import getPhotosByProfileId from '@/api/profiles/profile_photos/getPhotoFileById.js'
import getProfilePhotosByProfileId from '@/api/profiles/profile_photos/getPhotoFileById.js'
import uploadProfilePhoto from '@/api/profiles/profile_photos/uploadProfilePhoto.js'
import deletePhoto from '@/api/profiles/profile_photos/deletePhoto.js'
import deleteProfilePhoto from '@/api/profiles/profile_photos/deletePhoto.js'
import downloadProfilePhotoFile from '@/api/profiles/profile_photos/downloadPhotoFile.js'
import getContestCarouselPhotosByContestId from '@/api/contests/contest_carousel_photos/getContestPhotoFileById.js'
import uploadContestCarouselPhoto from '@/api/contests/contest_carousel_photos/uploadContestProfilePhoto.js'
import deleteContestCarouselPhoto from '@/api/contests/contest_carousel_photos/deleteContestPhoto.js'
import downloadContestCarouselPhotoFile from '@/api/contests/contest_carousel_photos/downloadContestPhotoFile.js'
// --- Imports for Contest Files ---
import getContestFiles from '@/api/contests/contest_files/getContestFiles.js'
import uploadContestFile from '@/api/contests/contest_files/uploadContestFile.js'
import deleteContestFile from '@/api/contests/contest_files/deleteContestFile.js'
import downloadContestFile from '@/api/contests/contest_files/downloadContestFile.js'
// --- Imports for Project Files ---
import getProjectFiles from '@/api/projects/project_files/getProjectFiles.js'
import uploadProjectFile from '@/api/projects/project_files/uploadProjectFile.js'
import deleteProjectFile from '@/api/projects/project_files/deleteProjectFile.js'
import downloadProjectFile from '@/api/projects/project_files/downloadProjectFile.js'
const $q = useQuasar()
// Текущая вкладка
const tab = ref('profiles')
// --- Profiles ---
@ -362,9 +493,9 @@ const projectColumns = [
const contests = ref([])
const loadingContests = ref(false)
const contestColumns = [
{ name: 'title', label: 'Название проекта', field: 'title', sortable: true },
{ name: 'title', label: 'Название конкурса', field: 'title', sortable: true },
{ name: 'description', label: 'Описание', field: 'description', sortable: true },
{ name: 'web_url', label: 'Репозиторий', field: 'repository_url', sortable: true },
{ name: 'web_url', label: 'URL сайта', field: 'web_url', sortable: true },
{ name: 'photo', label: 'Фото', field: 'photo', sortable: true },
{ name: 'results', label: 'Результаты', field: 'results', sortable: true },
{ name: 'is_win', label: 'Победа', field: 'is_win', sortable: true },
@ -381,18 +512,37 @@ const dialogType = ref('')
const profilePhotos = ref([])
const loadingProfilePhotos = ref(false)
const newProfilePhotoFile = ref(null)
const uploadingPhoto = ref(false)
const uploadingPhoto = ref(false) // Общее состояние для загрузки фото
// --- Состояния для фотографий карусели конкурса ---
const contestPhotos = ref([])
const loadingContestPhotos = ref(false)
const newContestPhotoFile = ref(null)
// --- Состояния для файлов конкурса ---
const contestFiles = ref([])
const loadingContestFiles = ref(false)
const newContestFile = ref(null)
const uploadingFile = ref(false) // Отдельное состояние для загрузки файлов
// Функция для получения URL фото
const getPhotoUrl = (photoId) => {
return `${CONFIG.BASE_URL}/profile_photos/${photoId}/file`;
const getPhotoUrl = (photoId, type) => {
if (type === 'profile') {
// Используем шаблонные строки JavaScript для корректного формирования URL
return `${CONFIG.BASE_URL}/profile_photos/${photoId}/file`;
} else if (type === 'contest') {
// Используем шаблонные строки JavaScript для корректного формирования URL
return `${CONFIG.BASE_URL}/contest_carousel_photos/${photoId}/file`;
}
return '';
}
// Загрузка фотографий профиля при открытии диалога для профиля
async function loadProfilePhotos(profileId) {
loadingProfilePhotos.value = true;
try {
profilePhotos.value = await getPhotosByProfileId(profileId);
profilePhotos.value = await getProfilePhotosByProfileId(profileId);
} catch (error) {
Notify.create({
type: 'negative',
@ -405,13 +555,58 @@ async function loadProfilePhotos(profileId) {
}
}
// Обработчик выбора нового файла
// Загрузка фотографий карусели конкурса
async function loadContestPhotos(contestId) {
loadingContestPhotos.value = true;
try {
contestPhotos.value = await getContestCarouselPhotosByContestId(contestId);
} catch (error) {
Notify.create({
type: 'negative',
message: `Ошибка загрузки фотографий карусели конкурса: ${error.message}`,
icon: 'error',
});
contestPhotos.value = [];
} finally {
loadingContestPhotos.value = false;
}
}
// Загрузка файлов конкурса
async function loadContestFiles(contestId) {
loadingContestFiles.value = true;
try {
contestFiles.value = await getContestFiles(contestId);
} catch (error) {
Notify.create({
type: 'negative',
message: `Ошибка загрузки файлов конкурса: ${error.message}`,
icon: 'error',
});
contestFiles.value = [];
} finally {
loadingContestFiles.value = false;
}
}
// Обработчик выбора нового файла для профиля
function handleNewPhotoSelected(file) {
newProfilePhotoFile.value = file;
}
// Загрузка новой фотографии
async function uploadNewPhoto() {
// Обработчик выбора нового файла для конкурса (фото)
function handleNewContestPhotoSelected(file) {
newContestPhotoFile.value = file;
}
// Обработчик выбора нового файла для конкурса (обычный файл)
function handleNewContestFileSelected(file) {
newContestFile.value = file;
}
// Загрузка новой фотографии профиля
async function uploadNewProfilePhoto() {
if (!newProfilePhotoFile.value || !dialogData.value.id) {
Notify.create({
type: 'warning',
@ -424,18 +619,17 @@ async function uploadNewPhoto() {
uploadingPhoto.value = true;
try {
const uploadedPhoto = await uploadProfilePhoto(dialogData.value.id, newProfilePhotoFile.value);
// Добавляем новую фото в список
profilePhotos.value.push(uploadedPhoto);
newProfilePhotoFile.value = null; // Сбрасываем выбранный файл
newProfilePhotoFile.value = null;
Notify.create({
type: 'positive',
message: 'Фотография успешно загружена!',
message: 'Фотография профиля успешно загружена!',
icon: 'check_circle',
});
} catch (error) {
Notify.create({
type: 'negative',
message: `Ошибка загрузки фотографии: ${error.message}`,
message: `Ошибка загрузки фотографии профиля: ${error.message}`,
icon: 'error',
});
} finally {
@ -443,8 +637,73 @@ async function uploadNewPhoto() {
}
}
// Загрузка новой фотографии карусели конкурса
async function uploadNewContestPhoto() {
if (!newContestPhotoFile.value || !dialogData.value.id) {
Notify.create({
type: 'warning',
message: 'Выберите файл и убедитесь, что конкурс выбран.',
icon: 'warning',
});
return;
}
uploadingPhoto.value = true;
try {
const uploadedPhoto = await uploadContestCarouselPhoto(dialogData.value.id, newContestPhotoFile.value);
contestPhotos.value.push(uploadedPhoto);
newContestPhotoFile.value = null;
Notify.create({
type: 'positive',
message: 'Фотография карусели конкурса успешно загружена!',
icon: 'check_circle',
});
} catch (error) {
Notify.create({
type: 'negative',
message: `Ошибка загрузки фотографии карусели конкурса: ${error.message}`,
icon: 'error',
});
} finally {
uploadingPhoto.value = false;
}
}
// Загрузка нового файла конкурса
async function uploadNewContestFile() {
if (!newContestFile.value || !dialogData.value.id) {
Notify.create({
type: 'warning',
message: 'Выберите файл и убедитесь, что конкурс выбран.',
icon: 'warning',
});
return;
}
uploadingFile.value = true; // Use separate loading state
try {
const uploadedFile = await uploadContestFile(dialogData.value.id, newContestFile.value);
contestFiles.value.push(uploadedFile);
newContestFile.value = null;
Notify.create({
type: 'positive',
message: 'Файл конкурса успешно загружен!',
icon: 'check_circle',
});
} catch (error) {
Notify.create({
type: 'negative',
message: `Ошибка загрузки файла конкурса: ${error.message}`,
icon: 'error',
});
} finally {
uploadingFile.value = false;
}
}
// Подтверждение и удаление фотографии
function confirmDeletePhoto(photoId) {
function confirmDeletePhoto(photoId, type) {
$q.dialog({
title: 'Подтверждение удаления',
message: 'Вы уверены, что хотите удалить эту фотографию?',
@ -459,23 +718,89 @@ function confirmDeletePhoto(photoId) {
color: 'primary'
}
}).onOk(async () => {
await deleteExistingPhoto(photoId);
await deleteExistingPhoto(photoId, type);
});
}
async function deleteExistingPhoto(photoId) {
async function deleteExistingPhoto(photoId, type) {
try {
await deletePhoto(photoId);
profilePhotos.value = profilePhotos.value.filter(p => p.id !== photoId);
if (type === 'profile') {
await deleteProfilePhoto(photoId);
profilePhotos.value = profilePhotos.value.filter(p => p.id !== photoId);
Notify.create({
type: 'positive',
message: 'Фотография профиля успешно удалена!',
icon: 'check_circle',
});
} else if (type === 'contest') {
await deleteContestCarouselPhoto(photoId);
contestPhotos.value = contestPhotos.value.filter(p => p.id !== photoId);
Notify.create({
type: 'positive',
message: 'Фотография карусели конкурса успешно удалена!',
icon: 'check_circle',
});
}
} catch (error) {
Notify.create({
type: 'negative',
message: `Ошибка удаления фотографии: ${error.message}`,
icon: 'error',
});
}
}
// Подтверждение и удаление файла конкурса
function confirmDeleteContestFile(fileId) {
$q.dialog({
title: 'Подтверждение удаления',
message: 'Вы уверены, что хотите удалить этот файл?',
cancel: true,
persistent: true,
ok: {
label: 'Удалить',
color: 'negative'
},
cancel: {
label: 'Отмена',
color: 'primary'
}
}).onOk(async () => {
await deleteExistingContestFile(fileId);
});
}
async function deleteExistingContestFile(fileId) {
try {
await deleteContestFile(fileId);
contestFiles.value = contestFiles.value.filter(f => f.id !== fileId);
Notify.create({
type: 'positive',
message: 'Фотография успешно удалена!',
message: айл конкурса успешно удален!',
icon: 'check_circle',
});
} catch (error) {
Notify.create({
type: 'negative',
message: `Ошибка удаления фотографии: ${error.message}`,
message: `Ошибка удаления файла конкурса: ${error.message}`,
icon: 'error',
});
}
}
// Скачивание файла конкурса
async function downloadExistingContestFile(fileId) {
try {
await downloadContestFile(fileId);
Notify.create({
type: 'positive',
message: 'Файл конкурса успешно скачан!',
icon: 'check_circle',
});
} catch (error) {
Notify.create({
type: 'negative',
message: `Ошибка скачивания файла конкурса: ${error.message}`,
icon: 'error',
});
}
@ -490,20 +815,27 @@ function openEdit(type, row) {
if (type === 'teams') {
dialogData.value = { title: '', description: '', logo: '', git_url: '' }
} else if (type === 'projects') {
dialogData.value = { name: '', summary: '', deadline: '' }
dialogData.value = { title: '', description: '', repository_url: '' }
} else if (type === 'profiles') {
dialogData.value = { first_name: '', last_name: '', patronymic: '', birthday: '', email: '', phone: '', role_id: null, team_id: null }
profilePhotos.value = [];
} else if (type === 'contests') {
dialogData.value = { title: '', description: '', web_url: '', photo: '', results: '', is_win: false, project_id: null, status_id: null }
contestPhotos.value = [];
contestFiles.value = []; // Clear contest files when opening dialog
}
}
dialogVisible.value = true
if (type === 'profiles' && dialogData.value.id) {
loadProfilePhotos(dialogData.value.id);
} else if (type === 'contests' && dialogData.value.id) {
loadContestPhotos(dialogData.value.id);
loadContestFiles(dialogData.value.id); // Load contest files
} else {
profilePhotos.value = [];
contestPhotos.value = [];
contestFiles.value = []; // Clear contest files
}
}
@ -515,7 +847,12 @@ function closeDialog() {
dialogVisible.value = false
profilePhotos.value = [];
newProfilePhotoFile.value = null;
contestPhotos.value = [];
newContestPhotoFile.value = null;
contestFiles.value = []; // Clear contest files
newContestFile.value = null; // Clear new contest file input
uploadingPhoto.value = false;
uploadingFile.value = false; // Reset file uploading state
}
async function saveChanges() {