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,