Spaces:
Running
Running
database update
Browse files- frontend/src/App.css +33 -2
- frontend/src/App.tsx +2 -2
- frontend/src/components/HeaderNav.tsx +9 -9
- frontend/src/pages/ExplorePage.tsx +120 -3
- frontend/src/pages/MapDetailPage.tsx +198 -0
- frontend/src/pages/ReviewPage.tsx +0 -33
- frontend/src/pages/UploadPage.tsx +180 -102
- frontend/src/types.ts +17 -0
- package-lock.json +0 -108
- package.json +0 -5
- prisma/schema.prisma +0 -87
- py_backend/alembic/versions/0002_seed.py +0 -87
- py_backend/alembic/versions/ad38fd571716_init_schema.py +0 -113
- py_backend/alembic/versions/b8fc40bfe3c7_initial_schema_seed.py +248 -0
- py_backend/app/config.py +1 -1
- py_backend/app/crud.py +61 -22
- py_backend/app/main.py +1 -1
- py_backend/app/models.py +50 -24
- py_backend/app/routers/caption.py +36 -17
- py_backend/app/routers/metadata.py +26 -0
- py_backend/app/routers/upload.py +54 -17
- py_backend/app/schemas.py +66 -19
- py_backend/requirements.txt +6 -2
frontend/src/App.css
CHANGED
|
@@ -1,8 +1,39 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
#root {
|
| 2 |
-
max-width:
|
| 3 |
margin: 0 auto;
|
| 4 |
-
padding:
|
| 5 |
text-align: center;
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 6 |
}
|
| 7 |
|
| 8 |
.logo {
|
|
|
|
| 1 |
+
html, body {
|
| 2 |
+
margin: 0;
|
| 3 |
+
padding: 0;
|
| 4 |
+
overflow-x: hidden;
|
| 5 |
+
width: 100%;
|
| 6 |
+
}
|
| 7 |
+
|
| 8 |
#root {
|
| 9 |
+
max-width: 100%;
|
| 10 |
margin: 0 auto;
|
| 11 |
+
padding: 1rem;
|
| 12 |
text-align: center;
|
| 13 |
+
min-height: 100vh;
|
| 14 |
+
box-sizing: border-box;
|
| 15 |
+
overflow-x: hidden;
|
| 16 |
+
transform: scale(0.8);
|
| 17 |
+
transform-origin: top center;
|
| 18 |
+
}
|
| 19 |
+
|
| 20 |
+
/* Responsive adjustments for different screen sizes */
|
| 21 |
+
@media (min-width: 640px) {
|
| 22 |
+
#root {
|
| 23 |
+
padding: 1.5rem;
|
| 24 |
+
}
|
| 25 |
+
}
|
| 26 |
+
|
| 27 |
+
@media (min-width: 1024px) {
|
| 28 |
+
#root {
|
| 29 |
+
padding: 2rem;
|
| 30 |
+
}
|
| 31 |
+
}
|
| 32 |
+
|
| 33 |
+
@media (min-width: 1280px) {
|
| 34 |
+
#root {
|
| 35 |
+
max-width: 1280px;
|
| 36 |
+
}
|
| 37 |
}
|
| 38 |
|
| 39 |
.logo {
|
frontend/src/App.tsx
CHANGED
|
@@ -1,10 +1,10 @@
|
|
| 1 |
import { createBrowserRouter, RouterProvider } from 'react-router-dom';
|
| 2 |
import RootLayout from './layouts/RootLayout';
|
| 3 |
import UploadPage from './pages/UploadPage';
|
| 4 |
-
import ReviewPage from './pages/ReviewPage';
|
| 5 |
import AnalyticsPage from './pages/AnalyticsPage';
|
| 6 |
import ExplorePage from './pages/ExplorePage';
|
| 7 |
import HelpPage from './pages/HelpPage';
|
|
|
|
| 8 |
|
| 9 |
const router = createBrowserRouter([
|
| 10 |
{
|
|
@@ -12,10 +12,10 @@ const router = createBrowserRouter([
|
|
| 12 |
children: [
|
| 13 |
{ path: '/', element: <UploadPage /> },
|
| 14 |
{ path: '/upload', element: <UploadPage /> },
|
| 15 |
-
{ path: '/review/:id', element: <ReviewPage /> },
|
| 16 |
{ path: '/analytics', element: <AnalyticsPage /> },
|
| 17 |
{ path: '/explore', element: <ExplorePage /> },
|
| 18 |
{ path: '/help', element: <HelpPage /> },
|
|
|
|
| 19 |
],
|
| 20 |
},
|
| 21 |
]);
|
|
|
|
| 1 |
import { createBrowserRouter, RouterProvider } from 'react-router-dom';
|
| 2 |
import RootLayout from './layouts/RootLayout';
|
| 3 |
import UploadPage from './pages/UploadPage';
|
|
|
|
| 4 |
import AnalyticsPage from './pages/AnalyticsPage';
|
| 5 |
import ExplorePage from './pages/ExplorePage';
|
| 6 |
import HelpPage from './pages/HelpPage';
|
| 7 |
+
import MapDetailPage from './pages/MapDetailPage';
|
| 8 |
|
| 9 |
const router = createBrowserRouter([
|
| 10 |
{
|
|
|
|
| 12 |
children: [
|
| 13 |
{ path: '/', element: <UploadPage /> },
|
| 14 |
{ path: '/upload', element: <UploadPage /> },
|
|
|
|
| 15 |
{ path: '/analytics', element: <AnalyticsPage /> },
|
| 16 |
{ path: '/explore', element: <ExplorePage /> },
|
| 17 |
{ path: '/help', element: <HelpPage /> },
|
| 18 |
+
{ path: '/map/:mapId', element: <MapDetailPage /> },
|
| 19 |
],
|
| 20 |
},
|
| 21 |
]);
|
frontend/src/components/HeaderNav.tsx
CHANGED
|
@@ -4,13 +4,13 @@ import {
|
|
| 4 |
AnalysisIcon,
|
| 5 |
SearchLineIcon,
|
| 6 |
QuestionLineIcon,
|
|
|
|
| 7 |
} from "@ifrc-go/icons";
|
| 8 |
|
| 9 |
/* Style helper for active vs. inactive nav links */
|
| 10 |
const navLink = ({ isActive }: { isActive: boolean }) =>
|
| 11 |
-
`flex items-center gap-1 px-
|
| 12 |
-
|
| 13 |
-
}`;
|
| 14 |
|
| 15 |
/* Put page info in one list so itβs easy to extend */
|
| 16 |
const navItems = [
|
|
@@ -36,19 +36,19 @@ export default function HeaderNav() {
|
|
| 36 |
|
| 37 |
return (
|
| 38 |
<header className="bg-white border-b border-ifrcRed/40">
|
| 39 |
-
<div className="flex items-center justify-between px-
|
| 40 |
|
| 41 |
{/* ββ Logo + title βββββββββββββββββββββββββββ */}
|
| 42 |
-
<NavLink to="/" className="flex items-center gap-2" onClick={(e) => handleNavigation(e, "/")}>
|
| 43 |
-
<
|
| 44 |
-
<span className="font-semibold">PromptAid Vision</span>
|
| 45 |
</NavLink>
|
| 46 |
|
| 47 |
{/* ββ Centre nav links βββββββββββββββββββββββ */}
|
| 48 |
-
<nav className="flex gap-6">
|
| 49 |
{navItems.map(({ to, label, Icon }) => (
|
| 50 |
<NavLink key={to} to={to} className={navLink} onClick={(e) => handleNavigation(e, to)}>
|
| 51 |
-
<Icon className="w-4 h-4" /> {label}
|
| 52 |
</NavLink>
|
| 53 |
))}
|
| 54 |
</nav>
|
|
|
|
| 4 |
AnalysisIcon,
|
| 5 |
SearchLineIcon,
|
| 6 |
QuestionLineIcon,
|
| 7 |
+
GoMainIcon,
|
| 8 |
} from "@ifrc-go/icons";
|
| 9 |
|
| 10 |
/* Style helper for active vs. inactive nav links */
|
| 11 |
const navLink = ({ isActive }: { isActive: boolean }) =>
|
| 12 |
+
`flex items-center gap-1 px-4 sm:px-6 py-2 text-xs sm:text-sm transition-colors whitespace-nowrap mx-4 sm:mx-6
|
| 13 |
+
${isActive ? "text-ifrcRed font-semibold" : "text-gray-600 hover:text-ifrcRed"}`;
|
|
|
|
| 14 |
|
| 15 |
/* Put page info in one list so itβs easy to extend */
|
| 16 |
const navItems = [
|
|
|
|
| 36 |
|
| 37 |
return (
|
| 38 |
<header className="bg-white border-b border-ifrcRed/40">
|
| 39 |
+
<div className="flex items-center justify-between px-2 sm:px-4 py-3 max-w-full overflow-hidden">
|
| 40 |
|
| 41 |
{/* ββ Logo + title βββββββββββββββββββββββββββ */}
|
| 42 |
+
<NavLink to="/" className="flex items-center gap-2 min-w-0" onClick={(e) => handleNavigation(e, "/")}>
|
| 43 |
+
<GoMainIcon className="h-6 w-6 flex-shrink-0 text-ifrcRed" />
|
| 44 |
+
<span className="font-semibold text-sm sm:text-base truncate">PromptAid Vision</span>
|
| 45 |
</NavLink>
|
| 46 |
|
| 47 |
{/* ββ Centre nav links βββββββββββββββββββββββ */}
|
| 48 |
+
<nav className="flex flex-wrap justify-center gap-6">
|
| 49 |
{navItems.map(({ to, label, Icon }) => (
|
| 50 |
<NavLink key={to} to={to} className={navLink} onClick={(e) => handleNavigation(e, to)}>
|
| 51 |
+
<Icon className="w-4 h-4" /> <span className="inline">{label}</span>
|
| 52 |
</NavLink>
|
| 53 |
))}
|
| 54 |
</nav>
|
frontend/src/pages/ExplorePage.tsx
CHANGED
|
@@ -1,9 +1,126 @@
|
|
| 1 |
-
import { PageContainer, Heading } from '@ifrc-go/ui';
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2 |
|
| 3 |
export default function ExplorePage() {
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 4 |
return (
|
| 5 |
-
<PageContainer
|
| 6 |
-
<Heading level={2}>Explore
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 7 |
</PageContainer>
|
| 8 |
);
|
| 9 |
}
|
|
|
|
| 1 |
+
import { PageContainer, Heading, TextInput, SearchSelectInput, SearchMultiSelectInput } from '@ifrc-go/ui';
|
| 2 |
+
import { useState, useEffect, useMemo } from 'react';
|
| 3 |
+
import { useNavigate } from 'react-router-dom';
|
| 4 |
+
|
| 5 |
+
interface MapOut {
|
| 6 |
+
image_id: string;
|
| 7 |
+
file_key: string;
|
| 8 |
+
image_url: string;
|
| 9 |
+
source: string;
|
| 10 |
+
type: string;
|
| 11 |
+
epsg: string;
|
| 12 |
+
image_type: string;
|
| 13 |
+
caption?: {
|
| 14 |
+
generated: string;
|
| 15 |
+
};
|
| 16 |
+
}
|
| 17 |
|
| 18 |
export default function ExplorePage() {
|
| 19 |
+
const navigate = useNavigate();
|
| 20 |
+
const [maps, setMaps] = useState<MapOut[]>([]);
|
| 21 |
+
const [search, setSearch] = useState('');
|
| 22 |
+
const [srcFilter, setSrcFilter] = useState('');
|
| 23 |
+
const [catFilter, setCatFilter] = useState('');
|
| 24 |
+
const [sources, setSources] = useState<{s_code: string, label: string}[]>([]);
|
| 25 |
+
const [types, setTypes] = useState<{t_code: string, label: string}[]>([]);
|
| 26 |
+
|
| 27 |
+
useEffect(() => {
|
| 28 |
+
// Fetch maps
|
| 29 |
+
fetch('/api/images/').then(r => r.json()).then(setMaps);
|
| 30 |
+
|
| 31 |
+
// Fetch lookup data
|
| 32 |
+
fetch('/api/sources').then(r => r.json()).then(setSources);
|
| 33 |
+
fetch('/api/types').then(r => r.json()).then(setTypes);
|
| 34 |
+
}, []);
|
| 35 |
+
|
| 36 |
+
const filtered = useMemo(() => {
|
| 37 |
+
return maps.filter(m => {
|
| 38 |
+
return (
|
| 39 |
+
m.file_key.toLowerCase().includes(search.toLowerCase()) &&
|
| 40 |
+
(!srcFilter || m.source === srcFilter) &&
|
| 41 |
+
(!catFilter || m.type === catFilter)
|
| 42 |
+
);
|
| 43 |
+
});
|
| 44 |
+
}, [maps, search, srcFilter, catFilter]);
|
| 45 |
+
|
| 46 |
+
const sourceOptions = sources.map(s => ({ value: s.s_code, label: s.label }));
|
| 47 |
+
const typeOptions = types.map(t => ({ value: t.t_code, label: t.label }));
|
| 48 |
+
|
| 49 |
return (
|
| 50 |
+
<PageContainer>
|
| 51 |
+
<Heading level={2}>Explore Examples</Heading>
|
| 52 |
+
|
| 53 |
+
{/* ββ Filters Bar ββββββββββββββββββββββββββββββββ */}
|
| 54 |
+
<div className="mt-4 flex flex-wrap gap-4 items-center">
|
| 55 |
+
<TextInput
|
| 56 |
+
name="search"
|
| 57 |
+
placeholder="Search by filenameβ¦"
|
| 58 |
+
value={search}
|
| 59 |
+
onChange={(e) => setSearch(e || '')}
|
| 60 |
+
className="flex-1 min-w-[12rem]"
|
| 61 |
+
/>
|
| 62 |
+
|
| 63 |
+
<SearchSelectInput
|
| 64 |
+
name="source"
|
| 65 |
+
placeholder="All Sources"
|
| 66 |
+
options={sourceOptions}
|
| 67 |
+
value={srcFilter || null}
|
| 68 |
+
onChange={(v) => setSrcFilter(v as string || '')}
|
| 69 |
+
keySelector={(o) => o.value}
|
| 70 |
+
labelSelector={(o) => o.label}
|
| 71 |
+
selectedOnTop={false}
|
| 72 |
+
/>
|
| 73 |
+
|
| 74 |
+
<SearchMultiSelectInput
|
| 75 |
+
name="type"
|
| 76 |
+
placeholder="All Types"
|
| 77 |
+
options={typeOptions}
|
| 78 |
+
value={catFilter ? [catFilter] : []}
|
| 79 |
+
onChange={(v) => setCatFilter((v as string[])[0] || '')}
|
| 80 |
+
keySelector={(o) => o.value}
|
| 81 |
+
labelSelector={(o) => o.label}
|
| 82 |
+
selectedOnTop={false}
|
| 83 |
+
/>
|
| 84 |
+
</div>
|
| 85 |
+
|
| 86 |
+
{/* ββ List βββββββββββββββββββββββββββββββββββββββ */}
|
| 87 |
+
<div className="mt-6 space-y-4">
|
| 88 |
+
{filtered.map(m => (
|
| 89 |
+
<div key={m.image_id} className="border rounded-lg p-4 flex gap-4 cursor-pointer" onClick={() => navigate(`/map/${m.image_id}`)}>
|
| 90 |
+
<div className="bg-gray-100 flex items-center justify-center text-gray-400 text-xs overflow-hidden rounded" style={{ width: '120px', height: '80px' }}>
|
| 91 |
+
{m.image_url ? (
|
| 92 |
+
<img
|
| 93 |
+
src={m.image_url}
|
| 94 |
+
alt={m.file_key}
|
| 95 |
+
className="w-full h-full object-cover"
|
| 96 |
+
style={{ imageRendering: 'pixelated' }}
|
| 97 |
+
onError={(e) => {
|
| 98 |
+
// Fallback to placeholder if image fails to load
|
| 99 |
+
const target = e.target as HTMLImageElement;
|
| 100 |
+
target.style.display = 'none';
|
| 101 |
+
target.parentElement!.innerHTML = 'Img';
|
| 102 |
+
}}
|
| 103 |
+
/>
|
| 104 |
+
) : (
|
| 105 |
+
'Img'
|
| 106 |
+
)}
|
| 107 |
+
</div>
|
| 108 |
+
<div className="flex-1 min-w-0">
|
| 109 |
+
<div className="flex flex-wrap gap-2">
|
| 110 |
+
<span className="px-2 py-1 bg-ifrcRed/10 text-ifrcRed text-xs rounded">{m.source}</span>
|
| 111 |
+
<span className="px-2 py-1 bg-ifrcRed/10 text-ifrcRed text-xs rounded">{m.type}</span>
|
| 112 |
+
</div>
|
| 113 |
+
<p className="mt-2 text-sm text-gray-700 line-clamp-2">
|
| 114 |
+
{m.caption?.generated || 'β no caption yet β'}
|
| 115 |
+
</p>
|
| 116 |
+
</div>
|
| 117 |
+
</div>
|
| 118 |
+
))}
|
| 119 |
+
|
| 120 |
+
{!filtered.length && (
|
| 121 |
+
<p className="text-center text-gray-500">No examples found.</p>
|
| 122 |
+
)}
|
| 123 |
+
</div>
|
| 124 |
</PageContainer>
|
| 125 |
);
|
| 126 |
}
|
frontend/src/pages/MapDetailPage.tsx
ADDED
|
@@ -0,0 +1,198 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { PageContainer, Heading, Button } from '@ifrc-go/ui';
|
| 2 |
+
import { useState, useEffect } from 'react';
|
| 3 |
+
import { useParams, useNavigate } from 'react-router-dom';
|
| 4 |
+
|
| 5 |
+
interface MapOut {
|
| 6 |
+
image_id: string;
|
| 7 |
+
file_key: string;
|
| 8 |
+
image_url: string;
|
| 9 |
+
source: string;
|
| 10 |
+
type: string;
|
| 11 |
+
epsg: string;
|
| 12 |
+
image_type: string;
|
| 13 |
+
caption?: {
|
| 14 |
+
generated: string;
|
| 15 |
+
};
|
| 16 |
+
}
|
| 17 |
+
|
| 18 |
+
export default function MapDetailPage() {
|
| 19 |
+
const { mapId } = useParams<{ mapId: string }>();
|
| 20 |
+
const navigate = useNavigate();
|
| 21 |
+
const [map, setMap] = useState<MapOut | null>(null);
|
| 22 |
+
const [loading, setLoading] = useState(true);
|
| 23 |
+
const [error, setError] = useState<string | null>(null);
|
| 24 |
+
const [contributing, setContributing] = useState(false);
|
| 25 |
+
|
| 26 |
+
useEffect(() => {
|
| 27 |
+
if (!mapId) {
|
| 28 |
+
setError('Map ID is required');
|
| 29 |
+
setLoading(false);
|
| 30 |
+
return;
|
| 31 |
+
}
|
| 32 |
+
|
| 33 |
+
// Fetch the specific map
|
| 34 |
+
fetch(`/api/images/${mapId}`)
|
| 35 |
+
.then(response => {
|
| 36 |
+
if (!response.ok) {
|
| 37 |
+
throw new Error('Map not found');
|
| 38 |
+
}
|
| 39 |
+
return response.json();
|
| 40 |
+
})
|
| 41 |
+
.then(data => {
|
| 42 |
+
setMap(data);
|
| 43 |
+
setLoading(false);
|
| 44 |
+
})
|
| 45 |
+
.catch(err => {
|
| 46 |
+
setError(err.message);
|
| 47 |
+
setLoading(false);
|
| 48 |
+
});
|
| 49 |
+
}, [mapId]);
|
| 50 |
+
|
| 51 |
+
const handleContribute = async () => {
|
| 52 |
+
if (!map) return;
|
| 53 |
+
|
| 54 |
+
setContributing(true);
|
| 55 |
+
try {
|
| 56 |
+
// Simulate uploading the current image by creating a new map entry
|
| 57 |
+
const formData = new FormData();
|
| 58 |
+
formData.append('source', map.source);
|
| 59 |
+
formData.append('type', map.type);
|
| 60 |
+
formData.append('epsg', map.epsg);
|
| 61 |
+
formData.append('image_type', map.image_type);
|
| 62 |
+
|
| 63 |
+
// We'll need to fetch the image and create a file from it
|
| 64 |
+
const imageResponse = await fetch(map.image_url);
|
| 65 |
+
const imageBlob = await imageResponse.blob();
|
| 66 |
+
const file = new File([imageBlob], map.file_key, { type: 'image/jpeg' });
|
| 67 |
+
formData.append('file', file);
|
| 68 |
+
|
| 69 |
+
const response = await fetch('/api/images/', {
|
| 70 |
+
method: 'POST',
|
| 71 |
+
body: formData,
|
| 72 |
+
});
|
| 73 |
+
|
| 74 |
+
if (!response.ok) {
|
| 75 |
+
throw new Error('Failed to contribute image');
|
| 76 |
+
}
|
| 77 |
+
|
| 78 |
+
const result = await response.json();
|
| 79 |
+
|
| 80 |
+
// Navigate to the upload page with the new map ID and step 2
|
| 81 |
+
navigate(`/upload?mapId=${result.image_id}&step=2`);
|
| 82 |
+
} catch (err) {
|
| 83 |
+
console.error('Contribution failed:', err);
|
| 84 |
+
alert('Failed to contribute image. Please try again.');
|
| 85 |
+
} finally {
|
| 86 |
+
setContributing(false);
|
| 87 |
+
}
|
| 88 |
+
};
|
| 89 |
+
|
| 90 |
+
if (loading) {
|
| 91 |
+
return (
|
| 92 |
+
<PageContainer>
|
| 93 |
+
<div className="flex items-center justify-center min-h-[400px]">
|
| 94 |
+
<div className="text-gray-500">Loading...</div>
|
| 95 |
+
</div>
|
| 96 |
+
</PageContainer>
|
| 97 |
+
);
|
| 98 |
+
}
|
| 99 |
+
|
| 100 |
+
if (error || !map) {
|
| 101 |
+
return (
|
| 102 |
+
<PageContainer>
|
| 103 |
+
<div className="flex items-center justify-center min-h-[400px]">
|
| 104 |
+
<div className="text-red-500">{error || 'Map not found'}</div>
|
| 105 |
+
</div>
|
| 106 |
+
</PageContainer>
|
| 107 |
+
);
|
| 108 |
+
}
|
| 109 |
+
|
| 110 |
+
return (
|
| 111 |
+
<PageContainer>
|
| 112 |
+
<div className="mb-4">
|
| 113 |
+
<button
|
| 114 |
+
onClick={() => navigate('/explore')}
|
| 115 |
+
className="text-ifrcRed hover:text-ifrcRed/80 mb-4 flex items-center gap-2"
|
| 116 |
+
>
|
| 117 |
+
β Back to Explore
|
| 118 |
+
</button>
|
| 119 |
+
<Heading level={2}>Map Details</Heading>
|
| 120 |
+
</div>
|
| 121 |
+
|
| 122 |
+
<div className="grid grid-cols-1 lg:grid-cols-2 gap-8">
|
| 123 |
+
{/* Image Section */}
|
| 124 |
+
<div className="space-y-4">
|
| 125 |
+
<div className="bg-gray-100 rounded-lg overflow-hidden">
|
| 126 |
+
{map.image_url ? (
|
| 127 |
+
<img
|
| 128 |
+
src={map.image_url}
|
| 129 |
+
alt={map.file_key}
|
| 130 |
+
className="w-full h-auto object-contain"
|
| 131 |
+
style={{ imageRendering: 'pixelated' }}
|
| 132 |
+
/>
|
| 133 |
+
) : (
|
| 134 |
+
<div className="w-full h-64 bg-gray-200 flex items-center justify-center text-gray-400">
|
| 135 |
+
No image available
|
| 136 |
+
</div>
|
| 137 |
+
)}
|
| 138 |
+
</div>
|
| 139 |
+
</div>
|
| 140 |
+
|
| 141 |
+
{/* Details Section */}
|
| 142 |
+
<div className="space-y-6">
|
| 143 |
+
<div>
|
| 144 |
+
<h3 className="text-lg font-semibold mb-2">File Information</h3>
|
| 145 |
+
<div className="space-y-2 text-sm">
|
| 146 |
+
<div><span className="font-medium">File:</span> {map.file_key}</div>
|
| 147 |
+
<div><span className="font-medium">ID:</span> {map.image_id}</div>
|
| 148 |
+
</div>
|
| 149 |
+
</div>
|
| 150 |
+
|
| 151 |
+
<div>
|
| 152 |
+
<h3 className="text-lg font-semibold mb-2">Metadata</h3>
|
| 153 |
+
<div className="flex flex-wrap gap-2">
|
| 154 |
+
<span className="px-3 py-1 bg-ifrcRed/10 text-ifrcRed text-sm rounded">
|
| 155 |
+
{map.source}
|
| 156 |
+
</span>
|
| 157 |
+
<span className="px-3 py-1 bg-ifrcRed/10 text-ifrcRed text-sm rounded">
|
| 158 |
+
{map.type}
|
| 159 |
+
</span>
|
| 160 |
+
<span className="px-3 py-1 bg-ifrcRed/10 text-ifrcRed text-sm rounded">
|
| 161 |
+
{map.epsg}
|
| 162 |
+
</span>
|
| 163 |
+
<span className="px-3 py-1 bg-ifrcRed/10 text-ifrcRed text-sm rounded">
|
| 164 |
+
{map.image_type}
|
| 165 |
+
</span>
|
| 166 |
+
</div>
|
| 167 |
+
</div>
|
| 168 |
+
|
| 169 |
+
<div>
|
| 170 |
+
<h3 className="text-lg font-semibold mb-2">Generated Caption</h3>
|
| 171 |
+
<div className="bg-gray-50 p-4 rounded-lg">
|
| 172 |
+
<p className="text-gray-700">
|
| 173 |
+
{map.caption?.generated || 'β no caption yet β'}
|
| 174 |
+
</p>
|
| 175 |
+
</div>
|
| 176 |
+
</div>
|
| 177 |
+
</div>
|
| 178 |
+
</div>
|
| 179 |
+
|
| 180 |
+
{/* Contribute Section */}
|
| 181 |
+
<div className="mt-8 pt-6 border-t border-gray-200">
|
| 182 |
+
<div className="text-center">
|
| 183 |
+
<p className="text-gray-600 mb-4">
|
| 184 |
+
Want to contribute to this map? Use this image as a starting point for your own analysis.
|
| 185 |
+
</p>
|
| 186 |
+
<Button
|
| 187 |
+
name="contribute"
|
| 188 |
+
onClick={handleContribute}
|
| 189 |
+
disabled={contributing}
|
| 190 |
+
className="bg-ifrcRed hover:bg-ifrcRed/90 text-white px-6 py-2 rounded-lg"
|
| 191 |
+
>
|
| 192 |
+
{contributing ? 'Contributing...' : 'Contribute'}
|
| 193 |
+
</Button>
|
| 194 |
+
</div>
|
| 195 |
+
</div>
|
| 196 |
+
</PageContainer>
|
| 197 |
+
);
|
| 198 |
+
}
|
frontend/src/pages/ReviewPage.tsx
DELETED
|
@@ -1,33 +0,0 @@
|
|
| 1 |
-
// src/pages/ReviewPage.tsx
|
| 2 |
-
import { useParams } from "react-router-dom";
|
| 3 |
-
import { useEffect, useState } from "react";
|
| 4 |
-
|
| 5 |
-
export default function ReviewPage() {
|
| 6 |
-
const { id } = useParams(); // captionId
|
| 7 |
-
const [data, setData] = useState<any>(null);
|
| 8 |
-
const [draft, setDraft] = useState("");
|
| 9 |
-
|
| 10 |
-
useEffect(() => {
|
| 11 |
-
(async () => {
|
| 12 |
-
const res = await fetch(`/api/captions/${id}`);
|
| 13 |
-
const json = await res.json();
|
| 14 |
-
setData(json);
|
| 15 |
-
setDraft(json.generated || "");
|
| 16 |
-
})();
|
| 17 |
-
}, [id]);
|
| 18 |
-
|
| 19 |
-
if (!data) return <p className="p-6">Loadingβ¦</p>;
|
| 20 |
-
|
| 21 |
-
return (
|
| 22 |
-
<main className="p-6 space-y-6">
|
| 23 |
-
<img src={data.imageUrl} className="max-w-full rounded-xl shadow" />
|
| 24 |
-
<textarea
|
| 25 |
-
value={draft}
|
| 26 |
-
onChange={e => setDraft(e.target.value)}
|
| 27 |
-
className="w-full border rounded p-3 font-mono" rows={5}
|
| 28 |
-
/>
|
| 29 |
-
{/* sliders for accuracy/context/usability */}
|
| 30 |
-
{/* Save button calls PUT /api/captions/{id} */}
|
| 31 |
-
</main>
|
| 32 |
-
);
|
| 33 |
-
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
frontend/src/pages/UploadPage.tsx
CHANGED
|
@@ -9,39 +9,97 @@ import {
|
|
| 9 |
UploadCloudLineIcon,
|
| 10 |
ArrowRightLineIcon,
|
| 11 |
} from '@ifrc-go/icons';
|
| 12 |
-
import { Link, useNavigate } from 'react-router-dom';
|
| 13 |
|
| 14 |
export default function UploadPage() {
|
| 15 |
const navigate = useNavigate();
|
|
|
|
| 16 |
const [step, setStep] = useState<1 | 2 | 3>(1);
|
| 17 |
const [preview, setPreview] = useState<string | null>(null);
|
| 18 |
/* ---------------- local state ----------------- */
|
| 19 |
|
| 20 |
-
const PH_SOURCE = "
|
| 21 |
-
const
|
| 22 |
-
const
|
|
|
|
| 23 |
|
| 24 |
const [file, setFile] = useState<File | null>(null);
|
| 25 |
//const [source, setSource] = useState('');
|
| 26 |
-
//const [
|
| 27 |
-
//const [category, setCategory] = useState('');
|
| 28 |
const [source, setSource] = useState(PH_SOURCE);
|
| 29 |
-
const [
|
| 30 |
-
const [
|
|
|
|
| 31 |
const [countries, setCountries] = useState<string[]>([]);
|
| 32 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 33 |
// Wrapper functions to handle OptionKey to string conversion
|
| 34 |
const handleSourceChange = (value: any) => setSource(String(value));
|
| 35 |
-
const
|
| 36 |
-
const
|
|
|
|
| 37 |
const handleCountriesChange = (value: any) => setCountries(Array.isArray(value) ? value.map(String) : []);
|
| 38 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 39 |
const [captionId, setCaptionId] = useState<string | null>(null);
|
| 40 |
|
| 41 |
const [imageUrl, setImageUrl] = useState<string|null>(null);
|
| 42 |
|
| 43 |
const [draft, setDraft] = useState('');
|
| 44 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 45 |
// Handle navigation with confirmation
|
| 46 |
const handleNavigation = () => {
|
| 47 |
if (step === 2) {
|
|
@@ -111,25 +169,35 @@ export default function UploadPage() {
|
|
| 111 |
const fd = new FormData();
|
| 112 |
fd.append('file', file);
|
| 113 |
fd.append('source', source);
|
| 114 |
-
fd.append('
|
| 115 |
-
fd.append('
|
|
|
|
| 116 |
countries.forEach((c) => fd.append('countries', c));
|
| 117 |
|
| 118 |
try {
|
| 119 |
/* 1) upload */
|
| 120 |
-
const mapRes = await fetch('/api/
|
| 121 |
const mapJson = await readJsonSafely(mapRes);
|
| 122 |
if (!mapRes.ok) throw new Error(mapJson.error || 'Upload failed');
|
| 123 |
setImageUrl(mapJson.image_url);
|
| 124 |
|
| 125 |
-
const mapIdVal = mapJson.
|
| 126 |
-
if (!mapIdVal) throw new Error('Upload failed:
|
| 127 |
// setMapId(mapIdVal);
|
| 128 |
|
| 129 |
/* 2) caption */
|
| 130 |
const capRes = await fetch(
|
| 131 |
-
`/api/
|
| 132 |
-
{
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 133 |
);
|
| 134 |
const capJson = await readJsonSafely(capRes);
|
| 135 |
if (!capRes.ok) throw new Error(capJson.error || 'Caption failed');
|
|
@@ -147,7 +215,10 @@ export default function UploadPage() {
|
|
| 147 |
/* ------------------------------------------------------------------- */
|
| 148 |
return (
|
| 149 |
<PageContainer>
|
| 150 |
-
<div
|
|
|
|
|
|
|
|
|
|
| 151 |
{/* Title & intro copy */}
|
| 152 |
{step === 1 && <>
|
| 153 |
<Heading level={2}>Upload Your Crisis Map</Heading>
|
|
@@ -157,7 +228,7 @@ export default function UploadPage() {
|
|
| 157 |
description, then review and rate the result based on your expertise.
|
| 158 |
</p>
|
| 159 |
{/* βMore Β»β link */}
|
| 160 |
-
<div className="mt-2">
|
| 161 |
<Link
|
| 162 |
to="/help"
|
| 163 |
className="text-ifrcRed text-xs hover:underline flex items-center gap-1"
|
|
@@ -172,7 +243,7 @@ export default function UploadPage() {
|
|
| 172 |
{/* Drop-zone */}
|
| 173 |
{step === 1 && (
|
| 174 |
<div
|
| 175 |
-
className="mt-10 border-2 border-dashed border-gray-300 bg-gray-50 rounded-xl
|
| 176 |
onDragOver={(e) => e.preventDefault()}
|
| 177 |
onDrop={onDrop}
|
| 178 |
>
|
|
@@ -183,36 +254,44 @@ export default function UploadPage() {
|
|
| 183 |
Selected file: {file.name}
|
| 184 |
</p>
|
| 185 |
) : (
|
| 186 |
-
|
| 187 |
-
<p className="text-sm text-gray-600">Drag & Drop a file here</p>
|
| 188 |
-
<p className="text-xs text-gray-500">or</p>
|
| 189 |
-
|
| 190 |
-
{/* File-picker button */}
|
| 191 |
-
<RawFileInput name="file" accept="image/*" onChange={onFileChange}>
|
| 192 |
-
<Button name="upload" size={1}>
|
| 193 |
-
Upload
|
| 194 |
-
</Button>
|
| 195 |
-
</RawFileInput>
|
| 196 |
-
</>
|
| 197 |
)}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 198 |
</div>
|
| 199 |
)}
|
| 200 |
|
| 201 |
{/* Generate button */}
|
| 202 |
{step === 1 && (
|
| 203 |
-
<
|
| 204 |
-
|
| 205 |
-
|
| 206 |
-
|
| 207 |
-
|
| 208 |
-
|
| 209 |
-
|
| 210 |
-
|
|
|
|
| 211 |
)}
|
| 212 |
|
| 213 |
{step === 2 && imageUrl && (
|
| 214 |
<div className="mt-6 flex justify-center">
|
| 215 |
-
<div className="w-full max-w-
|
| 216 |
<img
|
| 217 |
src={preview || undefined}
|
| 218 |
alt="Uploaded map preview"
|
|
@@ -225,47 +304,44 @@ export default function UploadPage() {
|
|
| 225 |
{step === 2 && (
|
| 226 |
<div className="space-y-10">
|
| 227 |
{/* ββββββ METADATA FORM ββββββ */}
|
| 228 |
-
<div className="grid gap-4 text-left
|
| 229 |
<SelectInput
|
| 230 |
label="Source"
|
| 231 |
name="source"
|
| 232 |
value={source}
|
| 233 |
onChange={handleSourceChange}
|
| 234 |
-
options={
|
| 235 |
-
|
| 236 |
-
{ value: 'FIELD', label: 'FieldΒ HQ' },
|
| 237 |
-
]}
|
| 238 |
-
keySelector={(o) => o.value}
|
| 239 |
labelSelector={(o) => o.label}
|
| 240 |
required
|
| 241 |
/>
|
| 242 |
<SelectInput
|
| 243 |
-
label="
|
| 244 |
-
name="
|
| 245 |
-
value={
|
| 246 |
-
onChange={
|
| 247 |
-
options={
|
| 248 |
-
|
| 249 |
-
{ value: 'AMR', label: 'Americas' },
|
| 250 |
-
{ value: 'APA', label: 'AsiaβPacific' },
|
| 251 |
-
{ value: 'EUR', label: 'Europe' },
|
| 252 |
-
{ value: 'MENA', label: 'MiddleΒ EastΒ &Β NΒ Africa' },
|
| 253 |
-
]}
|
| 254 |
-
keySelector={(o) => o.value}
|
| 255 |
labelSelector={(o) => o.label}
|
| 256 |
required
|
| 257 |
/>
|
| 258 |
<SelectInput
|
| 259 |
-
label="
|
| 260 |
-
name="
|
| 261 |
-
value={
|
| 262 |
-
onChange={
|
| 263 |
-
options={
|
| 264 |
-
|
| 265 |
-
|
| 266 |
-
|
| 267 |
-
|
| 268 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 269 |
labelSelector={(o) => o.label}
|
| 270 |
required
|
| 271 |
/>
|
|
@@ -289,8 +365,8 @@ export default function UploadPage() {
|
|
| 289 |
<div className="text-left">
|
| 290 |
<Heading level={3}>How well did the AI perform on the task?</Heading>
|
| 291 |
{(['accuracy', 'context', 'usability'] as const).map((k) => (
|
| 292 |
-
<div key={k} className="mt-6 flex items-center gap-4">
|
| 293 |
-
<label className="block text-sm font-medium capitalize w-28">{k}</label>
|
| 294 |
<input
|
| 295 |
type="range"
|
| 296 |
min={0}
|
|
@@ -301,7 +377,7 @@ export default function UploadPage() {
|
|
| 301 |
}
|
| 302 |
className="w-full accent-ifrcRed"
|
| 303 |
/>
|
| 304 |
-
<span className="ml-2 w-10 text-right tabular-nums">{scores[k]}</span>
|
| 305 |
</div>
|
| 306 |
))}
|
| 307 |
</div>
|
|
@@ -310,7 +386,7 @@ export default function UploadPage() {
|
|
| 310 |
<div className="text-left">
|
| 311 |
<Heading level={3}>AIβGenerated Caption</Heading>
|
| 312 |
<textarea
|
| 313 |
-
className="w-full border rounded p-3 font-mono mt-2"
|
| 314 |
rows={5}
|
| 315 |
value={draft}
|
| 316 |
onChange={(e) => setDraft(e.target.value)}
|
|
@@ -318,29 +394,30 @@ export default function UploadPage() {
|
|
| 318 |
</div>
|
| 319 |
|
| 320 |
{/* ββββββ SUBMIT BUTTON ββββββ */}
|
| 321 |
-
<
|
| 322 |
-
|
| 323 |
-
|
| 324 |
-
|
| 325 |
-
|
| 326 |
-
|
| 327 |
-
|
| 328 |
-
|
| 329 |
-
|
| 330 |
-
|
| 331 |
-
|
| 332 |
-
|
| 333 |
-
|
| 334 |
-
|
| 335 |
-
|
| 336 |
-
|
| 337 |
-
|
| 338 |
-
|
| 339 |
-
|
| 340 |
-
|
| 341 |
-
|
| 342 |
-
|
| 343 |
-
|
|
|
|
| 344 |
</div>
|
| 345 |
)}
|
| 346 |
|
|
@@ -349,13 +426,14 @@ export default function UploadPage() {
|
|
| 349 |
<div className="text-center space-y-6">
|
| 350 |
<Heading level={2}>Saved!</Heading>
|
| 351 |
<p className="text-gray-700">Your caption has been successfully saved.</p>
|
| 352 |
-
<
|
| 353 |
-
|
| 354 |
-
|
| 355 |
-
|
| 356 |
-
|
| 357 |
-
|
| 358 |
-
|
|
|
|
| 359 |
</div>
|
| 360 |
)}
|
| 361 |
|
|
|
|
| 9 |
UploadCloudLineIcon,
|
| 10 |
ArrowRightLineIcon,
|
| 11 |
} from '@ifrc-go/icons';
|
| 12 |
+
import { Link, useNavigate, useSearchParams } from 'react-router-dom';
|
| 13 |
|
| 14 |
export default function UploadPage() {
|
| 15 |
const navigate = useNavigate();
|
| 16 |
+
const [searchParams] = useSearchParams();
|
| 17 |
const [step, setStep] = useState<1 | 2 | 3>(1);
|
| 18 |
const [preview, setPreview] = useState<string | null>(null);
|
| 19 |
/* ---------------- local state ----------------- */
|
| 20 |
|
| 21 |
+
const PH_SOURCE = "OTHER";
|
| 22 |
+
const PH_TYPE = "OTHER";
|
| 23 |
+
const PH_EPSG = "OTHER";
|
| 24 |
+
const PH_IMAGE_TYPE = "crisis_map";
|
| 25 |
|
| 26 |
const [file, setFile] = useState<File | null>(null);
|
| 27 |
//const [source, setSource] = useState('');
|
| 28 |
+
//const [type, setType] = useState('');
|
|
|
|
| 29 |
const [source, setSource] = useState(PH_SOURCE);
|
| 30 |
+
const [type, setType] = useState(PH_TYPE);
|
| 31 |
+
const [epsg, setEpsg] = useState(PH_EPSG);
|
| 32 |
+
const [imageType, setImageType] = useState(PH_IMAGE_TYPE);
|
| 33 |
const [countries, setCountries] = useState<string[]>([]);
|
| 34 |
|
| 35 |
+
// Metadata options from database
|
| 36 |
+
const [sources, setSources] = useState<{s_code: string, label: string}[]>([]);
|
| 37 |
+
const [types, setTypes] = useState<{t_code: string, label: string}[]>([]);
|
| 38 |
+
const [spatialReferences, setSpatialReferences] = useState<{epsg: string, srid: string, proj4: string, wkt: string}[]>([]);
|
| 39 |
+
const [imageTypes, setImageTypes] = useState<{image_type: string, label: string}[]>([]);
|
| 40 |
+
|
| 41 |
// Wrapper functions to handle OptionKey to string conversion
|
| 42 |
const handleSourceChange = (value: any) => setSource(String(value));
|
| 43 |
+
const handleTypeChange = (value: any) => setType(String(value));
|
| 44 |
+
const handleEpsgChange = (value: any) => setEpsg(String(value));
|
| 45 |
+
const handleImageTypeChange = (value: any) => setImageType(String(value));
|
| 46 |
const handleCountriesChange = (value: any) => setCountries(Array.isArray(value) ? value.map(String) : []);
|
| 47 |
|
| 48 |
+
// Fetch metadata options on component mount
|
| 49 |
+
useEffect(() => {
|
| 50 |
+
fetch('/api/sources').then(r => r.json()).then(setSources);
|
| 51 |
+
fetch('/api/types').then(r => r.json()).then(setTypes);
|
| 52 |
+
fetch('/api/spatial-references').then(r => r.json()).then(setSpatialReferences);
|
| 53 |
+
fetch('/api/image-types').then(r => r.json()).then(setImageTypes);
|
| 54 |
+
}, []);
|
| 55 |
+
|
| 56 |
const [captionId, setCaptionId] = useState<string | null>(null);
|
| 57 |
|
| 58 |
const [imageUrl, setImageUrl] = useState<string|null>(null);
|
| 59 |
|
| 60 |
const [draft, setDraft] = useState('');
|
| 61 |
|
| 62 |
+
// Handle URL parameters for direct step 2 navigation
|
| 63 |
+
useEffect(() => {
|
| 64 |
+
const mapId = searchParams.get('mapId');
|
| 65 |
+
const stepParam = searchParams.get('step');
|
| 66 |
+
|
| 67 |
+
if (mapId && stepParam === '2') {
|
| 68 |
+
// Load the map data and start at step 2
|
| 69 |
+
fetch(`/api/images/${mapId}`)
|
| 70 |
+
.then(response => response.json())
|
| 71 |
+
.then(mapData => {
|
| 72 |
+
setImageUrl(mapData.image_url);
|
| 73 |
+
setSource(mapData.source);
|
| 74 |
+
setType(mapData.type);
|
| 75 |
+
setEpsg(mapData.epsg);
|
| 76 |
+
setImageType(mapData.image_type);
|
| 77 |
+
|
| 78 |
+
// Generate caption for the existing map
|
| 79 |
+
return fetch(`/api/images/${mapId}/caption`, {
|
| 80 |
+
method: 'POST',
|
| 81 |
+
headers: {
|
| 82 |
+
'Content-Type': 'application/x-www-form-urlencoded',
|
| 83 |
+
},
|
| 84 |
+
body: new URLSearchParams({
|
| 85 |
+
title: 'Generated Caption',
|
| 86 |
+
prompt: 'Describe this crisis map in detail'
|
| 87 |
+
})
|
| 88 |
+
});
|
| 89 |
+
})
|
| 90 |
+
.then(capResponse => capResponse.json())
|
| 91 |
+
.then(capData => {
|
| 92 |
+
setCaptionId(capData.cap_id);
|
| 93 |
+
setDraft(capData.generated);
|
| 94 |
+
setStep(2);
|
| 95 |
+
})
|
| 96 |
+
.catch(err => {
|
| 97 |
+
console.error('Failed to load map data:', err);
|
| 98 |
+
alert('Failed to load map data. Please try again.');
|
| 99 |
+
});
|
| 100 |
+
}
|
| 101 |
+
}, [searchParams]);
|
| 102 |
+
|
| 103 |
// Handle navigation with confirmation
|
| 104 |
const handleNavigation = () => {
|
| 105 |
if (step === 2) {
|
|
|
|
| 169 |
const fd = new FormData();
|
| 170 |
fd.append('file', file);
|
| 171 |
fd.append('source', source);
|
| 172 |
+
fd.append('type', type);
|
| 173 |
+
fd.append('epsg', epsg);
|
| 174 |
+
fd.append('image_type', imageType);
|
| 175 |
countries.forEach((c) => fd.append('countries', c));
|
| 176 |
|
| 177 |
try {
|
| 178 |
/* 1) upload */
|
| 179 |
+
const mapRes = await fetch('/api/images/', { method: 'POST', body: fd });
|
| 180 |
const mapJson = await readJsonSafely(mapRes);
|
| 181 |
if (!mapRes.ok) throw new Error(mapJson.error || 'Upload failed');
|
| 182 |
setImageUrl(mapJson.image_url);
|
| 183 |
|
| 184 |
+
const mapIdVal = mapJson.image_id;
|
| 185 |
+
if (!mapIdVal) throw new Error('Upload failed: image_id not found');
|
| 186 |
// setMapId(mapIdVal);
|
| 187 |
|
| 188 |
/* 2) caption */
|
| 189 |
const capRes = await fetch(
|
| 190 |
+
`/api/images/${mapIdVal}/caption`,
|
| 191 |
+
{
|
| 192 |
+
method: 'POST',
|
| 193 |
+
headers: {
|
| 194 |
+
'Content-Type': 'application/x-www-form-urlencoded',
|
| 195 |
+
},
|
| 196 |
+
body: new URLSearchParams({
|
| 197 |
+
title: 'Generated Caption',
|
| 198 |
+
prompt: 'Describe this crisis map in detail'
|
| 199 |
+
})
|
| 200 |
+
},
|
| 201 |
);
|
| 202 |
const capJson = await readJsonSafely(capRes);
|
| 203 |
if (!capRes.ok) throw new Error(capJson.error || 'Caption failed');
|
|
|
|
| 215 |
/* ------------------------------------------------------------------- */
|
| 216 |
return (
|
| 217 |
<PageContainer>
|
| 218 |
+
<div
|
| 219 |
+
className="mx-auto max-w-screen-lg text-center px-2 sm:px-4 py-6 sm:py-10 overflow-x-hidden"
|
| 220 |
+
data-step={step}
|
| 221 |
+
>
|
| 222 |
{/* Title & intro copy */}
|
| 223 |
{step === 1 && <>
|
| 224 |
<Heading level={2}>Upload Your Crisis Map</Heading>
|
|
|
|
| 228 |
description, then review and rate the result based on your expertise.
|
| 229 |
</p>
|
| 230 |
{/* βMore Β»β link */}
|
| 231 |
+
<div className="mt-2 flex justify-center">
|
| 232 |
<Link
|
| 233 |
to="/help"
|
| 234 |
className="text-ifrcRed text-xs hover:underline flex items-center gap-1"
|
|
|
|
| 243 |
{/* Drop-zone */}
|
| 244 |
{step === 1 && (
|
| 245 |
<div
|
| 246 |
+
className="mt-6 sm:mt-10 border-2 border-dashed border-gray-300 bg-gray-50 rounded-xl py-12 px-8 flex flex-col items-center gap-6 hover:bg-gray-100 transition-colors max-w-md mx-auto min-h-[300px] justify-center"
|
| 247 |
onDragOver={(e) => e.preventDefault()}
|
| 248 |
onDrop={onDrop}
|
| 249 |
>
|
|
|
|
| 254 |
Selected file: {file.name}
|
| 255 |
</p>
|
| 256 |
) : (
|
| 257 |
+
<p className="text-sm text-gray-600">Drag & Drop a file here</p>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 258 |
)}
|
| 259 |
+
|
| 260 |
+
{/* File-picker button - always visible */}
|
| 261 |
+
<label className="inline-block cursor-pointer">
|
| 262 |
+
<input
|
| 263 |
+
type="file"
|
| 264 |
+
accept="image/*"
|
| 265 |
+
className="sr-only"
|
| 266 |
+
onChange={e => onFileChange(e.target.files?.[0], "file")}
|
| 267 |
+
/>
|
| 268 |
+
<Button
|
| 269 |
+
name="upload"
|
| 270 |
+
size={1}
|
| 271 |
+
onClick={() => (document.querySelector('input[type="file"]') as HTMLInputElement)?.click()}
|
| 272 |
+
>
|
| 273 |
+
{file ? 'Change File' : 'Upload'}
|
| 274 |
+
</Button>
|
| 275 |
+
</label>
|
| 276 |
</div>
|
| 277 |
)}
|
| 278 |
|
| 279 |
{/* Generate button */}
|
| 280 |
{step === 1 && (
|
| 281 |
+
<div className="flex justify-center mt-12">
|
| 282 |
+
<Button
|
| 283 |
+
name="generate"
|
| 284 |
+
disabled={!file}
|
| 285 |
+
onClick={handleGenerate}
|
| 286 |
+
>
|
| 287 |
+
Generate
|
| 288 |
+
</Button>
|
| 289 |
+
</div>
|
| 290 |
)}
|
| 291 |
|
| 292 |
{step === 2 && imageUrl && (
|
| 293 |
<div className="mt-6 flex justify-center">
|
| 294 |
+
<div className="w-full max-w-screen-lg max-h-80 overflow-hidden bg-red-50">
|
| 295 |
<img
|
| 296 |
src={preview || undefined}
|
| 297 |
alt="Uploaded map preview"
|
|
|
|
| 304 |
{step === 2 && (
|
| 305 |
<div className="space-y-10">
|
| 306 |
{/* ββββββ METADATA FORM ββββββ */}
|
| 307 |
+
<div className="grid gap-4 text-left grid-cols-1 lg:grid-cols-2">
|
| 308 |
<SelectInput
|
| 309 |
label="Source"
|
| 310 |
name="source"
|
| 311 |
value={source}
|
| 312 |
onChange={handleSourceChange}
|
| 313 |
+
options={sources}
|
| 314 |
+
keySelector={(o) => o.s_code}
|
|
|
|
|
|
|
|
|
|
| 315 |
labelSelector={(o) => o.label}
|
| 316 |
required
|
| 317 |
/>
|
| 318 |
<SelectInput
|
| 319 |
+
label="Type"
|
| 320 |
+
name="type"
|
| 321 |
+
value={type}
|
| 322 |
+
onChange={handleTypeChange}
|
| 323 |
+
options={types}
|
| 324 |
+
keySelector={(o) => o.t_code}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 325 |
labelSelector={(o) => o.label}
|
| 326 |
required
|
| 327 |
/>
|
| 328 |
<SelectInput
|
| 329 |
+
label="EPSG"
|
| 330 |
+
name="epsg"
|
| 331 |
+
value={epsg}
|
| 332 |
+
onChange={handleEpsgChange}
|
| 333 |
+
options={spatialReferences}
|
| 334 |
+
keySelector={(o) => o.epsg}
|
| 335 |
+
labelSelector={(o) => `${o.srid} (EPSG:${o.epsg})`}
|
| 336 |
+
required
|
| 337 |
+
/>
|
| 338 |
+
<SelectInput
|
| 339 |
+
label="Image Type"
|
| 340 |
+
name="image_type"
|
| 341 |
+
value={imageType}
|
| 342 |
+
onChange={handleImageTypeChange}
|
| 343 |
+
options={imageTypes}
|
| 344 |
+
keySelector={(o) => o.image_type}
|
| 345 |
labelSelector={(o) => o.label}
|
| 346 |
required
|
| 347 |
/>
|
|
|
|
| 365 |
<div className="text-left">
|
| 366 |
<Heading level={3}>How well did the AI perform on the task?</Heading>
|
| 367 |
{(['accuracy', 'context', 'usability'] as const).map((k) => (
|
| 368 |
+
<div key={k} className="mt-6 flex items-center gap-2 sm:gap-4">
|
| 369 |
+
<label className="block text-sm font-medium capitalize w-20 sm:w-28 flex-shrink-0">{k}</label>
|
| 370 |
<input
|
| 371 |
type="range"
|
| 372 |
min={0}
|
|
|
|
| 377 |
}
|
| 378 |
className="w-full accent-ifrcRed"
|
| 379 |
/>
|
| 380 |
+
<span className="ml-2 w-8 sm:w-10 text-right tabular-nums flex-shrink-0">{scores[k]}</span>
|
| 381 |
</div>
|
| 382 |
))}
|
| 383 |
</div>
|
|
|
|
| 386 |
<div className="text-left">
|
| 387 |
<Heading level={3}>AIβGenerated Caption</Heading>
|
| 388 |
<textarea
|
| 389 |
+
className="w-full border rounded p-2 sm:p-3 font-mono mt-2"
|
| 390 |
rows={5}
|
| 391 |
value={draft}
|
| 392 |
onChange={(e) => setDraft(e.target.value)}
|
|
|
|
| 394 |
</div>
|
| 395 |
|
| 396 |
{/* ββββββ SUBMIT BUTTON ββββββ */}
|
| 397 |
+
<div className="flex justify-center mt-10">
|
| 398 |
+
<Button
|
| 399 |
+
name="submit"
|
| 400 |
+
onClick={async () => {
|
| 401 |
+
if (!captionId) return alert("No caption to submit");
|
| 402 |
+
const body = {
|
| 403 |
+
edited: draft,
|
| 404 |
+
accuracy: scores.accuracy,
|
| 405 |
+
context: scores.context,
|
| 406 |
+
usability: scores.usability,
|
| 407 |
+
};
|
| 408 |
+
const res = await fetch(`/api/captions/${captionId}`, {
|
| 409 |
+
method: "PUT",
|
| 410 |
+
headers: { "Content-Type": "application/json" },
|
| 411 |
+
body: JSON.stringify(body),
|
| 412 |
+
});
|
| 413 |
+
const json = await readJsonSafely(res);
|
| 414 |
+
if (!res.ok) return alert(json.error || "Save failed");
|
| 415 |
+
setStep(3);
|
| 416 |
+
}}
|
| 417 |
+
>
|
| 418 |
+
Submit
|
| 419 |
+
</Button>
|
| 420 |
+
</div>
|
| 421 |
</div>
|
| 422 |
)}
|
| 423 |
|
|
|
|
| 426 |
<div className="text-center space-y-6">
|
| 427 |
<Heading level={2}>Saved!</Heading>
|
| 428 |
<p className="text-gray-700">Your caption has been successfully saved.</p>
|
| 429 |
+
<div className="flex justify-center mt-6">
|
| 430 |
+
<Button
|
| 431 |
+
name="upload-another"
|
| 432 |
+
onClick={resetToStep1}
|
| 433 |
+
>
|
| 434 |
+
Upload Another
|
| 435 |
+
</Button>
|
| 436 |
+
</div>
|
| 437 |
</div>
|
| 438 |
)}
|
| 439 |
|
frontend/src/types.ts
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
export interface MapOut {
|
| 2 |
+
map_id: string; // UUID as string
|
| 3 |
+
file_key: string;
|
| 4 |
+
sha256: string;
|
| 5 |
+
source: string;
|
| 6 |
+
region: string;
|
| 7 |
+
category: string;
|
| 8 |
+
caption?: {
|
| 9 |
+
cap_id: string; // UUID as string
|
| 10 |
+
map_id: string; // UUID as string
|
| 11 |
+
generated: string;
|
| 12 |
+
edited?: string;
|
| 13 |
+
accuracy?: number;
|
| 14 |
+
context?: number;
|
| 15 |
+
usability?: number;
|
| 16 |
+
};
|
| 17 |
+
}
|
package-lock.json
DELETED
|
@@ -1,108 +0,0 @@
|
|
| 1 |
-
{
|
| 2 |
-
"name": "PromptAid-Vision",
|
| 3 |
-
"lockfileVersion": 3,
|
| 4 |
-
"requires": true,
|
| 5 |
-
"packages": {
|
| 6 |
-
"": {
|
| 7 |
-
"devDependencies": {
|
| 8 |
-
"prisma": "^6.12.0"
|
| 9 |
-
}
|
| 10 |
-
},
|
| 11 |
-
"node_modules/@prisma/config": {
|
| 12 |
-
"version": "6.12.0",
|
| 13 |
-
"resolved": "https://registry.npmjs.org/@prisma/config/-/config-6.12.0.tgz",
|
| 14 |
-
"integrity": "sha512-HovZWzhWEMedHxmjefQBRZa40P81N7/+74khKFz9e1AFjakcIQdXgMWKgt20HaACzY+d1LRBC+L4tiz71t9fkg==",
|
| 15 |
-
"dev": true,
|
| 16 |
-
"license": "Apache-2.0",
|
| 17 |
-
"dependencies": {
|
| 18 |
-
"jiti": "2.4.2"
|
| 19 |
-
}
|
| 20 |
-
},
|
| 21 |
-
"node_modules/@prisma/debug": {
|
| 22 |
-
"version": "6.12.0",
|
| 23 |
-
"resolved": "https://registry.npmjs.org/@prisma/debug/-/debug-6.12.0.tgz",
|
| 24 |
-
"integrity": "sha512-plbz6z72orcqr0eeio7zgUrZj5EudZUpAeWkFTA/DDdXEj28YHDXuiakvR6S7sD6tZi+jiwQEJAPeV6J6m/tEQ==",
|
| 25 |
-
"dev": true,
|
| 26 |
-
"license": "Apache-2.0"
|
| 27 |
-
},
|
| 28 |
-
"node_modules/@prisma/engines": {
|
| 29 |
-
"version": "6.12.0",
|
| 30 |
-
"resolved": "https://registry.npmjs.org/@prisma/engines/-/engines-6.12.0.tgz",
|
| 31 |
-
"integrity": "sha512-4BRZZUaAuB4p0XhTauxelvFs7IllhPmNLvmla0bO1nkECs8n/o1pUvAVbQ/VOrZR5DnF4HED0PrGai+rIOVePA==",
|
| 32 |
-
"dev": true,
|
| 33 |
-
"hasInstallScript": true,
|
| 34 |
-
"license": "Apache-2.0",
|
| 35 |
-
"dependencies": {
|
| 36 |
-
"@prisma/debug": "6.12.0",
|
| 37 |
-
"@prisma/engines-version": "6.12.0-15.8047c96bbd92db98a2abc7c9323ce77c02c89dbc",
|
| 38 |
-
"@prisma/fetch-engine": "6.12.0",
|
| 39 |
-
"@prisma/get-platform": "6.12.0"
|
| 40 |
-
}
|
| 41 |
-
},
|
| 42 |
-
"node_modules/@prisma/engines-version": {
|
| 43 |
-
"version": "6.12.0-15.8047c96bbd92db98a2abc7c9323ce77c02c89dbc",
|
| 44 |
-
"resolved": "https://registry.npmjs.org/@prisma/engines-version/-/engines-version-6.12.0-15.8047c96bbd92db98a2abc7c9323ce77c02c89dbc.tgz",
|
| 45 |
-
"integrity": "sha512-70vhecxBJlRr06VfahDzk9ow4k1HIaSfVUT3X0/kZoHCMl9zbabut4gEXAyzJZxaCGi5igAA7SyyfBI//mmkbQ==",
|
| 46 |
-
"dev": true,
|
| 47 |
-
"license": "Apache-2.0"
|
| 48 |
-
},
|
| 49 |
-
"node_modules/@prisma/fetch-engine": {
|
| 50 |
-
"version": "6.12.0",
|
| 51 |
-
"resolved": "https://registry.npmjs.org/@prisma/fetch-engine/-/fetch-engine-6.12.0.tgz",
|
| 52 |
-
"integrity": "sha512-EamoiwrK46rpWaEbLX9aqKDPOd8IyLnZAkiYXFNuq0YsU0Z8K09/rH8S7feOWAVJ3xzeSgcEJtBlVDrajM9Sag==",
|
| 53 |
-
"dev": true,
|
| 54 |
-
"license": "Apache-2.0",
|
| 55 |
-
"dependencies": {
|
| 56 |
-
"@prisma/debug": "6.12.0",
|
| 57 |
-
"@prisma/engines-version": "6.12.0-15.8047c96bbd92db98a2abc7c9323ce77c02c89dbc",
|
| 58 |
-
"@prisma/get-platform": "6.12.0"
|
| 59 |
-
}
|
| 60 |
-
},
|
| 61 |
-
"node_modules/@prisma/get-platform": {
|
| 62 |
-
"version": "6.12.0",
|
| 63 |
-
"resolved": "https://registry.npmjs.org/@prisma/get-platform/-/get-platform-6.12.0.tgz",
|
| 64 |
-
"integrity": "sha512-nRerTGhTlgyvcBlyWgt8OLNIV7QgJS2XYXMJD1hysorMCuLAjuDDuoxmVt7C2nLxbuxbWPp7OuFRHC23HqD9dA==",
|
| 65 |
-
"dev": true,
|
| 66 |
-
"license": "Apache-2.0",
|
| 67 |
-
"dependencies": {
|
| 68 |
-
"@prisma/debug": "6.12.0"
|
| 69 |
-
}
|
| 70 |
-
},
|
| 71 |
-
"node_modules/jiti": {
|
| 72 |
-
"version": "2.4.2",
|
| 73 |
-
"resolved": "https://registry.npmjs.org/jiti/-/jiti-2.4.2.tgz",
|
| 74 |
-
"integrity": "sha512-rg9zJN+G4n2nfJl5MW3BMygZX56zKPNVEYYqq7adpmMh4Jn2QNEwhvQlFy6jPVdcod7txZtKHWnyZiA3a0zP7A==",
|
| 75 |
-
"dev": true,
|
| 76 |
-
"license": "MIT",
|
| 77 |
-
"bin": {
|
| 78 |
-
"jiti": "lib/jiti-cli.mjs"
|
| 79 |
-
}
|
| 80 |
-
},
|
| 81 |
-
"node_modules/prisma": {
|
| 82 |
-
"version": "6.12.0",
|
| 83 |
-
"resolved": "https://registry.npmjs.org/prisma/-/prisma-6.12.0.tgz",
|
| 84 |
-
"integrity": "sha512-pmV7NEqQej9WjizN6RSNIwf7Y+jeh9mY1JEX2WjGxJi4YZWexClhde1yz/FuvAM+cTwzchcMytu2m4I6wPkIzg==",
|
| 85 |
-
"dev": true,
|
| 86 |
-
"hasInstallScript": true,
|
| 87 |
-
"license": "Apache-2.0",
|
| 88 |
-
"dependencies": {
|
| 89 |
-
"@prisma/config": "6.12.0",
|
| 90 |
-
"@prisma/engines": "6.12.0"
|
| 91 |
-
},
|
| 92 |
-
"bin": {
|
| 93 |
-
"prisma": "build/index.js"
|
| 94 |
-
},
|
| 95 |
-
"engines": {
|
| 96 |
-
"node": ">=18.18"
|
| 97 |
-
},
|
| 98 |
-
"peerDependencies": {
|
| 99 |
-
"typescript": ">=5.1.0"
|
| 100 |
-
},
|
| 101 |
-
"peerDependenciesMeta": {
|
| 102 |
-
"typescript": {
|
| 103 |
-
"optional": true
|
| 104 |
-
}
|
| 105 |
-
}
|
| 106 |
-
}
|
| 107 |
-
}
|
| 108 |
-
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
package.json
DELETED
|
@@ -1,5 +0,0 @@
|
|
| 1 |
-
{
|
| 2 |
-
"devDependencies": {
|
| 3 |
-
"prisma": "^6.12.0"
|
| 4 |
-
}
|
| 5 |
-
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
prisma/schema.prisma
DELETED
|
@@ -1,87 +0,0 @@
|
|
| 1 |
-
generator client {
|
| 2 |
-
provider = "prisma-client-js"
|
| 3 |
-
output = "../generated/prisma"
|
| 4 |
-
}
|
| 5 |
-
|
| 6 |
-
datasource db {
|
| 7 |
-
provider = "postgresql"
|
| 8 |
-
url = env("DATABASE_URL")
|
| 9 |
-
}
|
| 10 |
-
|
| 11 |
-
/// This table contains check constraints and requires additional setup for migrations. Visit https://pris.ly/d/check-constraints for more info.
|
| 12 |
-
model captions {
|
| 13 |
-
cap_id String @id @default(dbgenerated("gen_random_uuid()")) @db.Uuid
|
| 14 |
-
map_id String? @unique @db.Uuid
|
| 15 |
-
model String
|
| 16 |
-
raw_json Json
|
| 17 |
-
generated String
|
| 18 |
-
edited String?
|
| 19 |
-
accuracy Int? @db.SmallInt
|
| 20 |
-
context Int? @db.SmallInt
|
| 21 |
-
usability Int? @db.SmallInt
|
| 22 |
-
created_at DateTime @default(now()) @db.Timestamptz(6)
|
| 23 |
-
updated_at DateTime? @db.Timestamptz(6)
|
| 24 |
-
maps maps? @relation(fields: [map_id], references: [map_id], onDelete: Cascade, onUpdate: NoAction)
|
| 25 |
-
model_captions_modelTomodel model @relation("captions_modelTomodel", fields: [model], references: [m_code], onDelete: NoAction, onUpdate: NoAction)
|
| 26 |
-
}
|
| 27 |
-
|
| 28 |
-
model category {
|
| 29 |
-
cat_code String @id
|
| 30 |
-
label String
|
| 31 |
-
maps_maps_categoryTocategory maps[] @relation("maps_categoryTocategory")
|
| 32 |
-
}
|
| 33 |
-
|
| 34 |
-
model country {
|
| 35 |
-
c_code String @id @db.Char(2)
|
| 36 |
-
label String
|
| 37 |
-
map_countries map_countries[]
|
| 38 |
-
}
|
| 39 |
-
|
| 40 |
-
model goose_db_version {
|
| 41 |
-
id Int @id @default(autoincrement())
|
| 42 |
-
version_id BigInt
|
| 43 |
-
is_applied Boolean
|
| 44 |
-
tstamp DateTime @default(now()) @db.Timestamp(6)
|
| 45 |
-
}
|
| 46 |
-
|
| 47 |
-
model map_countries {
|
| 48 |
-
map_id String @db.Uuid
|
| 49 |
-
c_code String @db.Char(2)
|
| 50 |
-
country country @relation(fields: [c_code], references: [c_code], onDelete: NoAction, onUpdate: NoAction)
|
| 51 |
-
maps maps @relation(fields: [map_id], references: [map_id], onDelete: Cascade, onUpdate: NoAction)
|
| 52 |
-
|
| 53 |
-
@@id([map_id, c_code])
|
| 54 |
-
}
|
| 55 |
-
|
| 56 |
-
model maps {
|
| 57 |
-
map_id String @id @default(dbgenerated("gen_random_uuid()")) @db.Uuid
|
| 58 |
-
file_key String
|
| 59 |
-
sha256 String
|
| 60 |
-
created_at DateTime @default(now()) @db.Timestamptz(6)
|
| 61 |
-
source String
|
| 62 |
-
region String
|
| 63 |
-
category String
|
| 64 |
-
captions captions?
|
| 65 |
-
map_countries map_countries[]
|
| 66 |
-
category_maps_categoryTocategory category @relation("maps_categoryTocategory", fields: [category], references: [cat_code], onDelete: NoAction, onUpdate: NoAction)
|
| 67 |
-
region_maps_regionToregion region @relation("maps_regionToregion", fields: [region], references: [r_code], onDelete: NoAction, onUpdate: NoAction)
|
| 68 |
-
sources sources @relation(fields: [source], references: [s_code], onDelete: NoAction, onUpdate: NoAction)
|
| 69 |
-
}
|
| 70 |
-
|
| 71 |
-
model model {
|
| 72 |
-
m_code String @id
|
| 73 |
-
label String
|
| 74 |
-
captions_captions_modelTomodel captions[] @relation("captions_modelTomodel")
|
| 75 |
-
}
|
| 76 |
-
|
| 77 |
-
model region {
|
| 78 |
-
r_code String @id
|
| 79 |
-
label String
|
| 80 |
-
maps_maps_regionToregion maps[] @relation("maps_regionToregion")
|
| 81 |
-
}
|
| 82 |
-
|
| 83 |
-
model sources {
|
| 84 |
-
s_code String @id
|
| 85 |
-
label String
|
| 86 |
-
maps maps[]
|
| 87 |
-
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
py_backend/alembic/versions/0002_seed.py
DELETED
|
@@ -1,87 +0,0 @@
|
|
| 1 |
-
# py_backend/alembic/versions/0002_seed_lookups.py
|
| 2 |
-
|
| 3 |
-
"""seed lookup tables
|
| 4 |
-
|
| 5 |
-
Revision ID: 0002seed
|
| 6 |
-
Revises: ad38fd571716
|
| 7 |
-
Create Date: 2025-07-25 14:00:00.000000
|
| 8 |
-
"""
|
| 9 |
-
|
| 10 |
-
from alembic import op
|
| 11 |
-
|
| 12 |
-
# revision identifiers, used by Alembic.
|
| 13 |
-
revision = '0002seed'
|
| 14 |
-
down_revision = 'ad38fd571716'
|
| 15 |
-
branch_labels = None
|
| 16 |
-
depends_on = None
|
| 17 |
-
|
| 18 |
-
|
| 19 |
-
def upgrade():
|
| 20 |
-
# 1) sources
|
| 21 |
-
op.execute("""
|
| 22 |
-
INSERT INTO sources (s_code, label) VALUES
|
| 23 |
-
('WFP_ADAM', 'WFP ADAM β Automated Disaster Analysis & Mapping'),
|
| 24 |
-
('PDC', 'Pacific Disaster Center (PDC)'),
|
| 25 |
-
('GDACS', 'GDACS β Global Disaster Alert & Coordination System'),
|
| 26 |
-
('GFL_HUB', 'Google Flood Hub'),
|
| 27 |
-
('GFL_GENCAST', 'Google GenCast'),
|
| 28 |
-
('USGS', 'USGS β United States Geological Survey'),
|
| 29 |
-
('_TBD_SOURCE','TBD placeholder')
|
| 30 |
-
ON CONFLICT (s_code) DO NOTHING;
|
| 31 |
-
""")
|
| 32 |
-
|
| 33 |
-
# 2) region
|
| 34 |
-
op.execute("""
|
| 35 |
-
INSERT INTO region (r_code, label) VALUES
|
| 36 |
-
('AFR','Africa'),
|
| 37 |
-
('AMR','Americas'),
|
| 38 |
-
('APA','AsiaβPacific'),
|
| 39 |
-
('EUR','Europe'),
|
| 40 |
-
('MENA','Middleβ―East & Northβ―Africa'),
|
| 41 |
-
('_TBD_REGION','TBD placeholder')
|
| 42 |
-
ON CONFLICT (r_code) DO NOTHING;
|
| 43 |
-
""")
|
| 44 |
-
|
| 45 |
-
# 3) category
|
| 46 |
-
op.execute("""
|
| 47 |
-
INSERT INTO category (cat_code, label) VALUES
|
| 48 |
-
('FLOOD','Flood'),
|
| 49 |
-
('WILDFIRE','Wildfire'),
|
| 50 |
-
('EARTHQUAKE','Earthquake'),
|
| 51 |
-
('CYCLONE','Cyclone'),
|
| 52 |
-
('DROUGHT','Drought'),
|
| 53 |
-
('LANDSLIDE','Landslide'),
|
| 54 |
-
('TORNADO','Tornado'),
|
| 55 |
-
('VOLCANO','Volcano'),
|
| 56 |
-
('OTHER','Other'),
|
| 57 |
-
('_TBD_CATEGORY','TBD placeholder')
|
| 58 |
-
ON CONFLICT (cat_code) DO NOTHING;
|
| 59 |
-
""")
|
| 60 |
-
|
| 61 |
-
# 4) model
|
| 62 |
-
op.execute("""
|
| 63 |
-
INSERT INTO model (m_code, label) VALUES
|
| 64 |
-
('GPT-4O', 'GPTβ4o Vision'),
|
| 65 |
-
('GEMINI15', 'GeminiΒ 1.5 Pro'),
|
| 66 |
-
('CLAUDE3', 'ClaudeΒ 3 Sonnet'),
|
| 67 |
-
('STUB_MODEL','Stub Captioner')
|
| 68 |
-
ON CONFLICT (m_code) DO NOTHING;
|
| 69 |
-
""")
|
| 70 |
-
|
| 71 |
-
# 5) country (example set; add more ISOβ2 codes as needed)
|
| 72 |
-
op.execute("""
|
| 73 |
-
INSERT INTO country (c_code, label) VALUES
|
| 74 |
-
('PH','Philippines'),
|
| 75 |
-
('ID','Indonesia'),
|
| 76 |
-
('VN','Vietnam')
|
| 77 |
-
ON CONFLICT (c_code) DO NOTHING;
|
| 78 |
-
""")
|
| 79 |
-
|
| 80 |
-
|
| 81 |
-
def downgrade():
|
| 82 |
-
# reverse in roughly the opposite order
|
| 83 |
-
op.execute("DELETE FROM country WHERE c_code IN ('PH','ID','VN');")
|
| 84 |
-
op.execute("DELETE FROM model WHERE m_code IN ('GPT-4O','GEMINI15','CLAUDE3','STUB_MODEL');")
|
| 85 |
-
op.execute("DELETE FROM category WHERE cat_code IN ('FLOOD','WILDFIRE','EARTHQUAKE','CYCLONE','DROUGHT','LANDSLIDE','TORNADO','VOLCANO','OTHER','_TBD_CATEGORY');")
|
| 86 |
-
op.execute("DELETE FROM region WHERE r_code IN ('AFR','AMR','APA','EUR','MENA','_TBD_REGION');")
|
| 87 |
-
op.execute("DELETE FROM sources WHERE s_code IN ('WFP_ADAM','PDC','GDACS','GFL_HUB','GFL_GENCAST','USGS','_TBD_SOURCE');")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
py_backend/alembic/versions/ad38fd571716_init_schema.py
DELETED
|
@@ -1,113 +0,0 @@
|
|
| 1 |
-
"""init schema
|
| 2 |
-
|
| 3 |
-
Revision ID: ad38fd571716
|
| 4 |
-
Revises:
|
| 5 |
-
Create Date: 2025-07-24 15:30:00.000000
|
| 6 |
-
|
| 7 |
-
"""
|
| 8 |
-
from alembic import op
|
| 9 |
-
import sqlalchemy as sa
|
| 10 |
-
|
| 11 |
-
# revision identifiers, used by Alembic.
|
| 12 |
-
revision = 'ad38fd571716'
|
| 13 |
-
down_revision = None
|
| 14 |
-
branch_labels = None
|
| 15 |
-
depends_on = None
|
| 16 |
-
|
| 17 |
-
|
| 18 |
-
def upgrade():
|
| 19 |
-
# 1) Enable pgcrypto extension for gen_random_uuid()
|
| 20 |
-
op.execute('CREATE EXTENSION IF NOT EXISTS "pgcrypto";')
|
| 21 |
-
|
| 22 |
-
# 2) Lookup tables
|
| 23 |
-
op.create_table(
|
| 24 |
-
'sources',
|
| 25 |
-
sa.Column('s_code', sa.Text(), primary_key=True),
|
| 26 |
-
sa.Column('label', sa.Text(), nullable=False),
|
| 27 |
-
)
|
| 28 |
-
op.create_table(
|
| 29 |
-
'region',
|
| 30 |
-
sa.Column('r_code', sa.Text(), primary_key=True),
|
| 31 |
-
sa.Column('label', sa.Text(), nullable=False),
|
| 32 |
-
)
|
| 33 |
-
op.create_table(
|
| 34 |
-
'category',
|
| 35 |
-
sa.Column('cat_code', sa.Text(), primary_key=True),
|
| 36 |
-
sa.Column('label', sa.Text(), nullable=False),
|
| 37 |
-
)
|
| 38 |
-
op.create_table(
|
| 39 |
-
'country',
|
| 40 |
-
sa.Column('c_code', sa.CHAR(length=2), primary_key=True),
|
| 41 |
-
sa.Column('label', sa.Text(), nullable=False),
|
| 42 |
-
)
|
| 43 |
-
op.create_table(
|
| 44 |
-
'model',
|
| 45 |
-
sa.Column('m_code', sa.Text(), primary_key=True),
|
| 46 |
-
sa.Column('label', sa.Text(), nullable=False),
|
| 47 |
-
)
|
| 48 |
-
|
| 49 |
-
# 3) maps table
|
| 50 |
-
op.create_table(
|
| 51 |
-
'maps',
|
| 52 |
-
sa.Column('map_id', sa.UUID(),
|
| 53 |
-
server_default=sa.text('gen_random_uuid()'),
|
| 54 |
-
primary_key=True),
|
| 55 |
-
sa.Column('file_key', sa.Text(), nullable=False),
|
| 56 |
-
sa.Column('sha256', sa.Text(), nullable=False),
|
| 57 |
-
sa.Column('source', sa.Text(), nullable=False),
|
| 58 |
-
sa.Column('region', sa.Text(), nullable=False),
|
| 59 |
-
sa.Column('category', sa.Text(), nullable=False),
|
| 60 |
-
sa.Column('created_at',sa.TIMESTAMP(timezone=True),
|
| 61 |
-
server_default=sa.text('NOW()'),
|
| 62 |
-
nullable=False),
|
| 63 |
-
sa.ForeignKeyConstraint(['source'], ['sources.s_code']),
|
| 64 |
-
sa.ForeignKeyConstraint(['region'], ['region.r_code']),
|
| 65 |
-
sa.ForeignKeyConstraint(['category'], ['category.cat_code']),
|
| 66 |
-
)
|
| 67 |
-
|
| 68 |
-
# 4) map_countries join table
|
| 69 |
-
op.create_table(
|
| 70 |
-
'map_countries',
|
| 71 |
-
sa.Column('map_id', sa.UUID(), nullable=False),
|
| 72 |
-
sa.Column('c_code', sa.CHAR(length=2), nullable=False),
|
| 73 |
-
sa.PrimaryKeyConstraint('map_id', 'c_code'),
|
| 74 |
-
sa.ForeignKeyConstraint(['map_id'], ['maps.map_id'], ondelete='CASCADE'),
|
| 75 |
-
sa.ForeignKeyConstraint(['c_code'], ['country.c_code']),
|
| 76 |
-
)
|
| 77 |
-
|
| 78 |
-
# 5) captions table
|
| 79 |
-
op.create_table(
|
| 80 |
-
'captions',
|
| 81 |
-
sa.Column('cap_id', sa.UUID(),
|
| 82 |
-
server_default=sa.text('gen_random_uuid()'),
|
| 83 |
-
primary_key=True),
|
| 84 |
-
sa.Column('map_id', sa.UUID(), nullable=False, unique=True),
|
| 85 |
-
sa.Column('model', sa.Text(), nullable=False),
|
| 86 |
-
sa.Column('raw_json', sa.JSON(), nullable=False),
|
| 87 |
-
sa.Column('generated', sa.Text(), nullable=False),
|
| 88 |
-
sa.Column('edited', sa.Text(), nullable=True),
|
| 89 |
-
sa.Column('accuracy', sa.SmallInteger(),
|
| 90 |
-
sa.CheckConstraint('accuracy BETWEEN 0 AND 100')),
|
| 91 |
-
sa.Column('context', sa.SmallInteger(),
|
| 92 |
-
sa.CheckConstraint('context BETWEEN 0 AND 100')),
|
| 93 |
-
sa.Column('usability', sa.SmallInteger(),
|
| 94 |
-
sa.CheckConstraint('usability BETWEEN 0 AND 100')),
|
| 95 |
-
sa.Column('created_at',sa.TIMESTAMP(timezone=True),
|
| 96 |
-
server_default=sa.text('NOW()'),
|
| 97 |
-
nullable=False),
|
| 98 |
-
sa.Column('updated_at',sa.TIMESTAMP(timezone=True), nullable=True),
|
| 99 |
-
sa.ForeignKeyConstraint(['map_id'], ['maps.map_id'], ondelete='CASCADE'),
|
| 100 |
-
sa.ForeignKeyConstraint(['model'], ['model.m_code']),
|
| 101 |
-
)
|
| 102 |
-
|
| 103 |
-
|
| 104 |
-
def downgrade():
|
| 105 |
-
# drop in reverse order to respect FKs
|
| 106 |
-
op.drop_table('captions')
|
| 107 |
-
op.drop_table('map_countries')
|
| 108 |
-
op.drop_table('maps')
|
| 109 |
-
op.drop_table('model')
|
| 110 |
-
op.drop_table('country')
|
| 111 |
-
op.drop_table('category')
|
| 112 |
-
op.drop_table('region')
|
| 113 |
-
op.drop_table('sources')
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
py_backend/alembic/versions/b8fc40bfe3c7_initial_schema_seed.py
ADDED
|
@@ -0,0 +1,248 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""initial schema + full dynamic country seed
|
| 2 |
+
|
| 3 |
+
Revision ID: 0001_initial_schema_and_seed
|
| 4 |
+
Revises:
|
| 5 |
+
Create Date: 2025-08-01 20:00:00.000000
|
| 6 |
+
|
| 7 |
+
"""
|
| 8 |
+
from alembic import op
|
| 9 |
+
import sqlalchemy as sa
|
| 10 |
+
from sqlalchemy.dialects import postgresql
|
| 11 |
+
import pycountry
|
| 12 |
+
|
| 13 |
+
# revision identifiers, used by Alembic.
|
| 14 |
+
revision = '0001_initial_schema_and_seed'
|
| 15 |
+
down_revision = None
|
| 16 |
+
branch_labels = None
|
| 17 |
+
depends_on = None
|
| 18 |
+
|
| 19 |
+
def _guess_region(alpha2: str) -> str:
|
| 20 |
+
# Rough continentβregion buckets; tweak as needed
|
| 21 |
+
AFR = {'DZ','AO','BJ','BW','BF','BI','CM','CV','CF','TD','KM','CG','CD','CI','DJ','EG',
|
| 22 |
+
'GQ','ER','SZ','ET','GA','GM','GH','GN','GW','KE','LS','LR','LY','MG','MW','ML',
|
| 23 |
+
'MR','MU','YT','MA','MZ','NA','NE','NG','RE','RW','SH','ST','SN','SC','SL','SO',
|
| 24 |
+
'ZA','SS','SD','TZ','TG','TN','UG','EH','ZM','ZW'}
|
| 25 |
+
AMR = {'US','CA','MX','BR','AR','CO','PE','VE','CL','EC','GT','CU','BO','DO','HT','HN',
|
| 26 |
+
'PY','NI','SV','CR','PA','UY','JM','TT','GY','SR','BZ','KY','AG','BS','BB','BM',
|
| 27 |
+
'DM','GD','GP','HT','MQ','MS','PR','KN','LC','VC','SX','TC','VI'}
|
| 28 |
+
EUR = {'AL','AD','AT','BY','BE','BA','BG','HR','CY','CZ','DK','EE','FI','FR','DE','GI',
|
| 29 |
+
'GR','HU','IS','IE','IT','XK','LV','LI','LT','LU','MT','MD','MC','ME','NL','MK',
|
| 30 |
+
'NO','PL','PT','RO','RU','SM','RS','SK','SI','ES','SE','CH','TR','UA','GB','VA'}
|
| 31 |
+
MENA = {'DZ','BH','EG','IR','IQ','IL','JO','KW','LB','LY','MA','OM','QA','SA','SY','TN',
|
| 32 |
+
'AE','YE','PS','SD','EH'}
|
| 33 |
+
# If itβs in MENA, call it MENA; else AFR-only if in AFR set; else AMR, EUR, else APA
|
| 34 |
+
if alpha2 in MENA:
|
| 35 |
+
return 'MENA'
|
| 36 |
+
if alpha2 in (AFR - MENA):
|
| 37 |
+
return 'AFR'
|
| 38 |
+
if alpha2 in AMR:
|
| 39 |
+
return 'AMR'
|
| 40 |
+
if alpha2 in EUR:
|
| 41 |
+
return 'EUR'
|
| 42 |
+
return 'APA'
|
| 43 |
+
|
| 44 |
+
|
| 45 |
+
def upgrade():
|
| 46 |
+
# allow uuid generation
|
| 47 |
+
op.execute('CREATE EXTENSION IF NOT EXISTS pgcrypto;')
|
| 48 |
+
|
| 49 |
+
#
|
| 50 |
+
# 1) lookup tables
|
| 51 |
+
#
|
| 52 |
+
op.create_table(
|
| 53 |
+
'sources',
|
| 54 |
+
sa.Column('s_code', sa.String(), primary_key=True),
|
| 55 |
+
sa.Column('label', sa.String(), nullable=False),
|
| 56 |
+
)
|
| 57 |
+
op.create_table(
|
| 58 |
+
'regions',
|
| 59 |
+
sa.Column('r_code', sa.String(), primary_key=True),
|
| 60 |
+
sa.Column('label', sa.String(), nullable=False),
|
| 61 |
+
)
|
| 62 |
+
op.create_table(
|
| 63 |
+
'types',
|
| 64 |
+
sa.Column('t_code', sa.String(), primary_key=True),
|
| 65 |
+
sa.Column('label', sa.String(), nullable=False),
|
| 66 |
+
)
|
| 67 |
+
op.create_table(
|
| 68 |
+
'countries',
|
| 69 |
+
sa.Column('c_code', sa.CHAR(length=2), primary_key=True),
|
| 70 |
+
sa.Column('label', sa.String(), nullable=False),
|
| 71 |
+
sa.Column('r_code', sa.String(), sa.ForeignKey('regions.r_code'), nullable=False),
|
| 72 |
+
)
|
| 73 |
+
op.create_table(
|
| 74 |
+
'spatial_references',
|
| 75 |
+
sa.Column('epsg', sa.String(), primary_key=True),
|
| 76 |
+
sa.Column('srid', sa.String(), nullable=False),
|
| 77 |
+
sa.Column('proj4', sa.String(), nullable=False),
|
| 78 |
+
sa.Column('wkt', sa.String(), nullable=False),
|
| 79 |
+
)
|
| 80 |
+
op.create_table(
|
| 81 |
+
'image_types',
|
| 82 |
+
sa.Column('image_type', sa.String(), primary_key=True),
|
| 83 |
+
sa.Column('label', sa.String(), nullable=False),
|
| 84 |
+
)
|
| 85 |
+
op.create_table(
|
| 86 |
+
'models',
|
| 87 |
+
sa.Column('m_code', sa.String(), primary_key=True),
|
| 88 |
+
sa.Column('label', sa.String(), nullable=False),
|
| 89 |
+
)
|
| 90 |
+
|
| 91 |
+
#
|
| 92 |
+
# 2) seed lookup tables
|
| 93 |
+
#
|
| 94 |
+
# β sources β
|
| 95 |
+
op.execute("""
|
| 96 |
+
INSERT INTO sources (s_code,label) VALUES
|
| 97 |
+
('PDC','PDC'),
|
| 98 |
+
('GDACS','GDACS'),
|
| 99 |
+
('WFP','WFP ADAM'),
|
| 100 |
+
('GFH','Google Flood Hub'),
|
| 101 |
+
('GGC','Google GenCast'),
|
| 102 |
+
('USGS','USGS'),
|
| 103 |
+
('OTHER','Other')
|
| 104 |
+
""")
|
| 105 |
+
# β region β
|
| 106 |
+
op.execute("""
|
| 107 |
+
INSERT INTO regions (r_code,label) VALUES
|
| 108 |
+
('AFR','Africa'),
|
| 109 |
+
('AMR','Americas'),
|
| 110 |
+
('APA','Asia-Pacific'),
|
| 111 |
+
('EUR','Europe'),
|
| 112 |
+
('MENA','Middle East & N Africa')
|
| 113 |
+
""")
|
| 114 |
+
# β Type β
|
| 115 |
+
op.execute("""
|
| 116 |
+
INSERT INTO types (t_code,label) VALUES
|
| 117 |
+
('FLOOD','Flood'),
|
| 118 |
+
('FIRE','Fire'),
|
| 119 |
+
('EARTHQUAKE','Earthquake'),
|
| 120 |
+
('CYCLONE','Cyclone'),
|
| 121 |
+
('TSUNAMI','Tsunami'),
|
| 122 |
+
('POPULATION_MOVEMENT','Population Movement'),
|
| 123 |
+
('EPIDEMIC','Epidemic'),
|
| 124 |
+
('PLUVIAL','Pluvial'),
|
| 125 |
+
('STORM','Storm'),
|
| 126 |
+
('LANDSLIDE','Landslide'),
|
| 127 |
+
('COLD_WAVE','Cold Wave'),
|
| 128 |
+
('BIOLOGICAL_EMERGENCY','Biological Emergency'),
|
| 129 |
+
('CHEMICAL_EMERGENCY','Chemical Emergency'),
|
| 130 |
+
('CIVIL_UNREST','Civil Unrest'),
|
| 131 |
+
('COMPLEX_EMERGENCY','Complex Emergency'),
|
| 132 |
+
('DROUGHT','Drought'),
|
| 133 |
+
('FLOOD_INSECURITY','Flood Insecurity'),
|
| 134 |
+
('HEAT_WAVE','Heat Wave'),
|
| 135 |
+
('INSECT_INFESTATION','Insect Infestation'),
|
| 136 |
+
('RADIOLOGICAL_EMERGENCY','Radiological Emergency'),
|
| 137 |
+
('TRANSPORTATION_EMERGENCY','Transportation Emergency'),
|
| 138 |
+
('VOLCANIC_ERUPTION','Volcanic Eruption'),
|
| 139 |
+
('OTHER','Other')
|
| 140 |
+
""")
|
| 141 |
+
|
| 142 |
+
# seed only the most common CRSs + an οΏ½οΏ½οΏ½Otherβ placeholder
|
| 143 |
+
op.execute("""
|
| 144 |
+
INSERT INTO spatial_references (epsg, srid, proj4, wkt) VALUES
|
| 145 |
+
('4326','4326',
|
| 146 |
+
'+proj=longlat +datum=WGS84 +no_defs',
|
| 147 |
+
'GEOGCS["WGS 84",DATUM["WGS_1984",SPHEROID["WGS 84",6378137,298.257223563]],PRIMEM["Greenwich",0],UNIT["degree",0.0174532925199433]]'
|
| 148 |
+
),
|
| 149 |
+
('3857','3857',
|
| 150 |
+
'+proj=merc +lon_0=0 +k=1 +x_0=0 +y_0=0 +datum=WGS84 +units=m +no_defs',
|
| 151 |
+
'PROJCS["WGS 84 / Pseudo-Mercator",GEOGCS["WGS 84",DATUM["WGS_1984",SPHEROID["WGS 84",6378137,298.257223563]],PRIMEM["Greenwich",0],UNIT["degree",0.0174532925199433]]]'
|
| 152 |
+
),
|
| 153 |
+
('32633','32633',
|
| 154 |
+
'+proj=utm +zone=33 +datum=WGS84 +units=m +no_defs',
|
| 155 |
+
'PROJCS["WGS 84 / UTM zone 33N",GEOGCS["WGS 84",β¦]]'
|
| 156 |
+
),
|
| 157 |
+
('32634','32634',
|
| 158 |
+
'+proj=utm +zone=34 +datum=WGS84 +units=m +no_defs',
|
| 159 |
+
'PROJCS["WGS 84 / UTM zone 34N",GEOGCS["WGS 84",β¦]]'
|
| 160 |
+
),
|
| 161 |
+
('OTHER','OTHER',
|
| 162 |
+
'',
|
| 163 |
+
'Other'
|
| 164 |
+
);
|
| 165 |
+
""")
|
| 166 |
+
# β image_type β
|
| 167 |
+
op.execute("""
|
| 168 |
+
INSERT INTO image_types (image_type,label) VALUES
|
| 169 |
+
('crisis_map','Crisis Map'),
|
| 170 |
+
('drone_image','Drone Image')
|
| 171 |
+
""")
|
| 172 |
+
# β model β
|
| 173 |
+
op.execute("""
|
| 174 |
+
INSERT INTO models (m_code,label) VALUES
|
| 175 |
+
('GPT-4O','GPT-4O'),
|
| 176 |
+
('GEMINI15','Gemini 1.5'),
|
| 177 |
+
('CLAUDE3','Claude 3'),
|
| 178 |
+
('STUB_MODEL','<stub>')
|
| 179 |
+
""")
|
| 180 |
+
|
| 181 |
+
# β country: full ISO-3166 seed via pycountry + auto region guess β
|
| 182 |
+
for c in pycountry.countries:
|
| 183 |
+
code = c.alpha_2
|
| 184 |
+
name = c.name.replace("'", "''")
|
| 185 |
+
region = _guess_region(code)
|
| 186 |
+
op.execute(
|
| 187 |
+
f"INSERT INTO countries (c_code,label,r_code) "
|
| 188 |
+
f"VALUES ('{code}','{name}','{region}')"
|
| 189 |
+
)
|
| 190 |
+
|
| 191 |
+
#
|
| 192 |
+
# 3) core tables
|
| 193 |
+
#
|
| 194 |
+
op.create_table(
|
| 195 |
+
'images',
|
| 196 |
+
sa.Column('image_id', postgresql.UUID(as_uuid=True),
|
| 197 |
+
server_default=sa.text('gen_random_uuid()'),
|
| 198 |
+
primary_key=True),
|
| 199 |
+
sa.Column('file_key', sa.String(), nullable=False),
|
| 200 |
+
sa.Column('sha256', sa.String(), nullable=False),
|
| 201 |
+
sa.Column('source', sa.String(), sa.ForeignKey('sources.s_code'), nullable=False),
|
| 202 |
+
sa.Column('type', sa.String(), sa.ForeignKey('types.t_code'), nullable=False),
|
| 203 |
+
sa.Column('epsg', sa.String(), sa.ForeignKey('spatial_references.epsg'), nullable=False),
|
| 204 |
+
sa.Column('created_at', sa.TIMESTAMP(timezone=True), server_default=sa.text('NOW()'), nullable=False),
|
| 205 |
+
sa.Column('image_type', sa.String(), sa.ForeignKey('image_types.image_type'), nullable=False),
|
| 206 |
+
)
|
| 207 |
+
op.create_table(
|
| 208 |
+
'image_countries',
|
| 209 |
+
sa.Column('image_id', postgresql.UUID(as_uuid=True), nullable=False),
|
| 210 |
+
sa.Column('c_code', sa.CHAR(length=2), nullable=False),
|
| 211 |
+
sa.PrimaryKeyConstraint('image_id','c_code'),
|
| 212 |
+
sa.ForeignKeyConstraint(['image_id'], ['images.image_id'], ondelete='CASCADE'),
|
| 213 |
+
sa.ForeignKeyConstraint(['c_code'], ['countries.c_code']),
|
| 214 |
+
)
|
| 215 |
+
op.create_table(
|
| 216 |
+
'captions',
|
| 217 |
+
sa.Column('cap_id', postgresql.UUID(as_uuid=True),
|
| 218 |
+
server_default=sa.text('gen_random_uuid()'),
|
| 219 |
+
primary_key=True),
|
| 220 |
+
sa.Column('image_id', postgresql.UUID(as_uuid=True),
|
| 221 |
+
sa.ForeignKey('images.image_id', ondelete='CASCADE'),
|
| 222 |
+
unique=True, nullable=False),
|
| 223 |
+
sa.Column('title', sa.String(), nullable=False),
|
| 224 |
+
sa.Column('prompt', sa.String(), nullable=False),
|
| 225 |
+
sa.Column('model', sa.String(), sa.ForeignKey('models.m_code'), nullable=False),
|
| 226 |
+
sa.Column('raw_json', sa.JSON(), nullable=False),
|
| 227 |
+
sa.Column('generated', sa.String(), nullable=False),
|
| 228 |
+
sa.Column('edited', sa.String(), nullable=True),
|
| 229 |
+
sa.Column('accuracy', sa.SmallInteger()),
|
| 230 |
+
sa.Column('context', sa.SmallInteger()),
|
| 231 |
+
sa.Column('usability', sa.SmallInteger()),
|
| 232 |
+
sa.Column('starred', sa.Boolean(), server_default=sa.text('false')),
|
| 233 |
+
sa.Column('created_at', sa.TIMESTAMP(timezone=True), server_default=sa.text('NOW()'), nullable=False),
|
| 234 |
+
sa.Column('updated_at', sa.TIMESTAMP(timezone=True), nullable=True),
|
| 235 |
+
)
|
| 236 |
+
|
| 237 |
+
|
| 238 |
+
def downgrade():
|
| 239 |
+
op.drop_table('captions')
|
| 240 |
+
op.drop_table('image_countries')
|
| 241 |
+
op.drop_table('images')
|
| 242 |
+
op.drop_table('models')
|
| 243 |
+
op.drop_table('image_types')
|
| 244 |
+
op.drop_table('spatial_references')
|
| 245 |
+
op.drop_table('countries')
|
| 246 |
+
op.drop_table('types')
|
| 247 |
+
op.drop_table('regions')
|
| 248 |
+
op.drop_table('sources')
|
py_backend/app/config.py
CHANGED
|
@@ -1,4 +1,4 @@
|
|
| 1 |
-
from
|
| 2 |
|
| 3 |
class Settings(BaseSettings):
|
| 4 |
DATABASE_URL: str
|
|
|
|
| 1 |
+
from pydantic_settings import BaseSettings
|
| 2 |
|
| 3 |
class Settings(BaseSettings):
|
| 4 |
DATABASE_URL: str
|
py_backend/app/crud.py
CHANGED
|
@@ -1,36 +1,47 @@
|
|
| 1 |
import io, hashlib
|
| 2 |
-
from sqlalchemy.orm import Session
|
| 3 |
from . import models
|
| 4 |
|
| 5 |
def hash_bytes(data: bytes) -> str:
|
| 6 |
"""Compute SHAβ256 hex digest of the data."""
|
| 7 |
return hashlib.sha256(data).hexdigest()
|
| 8 |
|
| 9 |
-
def
|
| 10 |
-
"""Insert into
|
| 11 |
-
|
| 12 |
-
source=src,
|
| 13 |
-
file_key=key, sha256=sha
|
| 14 |
)
|
| 15 |
-
db.add(
|
| 16 |
-
db.flush() # assign
|
| 17 |
|
| 18 |
# link countries
|
| 19 |
for c in countries:
|
| 20 |
country = db.get(models.Country, c)
|
| 21 |
if country:
|
| 22 |
-
|
| 23 |
|
| 24 |
db.commit()
|
| 25 |
-
db.refresh(
|
| 26 |
-
return
|
| 27 |
|
| 28 |
-
def
|
| 29 |
-
|
|
|
|
|
|
|
|
|
|
| 30 |
|
| 31 |
-
def
|
| 32 |
-
|
| 33 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 34 |
model=model_code,
|
| 35 |
raw_json=raw_json,
|
| 36 |
generated=text
|
|
@@ -41,16 +52,44 @@ def create_caption(db: Session, map_id, model_code, raw_json, text):
|
|
| 41 |
return c
|
| 42 |
|
| 43 |
def get_caption(db: Session, cap_id):
|
| 44 |
-
return db.get(models.
|
| 45 |
|
| 46 |
-
def update_caption(db: Session, cap_id, edited, accuracy, context, usability):
|
| 47 |
c = get_caption(db, cap_id)
|
| 48 |
if not c:
|
| 49 |
return None
|
| 50 |
-
|
| 51 |
-
|
| 52 |
-
|
| 53 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 54 |
db.commit()
|
| 55 |
db.refresh(c)
|
| 56 |
return c
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
import io, hashlib
|
| 2 |
+
from sqlalchemy.orm import Session, joinedload
|
| 3 |
from . import models
|
| 4 |
|
| 5 |
def hash_bytes(data: bytes) -> str:
|
| 6 |
"""Compute SHAβ256 hex digest of the data."""
|
| 7 |
return hashlib.sha256(data).hexdigest()
|
| 8 |
|
| 9 |
+
def create_image(db: Session, src, type_code, key, sha, countries: list[str], epsg: str, image_type: str):
|
| 10 |
+
"""Insert into images and image_countries."""
|
| 11 |
+
img = models.Images(
|
| 12 |
+
source=src, type=type_code,
|
| 13 |
+
file_key=key, sha256=sha, epsg=epsg, image_type=image_type
|
| 14 |
)
|
| 15 |
+
db.add(img)
|
| 16 |
+
db.flush() # assign img.image_id
|
| 17 |
|
| 18 |
# link countries
|
| 19 |
for c in countries:
|
| 20 |
country = db.get(models.Country, c)
|
| 21 |
if country:
|
| 22 |
+
img.countries.append(country)
|
| 23 |
|
| 24 |
db.commit()
|
| 25 |
+
db.refresh(img)
|
| 26 |
+
return img
|
| 27 |
|
| 28 |
+
def get_images(db: Session):
|
| 29 |
+
"""Get all images with their captions"""
|
| 30 |
+
return db.query(models.Images).options(
|
| 31 |
+
joinedload(models.Images.caption)
|
| 32 |
+
).all()
|
| 33 |
|
| 34 |
+
def get_image(db: Session, image_id: str):
|
| 35 |
+
"""Get a single image by ID with its caption"""
|
| 36 |
+
return db.query(models.Images).options(
|
| 37 |
+
joinedload(models.Images.caption)
|
| 38 |
+
).filter(models.Images.image_id == image_id).first()
|
| 39 |
+
|
| 40 |
+
def create_caption(db: Session, image_id, title, prompt, model_code, raw_json, text):
|
| 41 |
+
c = models.Captions(
|
| 42 |
+
image_id=image_id,
|
| 43 |
+
title=title,
|
| 44 |
+
prompt=prompt,
|
| 45 |
model=model_code,
|
| 46 |
raw_json=raw_json,
|
| 47 |
generated=text
|
|
|
|
| 52 |
return c
|
| 53 |
|
| 54 |
def get_caption(db: Session, cap_id):
|
| 55 |
+
return db.get(models.Captions, cap_id)
|
| 56 |
|
| 57 |
+
def update_caption(db: Session, cap_id, edited=None, accuracy=None, context=None, usability=None, starred=None):
|
| 58 |
c = get_caption(db, cap_id)
|
| 59 |
if not c:
|
| 60 |
return None
|
| 61 |
+
|
| 62 |
+
if edited is not None:
|
| 63 |
+
c.edited = edited
|
| 64 |
+
if accuracy is not None:
|
| 65 |
+
c.accuracy = accuracy
|
| 66 |
+
if context is not None:
|
| 67 |
+
c.context = context
|
| 68 |
+
if usability is not None:
|
| 69 |
+
c.usability = usability
|
| 70 |
+
if starred is not None:
|
| 71 |
+
c.starred = starred
|
| 72 |
+
|
| 73 |
db.commit()
|
| 74 |
db.refresh(c)
|
| 75 |
return c
|
| 76 |
+
|
| 77 |
+
def get_sources(db: Session):
|
| 78 |
+
"""Get all sources for lookup"""
|
| 79 |
+
return db.query(models.Source).all()
|
| 80 |
+
|
| 81 |
+
def get_regions(db: Session):
|
| 82 |
+
"""Get all regions for lookup"""
|
| 83 |
+
return db.query(models.Region).all()
|
| 84 |
+
|
| 85 |
+
def get_types(db: Session):
|
| 86 |
+
"""Get all types for lookup"""
|
| 87 |
+
return db.query(models.Type).all()
|
| 88 |
+
|
| 89 |
+
def get_spatial_references(db: Session):
|
| 90 |
+
"""Get all spatial references for lookup"""
|
| 91 |
+
return db.query(models.SpatialReference).all()
|
| 92 |
+
|
| 93 |
+
def get_image_types(db: Session):
|
| 94 |
+
"""Get all image types for lookup"""
|
| 95 |
+
return db.query(models.ImageTypes).all()
|
py_backend/app/main.py
CHANGED
|
@@ -21,7 +21,7 @@ app.add_middleware(
|
|
| 21 |
)
|
| 22 |
|
| 23 |
# Mount routers
|
| 24 |
-
app.include_router(upload.router, prefix="/api/
|
| 25 |
app.include_router(caption.router, prefix="/api", tags=["captions"])
|
| 26 |
app.include_router(metadata.router, prefix="/api", tags=["metadata"])
|
| 27 |
|
|
|
|
| 21 |
)
|
| 22 |
|
| 23 |
# Mount routers
|
| 24 |
+
app.include_router(upload.router, prefix="/api/images", tags=["images"])
|
| 25 |
app.include_router(caption.router, prefix="/api", tags=["captions"])
|
| 26 |
app.include_router(metadata.router, prefix="/api", tags=["metadata"])
|
| 27 |
|
py_backend/app/models.py
CHANGED
|
@@ -1,16 +1,25 @@
|
|
| 1 |
from sqlalchemy import (
|
| 2 |
-
Column, String, DateTime, JSON, SmallInteger, Table, ForeignKey
|
| 3 |
)
|
| 4 |
from sqlalchemy.dialects.postgresql import UUID, TIMESTAMP, CHAR
|
| 5 |
from sqlalchemy.orm import relationship
|
| 6 |
import datetime, uuid
|
| 7 |
from .database import Base
|
| 8 |
|
| 9 |
-
|
| 10 |
-
|
| 11 |
-
|
| 12 |
-
|
| 13 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 14 |
)
|
| 15 |
|
| 16 |
class Source(Base):
|
|
@@ -19,50 +28,67 @@ class Source(Base):
|
|
| 19 |
label = Column(String, nullable=False)
|
| 20 |
|
| 21 |
class Region(Base):
|
| 22 |
-
__tablename__ = "
|
| 23 |
r_code = Column(String, primary_key=True)
|
| 24 |
label = Column(String, nullable=False)
|
| 25 |
|
| 26 |
-
class
|
| 27 |
-
__tablename__ = "
|
| 28 |
-
|
| 29 |
label = Column(String, nullable=False)
|
| 30 |
|
| 31 |
class Country(Base):
|
| 32 |
-
__tablename__ = "
|
| 33 |
c_code = Column(CHAR(2), primary_key=True)
|
| 34 |
label = Column(String, nullable=False)
|
|
|
|
| 35 |
|
| 36 |
-
class
|
| 37 |
-
__tablename__ = "
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 38 |
m_code = Column(String, primary_key=True)
|
| 39 |
label = Column(String, nullable=False)
|
| 40 |
|
| 41 |
-
class
|
| 42 |
-
__tablename__ = "
|
| 43 |
-
|
| 44 |
file_key = Column(String, nullable=False)
|
| 45 |
sha256 = Column(String, nullable=False)
|
| 46 |
source = Column(String, ForeignKey("sources.s_code"), nullable=False)
|
| 47 |
-
|
| 48 |
-
|
| 49 |
created_at = Column(TIMESTAMP(timezone=True), default=datetime.datetime.utcnow)
|
|
|
|
| 50 |
|
| 51 |
-
countries = relationship("Country", secondary=
|
| 52 |
-
caption = relationship("
|
| 53 |
|
| 54 |
-
class
|
| 55 |
__tablename__ = "captions"
|
| 56 |
cap_id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
| 57 |
-
|
| 58 |
-
|
|
|
|
|
|
|
| 59 |
raw_json = Column(JSON, nullable=False)
|
| 60 |
generated = Column(String, nullable=False)
|
| 61 |
edited = Column(String)
|
| 62 |
accuracy = Column(SmallInteger)
|
| 63 |
context = Column(SmallInteger)
|
| 64 |
usability = Column(SmallInteger)
|
|
|
|
| 65 |
created_at = Column(TIMESTAMP(timezone=True), default=datetime.datetime.utcnow)
|
| 66 |
updated_at = Column(TIMESTAMP(timezone=True), onupdate=datetime.datetime.utcnow)
|
| 67 |
|
| 68 |
-
|
|
|
|
| 1 |
from sqlalchemy import (
|
| 2 |
+
Column, String, DateTime, JSON, SmallInteger, Table, ForeignKey, Boolean
|
| 3 |
)
|
| 4 |
from sqlalchemy.dialects.postgresql import UUID, TIMESTAMP, CHAR
|
| 5 |
from sqlalchemy.orm import relationship
|
| 6 |
import datetime, uuid
|
| 7 |
from .database import Base
|
| 8 |
|
| 9 |
+
image_countries = Table(
|
| 10 |
+
"image_countries", Base.metadata,
|
| 11 |
+
Column(
|
| 12 |
+
"image_id",
|
| 13 |
+
UUID(as_uuid=True),
|
| 14 |
+
ForeignKey("images.image_id", ondelete="CASCADE"),
|
| 15 |
+
primary_key=True,
|
| 16 |
+
),
|
| 17 |
+
Column(
|
| 18 |
+
"c_code",
|
| 19 |
+
CHAR(2),
|
| 20 |
+
ForeignKey("countries.c_code"),
|
| 21 |
+
primary_key=True,
|
| 22 |
+
),
|
| 23 |
)
|
| 24 |
|
| 25 |
class Source(Base):
|
|
|
|
| 28 |
label = Column(String, nullable=False)
|
| 29 |
|
| 30 |
class Region(Base):
|
| 31 |
+
__tablename__ = "regions"
|
| 32 |
r_code = Column(String, primary_key=True)
|
| 33 |
label = Column(String, nullable=False)
|
| 34 |
|
| 35 |
+
class Type(Base):
|
| 36 |
+
__tablename__ = "types"
|
| 37 |
+
t_code = Column(String, primary_key=True)
|
| 38 |
label = Column(String, nullable=False)
|
| 39 |
|
| 40 |
class Country(Base):
|
| 41 |
+
__tablename__ = "countries"
|
| 42 |
c_code = Column(CHAR(2), primary_key=True)
|
| 43 |
label = Column(String, nullable=False)
|
| 44 |
+
r_code = Column(String, ForeignKey("regions.r_code"), nullable=False)
|
| 45 |
|
| 46 |
+
class SpatialReference(Base):
|
| 47 |
+
__tablename__ = "spatial_references"
|
| 48 |
+
epsg = Column(String, primary_key=True)
|
| 49 |
+
srid = Column(String, nullable=False)
|
| 50 |
+
proj4 = Column(String, nullable=False)
|
| 51 |
+
wkt = Column(String, nullable=False)
|
| 52 |
+
|
| 53 |
+
class ImageTypes(Base):
|
| 54 |
+
__tablename__ = "image_types"
|
| 55 |
+
image_type = Column(String, primary_key=True)
|
| 56 |
+
label = Column(String, nullable=False)
|
| 57 |
+
|
| 58 |
+
class Models(Base):
|
| 59 |
+
__tablename__ = "models"
|
| 60 |
m_code = Column(String, primary_key=True)
|
| 61 |
label = Column(String, nullable=False)
|
| 62 |
|
| 63 |
+
class Images(Base):
|
| 64 |
+
__tablename__ = "images"
|
| 65 |
+
image_id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
| 66 |
file_key = Column(String, nullable=False)
|
| 67 |
sha256 = Column(String, nullable=False)
|
| 68 |
source = Column(String, ForeignKey("sources.s_code"), nullable=False)
|
| 69 |
+
type = Column(String, ForeignKey("types.t_code"), nullable=False)
|
| 70 |
+
epsg = Column(String, ForeignKey("spatial_references.epsg"), nullable=False)
|
| 71 |
created_at = Column(TIMESTAMP(timezone=True), default=datetime.datetime.utcnow)
|
| 72 |
+
image_type = Column(String, ForeignKey("image_types.image_type"), nullable=False)
|
| 73 |
|
| 74 |
+
countries = relationship("Country", secondary=image_countries, backref="images")
|
| 75 |
+
caption = relationship("Captions", uselist=False, back_populates="image")
|
| 76 |
|
| 77 |
+
class Captions(Base):
|
| 78 |
__tablename__ = "captions"
|
| 79 |
cap_id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
| 80 |
+
image_id = Column(UUID(as_uuid=True), ForeignKey("images.image_id", ondelete="CASCADE"), unique=True)
|
| 81 |
+
title = Column(String, nullable=False)
|
| 82 |
+
prompt = Column(String, nullable=False)
|
| 83 |
+
model = Column(String, ForeignKey("models.m_code"), nullable=False)
|
| 84 |
raw_json = Column(JSON, nullable=False)
|
| 85 |
generated = Column(String, nullable=False)
|
| 86 |
edited = Column(String)
|
| 87 |
accuracy = Column(SmallInteger)
|
| 88 |
context = Column(SmallInteger)
|
| 89 |
usability = Column(SmallInteger)
|
| 90 |
+
starred = Column(Boolean, default=False)
|
| 91 |
created_at = Column(TIMESTAMP(timezone=True), default=datetime.datetime.utcnow)
|
| 92 |
updated_at = Column(TIMESTAMP(timezone=True), onupdate=datetime.datetime.utcnow)
|
| 93 |
|
| 94 |
+
image = relationship("Images", back_populates="caption")
|
py_backend/app/routers/caption.py
CHANGED
|
@@ -1,4 +1,4 @@
|
|
| 1 |
-
from fastapi import APIRouter, HTTPException, Depends
|
| 2 |
from sqlalchemy.orm import Session
|
| 3 |
from .. import crud, database, schemas, storage
|
| 4 |
import io
|
|
@@ -22,25 +22,44 @@ class CaptionerStub:
|
|
| 22 |
|
| 23 |
cap = CaptionerStub()
|
| 24 |
|
| 25 |
-
@router.post("/
|
| 26 |
-
def create_caption(
|
| 27 |
-
|
| 28 |
-
|
| 29 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 30 |
|
| 31 |
-
|
| 32 |
-
|
| 33 |
-
|
| 34 |
-
|
| 35 |
-
|
| 36 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 37 |
|
| 38 |
-
|
| 39 |
-
|
| 40 |
|
| 41 |
-
|
| 42 |
-
|
| 43 |
-
|
|
|
|
|
|
|
|
|
|
| 44 |
|
| 45 |
@router.get("/captions/{cap_id}", response_model=schemas.CaptionOut)
|
| 46 |
def get_caption(cap_id: str, db: Session = Depends(get_db)):
|
|
|
|
| 1 |
+
from fastapi import APIRouter, HTTPException, Depends, Form
|
| 2 |
from sqlalchemy.orm import Session
|
| 3 |
from .. import crud, database, schemas, storage
|
| 4 |
import io
|
|
|
|
| 22 |
|
| 23 |
cap = CaptionerStub()
|
| 24 |
|
| 25 |
+
@router.post("/images/{image_id}/caption", response_model=schemas.CaptionOut)
|
| 26 |
+
def create_caption(
|
| 27 |
+
image_id: str,
|
| 28 |
+
title: str = Form(...),
|
| 29 |
+
prompt: str = Form(...),
|
| 30 |
+
db: Session = Depends(get_db)
|
| 31 |
+
):
|
| 32 |
+
img = crud.get_image(db, image_id)
|
| 33 |
+
if not img:
|
| 34 |
+
raise HTTPException(404, "image not found")
|
| 35 |
|
| 36 |
+
try:
|
| 37 |
+
# fetch image bytes from S3
|
| 38 |
+
url = storage.generate_presigned_url(img.file_key)
|
| 39 |
+
|
| 40 |
+
# Try requests first, fallback to httpx
|
| 41 |
+
try:
|
| 42 |
+
import requests
|
| 43 |
+
resp = requests.get(url)
|
| 44 |
+
resp.raise_for_status()
|
| 45 |
+
img_bytes = resp.content
|
| 46 |
+
except ImportError:
|
| 47 |
+
# Fallback to httpx if requests is not available
|
| 48 |
+
import httpx
|
| 49 |
+
with httpx.Client() as client:
|
| 50 |
+
resp = client.get(url)
|
| 51 |
+
resp.raise_for_status()
|
| 52 |
+
img_bytes = resp.content
|
| 53 |
|
| 54 |
+
# generate caption
|
| 55 |
+
text, model, raw = cap.generate(img_bytes)
|
| 56 |
|
| 57 |
+
# insert into DB
|
| 58 |
+
c = crud.create_caption(db, image_id, title, prompt, model, raw, text)
|
| 59 |
+
return c
|
| 60 |
+
|
| 61 |
+
except Exception as e:
|
| 62 |
+
raise HTTPException(500, f"Failed to generate caption: {str(e)}")
|
| 63 |
|
| 64 |
@router.get("/captions/{cap_id}", response_model=schemas.CaptionOut)
|
| 65 |
def get_caption(cap_id: str, db: Session = Depends(get_db)):
|
py_backend/app/routers/metadata.py
CHANGED
|
@@ -1,5 +1,6 @@
|
|
| 1 |
from fastapi import APIRouter, HTTPException, Depends
|
| 2 |
from sqlalchemy.orm import Session
|
|
|
|
| 3 |
from .. import crud, database, schemas
|
| 4 |
|
| 5 |
router = APIRouter()
|
|
@@ -22,3 +23,28 @@ def update_metadata(
|
|
| 22 |
if not c:
|
| 23 |
raise HTTPException(404, "caption not found")
|
| 24 |
return c
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
from fastapi import APIRouter, HTTPException, Depends
|
| 2 |
from sqlalchemy.orm import Session
|
| 3 |
+
from typing import List
|
| 4 |
from .. import crud, database, schemas
|
| 5 |
|
| 6 |
router = APIRouter()
|
|
|
|
| 23 |
if not c:
|
| 24 |
raise HTTPException(404, "caption not found")
|
| 25 |
return c
|
| 26 |
+
|
| 27 |
+
@router.get("/sources", response_model=List[schemas.SourceOut])
|
| 28 |
+
def get_sources(db: Session = Depends(get_db)):
|
| 29 |
+
"""Get all sources for lookup"""
|
| 30 |
+
return crud.get_sources(db)
|
| 31 |
+
|
| 32 |
+
@router.get("/regions", response_model=List[schemas.RegionOut])
|
| 33 |
+
def get_regions(db: Session = Depends(get_db)):
|
| 34 |
+
"""Get all regions for lookup"""
|
| 35 |
+
return crud.get_regions(db)
|
| 36 |
+
|
| 37 |
+
@router.get("/types", response_model=List[schemas.TypeOut])
|
| 38 |
+
def get_types(db: Session = Depends(get_db)):
|
| 39 |
+
"""Get all types for lookup"""
|
| 40 |
+
return crud.get_types(db)
|
| 41 |
+
|
| 42 |
+
@router.get("/spatial-references", response_model=List[schemas.SpatialReferenceOut])
|
| 43 |
+
def get_spatial_references(db: Session = Depends(get_db)):
|
| 44 |
+
"""Get all spatial references for lookup"""
|
| 45 |
+
return crud.get_spatial_references(db)
|
| 46 |
+
|
| 47 |
+
@router.get("/image-types", response_model=List[schemas.ImageTypeOut])
|
| 48 |
+
def get_image_types(db: Session = Depends(get_db)):
|
| 49 |
+
"""Get all image types for lookup"""
|
| 50 |
+
return crud.get_image_types(db)
|
py_backend/app/routers/upload.py
CHANGED
|
@@ -12,12 +12,50 @@ def get_db():
|
|
| 12 |
finally:
|
| 13 |
db.close()
|
| 14 |
|
| 15 |
-
|
| 16 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 17 |
source: str = Form(...),
|
| 18 |
-
|
| 19 |
-
category: str = Form(...),
|
| 20 |
countries: list[str] = Form([]),
|
|
|
|
|
|
|
| 21 |
file: UploadFile = Form(...),
|
| 22 |
db: Session = Depends(get_db)
|
| 23 |
):
|
|
@@ -29,26 +67,25 @@ async def upload_map(
|
|
| 29 |
key = storage.upload_fileobj(io.BytesIO(content), file.filename)
|
| 30 |
|
| 31 |
# 3) persist the DB record
|
| 32 |
-
|
| 33 |
|
| 34 |
# 4) generate a URL for your frontβend
|
| 35 |
#
|
| 36 |
# If you have an S3/MinIO client in storage:
|
| 37 |
try:
|
| 38 |
-
url = storage.
|
| 39 |
except AttributeError:
|
| 40 |
-
# fallback: if you
|
| 41 |
url = f"/uploads/{key}"
|
| 42 |
|
| 43 |
-
# 5) return the
|
| 44 |
-
return schemas.
|
| 45 |
-
|
| 46 |
-
file_key =
|
| 47 |
-
sha256 =
|
| 48 |
-
source =
|
| 49 |
-
|
| 50 |
-
|
| 51 |
-
|
| 52 |
-
created_at = m.created_at,
|
| 53 |
image_url = url,
|
| 54 |
)
|
|
|
|
| 12 |
finally:
|
| 13 |
db.close()
|
| 14 |
|
| 15 |
+
|
| 16 |
+
@router.get("/", response_model=list[schemas.ImageOut])
|
| 17 |
+
def list_images(db: Session = Depends(get_db)):
|
| 18 |
+
images = crud.get_images(db)
|
| 19 |
+
return [
|
| 20 |
+
schemas.ImageOut(
|
| 21 |
+
image_id=img.image_id,
|
| 22 |
+
file_key=img.file_key,
|
| 23 |
+
sha256=img.sha256,
|
| 24 |
+
source=img.source,
|
| 25 |
+
type=img.type,
|
| 26 |
+
epsg=img.epsg,
|
| 27 |
+
image_type=img.image_type,
|
| 28 |
+
caption=img.caption,
|
| 29 |
+
image_url=storage.generate_presigned_url(img.file_key, expires_in=3600)
|
| 30 |
+
) for img in images
|
| 31 |
+
]
|
| 32 |
+
|
| 33 |
+
@router.get("/{image_id}", response_model=schemas.ImageOut)
|
| 34 |
+
def get_image(image_id: str, db: Session = Depends(get_db)):
|
| 35 |
+
img = crud.get_image(db, image_id)
|
| 36 |
+
if not img:
|
| 37 |
+
raise HTTPException(404, "image not found")
|
| 38 |
+
|
| 39 |
+
return schemas.ImageOut(
|
| 40 |
+
image_id=img.image_id,
|
| 41 |
+
file_key=img.file_key,
|
| 42 |
+
sha256=img.sha256,
|
| 43 |
+
source=img.source,
|
| 44 |
+
type=img.type,
|
| 45 |
+
epsg=img.epsg,
|
| 46 |
+
image_type=img.image_type,
|
| 47 |
+
caption=img.caption,
|
| 48 |
+
image_url=storage.generate_presigned_url(img.file_key, expires_in=3600)
|
| 49 |
+
)
|
| 50 |
+
|
| 51 |
+
|
| 52 |
+
@router.post("/", response_model=schemas.ImageOut)
|
| 53 |
+
async def upload_image(
|
| 54 |
source: str = Form(...),
|
| 55 |
+
type: str = Form(...),
|
|
|
|
| 56 |
countries: list[str] = Form([]),
|
| 57 |
+
epsg: str = Form(...),
|
| 58 |
+
image_type: str = Form(...),
|
| 59 |
file: UploadFile = Form(...),
|
| 60 |
db: Session = Depends(get_db)
|
| 61 |
):
|
|
|
|
| 67 |
key = storage.upload_fileobj(io.BytesIO(content), file.filename)
|
| 68 |
|
| 69 |
# 3) persist the DB record
|
| 70 |
+
img = crud.create_image(db, source, type, key, sha, countries, epsg, image_type)
|
| 71 |
|
| 72 |
# 4) generate a URL for your frontβend
|
| 73 |
#
|
| 74 |
# If you have an S3/MinIO client in storage:
|
| 75 |
try:
|
| 76 |
+
url = storage.generate_presigned_url(key, expires_in=3600)
|
| 77 |
except AttributeError:
|
| 78 |
+
# fallback: if you're serving via StaticFiles("/uploads")
|
| 79 |
url = f"/uploads/{key}"
|
| 80 |
|
| 81 |
+
# 5) return the Image plus that URL
|
| 82 |
+
return schemas.ImageOut(
|
| 83 |
+
image_id = img.image_id,
|
| 84 |
+
file_key = img.file_key,
|
| 85 |
+
sha256 = img.sha256,
|
| 86 |
+
source = img.source,
|
| 87 |
+
type = img.type,
|
| 88 |
+
epsg = img.epsg,
|
| 89 |
+
image_type = img.image_type,
|
|
|
|
| 90 |
image_url = url,
|
| 91 |
)
|
py_backend/app/schemas.py
CHANGED
|
@@ -1,22 +1,24 @@
|
|
| 1 |
-
from pydantic import BaseModel
|
| 2 |
from typing import List, Optional
|
| 3 |
from uuid import UUID
|
| 4 |
|
| 5 |
-
|
| 6 |
-
class MapCreate(BaseModel):
|
| 7 |
source: str
|
| 8 |
-
|
| 9 |
-
category: str
|
| 10 |
countries: List[str] = []
|
|
|
|
|
|
|
| 11 |
|
| 12 |
-
class
|
| 13 |
-
|
| 14 |
file_key: str
|
| 15 |
sha256: str
|
| 16 |
source: str
|
| 17 |
-
|
| 18 |
-
|
| 19 |
-
|
|
|
|
|
|
|
| 20 |
|
| 21 |
class Config:
|
| 22 |
orm_mode = True
|
|
@@ -24,20 +26,65 @@ class MapOut(BaseModel):
|
|
| 24 |
#ββ For the caption endpoints ββ
|
| 25 |
class CaptionOut(BaseModel):
|
| 26 |
cap_id: UUID
|
| 27 |
-
|
|
|
|
|
|
|
| 28 |
model: str
|
| 29 |
raw_json: dict
|
| 30 |
generated: str
|
| 31 |
-
edited: Optional[str]
|
| 32 |
-
accuracy: Optional[int]
|
| 33 |
-
context: Optional[int]
|
| 34 |
-
usability: Optional[int]
|
|
|
|
| 35 |
|
| 36 |
class Config:
|
| 37 |
orm_mode = True
|
| 38 |
|
| 39 |
class CaptionUpdate(BaseModel):
|
| 40 |
-
edited: str
|
| 41 |
-
accuracy:
|
| 42 |
-
context:
|
| 43 |
-
usability:
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from pydantic import BaseModel
|
| 2 |
from typing import List, Optional
|
| 3 |
from uuid import UUID
|
| 4 |
|
| 5 |
+
class ImageCreate(BaseModel):
|
|
|
|
| 6 |
source: str
|
| 7 |
+
type: str
|
|
|
|
| 8 |
countries: List[str] = []
|
| 9 |
+
epsg: str
|
| 10 |
+
image_type: str
|
| 11 |
|
| 12 |
+
class ImageOut(BaseModel):
|
| 13 |
+
image_id: UUID
|
| 14 |
file_key: str
|
| 15 |
sha256: str
|
| 16 |
source: str
|
| 17 |
+
type: str
|
| 18 |
+
epsg: str
|
| 19 |
+
image_type: str
|
| 20 |
+
image_url: str
|
| 21 |
+
caption: Optional["CaptionOut"] = None
|
| 22 |
|
| 23 |
class Config:
|
| 24 |
orm_mode = True
|
|
|
|
| 26 |
#ββ For the caption endpoints ββ
|
| 27 |
class CaptionOut(BaseModel):
|
| 28 |
cap_id: UUID
|
| 29 |
+
image_id: UUID
|
| 30 |
+
title: str
|
| 31 |
+
prompt: str
|
| 32 |
model: str
|
| 33 |
raw_json: dict
|
| 34 |
generated: str
|
| 35 |
+
edited: Optional[str] = None
|
| 36 |
+
accuracy: Optional[int] = None
|
| 37 |
+
context: Optional[int] = None
|
| 38 |
+
usability: Optional[int] = None
|
| 39 |
+
starred: bool = False
|
| 40 |
|
| 41 |
class Config:
|
| 42 |
orm_mode = True
|
| 43 |
|
| 44 |
class CaptionUpdate(BaseModel):
|
| 45 |
+
edited: Optional[str] = None
|
| 46 |
+
accuracy: Optional[int] = None
|
| 47 |
+
context: Optional[int] = None
|
| 48 |
+
usability: Optional[int] = None
|
| 49 |
+
starred: Optional[bool] = None
|
| 50 |
+
|
| 51 |
+
#ββ For lookup data ββ
|
| 52 |
+
class SourceOut(BaseModel):
|
| 53 |
+
s_code: str
|
| 54 |
+
label: str
|
| 55 |
+
|
| 56 |
+
class Config:
|
| 57 |
+
orm_mode = True
|
| 58 |
+
|
| 59 |
+
class RegionOut(BaseModel):
|
| 60 |
+
r_code: str
|
| 61 |
+
label: str
|
| 62 |
+
|
| 63 |
+
class Config:
|
| 64 |
+
orm_mode = True
|
| 65 |
+
|
| 66 |
+
class TypeOut(BaseModel):
|
| 67 |
+
t_code: str
|
| 68 |
+
label: str
|
| 69 |
+
|
| 70 |
+
class Config:
|
| 71 |
+
orm_mode = True
|
| 72 |
+
|
| 73 |
+
class SpatialReferenceOut(BaseModel):
|
| 74 |
+
epsg: str
|
| 75 |
+
srid: str
|
| 76 |
+
proj4: str
|
| 77 |
+
wkt: str
|
| 78 |
+
|
| 79 |
+
class Config:
|
| 80 |
+
orm_mode = True
|
| 81 |
+
|
| 82 |
+
class ImageTypeOut(BaseModel):
|
| 83 |
+
image_type: str
|
| 84 |
+
label: str
|
| 85 |
+
|
| 86 |
+
class Config:
|
| 87 |
+
orm_mode = True
|
| 88 |
+
|
| 89 |
+
# Update forward references
|
| 90 |
+
ImageOut.update_forward_refs()
|
py_backend/requirements.txt
CHANGED
|
@@ -5,7 +5,11 @@ alembic
|
|
| 5 |
psycopg2-binary
|
| 6 |
boto3
|
| 7 |
python-dotenv
|
| 8 |
-
pydantic
|
|
|
|
| 9 |
openai # or other VLM client
|
| 10 |
pytest
|
| 11 |
-
httpx
|
|
|
|
|
|
|
|
|
|
|
|
| 5 |
psycopg2-binary
|
| 6 |
boto3
|
| 7 |
python-dotenv
|
| 8 |
+
pydantic>=2.7.0
|
| 9 |
+
pydantic-settings>=0.2
|
| 10 |
openai # or other VLM client
|
| 11 |
pytest
|
| 12 |
+
httpx
|
| 13 |
+
requests
|
| 14 |
+
pycountry>=22.3.5
|
| 15 |
+
pycountry-convert>=0.7.2
|