SCGR commited on
Commit
23d1df7
·
1 Parent(s): 352da4f

upload flow

Browse files
docker-compose.yml CHANGED
@@ -25,15 +25,15 @@ services:
25
  - postgres
26
 
27
  minio:
28
- image: minio/minio
29
  restart: always
30
- command: server /data
31
  environment:
32
  MINIO_ROOT_USER: promptaid
33
  MINIO_ROOT_PASSWORD: promptaid
34
  ports:
35
- - "9000:9000"
36
- - "9001:9001"
37
  volumes:
38
  - minio_data:/data
39
  depends_on:
 
25
  - postgres
26
 
27
  minio:
28
+ image: minio/minio:latest
29
  restart: always
30
+ command: server /data --console-address ":9001"
31
  environment:
32
  MINIO_ROOT_USER: promptaid
33
  MINIO_ROOT_PASSWORD: promptaid
34
  ports:
35
+ - "9000:9000" # S3 API
36
+ - "9001:9001" # web console
37
  volumes:
38
  - minio_data:/data
39
  depends_on:
frontend/src/components/HeaderNav.tsx CHANGED
@@ -1,4 +1,4 @@
1
- import { NavLink } from "react-router-dom";
2
  import {
3
  UploadCloudLineIcon,
4
  AnalysisIcon,
@@ -20,12 +20,26 @@ const navItems = [
20
  ];
21
 
22
  export default function HeaderNav() {
 
 
 
 
 
 
 
 
 
 
 
 
 
 
23
  return (
24
  <header className="bg-white border-b border-ifrcRed/40">
25
  <div className="flex items-center justify-between px-6 py-3">
26
 
27
  {/* ── Logo + title ─────────────────────────── */}
28
- <NavLink to="/" className="flex items-center gap-2">
29
  <img src="/ifrc-logo.svg" alt="IFRC logo" className="h-6" />
30
  <span className="font-semibold">PromptAid Vision</span>
31
  </NavLink>
@@ -33,7 +47,7 @@ export default function HeaderNav() {
33
  {/* ── Centre nav links ─────────────────────── */}
34
  <nav className="flex gap-6">
35
  {navItems.map(({ to, label, Icon }) => (
36
- <NavLink key={to} to={to} className={navLink}>
37
  <Icon className="w-4 h-4" /> {label}
38
  </NavLink>
39
  ))}
 
1
+ import { NavLink, useLocation } from "react-router-dom";
2
  import {
3
  UploadCloudLineIcon,
4
  AnalysisIcon,
 
20
  ];
21
 
22
  export default function HeaderNav() {
23
+ const location = useLocation();
24
+
25
+ const handleNavigation = (e: React.MouseEvent, to: string) => {
26
+ if (location.pathname === "/upload") {
27
+ const uploadPage = document.querySelector('[data-step="2"]');
28
+ if (uploadPage) {
29
+ e.preventDefault();
30
+ if (confirm("Changes will not be saved")) {
31
+ window.location.href = to;
32
+ }
33
+ }
34
+ }
35
+ };
36
+
37
  return (
38
  <header className="bg-white border-b border-ifrcRed/40">
39
  <div className="flex items-center justify-between px-6 py-3">
40
 
41
  {/* ── Logo + title ─────────────────────────── */}
42
+ <NavLink to="/" className="flex items-center gap-2" onClick={(e) => handleNavigation(e, "/")}>
43
  <img src="/ifrc-logo.svg" alt="IFRC logo" className="h-6" />
44
  <span className="font-semibold">PromptAid Vision</span>
45
  </NavLink>
 
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
  ))}
frontend/src/pages/UploadPage.tsx CHANGED
@@ -9,10 +9,11 @@ import {
9
  UploadCloudLineIcon,
10
  ArrowRightLineIcon,
11
  } from '@ifrc-go/icons';
12
- import { Link } from 'react-router-dom';
13
 
14
  export default function UploadPage() {
15
- const [step, setStep] = useState<1 | 2>(1);
 
16
  const [preview, setPreview] = useState<string | null>(null);
17
  /* ---------------- local state ----------------- */
18
 
@@ -35,7 +36,36 @@ export default function UploadPage() {
35
  const handleCategoryChange = (value: any) => setCategory(String(value));
36
  const handleCountriesChange = (value: any) => setCountries(Array.isArray(value) ? value.map(String) : []);
37
 
38
- const [draft, setDraft] = useState('');
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
39
  const [scores, setScores] = useState({
40
  accuracy: 50,
41
  context: 50,
@@ -90,6 +120,7 @@ export default function UploadPage() {
90
  const mapRes = await fetch('/api/maps/', { method: 'POST', body: fd });
91
  const mapJson = await readJsonSafely(mapRes);
92
  if (!mapRes.ok) throw new Error(mapJson.error || 'Upload failed');
 
93
 
94
  const mapIdVal = mapJson.map_id;
95
  if (!mapIdVal) throw new Error('Upload failed: map_id not found');
@@ -97,12 +128,14 @@ export default function UploadPage() {
97
 
98
  /* 2) caption */
99
  const capRes = await fetch(
100
- `/api/maps/${mapIdVal}/caption/`,
101
  { method: 'POST' },
102
  );
103
  const capJson = await readJsonSafely(capRes);
104
  if (!capRes.ok) throw new Error(capJson.error || 'Caption failed');
105
-
 
 
106
  /* 3) continue workflow */
107
  setDraft(capJson.generated);
108
  setStep(2);
@@ -114,7 +147,7 @@ export default function UploadPage() {
114
  /* ------------------------------------------------------------------- */
115
  return (
116
  <PageContainer>
117
- <div className="mx-auto max-w-3xl text-center px-4 py-10">
118
  {/* Title & intro copy */}
119
  {step === 1 && <>
120
  <Heading level={2}>Upload Your Crisis Map</Heading>
@@ -134,16 +167,7 @@ export default function UploadPage() {
134
  </div>
135
  </>}
136
 
137
- {/* Show uploaded image in step 2 */}
138
- {step === 2 && preview &&(
139
- <div className="mt-6 flex justify-center">
140
- <img
141
- src={preview}
142
- alt="Uploaded map preview"
143
- className="max-h-80 rounded shadow"
144
- />
145
- </div>
146
- )}
147
 
148
  {/* Drop-zone */}
149
  {step === 1 && (
@@ -186,6 +210,17 @@ export default function UploadPage() {
186
  </Button>
187
  )}
188
 
 
 
 
 
 
 
 
 
 
 
 
189
 
190
  {step === 2 && (
191
  <div className="space-y-10">
@@ -286,17 +321,46 @@ export default function UploadPage() {
286
  <Button
287
  name="submit"
288
  className="mt-10"
289
- onClick={() =>
290
- alert('Stub saved wire PUT /api/captions/:id later')
291
- }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
292
  >
293
  Submit
294
  </Button>
295
- </div>
296
- )}
297
-
298
-
299
- </div>
300
- </PageContainer>
301
- );
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
302
  }
 
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
 
 
36
  const handleCategoryChange = (value: any) => setCategory(String(value));
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) {
48
+ if (confirm("Changes will not be saved")) {
49
+ setStep(1);
50
+ setFile(null);
51
+ setPreview(null);
52
+ setImageUrl(null);
53
+ setCaptionId(null);
54
+ setDraft('');
55
+ setScores({ accuracy: 50, context: 50, usability: 50 });
56
+ }
57
+ }
58
+ };
59
+
60
+ const resetToStep1 = () => {
61
+ setStep(1);
62
+ setFile(null);
63
+ setPreview(null);
64
+ setImageUrl(null);
65
+ setCaptionId(null);
66
+ setDraft('');
67
+ setScores({ accuracy: 50, context: 50, usability: 50 });
68
+ };
69
  const [scores, setScores] = useState({
70
  accuracy: 50,
71
  context: 50,
 
120
  const mapRes = await fetch('/api/maps/', { method: 'POST', body: fd });
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.map_id;
126
  if (!mapIdVal) throw new Error('Upload failed: map_id not found');
 
128
 
129
  /* 2) caption */
130
  const capRes = await fetch(
131
+ `/api/maps/${mapIdVal}/caption`,
132
  { method: 'POST' },
133
  );
134
  const capJson = await readJsonSafely(capRes);
135
  if (!capRes.ok) throw new Error(capJson.error || 'Caption failed');
136
+ setCaptionId(capJson.cap_id);
137
+ console.log(capJson);
138
+
139
  /* 3) continue workflow */
140
  setDraft(capJson.generated);
141
  setStep(2);
 
147
  /* ------------------------------------------------------------------- */
148
  return (
149
  <PageContainer>
150
+ <div className="mx-auto max-w-3xl text-center px-4 py-10" data-step={step}>
151
  {/* Title & intro copy */}
152
  {step === 1 && <>
153
  <Heading level={2}>Upload Your Crisis Map</Heading>
 
167
  </div>
168
  </>}
169
 
170
+
 
 
 
 
 
 
 
 
 
171
 
172
  {/* Drop-zone */}
173
  {step === 1 && (
 
210
  </Button>
211
  )}
212
 
213
+ {step === 2 && imageUrl && (
214
+ <div className="mt-6 flex justify-center">
215
+ <div className="w-full max-w-3xl max-h-80 overflow-hidden bg-red-50">
216
+ <img
217
+ src={preview || undefined}
218
+ alt="Uploaded map preview"
219
+ className="w-full h-full object-contain rounded shadow"
220
+ />
221
+ </div>
222
+ </div>
223
+ )}
224
 
225
  {step === 2 && (
226
  <div className="space-y-10">
 
321
  <Button
322
  name="submit"
323
  className="mt-10"
324
+ onClick={async () => {
325
+ if (!captionId) return alert("No caption to submit");
326
+ const body = {
327
+ edited: draft,
328
+ accuracy: scores.accuracy,
329
+ context: scores.context,
330
+ usability: scores.usability,
331
+ };
332
+ const res = await fetch(`/api/captions/${captionId}`, {
333
+ method: "PUT",
334
+ headers: { "Content-Type": "application/json" },
335
+ body: JSON.stringify(body),
336
+ });
337
+ const json = await readJsonSafely(res);
338
+ if (!res.ok) return alert(json.error || "Save failed");
339
+ setStep(3);
340
+ }}
341
  >
342
  Submit
343
  </Button>
344
+ </div>
345
+ )}
346
+
347
+ {/* Success page */}
348
+ {step === 3 && (
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
+ <Button
353
+ name="upload-another"
354
+ onClick={resetToStep1}
355
+ className="mt-6"
356
+ >
357
+ Upload Another
358
+ </Button>
359
+ </div>
360
+ )}
361
+
362
+
363
+ </div>
364
+ </PageContainer>
365
+ );
366
  }
py_backend/app/database.py CHANGED
@@ -1,17 +1,35 @@
 
 
 
 
 
1
  from sqlalchemy import create_engine
2
- from sqlalchemy.ext.declarative import declarative_base
3
- from sqlalchemy.orm import sessionmaker
4
  from .config import settings
5
 
6
- # Create the SQLAlchemy engine
7
- engine = create_engine(settings.DATABASE_URL, echo=True)
 
 
 
 
 
 
 
 
 
 
 
 
8
 
9
- # Each instance of SessionLocal is a database session
10
  SessionLocal = sessionmaker(
11
  autocommit=False,
12
  autoflush=False,
13
- bind=engine
 
14
  )
15
 
16
- # Base class for ORM models
17
- Base = declarative_base()
 
1
+ # app/database.py
2
+
3
+ import os
4
+ import logging
5
+
6
  from sqlalchemy import create_engine
7
+ from sqlalchemy.orm import sessionmaker, declarative_base
8
+
9
  from .config import settings
10
 
11
+ # 1) Grab the raw DATABASE_URL (e.g. "postgresql://...:5433/promptaid?schema=public")
12
+ raw_db_url = settings.DATABASE_URL
13
+ logging.getLogger().warning(f"▶️ Raw DATABASE_URL = {raw_db_url!r}")
14
+
15
+ # 2) Strip off any query string (everything from the first "?" onward)
16
+ clean_db_url = raw_db_url.split("?", 1)[0]
17
+ logging.getLogger().warning(f"▶️ Using clean URL = {clean_db_url!r}")
18
+
19
+ # 3) Create the SQLAlchemy engine on that clean URL
20
+ engine = create_engine(
21
+ clean_db_url,
22
+ echo=True, # log all SQL to the console
23
+ future=True # recommended for SQLAlchemy 2.0 style
24
+ )
25
 
26
+ # 4) Configure a session factory
27
  SessionLocal = sessionmaker(
28
  autocommit=False,
29
  autoflush=False,
30
+ bind=engine,
31
+ future=True
32
  )
33
 
34
+ # 5) Base class for all ORM models
35
+ Base = declarative_base()
py_backend/app/routers/caption.py CHANGED
@@ -48,3 +48,10 @@ def get_caption(cap_id: str, db: Session = Depends(get_db)):
48
  if not c:
49
  raise HTTPException(404, "caption not found")
50
  return c
 
 
 
 
 
 
 
 
48
  if not c:
49
  raise HTTPException(404, "caption not found")
50
  return c
51
+
52
+ @router.put("/captions/{cap_id}", response_model=schemas.CaptionOut)
53
+ def update_caption(cap_id: str, update: schemas.CaptionUpdate, db: Session = Depends(get_db)):
54
+ c = crud.update_caption(db, cap_id, **update.dict())
55
+ if not c:
56
+ raise HTTPException(404, "caption not found")
57
+ return c
py_backend/app/routers/upload.py CHANGED
@@ -14,20 +14,41 @@ def get_db():
14
 
15
  @router.post("/", response_model=schemas.MapOut)
16
  async def upload_map(
17
- source: str = Form(...),
18
- region: str = Form(...),
19
- category: str = Form(...),
20
  countries: list[str] = Form([]),
21
- file: UploadFile = Form(...),
22
- db: Session = Depends(get_db)
23
  ):
24
  # 1) read & hash
25
  content = await file.read()
26
- sha = crud.hash_bytes(content)
27
 
28
- # 2) upload to S3
29
  key = storage.upload_fileobj(io.BytesIO(content), file.filename)
30
 
31
- # 3) insert into DB
32
  m = crud.create_map(db, source, region, category, key, sha, countries)
33
- return m
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
14
 
15
  @router.post("/", response_model=schemas.MapOut)
16
  async def upload_map(
17
+ source: str = Form(...),
18
+ region: str = Form(...),
19
+ category: str = Form(...),
20
  countries: list[str] = Form([]),
21
+ file: UploadFile = Form(...),
22
+ db: Session = Depends(get_db)
23
  ):
24
  # 1) read & hash
25
  content = await file.read()
26
+ sha = crud.hash_bytes(content)
27
 
28
+ # 2) upload to S3/MinIO (or local FS)
29
  key = storage.upload_fileobj(io.BytesIO(content), file.filename)
30
 
31
+ # 3) persist the DB record
32
  m = crud.create_map(db, source, region, category, key, sha, countries)
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.get_presigned_url(key, expires_in=3600)
39
+ except AttributeError:
40
+ # fallback: if you’re serving via StaticFiles("/uploads")
41
+ url = f"/uploads/{key}"
42
+
43
+ # 5) return the Map plus that URL
44
+ return schemas.MapOut(
45
+ map_id = m.map_id,
46
+ file_key = m.file_key,
47
+ sha256 = m.sha256,
48
+ source = m.source,
49
+ region = m.region,
50
+ category = m.category,
51
+ countries = [c.c_code for c in m.countries], # or however you model it
52
+ created_at = m.created_at,
53
+ image_url = url,
54
+ )
py_backend/app/schemas.py CHANGED
@@ -1,4 +1,4 @@
1
- from pydantic import BaseModel, Field
2
  from typing import List, Optional
3
  from uuid import UUID
4
 
@@ -16,6 +16,7 @@ class MapOut(BaseModel):
16
  source: str
17
  region: str
18
  category: str
 
19
 
20
  class Config:
21
  orm_mode = True
@@ -37,6 +38,6 @@ class CaptionOut(BaseModel):
37
 
38
  class CaptionUpdate(BaseModel):
39
  edited: str
40
- accuracy: int = Field(..., ge=0, le=100)
41
- context: int = Field(..., ge=0, le=100)
42
- usability:int = Field(..., ge=0, le=100)
 
1
+ from pydantic import BaseModel, Field, conint
2
  from typing import List, Optional
3
  from uuid import UUID
4
 
 
16
  source: str
17
  region: str
18
  category: str
19
+ image_url: str
20
 
21
  class Config:
22
  orm_mode = True
 
38
 
39
  class CaptionUpdate(BaseModel):
40
  edited: str
41
+ accuracy: conint(ge=0, le=100) = None
42
+ context: conint(ge=0, le=100) = None
43
+ usability: conint(ge=0, le=100) = None
py_backend/requirements.txt CHANGED
@@ -6,4 +6,5 @@ psycopg2-binary
6
  boto3
7
  python-dotenv
8
  pydantic<2.0.0
9
- openai # or other VLM client
 
 
6
  boto3
7
  python-dotenv
8
  pydantic<2.0.0
9
+ openai # or other VLM client
10
+ pytest