Skip to content
Merged

Jem #68

Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
158 changes: 149 additions & 9 deletions backend/app/api/v1/certificates.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,10 @@
from __future__ import annotations

import uuid
from io import BytesIO

from fastapi import APIRouter, Depends, HTTPException
from fastapi.responses import HTMLResponse, StreamingResponse
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select

Expand All @@ -12,15 +14,36 @@
from app.models.user import User
from app.models.donation_certificate import DonationCertificate
from app.models.donation import Donation
from app.models.campaign_drive import CampaignDrive
from app.schemas.donation_certificate import (
DonationCertificateRead,
DonationCertificateUpdate,
)
from app.storage.s3 import generate_presigned_download_url
from app.certificates.generator import generate_certificate_pdf

router = APIRouter(prefix="/certificates", tags=["certificates"])


def _campaign_id_from_purpose(purpose: str | None) -> str | None:
if not purpose or not purpose.startswith("campaign:"):
return None
return purpose.replace("campaign:", "", 1)


async def _campaign_title_for_donation(db: AsyncSession, donation: Donation) -> str:
campaign_id = _campaign_id_from_purpose(donation.purpose)
if not campaign_id:
return "LINGAP Campaign"
campaign = (
await db.execute(select(CampaignDrive).where(CampaignDrive.id == campaign_id))
).scalar_one_or_none()
return campaign.title if campaign else "LINGAP Campaign"


async def _get_donation(db: AsyncSession, donation_id: uuid.UUID) -> Donation | None:
return (await db.execute(select(Donation).where(Donation.id == donation_id))).scalar_one_or_none()


