From c3d83f205179001f8f636afa85b33c21eba95a36 Mon Sep 17 00:00:00 2001 From: calebyhan Date: Tue, 30 Jun 2026 11:16:59 -0400 Subject: [PATCH] Support PDF upload for legislation full text Adds full_text_pdf_url to the Legislation model/schemas, extends the admin upload endpoint to accept PDFs, lets admins attach a PDF in LegislationForm, and shows a download link on the public legislation detail page when present. (#163) --- backend/app/models/Legislation.py | 1 + backend/app/routers/admin/upload.py | 28 +++++++++++ backend/app/routers/legislation.py | 1 + backend/app/schemas/legislation.py | 3 ++ backend/tests/models/test_other_models.py | 1 + frontend/src/app/legislation/[id]/page.tsx | 10 ++++ .../src/components/admin/LegislationForm.tsx | 48 +++++++++++++++++++ frontend/src/lib/admin-api.ts | 19 +++++++- frontend/src/types/admin.ts | 1 + frontend/src/types/index.ts | 1 + 10 files changed, 111 insertions(+), 2 deletions(-) diff --git a/backend/app/models/Legislation.py b/backend/app/models/Legislation.py index e6e8d0d..2878bff 100644 --- a/backend/app/models/Legislation.py +++ b/backend/app/models/Legislation.py @@ -20,6 +20,7 @@ class Legislation(Base): sponsor_name: Mapped[str] = mapped_column(String(255), nullable=False) summary: Mapped[str] = mapped_column(Text, nullable=False) full_text: Mapped[str] = mapped_column(Text, nullable=False) + full_text_pdf_url: Mapped[str | None] = mapped_column(String(500), nullable=True) status: Mapped[str] = mapped_column(String(50), nullable=False) type: Mapped[str] = mapped_column(String(50), nullable=False) date_introduced: Mapped[date] = mapped_column(Date, nullable=False) diff --git a/backend/app/routers/admin/upload.py b/backend/app/routers/admin/upload.py index 7a46b36..80cc20d 100644 --- a/backend/app/routers/admin/upload.py +++ b/backend/app/routers/admin/upload.py @@ -38,6 +38,21 @@ def _validate_and_load_image(content: bytes) -> tuple[Image.Image, str]: return image, _ALLOWED_FORMATS[fmt] +def _validate_pdf(content: bytes) -> None: + if len(content) > MAX_UPLOAD_SIZE_BYTES: + raise HTTPException(status_code=413, detail="File too large. Max size is 5MB") + if not content.startswith(b"%PDF-"): + raise HTTPException(status_code=400, detail="Unsupported file type") + + +def _save_pdf(content: bytes) -> str: + uploads_dir = Path(UPLOAD_DIR) + uploads_dir.mkdir(parents=True, exist_ok=True) + filename = f"{uuid4().hex}.pdf" + (uploads_dir / filename).write_bytes(content) + return filename + + def _save_resized_images(image: Image.Image, extension: str) -> str: """Save standard and thumbnail variants and return the main filename.""" uploads_dir = Path(UPLOAD_DIR) @@ -79,3 +94,16 @@ async def upload_image( image, extension = _validate_and_load_image(content) filename = _save_resized_images(image, extension) return {"url": f"{UPLOAD_BASE_URL}/{filename}"} + + +@router.post("/upload/pdf") +async def upload_pdf( + file: UploadFile = File(...), + current_user: Admin = Depends(get_current_user), +): + """Upload a PDF and return a relative URL to the stored file.""" + _ = current_user + content = await file.read() + _validate_pdf(content) + filename = _save_pdf(content) + return {"url": f"{UPLOAD_BASE_URL}/{filename}"} diff --git a/backend/app/routers/legislation.py b/backend/app/routers/legislation.py index e3cd755..838e08a 100644 --- a/backend/app/routers/legislation.py +++ b/backend/app/routers/legislation.py @@ -38,6 +38,7 @@ def _legislation_base_dict(leg: Legislation) -> dict: "sponsor_name": leg.sponsor_name, "summary": sanitize_html(leg.summary), "full_text": sanitize_html(leg.full_text), + "full_text_pdf_url": leg.full_text_pdf_url, "status": leg.status, "type": leg.type, "date_introduced": leg.date_introduced, diff --git a/backend/app/schemas/legislation.py b/backend/app/schemas/legislation.py index 4ac0f8c..84b33e6 100644 --- a/backend/app/schemas/legislation.py +++ b/backend/app/schemas/legislation.py @@ -23,6 +23,7 @@ class LegislationListDTO(BaseModel): sponsor_name: str summary: str full_text: str + full_text_pdf_url: str | None = None status: str type: str date_introduced: date @@ -48,6 +49,7 @@ class CreateLegislationDTO(BaseModel): sponsor_name: str summary: str full_text: str + full_text_pdf_url: str | None = None status: str type: str date_introduced: date @@ -62,6 +64,7 @@ class UpdateLegislationDTO(BaseModel): sponsor_name: str | None = None summary: str | None = None full_text: str | None = None + full_text_pdf_url: str | None = None status: str | None = None type: str | None = None date_introduced: date | None = None diff --git a/backend/tests/models/test_other_models.py b/backend/tests/models/test_other_models.py index d7c1dc6..8cc0d82 100644 --- a/backend/tests/models/test_other_models.py +++ b/backend/tests/models/test_other_models.py @@ -88,6 +88,7 @@ def test_columns_exist(self): "sponsor_name", "summary", "full_text", + "full_text_pdf_url", "status", "type", "date_introduced", diff --git a/frontend/src/app/legislation/[id]/page.tsx b/frontend/src/app/legislation/[id]/page.tsx index 1973167..3f91a57 100644 --- a/frontend/src/app/legislation/[id]/page.tsx +++ b/frontend/src/app/legislation/[id]/page.tsx @@ -117,6 +117,16 @@ export default async function LegislationDetailPage({

Full Text

+ {legislation.full_text_pdf_url && ( + + Download Full Text (PDF) + + )} (null); const [status, setStatus] = useState(initialData?.status || "Introduced"); const [type, setType] = useState(initialData?.type || "Bill"); + const handlePdfChange = async (e: React.ChangeEvent) => { + const file = e.target.files?.[0]; + e.target.value = ""; + if (!file) return; + + setIsUploadingPdf(true); + setPdfUploadError(null); + try { + const { url } = await uploadAdminPdf(file); + setFullTextPdfUrl(url); + } catch (err) { + setPdfUploadError( + err instanceof Error ? err.message : "PDF upload failed.", + ); + } finally { + setIsUploadingPdf(false); + } + }; + const formatInitialDate = (dateString?: string) => { if (!dateString) return ""; return new Date(dateString).toISOString().split("T")[0]; @@ -106,6 +131,7 @@ export function LegislationForm({ sponsor_name: sponsorName.trim(), summary: summary.trim(), full_text: fullText.trim(), + full_text_pdf_url: fullTextPdfUrl || null, status, type, date_introduced: new Date(dateIntroduced).toISOString(), @@ -246,6 +272,28 @@ export function LegislationForm({ +
+ + + {isUploadingPdf ? ( +

Uploading...

+ ) : null} + {pdfUploadError ? ( +

{pdfUploadError}

+ ) : null} + {fullTextPdfUrl ? ( +

+ Stored URL: {fullTextPdfUrl} +

+ ) : null} +
+