From 5662dc4c0e58bb9e4f4cd3ff1c76ca6443e525d7 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 25 Jun 2026 20:49:27 +0000 Subject: [PATCH 1/3] Initial plan From 425888bd91151a943af0195688db82635a208fee Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 25 Jun 2026 20:52:46 +0000 Subject: [PATCH 2/3] feat: add notes field to photos --- .../alembic/versions/024_add_notes_column.py | 28 ++++++++ backend/api/app/backfill_places.py | 1 + backend/api/app/bestof_routes.py | 1 + backend/api/app/featured_routes.py | 4 ++ backend/api/app/hillview_routes.py | 3 + backend/api/app/photo_routes.py | 8 ++- .../api/app/tests/unit/test_photo_notes.py | 68 +++++++++++++++++++ backend/api/app/user_routes.py | 5 +- backend/common/models.py | 1 + backend/tests/utils/secure_upload_utils.py | 4 +- 10 files changed, 120 insertions(+), 3 deletions(-) create mode 100644 backend/api/app/alembic/versions/024_add_notes_column.py create mode 100644 backend/api/app/tests/unit/test_photo_notes.py diff --git a/backend/api/app/alembic/versions/024_add_notes_column.py b/backend/api/app/alembic/versions/024_add_notes_column.py new file mode 100644 index 00000000..7b40e264 --- /dev/null +++ b/backend/api/app/alembic/versions/024_add_notes_column.py @@ -0,0 +1,28 @@ +"""Add notes column to photos. + +Place-oriented notes are stored separately from the longer photo description so +we can eventually give them annotation-like licensing/editing behavior without +overloading the existing description body. + +Revision ID: 024_add_notes_column +Revises: 023_timeline_filename_tiebreak +Create Date: 2026-06-25 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + +revision: str = '024_add_notes_column' +down_revision: Union[str, None] = '023_timeline_filename_tiebreak' +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + op.add_column('photos', sa.Column('notes', sa.Text(), nullable=True)) + + +def downgrade() -> None: + op.drop_column('photos', 'notes') diff --git a/backend/api/app/backfill_places.py b/backend/api/app/backfill_places.py index aa4b7939..68d0533d 100644 --- a/backend/api/app/backfill_places.py +++ b/backend/api/app/backfill_places.py @@ -146,6 +146,7 @@ def _interesting(): Photo.featured == True, and_(Photo.title.isnot(None), Photo.title != ""), and_(Photo.description.isnot(None), Photo.description != ""), + and_(Photo.notes.isnot(None), Photo.notes != ""), func.array_length(Photo.keywords, 1) > 0, select(PhotoAnnotation.id).where(PhotoAnnotation.photo_id == Photo.id).exists(), ) diff --git a/backend/api/app/bestof_routes.py b/backend/api/app/bestof_routes.py index 501059dd..2f4ac19a 100644 --- a/backend/api/app/bestof_routes.py +++ b/backend/api/app/bestof_routes.py @@ -131,6 +131,7 @@ async def get_best_photos( "original_filename": photo.original_filename, "title": photo.title, "description": photo.description, + "notes": photo.notes, "uploaded_at": format_utc(photo.uploaded_at), "captured_at": format_utc(photo.captured_at), "processing_status": photo.processing_status, diff --git a/backend/api/app/featured_routes.py b/backend/api/app/featured_routes.py index a91f65f8..36088b77 100644 --- a/backend/api/app/featured_routes.py +++ b/backend/api/app/featured_routes.py @@ -94,6 +94,7 @@ async def _query_global_best(db: AsyncSession, annotation_sub) -> Optional[dict] select( Photo.id, Photo.description, + Photo.notes, Photo.compass_angle, ST_Y(Photo.geometry).label('latitude'), ST_X(Photo.geometry).label('longitude'), @@ -122,6 +123,7 @@ async def _query_global_best(db: AsyncSession, annotation_sub) -> Optional[dict] "longitude": row.longitude, "bearing": row.compass_angle, "description": row.description, + "notes": row.notes, } @@ -140,6 +142,7 @@ async def _query_nearest( select( Photo.id, Photo.description, + Photo.notes, Photo.compass_angle, ST_Y(Photo.geometry).label('latitude'), ST_X(Photo.geometry).label('longitude'), @@ -168,6 +171,7 @@ async def _query_nearest( "longitude": row.longitude, "bearing": row.compass_angle, "description": row.description, + "notes": row.notes, } diff --git a/backend/api/app/hillview_routes.py b/backend/api/app/hillview_routes.py index 12fe6b3e..08029683 100644 --- a/backend/api/app/hillview_routes.py +++ b/backend/api/app/hillview_routes.py @@ -206,6 +206,9 @@ def convert_photo_to_response(photo, username: str, longitude: float, latitude: if photo.description: photo_data['description'] = photo.description + if photo.notes: + photo_data['notes'] = photo.notes + if photo.keywords: photo_data['keywords'] = photo.keywords diff --git a/backend/api/app/photo_routes.py b/backend/api/app/photo_routes.py index bf963521..cd48b681 100644 --- a/backend/api/app/photo_routes.py +++ b/backend/api/app/photo_routes.py @@ -560,6 +560,7 @@ async def list_photos( "original_filename": photo.original_filename, "title": photo.title, "description": photo.description, + "notes": photo.notes, "is_public": photo.is_public, "latitude": latitude, "longitude": longitude, @@ -704,7 +705,7 @@ async def get_sitemap_photo_ids( count) for the index to compute its page count. CURATED: only photos with something worth indexing are listed — featured, - or carrying a title/description/keywords, or with at least one annotation. + or carrying a title/description/notes/keywords, or with at least one annotation. Bulk title-less uploads are left out so they don't dilute crawl budget / site quality; they join automatically once they gain any such signal. They stay indexable if found by other means (no noindex). @@ -716,6 +717,7 @@ async def get_sitemap_photo_ids( Photo.featured == True, and_(Photo.title.isnot(None), Photo.title != ""), and_(Photo.description.isnot(None), Photo.description != ""), + and_(Photo.notes.isnot(None), Photo.notes != ""), func.array_length(Photo.keywords, 1) > 0, select(PhotoAnnotation.id).where(PhotoAnnotation.photo_id == Photo.id).exists(), ) @@ -788,6 +790,7 @@ async def get_photo( "original_filename": photo.original_filename, "title": photo.title, "description": photo.description, + "notes": photo.notes, "is_public": photo.is_public, "latitude": latitude, "longitude": longitude, @@ -968,6 +971,7 @@ async def get_photo_share_metadata( Photo.sizes, Photo.title, Photo.description, + Photo.notes, ST_X(Photo.geometry).label('longitude'), ST_Y(Photo.geometry).label('latitude') ).where(Photo.id == photo_id, Photo.deleted == False) @@ -1004,6 +1008,7 @@ async def get_photo_share_metadata( "source": "hillview", "title": photo_data.title, "description": photo_data.description, #f"Photo taken at {photo_data.latitude:.6f}, {photo_data.longitude:.6f}", + "notes": photo_data.notes, "image_url": photo_url, "thumbnail_url": thumbnail_url, "width": width, @@ -1116,6 +1121,7 @@ async def get_public_photo( "original_filename": photo.original_filename, "title": photo.title, "description": photo.description, + "notes": photo.notes, "keywords": photo.keywords, "place_name": photo.place_name, "license": legal_rights_to_license(photo.legal_rights), diff --git a/backend/api/app/tests/unit/test_photo_notes.py b/backend/api/app/tests/unit/test_photo_notes.py new file mode 100644 index 00000000..e481e761 --- /dev/null +++ b/backend/api/app/tests/unit/test_photo_notes.py @@ -0,0 +1,68 @@ +"""Unit tests for photo notes plumbing.""" +from types import SimpleNamespace +import sys +import os + +sys.path.insert(0, os.path.join(os.path.dirname(__file__), '..', '..')) + +from hillview_routes import convert_photo_to_response +from user_routes import UploadAuthorizationRequest + + +class TestPhotoNotes: + def test_upload_authorization_request_accepts_notes(self): + request = UploadAuthorizationRequest( + filename='test.jpg', + file_size=123, + content_type='image/jpeg', + file_md5='abc123', + client_key_id='key-1', + description='body', + notes='place note', + ) + + assert request.notes == 'place note' + + def test_convert_photo_to_response_includes_notes(self): + photo = SimpleNamespace( + id='photo-1', + compass_angle=123, + altitude=456, + captured_at=None, + original_filename='test.jpg', + sizes={}, + owner_id='user-1', + file_md5=None, + featured=False, + title=None, + description='body', + notes='place note', + keywords=None, + legal_rights=None, + ) + + response = convert_photo_to_response(photo, 'alice', 14.4, 50.1) + + assert response['notes'] == 'place note' + + def test_convert_photo_to_response_omits_empty_notes(self): + photo = SimpleNamespace( + id='photo-1', + compass_angle=123, + altitude=456, + captured_at=None, + original_filename='test.jpg', + sizes={}, + owner_id='user-1', + file_md5=None, + featured=False, + title=None, + description='body', + notes='', + keywords=None, + legal_rights=None, + ) + + response = convert_photo_to_response(photo, 'alice', 14.4, 50.1) + + assert 'notes' not in response diff --git a/backend/api/app/user_routes.py b/backend/api/app/user_routes.py index 228b22a9..0c7409f5 100644 --- a/backend/api/app/user_routes.py +++ b/backend/api/app/user_routes.py @@ -1266,6 +1266,7 @@ class UploadAuthorizationRequest(BaseModel): client_key_id: str # Key ID that will be used for signing title: Optional[str] = None description: Optional[str] = None + notes: Optional[str] = None keywords: Optional[list[str]] = None is_public: bool = True license: Optional[str] = None # e.g. 'ccbysa4' @@ -1433,6 +1434,7 @@ async def authorize_upload( file_md5=auth_request.file_md5, title=auth_request.title, description=auth_request.description, + notes=auth_request.notes, keywords=auth_request.keywords, is_public=auth_request.is_public, owner_id=current_user.id, @@ -1720,7 +1722,8 @@ async def get_user_photos( "width": photo.width, "height": photo.height, "sizes": photo.sizes, - "description": photo.description + "description": photo.description, + "notes": photo.notes } photo_list.append(photo_data) diff --git a/backend/common/models.py b/backend/common/models.py index 9a6cd6ed..dc5d5c28 100644 --- a/backend/common/models.py +++ b/backend/common/models.py @@ -92,6 +92,7 @@ class Photo(Base): record_created_ts: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now()) title: Mapped[Optional[str]] = mapped_column(Text) # concise headline (og:title, , schema.org name) description: Mapped[Optional[str]] = mapped_column(Text) # longer body text + notes: Mapped[Optional[str]] = mapped_column(Text) # place-specific notes / local context keywords: Mapped[Optional[list[str]]] = mapped_column(ARRAY(Text)) # alt names / search synonyms (schema.org keywords) # Reverse-geocoded place (backfilled out-of-band; see scripts/backfill_places.py) geocode: Mapped[Optional[dict]] = mapped_column(JSONB) # raw {address, display_name} — re-derive without re-geocoding diff --git a/backend/tests/utils/secure_upload_utils.py b/backend/tests/utils/secure_upload_utils.py index 7c63056e..97dc8d77 100644 --- a/backend/tests/utils/secure_upload_utils.py +++ b/backend/tests/utils/secure_upload_utils.py @@ -230,7 +230,7 @@ async def authorize_upload_with_params(self, auth_token: str, filename: str, fil is_public: bool = True, file_data: bytes = None, captured_at: str = None, version: int = None, license: str = 'ccbysa4+osm', - title: str = None, keywords: list = None): + title: str = None, notes: str = None, keywords: list = None): """Request upload authorization with custom parameters. Args: @@ -266,6 +266,8 @@ async def authorize_upload_with_params(self, auth_token: str, filename: str, fil # Only include title/keywords when set, so non-pipeline callers are unchanged. if title is not None: upload_request["title"] = title + if notes is not None: + upload_request["notes"] = notes if keywords is not None: upload_request["keywords"] = keywords From bec5ebd01aa6efd828a4050af4888268c93e1a54 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 25 Jun 2026 20:54:01 +0000 Subject: [PATCH 3/3] fix: omit empty photo notes in responses --- backend/api/app/bestof_routes.py | 3 ++- backend/api/app/featured_routes.py | 12 ++++++++---- backend/api/app/photo_routes.py | 21 ++++++++++++++------- backend/api/app/user_routes.py | 5 +++-- 4 files changed, 27 insertions(+), 14 deletions(-) diff --git a/backend/api/app/bestof_routes.py b/backend/api/app/bestof_routes.py index 2f4ac19a..142e40ca 100644 --- a/backend/api/app/bestof_routes.py +++ b/backend/api/app/bestof_routes.py @@ -131,7 +131,6 @@ async def get_best_photos( "original_filename": photo.original_filename, "title": photo.title, "description": photo.description, - "notes": photo.notes, "uploaded_at": format_utc(photo.uploaded_at), "captured_at": format_utc(photo.captured_at), "processing_status": photo.processing_status, @@ -147,6 +146,8 @@ async def get_best_photos( "annotation_count": int(annotation_count) if annotation_count else 0, "license": legal_rights_to_license(photo.legal_rights) }) + if photo.notes: + photos_data[-1]["notes"] = photo.notes next_cursor = f"{score_int}:{photo.id}" return { diff --git a/backend/api/app/featured_routes.py b/backend/api/app/featured_routes.py index 36088b77..b568451c 100644 --- a/backend/api/app/featured_routes.py +++ b/backend/api/app/featured_routes.py @@ -117,14 +117,16 @@ async def _query_global_best(db: AsyncSession, annotation_sub) -> Optional[dict] row = result.first() if not row: return None - return { + response = { "id": row.id, "latitude": row.latitude, "longitude": row.longitude, "bearing": row.compass_angle, "description": row.description, - "notes": row.notes, } + if row.notes: + response["notes"] = row.notes + return response async def _query_nearest( @@ -165,14 +167,16 @@ async def _query_nearest( row = result.first() if not row: return None - return { + response = { "id": row.id, "latitude": row.latitude, "longitude": row.longitude, "bearing": row.compass_angle, "description": row.description, - "notes": row.notes, } + if row.notes: + response["notes"] = row.notes + return response @router.get("/nearest") diff --git a/backend/api/app/photo_routes.py b/backend/api/app/photo_routes.py index cd48b681..c9bcf4b6 100644 --- a/backend/api/app/photo_routes.py +++ b/backend/api/app/photo_routes.py @@ -560,7 +560,6 @@ async def list_photos( "original_filename": photo.original_filename, "title": photo.title, "description": photo.description, - "notes": photo.notes, "is_public": photo.is_public, "latitude": latitude, "longitude": longitude, @@ -577,6 +576,8 @@ async def list_photos( "user_rating": photo_rating['user_rating'], "rating_counts": photo_rating['rating_counts'] }) + if photo.notes: + photos_data[-1]["notes"] = photo.notes return { "photos": photos_data, @@ -784,13 +785,12 @@ async def get_photo( 'rating_counts': {'thumbs_up': 0, 'thumbs_down': 0} }) - return { + photo_data = { "id": photo.id, "filename": photo.filename, "original_filename": photo.original_filename, "title": photo.title, "description": photo.description, - "notes": photo.notes, "is_public": photo.is_public, "latitude": latitude, "longitude": longitude, @@ -810,6 +810,9 @@ async def get_photo( "user_rating": photo_rating['user_rating'], "rating_counts": photo_rating['rating_counts'] } + if photo.notes: + photo_data["notes"] = photo.notes + return photo_data except HTTPException: raise @@ -1003,12 +1006,11 @@ async def get_photo_share_metadata( thumbnail_url = photo_data.sizes[size_key].get('url') break - return { + response = { "id": photo_data.id, "source": "hillview", "title": photo_data.title, "description": photo_data.description, #f"Photo taken at {photo_data.latitude:.6f}, {photo_data.longitude:.6f}", - "notes": photo_data.notes, "image_url": photo_url, "thumbnail_url": thumbnail_url, "width": width, @@ -1017,6 +1019,9 @@ async def get_photo_share_metadata( "latitude": photo_data.latitude, "longitude": photo_data.longitude } + if photo_data.notes: + response["notes"] = photo_data.notes + return response elif source == "mapillary": # For Mapillary photos, we'd need to implement lookup from cached data @@ -1113,7 +1118,7 @@ async def get_public_photo( is_own_photo = bool(current_user and str(current_user.id) == str(photo.owner_id)) - return { + response = { "id": photo.id, "uid": f"hillview-{photo.id}", "source": "hillview", @@ -1121,7 +1126,6 @@ async def get_public_photo( "original_filename": photo.original_filename, "title": photo.title, "description": photo.description, - "notes": photo.notes, "keywords": photo.keywords, "place_name": photo.place_name, "license": legal_rights_to_license(photo.legal_rights), @@ -1142,6 +1146,9 @@ async def get_public_photo( "rating_counts": photo_rating['rating_counts'], "is_own_photo": is_own_photo } + if photo.notes: + response["notes"] = photo.notes + return response except HTTPException: raise diff --git a/backend/api/app/user_routes.py b/backend/api/app/user_routes.py index 0c7409f5..6ff7f18c 100644 --- a/backend/api/app/user_routes.py +++ b/backend/api/app/user_routes.py @@ -1722,9 +1722,10 @@ async def get_user_photos( "width": photo.width, "height": photo.height, "sizes": photo.sizes, - "description": photo.description, - "notes": photo.notes + "description": photo.description } + if photo.notes: + photo_data["notes"] = photo.notes photo_list.append(photo_data) # Get total count for this user