Skip to content

Commit 527d383

Browse files
authored
Add the presigned url functionality to celery (#2496)
This change: - adds the presigned S3 url functionality to celery - adds a db migration to update the reports table (the presigned url is really long so I had to make the url column bigger, and I added the language column while I was at it) - adds unit tests for the presigned url function - renames the last migration file where the filename didn't match the revision
1 parent 15d822e commit 527d383

File tree

6 files changed

+112
-11
lines changed

6 files changed

+112
-11
lines changed

app/aws/s3.py

Lines changed: 33 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@
1212

1313
FILE_LOCATION_STRUCTURE = "service-{}-notify/{}.csv"
1414
REPORTS_FILE_LOCATION_STRUCTURE = "service-{}/{}.csv"
15+
THREE_DAYS_IN_SECONDS = 3 * 24 * 60 * 60
1516

1617

1718
def get_s3_file(bucket_name, file_location):
@@ -150,14 +151,41 @@ def get_report_location(service_id, report_id):
150151

151152

152153
def upload_report_to_s3(service_id: str, report_id: str, file_data: bytes) -> str:
153-
location = get_report_location(service_id, report_id)
154+
object_key = get_report_location(service_id, report_id)
154155
utils_s3upload(
155156
filedata=file_data,
156157
region=current_app.config["AWS_REGION"],
157158
bucket_name=current_app.config["REPORTS_BUCKET_NAME"],
158-
file_location=location,
159+
file_location=object_key,
160+
)
161+
url = generate_presigned_url(
162+
bucket_name=current_app.config["REPORTS_BUCKET_NAME"],
163+
object_key=object_key,
164+
expiration=THREE_DAYS_IN_SECONDS,
159165
)
160-
# todo: generate a presigned url
161-
# https://docs.aws.amazon.com/AmazonS3/latest/userguide/ShareObjectPreSignedURL.html
162-
url = f"https://{current_app.config['REPORTS_BUCKET_NAME']}.s3.{current_app.config["AWS_REGION"]}.amazonaws.com/{location}"
163166
return url
167+
168+
169+
def generate_presigned_url(bucket_name: str, object_key: str, expiration: int = 3600) -> str:
170+
"""
171+
Generate a presigned URL to share an S3 object
172+
173+
:param bucket_name: string
174+
:param object_key: string
175+
:param expiration: Time in seconds for the presigned URL to remain valid
176+
:return: Presigned URL as string. If error, returns None.
177+
178+
Docs: https://docs.aws.amazon.com/AmazonS3/latest/userguide/ShareObjectPreSignedURL.html
179+
"""
180+
s3_client = client("s3", current_app.config["AWS_REGION"])
181+
try:
182+
response = s3_client.generate_presigned_url(
183+
"get_object",
184+
Params={"Bucket": bucket_name, "Key": object_key},
185+
ExpiresIn=expiration,
186+
)
187+
except botocore.exceptions.ClientError as e:
188+
current_app.logger.error(e)
189+
return ""
190+
191+
return response

app/models.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2731,8 +2731,9 @@ class Report(BaseModel):
27312731
nullable=False,
27322732
)
27332733
job_id = db.Column(UUID(as_uuid=True), db.ForeignKey("jobs.id"), nullable=True) # only set if report is for a bulk job
2734-
url = db.Column(db.String(255), nullable=True) # url to download the report from s3
2734+
url = db.Column(db.String(800), nullable=True) # url to download the report from s3
27352735
status = db.Column(db.String(255), nullable=False)
2736+
language = db.Column(db.String(2), nullable=True)
27362737

