SCGR commited on
Commit
f503159
·
1 Parent(s): a43ba27

database images table transformation

Browse files
frontend/src/pages/AnalyticsPage/AnalyticsPage.tsx CHANGED
@@ -210,7 +210,7 @@ export default function AnalyticsPage() {
210
  if (map.context != null) ctr.avgContext += map.context;
211
  if (map.usability != null) ctr.avgUsability += map.usability;
212
 
213
- // Calculate edit time if both timestamps exist
214
  if (map.created_at && map.updated_at) {
215
  const created = new Date(map.created_at).getTime();
216
  const updated = new Date(map.updated_at).getTime();
 
210
  if (map.context != null) ctr.avgContext += map.context;
211
  if (map.usability != null) ctr.avgUsability += map.usability;
212
 
213
+ // Calculate edit time if both timestamps exist (now from captions)
214
  if (map.created_at && map.updated_at) {
215
  const created = new Date(map.created_at).getTime();
216
  const updated = new Date(map.updated_at).getTime();
frontend/src/pages/ExplorePage/ExplorePage.tsx CHANGED
@@ -62,7 +62,7 @@ export default function ExplorePage() {
62
 
63
  const fetchCaptions = () => {
64
  setIsLoadingContent(true);
65
- fetch('/api/captions')
66
  .then(r => {
67
  if (!r.ok) {
68
  throw new Error(`HTTP ${r.status}: ${r.statusText}`);
 
62
 
63
  const fetchCaptions = () => {
64
  setIsLoadingContent(true);
65
+ fetch('/api/captions/legacy')
66
  .then(r => {
67
  if (!r.ok) {
68
  throw new Error(`HTTP ${r.status}: ${r.statusText}`);
py_backend/alembic/versions/0016_restructure_images_to_three_tables.py ADDED
@@ -0,0 +1,206 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Restructure images table into three tables: Images, Captions, and Images_captions
2
+
3
+ Revision ID: 0016
4
+ Revises: 0015
5
+ Create Date: 2024-01-01 00:00:00.000000
6
+
7
+ """
8
+ from alembic import op
9
+ import sqlalchemy as sa
10
+ from sqlalchemy.dialects import postgresql
11
+
12
+ # revision identifiers, used by Alembic.
13
+ revision = '0016'
14
+ down_revision = '0015'
15
+ branch_labels = None
16
+ depends_on = None
17
+
18
+ def upgrade():
19
+ # Ensure pgcrypto extension is available
20
+ op.execute('CREATE EXTENSION IF NOT EXISTS pgcrypto;')
21
+
22
+ # First, ensure the old images table has all the caption columns
23
+ # (in case they were removed by a previous partial migration)
24
+ connection = op.get_bind()
25
+
26
+ # Check if caption columns exist and add them if they don't
27
+ caption_columns = [
28
+ ('title', 'VARCHAR'),
29
+ ('prompt', 'VARCHAR'),
30
+ ('model', 'VARCHAR'),
31
+ ('schema_id', 'VARCHAR'),
32
+ ('raw_json', 'JSONB'),
33
+ ('generated', 'TEXT'),
34
+ ('edited', 'TEXT'),
35
+ ('accuracy', 'SMALLINT'),
36
+ ('context', 'SMALLINT'),
37
+ ('usability', 'SMALLINT'),
38
+ ('starred', 'BOOLEAN'),
39
+ ('updated_at', 'TIMESTAMP WITH TIME ZONE'),
40
+ ('created_at', 'TIMESTAMP WITH TIME ZONE')
41
+ ]
42
+
43
+ for col_name, col_type in caption_columns:
44
+ result = connection.execute(sa.text(f"""
45
+ SELECT COUNT(*) FROM information_schema.columns
46
+ WHERE table_name = 'images' AND column_name = '{col_name}'
47
+ """))
48
+ if result.scalar() == 0:
49
+ if col_type == 'BOOLEAN':
50
+ op.add_column('images', sa.Column(col_name, sa.Boolean(), nullable=True, server_default='false'))
51
+ elif col_type == 'TIMESTAMP WITH TIME ZONE':
52
+ op.add_column('images', sa.Column(col_name, sa.TIMESTAMP(timezone=True), nullable=True))
53
+ else:
54
+ op.add_column('images', sa.Column(col_name, sa.String() if col_type == 'VARCHAR' else sa.Text() if col_type == 'TEXT' else sa.SmallInteger(), nullable=True))
55
+
56
+ # Create new Images table with temporary caption_id column for mapping
57
+ op.create_table(
58
+ 'images_new',
59
+ sa.Column('image_id', postgresql.UUID(as_uuid=True),
60
+ server_default=sa.text('gen_random_uuid()'),
61
+ primary_key=True),
62
+ sa.Column('file_key', sa.String(), nullable=False),
63
+ sa.Column('sha256', sa.String(), nullable=False),
64
+ sa.Column('source', sa.String(), sa.ForeignKey('sources.s_code'), nullable=True),
65
+ sa.Column('event_type', sa.String(), sa.ForeignKey('event_types.t_code'), nullable=False),
66
+ sa.Column('epsg', sa.String(), sa.ForeignKey('spatial_references.epsg'), nullable=False),
67
+ sa.Column('image_type', sa.String(), sa.ForeignKey('image_types.image_type'), nullable=False),
68
+ sa.Column('captured_at', sa.TIMESTAMP(timezone=True), nullable=True),
69
+ sa.Column('center_lon', sa.Float(precision=53), nullable=True),
70
+ sa.Column('center_lat', sa.Float(precision=53), nullable=True),
71
+ sa.Column('amsl_m', sa.Float(precision=53), nullable=True),
72
+ sa.Column('agl_m', sa.Float(precision=53), nullable=True),
73
+ sa.Column('heading_deg', sa.Float(precision=53), nullable=True),
74
+ sa.Column('yaw_deg', sa.Float(precision=53), nullable=True),
75
+ sa.Column('pitch_deg', sa.Float(precision=53), nullable=True),
76
+ sa.Column('roll_deg', sa.Float(precision=53), nullable=True),
77
+ sa.Column('rtk_fix', sa.Boolean(), nullable=True),
78
+ sa.Column('std_h_m', sa.Float(precision=53), nullable=True),
79
+ sa.Column('std_v_m', sa.Float(precision=53), nullable=True),
80
+ sa.Column('temp_caption_id', postgresql.UUID(as_uuid=True), nullable=True),
81
+
82
+ sa.CheckConstraint('center_lat IS NULL OR (center_lat BETWEEN -90 AND 90)', name='chk_images_new_center_lat'),
83
+ sa.CheckConstraint('center_lon IS NULL OR (center_lon BETWEEN -180 AND 180)', name='chk_images_new_center_lon'),
84
+ sa.CheckConstraint('heading_deg IS NULL OR (heading_deg >= 0 AND heading_deg <= 360)', name='chk_images_new_heading_deg'),
85
+ sa.CheckConstraint('pitch_deg IS NULL OR (pitch_deg BETWEEN -90 AND 90)', name='chk_images_new_pitch_deg'),
86
+ sa.CheckConstraint('yaw_deg IS NULL OR (yaw_deg BETWEEN -180 AND 180)', name='chk_images_new_yaw_deg'),
87
+ sa.CheckConstraint('roll_deg IS NULL OR (roll_deg BETWEEN -180 AND 180)', name='chk_images_new_roll_deg'),
88
+ )
89
+
90
+ # Create new Captions table
91
+ op.create_table(
92
+ 'captions',
93
+ sa.Column('caption_id', postgresql.UUID(as_uuid=True),
94
+ server_default=sa.text('gen_random_uuid()'),
95
+ primary_key=True),
96
+ sa.Column('title', sa.String(), nullable=True),
97
+ sa.Column('prompt', sa.String(), sa.ForeignKey('prompts.p_code'), nullable=True),
98
+ sa.Column('model', sa.String(), sa.ForeignKey('models.m_code'), nullable=True),
99
+ sa.Column('schema_id', sa.String(), sa.ForeignKey('json_schemas.schema_id'), nullable=True),
100
+ sa.Column('raw_json', postgresql.JSONB(astext_type=sa.Text()), nullable=True),
101
+ sa.Column('generated', sa.Text(), nullable=True),
102
+ sa.Column('edited', sa.Text(), nullable=True),
103
+ sa.Column('accuracy', sa.SmallInteger()),
104
+ sa.Column('context', sa.SmallInteger()),
105
+ sa.Column('usability', sa.SmallInteger()),
106
+ sa.Column('starred', sa.Boolean(), server_default=sa.text('false')),
107
+ sa.Column('created_at', sa.TIMESTAMP(timezone=True), server_default=sa.text('NOW()'), nullable=False),
108
+ sa.Column('updated_at', sa.TIMESTAMP(timezone=True), nullable=True),
109
+
110
+ sa.CheckConstraint('accuracy IS NULL OR (accuracy BETWEEN 0 AND 100)', name='chk_captions_accuracy'),
111
+ sa.CheckConstraint('context IS NULL OR (context BETWEEN 0 AND 100)', name='chk_captions_context'),
112
+ sa.CheckConstraint('usability IS NULL OR (usability BETWEEN 0 AND 100)', name='chk_captions_usability')
113
+ )
114
+
115
+ # Create Images_captions junction table
116
+ op.create_table(
117
+ 'images_captions',
118
+ sa.Column('image_id', postgresql.UUID(as_uuid=True), nullable=False),
119
+ sa.Column('caption_id', postgresql.UUID(as_uuid=True), nullable=False),
120
+ sa.PrimaryKeyConstraint('image_id', 'caption_id', name='pk_images_captions'),
121
+ sa.ForeignKeyConstraint(['image_id'], ['images_new.image_id'], ondelete='CASCADE'),
122
+ sa.ForeignKeyConstraint(['caption_id'], ['captions.caption_id'], ondelete='CASCADE')
123
+ )
124
+
125
+ # Add indexes for performance
126
+ op.create_index('ix_images_captions_image_id', 'images_captions', ['image_id'])
127
+ op.create_index('ix_images_captions_caption_id', 'images_captions', ['caption_id'])
128
+
129
+ # Migrate data from old images table to new structure with temporary caption_id
130
+ op.execute("""
131
+ INSERT INTO images_new (
132
+ image_id, file_key, sha256, source, event_type, epsg, image_type, captured_at,
133
+ center_lon, center_lat, amsl_m, agl_m, heading_deg, yaw_deg, pitch_deg, roll_deg, rtk_fix, std_h_m, std_v_m,
134
+ temp_caption_id
135
+ )
136
+ SELECT
137
+ image_id, file_key, sha256, source, event_type, epsg, image_type, captured_at,
138
+ center_lon, center_lat, amsl_m, agl_m, heading_deg, yaw_deg, pitch_deg, roll_deg, rtk_fix, std_h_m, std_v_m,
139
+ CASE
140
+ WHEN title IS NOT NULL OR prompt IS NOT NULL OR model IS NOT NULL OR schema_id IS NOT NULL
141
+ OR raw_json IS NOT NULL OR generated IS NOT NULL OR edited IS NOT NULL
142
+ OR accuracy IS NOT NULL OR context IS NOT NULL OR usability IS NOT NULL
143
+ OR starred = true OR updated_at IS NOT NULL
144
+ THEN gen_random_uuid()
145
+ ELSE NULL
146
+ END as temp_caption_id
147
+ FROM images
148
+ """)
149
+
150
+ # Migrate caption data using the same temp_caption_id
151
+ op.execute("""
152
+ INSERT INTO captions (
153
+ caption_id, title, prompt, model, schema_id, raw_json, generated, edited,
154
+ accuracy, context, usability, starred, created_at, updated_at
155
+ )
156
+ SELECT
157
+ i_new.temp_caption_id, i.title, i.prompt, i.model, i.schema_id, i.raw_json, i.generated, i.edited,
158
+ i.accuracy, i.context, i.usability, i.starred, i.created_at, i.updated_at
159
+ FROM images i
160
+ JOIN images_new i_new ON i.image_id = i_new.image_id
161
+ WHERE i_new.temp_caption_id IS NOT NULL
162
+ """)
163
+
164
+ # Create relationships using the temp_caption_id
165
+ op.execute("""
166
+ INSERT INTO images_captions (image_id, caption_id)
167
+ SELECT image_id, temp_caption_id
168
+ FROM images_new
169
+ WHERE temp_caption_id IS NOT NULL
170
+ """)
171
+
172
+ # Drop the temporary column
173
+ op.drop_column('images_new', 'temp_caption_id')
174
+
175
+ # Save image_countries data to temp table before dropping
176
+ op.execute("CREATE TEMP TABLE tmp_image_countries AS TABLE image_countries")
177
+
178
+ # Drop old tables and constraints
179
+ op.drop_table('image_countries')
180
+ op.drop_index('ix_images_created_at', table_name='images', if_exists=True)
181
+ op.drop_table('images')
182
+
183
+ # Rename new table to 'images'
184
+ op.rename_table('images_new', 'images')
185
+
186
+ # Recreate image_countries table with new image_id reference
187
+ op.create_table(
188
+ 'image_countries',
189
+ sa.Column('image_id', postgresql.UUID(as_uuid=True), nullable=False),
190
+ sa.Column('c_code', sa.CHAR(length=2), nullable=False),
191
+ sa.PrimaryKeyConstraint('image_id', 'c_code', name='pk_image_countries'),
192
+ sa.ForeignKeyConstraint(['image_id'], ['images.image_id'], ondelete='CASCADE'),
193
+ sa.ForeignKeyConstraint(['c_code'], ['countries.c_code'])
194
+ )
195
+
196
+ # Restore image_countries data
197
+ op.execute("""
198
+ INSERT INTO image_countries (image_id, c_code)
199
+ SELECT image_id, c_code FROM tmp_image_countries
200
+ """)
201
+
202
+ # Recreate index with proper name
203
+ op.create_index('ix_images_captured_at', 'images', ['captured_at'])
204
+
205
+ def downgrade():
206
+ raise NotImplementedError("Downgrade not supported for 0016 - this is a structural table split")
py_backend/app/crud.py CHANGED
@@ -1,5 +1,5 @@
1
  import io, hashlib
2
- from typing import Optional
3
  from sqlalchemy.orm import Session, joinedload
4
  from . import models, schemas
5
  from fastapi import HTTPException
@@ -62,21 +62,23 @@ def create_image(db: Session, src, type_code, key, sha, countries: list[str], ep
62
  return img
63
 
64
  def get_images(db: Session):
65
- """Get all images with their countries"""
66
  return (
67
  db.query(models.Images)
68
  .options(
69
  joinedload(models.Images.countries),
 
70
  )
71
  .all()
72
  )
73
 
74
  def get_image(db: Session, image_id: str):
75
- """Get a single image by ID with its countries"""
76
  return (
77
  db.query(models.Images)
78
  .options(
79
  joinedload(models.Images.countries),
 
80
  )
81
  .filter(models.Images.image_id == image_id)
82
  .first()
@@ -100,35 +102,49 @@ def create_caption(db: Session, image_id, title, prompt, model_code, raw_json, t
100
  if img.image_type == "drone_image":
101
  schema_id = "drone_caption@1.0.0"
102
 
103
- img.title = title
104
- img.prompt = prompt
105
- img.model = model_code
106
- img.schema_id = schema_id
107
- img.raw_json = raw_json
108
- img.generated = text
109
- img.edited = text
 
 
 
 
 
 
 
 
110
 
111
  print(f"About to commit caption to database...")
112
  db.commit()
113
  print(f"Caption commit successful!")
114
- db.refresh(img)
115
  print(f"Caption created successfully for image: {img.image_id}")
116
- return img
117
 
118
- def get_caption(db: Session, image_id: str):
119
- """Get caption data for a specific image"""
120
- return db.get(models.Images, image_id)
121
 
122
  def get_captions_by_image(db: Session, image_id: str):
123
- """Get caption data for a specific image (now just returns the image)"""
124
  img = db.get(models.Images, image_id)
125
- if img and img.title:
126
- return [img]
127
  return []
128
 
129
  def get_all_captions_with_images(db: Session):
130
- """Get all images that have caption data"""
131
- return db.query(models.Images).filter(models.Images.title.isnot(None)).all()
 
 
 
 
 
 
132
 
133
  def get_prompts(db: Session):
134
  """Get all available prompts"""
@@ -253,37 +269,26 @@ def update_prompt(db: Session, p_code: str, prompt_update: schemas.PromptUpdate)
253
  db.refresh(prompt)
254
  return prompt
255
 
256
- def update_caption(db: Session, image_id: str, update: schemas.CaptionUpdate):
257
- """Update caption data for an image"""
258
- img = db.get(models.Images, image_id)
259
- if not img:
260
  return None
261
 
262
  for field, value in update.dict(exclude_unset=True).items():
263
- setattr(img, field, value)
264
 
265
  db.commit()
266
- db.refresh(img)
267
- return img
268
 
269
- def delete_caption(db: Session, image_id: str):
270
- """Delete caption data for an image (sets caption fields to None)"""
271
- img = db.get(models.Images, image_id)
272
- if not img:
273
  return False
274
 
275
- img.title = None
276
- img.prompt = None
277
- img.model = None
278
- img.schema_id = None
279
- img.raw_json = None
280
- img.generated = None
281
- img.edited = None
282
- img.accuracy = None
283
- img.context = None
284
- img.usability = None
285
- img.starred = False
286
-
287
  db.commit()
288
  return True
289
 
@@ -373,4 +378,4 @@ def get_schema(db: Session, schema_id: str):
373
 
374
  def get_recent_images_with_validation(db: Session, limit: int = 100):
375
  """Get recent images with validation info"""
376
- return db.query(models.Images).order_by(models.Images.created_at.desc()).limit(limit).all()
 
1
  import io, hashlib
2
+ from typing import Optional, List
3
  from sqlalchemy.orm import Session, joinedload
4
  from . import models, schemas
5
  from fastapi import HTTPException
 
62
  return img
63
 
64
  def get_images(db: Session):
65
+ """Get all images with their countries and captions"""
66
  return (
67
  db.query(models.Images)
68
  .options(
69
  joinedload(models.Images.countries),
70
+ joinedload(models.Images.captions),
71
  )
72
  .all()
73
  )
74
 
75
  def get_image(db: Session, image_id: str):
76
+ """Get a single image by ID with its countries and captions"""
77
  return (
78
  db.query(models.Images)
79
  .options(
80
  joinedload(models.Images.countries),
81
+ joinedload(models.Images.captions),
82
  )
83
  .filter(models.Images.image_id == image_id)
84
  .first()
 
102
  if img.image_type == "drone_image":
103
  schema_id = "drone_caption@1.0.0"
104
 
105
+ caption = models.Captions(
106
+ title=title,
107
+ prompt=prompt,
108
+ model=model_code,
109
+ schema_id=schema_id,
110
+ raw_json=raw_json,
111
+ generated=text,
112
+ edited=text
113
+ )
114
+
115
+ db.add(caption)
116
+ db.flush()
117
+
118
+ # Link caption to image
119
+ img.captions.append(caption)
120
 
121
  print(f"About to commit caption to database...")
122
  db.commit()
123
  print(f"Caption commit successful!")
124
+ db.refresh(caption)
125
  print(f"Caption created successfully for image: {img.image_id}")
126
+ return caption
127
 
128
+ def get_caption(db: Session, caption_id: str):
129
+ """Get caption data for a specific caption ID"""
130
+ return db.get(models.Captions, caption_id)
131
 
132
  def get_captions_by_image(db: Session, image_id: str):
133
+ """Get all captions for a specific image"""
134
  img = db.get(models.Images, image_id)
135
+ if img:
136
+ return img.captions
137
  return []
138
 
139
  def get_all_captions_with_images(db: Session):
140
+ """Get all captions with their associated images"""
141
+ return (
142
+ db.query(models.Captions)
143
+ .options(
144
+ joinedload(models.Captions.images),
145
+ )
146
+ .all()
147
+ )
148
 
149
  def get_prompts(db: Session):
150
  """Get all available prompts"""
 
269
  db.refresh(prompt)
270
  return prompt
271
 
272
+ def update_caption(db: Session, caption_id: str, update: schemas.CaptionUpdate):
273
+ """Update caption data for a caption"""
274
+ caption = db.get(models.Captions, caption_id)
275
+ if not caption:
276
  return None
277
 
278
  for field, value in update.dict(exclude_unset=True).items():
279
+ setattr(caption, field, value)
280
 
281
  db.commit()
282
+ db.refresh(caption)
283
+ return caption
284
 
285
+ def delete_caption(db: Session, caption_id: str):
286
+ """Delete caption data for a caption"""
287
+ caption = db.get(models.Captions, caption_id)
288
+ if not caption:
289
  return False
290
 
291
+ db.delete(caption)
 
 
 
 
 
 
 
 
 
 
 
292
  db.commit()
293
  return True
294
 
 
378
 
379
  def get_recent_images_with_validation(db: Session, limit: int = 100):
380
  """Get recent images with validation info"""
381
+ return db.query(models.Images).order_by(models.Images.captured_at.desc()).limit(limit).all()
py_backend/app/models.py CHANGED
@@ -24,6 +24,22 @@ image_countries = Table(
24
  ),
25
  )
26
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
27
  class Source(Base):
28
  __tablename__ = "sources"
29
  s_code = Column(String, primary_key=True)
@@ -90,9 +106,12 @@ class JSONSchema(Base):
90
  class Images(Base):
91
  __tablename__ = "images"
92
  __table_args__ = (
93
- CheckConstraint('accuracy IS NULL OR (accuracy BETWEEN 0 AND 100)', name='chk_images_accuracy'),
94
- CheckConstraint('context IS NULL OR (context BETWEEN 0 AND 100)', name='chk_images_context'),
95
- CheckConstraint('usability IS NULL OR (usability BETWEEN 0 AND 100)', name='chk_images_usability'),
 
 
 
96
  )
97
 
98
  image_id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
@@ -102,27 +121,9 @@ class Images(Base):
102
  event_type = Column(String, ForeignKey("event_types.t_code"), nullable=False)
103
  epsg = Column(String, ForeignKey("spatial_references.epsg"), nullable=False)
104
  image_type = Column(String, ForeignKey("image_types.image_type"), nullable=False)
105
- created_at = Column(TIMESTAMP(timezone=True), default=datetime.datetime.utcnow)
106
  captured_at = Column(TIMESTAMP(timezone=True))
107
 
108
- title = Column(String, nullable=True)
109
- prompt = Column(String, ForeignKey("prompts.p_code"), nullable=True)
110
- model = Column(String, ForeignKey("models.m_code"), nullable=True)
111
- schema_id = Column(String, ForeignKey("json_schemas.schema_id"), nullable=True)
112
- raw_json = Column(JSONB, nullable=True)
113
- generated = Column(Text, nullable=True)
114
- edited = Column(Text)
115
- accuracy = Column(SmallInteger)
116
- context = Column(SmallInteger)
117
- usability = Column(SmallInteger)
118
- starred = Column(Boolean, default=False)
119
- updated_at = Column(TIMESTAMP(timezone=True), onupdate=datetime.datetime.utcnow)
120
-
121
- countries = relationship("Country", secondary=image_countries, backref="images")
122
- schema = relationship("JSONSchema")
123
- model_r = relationship("Models", foreign_keys=[model])
124
- prompt_r = relationship("Prompts", foreign_keys=[prompt])
125
-
126
  center_lon = Column(Float)
127
  center_lat = Column(Float)
128
  amsl_m = Column(Float)
@@ -134,3 +135,35 @@ class Images(Base):
134
  rtk_fix = Column(Boolean)
135
  std_h_m = Column(Float)
136
  std_v_m = Column(Float)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
24
  ),
25
  )
26
 
27
+ images_captions = Table(
28
+ "images_captions", Base.metadata,
29
+ Column(
30
+ "image_id",
31
+ UUID(as_uuid=True),
32
+ ForeignKey("images.image_id", ondelete="CASCADE"),
33
+ primary_key=True,
34
+ ),
35
+ Column(
36
+ "caption_id",
37
+ UUID(as_uuid=True),
38
+ ForeignKey("captions.caption_id", ondelete="CASCADE"),
39
+ primary_key=True,
40
+ ),
41
+ )
42
+
43
  class Source(Base):
44
  __tablename__ = "sources"
45
  s_code = Column(String, primary_key=True)
 
106
  class Images(Base):
107
  __tablename__ = "images"
108
  __table_args__ = (
109
+ CheckConstraint('center_lat IS NULL OR (center_lat BETWEEN -90 AND 90)', name='chk_images_center_lat'),
110
+ CheckConstraint('center_lon IS NULL OR (center_lon BETWEEN -180 AND 180)', name='chk_images_center_lon'),
111
+ CheckConstraint('heading_deg IS NULL OR (heading_deg >= 0 AND heading_deg <= 360)', name='chk_images_heading_deg'),
112
+ CheckConstraint('pitch_deg IS NULL OR (pitch_deg BETWEEN -90 AND 90)', name='chk_images_pitch_deg'),
113
+ CheckConstraint('yaw_deg IS NULL OR (yaw_deg BETWEEN -180 AND 180)', name='chk_images_yaw_deg'),
114
+ CheckConstraint('roll_deg IS NULL OR (roll_deg BETWEEN -180 AND 180)', name='chk_images_roll_deg'),
115
  )
116
 
117
  image_id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
 
121
  event_type = Column(String, ForeignKey("event_types.t_code"), nullable=False)
122
  epsg = Column(String, ForeignKey("spatial_references.epsg"), nullable=False)
123
  image_type = Column(String, ForeignKey("image_types.image_type"), nullable=False)
 
124
  captured_at = Column(TIMESTAMP(timezone=True))
125
 
126
+ # Drone-specific fields
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
127
  center_lon = Column(Float)
128
  center_lat = Column(Float)
129
  amsl_m = Column(Float)
 
135
  rtk_fix = Column(Boolean)
136
  std_h_m = Column(Float)
137
  std_v_m = Column(Float)
138
+
139
+ # Relationships
140
+ countries = relationship("Country", secondary=image_countries, backref="images")
141
+ captions = relationship("Captions", secondary=images_captions, backref="images")
142
+
143
+ class Captions(Base):
144
+ __tablename__ = "captions"
145
+ __table_args__ = (
146
+ CheckConstraint('accuracy IS NULL OR (accuracy BETWEEN 0 AND 100)', name='chk_captions_accuracy'),
147
+ CheckConstraint('context IS NULL OR (context BETWEEN 0 AND 100)', name='chk_captions_context'),
148
+ CheckConstraint('usability IS NULL OR (usability BETWEEN 0 AND 100)', name='chk_captions_usability'),
149
+ )
150
+
151
+ caption_id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
152
+ title = Column(String, nullable=True)
153
+ prompt = Column(String, ForeignKey("prompts.p_code"), nullable=True)
154
+ model = Column(String, ForeignKey("models.m_code"), nullable=True)
155
+ schema_id = Column(String, ForeignKey("json_schemas.schema_id"), nullable=True)
156
+ raw_json = Column(JSONB, nullable=True)
157
+ generated = Column(Text, nullable=True)
158
+ edited = Column(Text)
159
+ accuracy = Column(SmallInteger)
160
+ context = Column(SmallInteger)
161
+ usability = Column(SmallInteger)
162
+ starred = Column(Boolean, default=False)
163
+ created_at = Column(TIMESTAMP(timezone=True), default=datetime.datetime.utcnow)
164
+ updated_at = Column(TIMESTAMP(timezone=True), onupdate=datetime.datetime.utcnow)
165
+
166
+ # Relationships
167
+ schema = relationship("JSONSchema")
168
+ model_r = relationship("Models", foreign_keys=[model])
169
+ prompt_r = relationship("Prompts", foreign_keys=[prompt])
py_backend/app/routers/admin.py CHANGED
@@ -290,13 +290,13 @@ async def delete_model(
290
  detail=f"Model '{model_code}' not found"
291
  )
292
 
293
- # Check if model is being used by any images
294
- from ..models import Images
295
- image_count = db.query(Images).filter(Images.model == model_code).count()
296
- if image_count > 0:
297
  raise HTTPException(
298
  status_code=400,
299
- detail=f"Cannot delete model '{model_code}' - it is used by {image_count} image(s)"
300
  )
301
 
302
  # Hard delete model (remove from database)
 
290
  detail=f"Model '{model_code}' not found"
291
  )
292
 
293
+ # Check if model is being used by any captions
294
+ from ..models import Captions
295
+ caption_count = db.query(Captions).filter(Captions.model == model_code).count()
296
+ if caption_count > 0:
297
  raise HTTPException(
298
  status_code=400,
299
+ detail=f"Cannot delete model '{model_code}' - it is used by {caption_count} caption(s)"
300
  )
301
 
302
  # Hard delete model (remove from database)
py_backend/app/routers/caption.py CHANGED
@@ -82,7 +82,7 @@ def get_db():
82
 
83
  @router.post(
84
  "/images/{image_id}/caption",
85
- response_model=schemas.ImageOut,
86
  )
87
  async def create_caption(
88
  image_id: str,
@@ -205,7 +205,7 @@ async def create_caption(
205
  raw = {"error": str(e), "fallback": True}
206
  metadata = {}
207
 
208
- c = crud.create_caption(
209
  db,
210
  image_id=image_id,
211
  title=title,
@@ -216,141 +216,170 @@ async def create_caption(
216
  metadata=metadata,
217
  )
218
 
219
- db.refresh(c)
220
-
221
- print(f"DEBUG: Caption created, image object: {c}")
222
- print(f"DEBUG: file_key: {c.file_key}")
223
- print(f"DEBUG: image_id: {c.image_id}")
224
-
225
- from .upload import convert_image_to_dict
226
- try:
227
- url = storage.get_object_url(c.file_key)
228
- print(f"DEBUG: Generated URL: {url}")
229
- if url.startswith('/') and settings.STORAGE_PROVIDER == "local":
230
- url = f"http://localhost:8000{url}"
231
- print(f"DEBUG: Local URL adjusted to: {url}")
232
- except Exception as e:
233
- print(f"DEBUG: URL generation failed: {e}")
234
- url = f"/api/images/{c.image_id}/file"
235
- print(f"DEBUG: Using fallback URL: {url}")
236
-
237
- img_dict = convert_image_to_dict(c, url)
238
- return schemas.ImageOut(**img_dict)
239
-
240
- @router.get(
241
- "/images/{image_id}/caption",
242
- response_model=schemas.ImageOut,
243
- )
244
- def get_caption(
245
- image_id: str,
246
- db: Session = Depends(get_db),
247
- ):
248
- caption = crud.get_caption(db, image_id)
249
- if not caption or not caption.title:
250
- raise HTTPException(404, "caption not found")
251
-
252
  db.refresh(caption)
253
 
254
- from .upload import convert_image_to_dict
255
- try:
256
- url = storage.get_object_url(caption.file_key)
257
- if url.startswith('/') and settings.STORAGE_PROVIDER == "local":
258
- url = f"http://localhost:8000{url}"
259
- except Exception:
260
- url = f"/api/images/{caption.image_id}/file"
261
 
262
- img_dict = convert_image_to_dict(caption, url)
263
- return schemas.ImageOut(**img_dict)
264
 
265
  @router.get(
266
- "/images/{image_id}/captions",
267
  response_model=List[schemas.ImageOut],
268
  )
269
- def get_captions_by_image(
270
- image_id: str,
271
  db: Session = Depends(get_db),
272
  ):
273
- """Get caption data for a specific image"""
274
- captions = crud.get_captions_by_image(db, image_id)
 
 
275
 
276
- from .upload import convert_image_to_dict
277
  result = []
278
  for caption in captions:
279
  db.refresh(caption)
280
 
281
- try:
282
- url = storage.get_object_url(caption.file_key)
283
- except Exception:
284
- url = f"/api/images/{caption.image_id}/file"
285
-
286
- img_dict = convert_image_to_dict(caption, url)
287
- result.append(schemas.ImageOut(**img_dict))
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
288
 
 
289
  return result
290
 
291
  @router.get(
292
  "/captions",
293
- response_model=List[schemas.ImageOut],
294
  )
295
  def get_all_captions_with_images(
296
  db: Session = Depends(get_db),
297
  ):
298
- """Get all images that have caption data"""
299
- print(f"DEBUG: Fetching all captions with images...")
300
  captions = crud.get_all_captions_with_images(db)
301
- print(f"DEBUG: Found {len(captions)} images with caption data")
302
 
303
- from .upload import convert_image_to_dict
304
  result = []
305
  for caption in captions:
306
- print(f"DEBUG: Processing image {caption.image_id}, title: {caption.title}, generated: {caption.generated}, model: {caption.model}")
307
 
308
  db.refresh(caption)
309
-
310
- try:
311
- url = storage.get_object_url(caption.file_key)
312
- except Exception:
313
- url = f"/api/images/{caption.image_id}/file"
314
-
315
- img_dict = convert_image_to_dict(caption, url)
316
- result.append(schemas.ImageOut(**img_dict))
317
 
318
  print(f"DEBUG: Returning {len(result)} formatted results")
319
  return result
320
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
321
  @router.put(
322
- "/images/{image_id}/caption",
323
- response_model=schemas.ImageOut,
324
  )
325
  def update_caption(
326
- image_id: str,
327
  update: schemas.CaptionUpdate,
328
  db: Session = Depends(get_db),
329
  ):
330
- caption = crud.update_caption(db, image_id, update)
331
  if not caption:
332
  raise HTTPException(404, "caption not found")
333
 
334
  db.refresh(caption)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
335
 
336
- from .upload import convert_image_to_dict
337
- try:
338
- url = storage.get_object_url(caption.file_key)
339
- except Exception:
340
- url = f"/api/images/{caption.image_id}/file"
341
 
342
- img_dict = convert_image_to_dict(caption, url)
343
- return schemas.ImageOut(**img_dict)
 
 
 
 
 
344
 
345
  @router.delete(
346
- "/images/{image_id}/caption",
347
  )
348
  def delete_caption(
349
- image_id: str,
350
  db: Session = Depends(get_db),
351
  ):
352
- """Delete caption data for an image"""
353
- success = crud.delete_caption(db, image_id)
354
  if not success:
355
  raise HTTPException(404, "caption not found")
356
  return {"message": "Caption deleted successfully"}
 
82
 
83
  @router.post(
84
  "/images/{image_id}/caption",
85
+ response_model=schemas.CaptionOut,
86
  )
87
  async def create_caption(
88
  image_id: str,
 
205
  raw = {"error": str(e), "fallback": True}
206
  metadata = {}
207
 
208
+ caption = crud.create_caption(
209
  db,
210
  image_id=image_id,
211
  title=title,
 
216
  metadata=metadata,
217
  )
218
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
219
  db.refresh(caption)
220
 
221
+ print(f"DEBUG: Caption created, caption object: {caption}")
222
+ print(f"DEBUG: caption_id: {caption.caption_id}")
 
 
 
 
 
223
 
224
+ return schemas.CaptionOut.from_orm(caption)
 
225
 
226
  @router.get(
227
+ "/captions/legacy",
228
  response_model=List[schemas.ImageOut],
229
  )
230
+ def get_all_captions_legacy_format(
 
231
  db: Session = Depends(get_db),
232
  ):
233
+ """Get all images with captions in the old format for backward compatibility"""
234
+ print(f"DEBUG: Fetching all captions in legacy format...")
235
+ captions = crud.get_all_captions_with_images(db)
236
+ print(f"DEBUG: Found {len(captions)} captions")
237
 
 
238
  result = []
239
  for caption in captions:
240
  db.refresh(caption)
241
 
242
+ # Get the associated image for this caption
243
+ if caption.images:
244
+ for image in caption.images:
245
+ # Create a response in the old format where caption data is embedded in image
246
+ from .upload import convert_image_to_dict
247
+ try:
248
+ url = storage.get_object_url(image.file_key)
249
+ if url.startswith('/') and settings.STORAGE_PROVIDER == "local":
250
+ url = f"http://localhost:8000{url}"
251
+ except Exception:
252
+ url = f"/api/images/{image.image_id}/file"
253
+
254
+ img_dict = convert_image_to_dict(image, url)
255
+
256
+ # Override with caption data
257
+ img_dict.update({
258
+ "title": caption.title,
259
+ "prompt": caption.prompt,
260
+ "model": caption.model,
261
+ "schema_id": caption.schema_id,
262
+ "raw_json": caption.raw_json,
263
+ "generated": caption.generated,
264
+ "edited": caption.edited,
265
+ "accuracy": caption.accuracy,
266
+ "context": caption.context,
267
+ "usability": caption.usability,
268
+ "starred": caption.starred,
269
+ "created_at": caption.created_at,
270
+ "updated_at": caption.updated_at,
271
+ })
272
+
273
+ result.append(schemas.ImageOut(**img_dict))
274
 
275
+ print(f"DEBUG: Returning {len(result)} legacy format results")
276
  return result
277
 
278
  @router.get(
279
  "/captions",
280
+ response_model=List[schemas.CaptionOut],
281
  )
282
  def get_all_captions_with_images(
283
  db: Session = Depends(get_db),
284
  ):
285
+ """Get all captions"""
286
+ print(f"DEBUG: Fetching all captions...")
287
  captions = crud.get_all_captions_with_images(db)
288
+ print(f"DEBUG: Found {len(captions)} captions")
289
 
 
290
  result = []
291
  for caption in captions:
292
+ print(f"DEBUG: Processing caption {caption.caption_id}, title: {caption.title}, generated: {caption.generated}, model: {caption.model}")
293
 
294
  db.refresh(caption)
295
+ result.append(schemas.CaptionOut.from_orm(caption))
 
 
 
 
 
 
 
296
 
297
  print(f"DEBUG: Returning {len(result)} formatted results")
298
  return result
299
 
300
+ @router.get(
301
+ "/images/{image_id}/captions",
302
+ response_model=List[schemas.CaptionOut],
303
+ )
304
+ def get_captions_by_image(
305
+ image_id: str,
306
+ db: Session = Depends(get_db),
307
+ ):
308
+ """Get all captions for a specific image"""
309
+ captions = crud.get_captions_by_image(db, image_id)
310
+
311
+ result = []
312
+ for caption in captions:
313
+ db.refresh(caption)
314
+ result.append(schemas.CaptionOut.from_orm(caption))
315
+
316
+ return result
317
+
318
+ @router.get(
319
+ "/captions/{caption_id}",
320
+ response_model=schemas.CaptionOut,
321
+ )
322
+ def get_caption(
323
+ caption_id: str,
324
+ db: Session = Depends(get_db),
325
+ ):
326
+ caption = crud.get_caption(db, caption_id)
327
+ if not caption:
328
+ raise HTTPException(404, "caption not found")
329
+
330
+ db.refresh(caption)
331
+ return schemas.CaptionOut.from_orm(caption)
332
+
333
  @router.put(
334
+ "/captions/{caption_id}",
335
+ response_model=schemas.CaptionOut,
336
  )
337
  def update_caption(
338
+ caption_id: str,
339
  update: schemas.CaptionUpdate,
340
  db: Session = Depends(get_db),
341
  ):
342
+ caption = crud.update_caption(db, caption_id, update)
343
  if not caption:
344
  raise HTTPException(404, "caption not found")
345
 
346
  db.refresh(caption)
347
+ return schemas.CaptionOut.from_orm(caption)
348
+
349
+ @router.put(
350
+ "/images/{image_id}/caption",
351
+ response_model=schemas.CaptionOut,
352
+ )
353
+ def update_caption_by_image(
354
+ image_id: str,
355
+ update: schemas.CaptionUpdate,
356
+ db: Session = Depends(get_db),
357
+ ):
358
+ """Update the first caption for an image (for backward compatibility)"""
359
+ img = crud.get_image(db, image_id)
360
+ if not img:
361
+ raise HTTPException(404, "image not found")
362
 
363
+ if not img.captions:
364
+ raise HTTPException(404, "no captions found for this image")
 
 
 
365
 
366
+ # Update the first caption
367
+ caption = crud.update_caption(db, str(img.captions[0].caption_id), update)
368
+ if not caption:
369
+ raise HTTPException(404, "caption not found")
370
+
371
+ db.refresh(caption)
372
+ return schemas.CaptionOut.from_orm(caption)
373
 
374
  @router.delete(
375
+ "/captions/{caption_id}",
376
  )
377
  def delete_caption(
378
+ caption_id: str,
379
  db: Session = Depends(get_db),
380
  ):
381
+ """Delete caption data for a caption"""
382
+ success = crud.delete_caption(db, caption_id)
383
  if not success:
384
  raise HTTPException(404, "caption not found")
385
  return {"message": "Caption deleted successfully"}
py_backend/app/routers/images.py CHANGED
@@ -153,17 +153,6 @@ async def create_image_from_url(payload: CreateImageFromUrlIn, db: Session = Dep
153
  event_type=event_type,
154
  epsg=epsg,
155
  image_type=payload.image_type,
156
- title="no title",
157
- prompt=prompt_code,
158
- model="STUB_MODEL",
159
- schema_id=schema_id,
160
- raw_json={},
161
- generated="",
162
- edited="",
163
- accuracy=50,
164
- context=50,
165
- usability=50,
166
- starred=False,
167
  center_lon=payload.center_lon,
168
  center_lat=payload.center_lat,
169
  amsl_m=payload.amsl_m,
@@ -219,7 +208,8 @@ async def debug_database_status(db: Session = Depends(get_db)):
219
  # Check required tables
220
  tables_to_check = [
221
  "sources", "event_types", "spatial_references", "image_types",
222
- "prompts", "models", "json_schemas", "images", "image_countries"
 
223
  ]
224
 
225
  for table in tables_to_check:
 
153
  event_type=event_type,
154
  epsg=epsg,
155
  image_type=payload.image_type,
 
 
 
 
 
 
 
 
 
 
 
156
  center_lon=payload.center_lon,
157
  center_lat=payload.center_lat,
158
  amsl_m=payload.amsl_m,
 
208
  # Check required tables
209
  tables_to_check = [
210
  "sources", "event_types", "spatial_references", "image_types",
211
+ "prompts", "models", "json_schemas", "images", "image_countries",
212
+ "captions", "images_captions"
213
  ]
214
 
215
  for table in tables_to_check:
py_backend/app/routers/metadata.py CHANGED
@@ -12,16 +12,16 @@ def get_db():
12
  finally:
13
  db.close()
14
 
15
- @router.put("/maps/{map_id}/metadata", response_model=schemas.ImageOut)
16
  def update_metadata(
17
- map_id: str,
18
  update: schemas.CaptionUpdate,
19
  db: Session = Depends(get_db)
20
  ):
21
- c = crud.update_caption(db, map_id, update)
22
- if not c:
23
  raise HTTPException(404, "caption not found")
24
- return c
25
 
26
  @router.get("/sources", response_model=List[schemas.SourceOut])
27
  def get_sources(db: Session = Depends(get_db)):
 
12
  finally:
13
  db.close()
14
 
15
+ @router.put("/captions/{caption_id}/metadata", response_model=schemas.CaptionOut)
16
  def update_metadata(
17
+ caption_id: str,
18
  update: schemas.CaptionUpdate,
19
  db: Session = Depends(get_db)
20
  ):
21
+ caption = crud.update_caption(db, caption_id, update)
22
+ if not caption:
23
  raise HTTPException(404, "caption not found")
24
+ return schemas.CaptionOut.from_orm(caption)
25
 
26
  @router.get("/sources", response_model=List[schemas.SourceOut])
27
  def get_sources(db: Session = Depends(get_db)):
py_backend/app/routers/upload.py CHANGED
@@ -9,6 +9,7 @@ from typing import List, Optional
9
  import boto3
10
  import time
11
  import base64
 
12
 
13
  router = APIRouter()
14
 
@@ -50,6 +51,62 @@ def convert_image_to_dict(img, image_url):
50
  print(f"Warning: Error processing countries for image {img.image_id}: {e}")
51
  countries_list = []
52
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
53
  img_dict = {
54
  "image_id": img.image_id,
55
  "file_key": img.file_key,
@@ -60,19 +117,23 @@ def convert_image_to_dict(img, image_url):
60
  "image_type": img.image_type,
61
  "image_url": image_url,
62
  "countries": countries_list,
63
- "title": img.title,
64
- "prompt": img.prompt,
65
- "model": img.model,
66
- "schema_id": img.schema_id,
67
- "raw_json": img.raw_json,
68
- "generated": img.generated,
69
- "edited": img.edited,
70
- "accuracy": img.accuracy,
71
- "context": img.context,
72
- "usability": img.usability,
73
- "starred": img.starred if img.starred is not None else False,
74
- "created_at": img.created_at,
75
- "updated_at": img.updated_at,
 
 
 
 
76
 
77
  # Drone-specific fields
78
  "center_lon": getattr(img, 'center_lon', None),
@@ -367,8 +428,21 @@ def update_image_metadata(
367
  img.epsg = metadata.epsg
368
  if metadata.image_type is not None:
369
  img.image_type = metadata.image_type
 
370
  if metadata.starred is not None:
371
- img.starred = metadata.starred
 
 
 
 
 
 
 
 
 
 
 
 
372
 
373
  # Update drone-specific fields
374
  if metadata.center_lon is not None:
@@ -435,12 +509,15 @@ def delete_image(image_id: str, db: Session = Depends(get_db), content_managemen
435
 
436
  # Only increment delete count if this is NOT a content management delete
437
  # Content management deletes (from map details) should not count against model performance
438
- if not content_management and img.model:
439
- from .. import crud as crud_module
440
- model = crud_module.get_model(db, img.model)
441
- if model:
442
- model.delete_count += 1
443
- db.commit()
 
 
 
444
 
445
  db.delete(img)
446
  db.commit()
 
9
  import boto3
10
  import time
11
  import base64
12
+ import datetime
13
 
14
  router = APIRouter()
15
 
 
51
  print(f"Warning: Error processing countries for image {img.image_id}: {e}")
52
  countries_list = []
53
 
54
+ captions_list = []
55
+ if hasattr(img, 'captions') and img.captions is not None:
56
+ try:
57
+ captions_list = [
58
+ {
59
+ "caption_id": c.caption_id,
60
+ "title": c.title,
61
+ "prompt": c.prompt,
62
+ "model": c.model,
63
+ "schema_id": c.schema_id,
64
+ "raw_json": c.raw_json,
65
+ "generated": c.generated,
66
+ "edited": c.edited,
67
+ "accuracy": c.accuracy,
68
+ "context": c.context,
69
+ "usability": c.usability,
70
+ "starred": c.starred if c.starred is not None else False,
71
+ "created_at": c.created_at,
72
+ "updated_at": c.updated_at
73
+ } for c in img.captions
74
+ ]
75
+ except Exception as e:
76
+ print(f"Warning: Error processing captions for image {img.image_id}: {e}")
77
+ captions_list = []
78
+
79
+ # Get starred status and other caption fields from first caption for backward compatibility
80
+ starred = False
81
+ title = None
82
+ prompt = None
83
+ model = None
84
+ schema_id = None
85
+ raw_json = None
86
+ generated = None
87
+ edited = None
88
+ accuracy = None
89
+ context = None
90
+ usability = None
91
+ created_at = None
92
+ updated_at = None
93
+
94
+ if captions_list:
95
+ first_caption = captions_list[0]
96
+ starred = first_caption.get("starred", False)
97
+ title = first_caption.get("title")
98
+ prompt = first_caption.get("prompt")
99
+ model = first_caption.get("model")
100
+ schema_id = first_caption.get("schema_id")
101
+ raw_json = first_caption.get("raw_json")
102
+ generated = first_caption.get("generated")
103
+ edited = first_caption.get("edited")
104
+ accuracy = first_caption.get("accuracy")
105
+ context = first_caption.get("context")
106
+ usability = first_caption.get("usability")
107
+ created_at = first_caption.get("created_at")
108
+ updated_at = first_caption.get("updated_at")
109
+
110
  img_dict = {
111
  "image_id": img.image_id,
112
  "file_key": img.file_key,
 
117
  "image_type": img.image_type,
118
  "image_url": image_url,
119
  "countries": countries_list,
120
+ "captions": captions_list,
121
+ "starred": starred, # Backward compatibility
122
+ "captured_at": img.captured_at,
123
+
124
+ # Backward compatibility fields for legacy frontend
125
+ "title": title,
126
+ "prompt": prompt,
127
+ "model": model,
128
+ "schema_id": schema_id,
129
+ "raw_json": raw_json,
130
+ "generated": generated,
131
+ "edited": edited,
132
+ "accuracy": accuracy,
133
+ "context": context,
134
+ "usability": usability,
135
+ "created_at": created_at,
136
+ "updated_at": updated_at,
137
 
138
  # Drone-specific fields
139
  "center_lon": getattr(img, 'center_lon', None),
 
428
  img.epsg = metadata.epsg
429
  if metadata.image_type is not None:
430
  img.image_type = metadata.image_type
431
+ # Handle starred field - update the first caption's starred status
432
  if metadata.starred is not None:
433
+ if img.captions:
434
+ # Update the first caption's starred status
435
+ img.captions[0].starred = metadata.starred
436
+ else:
437
+ # If no captions exist, create a minimal caption with starred status
438
+ from app import models
439
+ caption = models.Captions(
440
+ title="",
441
+ starred=metadata.starred,
442
+ created_at=datetime.datetime.utcnow()
443
+ )
444
+ db.add(caption)
445
+ img.captions.append(caption)
446
 
447
  # Update drone-specific fields
448
  if metadata.center_lon is not None:
 
509
 
510
  # Only increment delete count if this is NOT a content management delete
511
  # Content management deletes (from map details) should not count against model performance
512
+ if not content_management and img.captions:
513
+ # Get model from the first caption
514
+ model_name = img.captions[0].model
515
+ if model_name:
516
+ from .. import crud as crud_module
517
+ model = crud_module.get_model(db, model_name)
518
+ if model:
519
+ model.delete_count += 1
520
+ db.commit()
521
 
522
  db.delete(img)
523
  db.commit()
py_backend/app/schemas.py CHANGED
@@ -29,7 +29,7 @@ class ImageMetadataUpdate(BaseModel):
29
  countries: Optional[List[str]] = None
30
  epsg: Optional[str] = None
31
  image_type: Optional[str] = None
32
- starred: Optional[bool] = None
33
 
34
  # Drone-specific fields (optional)
35
  center_lon: Optional[float] = None
@@ -44,6 +44,25 @@ class ImageMetadataUpdate(BaseModel):
44
  std_h_m: Optional[float] = None
45
  std_v_m: Optional[float] = None
46
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
47
  class ImageOut(BaseModel):
48
  image_id: UUID
49
  file_key: str
@@ -54,6 +73,11 @@ class ImageOut(BaseModel):
54
  image_type: str
55
  image_url: str
56
  countries: List["CountryOut"] = []
 
 
 
 
 
57
  title: Optional[str] = None
58
  prompt: Optional[str] = None
59
  model: Optional[str] = None
@@ -64,7 +88,6 @@ class ImageOut(BaseModel):
64
  accuracy: Optional[int] = None
65
  context: Optional[int] = None
66
  usability: Optional[int] = None
67
- starred: bool = False
68
  created_at: Optional[datetime] = None
69
  updated_at: Optional[datetime] = None
70
 
@@ -94,6 +117,18 @@ class CaptionUpdate(BaseModel):
94
  context: Optional[int] = None
95
  usability: Optional[int] = None
96
 
 
 
 
 
 
 
 
 
 
 
 
 
97
  class PromptOut(BaseModel):
98
  p_code: str
99
  label: str
 
29
  countries: Optional[List[str]] = None
30
  epsg: Optional[str] = None
31
  image_type: Optional[str] = None
32
+ starred: Optional[bool] = None # Backward compatibility - updates first caption
33
 
34
  # Drone-specific fields (optional)
35
  center_lon: Optional[float] = None
 
44
  std_h_m: Optional[float] = None
45
  std_v_m: Optional[float] = None
46
 
47
+ class CaptionOut(BaseModel):
48
+ caption_id: UUID
49
+ title: Optional[str] = None
50
+ prompt: Optional[str] = None
51
+ model: Optional[str] = None
52
+ schema_id: Optional[str] = None
53
+ raw_json: Optional[dict] = None
54
+ generated: Optional[str] = None
55
+ edited: Optional[str] = None
56
+ accuracy: Optional[int] = None
57
+ context: Optional[int] = None
58
+ usability: Optional[int] = None
59
+ starred: bool = False
60
+ created_at: Optional[datetime] = None
61
+ updated_at: Optional[datetime] = None
62
+
63
+ class Config:
64
+ from_attributes = True
65
+
66
  class ImageOut(BaseModel):
67
  image_id: UUID
68
  file_key: str
 
73
  image_type: str
74
  image_url: str
75
  countries: List["CountryOut"] = []
76
+ captions: List[CaptionOut] = []
77
+ starred: bool = False # Backward compatibility - from first caption
78
+ captured_at: Optional[datetime] = None
79
+
80
+ # Backward compatibility fields for legacy frontend
81
  title: Optional[str] = None
82
  prompt: Optional[str] = None
83
  model: Optional[str] = None
 
88
  accuracy: Optional[int] = None
89
  context: Optional[int] = None
90
  usability: Optional[int] = None
 
91
  created_at: Optional[datetime] = None
92
  updated_at: Optional[datetime] = None
93
 
 
117
  context: Optional[int] = None
118
  usability: Optional[int] = None
119
 
120
+ class CaptionCreate(BaseModel):
121
+ title: str
122
+ prompt: str
123
+ model: str
124
+ raw_json: dict
125
+ generated: str
126
+ edited: str
127
+ accuracy: Optional[int] = None
128
+ context: Optional[int] = None
129
+ usability: Optional[int] = None
130
+ starred: bool = False
131
+
132
  class PromptOut(BaseModel):
133
  p_code: str
134
  label: str
py_backend/tests/integration_tests/test_explore_page.py CHANGED
@@ -163,8 +163,8 @@ def test_database_consistency():
163
  images = db.query(models.Images).all()
164
  print(f" + Total images in database: {len(images)}")
165
 
166
- images_with_captions = db.query(models.Images).filter(models.Images.title.isnot(None)).all()
167
- print(f" + Images with caption data: {len(images_with_captions)}")
168
 
169
  sources = db.query(models.Source).all()
170
  print(f" + Total sources: {len(sources)}")
 
163
  images = db.query(models.Images).all()
164
  print(f" + Total images in database: {len(images)}")
165
 
166
+ captions = db.query(models.Captions).all()
167
+ print(f" + Total captions in database: {len(captions)}")
168
 
169
  sources = db.query(models.Source).all()
170
  print(f" + Total sources: {len(sources)}")
py_backend/tests/integration_tests/test_upload_flow.py CHANGED
@@ -27,13 +27,7 @@ def test_database_connection():
27
  source="OTHER",
28
  event_type="OTHER",
29
  epsg="4326",
30
- image_type="crisis_map",
31
- title="Test Title",
32
- prompt="Test prompt",
33
- model="STUB_MODEL",
34
- schema_id="default_caption@1.0.0",
35
- raw_json={"test": True},
36
- generated="This is a test caption"
37
  )
38
 
39
  db.add(test_img)
 
27
  source="OTHER",
28
  event_type="OTHER",
29
  epsg="4326",
30
+ image_type="crisis_map"
 
 
 
 
 
 
31
  )
32
 
33
  db.add(test_img)