Skip to content
Merged
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
1 change: 1 addition & 0 deletions backend/app/models/Legislation.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
28 changes: 28 additions & 0 deletions backend/app/routers/admin/upload.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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}"}
1 change: 1 addition & 0 deletions backend/app/routers/legislation.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
3 changes: 3 additions & 0 deletions backend/app/schemas/legislation.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand All @@ -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
Expand Down
1 change: 1 addition & 0 deletions backend/tests/models/test_other_models.py
Original file line number Diff line number Diff line change
Expand Up @@ -88,6 +88,7 @@ def test_columns_exist(self):
"sponsor_name",
"summary",
"full_text",
"full_text_pdf_url",
"status",
"type",
"date_introduced",
Expand Down
10 changes: 10 additions & 0 deletions frontend/src/app/legislation/[id]/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -117,6 +117,16 @@ export default async function LegislationDetailPage({

<section className="mb-8">
<h2 className="text-xl font-semibold mb-2">Full Text</h2>
{legislation.full_text_pdf_url && (
<a
href={legislation.full_text_pdf_url}
target="_blank"
rel="noopener noreferrer"
className="inline-block mb-3 text-blue-600 hover:underline"
>
Download Full Text (PDF)
</a>
)}
<HtmlContent
html={legislation.full_text}
className="prose max-w-none text-gray-700"
Expand Down
48 changes: 48 additions & 0 deletions frontend/src/components/admin/LegislationForm.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import {
SelectValue,
} from "@/components/ui/select";
import { getSenators } from "@/lib/api";
import { uploadAdminPdf } from "@/lib/admin-api";
import type { Legislation, Senator } from "@/types";
import type { CreateLegislation } from "@/types/admin";
import { RichTextEditor } from "./RichTextEditor";
Expand Down Expand Up @@ -44,9 +45,33 @@ export function LegislationForm({
);
const [summary, setSummary] = useState(initialData?.summary || "");
const [fullText, setFullText] = useState(initialData?.full_text || "");
const [fullTextPdfUrl, setFullTextPdfUrl] = useState(
initialData?.full_text_pdf_url || "",
);
const [isUploadingPdf, setIsUploadingPdf] = useState(false);
const [pdfUploadError, setPdfUploadError] = useState<string | null>(null);
const [status, setStatus] = useState(initialData?.status || "Introduced");
const [type, setType] = useState(initialData?.type || "Bill");

const handlePdfChange = async (e: React.ChangeEvent<HTMLInputElement>) => {
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];
Expand Down Expand Up @@ -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(),
Expand Down Expand Up @@ -246,6 +272,28 @@ export function LegislationForm({
<RichTextEditor value={fullText} onChange={setFullText} />
</div>

<div className="space-y-2">
<Label htmlFor="full-text-pdf">Full Text PDF (optional)</Label>
<input
id="full-text-pdf"
type="file"
accept="application/pdf"
onChange={handlePdfChange}
disabled={isUploadingPdf}
/>
{isUploadingPdf ? (
<p className="text-sm text-gray-600">Uploading...</p>
) : null}
{pdfUploadError ? (
<p className="text-sm text-red-600">{pdfUploadError}</p>
) : null}
{fullTextPdfUrl ? (
<p className="text-xs text-gray-500 break-all">
Stored URL: {fullTextPdfUrl}
</p>
) : null}
</div>

<div className="flex justify-end gap-3 pt-2">
<Button
type="button"
Expand Down
19 changes: 17 additions & 2 deletions frontend/src/lib/admin-api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -100,7 +100,8 @@ export interface UploadImageResponse {
url: string;
}

export async function uploadAdminImage(
function uploadFile(
path: string,
file: File,
onProgress?: (percent: number) => void,
): Promise<UploadImageResponse> {
Expand All @@ -110,7 +111,7 @@ export async function uploadAdminImage(

return new Promise((resolve, reject) => {
const xhr = new XMLHttpRequest();
xhr.open("POST", `${API_BASE}${buildApiPath("/admin/upload")}`);
xhr.open("POST", `${API_BASE}${buildApiPath(path)}`);

if (token) {
xhr.setRequestHeader("Authorization", `Bearer ${token}`);
Expand Down Expand Up @@ -161,6 +162,20 @@ export async function uploadAdminImage(
});
}

export async function uploadAdminImage(
file: File,
onProgress?: (percent: number) => void,
): Promise<UploadImageResponse> {
return uploadFile("/admin/upload", file, onProgress);
}

export async function uploadAdminPdf(
file: File,
onProgress?: (percent: number) => void,
): Promise<UploadImageResponse> {
return uploadFile("/admin/upload/pdf", file, onProgress);
}

// Auth
export async function login(
onyen: string,
Expand Down
1 change: 1 addition & 0 deletions frontend/src/types/admin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,7 @@ export interface CreateLegislation {
sponsor_name: string;
summary: string;
full_text: string;
full_text_pdf_url?: string | null;
status: string;
type: string;
date_introduced: string;
Expand Down
1 change: 1 addition & 0 deletions frontend/src/types/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,7 @@ export interface Legislation {
sponsor_name: string;
summary: string;
full_text: string;
full_text_pdf_url?: string | null;
status: string;
type: string;
date_introduced: string;
Expand Down
Loading