This commit is contained in:
2026-01-03 00:12:07 +07:00
parent d295ff2aff
commit 7a3576aec0
18 changed files with 844 additions and 125 deletions

View File

@@ -101,7 +101,9 @@ async def get_assignment_detail(
.options(
selectinload(Assignment.challenge).selectinload(Challenge.game),
selectinload(Assignment.game), # For playthrough
selectinload(Assignment.proof_files), # Load multiple proof files
selectinload(Assignment.bonus_assignments).selectinload(BonusAssignment.challenge), # For playthrough
selectinload(Assignment.bonus_assignments).selectinload(BonusAssignment.proof_files), # Load bonus proof files
selectinload(Assignment.bonus_assignments).selectinload(BonusAssignment.dispute).selectinload(Dispute.raised_by), # Bonus disputes
selectinload(Assignment.bonus_assignments).selectinload(BonusAssignment.dispute).selectinload(Dispute.comments).selectinload(DisputeComment.user),
selectinload(Assignment.bonus_assignments).selectinload(BonusAssignment.dispute).selectinload(Dispute.votes).selectinload(DisputeVote.user),
@@ -176,6 +178,18 @@ async def get_assignment_detail(
time_since_bonus_completion = datetime.utcnow() - ba.completed_at
bonus_can_dispute = time_since_bonus_completion < timedelta(hours=DISPUTE_WINDOW_HOURS)
# Build bonus proof files list
from app.schemas.assignment import ProofFileResponse
bonus_proof_files = [
ProofFileResponse(
id=pf.id,
file_type=pf.file_type,
order_index=pf.order_index,
created_at=pf.created_at,
)
for pf in ba.proof_files
]
bonus_challenges.append({
"id": ba.id,
"challenge": {
@@ -189,6 +203,7 @@ async def get_assignment_detail(
"status": ba.status,
"proof_url": ba.proof_url,
"proof_image_url": storage_service.get_url(ba.proof_path, "bonus_proofs") if ba.proof_path else None,
"proof_files": bonus_proof_files,
"proof_comment": ba.proof_comment,
"points_earned": ba.points_earned,
"completed_at": ba.completed_at.isoformat() if ba.completed_at else None,
@@ -196,6 +211,18 @@ async def get_assignment_detail(
"dispute": build_dispute_response(ba.dispute, current_user.id) if ba.dispute else None,
})
# Build proof files list
from app.schemas.assignment import ProofFileResponse
proof_files_list = [
ProofFileResponse(
id=pf.id,
file_type=pf.file_type,
order_index=pf.order_index,
created_at=pf.created_at,
)
for pf in assignment.proof_files
]
return AssignmentDetailResponse(
id=assignment.id,
challenge=None,
@@ -203,6 +230,7 @@ async def get_assignment_detail(
id=game.id,
title=game.title,
cover_url=storage_service.get_url(game.cover_path, "covers"),
download_url=game.download_url,
game_type=game.game_type,
),
is_playthrough=True,
@@ -216,6 +244,7 @@ async def get_assignment_detail(
status=assignment.status,
proof_url=assignment.proof_url,
proof_image_url=proof_image_url,
proof_files=proof_files_list,
proof_comment=assignment.proof_comment,
points_earned=assignment.points_earned,
streak_at_completion=assignment.streak_at_completion,
@@ -230,6 +259,18 @@ async def get_assignment_detail(
challenge = assignment.challenge
game = challenge.game
# Build proof files list
from app.schemas.assignment import ProofFileResponse
proof_files_list = [
ProofFileResponse(
id=pf.id,
file_type=pf.file_type,
order_index=pf.order_index,
created_at=pf.created_at,
)
for pf in assignment.proof_files
]
return AssignmentDetailResponse(
id=assignment.id,
challenge=ChallengeResponse(
@@ -246,6 +287,7 @@ async def get_assignment_detail(
id=game.id,
title=game.title,
cover_url=storage_service.get_url(game.cover_path, "covers"),
download_url=game.download_url,
),
is_generated=challenge.is_generated,
created_at=challenge.created_at,
@@ -254,6 +296,7 @@ async def get_assignment_detail(
status=assignment.status,
proof_url=assignment.proof_url,
proof_image_url=proof_image_url,
proof_files=proof_files_list,
proof_comment=assignment.proof_comment,
points_earned=assignment.points_earned,
streak_at_completion=assignment.streak_at_completion,
@@ -490,6 +533,212 @@ async def get_bonus_proof_media(
)
@router.get("/assignments/{assignment_id}/proof-files/{proof_file_id}/media")
async def get_assignment_proof_file_media(
assignment_id: int,
proof_file_id: int,
request: Request,
current_user: CurrentUser,
db: DbSession,
):
"""Stream a specific proof file (image or video) for an assignment"""
from app.models import AssignmentProof, Game
# Get proof file
result = await db.execute(
select(AssignmentProof)
.options(
selectinload(AssignmentProof.assignment).selectinload(Assignment.challenge).selectinload(Challenge.game),
selectinload(AssignmentProof.assignment).selectinload(Assignment.game), # For playthrough
)
.where(
AssignmentProof.id == proof_file_id,
AssignmentProof.assignment_id == assignment_id,
)
)
proof_file = result.scalar_one_or_none()
if not proof_file:
raise HTTPException(status_code=404, detail="Proof file not found")
assignment = proof_file.assignment
# Get marathon_id based on assignment type
if assignment.is_playthrough:
marathon_id = assignment.game.marathon_id
else:
marathon_id = assignment.challenge.game.marathon_id
# Check user is participant of the marathon
result = await db.execute(
select(Participant).where(
Participant.user_id == current_user.id,
Participant.marathon_id == marathon_id,
)
)
participant = result.scalar_one_or_none()
if not participant:
raise HTTPException(status_code=403, detail="You are not a participant of this marathon")
# Get file from storage
file_data = await storage_service.get_file(proof_file.file_path, "proofs")
if not file_data:
raise HTTPException(status_code=404, detail="Proof file not found in storage")
content, content_type = file_data
file_size = len(content)
# Check if it's a video and handle Range requests
is_video = content_type.startswith("video/")
if is_video:
range_header = request.headers.get("range")
if range_header:
# Parse range header
range_match = range_header.replace("bytes=", "").split("-")
start = int(range_match[0]) if range_match[0] else 0
end = int(range_match[1]) if len(range_match) > 1 and range_match[1] else file_size - 1
chunk = content[start:end + 1]
return Response(
content=chunk,
status_code=206,
media_type=content_type,
headers={
"Content-Range": f"bytes {start}-{end}/{file_size}",
"Accept-Ranges": "bytes",
"Content-Length": str(len(chunk)),
"Cache-Control": "public, max-age=31536000",
}
)
return Response(
content=content,
media_type=content_type,
headers={
"Accept-Ranges": "bytes",
"Content-Length": str(file_size),
"Cache-Control": "public, max-age=31536000",
}
)
# For images, just return the content
return Response(
content=content,
media_type=content_type,
headers={
"Cache-Control": "public, max-age=31536000",
}
)
@router.get("/assignments/{assignment_id}/bonus/{bonus_id}/proof-files/{proof_file_id}/media")
async def get_bonus_proof_file_media(
assignment_id: int,
bonus_id: int,
proof_file_id: int,
request: Request,
current_user: CurrentUser,
db: DbSession,
):
"""Stream a specific proof file (image or video) for a bonus assignment"""
from app.models import BonusAssignmentProof, Game
# Get proof file
result = await db.execute(
select(BonusAssignmentProof)
.options(
selectinload(BonusAssignmentProof.bonus_assignment)
.selectinload(BonusAssignment.main_assignment)
.selectinload(Assignment.game), # For playthrough
)
.where(
BonusAssignmentProof.id == proof_file_id,
BonusAssignmentProof.bonus_assignment_id == bonus_id,
)
)
proof_file = result.scalar_one_or_none()
if not proof_file:
raise HTTPException(status_code=404, detail="Proof file not found")
bonus = proof_file.bonus_assignment
assignment = bonus.main_assignment
# Check assignment matches
if assignment.id != assignment_id:
raise HTTPException(status_code=404, detail="Proof file not found for this assignment")
# Get marathon_id (bonus assignments are always for playthrough)
marathon_id = assignment.game.marathon_id
# Check user is participant of the marathon
result = await db.execute(
select(Participant).where(
Participant.user_id == current_user.id,
Participant.marathon_id == marathon_id,
)
)
participant = result.scalar_one_or_none()
if not participant:
raise HTTPException(status_code=403, detail="You are not a participant of this marathon")
# Get file from storage
file_data = await storage_service.get_file(proof_file.file_path, "bonus_proofs")
if not file_data:
raise HTTPException(status_code=404, detail="Proof file not found in storage")
content, content_type = file_data
file_size = len(content)
# Check if it's a video and handle Range requests
is_video = content_type.startswith("video/")
if is_video:
range_header = request.headers.get("range")
if range_header:
# Parse range header
range_match = range_header.replace("bytes=", "").split("-")
start = int(range_match[0]) if range_match[0] else 0
end = int(range_match[1]) if len(range_match) > 1 and range_match[1] else file_size - 1
chunk = content[start:end + 1]
return Response(
content=chunk,
status_code=206,
media_type=content_type,
headers={
"Content-Range": f"bytes {start}-{end}/{file_size}",
"Accept-Ranges": "bytes",
"Content-Length": str(len(chunk)),
"Cache-Control": "public, max-age=31536000",
}
)
return Response(
content=content,
media_type=content_type,
headers={
"Accept-Ranges": "bytes",
"Content-Length": str(file_size),
"Cache-Control": "public, max-age=31536000",
}
)
# For images, just return the content
return Response(
content=content,
media_type=content_type,
headers={
"Cache-Control": "public, max-age=31536000",
}
)
@router.post("/bonus-assignments/{bonus_id}/dispute", response_model=DisputeResponse)
async def create_bonus_dispute(
bonus_id: int,
@@ -891,6 +1140,7 @@ async def get_returned_assignments(
id=a.challenge.game.id,
title=a.challenge.game.title,
cover_url=storage_service.get_url(a.challenge.game.cover_path, "covers"),
download_url=a.challenge.game.download_url,
),
is_generated=a.challenge.is_generated,
created_at=a.challenge.created_at,
@@ -951,6 +1201,7 @@ async def get_bonus_assignments(
id=assignment.game.id,
title=assignment.game.title,
cover_url=storage_service.get_url(assignment.game.cover_path, "covers") if hasattr(assignment.game, 'cover_path') else None,
download_url=assignment.game.download_url,
game_type=assignment.game.game_type,
),
is_generated=ba.challenge.is_generated,
@@ -974,7 +1225,8 @@ async def complete_bonus_assignment(
db: DbSession,
proof_url: str | None = Form(None),
comment: str | None = Form(None),
proof_file: UploadFile | None = File(None),
proof_file: UploadFile | None = File(None), # Legacy single file support
proof_files: list[UploadFile] = File([]), # Multiple files support
):
"""Complete a bonus challenge for a playthrough assignment"""
from app.core.config import settings
@@ -1020,40 +1272,66 @@ async def complete_bonus_assignment(
if bonus_assignment.status == BonusAssignmentStatus.COMPLETED.value:
raise HTTPException(status_code=400, detail="This bonus challenge is already completed")
# Validate proof (need file, URL, or comment)
if not proof_file and not proof_url and not comment:
# Combine legacy single file with new multiple files
all_files = []
if proof_file:
all_files.append(proof_file)
if proof_files:
all_files.extend(proof_files)
# Validate proof (need file(s), URL, or comment)
if not all_files and not proof_url and not comment:
raise HTTPException(
status_code=400,
detail="Необходимо прикрепить файл, ссылку или комментарий"
)
# Handle file upload
if proof_file:
contents = await proof_file.read()
if len(contents) > settings.MAX_UPLOAD_SIZE:
raise HTTPException(
status_code=400,
detail=f"File too large. Maximum size is {settings.MAX_UPLOAD_SIZE // 1024 // 1024} MB",
# Handle multiple file uploads
if all_files:
from app.models import BonusAssignmentProof
for idx, file in enumerate(all_files):
contents = await file.read()
if len(contents) > settings.MAX_UPLOAD_SIZE:
raise HTTPException(
status_code=400,
detail=f"File {file.filename} too large. Maximum size is {settings.MAX_UPLOAD_SIZE // 1024 // 1024} MB",
)
ext = file.filename.split(".")[-1].lower() if file.filename else "jpg"
if ext not in settings.ALLOWED_EXTENSIONS:
raise HTTPException(
status_code=400,
detail=f"Invalid file type for {file.filename}. Allowed: {settings.ALLOWED_EXTENSIONS}",
)
# Determine file type (image or video)
file_type = "video" if ext in ["mp4", "webm", "mov", "avi"] else "image"
# Upload file to storage
filename = storage_service.generate_filename(f"bonus_{bonus_id}_{idx}", file.filename)
file_path = await storage_service.upload_file(
content=contents,
folder="bonus_proofs",
filename=filename,
content_type=file.content_type or "application/octet-stream",
)
ext = proof_file.filename.split(".")[-1].lower() if proof_file.filename else "jpg"
if ext not in settings.ALLOWED_EXTENSIONS:
raise HTTPException(
status_code=400,
detail=f"Invalid file type. Allowed: {settings.ALLOWED_EXTENSIONS}",
# Create BonusAssignmentProof record
proof_record = BonusAssignmentProof(
bonus_assignment_id=bonus_id,
file_path=file_path,
file_type=file_type,
order_index=idx
)
db.add(proof_record)
# Upload file to storage
filename = storage_service.generate_filename(bonus_id, proof_file.filename)
file_path = await storage_service.upload_file(
content=contents,
folder="bonus_proofs",
filename=filename,
content_type=proof_file.content_type or "application/octet-stream",
)
# Legacy: set proof_path on first file for backward compatibility
if idx == 0:
bonus_assignment.proof_path = file_path
bonus_assignment.proof_path = file_path
else:
# Set proof URL if provided
if proof_url:
bonus_assignment.proof_url = proof_url
# Complete the bonus assignment