Skip to content

Commit 91e2c71

Browse files
authored
Merge pull request #334 from hancocb/feature/annotations-mvp
[Feature] Add map annotations with tags, attachments, and visibility controls
2 parents 193e9d1 + 3458545 commit 91e2c71

80 files changed

Lines changed: 5865 additions & 64 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.
Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
"""add style to annotations
2+
3+
Revision ID: 4109959ebc3e
4+
Revises: 88583aa69cf0
5+
Create Date: 2026-03-20 00:33:00.720137
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 = '4109959ebc3e'
14+
down_revision: str | None = '88583aa69cf0'
15+
branch_labels: str | None = None
16+
depends_on: str | None = None
17+
18+
19+
def upgrade() -> None:
20+
op.add_column('annotations', sa.Column('style', postgresql.JSONB(astext_type=sa.Text()), nullable=True))
21+
22+
23+
def downgrade() -> None:
24+
op.drop_column('annotations', 'style')
Lines changed: 214 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,214 @@
1+
"""add annotation and tag tables
2+
3+
Revision ID: 88583aa69cf0
4+
Revises: 560e396bce4f
5+
Create Date: 2025-09-15 14:30:55.548283
6+
7+
"""
8+
9+
from alembic import op
10+
import sqlalchemy as sa
11+
from geoalchemy2 import Geometry
12+
from sqlalchemy.dialects import postgresql
13+
14+
# revision identifiers, used by Alembic.
15+
revision = "88583aa69cf0"
16+
down_revision: str | None = "560e396bce4f"
17+
branch_labels: str | None = None
18+
depends_on: str | None = None
19+
20+
21+
def upgrade() -> None:
22+
# ### commands auto generated by Alembic - please adjust! ###
23+
op.create_table(
24+
"tags",
25+
sa.Column("id", sa.UUID(), nullable=False),
26+
sa.Column("name", sa.String(length=255), nullable=False),
27+
sa.Column(
28+
"created_at",
29+
sa.DateTime(timezone=True),
30+
server_default=sa.text("TIMEZONE('utc', CURRENT_TIMESTAMP)"),
31+
nullable=False,
32+
),
33+
sa.Column(
34+
"updated_at",
35+
sa.DateTime(timezone=True),
36+
server_default=sa.text("TIMEZONE('utc', CURRENT_TIMESTAMP)"),
37+
nullable=False,
38+
),
39+
sa.PrimaryKeyConstraint("id"),
40+
)
41+
op.create_index(
42+
"uq_tag_name_ci",
43+
"tags",
44+
[sa.text("lower(name)")],
45+
unique=True,
46+
)
47+
op.create_geospatial_table(
48+
"annotations",
49+
sa.Column("id", sa.UUID(), nullable=False),
50+
sa.Column("description", sa.Text(), nullable=False),
51+
sa.Column(
52+
"geom",
53+
Geometry(
54+
srid=4326,
55+
spatial_index=False,
56+
from_text="ST_GeomFromEWKT",
57+
name="geometry",
58+
nullable=False,
59+
),
60+
nullable=False,
61+
),
62+
sa.Column(
63+
"created_at",
64+
sa.DateTime(timezone=True),
65+
server_default=sa.text("TIMEZONE('utc', CURRENT_TIMESTAMP)"),
66+
nullable=False,
67+
),
68+
sa.Column(
69+
"updated_at",
70+
sa.DateTime(timezone=True),
71+
server_default=sa.text("TIMEZONE('utc', CURRENT_TIMESTAMP)"),
72+
nullable=False,
73+
),
74+
sa.Column(
75+
"visibility",
76+
sa.Enum("OWNER", "PROJECT", name="visibility_scope"),
77+
server_default=sa.text("'OWNER'"),
78+
nullable=False,
79+
),
80+
sa.Column("data_product_id", sa.UUID(), nullable=False),
81+
sa.Column("created_by_id", sa.UUID(), nullable=True),
82+
sa.ForeignKeyConstraint(["created_by_id"], ["users.id"], ondelete="SET NULL"),
83+
sa.ForeignKeyConstraint(
84+
["data_product_id"], ["data_products.id"], ondelete="CASCADE"
85+
),
86+
sa.PrimaryKeyConstraint("id"),
87+
)
88+
op.create_geospatial_index(
89+
"idx_annotations_geom",
90+
"annotations",
91+
["geom"],
92+
unique=False,
93+
postgresql_using="gist",
94+
postgresql_ops={},
95+
)
96+
op.create_index(
97+
op.f("ix_annotations_created_by_id"),
98+
"annotations",
99+
["created_by_id"],
100+
unique=False,
101+
)
102+
op.create_index(
103+
op.f("ix_annotations_data_product_id"),
104+
"annotations",
105+
["data_product_id"],
106+
unique=False,
107+
)
108+
op.create_table(
109+
"annotation_attachments",
110+
sa.Column("id", sa.UUID(), nullable=False),
111+
sa.Column("original_filename", sa.String(length=255), nullable=False),
112+
sa.Column("filepath", sa.Text(), nullable=False),
113+
sa.Column("content_type", sa.String(length=127), nullable=False),
114+
sa.Column("size_bytes", sa.BigInteger(), nullable=False),
115+
sa.Column("width_px", sa.Integer(), nullable=True),
116+
sa.Column("height_px", sa.Integer(), nullable=True),
117+
sa.Column("duration_seconds", sa.Float(), nullable=True),
118+
sa.Column(
119+
"created_at",
120+
sa.DateTime(timezone=True),
121+
server_default=sa.text("TIMEZONE('utc', CURRENT_TIMESTAMP)"),
122+
nullable=False,
123+
),
124+
sa.Column(
125+
"updated_at",
126+
sa.DateTime(timezone=True),
127+
server_default=sa.text("TIMEZONE('utc', CURRENT_TIMESTAMP)"),
128+
nullable=False,
129+
),
130+
sa.Column("annotation_id", sa.UUID(), nullable=False),
131+
sa.CheckConstraint("size_bytes >= 0", name="check_size_bytes"),
132+
sa.ForeignKeyConstraint(
133+
["annotation_id"], ["annotations.id"], ondelete="CASCADE"
134+
),
135+
sa.PrimaryKeyConstraint("id"),
136+
)
137+
op.create_index(
138+
op.f("ix_annotation_attachments_annotation_id"),
139+
"annotation_attachments",
140+
["annotation_id"],
141+
unique=False,
142+
)
143+
op.create_table(
144+
"annotation_tags",
145+
sa.Column("id", sa.UUID(), nullable=False),
146+
sa.Column(
147+
"created_at",
148+
sa.DateTime(timezone=True),
149+
server_default=sa.text("TIMEZONE('utc', CURRENT_TIMESTAMP)"),
150+
nullable=False,
151+
),
152+
sa.Column(
153+
"updated_at",
154+
sa.DateTime(timezone=True),
155+
server_default=sa.text("TIMEZONE('utc', CURRENT_TIMESTAMP)"),
156+
nullable=False,
157+
),
158+
sa.Column("annotation_id", sa.UUID(), nullable=False),
159+
sa.Column("tag_id", sa.UUID(), nullable=False),
160+
sa.ForeignKeyConstraint(
161+
["annotation_id"], ["annotations.id"], ondelete="CASCADE"
162+
),
163+
sa.ForeignKeyConstraint(["tag_id"], ["tags.id"], ondelete="CASCADE"),
164+
sa.PrimaryKeyConstraint("id"),
165+
sa.UniqueConstraint(
166+
"annotation_id", "tag_id", name="uq_annotation_tag_annotation_id_tag_id"
167+
),
168+
)
169+
op.create_index(
170+
op.f("ix_annotation_tags_annotation_id"),
171+
"annotation_tags",
172+
["annotation_id"],
173+
unique=False,
174+
)
175+
op.create_index(
176+
op.f("ix_annotation_tags_tag_id"), "annotation_tags", ["tag_id"], unique=False
177+
)
178+
# ### end Alembic commands ###
179+
180+
181+
def downgrade() -> None:
182+
# ### commands auto generated by Alembic - please adjust! ###
183+
# Drop annotation_tags (no enum dependency there)
184+
op.drop_index(op.f("ix_annotation_tags_tag_id"), table_name="annotation_tags")
185+
op.drop_index(
186+
op.f("ix_annotation_tags_annotation_id"), table_name="annotation_tags"
187+
)
188+
op.drop_table("annotation_tags")
189+
190+
# Drop attachments
191+
op.drop_index(
192+
op.f("ix_annotation_attachments_annotation_id"),
193+
table_name="annotation_attachments",
194+
)
195+
op.drop_table("annotation_attachments")
196+
197+
# Drop indexes on annotations, then the spatial index, then the table itself
198+
op.drop_index(op.f("ix_annotations_data_product_id"), table_name="annotations")
199+
op.drop_index(op.f("ix_annotations_created_by_id"), table_name="annotations")
200+
op.drop_geospatial_index(
201+
"idx_annotations_geom",
202+
table_name="annotations",
203+
postgresql_using="gist",
204+
column_name="geom",
205+
)
206+
op.drop_geospatial_table("annotations")
207+
208+
# Now that no columns depend on the enum, drop it
209+
op.execute("DROP TYPE IF EXISTS public.visibility_scope")
210+
211+
# Finally drop tags
212+
op.drop_index("uq_tag_name_ci", table_name="tags")
213+
op.drop_table("tags")
214+
# ### end Alembic commands ###

backend/app/api/api_v1/api.py

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,8 @@
22

33
from app.api.api_v1.endpoints import (
44
admin,
5+
annotation_attachments,
6+
annotations,
57
auth,
68
file_permission,
79
flights,
@@ -65,6 +67,16 @@
6567
prefix="/projects/{project_id}/flights/{flight_id}/data_products",
6668
tags=["data_products"],
6769
)
70+
api_router.include_router(
71+
annotations.router,
72+
prefix="/projects/{project_id}/flights/{flight_id}/data_products/{data_product_id}/annotations",
73+
tags=["annotations"],
74+
)
75+
api_router.include_router(
76+
annotation_attachments.router,
77+
prefix="/projects/{project_id}/flights/{flight_id}/data_products/{data_product_id}/annotations/{annotation_id}/attachments",
78+
tags=["annotation_attachments"],
79+
)
6880
api_router.include_router(
6981
raw_data.router,
7082
prefix="/projects/{project_id}/flights/{flight_id}/raw_data",
Lines changed: 112 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,112 @@
1+
import logging
2+
import os
3+
from typing import Any
4+
from uuid import UUID
5+
6+
from fastapi import APIRouter, Depends, HTTPException, status
7+
from fastapi.responses import FileResponse
8+
from sqlalchemy.orm import Session
9+
10+
from app import crud
11+
from app.api import deps
12+
from app.core.config import settings
13+
from app.models.annotation_attachment import AnnotationAttachment
14+
from app.models.data_product import DataProduct
15+
from app.models.user import User
16+
from app.schemas.annotation_attachment import (
17+
AnnotationAttachment as AnnotationAttachmentSchema,
18+
)
19+
20+
router = APIRouter()
21+
22+
logger = logging.getLogger("__name__")
23+
24+
25+
def _get_absolute_filepath(relative_path: str) -> str:
26+
"""Convert a /static/... relative URL to an absolute filesystem path."""
27+
if os.environ.get("RUNNING_TESTS") == "1":
28+
static_dir = settings.TEST_STATIC_DIR
29+
else:
30+
static_dir = settings.STATIC_DIR
31+
return relative_path.replace("/static/", f"{static_dir}/", 1)
32+
33+
34+
def _get_verified_attachment(
35+
db: Session,
36+
annotation_id: UUID,
37+
attachment_id: UUID,
38+
data_product: DataProduct,
39+
) -> AnnotationAttachment:
40+
"""Look up an attachment and verify it belongs to the annotation and data product."""
41+
annotation = crud.annotation.get(db, id=annotation_id)
42+
if not annotation or annotation.data_product_id != data_product.id:
43+
raise HTTPException(
44+
status_code=status.HTTP_404_NOT_FOUND,
45+
detail="Annotation not found or does not belong to this data product",
46+
)
47+
48+
attachment = crud.annotation_attachment.get(db, id=attachment_id)
49+
if not attachment or attachment.annotation_id != annotation_id:
50+
raise HTTPException(
51+
status_code=status.HTTP_404_NOT_FOUND,
52+
detail="Attachment not found or does not belong to this annotation",
53+
)
54+
return attachment
55+
56+
57+
@router.get("/{attachment_id}/download")
58+
def download_annotation_attachment(
59+
annotation_id: UUID,
60+
attachment_id: UUID,
61+
data_product: DataProduct = Depends(deps.can_read_data_product),
62+
db: Session = Depends(deps.get_db),
63+
) -> Any:
64+
"""Download an annotation attachment by ID."""
65+
attachment = _get_verified_attachment(db, annotation_id, attachment_id, data_product)
66+
67+
abs_path = _get_absolute_filepath(attachment.filepath)
68+
if not os.path.exists(abs_path):
69+
raise HTTPException(
70+
status_code=status.HTTP_404_NOT_FOUND,
71+
detail="Attachment file not found on disk",
72+
)
73+
74+
return FileResponse(
75+
abs_path,
76+
filename=attachment.original_filename,
77+
media_type=attachment.content_type,
78+
)
79+
80+
81+
@router.delete(
82+
"/{attachment_id}",
83+
response_model=AnnotationAttachmentSchema,
84+
status_code=status.HTTP_200_OK,
85+
)
86+
def delete_annotation_attachment(
87+
annotation_id: UUID,
88+
attachment_id: UUID,
89+
current_user: User = Depends(deps.get_current_approved_user),
90+
data_product: DataProduct = Depends(deps.can_read_write_data_product),
91+
db: Session = Depends(deps.get_db),
92+
) -> Any:
93+
"""Delete an annotation attachment by ID."""
94+
attachment = _get_verified_attachment(db, annotation_id, attachment_id, data_product)
95+
96+
# Delete file from disk
97+
if attachment.filepath:
98+
abs_path = _get_absolute_filepath(attachment.filepath)
99+
if os.path.exists(abs_path):
100+
os.remove(abs_path)
101+
102+
# Delete DB record
103+
try:
104+
deleted = crud.annotation_attachment.remove(db, id=attachment_id)
105+
except Exception:
106+
logger.exception(f"Failed to delete attachment {attachment_id}")
107+
raise HTTPException(
108+
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
109+
detail="Unable to delete attachment",
110+
)
111+
112+
return deleted

0 commit comments

Comments
 (0)