diff --git a/API/app/database/migrations/versions/7c4a804d9c4b_0008_добавил_url_репозитория_у_профилей.py b/API/app/database/migrations/versions/7c4a804d9c4b_0008_добавил_url_репозитория_у_профилей.py
new file mode 100644
index 0000000..f0d071f
--- /dev/null
+++ b/API/app/database/migrations/versions/7c4a804d9c4b_0008_добавил_url_репозитория_у_профилей.py
@@ -0,0 +1,32 @@
+"""0008_добавил_url_репозитория_у_профилей
+
+Revision ID: 7c4a804d9c4b
+Revises: b6c6c906cd2b
+Create Date: 2025-06-11 00:03:04.123410
+
+"""
+from typing import Sequence, Union
+
+from alembic import op
+import sqlalchemy as sa
+
+
+# revision identifiers, used by Alembic.
+revision: str = '7c4a804d9c4b'
+down_revision: Union[str, None] = 'b6c6c906cd2b'
+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('profiles', sa.Column('repository_url', sa.String(), nullable=True))
+ # ### end Alembic commands ###
+
+
+def downgrade() -> None:
+ """Downgrade schema."""
+ # ### commands auto generated by Alembic - please adjust! ###
+ op.drop_column('profiles', 'repository_url')
+ # ### end Alembic commands ###
diff --git a/API/app/domain/entities/base_profile.py b/API/app/domain/entities/base_profile.py
index 8a4953a..b5fe3c9 100644
--- a/API/app/domain/entities/base_profile.py
+++ b/API/app/domain/entities/base_profile.py
@@ -11,9 +11,10 @@ class BaseProfileEntity(BaseModel):
birthday: datetime.date
email: Optional[str] = None
phone: Optional[str] = None
+ repository_url: Optional[str] = None
role_id: int
team_id: int
class Config:
- abstract = True
+ abstract = True
\ No newline at end of file
diff --git a/API/app/domain/models/contests.py b/API/app/domain/models/contests.py
index 78375f7..27cd998 100644
--- a/API/app/domain/models/contests.py
+++ b/API/app/domain/models/contests.py
@@ -20,5 +20,5 @@ class Contest(AdvancedBaseModel):
project = relationship('Project', back_populates='contests')
status = relationship('ContestStatus', back_populates='contests')
- carousel_photos = relationship('ContestCarouselPhoto', back_populates='contest')
- files = relationship('ContestFile', back_populates='contest')
+ carousel_photos = relationship('ContestCarouselPhoto', back_populates='contest', cascade='all')
+ files = relationship('ContestFile', back_populates='contest', cascade='all')
diff --git a/API/app/domain/models/profiles.py b/API/app/domain/models/profiles.py
index 4a33b20..eedb200 100644
--- a/API/app/domain/models/profiles.py
+++ b/API/app/domain/models/profiles.py
@@ -1,4 +1,4 @@
-from sqlalchemy import Column, VARCHAR, Date, ForeignKey, Integer
+from sqlalchemy import Column, VARCHAR, Date, ForeignKey, Integer, String
from sqlalchemy.orm import relationship
from app.domain.models.base import AdvancedBaseModel
@@ -13,6 +13,7 @@ class Profile(AdvancedBaseModel):
birthday = Column(Date, nullable=False)
email = Column(VARCHAR(150))
phone = Column(VARCHAR(28))
+ repository_url = Column(String, nullable=True)
role_id = Column(Integer, ForeignKey('roles.id'), nullable=False)
team_id = Column(Integer, ForeignKey('teams.id'), nullable=False)
@@ -21,5 +22,5 @@ class Profile(AdvancedBaseModel):
team = relationship('Team', back_populates='profiles')
user = relationship('User', back_populates='profile', cascade='all')
- profile_photos = relationship('ProfilePhoto', back_populates='profile')
- projects = relationship('ProjectMember', back_populates='profile')
+ profile_photos = relationship('ProfilePhoto', back_populates='profile', cascade='all')
+ projects = relationship('ProjectMember', back_populates='profile')
\ No newline at end of file
diff --git a/API/app/domain/models/projects.py b/API/app/domain/models/projects.py
index 96f35ff..5fee521 100644
--- a/API/app/domain/models/projects.py
+++ b/API/app/domain/models/projects.py
@@ -12,5 +12,5 @@ class Project(AdvancedBaseModel):
repository_url = Column(String, nullable=False)
contests = relationship("Contest", back_populates="project")
- files = relationship("ProjectFile", back_populates="project")
+ files = relationship("ProjectFile", back_populates="project", cascade="all")
members = relationship("ProjectMember", back_populates="project")
diff --git a/API/app/infrastructure/profiles_service.py b/API/app/infrastructure/profiles_service.py
index f7b7c04..0545ff3 100644
--- a/API/app/infrastructure/profiles_service.py
+++ b/API/app/infrastructure/profiles_service.py
@@ -83,6 +83,7 @@ class ProfilesService:
profile_model.birthday = profile.birthday
profile_model.email = profile.email
profile_model.phone = profile.phone
+ profile_model.repository_url = profile.repository_url
profile_model.role_id = profile.role_id
profile_model.team_id = profile.team_id
@@ -130,6 +131,7 @@ class ProfilesService:
birthday=profile_model.birthday,
email=profile_model.email,
phone=profile_model.phone,
+ repository_url=profile_model.repository_url,
role_id=profile_model.role_id,
team_id=profile_model.team_id,
)
@@ -143,6 +145,7 @@ class ProfilesService:
birthday=profile_entity.birthday,
email=profile_entity.email,
phone=profile_entity.phone,
+ repository_url=profile_entity.repository_url,
role_id=profile_entity.role_id,
team_id=profile_entity.team_id,
)
diff --git a/API/app/main.py b/API/app/main.py
index f241ec6..042b030 100644
--- a/API/app/main.py
+++ b/API/app/main.py
@@ -21,7 +21,12 @@ def start_app():
api_app.add_middleware(
CORSMiddleware,
- allow_origins=["https://api.numerum.team", "https://numerum.team", "http://localhost:5173"],
+ allow_origins=[
+ "https://api.numerum.team",
+ "https://numerum.team",
+ "http://localhost:5173", # Это уже есть
+ "http://127.0.0.1:5173" # <-- Добавьте эту строку
+ ],
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
@@ -47,7 +52,6 @@ def start_app():
app = start_app()
-
@app.get("/")
async def root():
return {"message": "Hello API"}
diff --git a/WEB/src/api/contests/contest_carousel_photos/deleteContestPhoto.js b/WEB/src/api/contests/contest_carousel_photos/deleteContestPhoto.js
index 5437d99..ad35638 100644
--- a/WEB/src/api/contests/contest_carousel_photos/deleteContestPhoto.js
+++ b/WEB/src/api/contests/contest_carousel_photos/deleteContestPhoto.js
@@ -7,7 +7,7 @@ const deleteContestCarouselPhoto = async (photoId) => {
const token = localStorage.getItem('access_token')
const response = await axios.delete(
- `${CONFIG.BASE_URL}/contest_carousel_photos/${photoId}/`, // Изменено здесь
+ `${CONFIG.BASE_URL}/contest_carousel_photos/${photoId}/`,
{
withCredentials: true,
headers: {
diff --git a/WEB/src/api/contests/contest_carousel_photos/downloadContestPhotoFile.js b/WEB/src/api/contests/contest_carousel_photos/downloadContestPhotoFile.js
index 0d3babc..ca3349f 100644
--- a/WEB/src/api/contests/contest_carousel_photos/downloadContestPhotoFile.js
+++ b/WEB/src/api/contests/contest_carousel_photos/downloadContestPhotoFile.js
@@ -7,7 +7,7 @@ const downloadContestCarouselPhotoFile = async (photoId) => {
const token = localStorage.getItem('access_token')
const response = await axios.get(
- `${CONFIG.BASE_URL}/contest_carousel_photos/${photoId}/file`,
+ `${CONFIG.BASE_URL}/contest_carousel_photos/${photoId}/file/`,
{
withCredentials: true,
headers: {
diff --git a/WEB/src/api/contests/contest_carousel_photos/uploadContestProfilePhoto.js b/WEB/src/api/contests/contest_carousel_photos/uploadContestProfilePhoto.js
index b202ae3..2b7c418 100644
--- a/WEB/src/api/contests/contest_carousel_photos/uploadContestProfilePhoto.js
+++ b/WEB/src/api/contests/contest_carousel_photos/uploadContestProfilePhoto.js
@@ -12,7 +12,7 @@ const uploadContestCarouselPhoto = async (contestId, file) => {
formData.append('contest_id', contestId);
const response = await axios.post(
- `${CONFIG.BASE_URL}/contest_carousel_photos/contests/${contestId}/upload`,
+ `${CONFIG.BASE_URL}/contest_carousel_photos/contests/${contestId}/upload/`,
formData,
{
withCredentials: true,
diff --git a/WEB/src/api/contests/contest_files/downloadContestFile.js b/WEB/src/api/contests/contest_files/downloadContestFile.js
index 16363ca..8dc7c90 100644
--- a/WEB/src/api/contests/contest_files/downloadContestFile.js
+++ b/WEB/src/api/contests/contest_files/downloadContestFile.js
@@ -1,38 +1,82 @@
import axios from "axios";
import CONFIG from "@/core/config.js";
-const downloadContestFile = async (fileId) => {
+// Добавляем параметры suggestedFileName и suggestedFileFormat для большей надежности
+const downloadContestFile = async (fileId, suggestedFileName, suggestedFileFormat) => {
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 response = await axios.get(
+ `${CONFIG.BASE_URL}/contest_files/${fileId}/file`, // Убедитесь, что это правильный URL для скачивания самого файла
+ {
+ responseType: 'blob', // Важно для бинарных данных
+ withCredentials: true, // Важно для аутентификации
+ }
+ );
+
+ // Получаем MIME-тип файла из заголовков ответа
+ const contentType = response.headers['content-type'] || 'application/octet-stream';
+ // Создаем Blob с правильным MIME-типом
+ const blob = new Blob([response.data], { type: contentType });
+ const url = window.URL.createObjectURL(blob);
const link = document.createElement('a');
link.href = url;
+
+ let filename = `contest_file_${fileId}`; // Запасное имя файла
+
+ // 1. Попытка получить имя файла из заголовка Content-Disposition
const contentDisposition = response.headers['content-disposition'];
- let filename = 'download';
if (contentDisposition) {
- const filenameMatch = contentDisposition.match(/filename="(.+)"/);
- if (filenameMatch && filenameMatch[1]) {
- filename = filenameMatch[1];
+ // Расширенное регулярное выражение для извлечения filename или filename* (с поддержкой UTF-8)
+ const filenameMatch = contentDisposition.match(/filename\*=(?:UTF-8'')?([^;]+)|filename="([^"]+)"/i);
+ if (filenameMatch) {
+ if (filenameMatch[1]) { // filename* (RFC 5987)
+ try {
+ filename = decodeURIComponent(filenameMatch[1]);
+ } catch (e) {
+ console.warn("Ошибка декодирования filename* из Content-Disposition:", e);
+ filename = filenameMatch[1]; // Используем как есть, если декодирование не удалось
+ }
+ } else if (filenameMatch[2]) { // Обычный filename
+ filename = filenameMatch[2];
+ }
}
}
+
+ // 2. Если имя файла всё ещё не идеально или не содержит расширения,
+ // используем переданные suggestedFileName и suggestedFileFormat как запасной вариант
+ if (!filename || filename === `contest_file_${fileId}` || !filename.includes('.')) {
+ let finalName = suggestedFileName || `contest_file_${fileId}`;
+ if (suggestedFileFormat && !finalName.toLowerCase().endsWith(`.${suggestedFileFormat.toLowerCase()}`)) {
+ finalName = `${finalName}.${suggestedFileFormat.toLowerCase()}`;
+ }
+ filename = finalName;
+ }
+
link.setAttribute('download', filename);
document.body.appendChild(link);
link.click();
link.remove();
- window.URL.revokeObjectURL(url);
+ window.URL.revokeObjectURL(url); // Освобождаем память Blob URL
return filename;
} catch (error) {
- if (error.response?.status === 401) {
- throw new Error("Нет доступа для скачивания файла (401)");
+ // Улучшенная обработка ошибок для более информативных сообщений
+ if (error.response) {
+ if (error.response.status === 401) {
+ throw new Error("Недостаточно прав для скачивания файла конкурса (401). Пожалуйста, авторизуйтесь.");
+ }
+ if (error.response.status === 404) {
+ throw new Error(`Файл конкурса с ID ${fileId} не найден (404). Возможно, он был удалён.`);
+ }
+ // Попробуем получить сообщение об ошибке из данных ответа, если это JSON
+ if (error.response.data && typeof error.response.data === 'object' && error.response.data.detail) {
+ throw new Error(`Ошибка сервера при скачивании файла конкурса: ${error.response.data.detail}`);
+ }
+ throw new Error(`Ошибка сети или сервера при скачивании файла конкурса: Статус ${error.response.status}`);
+ } else if (error.request) {
+ throw new Error("Не удалось отправить запрос на скачивание файла конкурса. Проверьте ваше интернет-соединение.");
+ } else {
+ throw new Error(`Неизвестная ошибка при скачивании файла конкурса: ${error.message}`);
}
- if (error.response?.status === 404) {
- throw new Error("Файл не найден (404)");
- }
- throw new Error(error.message);
}
};
diff --git a/WEB/src/api/contests/updateContest.js b/WEB/src/api/contests/updateContest.js
index 57ef747..45e67c5 100644
--- a/WEB/src/api/contests/updateContest.js
+++ b/WEB/src/api/contests/updateContest.js
@@ -4,12 +4,7 @@ import CONFIG from '@/core/config.js'
const updateContest = async (profile) => {
try {
const token = localStorage.getItem('access_token')
-
- // Убираем id из тела запроса, он идет в URL
const { id, ...profileData } = profile
-
- console.log('Отправляем на сервер:', profileData)
-
const response = await axios.put(
`${CONFIG.BASE_URL}/contests/${id}/`,
profileData,
@@ -20,8 +15,6 @@ const updateContest = async (profile) => {
}
}
)
-
- console.log('Ответ от сервера:', response.data)
return response.data
} catch (error) {
throw new Error(error.response?.data?.detail || error.message)
diff --git a/WEB/src/api/profiles/profile_photos/downloadPhotoFile.js b/WEB/src/api/profiles/profile_photos/downloadPhotoFile.js
index c06b06e..60ed6d2 100644
--- a/WEB/src/api/profiles/profile_photos/downloadPhotoFile.js
+++ b/WEB/src/api/profiles/profile_photos/downloadPhotoFile.js
@@ -7,7 +7,7 @@ const downloadPhotoFile = async (photoId) => {
const token = localStorage.getItem('access_token')
const response = await axios.get(
- `${CONFIG.BASE_URL}/profile_photos/${photoId}/file`,
+ `${CONFIG.BASE_URL}/profile_photos/${photoId}/file/`,
{
withCredentials: true,
headers: {
diff --git a/WEB/src/api/profiles/profile_photos/getProfileByUserId.js b/WEB/src/api/profiles/profile_photos/getProfileByUserId.js
new file mode 100644
index 0000000..50653e0
--- /dev/null
+++ b/WEB/src/api/profiles/profile_photos/getProfileByUserId.js
@@ -0,0 +1,33 @@
+import axios from 'axios';
+import CONFIG from '@/core/config.js';
+
+const getProfileByUserId = async (profileId) => {
+ try {
+ const token = localStorage.getItem('access_token');
+ if (!profileId) {
+ throw new Error('ID профиля не указан.');
+ }
+ const response = await axios.get(
+ `${CONFIG.BASE_URL}/profiles/user/${profileId}/`,
+ {
+ withCredentials: true,
+ headers: {
+ Authorization: `Bearer ${token}`
+ }
+ }
+ );
+ return response.data; // Возвращаем данные профиля
+ } catch (error) {
+ const errorMessage = error.response?.data?.detail || error.message;
+ console.error(`Ошибка получения профиля ${profileId}:`, errorMessage);
+
+ if (error.response && error.response.status === 404) {
+ throw new Error(`Профиль с ID ${profileId} не найден.`);
+ } else {
+ throw new Error(`Не удалось загрузить профиль: ${errorMessage}`);
+ }
+ }
+};
+
+
+export default getProfileByUserId;
\ 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
index c07ec4d..b7d6738 100644
--- a/WEB/src/api/profiles/profile_photos/uploadProfilePhoto.js
+++ b/WEB/src/api/profiles/profile_photos/uploadProfilePhoto.js
@@ -10,7 +10,7 @@ const uploadProfilePhoto = async (profileId, file) => {
formData.append('file', file)
const response = await axios.post(
- `${CONFIG.BASE_URL}/profile_photos/profiles/${profileId}/upload`,
+ `${CONFIG.BASE_URL}/profile_photos/profiles/${profileId}/upload/`,
formData,
{
withCredentials: true,
diff --git a/WEB/src/api/projects/getProjects.js b/WEB/src/api/projects/getProjects.js
index bb4f226..3ee5197 100644
--- a/WEB/src/api/projects/getProjects.js
+++ b/WEB/src/api/projects/getProjects.js
@@ -4,7 +4,7 @@ import CONFIG from "@/core/config.js";
const fetchProjects = async () => {
try {
const token = localStorage.getItem("access_token");
- const response = await axios.get(`${CONFIG.BASE_URL}/projects`, {
+ const response = await axios.get(`${CONFIG.BASE_URL}/projects/`, {
headers: {
Authorization: `Bearer ${token}/`,
},
diff --git a/WEB/src/api/projects/project_files/downloadProjectFile.js b/WEB/src/api/projects/project_files/downloadProjectFile.js
index a988ee9..839921d 100644
--- a/WEB/src/api/projects/project_files/downloadProjectFile.js
+++ b/WEB/src/api/projects/project_files/downloadProjectFile.js
@@ -1,43 +1,82 @@
import axios from 'axios';
import CONFIG from '@/core/config.js';
-const downloadProjectFile = async (fileId) => {
+// Добавляем параметры suggestedFileName и suggestedFileFormat
+const downloadProjectFile = async (fileId, suggestedFileName, suggestedFileFormat) => {
try {
const response = await axios.get(
- `${CONFIG.BASE_URL}/project_files/${fileId}/download/`,
+ `${CONFIG.BASE_URL}/project_files/${fileId}/file/`, // Убедитесь, что это правильный URL для скачивания самого файла
{
- responseType: 'blob',
- withCredentials: true,
+ responseType: 'blob', // Важно для бинарных данных
+ withCredentials: true, // Важно для аутентификации
}
);
- const url = window.URL.createObjectURL(new Blob([response.data]));
+ // Получаем MIME-тип файла из заголовков ответа
+ const contentType = response.headers['content-type'] || 'application/octet-stream';
+ // Создаем Blob с правильным MIME-типом
+ const blob = new Blob([response.data], { type: contentType });
+ const url = window.URL.createObjectURL(blob);
const link = document.createElement('a');
link.href = url;
+ let filename = `project_file_${fileId}`; // Запасное имя файла
+
+ // 1. Попытка получить имя файла из заголовка Content-Disposition
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]);
+ // Расширенное регулярное выражение для извлечения filename или filename* (с поддержкой UTF-8)
+ const filenameMatch = contentDisposition.match(/filename\*=(?:UTF-8'')?([^;]+)|filename="([^"]+)"/i);
+ if (filenameMatch) {
+ if (filenameMatch[1]) { // filename* (RFC 5987)
+ try {
+ filename = decodeURIComponent(filenameMatch[1]);
+ } catch (e) {
+ console.warn("Ошибка декодирования filename* из Content-Disposition:", e);
+ filename = filenameMatch[1]; // Используем как есть, если декодирование не удалось
+ }
+ } else if (filenameMatch[2]) { // Обычный filename
+ filename = filenameMatch[2];
+ }
}
}
+
+ // 2. Если имя файла всё ещё не идеально или не содержит расширения,
+ // используем переданные suggestedFileName и suggestedFileFormat как запасной вариант
+ if (!filename || filename === `project_file_${fileId}` || !filename.includes('.')) {
+ let finalName = suggestedFileName || `project_file_${fileId}`;
+ if (suggestedFileFormat && !finalName.toLowerCase().endsWith(`.${suggestedFileFormat.toLowerCase()}`)) {
+ finalName = `${finalName}.${suggestedFileFormat.toLowerCase()}`;
+ }
+ filename = finalName;
+ }
+
link.setAttribute('download', filename);
document.body.appendChild(link);
link.click();
link.remove();
- window.URL.revokeObjectURL(url);
+ window.URL.revokeObjectURL(url); // Освобождаем память Blob URL
return filename;
} catch (error) {
- if (error.response?.status === 401) {
- throw new Error("Недостаточно прав для скачивания файла (401)");
+ // Улучшенная обработка ошибок для более информативных сообщений
+ if (error.response) {
+ if (error.response.status === 401) {
+ throw new Error("Недостаточно прав для скачивания файла (401). Пожалуйста, авторизуйтесь.");
+ }
+ if (error.response.status === 404) {
+ throw new Error(`Файл с ID ${fileId} не найден (404). Возможно, он был удалён.`);
+ }
+ // Попробуем получить сообщение об ошибке из данных ответа, если это JSON
+ if (error.response.data && typeof error.response.data === 'object' && error.response.data.detail) {
+ throw new Error(`Ошибка сервера при скачивании файла: ${error.response.data.detail}`);
+ }
+ throw new Error(`Ошибка сети или сервера при скачивании файла: Статус ${error.response.status}`);
+ } else if (error.request) {
+ throw new Error("Не удалось отправить запрос на скачивание файла. Проверьте ваше интернет-соединение.");
+ } else {
+ throw new Error(`Неизвестная ошибка при скачивании файла: ${error.message}`);
}
- if (error.response?.status === 404) {
- throw new Error("Файл не найден (404)");
- }
- throw new Error(error.message);
}
};
diff --git a/WEB/src/main.js b/WEB/src/main.js
index 2924eda..8edc1eb 100644
--- a/WEB/src/main.js
+++ b/WEB/src/main.js
@@ -41,7 +41,8 @@ import {
QItem,
QImg,
QFile,
- QSelect
+ QSelect,
+ QSpinner
} from 'quasar'
@@ -61,7 +62,7 @@ app.use(Quasar, {
QSeparator, QCardActions, QDialog, QIcon, QSpace,
QAvatar, QTooltip, QBanner, QSlideTransition, QToggle,
QList, QSpinnerDots, QCarouselSlide, QCarousel,
- QItemSection, QItemLabel, QItem, QImg, QFile, QSelect
+ QItemSection, QItemLabel, QItem, QImg, QFile, QSelect, QSpinner
},
directives: {
Ripple
diff --git a/WEB/src/pages/AdminPage.vue b/WEB/src/pages/AdminPage.vue
index c24d64c..b64f879 100644
--- a/WEB/src/pages/AdminPage.vue
+++ b/WEB/src/pages/AdminPage.vue
@@ -11,11 +11,11 @@
animated
@update:model-value="loadData"
>
-
+
+
+