a
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -54,7 +54,7 @@ def build_challenge_response(challenge: Challenge, game: Game) -> ChallengeRespo
|
||||
estimated_time=challenge.estimated_time,
|
||||
proof_type=challenge.proof_type,
|
||||
proof_hint=challenge.proof_hint,
|
||||
game=GameShort(id=game.id, title=game.title, cover_url=None),
|
||||
game=GameShort(id=game.id, title=game.title, cover_url=None, download_url=game.download_url),
|
||||
is_generated=challenge.is_generated,
|
||||
created_at=challenge.created_at,
|
||||
status=challenge.status,
|
||||
|
||||
@@ -937,6 +937,7 @@ def assignment_to_response(assignment: Assignment) -> AssignmentResponse:
|
||||
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,
|
||||
|
||||
@@ -315,7 +315,7 @@ async def spin_wheel(marathon_id: int, current_user: CurrentUser, db: DbSession)
|
||||
estimated_time=ch.estimated_time,
|
||||
proof_type=ch.proof_type,
|
||||
proof_hint=ch.proof_hint,
|
||||
game=GameShort(id=game.id, title=game.title, cover_url=None, game_type=game.game_type),
|
||||
game=GameShort(id=game.id, title=game.title, cover_url=None, download_url=game.download_url, game_type=game.game_type),
|
||||
is_generated=ch.is_generated,
|
||||
created_at=ch.created_at,
|
||||
)
|
||||
@@ -339,7 +339,7 @@ async def spin_wheel(marathon_id: int, current_user: CurrentUser, db: DbSession)
|
||||
estimated_time=challenge.estimated_time,
|
||||
proof_type=challenge.proof_type,
|
||||
proof_hint=challenge.proof_hint,
|
||||
game=GameShort(id=game.id, title=game.title, cover_url=None, game_type=game.game_type),
|
||||
game=GameShort(id=game.id, title=game.title, cover_url=None, download_url=game.download_url, game_type=game.game_type),
|
||||
is_generated=challenge.is_generated,
|
||||
created_at=challenge.created_at,
|
||||
),
|
||||
@@ -392,7 +392,7 @@ async def get_current_assignment(marathon_id: int, current_user: CurrentUser, db
|
||||
estimated_time=ba.challenge.estimated_time,
|
||||
proof_type=ba.challenge.proof_type,
|
||||
proof_hint=ba.challenge.proof_hint,
|
||||
game=GameShort(id=game.id, title=game.title, cover_url=None, game_type=game.game_type),
|
||||
game=GameShort(id=game.id, title=game.title, cover_url=None, download_url=game.download_url, game_type=game.game_type),
|
||||
is_generated=ba.challenge.is_generated,
|
||||
created_at=ba.challenge.created_at,
|
||||
),
|
||||
@@ -406,7 +406,7 @@ async def get_current_assignment(marathon_id: int, current_user: CurrentUser, db
|
||||
return AssignmentResponse(
|
||||
id=assignment.id,
|
||||
challenge=None,
|
||||
game=GameShort(id=game.id, title=game.title, cover_url=None, game_type=game.game_type),
|
||||
game=GameShort(id=game.id, title=game.title, cover_url=None, download_url=game.download_url, game_type=game.game_type),
|
||||
is_playthrough=True,
|
||||
playthrough_info=PlaythroughInfo(
|
||||
description=game.playthrough_description,
|
||||
@@ -445,7 +445,7 @@ async def get_current_assignment(marathon_id: int, current_user: CurrentUser, db
|
||||
estimated_time=challenge.estimated_time,
|
||||
proof_type=challenge.proof_type,
|
||||
proof_hint=challenge.proof_hint,
|
||||
game=GameShort(id=game.id, title=game.title, cover_url=None, game_type=game.game_type),
|
||||
game=GameShort(id=game.id, title=game.title, cover_url=None, download_url=game.download_url, game_type=game.game_type),
|
||||
is_generated=challenge.is_generated,
|
||||
created_at=challenge.created_at,
|
||||
),
|
||||
@@ -467,7 +467,8 @@ async def complete_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 regular assignment with proof (not event assignments)"""
|
||||
# Get assignment with all needed relationships
|
||||
@@ -497,42 +498,68 @@ async def complete_assignment(
|
||||
if assignment.is_event_assignment:
|
||||
raise HTTPException(status_code=400, detail="Use /event-assignments/{id}/complete for event assignments")
|
||||
|
||||
# For playthrough: need either file or URL or comment (proof is flexible)
|
||||
# For challenges: need either file or URL
|
||||
# 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)
|
||||
|
||||
# For playthrough: need either file(s) or URL or comment (proof is flexible)
|
||||
# For challenges: need either file(s) or URL
|
||||
if assignment.is_playthrough:
|
||||
if not proof_file and not proof_url and not comment:
|
||||
if not all_files and not proof_url and not comment:
|
||||
raise HTTPException(status_code=400, detail="Proof is required (file, URL, or comment)")
|
||||
else:
|
||||
if not proof_file and not proof_url:
|
||||
if not all_files and not proof_url:
|
||||
raise HTTPException(status_code=400, detail="Proof is required (file or URL)")
|
||||
|
||||
# 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 AssignmentProof
|
||||
|
||||
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"{assignment_id}_{idx}", file.filename)
|
||||
file_path = await storage_service.upload_file(
|
||||
content=contents,
|
||||
folder="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 AssignmentProof record
|
||||
proof_record = AssignmentProof(
|
||||
assignment_id=assignment_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(assignment_id, proof_file.filename)
|
||||
file_path = await storage_service.upload_file(
|
||||
content=contents,
|
||||
folder="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:
|
||||
assignment.proof_path = file_path
|
||||
|
||||
assignment.proof_path = file_path
|
||||
else:
|
||||
# Set proof URL if provided
|
||||
if proof_url:
|
||||
assignment.proof_url = proof_url
|
||||
|
||||
assignment.proof_comment = comment
|
||||
@@ -908,7 +935,7 @@ async def get_my_history(
|
||||
estimated_time=ba.challenge.estimated_time,
|
||||
proof_type=ba.challenge.proof_type,
|
||||
proof_hint=ba.challenge.proof_hint,
|
||||
game=GameShort(id=game.id, title=game.title, cover_url=None, game_type=game.game_type),
|
||||
game=GameShort(id=game.id, title=game.title, cover_url=None, download_url=game.download_url, game_type=game.game_type),
|
||||
is_generated=ba.challenge.is_generated,
|
||||
created_at=ba.challenge.created_at,
|
||||
),
|
||||
@@ -924,7 +951,7 @@ async def get_my_history(
|
||||
responses.append(AssignmentResponse(
|
||||
id=a.id,
|
||||
challenge=None,
|
||||
game=GameShort(id=game.id, title=game.title, cover_url=None, game_type=game.game_type),
|
||||
game=GameShort(id=game.id, title=game.title, cover_url=None, download_url=game.download_url, game_type=game.game_type),
|
||||
is_playthrough=True,
|
||||
playthrough_info=PlaythroughInfo(
|
||||
description=game.playthrough_description,
|
||||
@@ -959,6 +986,7 @@ async def get_my_history(
|
||||
id=a.challenge.game.id,
|
||||
title=a.challenge.game.title,
|
||||
cover_url=None,
|
||||
download_url=a.challenge.game.download_url,
|
||||
game_type=a.challenge.game.game_type,
|
||||
),
|
||||
is_generated=a.challenge.is_generated,
|
||||
|
||||
@@ -5,6 +5,7 @@ from app.models.game import Game, GameStatus, GameType
|
||||
from app.models.challenge import Challenge, ChallengeType, Difficulty, ProofType
|
||||
from app.models.assignment import Assignment, AssignmentStatus
|
||||
from app.models.bonus_assignment import BonusAssignment, BonusAssignmentStatus
|
||||
from app.models.assignment_proof import AssignmentProof, BonusAssignmentProof
|
||||
from app.models.activity import Activity, ActivityType
|
||||
from app.models.event import Event, EventType
|
||||
from app.models.swap_request import SwapRequest, SwapRequestStatus
|
||||
@@ -32,6 +33,8 @@ __all__ = [
|
||||
"AssignmentStatus",
|
||||
"BonusAssignment",
|
||||
"BonusAssignmentStatus",
|
||||
"AssignmentProof",
|
||||
"BonusAssignmentProof",
|
||||
"Activity",
|
||||
"ActivityType",
|
||||
"Event",
|
||||
|
||||
@@ -42,3 +42,4 @@ class Assignment(Base):
|
||||
event: Mapped["Event | None"] = relationship("Event", back_populates="assignments")
|
||||
dispute: Mapped["Dispute | None"] = relationship("Dispute", back_populates="assignment", uselist=False, cascade="all, delete-orphan", passive_deletes=True)
|
||||
bonus_assignments: Mapped[list["BonusAssignment"]] = relationship("BonusAssignment", back_populates="main_assignment", cascade="all, delete-orphan")
|
||||
proof_files: Mapped[list["AssignmentProof"]] = relationship("AssignmentProof", back_populates="assignment", cascade="all, delete-orphan", order_by="AssignmentProof.order_index")
|
||||
|
||||
47
backend/app/models/assignment_proof.py
Normal file
47
backend/app/models/assignment_proof.py
Normal file
@@ -0,0 +1,47 @@
|
||||
from datetime import datetime
|
||||
from sqlalchemy import String, ForeignKey, Integer, DateTime
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
|
||||
from app.core.database import Base
|
||||
|
||||
|
||||
class AssignmentProof(Base):
|
||||
"""Файлы-доказательства для заданий (множественные пруфы)"""
|
||||
__tablename__ = "assignment_proofs"
|
||||
|
||||
id: Mapped[int] = mapped_column(primary_key=True)
|
||||
assignment_id: Mapped[int] = mapped_column(
|
||||
ForeignKey("assignments.id", ondelete="CASCADE"),
|
||||
index=True
|
||||
)
|
||||
file_path: Mapped[str] = mapped_column(String(500)) # Путь к файлу в хранилище
|
||||
file_type: Mapped[str] = mapped_column(String(20)) # image или video
|
||||
order_index: Mapped[int] = mapped_column(Integer, default=0) # Порядок отображения
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow)
|
||||
|
||||
# Relationships
|
||||
assignment: Mapped["Assignment"] = relationship(
|
||||
"Assignment",
|
||||
back_populates="proof_files"
|
||||
)
|
||||
|
||||
|
||||
class BonusAssignmentProof(Base):
|
||||
"""Файлы-доказательства для бонусных заданий (множественные пруфы)"""
|
||||
__tablename__ = "bonus_assignment_proofs"
|
||||
|
||||
id: Mapped[int] = mapped_column(primary_key=True)
|
||||
bonus_assignment_id: Mapped[int] = mapped_column(
|
||||
ForeignKey("bonus_assignments.id", ondelete="CASCADE"),
|
||||
index=True
|
||||
)
|
||||
file_path: Mapped[str] = mapped_column(String(500)) # Путь к файлу в хранилище
|
||||
file_type: Mapped[str] = mapped_column(String(20)) # image или video
|
||||
order_index: Mapped[int] = mapped_column(Integer, default=0) # Порядок отображения
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow)
|
||||
|
||||
# Relationships
|
||||
bonus_assignment: Mapped["BonusAssignment"] = relationship(
|
||||
"BonusAssignment",
|
||||
back_populates="proof_files"
|
||||
)
|
||||
@@ -46,3 +46,9 @@ class BonusAssignment(Base):
|
||||
back_populates="bonus_assignment",
|
||||
uselist=False,
|
||||
)
|
||||
proof_files: Mapped[list["BonusAssignmentProof"]] = relationship(
|
||||
"BonusAssignmentProof",
|
||||
back_populates="bonus_assignment",
|
||||
cascade="all, delete-orphan",
|
||||
order_by="BonusAssignmentProof.order_index"
|
||||
)
|
||||
|
||||
@@ -5,6 +5,17 @@ from app.schemas.game import GameResponse, GameShort, PlaythroughInfo
|
||||
from app.schemas.challenge import ChallengeResponse
|
||||
|
||||
|
||||
class ProofFileResponse(BaseModel):
|
||||
"""Информация о файле-доказательстве"""
|
||||
id: int
|
||||
file_type: str # image или video
|
||||
order_index: int
|
||||
created_at: datetime
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
|
||||
class AssignmentBase(BaseModel):
|
||||
pass
|
||||
|
||||
@@ -20,6 +31,8 @@ class BonusAssignmentResponse(BaseModel):
|
||||
challenge: ChallengeResponse
|
||||
status: str # pending, completed
|
||||
proof_url: str | None = None
|
||||
proof_image_url: str | None = None # Legacy, for backward compatibility
|
||||
proof_files: list[ProofFileResponse] = [] # Multiple uploaded files
|
||||
proof_comment: str | None = None
|
||||
points_earned: int = 0
|
||||
completed_at: datetime | None = None
|
||||
|
||||
@@ -4,6 +4,7 @@ from pydantic import BaseModel, Field
|
||||
|
||||
from app.schemas.user import UserPublic
|
||||
from app.schemas.challenge import ChallengeResponse, GameShort
|
||||
from app.schemas.assignment import ProofFileResponse
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from app.schemas.game import PlaythroughInfo
|
||||
@@ -75,7 +76,8 @@ class AssignmentDetailResponse(BaseModel):
|
||||
participant: UserPublic
|
||||
status: str
|
||||
proof_url: str | None # External URL (YouTube, etc.)
|
||||
proof_image_url: str | None # Uploaded file URL
|
||||
proof_image_url: str | None # Uploaded file URL (legacy, for backward compatibility)
|
||||
proof_files: list[ProofFileResponse] = [] # Multiple uploaded files
|
||||
proof_comment: str | None
|
||||
points_earned: int
|
||||
streak_at_completion: int | None
|
||||
|
||||
@@ -56,6 +56,7 @@ class GameShort(BaseModel):
|
||||
id: int
|
||||
title: str
|
||||
cover_url: str | None = None
|
||||
download_url: str
|
||||
game_type: str = "challenges"
|
||||
|
||||
class Config:
|
||||
|
||||
@@ -15,7 +15,7 @@ from app.core.config import settings
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
StorageFolder = Literal["avatars", "covers", "proofs"]
|
||||
StorageFolder = Literal["avatars", "covers", "proofs", "bonus_proofs"]
|
||||
|
||||
|
||||
class StorageService:
|
||||
|
||||
Reference in New Issue
Block a user