Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
28 changes: 28 additions & 0 deletions backend/api/app/alembic/versions/024_add_notes_column.py
Original file line number Diff line number Diff line change
@@ -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')
1 change: 1 addition & 0 deletions backend/api/app/backfill_places.py
Original file line number Diff line number Diff line change
Expand Up @@ -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(),
)
Expand Down
2 changes: 2 additions & 0 deletions backend/api/app/bestof_routes.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
12 changes: 10 additions & 2 deletions backend/api/app/featured_routes.py
Original file line number Diff line number Diff line change
Expand Up @@ -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'),
Expand All @@ -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(
Expand All @@ -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'),
Expand All @@ -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")
Expand Down
3 changes: 3 additions & 0 deletions backend/api/app/hillview_routes.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
21 changes: 17 additions & 4 deletions backend/api/app/photo_routes.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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).
Expand All @@ -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(),
)
Expand Down Expand Up @@ -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,
Expand All @@ -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
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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,
Expand All @@ -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
Expand Down Expand Up @@ -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",
Expand Down Expand Up @@ -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
Expand Down
68 changes: 68 additions & 0 deletions backend/api/app/tests/unit/test_photo_notes.py
Original file line number Diff line number Diff line change
@@ -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
4 changes: 4 additions & 0 deletions backend/api/app/user_routes.py
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions backend/common/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -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, <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
Expand Down
4 changes: 3 additions & 1 deletion backend/tests/utils/secure_upload_utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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

Expand Down