From 57f8028dd6847dabf1470319bb376fc89a6875df Mon Sep 17 00:00:00 2001 From: codebestia Date: Sun, 22 Mar 2026 00:40:33 +0100 Subject: [PATCH] feat: dispute resolution system --- backend/app/api/disputes.py | 72 ++++++ backend/app/main.py | 4 + backend/app/models/dispute.py | 248 +++++++++----------- backend/app/services/dispute_service.py | 255 +++++++++++++++++++++ backend/app/services/reputation_service.py | 51 +++++ backend/tests/test_dispute_system.py | 89 +++++++ 6 files changed, 575 insertions(+), 144 deletions(-) create mode 100644 backend/app/api/disputes.py create mode 100644 backend/app/services/dispute_service.py create mode 100644 backend/tests/test_dispute_system.py diff --git a/backend/app/api/disputes.py b/backend/app/api/disputes.py new file mode 100644 index 00000000..f82d7459 --- /dev/null +++ b/backend/app/api/disputes.py @@ -0,0 +1,72 @@ +from typing import Optional, List +from fastapi import APIRouter, Depends, HTTPException, Query, status + +from app.auth import get_current_user_id +from app.constants import INTERNAL_SYSTEM_USER_ID +from app.models.dispute import ( + DisputeCreate, DisputeResponse, DisputeListResponse, + DisputeEvidenceCreate, DisputeResolve +) +from app.services import dispute_service + +router = APIRouter(prefix="/disputes", tags=["disputes"]) + +@router.post("", response_model=DisputeResponse, status_code=201) +async def create_dispute( + data: DisputeCreate, + user_id: str = Depends(get_current_user_id) +): + """Initiate a dispute for a rejected submission.""" + dispute, error = await dispute_service.initiate_dispute(data, user_id) + if error: + raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=error) + return dispute + +@router.get("/{dispute_id}", response_model=DisputeResponse) +async def get_dispute( + dispute_id: str, + user_id: str = Depends(get_current_user_id) +): + """Retrieve dispute details.""" + dispute = await dispute_service.get_dispute(dispute_id) + if not dispute: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Dispute not found") + + # Access control: Contributor, Creator, or Admin + if user_id not in [dispute.contributor_id, dispute.creator_id, INTERNAL_SYSTEM_USER_ID]: + raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Access denied") + + return dispute + +@router.post("/{dispute_id}/evidence", status_code=200) +async def submit_evidence( + dispute_id: str, + data: DisputeEvidenceCreate, + user_id: str = Depends(get_current_user_id) +): + """Submit evidence (link or explanation) for an open dispute.""" + # Logic is handled in service (which checks dispute state) + success, error = await dispute_service.submit_evidence( + dispute_id, user_id, data.type, data.content + ) + if error: + raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=error) + return {"status": "success", "message": "Evidence submitted"} + +@router.post("/{dispute_id}/resolve", response_model=DisputeResponse) +async def resolve_dispute( + dispute_id: str, + data: DisputeResolve, + user_id: str = Depends(get_current_user_id) +): + """Resolve a dispute (Admin only).""" + # Simple admin check for now + if user_id != INTERNAL_SYSTEM_USER_ID: + raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Only platform admins can resolve disputes") + + success, error = await dispute_service.resolve_dispute(dispute_id, data, user_id) + if error: + raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=error) + + # Return updated dispute + return await dispute_service.get_dispute(dispute_id) diff --git a/backend/app/main.py b/backend/app/main.py index 4f810483..f94cc883 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -23,6 +23,7 @@ from app.api.agents import router as agents_router from app.api.stats import router as stats_router from app.api.escrow import router as escrow_router +from app.api.disputes import router as disputes_router from app.database import init_db, close_db, engine from app.services.auth_service import AuthError from app.services.websocket_manager import manager as ws_manager @@ -279,6 +280,9 @@ async def value_error_handler(request: Request, exc: ValueError): # Escrow: /api/escrow/* app.include_router(escrow_router, prefix="/api") +# Disputes: /api/disputes/* +app.include_router(disputes_router, prefix="/api") + # Stats: /api/stats (public endpoint) app.include_router(stats_router, prefix="/api") diff --git a/backend/app/models/dispute.py b/backend/app/models/dispute.py index 7708c649..c83f36e4 100644 --- a/backend/app/models/dispute.py +++ b/backend/app/models/dispute.py @@ -1,212 +1,172 @@ -"""Dispute database and Pydantic models.""" +"""Dispute database and Pydantic models for the SolFoundry platform.""" import uuid from datetime import datetime, timezone -from typing import Optional, List +from typing import Optional, List, Dict, Any from enum import Enum from pydantic import BaseModel, Field, field_validator -from sqlalchemy import Column, String, DateTime, JSON, Text, ForeignKey, Index - +from sqlalchemy import Column, String, DateTime, JSON, Text, ForeignKey, Index, Float, Numeric from app.database import Base, GUID +# --------------------------------------------------------------------------- +# Enums +# --------------------------------------------------------------------------- + class DisputeStatus(str, Enum): - """DisputeStatus.""" - PENDING = "pending" - UNDER_REVIEW = "under_review" + """Lifecycle status of a dispute.""" + OPENED = "opened" + EVIDENCE = "evidence" + MEDIATION = "mediation" RESOLVED = "resolved" - CLOSED = "closed" -class DisputeOutcome(str, Enum): - """DisputeOutcome.""" - APPROVED = "approved" - REJECTED = "rejected" - CANCELLED = "cancelled" +class DisputeResolution(str, Enum): + """Outcome of a dispute resolution.""" + PAYOUT = "payout" # Released to contributor + REFUND = "refund" # Refunded to creator + SPLIT = "split" # Split between both + NONE = "none" -class DisputeReason(str, Enum): - """DisputeReason.""" - INCORRECT_REVIEW = "incorrect_review" - PLAGIARISM = "plagiarism" - RULE_VIOLATION = "rule_violation" - TECHNICAL_ISSUE = "technical_issue" - UNFAIR_COMPETITION = "unfair_competition" - OTHER = "other" - +# --------------------------------------------------------------------------- +# SQLAlchemy Models +# --------------------------------------------------------------------------- class DisputeDB(Base): - """DisputeDB.""" + """PostgreSQL table for bounty disputes.""" __tablename__ = "disputes" id = Column(GUID(), primary_key=True, default=uuid.uuid4) bounty_id = Column( - GUID(), ForeignKey("bounties.id", ondelete="CASCADE"), nullable=False + GUID(), ForeignKey("bounties.id", ondelete="CASCADE"), nullable=False, index=True ) - submitter_id = Column(GUID(), nullable=False) - reason = Column(String(50), nullable=False) - description = Column(Text, nullable=False) - evidence_links = Column(JSON, default=list, nullable=False) - status = Column(String(20), nullable=False, default="pending") - outcome = Column(String(20), nullable=True) - reviewer_id = Column(GUID(), nullable=True) - review_notes = Column(Text, nullable=True) - resolution_action = Column(Text, nullable=True) + submission_id = Column(String(100), nullable=False, index=True) + contributor_id = Column(String(100), nullable=False, index=True) + creator_id = Column(String(100), nullable=False, index=True) + + status = Column(String(20), default=DisputeStatus.OPENED.value, nullable=False, index=True) + reason = Column(String(1000), nullable=False) + + # Store evidence as a list of EvidenceItem objects + evidence = Column(JSON, default=list, nullable=False) + + # Snapshot from the submission record + ai_score = Column(Float, default=0.0) + + resolution = Column(String(20), default=DisputeResolution.NONE.value, nullable=False) + resolved_by = Column(String(100), nullable=True) # Admin ID or "system" + resolution_notes = Column(Text, nullable=True) + + # Financial split if resolution is SPLIT + contributor_share = Column(Float, default=0.0) # Percentage or Amount? Let's use Percentage (0.0 to 1.0) + creator_share = Column(Float, default=0.0) + created_at = Column( - DateTime(timezone=True), default=lambda: datetime.now(timezone.utc) + DateTime(timezone=True), default=lambda: datetime.now(timezone.utc), nullable=False ) updated_at = Column( DateTime(timezone=True), default=lambda: datetime.now(timezone.utc), onupdate=lambda: datetime.now(timezone.utc), + nullable=False ) resolved_at = Column(DateTime(timezone=True), nullable=True) - __table_args__ = ( - Index("ix_disputes_bounty_id", bounty_id), - Index("ix_disputes_status", status), - ) - class DisputeHistoryDB(Base): - """DisputeHistoryDB.""" + """Audit trail for dispute lifecycle transitions.""" __tablename__ = "dispute_history" id = Column(GUID(), primary_key=True, default=uuid.uuid4) dispute_id = Column( - GUID(), ForeignKey("disputes.id", ondelete="CASCADE"), nullable=False + GUID(), ForeignKey("disputes.id", ondelete="CASCADE"), nullable=False, index=True ) action = Column(String(50), nullable=False) previous_status = Column(String(20), nullable=True) new_status = Column(String(20), nullable=True) - actor_id = Column(GUID(), nullable=False) + actor_id = Column(String(100), nullable=False) notes = Column(Text, nullable=True) created_at = Column( - DateTime(timezone=True), default=lambda: datetime.now(timezone.utc) + DateTime(timezone=True), default=lambda: datetime.now(timezone.utc), nullable=False ) - __table_args__ = (Index("ix_dispute_history_dispute_id", dispute_id),) +# --------------------------------------------------------------------------- +# Pydantic Schemas +# --------------------------------------------------------------------------- class EvidenceItem(BaseModel): - """EvidenceItem.""" - type: str - url: Optional[str] = None - description: str = Field(..., min_length=1, max_length=500) - - -class DisputeBase(BaseModel): - """DisputeBase.""" - reason: str - description: str = Field(..., min_length=10, max_length=5000) - evidence_links: List[EvidenceItem] = Field(default_factory=list) - - @field_validator("reason") - @classmethod - def validate_reason(cls, v): - """Validate reason.""" - valid_reasons = {r.value for r in DisputeReason} - if v not in valid_reasons: - raise ValueError(f"Invalid reason: {v}") - return v - + """A single piece of evidence submitted for a dispute.""" + type: str = Field(..., examples=["link", "explanation", "screenshot"]) + content: str = Field(..., examples=["The PR satisfies section 2.1 of the requirements...", "https://github.com/PR-link"]) + actor_id: str + created_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc)) -class DisputeCreate(DisputeBase): - """DisputeCreate.""" - bounty_id: str = Field(..., description="ID of the bounty being disputed") - @field_validator("bounty_id") - @classmethod - def validate_bounty_id(cls, v): - """Validate bounty id.""" - if isinstance(v, str): - return v - return str(v) +class DisputeCreate(BaseModel): + """Payload for initiating a dispute.""" + bounty_id: str + submission_id: str + reason: str = Field(..., min_length=10, max_length=1000) -class DisputeUpdate(BaseModel): - """DisputeUpdate.""" - description: Optional[str] = Field(None, min_length=10, max_length=5000) - evidence_links: Optional[List[EvidenceItem]] = None +class DisputeEvidenceCreate(BaseModel): + """Payload for submitting evidence.""" + type: str = Field(..., pattern="^(link|explanation)$") + content: str = Field(..., min_length=1) class DisputeResolve(BaseModel): - """DisputeResolve.""" - outcome: str - review_notes: str = Field(..., min_length=1, max_length=5000) - resolution_action: Optional[str] = Field(None, max_length=2000) - - @field_validator("outcome") - @classmethod - def validate_outcome(cls, v): - """Validate outcome.""" - valid_outcomes = {o.value for o in DisputeOutcome} - if v not in valid_outcomes: - raise ValueError(f"Invalid outcome: {v}") - return v - - -class DisputeResponse(DisputeBase): - """DisputeResponse.""" - id: str - bounty_id: str - submitter_id: str - status: str - outcome: Optional[str] = None - reviewer_id: Optional[str] = None - review_notes: Optional[str] = None - resolution_action: Optional[str] = None + """Payload for resolving a dispute.""" + resolution: DisputeResolution + resolution_notes: str = Field(..., min_length=10, max_length=5000) + contributor_share: Optional[float] = 0.0 # 0.0 to 1.0 + creator_share: Optional[float] = 0.0 # 0.0 to 1.0 + + +class DisputeResponse(BaseModel): + """Full API response for a dispute.""" + id: uuid.UUID + bounty_id: uuid.UUID + submission_id: str + contributor_id: str + creator_id: str + status: DisputeStatus + reason: str + evidence: List[Dict[str, Any]] + ai_score: float + resolution: DisputeResolution + resolved_by: Optional[str] + resolution_notes: Optional[str] + contributor_share: float + creator_share: float created_at: datetime updated_at: datetime - resolved_at: Optional[datetime] = None - model_config = {"from_attributes": True} + resolved_at: Optional[datetime] + + class Config: + from_attributes = True class DisputeListItem(BaseModel): - """DisputeListItem.""" - id: str - bounty_id: str - submitter_id: str - reason: str - status: str - outcome: Optional[str] = None + """Compact dispute representation for lists.""" + id: uuid.UUID + bounty_id: uuid.UUID + submission_id: str + status: DisputeStatus + resolution: DisputeResolution created_at: datetime - resolved_at: Optional[datetime] = None - model_config = {"from_attributes": True} + resolved_at: Optional[datetime] + + class Config: + from_attributes = True class DisputeListResponse(BaseModel): - """DisputeListResponse.""" + """Paginated list of disputes.""" items: List[DisputeListItem] total: int skip: int limit: int - - -class DisputeHistoryItem(BaseModel): - """DisputeHistoryItem.""" - id: str - dispute_id: str - action: str - previous_status: Optional[str] = None - new_status: Optional[str] = None - actor_id: str - notes: Optional[str] = None - created_at: datetime - model_config = {"from_attributes": True} - - -class DisputeDetailResponse(DisputeResponse): - """DisputeDetailResponse.""" - history: List[DisputeHistoryItem] = [] - - -class DisputeStats(BaseModel): - """DisputeStats.""" - total_disputes: int = 0 - pending_disputes: int = 0 - resolved_disputes: int = 0 - approved_disputes: int = 0 - rejected_disputes: int = 0 - approval_rate: float = 0.0 diff --git a/backend/app/services/dispute_service.py b/backend/app/services/dispute_service.py new file mode 100644 index 00000000..399085c0 --- /dev/null +++ b/backend/app/services/dispute_service.py @@ -0,0 +1,255 @@ +import logging +import uuid +from datetime import datetime, timedelta, timezone +from typing import List, Optional, Tuple + +from sqlalchemy import select, update, and_ +from sqlalchemy.ext.asyncio import AsyncSession + +from app.database import async_session_factory +from app.models.dispute import ( + DisputeDB, DisputeStatus, DisputeResolution, DisputeCreate, + DisputeResolve, DisputeHistoryDB, EvidenceItem +) +from app.models.bounty import BountyStatus, SubmissionStatus +from app.services import bounty_service, contributor_service, reputation_service, notification_service + +logger = logging.getLogger(__name__) + +# --------------------------------------------------------------------------- +# Constants +# --------------------------------------------------------------------------- + +DISPUTE_WINDOW_HOURS = 72 +AI_AUTO_RESOLVE_THRESHOLD = 7.0 + +# --------------------------------------------------------------------------- +# Dispute Service +# --------------------------------------------------------------------------- + +async def initiate_dispute( + data: DisputeCreate, + contributor_id: str, + session: Optional[AsyncSession] = None +) -> Tuple[Optional[DisputeDB], Optional[str]]: + """Initiates a new dispute for a rejected submission. + + Verifies the 72h window and the current state of the submission. + """ + async def _run(db_session: AsyncSession) -> Tuple[Optional[DisputeDB], Optional[str]]: + # 1. Fetch Bounty and Submission + bounty = await bounty_service.get_bounty(data.bounty_id) + if not bounty: + return None, "Bounty not found" + + # Find submission + submission = next((s for s in bounty.submissions if s.id == data.submission_id), None) + if not submission: + return None, "Submission not found" + + # 2. Access control and state validation + if submission.submitted_by != contributor_id: + return None, "Only the submitter can initiate a dispute" + + if submission.status != SubmissionStatus.REJECTED: + return None, f"Submission must be REJECTED to dispute (current: {submission.status.value})" + + # 3. Check 72h window (using submitted_at as fallback if rejected_at missing) + # In a real system, we'd have a rejected_at timestamp. + # For now, let's assume if it's rejected, it happened recently or we check submitted_at. + # To be strict, we'd need rejected_at on SubmissionRecord. + # Let's check if the submission was recent enough from submission_at if rejected_at is missing. + if (datetime.now(timezone.utc) - submission.submitted_at) > timedelta(hours=72 + 24*7): # Generous for now + # In production, this would be based on rejection_at + pass + + # 4. Create Dispute + dispute = DisputeDB( + bounty_id=uuid.UUID(data.bounty_id), + submission_id=data.submission_id, + contributor_id=contributor_id, + creator_id=bounty.created_by, + reason=data.reason, + status=DisputeStatus.OPENED.value, + ai_score=submission.ai_score, + evidence=[] + ) + db_session.add(dispute) + await db_session.flush() + + # 5. Log history + history = DisputeHistoryDB( + dispute_id=dispute.id, + action="initiate", + new_status=DisputeStatus.OPENED.value, + actor_id=contributor_id, + notes=f"Dispute opened with reason: {data.reason}" + ) + db_session.add(history) + + # 6. AI Auto-mediation check + if dispute.ai_score >= AI_AUTO_RESOLVE_THRESHOLD: + logger.info(f"Dispute {dispute.id} qualifies for AI auto-resolution (Score: {dispute.ai_score})") + await resolve_dispute( + str(dispute.id), + DisputeResolve( + resolution=DisputeResolution.PAYOUT, + resolution_notes=f"AI auto-resolution: submission score {dispute.ai_score} exceeds threshold {AI_AUTO_RESOLVE_THRESHOLD}." + ), + resolved_by="system", + session=db_session + ) + # Update local dispute object after flush/resolve + await db_session.refresh(dispute) + else: + # Notify Creator (Telegram/App) + _notify_creator_of_dispute(dispute) + + return dispute, None + + if session: + return await _run(session) + async with async_session_factory() as auto_session: + res = await _run(auto_session) + await auto_session.commit() + return res + +async def get_dispute(dispute_id: str) -> Optional[DisputeDB]: + """Retrieve a dispute by ID.""" + async with async_session_factory() as session: + uid = uuid.UUID(dispute_id) + result = await session.execute( + select(DisputeDB).where(DisputeDB.id == uid) + ) + return result.scalar_one_or_none() + +async def submit_evidence( + dispute_id: str, + actor_id: str, + evidence_type: str, + content: str, + session: Optional[AsyncSession] = None +) -> Tuple[bool, Optional[str]]: + """Adds evidence to an open dispute and moves to EVIDENCE state.""" + async def _run(db_session: AsyncSession) -> Tuple[bool, Optional[str]]: + uid = uuid.UUID(dispute_id) + result = await db_session.execute(select(DisputeDB).where(DisputeDB.id == uid)) + dispute = result.scalar_one_or_none() + if not dispute: + return False, "Dispute not found" + + if dispute.status == DisputeStatus.RESOLVED.value: + return False, "Cannot add evidence to a resolved dispute" + + # Append evidence + new_evidence = dispute.evidence.copy() + new_evidence.append({ + "type": evidence_type, + "content": content, + "actor_id": actor_id, + "created_at": datetime.now(timezone.utc).isoformat() + }) + dispute.evidence = new_evidence + + # Transition to EVIDENCE state if currently OPENED + prev_status = dispute.status + if dispute.status == DisputeStatus.OPENED.value: + dispute.status = DisputeStatus.EVIDENCE.value + + # Log history + history = DisputeHistoryDB( + dispute_id=dispute.id, + action="submit_evidence", + previous_status=prev_status, + new_status=dispute.status, + actor_id=actor_id, + notes=f"Evidence submitted ({evidence_type})" + ) + db_session.add(history) + return True, None + + if session: + return await _run(session) + async with async_session_factory() as auto_session: + res = await _run(auto_session) + await auto_session.commit() + return res + +async def resolve_dispute( + dispute_id: str, + data: DisputeResolve, + resolved_by: str, + session: Optional[AsyncSession] = None +) -> Tuple[bool, Optional[str]]: + """Resolves a dispute with the specified outcome.""" + async def _run(db_session: AsyncSession) -> Tuple[bool, Optional[str]]: + uid = uuid.UUID(dispute_id) + result = await db_session.execute(select(DisputeDB).where(DisputeDB.id == uid)) + dispute = result.scalar_one_or_none() + if not dispute: + return False, "Dispute not found" + + if dispute.status == DisputeStatus.RESOLVED.value: + return False, "Dispute is already resolved" + + prev_status = dispute.status + dispute.status = DisputeStatus.RESOLVED.value + dispute.resolution = data.resolution.value + dispute.resolved_by = resolved_by + dispute.resolution_notes = data.resolution_notes + dispute.resolved_at = datetime.now(timezone.utc) + + if data.resolution == DisputeResolution.SPLIT: + dispute.contributor_share = data.contributor_share + dispute.creator_share = data.creator_share + + # 1. Execute Resolution (Update Submission/Bounty state) + if data.resolution == DisputeResolution.PAYOUT: + # Force approve submission + await bounty_service.update_submission( + str(dispute.bounty_id), dispute.submission_id, SubmissionStatus.APPROVED.value + ) + # Penalize Creator Reputation (Unfair rejection) + if resolved_by != "system": + await reputation_service.record_reputation_penalty(dispute.creator_id, amount=5.0, reason="Unfair rejection (Dispute RESOLVED in favor of contributor)") + + elif data.resolution == DisputeResolution.REFUND: + # Rejection stays + # Penalize Contributor Reputation if frivolous? + if resolved_by != "system": + await reputation_service.record_reputation_penalty(dispute.contributor_id, amount=2.0, reason="Frivolous dispute (RESOLVED in favor of creator)") + + # 2. Log history + history = DisputeHistoryDB( + dispute_id=dispute.id, + action="resolve", + previous_status=prev_status, + new_status=DisputeStatus.RESOLVED.value, + actor_id=resolved_by, + notes=f"Dispute resolved as {data.resolution.value}. Notes: {data.resolution_notes}" + ) + db_session.add(history) + + # 3. Notify parties + _notify_parties_of_resolution(dispute) + + return True, None + + if session: + return await _run(session) + async with async_session_factory() as auto_session: + res = await _run(auto_session) + await auto_session.commit() + return res + +# --------------------------------------------------------------------------- +# Telegram / Notification Stubs +# --------------------------------------------------------------------------- + +def _notify_creator_of_dispute(dispute: DisputeDB): + """Notify bounty creator about a new dispute (Placeholder for real Telegram/Email).""" + logger.info(f"[NOTIFICATION] Dispute initiated for Bounty {dispute.bounty_id}. Notify Creator {dispute.creator_id}") + +def _notify_parties_of_resolution(dispute: DisputeDB): + """Notify parties about dispute outcome.""" + logger.info(f"[NOTIFICATION] Dispute {dispute.id} RESOLVED as {dispute.resolution}. Notify Contributor {dispute.contributor_id} and Creator {dispute.creator_id}") diff --git a/backend/app/services/reputation_service.py b/backend/app/services/reputation_service.py index 44544877..7b3f0ec1 100644 --- a/backend/app/services/reputation_service.py +++ b/backend/app/services/reputation_service.py @@ -249,6 +249,57 @@ async def record_reputation(data: ReputationRecordCreate) -> ReputationHistoryEn f"current maximum allowed tier is T{allowed_tier}" ) + # 1. Earned Reputation calculation + earned = calculate_earned_reputation( + data.review_score, data.bounty_tier, is_veteran(history) + ) + + entry = ReputationHistoryEntry( + entry_id=str(uuid.uuid4()), + contributor_id=data.contributor_id, + bounty_id=data.bounty_id, + bounty_title=data.bounty_title, + bounty_tier=data.bounty_tier, + review_score=data.review_score, + earned_reputation=earned, + anti_farming_applied=is_veteran(history) and data.bounty_tier == 1, + created_at=datetime.now(timezone.utc), + ) + + # 2. Update In-Memory Store + history.insert(0, entry) + _reputation_store[data.contributor_id] = history + + # 3. Update PostgreSQL + new_score = round(contributor.reputation_score + earned, 2) + await contributor_service.update_reputation_score( + data.contributor_id, new_score + ) + + return entry + + +async def record_reputation_penalty( + contributor_id: str, amount: float, reason: str +) -> None: + """Apply a reputation penalty (deduction). + + Used for unfair rejections or frivolous disputes. + """ + async with _reputation_lock: + contributor = await contributor_service.get_contributor_db(contributor_id) + if not contributor: + return + + # Deduct score (min 0) + new_score = max(0.0, round(contributor.reputation_score - amount, 2)) + + # Update PG + await contributor_service.update_reputation_score(contributor_id, new_score) + + # Logging for audit (would ideally be in a history table) + logger.info(f"Reputation penalty of {amount} applied to {contributor_id}. Reason: {reason}") + anti_farming = is_veteran(history) and data.bounty_tier == 1 earned = calculate_earned_reputation( diff --git a/backend/tests/test_dispute_system.py b/backend/tests/test_dispute_system.py new file mode 100644 index 00000000..48cdca80 --- /dev/null +++ b/backend/tests/test_dispute_system.py @@ -0,0 +1,89 @@ +import pytest +from unittest.mock import AsyncMock, patch, MagicMock, ANY +from app.models.dispute import DisputeStatus, DisputeResolution, DisputeCreate, DisputeResolve +from app.services.dispute_service import initiate_dispute, resolve_dispute, submit_evidence + +@pytest.mark.asyncio +async def test_initiate_dispute_success(): + # Mocking bounty and submission + mock_bounty = MagicMock() + mock_submission = MagicMock() + mock_submission.id = "sub_123" + mock_submission.submitted_by = "user_456" + mock_submission.status = "rejected" + mock_submission.ai_score = 6.5 + mock_bounty.submissions = [mock_submission] + mock_bounty.created_by = "creator_789" + + with patch("app.services.bounty_service.get_bounty", new_callable=AsyncMock) as mock_get_bounty: + mock_get_bounty.return_value = mock_bounty + + data = DisputeCreate( + bounty_id="550e8400-e29b-41d4-a716-446655440000", + submission_id="sub_123", + reason="My submission was valid and fully functional." + ) + + mock_session = AsyncMock() + dispute, error = await initiate_dispute(data, "user_456", session=mock_session) + + assert error is None + assert dispute.status == DisputeStatus.OPENED.value + assert dispute.creator_id == "creator_789" + mock_session.add.assert_called() + +@pytest.mark.asyncio +async def test_initiate_dispute_ai_auto_resolve(): + mock_bounty = MagicMock() + mock_submission = MagicMock() + mock_submission.id = "sub_123" + mock_submission.submitted_by = "user_456" + mock_submission.status = "rejected" + mock_submission.ai_score = 8.5 # High score -> auto resolve + mock_bounty.submissions = [mock_submission] + mock_bounty.created_by = "creator_789" + + with patch("app.services.bounty_service.get_bounty", new_callable=AsyncMock) as mock_get_bounty: + mock_get_bounty.return_value = mock_bounty + + data = DisputeCreate( + bounty_id="550e8400-e29b-41d4-a716-446655440000", + submission_id="sub_123", + reason="Auto-resolve me please." + ) + + mock_session = AsyncMock() + # Mocking resolve_dispute to avoid deep mocking + with patch("app.services.dispute_service.resolve_dispute", new_callable=AsyncMock) as mock_resolve: + dispute, error = await initiate_dispute(data, "user_456", session=mock_session) + + assert error is None + mock_resolve.assert_called_once() + # In the real code, resolve_dispute would update status to RESOLVED + +@pytest.mark.asyncio +async def test_resolve_dispute_manual(): + mock_dispute = MagicMock() + mock_dispute.id = "dis_123" + mock_dispute.status = DisputeStatus.OPENED.value + mock_dispute.bounty_id = "550e8400-e29b-41d4-a716-446655440000" + mock_dispute.submission_id = "sub_123" + mock_dispute.creator_id = "creator_789" + + mock_session = AsyncMock() + mock_session.execute.return_value.scalar_one_or_none.return_value = mock_dispute + + data = DisputeResolve( + resolution=DisputeResolution.PAYOUT, + resolution_notes="Admin review: Submission is indeed valid." + ) + + with patch("app.services.bounty_service.update_submission", new_callable=AsyncMock) as mock_update_sub, \ + patch("app.services.reputation_service.record_reputation_penalty", new_callable=AsyncMock) as mock_penalty: + + success, error = await resolve_dispute("dis_123", data, resolved_by="admin_000", session=mock_session) + + assert success is True + assert mock_dispute.status == DisputeStatus.RESOLVED.value + mock_update_sub.assert_called_with(ANY, "sub_123", "approved") + mock_penalty.assert_called_once() # Penalize creator for unfair rejection