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..142e40ca 100644 --- a/backend/api/app/bestof_routes.py +++ b/backend/api/app/bestof_routes.py @@ -146,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 a91f65f8..b568451c 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'), @@ -116,13 +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, } + if row.notes: + response["notes"] = row.notes + return response async def _query_nearest( @@ -140,6 +144,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'), @@ -162,13 +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, } + if row.notes: + response["notes"] = row.notes + return response @router.get("/nearest") 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..c9bcf4b6 100644 --- a/backend/api/app/photo_routes.py +++ b/backend/api/app/photo_routes.py @@ -576,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, @@ -704,7 +706,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 +718,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(), ) @@ -782,7 +785,7 @@ 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, @@ -807,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 @@ -968,6 +974,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) @@ -999,7 +1006,7 @@ 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, @@ -1012,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 @@ -1108,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", @@ -1136,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/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..6ff7f18c 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, @@ -1722,6 +1724,8 @@ async def get_user_photos( "sizes": photo.sizes, "description": photo.description } + if photo.notes: + photo_data["notes"] = photo.notes photo_list.append(photo_data) # Get total count for this user 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