сделал добавление и удаление ответа на задание
This commit is contained in:
parent
1fb12bc7e4
commit
d57f2de2da
38
api/app/application/solution_files_repository.py
Normal file
38
api/app/application/solution_files_repository.py
Normal file
@ -0,0 +1,38 @@
|
|||||||
|
from typing import Optional, Sequence
|
||||||
|
|
||||||
|
from sqlalchemy import select
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from app.domain.models import SolutionFile
|
||||||
|
|
||||||
|
|
||||||
|
class SolutionFilesRepository:
|
||||||
|
def __init__(self, db: AsyncSession):
|
||||||
|
self.db = db
|
||||||
|
|
||||||
|
async def get_by_id(self, file_id: int) -> Optional[SolutionFile]:
|
||||||
|
query = (
|
||||||
|
select(SolutionFile)
|
||||||
|
.filter_by(id=file_id)
|
||||||
|
)
|
||||||
|
result = await self.db.execute(query)
|
||||||
|
return result.scalars().first()
|
||||||
|
|
||||||
|
async def get_by_solution_id(self, solution_id: int) -> Sequence[Optional[SolutionFile]]:
|
||||||
|
query = (
|
||||||
|
select(SolutionFile)
|
||||||
|
.filter_by(solution_id=solution_id)
|
||||||
|
)
|
||||||
|
result = await self.db.execute(query)
|
||||||
|
return result.scalars().all()
|
||||||
|
|
||||||
|
async def create(self, solution_file: SolutionFile) -> SolutionFile:
|
||||||
|
self.db.add(solution_file)
|
||||||
|
await self.db.commit()
|
||||||
|
await self.db.refresh(solution_file)
|
||||||
|
return solution_file
|
||||||
|
|
||||||
|
async def delete(self, solution_file: SolutionFile) -> SolutionFile:
|
||||||
|
await self.db.delete(solution_file)
|
||||||
|
await self.db.commit()
|
||||||
|
return solution_file
|
||||||
50
api/app/application/solutions_repository.py
Normal file
50
api/app/application/solutions_repository.py
Normal file
@ -0,0 +1,50 @@
|
|||||||
|
from typing import Optional, List
|
||||||
|
|
||||||
|
from sqlalchemy import select
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
from sqlalchemy.orm import selectinload
|
||||||
|
|
||||||
|
from app.domain.models import Solution
|
||||||
|
|
||||||
|
|
||||||
|
class SolutionsRepository:
|
||||||
|
def __init__(self, db: AsyncSession):
|
||||||
|
self.db = db
|
||||||
|
|
||||||
|
async def get_by_id(self, solution_id: int) -> Optional[Solution]:
|
||||||
|
query = (
|
||||||
|
select(Solution)
|
||||||
|
.filter_by(id=solution_id)
|
||||||
|
)
|
||||||
|
result = await self.db.execute(query)
|
||||||
|
return result.scalars().first()
|
||||||
|
|
||||||
|
async def get_by_task_id(self, task_id: int) -> Optional[List[Solution]]:
|
||||||
|
query = (
|
||||||
|
select(Solution)
|
||||||
|
.filter_by(task_id=task_id)
|
||||||
|
)
|
||||||
|
result = await self.db.execute(query)
|
||||||
|
return result.scalars().all()
|
||||||
|
|
||||||
|
async def get_by_task_id_and_student_id(self, task_id: int, student_id: int) -> Optional[List[Solution]]:
|
||||||
|
query = (
|
||||||
|
select(Solution)
|
||||||
|
.filter_by(task_id=task_id, student_id=student_id)
|
||||||
|
.options(
|
||||||
|
selectinload(Solution.files),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
result = await self.db.execute(query)
|
||||||
|
return result.scalars().all()
|
||||||
|
|
||||||
|
async def create(self, solution: Solution) -> Solution:
|
||||||
|
self.db.add(solution)
|
||||||
|
await self.db.commit()
|
||||||
|
await self.db.refresh(solution)
|
||||||
|
return solution
|
||||||
|
|
||||||
|
async def delete(self, solution: Solution) -> Solution:
|
||||||
|
await self.db.delete(solution)
|
||||||
|
await self.db.commit()
|
||||||
|
return solution
|
||||||
125
api/app/controllers/solutions_router.py
Normal file
125
api/app/controllers/solutions_router.py
Normal file
@ -0,0 +1,125 @@
|
|||||||
|
from typing import List, Optional
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends, HTTPException, status, Response, File, UploadFile
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
from starlette.responses import FileResponse
|
||||||
|
|
||||||
|
from app.database.session import get_db
|
||||||
|
from app.domain.entities.solution_files import ReadSolutionFile
|
||||||
|
from app.domain.entities.solutions import SolutionCreate, SolutionRead, SolutionAfterCreate
|
||||||
|
from app.domain.models import User
|
||||||
|
from app.infrastructure.dependencies import require_auth_user
|
||||||
|
from app.infrastructure.solution_files_service import SolutionFilesService
|
||||||
|
from app.infrastructure.solutions_service import SolutionsService
|
||||||
|
|
||||||
|
solution_router = APIRouter()
|
||||||
|
|
||||||
|
|
||||||
|
@solution_router.get(
|
||||||
|
'/task/{task_id}/',
|
||||||
|
response_model=List[SolutionRead],
|
||||||
|
summary='Get all solutions for task',
|
||||||
|
description='Get all solutions for task',
|
||||||
|
)
|
||||||
|
async def get_solutions_by_task(
|
||||||
|
task_id: int,
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
current_user: User = Depends(require_auth_user),
|
||||||
|
):
|
||||||
|
service = SolutionsService(db)
|
||||||
|
solutions = await service.get_by_task_id(task_id)
|
||||||
|
return solutions
|
||||||
|
|
||||||
|
|
||||||
|
@solution_router.get(
|
||||||
|
'/task/{task_id}/student/{student_id}/',
|
||||||
|
response_model=List[SolutionRead],
|
||||||
|
summary='Get all solutions for task',
|
||||||
|
description='Get all solutions for task',
|
||||||
|
)
|
||||||
|
async def get_solutions_by_task_and_student(
|
||||||
|
task_id: int,
|
||||||
|
student_id: int,
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
current_user: User = Depends(require_auth_user),
|
||||||
|
):
|
||||||
|
service = SolutionsService(db)
|
||||||
|
solutions = await service.get_by_task_id_and_student_id(task_id, student_id)
|
||||||
|
return solutions
|
||||||
|
|
||||||
|
|
||||||
|
@solution_router.post(
|
||||||
|
'/{task_id}/',
|
||||||
|
response_model=SolutionAfterCreate,
|
||||||
|
status_code=status.HTTP_201_CREATED,
|
||||||
|
summary='Send solution',
|
||||||
|
description='Send solution',
|
||||||
|
)
|
||||||
|
async def create_solution(
|
||||||
|
task_id: int,
|
||||||
|
solution_data: SolutionCreate,
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
current_user: User = Depends(require_auth_user),
|
||||||
|
):
|
||||||
|
service = SolutionsService(db)
|
||||||
|
return await service.create(solution_data, current_user, task_id)
|
||||||
|
|
||||||
|
|
||||||
|
@solution_router.delete(
|
||||||
|
'/{solution_id}/',
|
||||||
|
status_code=status.HTTP_204_NO_CONTENT,
|
||||||
|
summary='Delete my solution',
|
||||||
|
description='Delete my solution',
|
||||||
|
)
|
||||||
|
async def delete_solution(
|
||||||
|
solution_id: int,
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
current_user: User = Depends(require_auth_user),
|
||||||
|
):
|
||||||
|
service = SolutionsService(db)
|
||||||
|
await service.delete(solution_id, current_user)
|
||||||
|
return Response(status_code=status.HTTP_204_NO_CONTENT)
|
||||||
|
|
||||||
|
|
||||||
|
@solution_router.get(
|
||||||
|
'/file/{file_id}/',
|
||||||
|
response_class=FileResponse,
|
||||||
|
)
|
||||||
|
async def get_file(
|
||||||
|
file_id: int,
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
current_user: User = Depends(require_auth_user),
|
||||||
|
):
|
||||||
|
task_files_service = SolutionFilesService(db)
|
||||||
|
return await task_files_service.get_file_by_id(file_id)
|
||||||
|
|
||||||
|
|
||||||
|
@solution_router.get(
|
||||||
|
'/files/{solution_id}/',
|
||||||
|
response_model=Optional[List[ReadSolutionFile]],
|
||||||
|
summary='Get a files list by task ID',
|
||||||
|
description='Get a files list by task ID',
|
||||||
|
)
|
||||||
|
async def get_files(
|
||||||
|
solution_id: int,
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
current_user: User = Depends(require_auth_user),
|
||||||
|
):
|
||||||
|
task_files_service = SolutionFilesService(db)
|
||||||
|
return await task_files_service.get_files_list_by_solution(solution_id)
|
||||||
|
|
||||||
|
|
||||||
|
@solution_router.post(
|
||||||
|
'/files/{task_id}/upload/',
|
||||||
|
response_model=ReadSolutionFile,
|
||||||
|
summary='Upload a file',
|
||||||
|
description='Upload a file',
|
||||||
|
)
|
||||||
|
async def upload_file(
|
||||||
|
task_id: int,
|
||||||
|
file: UploadFile = File(...),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
user: User = Depends(require_auth_user),
|
||||||
|
):
|
||||||
|
task_files_service = SolutionFilesService(db)
|
||||||
|
return await task_files_service.upload_file(task_id, file)
|
||||||
@ -0,0 +1,107 @@
|
|||||||
|
"""0003 добавил таблицу с комментариями к решению
|
||||||
|
|
||||||
|
Revision ID: f8fd9a27eaa7
|
||||||
|
Revises: 33d77ac5ed79
|
||||||
|
Create Date: 2025-11-29 16:43:22.620248
|
||||||
|
|
||||||
|
"""
|
||||||
|
from typing import Sequence, Union
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
|
||||||
|
# revision identifiers, used by Alembic.
|
||||||
|
revision: str = 'f8fd9a27eaa7'
|
||||||
|
down_revision: Union[str, Sequence[str], None] = '33d77ac5ed79'
|
||||||
|
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.create_table('solution_comments',
|
||||||
|
sa.Column('comment_text', sa.String(), nullable=False),
|
||||||
|
sa.Column('comment_autor_id', sa.Integer(), nullable=False),
|
||||||
|
sa.Column('solution_id', sa.Integer(), nullable=False),
|
||||||
|
sa.Column('id', sa.Integer(), autoincrement=True, nullable=False),
|
||||||
|
sa.Column('created_at', sa.DateTime(), server_default=sa.text('now()'), nullable=False),
|
||||||
|
sa.Column('updated_at', sa.DateTime(), server_default=sa.text('now()'), nullable=False),
|
||||||
|
sa.ForeignKeyConstraint(['comment_autor_id'], ['public.users.id'], ),
|
||||||
|
sa.ForeignKeyConstraint(['solution_id'], ['public.solutions.id'], ),
|
||||||
|
sa.PrimaryKeyConstraint('id'),
|
||||||
|
schema='public'
|
||||||
|
)
|
||||||
|
op.drop_constraint(op.f('course_teachers_teacher_id_fkey'), 'course_teachers', type_='foreignkey')
|
||||||
|
op.drop_constraint(op.f('course_teachers_course_id_fkey'), 'course_teachers', type_='foreignkey')
|
||||||
|
op.create_foreign_key(None, 'course_teachers', 'courses', ['course_id'], ['id'], source_schema='public', referent_schema='public')
|
||||||
|
op.create_foreign_key(None, 'course_teachers', 'users', ['teacher_id'], ['id'], source_schema='public', referent_schema='public')
|
||||||
|
op.drop_constraint(op.f('enrollments_student_id_fkey'), 'enrollments', type_='foreignkey')
|
||||||
|
op.drop_constraint(op.f('enrollments_course_id_fkey'), 'enrollments', type_='foreignkey')
|
||||||
|
op.create_foreign_key(None, 'enrollments', 'users', ['student_id'], ['id'], source_schema='public', referent_schema='public')
|
||||||
|
op.create_foreign_key(None, 'enrollments', 'courses', ['course_id'], ['id'], source_schema='public', referent_schema='public')
|
||||||
|
op.drop_constraint(op.f('lesson_files_lesson_id_fkey'), 'lesson_files', type_='foreignkey')
|
||||||
|
op.create_foreign_key(None, 'lesson_files', 'lessons', ['lesson_id'], ['id'], source_schema='public', referent_schema='public')
|
||||||
|
op.drop_constraint(op.f('lessons_creator_id_fkey'), 'lessons', type_='foreignkey')
|
||||||
|
op.drop_constraint(op.f('lessons_course_id_fkey'), 'lessons', type_='foreignkey')
|
||||||
|
op.create_foreign_key(None, 'lessons', 'courses', ['course_id'], ['id'], source_schema='public', referent_schema='public')
|
||||||
|
op.create_foreign_key(None, 'lessons', 'users', ['creator_id'], ['id'], source_schema='public', referent_schema='public')
|
||||||
|
op.drop_constraint(op.f('solution_files_solution_id_fkey'), 'solution_files', type_='foreignkey')
|
||||||
|
op.create_foreign_key(None, 'solution_files', 'solutions', ['solution_id'], ['id'], source_schema='public', referent_schema='public')
|
||||||
|
op.drop_constraint(op.f('solutions_student_id_fkey'), 'solutions', type_='foreignkey')
|
||||||
|
op.drop_constraint(op.f('solutions_task_id_fkey'), 'solutions', type_='foreignkey')
|
||||||
|
op.drop_constraint(op.f('solutions_assessment_autor_id_fkey'), 'solutions', type_='foreignkey')
|
||||||
|
op.create_foreign_key(None, 'solutions', 'users', ['assessment_autor_id'], ['id'], source_schema='public', referent_schema='public')
|
||||||
|
op.create_foreign_key(None, 'solutions', 'tasks', ['task_id'], ['id'], source_schema='public', referent_schema='public')
|
||||||
|
op.create_foreign_key(None, 'solutions', 'users', ['student_id'], ['id'], source_schema='public', referent_schema='public')
|
||||||
|
op.drop_constraint(op.f('task_files_task_id_fkey'), 'task_files', type_='foreignkey')
|
||||||
|
op.create_foreign_key(None, 'task_files', 'tasks', ['task_id'], ['id'], source_schema='public', referent_schema='public')
|
||||||
|
op.drop_constraint(op.f('tasks_course_id_fkey'), 'tasks', type_='foreignkey')
|
||||||
|
op.drop_constraint(op.f('tasks_creator_id_fkey'), 'tasks', type_='foreignkey')
|
||||||
|
op.create_foreign_key(None, 'tasks', 'users', ['creator_id'], ['id'], source_schema='public', referent_schema='public')
|
||||||
|
op.create_foreign_key(None, 'tasks', 'courses', ['course_id'], ['id'], source_schema='public', referent_schema='public')
|
||||||
|
op.drop_constraint(op.f('users_role_id_fkey'), 'users', type_='foreignkey')
|
||||||
|
op.drop_constraint(op.f('users_status_id_fkey'), 'users', type_='foreignkey')
|
||||||
|
op.create_foreign_key(None, 'users', 'roles', ['role_id'], ['id'], source_schema='public', referent_schema='public')
|
||||||
|
op.create_foreign_key(None, 'users', 'statuses', ['status_id'], ['id'], source_schema='public', referent_schema='public')
|
||||||
|
# ### end Alembic commands ###
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
"""Downgrade schema."""
|
||||||
|
# ### commands auto generated by Alembic - please adjust! ###
|
||||||
|
op.drop_constraint(None, 'users', schema='public', type_='foreignkey')
|
||||||
|
op.drop_constraint(None, 'users', schema='public', type_='foreignkey')
|
||||||
|
op.create_foreign_key(op.f('users_status_id_fkey'), 'users', 'statuses', ['status_id'], ['id'])
|
||||||
|
op.create_foreign_key(op.f('users_role_id_fkey'), 'users', 'roles', ['role_id'], ['id'])
|
||||||
|
op.drop_constraint(None, 'tasks', schema='public', type_='foreignkey')
|
||||||
|
op.drop_constraint(None, 'tasks', schema='public', type_='foreignkey')
|
||||||
|
op.create_foreign_key(op.f('tasks_creator_id_fkey'), 'tasks', 'users', ['creator_id'], ['id'])
|
||||||
|
op.create_foreign_key(op.f('tasks_course_id_fkey'), 'tasks', 'courses', ['course_id'], ['id'])
|
||||||
|
op.drop_constraint(None, 'task_files', schema='public', type_='foreignkey')
|
||||||
|
op.create_foreign_key(op.f('task_files_task_id_fkey'), 'task_files', 'tasks', ['task_id'], ['id'])
|
||||||
|
op.drop_constraint(None, 'solutions', schema='public', type_='foreignkey')
|
||||||
|
op.drop_constraint(None, 'solutions', schema='public', type_='foreignkey')
|
||||||
|
op.drop_constraint(None, 'solutions', schema='public', type_='foreignkey')
|
||||||
|
op.create_foreign_key(op.f('solutions_assessment_autor_id_fkey'), 'solutions', 'users', ['assessment_autor_id'], ['id'])
|
||||||
|
op.create_foreign_key(op.f('solutions_task_id_fkey'), 'solutions', 'tasks', ['task_id'], ['id'])
|
||||||
|
op.create_foreign_key(op.f('solutions_student_id_fkey'), 'solutions', 'users', ['student_id'], ['id'])
|
||||||
|
op.drop_constraint(None, 'solution_files', schema='public', type_='foreignkey')
|
||||||
|
op.create_foreign_key(op.f('solution_files_solution_id_fkey'), 'solution_files', 'solutions', ['solution_id'], ['id'])
|
||||||
|
op.drop_constraint(None, 'lessons', schema='public', type_='foreignkey')
|
||||||
|
op.drop_constraint(None, 'lessons', schema='public', type_='foreignkey')
|
||||||
|
op.create_foreign_key(op.f('lessons_course_id_fkey'), 'lessons', 'courses', ['course_id'], ['id'])
|
||||||
|
op.create_foreign_key(op.f('lessons_creator_id_fkey'), 'lessons', 'users', ['creator_id'], ['id'])
|
||||||
|
op.drop_constraint(None, 'lesson_files', schema='public', type_='foreignkey')
|
||||||
|
op.create_foreign_key(op.f('lesson_files_lesson_id_fkey'), 'lesson_files', 'lessons', ['lesson_id'], ['id'])
|
||||||
|
op.drop_constraint(None, 'enrollments', schema='public', type_='foreignkey')
|
||||||
|
op.drop_constraint(None, 'enrollments', schema='public', type_='foreignkey')
|
||||||
|
op.create_foreign_key(op.f('enrollments_course_id_fkey'), 'enrollments', 'courses', ['course_id'], ['id'])
|
||||||
|
op.create_foreign_key(op.f('enrollments_student_id_fkey'), 'enrollments', 'users', ['student_id'], ['id'])
|
||||||
|
op.drop_constraint(None, 'course_teachers', schema='public', type_='foreignkey')
|
||||||
|
op.drop_constraint(None, 'course_teachers', schema='public', type_='foreignkey')
|
||||||
|
op.create_foreign_key(op.f('course_teachers_course_id_fkey'), 'course_teachers', 'courses', ['course_id'], ['id'])
|
||||||
|
op.create_foreign_key(op.f('course_teachers_teacher_id_fkey'), 'course_teachers', 'users', ['teacher_id'], ['id'])
|
||||||
|
op.drop_table('solution_comments', schema='public')
|
||||||
|
# ### end Alembic commands ###
|
||||||
11
api/app/domain/entities/solution_files.py
Normal file
11
api/app/domain/entities/solution_files.py
Normal file
@ -0,0 +1,11 @@
|
|||||||
|
from pydantic import BaseModel
|
||||||
|
|
||||||
|
|
||||||
|
class ReadSolutionFile(BaseModel):
|
||||||
|
id: int
|
||||||
|
filename: str
|
||||||
|
file_path: str
|
||||||
|
solution_id: int
|
||||||
|
|
||||||
|
class Config:
|
||||||
|
from_attributes = True
|
||||||
36
api/app/domain/entities/solutions.py
Normal file
36
api/app/domain/entities/solutions.py
Normal file
@ -0,0 +1,36 @@
|
|||||||
|
from datetime import datetime
|
||||||
|
from typing import Optional, List
|
||||||
|
from pydantic import BaseModel, Field
|
||||||
|
|
||||||
|
from app.domain.entities.solution_files import ReadSolutionFile
|
||||||
|
from app.domain.entities.users import UserRead
|
||||||
|
|
||||||
|
|
||||||
|
class SolutionBase(BaseModel):
|
||||||
|
answer_text: str = Field(...)
|
||||||
|
assessment: Optional[int] = Field(default=None)
|
||||||
|
|
||||||
|
|
||||||
|
class SolutionCreate(SolutionBase):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class SolutionAfterCreate(SolutionBase):
|
||||||
|
id: int
|
||||||
|
|
||||||
|
assessment_autor_id: Optional[int] = None
|
||||||
|
assessment_autor: Optional[UserRead] = None
|
||||||
|
student_id: int
|
||||||
|
task_id: int = Field(...)
|
||||||
|
|
||||||
|
class Config:
|
||||||
|
from_attributes = True
|
||||||
|
|
||||||
|
|
||||||
|
class SolutionRead(SolutionAfterCreate):
|
||||||
|
created_at: datetime
|
||||||
|
|
||||||
|
files: Optional[List[ReadSolutionFile]] = []
|
||||||
|
|
||||||
|
class Config:
|
||||||
|
from_attributes = True
|
||||||
@ -15,6 +15,7 @@ from app.domain.models.enrollments import Enrollment
|
|||||||
from app.domain.models.lesson_files import LessonFile
|
from app.domain.models.lesson_files import LessonFile
|
||||||
from app.domain.models.lessons import Lesson
|
from app.domain.models.lessons import Lesson
|
||||||
from app.domain.models.roles import Role
|
from app.domain.models.roles import Role
|
||||||
|
from app.domain.models.solution_comments import SolutionComment
|
||||||
from app.domain.models.solution_files import SolutionFile
|
from app.domain.models.solution_files import SolutionFile
|
||||||
from app.domain.models.solutions import Solution
|
from app.domain.models.solutions import Solution
|
||||||
from app.domain.models.statuses import Status
|
from app.domain.models.statuses import Status
|
||||||
|
|||||||
19
api/app/domain/models/solution_comments.py
Normal file
19
api/app/domain/models/solution_comments.py
Normal file
@ -0,0 +1,19 @@
|
|||||||
|
from typing import List
|
||||||
|
|
||||||
|
from sqlalchemy import String, ForeignKey
|
||||||
|
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||||
|
|
||||||
|
|
||||||
|
from app.domain.models.base import RootTable
|
||||||
|
|
||||||
|
|
||||||
|
class SolutionComment(RootTable):
|
||||||
|
__tablename__ = 'solution_comments'
|
||||||
|
|
||||||
|
comment_text: Mapped[str] = mapped_column(nullable=False)
|
||||||
|
|
||||||
|
comment_autor_id: Mapped[int] = mapped_column(ForeignKey('users.id'), nullable=False)
|
||||||
|
solution_id: Mapped[int] = mapped_column(ForeignKey('solutions.id'), nullable=False)
|
||||||
|
|
||||||
|
comment_autor: Mapped['User'] = relationship('User', back_populates='solution_comments')
|
||||||
|
solution: Mapped['Solution'] = relationship('Solution', back_populates='solution_comments')
|
||||||
@ -10,14 +10,18 @@ class Solution(RootTable):
|
|||||||
__tablename__ = 'solutions'
|
__tablename__ = 'solutions'
|
||||||
|
|
||||||
answer_text: Mapped[str] = mapped_column(nullable=True)
|
answer_text: Mapped[str] = mapped_column(nullable=True)
|
||||||
assessment_text: Mapped[str] = mapped_column(String(50), nullable=True)
|
assessment: Mapped[int] = mapped_column(nullable=True)
|
||||||
|
|
||||||
assessment_autor_id: Mapped[int] = mapped_column(ForeignKey('users.id'), nullable=True)
|
assessment_autor_id: Mapped[int] = mapped_column(ForeignKey('users.id'), nullable=True)
|
||||||
task_id: Mapped[int] = mapped_column(ForeignKey('tasks.id'), nullable=False)
|
task_id: Mapped[int] = mapped_column(ForeignKey('tasks.id'), nullable=False)
|
||||||
student_id: Mapped[int] = mapped_column(ForeignKey('users.id'), nullable=False)
|
student_id: Mapped[int] = mapped_column(ForeignKey('users.id'), nullable=False)
|
||||||
|
|
||||||
assessment_autor: Mapped['User'] = relationship('User', back_populates='assessments',
|
assessment_autor: Mapped['User'] = relationship('User', back_populates='assessments',
|
||||||
foreign_keys=[assessment_autor_id])
|
foreign_keys=[assessment_autor_id], lazy='joined')
|
||||||
student: Mapped['User'] = relationship('User', back_populates='my_solutions', foreign_keys=[student_id])
|
student: Mapped['User'] = relationship('User', back_populates='my_solutions', foreign_keys=[student_id])
|
||||||
|
|
||||||
files: Mapped[List['SolutionFile']] = relationship('SolutionFile', back_populates='solution')
|
files: Mapped[List['SolutionFile']] = relationship('SolutionFile', back_populates='solution')
|
||||||
|
solution_comments: Mapped[List['SolutionComment']] = relationship(
|
||||||
|
'SolutionComment',
|
||||||
|
back_populates='solution',
|
||||||
|
)
|
||||||
|
|||||||
@ -46,6 +46,11 @@ class User(PhotoAbstract):
|
|||||||
back_populates='student',
|
back_populates='student',
|
||||||
foreign_keys=[Solution.student_id],
|
foreign_keys=[Solution.student_id],
|
||||||
)
|
)
|
||||||
|
from app.domain.models.solution_comments import SolutionComment
|
||||||
|
solution_comments: Mapped[List['SolutionComment']] = relationship(
|
||||||
|
'SolutionComment',
|
||||||
|
back_populates='comment_autor',
|
||||||
|
)
|
||||||
|
|
||||||
def check_password(self, password):
|
def check_password(self, password):
|
||||||
return check_password_hash(self.password_hash, password)
|
return check_password_hash(self.password_hash, password)
|
||||||
|
|||||||
118
api/app/infrastructure/solution_files_service.py
Normal file
118
api/app/infrastructure/solution_files_service.py
Normal file
@ -0,0 +1,118 @@
|
|||||||
|
import os
|
||||||
|
import uuid
|
||||||
|
from typing import List
|
||||||
|
|
||||||
|
import aiofiles
|
||||||
|
from fastapi import UploadFile, HTTPException
|
||||||
|
from starlette.responses import FileResponse
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
from werkzeug.utils import secure_filename
|
||||||
|
|
||||||
|
from app.application.solution_files_repository import SolutionFilesRepository
|
||||||
|
from app.application.solutions_repository import SolutionsRepository
|
||||||
|
from app.domain.entities.solution_files import ReadSolutionFile
|
||||||
|
from app.domain.models import SolutionFile
|
||||||
|
|
||||||
|
|
||||||
|
class SolutionFilesService:
|
||||||
|
def __init__(self, db: AsyncSession):
|
||||||
|
self.solution_files_repository = SolutionFilesRepository(db)
|
||||||
|
self.solutions_repository = SolutionsRepository(db)
|
||||||
|
|
||||||
|
async def get_file_by_id(self, file_id: int) -> FileResponse:
|
||||||
|
solution_file = await self.solution_files_repository.get_by_id(file_id)
|
||||||
|
|
||||||
|
if not solution_file:
|
||||||
|
raise HTTPException(404, "Файл с таким ID не найден")
|
||||||
|
|
||||||
|
return FileResponse(
|
||||||
|
solution_file.file_path,
|
||||||
|
media_type=self.get_media_type(solution_file.filename),
|
||||||
|
filename=os.path.basename(solution_file.filename),
|
||||||
|
)
|
||||||
|
|
||||||
|
async def get_files_list_by_solution(self, solution_id: int) -> List[ReadSolutionFile]:
|
||||||
|
solution = await self.solutions_repository.get_by_id(solution_id)
|
||||||
|
|
||||||
|
if solution is None:
|
||||||
|
raise HTTPException(404, "Лекционный материал не найден")
|
||||||
|
|
||||||
|
solution_files = await self.solution_files_repository.get_by_solution_id(solution_id)
|
||||||
|
|
||||||
|
response = []
|
||||||
|
for solution_file in solution_files:
|
||||||
|
response.append(
|
||||||
|
ReadSolutionFile.model_validate(
|
||||||
|
solution_file
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
return response
|
||||||
|
|
||||||
|
async def upload_file(self, solution_id: int, file: UploadFile) -> ReadSolutionFile:
|
||||||
|
solution = await self.solutions_repository.get_by_id(solution_id)
|
||||||
|
|
||||||
|
if solution is None:
|
||||||
|
raise HTTPException(404, "Лекционный материал не найден")
|
||||||
|
|
||||||
|
file_path = await self.save_file(file, f'uploads/solutions/{solution.id}')
|
||||||
|
|
||||||
|
solution_file_model = SolutionFile(
|
||||||
|
filename=file.filename,
|
||||||
|
file_path=file_path,
|
||||||
|
solution_id=solution.id,
|
||||||
|
)
|
||||||
|
|
||||||
|
solution_file_model = await self.solution_files_repository.create(solution_file_model)
|
||||||
|
|
||||||
|
return ReadSolutionFile.model_validate(solution_file_model)
|
||||||
|
|
||||||
|
async def delete_file(self, file_id: int) -> ReadSolutionFile:
|
||||||
|
solution_file = await self.solution_files_repository.get_by_id(file_id)
|
||||||
|
|
||||||
|
if solution_file is None:
|
||||||
|
raise HTTPException(404, "Файл не найден")
|
||||||
|
|
||||||
|
if not os.path.exists(solution_file.file_path):
|
||||||
|
raise HTTPException(404, "Файл не найден на диске")
|
||||||
|
|
||||||
|
if os.path.exists(solution_file.file_path):
|
||||||
|
os.remove(solution_file.file_path)
|
||||||
|
|
||||||
|
solution_file = await self.solution_files_repository.delete(solution_file)
|
||||||
|
|
||||||
|
return ReadSolutionFile.model_validate(solution_file)
|
||||||
|
|
||||||
|
async def save_file(self, file: UploadFile, upload_dir: str = 'uploads/solutions') -> 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 generate_filename(file: UploadFile) -> str:
|
||||||
|
return secure_filename(f"{uuid.uuid4()}_{file.filename}")
|
||||||
|
|
||||||
|
@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"
|
||||||
118
api/app/infrastructure/solutions_service.py
Normal file
118
api/app/infrastructure/solutions_service.py
Normal file
@ -0,0 +1,118 @@
|
|||||||
|
import os
|
||||||
|
from typing import Optional, List
|
||||||
|
|
||||||
|
from fastapi import HTTPException, status
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from app.application.solution_files_repository import SolutionFilesRepository
|
||||||
|
from app.application.solutions_repository import SolutionsRepository
|
||||||
|
from app.application.tasks_repository import TasksRepository
|
||||||
|
from app.application.users_repository import UsersRepository
|
||||||
|
from app.domain.entities.solutions import SolutionRead, SolutionCreate, SolutionAfterCreate
|
||||||
|
from app.domain.models import User, Solution
|
||||||
|
from app.settings import Settings
|
||||||
|
|
||||||
|
|
||||||
|
class SolutionsService:
|
||||||
|
def __init__(self, db: AsyncSession):
|
||||||
|
self.solutions_repository = SolutionsRepository(db)
|
||||||
|
self.tasks_repository = TasksRepository(db)
|
||||||
|
self.users_repository = UsersRepository(db)
|
||||||
|
self.solution_files_repository = SolutionFilesRepository(db)
|
||||||
|
self.settings = Settings()
|
||||||
|
|
||||||
|
async def get_by_task_id(self, task_id: int) -> Optional[List[SolutionRead]]:
|
||||||
|
task_model = await self.tasks_repository.get_by_id(task_id)
|
||||||
|
|
||||||
|
if task_model is None:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_404_NOT_FOUND,
|
||||||
|
detail="Задание не найдено"
|
||||||
|
)
|
||||||
|
|
||||||
|
solutions = await self.solutions_repository.get_by_task_id(task_id)
|
||||||
|
|
||||||
|
response = []
|
||||||
|
for solution in solutions:
|
||||||
|
response.append(
|
||||||
|
SolutionRead.model_validate(solution)
|
||||||
|
)
|
||||||
|
|
||||||
|
return response
|
||||||
|
|
||||||
|
async def get_by_task_id_and_student_id(self, task_id: int, student_id: int) -> Optional[List[SolutionRead]]:
|
||||||
|
task_model = await self.tasks_repository.get_by_id(task_id)
|
||||||
|
|
||||||
|
if task_model is None:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_404_NOT_FOUND,
|
||||||
|
detail="Задание не найдено"
|
||||||
|
)
|
||||||
|
|
||||||
|
student_model = await self.users_repository.get_by_id(student_id)
|
||||||
|
|
||||||
|
if student_model is None:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_404_NOT_FOUND,
|
||||||
|
detail="Такой пользователь не найден"
|
||||||
|
)
|
||||||
|
|
||||||
|
solutions = await self.solutions_repository.get_by_task_id_and_student_id(task_id, student_id)
|
||||||
|
|
||||||
|
response = []
|
||||||
|
for solution in solutions:
|
||||||
|
response.append(
|
||||||
|
SolutionRead.model_validate(solution)
|
||||||
|
)
|
||||||
|
|
||||||
|
return response
|
||||||
|
|
||||||
|
async def create(self, solution: SolutionCreate, creator: User, task_id: int) -> SolutionAfterCreate:
|
||||||
|
task_model = await self.tasks_repository.get_by_id(task_id)
|
||||||
|
|
||||||
|
if task_model is None:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_404_NOT_FOUND,
|
||||||
|
detail="Задание не найдено"
|
||||||
|
)
|
||||||
|
|
||||||
|
model_solution = Solution(
|
||||||
|
answer_text=solution.answer_text,
|
||||||
|
assessment=solution.assessment,
|
||||||
|
task_id=task_id,
|
||||||
|
student_id=creator.id,
|
||||||
|
)
|
||||||
|
created_solution = await self.solutions_repository.create(model_solution)
|
||||||
|
return SolutionAfterCreate.model_validate(created_solution)
|
||||||
|
|
||||||
|
async def delete(self, solution_id: int, current_user: User) -> Optional[SolutionRead]:
|
||||||
|
solution = await self.solutions_repository.get_by_id(solution_id)
|
||||||
|
if solution is None:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_404_NOT_FOUND,
|
||||||
|
detail='Такое решение не найдено'
|
||||||
|
)
|
||||||
|
|
||||||
|
is_admin = current_user.role.title == self.settings.root_role_name
|
||||||
|
if solution.student_id != current_user.id and not is_admin:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_403_FORBIDDEN,
|
||||||
|
detail="Доступ запрещён"
|
||||||
|
)
|
||||||
|
|
||||||
|
solution_files = await self.solution_files_repository.get_by_solution_id(solution.id)
|
||||||
|
for file in solution_files:
|
||||||
|
solution_file = await self.solution_files_repository.get_by_id(file.id)
|
||||||
|
|
||||||
|
if solution_file is None:
|
||||||
|
raise HTTPException(404, "Файл не найден")
|
||||||
|
|
||||||
|
if not os.path.exists(solution_file.file_path):
|
||||||
|
raise HTTPException(404, "Файл не найден на диске")
|
||||||
|
|
||||||
|
if os.path.exists(solution_file.file_path):
|
||||||
|
os.remove(solution_file.file_path)
|
||||||
|
|
||||||
|
await self.solution_files_repository.delete(solution_file)
|
||||||
|
|
||||||
|
await self.solutions_repository.delete(solution)
|
||||||
@ -6,6 +6,7 @@ from app.controllers.courses_router import courses_router
|
|||||||
from app.controllers.lessons_router import lessons_router
|
from app.controllers.lessons_router import lessons_router
|
||||||
from app.controllers.register_router import register_router
|
from app.controllers.register_router import register_router
|
||||||
from app.controllers.roles_router import roles_router
|
from app.controllers.roles_router import roles_router
|
||||||
|
from app.controllers.solutions_router import solution_router
|
||||||
from app.controllers.statuses_router import statuses_router
|
from app.controllers.statuses_router import statuses_router
|
||||||
from app.controllers.tasks_router import tasks_router
|
from app.controllers.tasks_router import tasks_router
|
||||||
from app.controllers.users_router import users_router
|
from app.controllers.users_router import users_router
|
||||||
@ -29,6 +30,7 @@ def start_app():
|
|||||||
api_app.include_router(lessons_router, prefix=f'{settings.prefix}/lessons', tags=['lessons'])
|
api_app.include_router(lessons_router, prefix=f'{settings.prefix}/lessons', tags=['lessons'])
|
||||||
api_app.include_router(register_router, prefix=f'{settings.prefix}/register', tags=['register'])
|
api_app.include_router(register_router, prefix=f'{settings.prefix}/register', tags=['register'])
|
||||||
api_app.include_router(roles_router, prefix=f'{settings.prefix}/roles', tags=['roles'])
|
api_app.include_router(roles_router, prefix=f'{settings.prefix}/roles', tags=['roles'])
|
||||||
|
api_app.include_router(solution_router, prefix=f'{settings.prefix}/solutions', tags=['solutions'])
|
||||||
api_app.include_router(statuses_router, prefix=f'{settings.prefix}/statuses', tags=['statuses'])
|
api_app.include_router(statuses_router, prefix=f'{settings.prefix}/statuses', tags=['statuses'])
|
||||||
api_app.include_router(tasks_router, prefix=f'{settings.prefix}/tasks', tags=['tasks'])
|
api_app.include_router(tasks_router, prefix=f'{settings.prefix}/tasks', tags=['tasks'])
|
||||||
api_app.include_router(users_router, prefix=f'{settings.prefix}/users', tags=['users'])
|
api_app.include_router(users_router, prefix=f'{settings.prefix}/users', tags=['users'])
|
||||||
|
|||||||
73
web/src/Api/solutionsApi.js
Normal file
73
web/src/Api/solutionsApi.js
Normal file
@ -0,0 +1,73 @@
|
|||||||
|
import {createApi} from "@reduxjs/toolkit/query/react";
|
||||||
|
import {baseQueryWithAuth} from "./baseQuery.js";
|
||||||
|
|
||||||
|
|
||||||
|
export const solutionsApi = createApi({
|
||||||
|
reducerPath: "solutionsApi",
|
||||||
|
baseQuery: baseQueryWithAuth,
|
||||||
|
tagTypes: ["lesson"],
|
||||||
|
endpoints: (builder) => ({
|
||||||
|
getTaskSolutions: builder.query({
|
||||||
|
query: (taskId) => ({
|
||||||
|
url: `/solutions/task/${taskId}/`,
|
||||||
|
method: "GET"
|
||||||
|
}),
|
||||||
|
providesTags: ["lesson"],
|
||||||
|
}),
|
||||||
|
getTaskStudentSolutions: builder.query({
|
||||||
|
query: ({taskId, studentId}) => ({
|
||||||
|
url: `/solutions/task/${taskId}/student/${studentId}/`,
|
||||||
|
method: "GET"
|
||||||
|
}),
|
||||||
|
providesTags: ["lesson"],
|
||||||
|
}),
|
||||||
|
createSolution: builder.mutation({
|
||||||
|
query: ({taskId, solution}) => ({
|
||||||
|
url: `/solutions/${taskId}/`,
|
||||||
|
method: "POST",
|
||||||
|
body: solution,
|
||||||
|
}),
|
||||||
|
invalidatesTags: ["lesson"],
|
||||||
|
}),
|
||||||
|
deleteSolution: builder.mutation({
|
||||||
|
query: (solutionId) => ({
|
||||||
|
url: `/solutions/${solutionId}/`,
|
||||||
|
method: "DELETE",
|
||||||
|
}),
|
||||||
|
invalidatesTags: ["lesson"],
|
||||||
|
}),
|
||||||
|
getSolutionFilesList: builder.query({
|
||||||
|
query: (solutionId) => ({
|
||||||
|
url: `/solutions/files/${solutionId}/`,
|
||||||
|
method: "GET"
|
||||||
|
}),
|
||||||
|
providesTags: ["lesson"],
|
||||||
|
}),
|
||||||
|
uploadFile: builder.mutation({
|
||||||
|
query: ({solutionId, fileData}) => {
|
||||||
|
if (!(fileData instanceof File)) {
|
||||||
|
throw new Error('Invalid file object');
|
||||||
|
}
|
||||||
|
const formData = new FormData();
|
||||||
|
formData.append('file', fileData);
|
||||||
|
return {
|
||||||
|
url: `/solutions/files/${solutionId}/upload/`,
|
||||||
|
method: 'POST',
|
||||||
|
formData: true,
|
||||||
|
body: formData,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
invalidatesTags: ["task"],
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const {
|
||||||
|
useGetTaskSolutionsQuery,
|
||||||
|
useGetTaskStudentSolutionsQuery,
|
||||||
|
useCreateSolutionMutation,
|
||||||
|
useDeleteSolutionMutation,
|
||||||
|
useGetSolutionFilesListQuery,
|
||||||
|
useUploadFileMutation,
|
||||||
|
} = solutionsApi;
|
||||||
|
|
||||||
@ -12,7 +12,7 @@ const useCreateTaskModalForm = ({courseId}) => {
|
|||||||
} = useSelector((state) => state.tasks);
|
} = useSelector((state) => state.tasks);
|
||||||
const [form] = Form.useForm();
|
const [form] = Form.useForm();
|
||||||
|
|
||||||
const [createtask, {isLoading}] = useCreateTaskMutation();
|
const [createTask, {isLoading}] = useCreateTaskMutation();
|
||||||
const [draftFiles, setDraftFiles] = useState([]);
|
const [draftFiles, setDraftFiles] = useState([]);
|
||||||
const [uploadFile] = useUploadFileMutation();
|
const [uploadFile] = useUploadFileMutation();
|
||||||
|
|
||||||
@ -57,7 +57,7 @@ const useCreateTaskModalForm = ({courseId}) => {
|
|||||||
number: values.number || 1,
|
number: values.number || 1,
|
||||||
};
|
};
|
||||||
|
|
||||||
const response = await createtask({
|
const response = await createTask({
|
||||||
courseId,
|
courseId,
|
||||||
taskData,
|
taskData,
|
||||||
}).unwrap();
|
}).unwrap();
|
||||||
|
|||||||
@ -1,8 +1,32 @@
|
|||||||
import {Avatar, Button, Col, Divider, Modal, Popconfirm, Row, Space, Spin, Typography} from "antd";
|
import {
|
||||||
import {CloseOutlined, UserOutlined} from "@ant-design/icons";
|
Avatar,
|
||||||
|
Button,
|
||||||
|
Col, Collapse,
|
||||||
|
Divider,
|
||||||
|
Empty, Flex,
|
||||||
|
Form,
|
||||||
|
Modal,
|
||||||
|
Popconfirm,
|
||||||
|
Row,
|
||||||
|
Space,
|
||||||
|
Spin,
|
||||||
|
Tag,
|
||||||
|
Typography,
|
||||||
|
Upload
|
||||||
|
} from "antd";
|
||||||
|
import {
|
||||||
|
CloseOutlined, DeleteOutlined,
|
||||||
|
DownloadOutlined,
|
||||||
|
FileOutlined,
|
||||||
|
PlusOutlined,
|
||||||
|
UploadOutlined,
|
||||||
|
UserOutlined
|
||||||
|
} from "@ant-design/icons";
|
||||||
import useViewTaskModal from "./useTaskLessonModal.js";
|
import useViewTaskModal from "./useTaskLessonModal.js";
|
||||||
|
import {ROLES} from "../../../../../Core/constants.js";
|
||||||
|
import JoditEditor from "jodit-react";
|
||||||
|
|
||||||
|
const {Panel} = Collapse;
|
||||||
const {Title, Text, Paragraph} = Typography;
|
const {Title, Text, Paragraph} = Typography;
|
||||||
|
|
||||||
const ViewTaskModal = () => {
|
const ViewTaskModal = () => {
|
||||||
@ -14,7 +38,16 @@ const ViewTaskModal = () => {
|
|||||||
isCurrentTaskFilesLoading,
|
isCurrentTaskFilesLoading,
|
||||||
isCurrentTaskFilesError,
|
isCurrentTaskFilesError,
|
||||||
downloadFile,
|
downloadFile,
|
||||||
downloadingFiles
|
downloadingFiles,
|
||||||
|
currentUser,
|
||||||
|
mySolutions,
|
||||||
|
editorRef,
|
||||||
|
joditConfig,
|
||||||
|
handleAddFile,
|
||||||
|
handleRemoveFile,
|
||||||
|
handleOk,
|
||||||
|
draftFiles,
|
||||||
|
handleDeleSolution
|
||||||
} = useViewTaskModal();
|
} = useViewTaskModal();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@ -102,6 +135,133 @@ const ViewTaskModal = () => {
|
|||||||
<p>Файлы отсутствуют</p>
|
<p>Файлы отсутствуют</p>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{currentUser?.role?.title === ROLES.STUDENT ? (
|
||||||
|
<Col>
|
||||||
|
<Title level={3}>Ваши решения</Title>
|
||||||
|
{mySolutions.length > 0 ? (
|
||||||
|
<Collapse accordion>
|
||||||
|
{mySolutions.map((solution) => (
|
||||||
|
<Panel
|
||||||
|
key={solution.id}
|
||||||
|
header={
|
||||||
|
<Flex justify="space-between" align="center">
|
||||||
|
<Text strong>Решение
|
||||||
|
от {new Date(solution.created_at).toLocaleString("ru-RU")}</Text>
|
||||||
|
{solution.assessment !== null ? (
|
||||||
|
<Tag
|
||||||
|
color={solution.assessment >= 80 ? "green" : solution.assessment >= 60 ? "orange" : "red"}>
|
||||||
|
Оценка: {solution.assessment} / 100
|
||||||
|
</Tag>
|
||||||
|
) : (
|
||||||
|
<Tag color="blue">На проверке</Tag>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Popconfirm
|
||||||
|
title={`Удалить ответ на задание?`}
|
||||||
|
description="Это действие нельзя отменить"
|
||||||
|
onConfirm={(e) => {
|
||||||
|
handleDeleSolution(solution.id)
|
||||||
|
}}
|
||||||
|
okText="Удалить"
|
||||||
|
cancelText="Отмена"
|
||||||
|
>
|
||||||
|
<Button
|
||||||
|
type="text"
|
||||||
|
danger
|
||||||
|
icon={<DeleteOutlined/>}
|
||||||
|
/>
|
||||||
|
</Popconfirm>
|
||||||
|
</Flex>
|
||||||
|
}
|
||||||
|
extra={
|
||||||
|
solution.assessment !== null && (
|
||||||
|
<Tag color="purple">
|
||||||
|
Проверено: {solution.assessment_autor?.first_name} {solution.assessment_autor?.last_name}
|
||||||
|
</Tag>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<div style={{marginBottom: 16}}>
|
||||||
|
<Text strong>Ответ:</Text>
|
||||||
|
<Paragraph
|
||||||
|
style={{
|
||||||
|
background: "#f9f9f9",
|
||||||
|
padding: 12,
|
||||||
|
borderRadius: 8,
|
||||||
|
margin: "8px 0",
|
||||||
|
whiteSpace: "pre-wrap",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{solution.answer_text}
|
||||||
|
</Paragraph>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{solution.files && solution.files.length > 0 ? (
|
||||||
|
<div>
|
||||||
|
<Text strong>Прикреплённые файлы:</Text>
|
||||||
|
<div style={{
|
||||||
|
marginTop: 8,
|
||||||
|
display: "flex",
|
||||||
|
flexDirection: "column",
|
||||||
|
gap: 8
|
||||||
|
}}>
|
||||||
|
{solution.files.map((file) => (
|
||||||
|
<Button
|
||||||
|
key={file.id}
|
||||||
|
type="dashed"
|
||||||
|
icon={<FileOutlined/>}
|
||||||
|
style={{textAlign: "left"}}
|
||||||
|
onClick={() => downloadFile(file.id, file.filename)}
|
||||||
|
loading={downloadingFiles[file.id]}
|
||||||
|
>
|
||||||
|
<span style={{marginLeft: 8}}>
|
||||||
|
{file.filename} ({(file.file_size / 1024 / 1024).toFixed(2)} МБ)
|
||||||
|
</span>
|
||||||
|
<DownloadOutlined style={{marginLeft: 8, color: "#1890ff"}}/>
|
||||||
|
</Button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<Text type="secondary">Файлы не прикреплены</Text>
|
||||||
|
)}
|
||||||
|
</Panel>
|
||||||
|
))}
|
||||||
|
</Collapse>
|
||||||
|
) : (
|
||||||
|
<Empty
|
||||||
|
image={Empty.PRESENTED_IMAGE_SIMPLE}
|
||||||
|
description="Решений пока нет"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<Title level={3}>Добавить решение</Title>
|
||||||
|
<div style={{border: "1px solid #d9d9d9", borderRadius: 8, overflow: "hidden"}}>
|
||||||
|
<JoditEditor
|
||||||
|
ref={editorRef}
|
||||||
|
config={joditConfig}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<Divider/>
|
||||||
|
<Upload
|
||||||
|
fileList={draftFiles}
|
||||||
|
beforeUpload={(file) => {
|
||||||
|
handleAddFile(file);
|
||||||
|
return false;
|
||||||
|
}}
|
||||||
|
onRemove={(file) => handleRemoveFile(file)}
|
||||||
|
multiple
|
||||||
|
>
|
||||||
|
<Button icon={<UploadOutlined/>}>Выбрать файлы</Button>
|
||||||
|
</Upload>
|
||||||
|
<Divider/>
|
||||||
|
<Button type="primary" onClick={handleOk}>
|
||||||
|
Сохранить решение
|
||||||
|
</Button>
|
||||||
|
</Col>
|
||||||
|
) : [ROLES.ADMIN, ROLES.TEACHER].includes(currentUser?.role?.title) && (
|
||||||
|
<></>
|
||||||
|
)}
|
||||||
|
|
||||||
<div style={{textAlign: "right"}}>
|
<div style={{textAlign: "right"}}>
|
||||||
<Button onClick={handleClose}>
|
<Button onClick={handleClose}>
|
||||||
Закрыть
|
Закрыть
|
||||||
|
|||||||
@ -2,8 +2,15 @@ import {useDispatch, useSelector} from "react-redux";
|
|||||||
import {setSelectedTaskToView} from "../../../../../Redux/Slices/tasksSlice.js";
|
import {setSelectedTaskToView} from "../../../../../Redux/Slices/tasksSlice.js";
|
||||||
import {notification} from "antd";
|
import {notification} from "antd";
|
||||||
import CONFIG from "../../../../../Core/сonfig.js";
|
import CONFIG from "../../../../../Core/сonfig.js";
|
||||||
import {useState} from "react";
|
import {useMemo, useRef, useState} from "react";
|
||||||
import {useGetTaskFilesListQuery} from "../../../../../Api/tasksApi.js";
|
import {useGetTaskFilesListQuery} from "../../../../../Api/tasksApi.js";
|
||||||
|
import {useGetAuthenticatedUserDataQuery} from "../../../../../Api/usersApi.js";
|
||||||
|
import {
|
||||||
|
useCreateSolutionMutation, useDeleteSolutionMutation,
|
||||||
|
useGetTaskStudentSolutionsQuery,
|
||||||
|
useUploadFileMutation
|
||||||
|
} from "../../../../../Api/solutionsApi.js";
|
||||||
|
import {ROLES} from "../../../../../Core/constants.js";
|
||||||
|
|
||||||
|
|
||||||
const useViewTaskModal = () => {
|
const useViewTaskModal = () => {
|
||||||
@ -13,12 +20,113 @@ const useViewTaskModal = () => {
|
|||||||
selectedTaskToView
|
selectedTaskToView
|
||||||
} = useSelector((state) => state.tasks);
|
} = useSelector((state) => state.tasks);
|
||||||
|
|
||||||
|
const [
|
||||||
|
craeteColution,
|
||||||
|
{
|
||||||
|
isLoading: isCreatingSolution,
|
||||||
|
isError: isErrorCreatingSoltion
|
||||||
|
}
|
||||||
|
] = useCreateSolutionMutation();
|
||||||
|
|
||||||
const modalIsOpen = selectedTaskToView !== null;
|
const modalIsOpen = selectedTaskToView !== null;
|
||||||
|
|
||||||
|
const [draftFiles, setDraftFiles] = useState([]);
|
||||||
|
const [uploadFile] = useUploadFileMutation();
|
||||||
|
const [deleteSolution] = useDeleteSolutionMutation();
|
||||||
|
|
||||||
|
const handleAddFile = (file) => {
|
||||||
|
const maxSize = 50 * 1024 * 1024; // 50 мегабайт
|
||||||
|
if (file.size > maxSize) {
|
||||||
|
notification.error({
|
||||||
|
message: "Ошибка вставки",
|
||||||
|
description: "Файл слишком большой.",
|
||||||
|
placement: "topRight",
|
||||||
|
});
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
setDraftFiles((prev) => [...prev, file]);
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDeleSolution = async (solutionId) => {
|
||||||
|
try {
|
||||||
|
await deleteSolution(solutionId);
|
||||||
|
|
||||||
|
notification.success({
|
||||||
|
title: "Успех",
|
||||||
|
description: "Задание успешно удалено!",
|
||||||
|
placement: "topRight",
|
||||||
|
})
|
||||||
|
} catch (error) {
|
||||||
|
notification.error({
|
||||||
|
title: "Ошибка",
|
||||||
|
description: error?.data?.detail || "Не удалось удалить задание",
|
||||||
|
placement: "topRight",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleRemoveFile = (file) => {
|
||||||
|
setDraftFiles((prev) => prev.filter((f) => f.uid !== file.uid));
|
||||||
|
};
|
||||||
|
|
||||||
|
const editorRef = useRef(null);
|
||||||
|
|
||||||
|
const handleOk = async () => {
|
||||||
|
try {
|
||||||
|
const content = editorRef.current?.value || "";
|
||||||
|
|
||||||
|
const solutionData = {
|
||||||
|
answer_text: content,
|
||||||
|
};
|
||||||
|
|
||||||
|
const response = await craeteColution({
|
||||||
|
taskId: selectedTaskToView?.id,
|
||||||
|
solution: solutionData,
|
||||||
|
}).unwrap();
|
||||||
|
|
||||||
|
for (const file of draftFiles) {
|
||||||
|
try {
|
||||||
|
await uploadFile({
|
||||||
|
solutionId: response.id,
|
||||||
|
fileData: file,
|
||||||
|
}).unwrap();
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`Error uploading file ${file.name}:`, error);
|
||||||
|
const errorMessage = error.data?.detail
|
||||||
|
? JSON.stringify(error.data.detail, null, 2)
|
||||||
|
: JSON.stringify(error.data || error.message || "Неизвестная ошибка", null, 2);
|
||||||
|
notification.error({
|
||||||
|
title: "Ошибка загрузки файла",
|
||||||
|
description: `Не удалось загрузить файл ${file.name}: ${errorMessage}`,
|
||||||
|
placement: "topRight",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
notification.success({
|
||||||
|
title: "Успех",
|
||||||
|
description: "Задание успешно создано!",
|
||||||
|
placement: "topRight",
|
||||||
|
});
|
||||||
|
|
||||||
|
if (editorRef.current) {
|
||||||
|
editorRef.current.value = "";
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
notification.error({
|
||||||
|
title: "Ошибка",
|
||||||
|
description: error?.data?.detail || "Не удалось создать задание",
|
||||||
|
placement: "topRight",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const handleClose = () => {
|
const handleClose = () => {
|
||||||
dispatch(setSelectedTaskToView(null));
|
dispatch(setSelectedTaskToView(null));
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
||||||
const {
|
const {
|
||||||
data: currentTaskFiles = [],
|
data: currentTaskFiles = [],
|
||||||
isLoading: isCurrentTaskFilesLoading,
|
isLoading: isCurrentTaskFilesLoading,
|
||||||
@ -27,6 +135,24 @@ const useViewTaskModal = () => {
|
|||||||
skip: !selectedTaskToView?.id
|
skip: !selectedTaskToView?.id
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const {
|
||||||
|
data: currentUser = {},
|
||||||
|
isLoading: isCurrentUserLoading,
|
||||||
|
isError: isCurrentUserError,
|
||||||
|
} = useGetAuthenticatedUserDataQuery();
|
||||||
|
|
||||||
|
const {
|
||||||
|
data: mySolutions = [],
|
||||||
|
isLoading: isMySolutionsLoading,
|
||||||
|
isError: isMySolutionsError
|
||||||
|
} = useGetTaskStudentSolutionsQuery({
|
||||||
|
taskId: selectedTaskToView?.id,
|
||||||
|
studentId: currentUser?.id
|
||||||
|
}, {
|
||||||
|
skip: !selectedTaskToView?.id || currentUser?.role?.title !== ROLES.STUDENT,
|
||||||
|
pollingInterval: 5000,
|
||||||
|
});
|
||||||
|
|
||||||
const [downloadingFiles, setDownloadingFiles] = useState({});
|
const [downloadingFiles, setDownloadingFiles] = useState({});
|
||||||
|
|
||||||
const downloadFile = async (fileId, fileName) => {
|
const downloadFile = async (fileId, fileName) => {
|
||||||
@ -41,16 +167,15 @@ const useViewTaskModal = () => {
|
|||||||
placement: "topRight",
|
placement: "topRight",
|
||||||
});
|
});
|
||||||
return;
|
return;
|
||||||
}
|
};
|
||||||
|
|
||||||
const response = await fetch(`${CONFIG.BASE_URL}/tasks/file/${fileId}/`, {
|
const response = await fetch(`${CONFIG.BASE_URL}/solutions/file/${fileId}/`, {
|
||||||
method: 'GET',
|
method: 'GET',
|
||||||
headers: {
|
headers: {
|
||||||
'Authorization': `Bearer ${token}`,
|
'Authorization': `Bearer ${token}`,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
const errorText = await response.text();
|
const errorText = await response.text();
|
||||||
notification.error({
|
notification.error({
|
||||||
@ -59,7 +184,7 @@ const useViewTaskModal = () => {
|
|||||||
placement: "topRight",
|
placement: "topRight",
|
||||||
});
|
});
|
||||||
return;
|
return;
|
||||||
}
|
};
|
||||||
|
|
||||||
const contentType = response.headers.get('content-type');
|
const contentType = response.headers.get('content-type');
|
||||||
if (!contentType || contentType.includes('text/html')) {
|
if (!contentType || contentType.includes('text/html')) {
|
||||||
@ -105,6 +230,70 @@ const useViewTaskModal = () => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const joditConfig = useMemo(
|
||||||
|
() => ({
|
||||||
|
readonly: false,
|
||||||
|
height: 150,
|
||||||
|
toolbarAdaptive: false,
|
||||||
|
buttons: [
|
||||||
|
"bold", "italic", "underline", "strikethrough", "|",
|
||||||
|
"superscript", "subscript", "|",
|
||||||
|
"ul", "ol", "outdent", "indent", "|",
|
||||||
|
"font", "fontsize", "brush", "paragraph", "|",
|
||||||
|
"align", "hr", "|",
|
||||||
|
"table", "link", "image", "video", "symbols", "|",
|
||||||
|
"undo", "redo", "cut", "copy", "paste", "selectall", "eraser", "|",
|
||||||
|
"find", "source", "fullsize", "print", "preview",
|
||||||
|
],
|
||||||
|
autofocus: false,
|
||||||
|
preserveSelection: true,
|
||||||
|
askBeforePasteHTML: false,
|
||||||
|
askBeforePasteFromWord: false,
|
||||||
|
defaultActionOnPaste: "insert_clear_html",
|
||||||
|
spellcheck: true,
|
||||||
|
placeholder: "Заполните содержимое задания",
|
||||||
|
showCharsCounter: true,
|
||||||
|
showWordsCounter: true,
|
||||||
|
showXPathInStatusbar: false,
|
||||||
|
toolbarSticky: true,
|
||||||
|
toolbarButtonSize: "middle",
|
||||||
|
cleanHTML: {
|
||||||
|
removeEmptyElements: true,
|
||||||
|
replaceNBSP: false,
|
||||||
|
},
|
||||||
|
hotkeys: {
|
||||||
|
"ctrl + shift + f": "find",
|
||||||
|
"ctrl + b": "bold",
|
||||||
|
"ctrl + i": "italic",
|
||||||
|
"ctrl + u": "underline",
|
||||||
|
},
|
||||||
|
image: {
|
||||||
|
editSrc: true,
|
||||||
|
editTitle: true,
|
||||||
|
editAlt: true,
|
||||||
|
openOnDblClick: false,
|
||||||
|
},
|
||||||
|
video: {
|
||||||
|
allowedSources: ["youtube", "vimeo"],
|
||||||
|
},
|
||||||
|
uploader: {
|
||||||
|
insertImageAsBase64URI: true,
|
||||||
|
},
|
||||||
|
paste: {
|
||||||
|
insertAsBase64: true,
|
||||||
|
mimeTypes: ["image/png", "image/jpeg", "image/gif"],
|
||||||
|
maxFileSize: 5 * 1024 * 1024,
|
||||||
|
error: () => {
|
||||||
|
notification.error({
|
||||||
|
title: "Ошибка вставки",
|
||||||
|
description: "Файл слишком большой или неподдерживаемый формат.",
|
||||||
|
placement: "topRight",
|
||||||
|
});
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
[]
|
||||||
|
);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
selectedTaskToView,
|
selectedTaskToView,
|
||||||
@ -114,7 +303,16 @@ const useViewTaskModal = () => {
|
|||||||
isCurrentTaskFilesLoading,
|
isCurrentTaskFilesLoading,
|
||||||
isCurrentTaskFilesError,
|
isCurrentTaskFilesError,
|
||||||
downloadFile,
|
downloadFile,
|
||||||
downloadingFiles
|
downloadingFiles,
|
||||||
|
currentUser,
|
||||||
|
mySolutions,
|
||||||
|
editorRef,
|
||||||
|
joditConfig,
|
||||||
|
handleAddFile,
|
||||||
|
handleRemoveFile,
|
||||||
|
handleOk,
|
||||||
|
draftFiles,
|
||||||
|
handleDeleSolution
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@ -16,7 +16,7 @@ import {
|
|||||||
setSelectedTaskToUpdate,
|
setSelectedTaskToUpdate,
|
||||||
setSelectedTaskToView
|
setSelectedTaskToView
|
||||||
} from "../../../Redux/Slices/tasksSlice.js";
|
} from "../../../Redux/Slices/tasksSlice.js";
|
||||||
import {useGetTasksByCourseIdQuery} from "../../../Api/tasksApi.js";
|
import {useDeleteTaskMutation, useGetTasksByCourseIdQuery} from "../../../Api/tasksApi.js";
|
||||||
|
|
||||||
|
|
||||||
const useCourseDetailPage = (courseId) => {
|
const useCourseDetailPage = (courseId) => {
|
||||||
@ -58,6 +58,10 @@ const useCourseDetailPage = (courseId) => {
|
|||||||
deleteLesson,
|
deleteLesson,
|
||||||
] = useDeleteLessonMutation();
|
] = useDeleteLessonMutation();
|
||||||
|
|
||||||
|
const [
|
||||||
|
deleteTask,
|
||||||
|
] = useDeleteTaskMutation();
|
||||||
|
|
||||||
const handleDeleteLesson = async (lessonId) => {
|
const handleDeleteLesson = async (lessonId) => {
|
||||||
try {
|
try {
|
||||||
await deleteLesson(lessonId);
|
await deleteLesson(lessonId);
|
||||||
@ -77,7 +81,20 @@ const useCourseDetailPage = (courseId) => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const handleDeleteTask = async (taskId) => {
|
const handleDeleteTask = async (taskId) => {
|
||||||
|
try {
|
||||||
|
await deleteTask(taskId);
|
||||||
|
notification.success({
|
||||||
|
title: "Успешно",
|
||||||
|
description: "Задание удалено",
|
||||||
|
placement: "topRight",
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
notification.error({
|
||||||
|
title: "Ошибка",
|
||||||
|
description: error?.data?.detail || "Произошла ошибка при удалении задания",
|
||||||
|
placement: "topRight",
|
||||||
|
})
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|||||||
@ -11,6 +11,7 @@ import {statusesApi} from "../Api/statusesApi.js";
|
|||||||
import {coursesApi} from "../Api/coursesApi.js";
|
import {coursesApi} from "../Api/coursesApi.js";
|
||||||
import {lessonsApi} from "../Api/lessonsApi.js";
|
import {lessonsApi} from "../Api/lessonsApi.js";
|
||||||
import {tasksApi} from "../Api/tasksApi.js";
|
import {tasksApi} from "../Api/tasksApi.js";
|
||||||
|
import {solutionsApi} from "../Api/solutionsApi.js";
|
||||||
|
|
||||||
export const store = configureStore({
|
export const store = configureStore({
|
||||||
reducer: {
|
reducer: {
|
||||||
@ -32,6 +33,8 @@ export const store = configureStore({
|
|||||||
|
|
||||||
tasks: tasksReducer,
|
tasks: tasksReducer,
|
||||||
[tasksApi.reducerPath]: tasksApi.reducer,
|
[tasksApi.reducerPath]: tasksApi.reducer,
|
||||||
|
|
||||||
|
[solutionsApi.reducerPath]: solutionsApi.reducer,
|
||||||
},
|
},
|
||||||
middleware: (getDefaultMiddleware) => (
|
middleware: (getDefaultMiddleware) => (
|
||||||
getDefaultMiddleware().concat(
|
getDefaultMiddleware().concat(
|
||||||
@ -42,6 +45,7 @@ export const store = configureStore({
|
|||||||
coursesApi.middleware,
|
coursesApi.middleware,
|
||||||
lessonsApi.middleware,
|
lessonsApi.middleware,
|
||||||
tasksApi.middleware,
|
tasksApi.middleware,
|
||||||
|
solutionsApi.middleware,
|
||||||
)
|
)
|
||||||
),
|
),
|
||||||
});
|
});
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user