@router.get("/{cert_id}", response_model=DonationCertificateRead)
async def get_certificate(
cert_id: uuid.UUID,
Expand All @@ -37,12 +60,57 @@ async def get_certificate(
if not cert:
raise HTTPException(404, "Certificate not found")

if not cert.is_public and (not user or user.id != cert.donation.donor_id):
donation = await _get_donation(db, cert.donation_id)
if not donation:
raise HTTPException(404, "Donation record not found")
if not cert.is_public and (not user or user.id != donation.donor_id):
raise HTTPException(403, "Not authorized to view this certificate")

return DonationCertificateRead.model_validate(cert)


@router.get("/{cert_id}/public")
async def get_public_certificate_page(
cert_id: uuid.UUID,
db: AsyncSession = Depends(get_db),
):
cert = (
await db.execute(
select(DonationCertificate).where(DonationCertificate.id == cert_id)
)
).scalar_one_or_none()

if not cert or not cert.is_public:
raise HTTPException(404, "Public certificate not found")

donation = await _get_donation(db, cert.donation_id)
if not donation:
raise HTTPException(404, "Donation record not found")
campaign_name = await _campaign_title_for_donation(db, donation)
donor = (await db.execute(select(User).where(User.id == donation.donor_id))).scalar_one_or_none()
donor_name = cert.donor_name or (donor.stellar_public_key if donor else None) or "Anonymous Donor"
tx_hash = cert.stellar_tx_hash or donation.stellar_tx_hash or "N/A"
html = f"""
<!doctype html>
<html>
<head><meta charset="utf-8"><title>LINGAP Certificate {cert.id}</title></head>
<body style="font-family:Arial,sans-serif;background:#f5f7f5;padding:24px;color:#17231D;">
<div style="max-width:760px;margin:0 auto;background:#fff;border:1px solid #d7e1d9;border-radius:12px;padding:28px;">
<h1 style="margin:0 0 10px;">LINGAP On-Chain Donation Proof</h1>
<p style="margin:0 0 22px;color:#4A5C52;">Public Impact Certificate</p>
<p><strong>User Name:</strong> {donor_name}</p>
<p><strong>Campaign Name:</strong> {campaign_name}</p>
<p><strong>Milestone:</strong> {cert.milestone_description}</p>
<p><strong>Donation Amount:</strong> {float(cert.amount):,.2f} XLM</p>
<p><strong>Transaction Hash:</strong> {tx_hash}</p>
<p><strong>Verification:</strong> {"Verified" if cert.verified else "Pending"}</p>
</div>
</body>
</html>
"""
return HTMLResponse(content=html)


@router.get("/donation/{donation_id}", response_model=DonationCertificateRead)
async def get_certificate_by_donation(
donation_id: uuid.UUID,
Expand All @@ -61,7 +129,10 @@ async def get_certificate_by_donation(
if not cert:
raise HTTPException(404, "Certificate not found for this donation")

if not cert.is_public and user.id != cert.donation.donor_id:
donation = await _get_donation(db, cert.donation_id)
if not donation:
raise HTTPException(404, "Donation record not found")
if not cert.is_public and user.id != donation.donor_id:
raise HTTPException(403, "Not authorized to view this certificate")

return DonationCertificateRead.model_validate(cert)
Expand All @@ -84,7 +155,10 @@ async def update_certificate_visibility(
if not cert:
raise HTTPException(404, "Certificate not found")

if user.id != cert.donation.donor_id:
donation = await _get_donation(db, cert.donation_id)
if not donation:
raise HTTPException(404, "Donation record not found")
if user.id != donation.donor_id:
raise HTTPException(403, "Only certificate owner can update visibility")

cert.is_public = body.is_public
Expand All @@ -100,7 +174,7 @@ async def download_certificate(
db: AsyncSession = Depends(get_db),
user: User | None = Depends(get_current_user),
):
"""Get presigned download URL for certificate PDF."""
"""Render certificate PDF on demand and stream it."""
cert = (
await db.execute(
select(DonationCertificate).where(DonationCertificate.id == cert_id)
Expand All @@ -110,13 +184,36 @@ async def download_certificate(
if not cert:
raise HTTPException(404, "Certificate not found")

if not cert.is_public and (not user or user.id != cert.donation.donor_id):
donation = await _get_donation(db, cert.donation_id)
if not donation:
raise HTTPException(404, "Donation record not found")
if not cert.is_public and (not user or user.id != donation.donor_id):
raise HTTPException(403, "Not authorized to download this certificate")

s3_key = f"certificates/{cert.donation_id}/{cert.pdf_hash[:16]}.pdf"
presigned_url = await generate_presigned_download_url(s3_key)
campaign_name = await _campaign_title_for_donation(db, donation)
milestone = cert.milestone_description or "Campaign milestone completed"
donor = (await db.execute(select(User).where(User.id == donation.donor_id))).scalar_one_or_none()
donor_name = cert.donor_name or (donor.name if donor else None) or (donor.stellar_public_key if donor else None) or "Anonymous Donor"
tx_hash = cert.stellar_tx_hash or donation.stellar_tx_hash or "N/A"

pdf_bytes = generate_certificate_pdf(
donor_name=donor_name,
amount=float(cert.amount),
beneficiary_name=campaign_name,
milestone_description=milestone,
lives_touched=int(cert.lives_touched or 0),
total_donated=float(cert.total_donated or cert.amount),
current_donation=float(cert.amount),
donation_date=donation.created_at,
stellar_tx_hash=tx_hash,
)

return {"download_url": presigned_url, "filename": f"certificate-{cert.donation_id}.pdf"}
filename = f"certificate-{cert.donation_id}.pdf"
return StreamingResponse(
BytesIO(pdf_bytes),
media_type="application/pdf",
headers={"Content-Disposition": f'attachment; filename="{filename}"'},
)


@router.get("/donor/{donor_id}/all", response_model=list[DonationCertificateRead])
Expand All @@ -140,3 +237,46 @@ async def list_donor_certificates(
certificates = result.scalars().all()

return [DonationCertificateRead.model_validate(c) for c in certificates]


@router.get("")
async def list_certificates(
donor_id: uuid.UUID | None = None,
db: AsyncSession = Depends(get_db),
user: User | None = Depends(get_current_user),
):
query = select(DonationCertificate).order_by(DonationCertificate.created_at.desc())
if donor_id:
query = query.where(DonationCertificate.donation.has(donor_id=donor_id))

certs = (await db.execute(query)).scalars().all()
items = []
for cert in certs:
if not cert.is_public and (not user or user.id != cert.donation.donor_id):
donation = await _get_donation(db, cert.donation_id)
if not donation:
continue
if not user or user.id != donation.donor_id:
continue
donation = await _get_donation(db, cert.donation_id)
if not donation:
continue
campaign_name = await _campaign_title_for_donation(db, donation)
items.append(
{
"id": str(cert.id),
"donation_id": str(cert.donation_id),
"donor_name": cert.donor_name,
"amount": float(cert.amount),
"beneficiary_name": cert.beneficiary_name,
"campaign_name": campaign_name,
"milestone_description": cert.milestone_description,
"stellar_tx_hash": cert.stellar_tx_hash,
"verified": cert.verified,
"is_public": cert.is_public,
"created_at": cert.created_at,
"public_url": f"/api/v1/certificates/{cert.id}/public" if cert.is_public else None,
"download_url": f"/api/v1/certificates/{cert.id}/download",
}
)
return {"success": True, "data": items}
18 changes: 15 additions & 3 deletions backend/app/api/v1/profiles.py
Original file line number Diff line number Diff line change
Expand Up @@ -101,6 +101,11 @@ async def _profile_payload(db: AsyncSession, user: User) -> dict:
).one()

campaigns = await _public_campaigns_for_user(db, user)
campaign_rows = (
await db.execute(select(CampaignDrive.id, CampaignDrive.title))
).all()
campaign_titles = {str(row.id): row.title for row in campaign_rows}

cert_rows = (
await db.execute(
select(DonationCertificate, Donation)
Expand All @@ -116,15 +121,16 @@ async def _profile_payload(db: AsyncSession, user: User) -> dict:

activity_rows = (
await db.execute(
select(Donation)
select(Donation, DonationCertificate)
.outerjoin(DonationCertificate, DonationCertificate.donation_id == Donation.id)
.where(
Donation.donor_id == user.id,
Donation.purpose.like("campaign:%"),
)
.order_by(Donation.created_at.desc())
.limit(8)
)
).scalars().all()
).all()

total_xlm = float(donation_row.total or 0)
donation_count = int(donation_row.count or 0)
Expand All @@ -135,6 +141,8 @@ async def _profile_payload(db: AsyncSession, user: User) -> dict:
"amount": float(cert.amount),
"lives_touched": cert.lives_touched,
"stellar_tx_hash": cert.stellar_tx_hash,
"public_url": f"/api/v1/certificates/{cert.id}/public",
"download_url": f"/api/v1/certificates/{cert.id}/download",
"verified": cert.verified,
"created_at": cert.created_at,
}
Expand All @@ -144,13 +152,17 @@ async def _profile_payload(db: AsyncSession, user: User) -> dict:
{
"id": str(donation.id),
"campaign_id": (donation.purpose or "").replace("campaign:", "", 1),
"campaign_name": campaign_titles.get((donation.purpose or "").replace("campaign:", "", 1), "Campaign"),
"milestone": cert.milestone_description if cert else None,
"amount": float(donation.amount),
"asset": donation.asset,
"stellar_tx_hash": donation.stellar_tx_hash,
"wallet_address": user.stellar_public_key,
"certificate_id": str(cert.id) if cert else None,
"blockchain_confirmed": donation.blockchain_confirmed,
"created_at": donation.created_at,
}
for donation in activity_rows
for donation, cert in activity_rows
]

active_campaigns = [item for item in campaigns if item["status"].lower() == "active"]
Expand Down
4 changes: 4 additions & 0 deletions backend/app/api/v1/router.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,8 @@
from .campaigns import router as campaigns_router
from .profiles import router as profiles_router
from .balance import router as balance_router
from .certificates import router as certificates_router
from .onchain_certificates import router as onchain_certificates_router

api_router = APIRouter(prefix="/api/v1")

Expand All @@ -41,3 +43,5 @@
api_router.include_router(campaigns_router)
api_router.include_router(profiles_router)
api_router.include_router(balance_router)
api_router.include_router(certificates_router)
api_router.include_router(onchain_certificates_router)
19 changes: 14 additions & 5 deletions backend/app/certificates/service.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@
from app.models.beneficiary import Beneficiary
from app.models.user import User
from app.models.provenance import ProvenanceRecord
from app.models.campaign_drive import CampaignDrive
from app.certificates.generator import generate_certificate_pdf
from app.storage.s3 import upload_certificate_pdf

Expand Down Expand Up @@ -97,12 +98,20 @@ async def create_certificate_for_donation(
).scalar()
total_donated = float(total_donated_result or 0)

milestone_description = (
aid_request.purpose or f"Donation for {beneficiary.name}"
)
campaign_title = None
if donation.purpose and donation.purpose.startswith("campaign:"):
campaign_id = donation.purpose.replace("campaign:", "", 1)
campaign = (
await db.execute(select(CampaignDrive).where(CampaignDrive.id == campaign_id))
).scalar_one_or_none()
if campaign:
campaign_title = campaign.title

milestone_description = aid_request.purpose or campaign_title or f"Donation for {beneficiary.name}"
donor_name = donor.name or donor.stellar_public_key or donor.email

pdf_bytes = generate_certificate_pdf(
donor_name=donor.name,
donor_name=donor_name,
amount=float(donation.amount),
beneficiary_name=beneficiary.name,
milestone_description=milestone_description,
Expand All @@ -120,7 +129,7 @@ async def create_certificate_for_donation(
s3_url=stored.s3_url,
pdf_hash=stored.pdf_hash,
is_public=False,
donor_name=donor.name,
donor_name=donor_name,
amount=float(donation.amount),
beneficiary_name=beneficiary.name,
milestone_description=milestone_description,
Expand Down
Loading
Loading