27372738
def serialize(self):
27382739
return {
Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
"""
2+
3+
Revision ID: 0478_update_reports_table
4+
Revises: 0477_add_created_by_ids
5+
Create Date: 2025-04-07 23:44:02.031724
6+
7+
"""
8+
from alembic import op
9+
import sqlalchemy as sa
10+
11+
revision = '0478_update_reports_table'
12+
down_revision = '0477_add_created_by_ids'
13+
14+
15+
def upgrade():
16+
op.add_column('reports', sa.Column('language', sa.String(length=2), nullable=True))
17+
op.alter_column('reports', 'url',
18+
existing_type=sa.VARCHAR(length=255),
19+
type_=sa.String(length=800),
20+
existing_nullable=True)
21+
22+
23+
def downgrade():
24+
op.alter_column('reports', 'url',
25+
existing_type=sa.String(length=800),
26+
type_=sa.VARCHAR(length=255),
27+
existing_nullable=True)
28+
op.drop_column('reports', 'language')

tests/app/aws/test_s3.py

Lines changed: 48 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -4,12 +4,14 @@
44

55
import pytest
66
import pytz
7+
from botocore.exceptions import ClientError
78
from flask import current_app
89
from freezegun import freeze_time
910
from tests.app.conftest import datetime_in_past
1011

1112
from app.aws.s3 import (
1213
filter_s3_bucket_objects_within_date_range,
14+
generate_presigned_url,
1315
get_list_of_files_by_suffix,
1416
get_s3_bucket_objects,
1517
get_s3_file,
@@ -247,20 +249,62 @@ def test_remove_jobs_from_s3(notify_api, mocker):
247249

248250
def test_upload_report_to_s3(notify_api, mocker):
249251
utils_mock = mocker.patch("app.aws.s3.utils_s3upload")
252+
presigned_url_mock = mocker.patch("app.aws.s3.generate_presigned_url")
250253
service_id = uuid.uuid4()
251254
report_id = uuid.uuid4()
252255
csv_data = b"foo"
253256
file_location = f"service-{service_id}/{report_id}.csv"
254-
url = upload_report_to_s3(service_id, report_id, csv_data)
257+
upload_report_to_s3(service_id, report_id, csv_data)
255258

256259
utils_mock.assert_called_once_with(
257260
filedata=csv_data,
258261
region=notify_api.config["AWS_REGION"],
259262
bucket_name=current_app.config["REPORTS_BUCKET_NAME"],
260263
file_location=file_location,
261264
)
262-
expected_url = (
263-
f"https://{current_app.config['REPORTS_BUCKET_NAME']}.s3.{current_app.config["AWS_REGION"]}.amazonaws.com/{file_location}"
265+
presigned_url_mock.assert_called_once_with(
266+
bucket_name=current_app.config["REPORTS_BUCKET_NAME"],
267+
object_key=file_location,
268+
expiration=259200, # 3 days
269+
)
270+
271+
272+
def test_generate_presigned_url_success(notify_api, mocker):
273+
s3_client_mock = mocker.patch("app.aws.s3.client")
274+
s3_client_mock.return_value.generate_presigned_url.return_value = "https://example.com/presigned-url"
275+
276+
bucket_name = "test-bucket"
277+
object_key = "test-object"
278+
expiration = 3600
279+
280+
url = generate_presigned_url(bucket_name, object_key, expiration)
281+
282+
s3_client_mock.assert_called_once_with("s3", notify_api.config["AWS_REGION"])
283+
s3_client_mock.return_value.generate_presigned_url.assert_called_once_with(
284+
"get_object",
285+
Params={"Bucket": bucket_name, "Key": object_key},
286+
ExpiresIn=expiration,
264287
)
288+
assert url == "https://example.com/presigned-url"
265289

266-
assert url == expected_url
290+
291+
def test_generate_presigned_url_error(notify_api, mocker):
292+
s3_client_mock = mocker.patch("app.aws.s3.client")
293+
s3_client_mock.return_value.generate_presigned_url.side_effect = ClientError(
294+
error_response={"Error": {"Code": "403", "Message": "Forbidden"}},
295+
operation_name="GeneratePresignedUrl",
296+
)
297+
298+
bucket_name = "test-bucket"
299+
object_key = "test-object"
300+
expiration = 3600
301+
302+
url = generate_presigned_url(bucket_name, object_key, expiration)
303+
304+
s3_client_mock.assert_called_once_with("s3", notify_api.config["AWS_REGION"])
305+
s3_client_mock.return_value.generate_presigned_url.assert_called_once_with(
306+
"get_object",
307+
Params={"Bucket": bucket_name, "Key": object_key},
308+
ExpiresIn=expiration,
309+
)
310+
assert not url

tests/app/dao/test_api_key_dao.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -64,7 +64,7 @@ def test_expire_api_key_should_update_the_api_key_and_create_history_record(noti
6464

6565
def test_last_used_should_update_the_api_key_and_not_create_history_record(notify_api, sample_api_key):
6666
last_used = datetime.utcnow()
67-
update_last_used_api_key(api_key_id=sample_api_key.id, last_used=last_used)
67+
update_last_used_api_key(sample_api_key.id, last_used)
6868
all_api_keys = get_model_api_keys(service_id=sample_api_key.service_id)
6969
assert len(all_api_keys) == 1
7070
assert all_api_keys[0].last_used_timestamp == last_used

0 commit comments

Comments
 (0)