Coverage for app / api / routes / campaigns.py: 95%
126 statements
« prev ^ index » next coverage.py v7.13.3, created at 2026-02-04 06:09 -0500
« prev ^ index » next coverage.py v7.13.3, created at 2026-02-04 06:09 -0500
1from typing import Optional, List
2from fastapi import APIRouter, Depends, HTTPException, Query
3from sqlalchemy.ext.asyncio import AsyncSession
4from sqlalchemy import select, func, or_
5from sqlalchemy.orm import selectinload
7from app.db.database import get_db
8from app.db.models import Campaign, Creator, CampaignCategory, CampaignStatus, Donation, Advocacy
9from app.schemas.campaign import CampaignCreate, CampaignUpdate, CampaignResponse, CampaignListResponse, CampaignDetailResponse
10from app.schemas.donation import DonationResponse, DonationListResponse
11from app.api.deps import get_required_creator
13router = APIRouter()
16def calculate_advocate_count(campaign: Campaign) -> int:
17 """Calculate active advocate count for a campaign."""
18 return len([a for a in campaign.advocacies if a.is_active])
21@router.get("", response_model=CampaignListResponse)
22async def list_campaigns(
23 page: int = Query(1, ge=1),
24 per_page: int = Query(20, ge=1, le=100),
25 category: Optional[CampaignCategory] = None,
26 search: Optional[str] = None,
27 sort: str = Query("newest", pattern="^(newest|most_advocates|trending)$"),
28 db: AsyncSession = Depends(get_db),
29):
30 """List campaigns with filtering and pagination."""
31 query = select(Campaign).where(Campaign.status == CampaignStatus.ACTIVE)
33 if category:
34 query = query.where(Campaign.category == category)
36 if search:
37 search_term = f"%{search}%"
38 query = query.where(
39 or_(
40 Campaign.title.ilike(search_term),
41 Campaign.description.ilike(search_term),
42 )
43 )
45 # Get total count
46 count_query = select(func.count()).select_from(query.subquery())
47 total_result = await db.execute(count_query)
48 total = total_result.scalar() or 0
50 # Apply sorting
51 if sort == "newest":
52 query = query.order_by(Campaign.created_at.desc())
53 elif sort == "most_advocates":
54 # Count active advocacies and sort by that count
55 query = query.outerjoin(Advocacy, (Advocacy.campaign_id == Campaign.id) & (Advocacy.is_active == True))
56 query = query.group_by(Campaign.id).order_by(func.count(Advocacy.id).desc(), Campaign.created_at.desc())
57 elif sort == "trending":
58 # Trending = recent campaigns with advocates (sort by advocate count, then recency)
59 query = query.outerjoin(Advocacy, (Advocacy.campaign_id == Campaign.id) & (Advocacy.is_active == True))
60 query = query.group_by(Campaign.id).order_by(func.count(Advocacy.id).desc(), Campaign.created_at.desc())
62 # Apply pagination
63 query = query.offset((page - 1) * per_page).limit(per_page)
65 # Load relationships
66 query = query.options(selectinload(Campaign.advocacies))
68 result = await db.execute(query)
69 campaigns = result.scalars().all()
71 # Convert to response models
72 campaign_responses = []
73 for campaign in campaigns:
74 campaign_dict = {
75 **campaign.__dict__,
76 "advocate_count": calculate_advocate_count(campaign),
77 }
78 campaign_responses.append(CampaignResponse(**campaign_dict))
80 return CampaignListResponse(
81 campaigns=campaign_responses,
82 total=total,
83 page=page,
84 per_page=per_page,
85 )
88@router.get("/{campaign_id}", response_model=CampaignDetailResponse)
89async def get_campaign(
90 campaign_id: str,
91 db: AsyncSession = Depends(get_db),
92):
93 """Get a single campaign by ID."""
94 query = select(Campaign).where(Campaign.id == campaign_id)
95 query = query.options(selectinload(Campaign.advocacies))
97 result = await db.execute(query)
98 campaign = result.scalar_one_or_none()
100 if not campaign:
101 raise HTTPException(status_code=404, detail="Campaign not found")
103 campaign_dict = {
104 **campaign.__dict__,
105 "advocate_count": calculate_advocate_count(campaign),
106 }
108 return CampaignDetailResponse(**campaign_dict)
111@router.post("", response_model=CampaignDetailResponse, status_code=201)
112async def create_campaign(
113 campaign_data: CampaignCreate,
114 creator: Creator = Depends(get_required_creator),
115 db: AsyncSession = Depends(get_db),
116):
117 """Create a new campaign."""
118 # Validate at least one wallet address
119 if not any([
120 campaign_data.eth_wallet_address,
121 campaign_data.btc_wallet_address,
122 campaign_data.doge_wallet_address,
123 campaign_data.sol_wallet_address,
124 ]):
125 raise HTTPException(
126 status_code=400,
127 detail="At least one wallet address (BTC, ETH, DOGE, or SOL) is required",
128 )
130 try:
131 campaign_dict = campaign_data.model_dump(exclude={"contact_email"})
132 campaign = Campaign(
133 **campaign_dict,
134 creator_id=creator.id,
135 contact_email=creator.email, # Use creator's email
136 )
138 db.add(campaign)
139 await db.flush() # Flush to get campaign.id
141 # Create feed event for campaign creation
142 from app.db.models import FeedEvent, FeedEventType
143 feed_event = FeedEvent(
144 event_type=FeedEventType.CAMPAIGN_CREATED,
145 campaign_id=campaign.id,
146 agent_id=None,
147 event_metadata={"title": campaign.title},
148 )
149 db.add(feed_event)
151 await db.commit()
152 await db.refresh(campaign)
154 # Load advocacies for response
155 await db.refresh(campaign, ["advocacies"])
157 campaign_dict = {
158 **campaign.__dict__,
159 "advocate_count": 0,
160 }
162 return CampaignDetailResponse(**campaign_dict)
163 except Exception as e:
164 await db.rollback()
165 raise HTTPException(status_code=500, detail=f"Failed to create campaign: {str(e)}")
168@router.patch("/{campaign_id}", response_model=CampaignDetailResponse)
169async def update_campaign(
170 campaign_id: str,
171 campaign_data: CampaignUpdate,
172 creator: Creator = Depends(get_required_creator),
173 db: AsyncSession = Depends(get_db),
174):
175 """Update a campaign (creator must own it)."""
176 query = select(Campaign).where(Campaign.id == campaign_id)
177 result = await db.execute(query)
178 campaign = result.scalar_one_or_none()
180 if not campaign:
181 raise HTTPException(status_code=404, detail="Campaign not found")
183 if campaign.creator_id != creator.id:
184 raise HTTPException(status_code=403, detail="Not authorized to update this campaign")
186 try:
187 # Update fields
188 update_data = campaign_data.model_dump(exclude_unset=True)
189 for field, value in update_data.items():
190 setattr(campaign, field, value)
192 await db.commit()
193 await db.refresh(campaign)
194 await db.refresh(campaign, ["advocacies"])
196 campaign_dict = {
197 **campaign.__dict__,
198 "advocate_count": calculate_advocate_count(campaign),
199 }
201 return CampaignDetailResponse(**campaign_dict)
202 except Exception as e:
203 await db.rollback()
204 raise HTTPException(status_code=500, detail=f"Failed to update campaign: {str(e)}")
207@router.delete("/{campaign_id}", status_code=204)
208async def delete_campaign(
209 campaign_id: str,
210 creator: Creator = Depends(get_required_creator),
211 db: AsyncSession = Depends(get_db),
212):
213 """Cancel/delete a campaign (creator must own it)."""
214 query = select(Campaign).where(Campaign.id == campaign_id)
215 result = await db.execute(query)
216 campaign = result.scalar_one_or_none()
218 if not campaign:
219 raise HTTPException(status_code=404, detail="Campaign not found")
221 if campaign.creator_id != creator.id:
222 raise HTTPException(status_code=403, detail="Not authorized to delete this campaign")
224 try:
225 campaign.status = CampaignStatus.CANCELLED
226 await db.commit()
227 return None
228 except Exception as e:
229 await db.rollback()
230 raise HTTPException(status_code=500, detail=f"Failed to delete campaign: {str(e)}")
233@router.get("/{campaign_id}/donations", response_model=DonationListResponse)
234async def list_donations(
235 campaign_id: str,
236 page: int = Query(1, ge=1),
237 per_page: int = Query(20, ge=1, le=100),
238 chain: Optional[str] = Query(None, pattern="^(btc|eth|doge|sol)$"),
239 db: AsyncSession = Depends(get_db),
240):
241 """List donations for a campaign."""
242 # Verify campaign exists
243 campaign_result = await db.execute(
244 select(Campaign).where(Campaign.id == campaign_id)
245 )
246 campaign = campaign_result.scalar_one_or_none()
247 if not campaign:
248 raise HTTPException(status_code=404, detail="Campaign not found")
250 # Build query
251 query = select(Donation).where(Donation.campaign_id == campaign_id)
253 if chain:
254 query = query.where(Donation.chain == chain)
256 # Get total count
257 count_query = select(func.count()).select_from(query.subquery())
258 total_result = await db.execute(count_query)
259 total = total_result.scalar() or 0
261 # Apply pagination and ordering
262 query = query.order_by(Donation.confirmed_at.desc())
263 query = query.offset((page - 1) * per_page).limit(per_page)
265 result = await db.execute(query)
266 donations = result.scalars().all()
268 donation_responses = [DonationResponse(**donation.__dict__) for donation in donations]
270 return DonationListResponse(
271 donations=donation_responses,
272 total=total,
273 page=page,
274 per_page=per_page,
275 )