Skip to content
Merged
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
2 changes: 1 addition & 1 deletion strr-api/pyproject.toml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
[tool.poetry]
name = "strr-api"
version = "0.1.16"
version = "0.1.17"
description = ""
authors = ["thorwolpert <thor@wolpert.ca>"]
license = "BSD 3-Clause"
Expand Down
16 changes: 16 additions & 0 deletions strr-api/src/strr_api/models/application.py
Original file line number Diff line number Diff line change
Expand Up @@ -599,4 +599,20 @@ def to_dict(application: Application) -> dict:
application_dict["header"]["nocStartDate"] = application.noc.start_date.strftime("%Y-%m-%d")
application_dict["header"]["nocEndDate"] = application.noc.end_date.strftime("%Y-%m-%d")

# Set addedOn for registration documents (application-stage docs live only in application_json, not in DB)
if registration_docs := application_dict.get("registration", {}).get("documents"):
# First: use stored addedOn or uploadDate so application-stage docs show a date when we have one
for doc_item in registration_docs:
doc_item["addedOn"] = doc_item.get("addedOn") or doc_item.get("uploadDate")
# Then: overwrite with DB (created/added_on) when we have a live registration
if application.registration and application.registration.documents:
file_key_to_added_on = {}
for doc in application.registration.documents:
added_on_value = doc.added_on if doc.added_on is not None else getattr(doc, "created", None)
if added_on_value is not None:
file_key_to_added_on[doc.path] = added_on_value.isoformat()
for doc_item in registration_docs:
file_key = doc_item.get("fileKey")
doc_item["addedOn"] = file_key_to_added_on.get(file_key) if file_key else doc_item.get("addedOn")

return application_dict
11 changes: 9 additions & 2 deletions strr-api/src/strr_api/resources/application.py
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@

import logging
import traceback
from datetime import datetime, timezone
from http import HTTPStatus
from io import BytesIO
from typing import Optional
Expand Down Expand Up @@ -341,7 +342,10 @@ def get_application_details(application_number):
application = ApplicationService.get_application(application_number=application_number, account_id=account_id)
if not application:
return error_response(http_status=HTTPStatus.NOT_FOUND, message=ErrorMessage.APPLICATION_NOT_FOUND.value)
return jsonify(ApplicationService.serialize(application)), HTTPStatus.OK
app_dict = ApplicationService.serialize(application)
# Only fetch GCP blob creation times when viewing a single application (not when listing)
ApplicationService.enrich_document_added_on_from_gcp(app_dict)
return jsonify(app_dict), HTTPStatus.OK
except AuthException as auth_exception:
return exception_response(auth_exception)

Expand Down Expand Up @@ -803,7 +807,10 @@ def update_registration_supporting_document(application_number):
document = DocumentService.upload_document(filename, file.content_type, file.read())
document["documentType"] = document_type
document["uploadStep"] = upload_step
document["uploadDate"] = upload_date
# Store upload date for application-stage docs (no registration yet, so not in documents table)
now_iso = datetime.now(timezone.utc).isoformat()
document["uploadDate"] = upload_date if upload_date else now_iso
document["addedOn"] = now_iso

application = ApplicationService.update_document_list(application=application, document=document)

Expand Down
4 changes: 3 additions & 1 deletion strr-api/src/strr_api/responses/DocumentResponse.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,10 +21,12 @@ class Document(BaseModel):
@classmethod
def from_db(cls, source: models.Document):
"""Return a Document object from a database model."""
# Use added_on when set, otherwise document upload time from documents.created
added_on = source.added_on if source.added_on is not None else getattr(source, "created", None)
return cls(
registrationId=source.eligibility.registration_id,
documentId=source.id,
fileName=source.file_name,
fileType=source.file_type,
addedOn=source.added_on,
addedOn=added_on,
)
4 changes: 3 additions & 1 deletion strr-api/src/strr_api/responses/RegistrationSerializer.py
Original file line number Diff line number Diff line change
Expand Up @@ -82,13 +82,15 @@ def serialize(cls, registration: Registration):
documents = []
if registration.documents:
for doc in registration.documents:
# Use added_on when set, otherwise document upload time from documents.created
added_on_value = doc.added_on if doc.added_on is not None else getattr(doc, "created", None)
documents.append(
{
"fileKey": doc.path,
"fileName": doc.file_name,
"fileType": doc.file_type,
"documentType": doc.document_type,
"addedOn": doc.added_on.isoformat() if doc.added_on else None,
"addedOn": added_on_value.isoformat() if added_on_value else None,
}
)
registration_data["documents"] = documents
Expand Down
19 changes: 19 additions & 0 deletions strr-api/src/strr_api/services/application_service.py
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,7 @@
from strr_api.models.rental import PropertyContact
from strr_api.services.email_service import EmailService
from strr_api.services.events_service import EventsService
from strr_api.services.gcp_storage_service import GCPStorageService
from strr_api.services.registration_service import RegistrationService
from strr_api.services.user_service import UserService

Expand Down Expand Up @@ -101,6 +102,24 @@ def serialize(application: Application) -> dict:
)
return app_dict

@staticmethod
def enrich_document_added_on_from_gcp(app_dict: dict) -> dict:
"""
For application-stage docs with no addedOn, set addedOn from GCP blob creation time.
Call this only when returning a single application (GET /applications/<id>), not when listing,
to avoid N GCP calls per page.
"""
registration_docs = app_dict.get("registration", {}).get("documents", [])
for doc_item in registration_docs:
if doc_item.get("addedOn"):
continue
file_key = doc_item.get("fileKey")
if file_key:
created_iso = GCPStorageService.get_registration_document_creation_time(file_key)
if created_iso:
doc_item["addedOn"] = created_iso
return app_dict

@staticmethod
def save_application(account_id: int, request_json: dict, application: Application) -> Application:
"""Saves an application to db."""
Expand Down
17 changes: 17 additions & 0 deletions strr-api/src/strr_api/services/gcp_storage_service.py
Original file line number Diff line number Diff line change
Expand Up @@ -108,6 +108,23 @@ def fetch_registration_document(cls, blob_name):
logger.error(traceback.format_exc())
raise ExternalServiceException(message="Error fetching registration document from gcp bucket.") from e

@classmethod
def get_registration_document_creation_time(cls, blob_name):
"""
Return the blob creation time (when uploaded to GCP) as ISO string, or None if not found/error.
Used to expose addedOn for application-stage documents that have no date in application_json.
"""
try:
registration_documents_bucket = cls.registration_documents_bucket()
blob = registration_documents_bucket.blob(blob_name)
blob.reload()
if blob.time_created:
return blob.time_created.isoformat()
return None
except Exception as e:
logger.debug("Could not get creation time for blob %s: %s", blob_name, e)
return None

@classmethod
def upload_file(cls, file_type, file_contents, bucket_id):
"""Save the document to the specified bucket."""
Expand Down
107 changes: 107 additions & 0 deletions strr-api/tests/unit/resources/test_registration_applications.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
from strr_api.enums.enum import PaymentStatus, RegistrationStatus
from strr_api.models import Application, Events
from strr_api.models.application import ApplicationSerializer
from strr_api.services import ApplicationService
from tests.unit.utils.auth_helpers import PUBLIC_USER, STRR_EXAMINER, create_header

CREATE_HOST_REGISTRATION_REQUEST = os.path.join(
Expand Down Expand Up @@ -480,6 +481,112 @@ def test_post_and_delete_registration_documents(session, client, jwt):
assert rv.status_code == HTTPStatus.NO_CONTENT


@patch("strr_api.services.strr_pay.create_invoice", return_value=MOCK_INVOICE_RESPONSE)
def test_put_application_documents_includes_added_on(session, client, jwt):
"""PUT /applications/<id>/documents sets addedOn (and uploadDate) on the appended document."""
with open(CREATE_HOST_REGISTRATION_REQUEST) as f:
headers = create_header(jwt, [PUBLIC_USER], "Account-Id")
headers["Account-Id"] = ACCOUNT_ID
json_data = json.load(f)
rv = client.post("/applications", json=json_data, headers=headers)
response_json = rv.json
application_number = response_json.get("header").get("applicationNumber")

with patch(
"strr_api.services.gcp_storage_service.GCPStorageService.upload_registration_document",
return_value="put-doc-file-key-456",
):
with open(MOCK_DOCUMENT_UPLOAD, "rb") as df:
data = {
"file": (df, MOCK_DOCUMENT_UPLOAD),
"documentType": "BC_DRIVERS_LICENSE",
"uploadStep": "step1",
"uploadDate": "2025-06-01T12:00:00",
}
rv = client.put(
f"/applications/{application_number}/documents",
content_type="multipart/form-data",
data=data,
headers=headers,
)
assert rv.status_code == HTTPStatus.OK
response_json = rv.json
docs = response_json.get("registration", {}).get("documents", [])
doc_uploaded = next((d for d in docs if d.get("fileKey") == "put-doc-file-key-456"), None)
assert doc_uploaded is not None
assert doc_uploaded.get("addedOn") is not None
assert doc_uploaded.get("uploadDate") is not None

rv = client.get(f"/applications/{application_number}", headers=headers)
assert rv.status_code == HTTPStatus.OK
response_json = rv.json
docs = response_json.get("registration", {}).get("documents", [])
doc_uploaded = next((d for d in docs if d.get("fileKey") == "put-doc-file-key-456"), None)
assert doc_uploaded is not None
assert doc_uploaded.get("addedOn") is not None


@patch("strr_api.services.strr_pay.create_invoice", return_value=MOCK_INVOICE_RESPONSE)
def test_application_serializer_added_on_from_upload_date(mock_invoice, session, client, jwt):
"""ApplicationSerializer.to_dict sets addedOn from stored uploadDate when no registration."""
with open(CREATE_HOST_REGISTRATION_REQUEST) as f:
headers = create_header(jwt, [PUBLIC_USER], "Account-Id")
headers["Account-Id"] = ACCOUNT_ID
json_data = json.load(f)
json_data.setdefault("registration", {})["documents"] = [
{
"fileKey": "stored-doc-key",
"fileName": "stored.pdf",
"fileType": "application/pdf",
"documentType": "BC_DRIVERS_LICENSE",
"uploadDate": "2025-01-15T14:30:00",
}
]
rv = client.post("/applications", json=json_data, headers=headers)
assert rv.status_code == HTTPStatus.OK
application_number = rv.json.get("header").get("applicationNumber")

application = Application.find_by_application_number(application_number=application_number)
app_dict = ApplicationSerializer.to_dict(application)
docs = app_dict.get("registration", {}).get("documents", [])
doc_stored = next((d for d in docs if d.get("fileKey") == "stored-doc-key"), None)
assert doc_stored is not None
assert doc_stored.get("addedOn") == "2025-01-15T14:30:00"


@patch("strr_api.services.strr_pay.create_invoice", return_value=MOCK_INVOICE_RESPONSE)
@patch("strr_api.services.gcp_storage_service.GCPStorageService.get_registration_document_creation_time")
def test_application_serialize_gcp_fallback_for_missing_added_on(
mock_gcp_creation_time, mock_invoice, session, client, jwt
):
"""enrich_document_added_on_from_gcp (used only for GET single application) uses GCP when doc has no addedOn."""
mock_gcp_creation_time.return_value = "2025-02-01T10:00:00Z"
with open(CREATE_HOST_REGISTRATION_REQUEST) as f:
headers = create_header(jwt, [PUBLIC_USER], "Account-Id")
headers["Account-Id"] = ACCOUNT_ID
json_data = json.load(f)
json_data.setdefault("registration", {})["documents"] = [
{
"fileKey": "gcp-fallback-key",
"fileName": "no-date.pdf",
"fileType": "application/pdf",
"documentType": "BC_DRIVERS_LICENSE",
}
]
rv = client.post("/applications", json=json_data, headers=headers)
assert rv.status_code == HTTPStatus.OK
application_number = rv.json.get("header").get("applicationNumber")

application = Application.find_by_application_number(application_number=application_number)
app_dict = ApplicationService.serialize(application)
ApplicationService.enrich_document_added_on_from_gcp(app_dict)
docs = app_dict.get("registration", {}).get("documents", [])
doc_no_date = next((d for d in docs if d.get("fileKey") == "gcp-fallback-key"), None)
assert doc_no_date is not None
assert doc_no_date.get("addedOn") == "2025-02-01T10:00:00Z"
mock_gcp_creation_time.assert_called_once_with("gcp-fallback-key")


@patch("strr_api.services.strr_pay.create_invoice", return_value=MOCK_INVOICE_RESPONSE)
def test_search_applications(session, client, jwt):
with open(CREATE_HOST_REGISTRATION_MINIMUM_FIELDS_REQUEST) as f:
Expand Down
94 changes: 93 additions & 1 deletion strr-api/tests/unit/resources/test_registrations.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,8 @@
RegistrationType,
)
from strr_api.exceptions import ExternalServiceException
from strr_api.models import Application, Events, Registration, User
from strr_api.models import Application, Document, Events, Registration, User
from strr_api.responses import RegistrationSerializer
from tests.unit.utils.auth_helpers import PUBLIC_USER, STRR_EXAMINER, create_header
from tests.unit.utils.mocks import (
fake_document,
Expand Down Expand Up @@ -392,6 +393,97 @@ def test_get_registration_by_id_unauthorized(client):
assert rv.status_code == HTTPStatus.UNAUTHORIZED


@patch("strr_api.services.strr_pay.create_invoice", return_value=MOCK_INVOICE_RESPONSE)
def test_get_registration_by_id_includes_document_added_on(session, client, jwt):
"""GET /registrations/<id> returns documents with addedOn (from DB created when added_on is null)."""
with open(CREATE_HOST_REGISTRATION_REQUEST) as f:
headers = create_header(jwt, [PUBLIC_USER], "Account-Id")
headers["Account-Id"] = ACCOUNT_ID
json_data = json.load(f)
rv = client.post("/applications", json=json_data, headers=headers)
response_json = rv.json
application_number = response_json.get("header").get("applicationNumber")

application = Application.find_by_application_number(application_number=application_number)
application.payment_status = PaymentStatus.COMPLETED.value
application.status = Application.Status.FULL_REVIEW
application.save()

staff_headers = create_header(jwt, [STRR_EXAMINER], "Account-Id")
rv = client.put(f"/applications/{application_number}/assign", headers=staff_headers)
assert rv.status_code == HTTPStatus.OK
status_update_request = {"status": Application.Status.FULL_REVIEW_APPROVED}
rv = client.put(f"/applications/{application_number}/status", json=status_update_request, headers=staff_headers)
assert rv.status_code == HTTPStatus.OK
response_json = rv.json
registration_id = response_json.get("header").get("registrationId")

with patch(
"strr_api.services.gcp_storage_service.GCPStorageService.upload_registration_document",
return_value="test-file-key-123",
):
with open(MOCK_DOCUMENT_UPLOAD, "rb") as df:
data = {"file": (df, MOCK_DOCUMENT_UPLOAD), "documentType": "BC_DRIVERS_LICENSE"}
rv = client.post(
f"/registrations/{registration_id}/documents",
content_type="multipart/form-data",
data=data,
headers=staff_headers,
)
assert rv.status_code == HTTPStatus.CREATED

rv = client.get(f"/registrations/{registration_id}", headers=headers)
assert rv.status_code == HTTPStatus.OK
response_json = rv.json
documents = response_json.get("documents", [])
assert len(documents) >= 1
doc_with_key = next((d for d in documents if d.get("fileKey") == "test-file-key-123"), None)
assert doc_with_key is not None
assert doc_with_key.get("addedOn") is not None


@patch.object(RegistrationSerializer, "populate_host_registration_details")
def test_registration_serializer_document_added_on_uses_created_when_added_on_null(mock_populate, session):
"""RegistrationSerializer uses doc.created for addedOn when doc.added_on is null."""
user = User(
username="serializer_test_user",
firstname="Test",
lastname="User",
iss="test",
sub="sub_serializer",
idp_userid="serializer_test_id",
login_source="test",
)
user.save()
registration = Registration(
user_id=user.id,
sbc_account_id=ACCOUNT_ID,
status=RegistrationStatus.ACTIVE,
registration_type=RegistrationType.HOST.value,
registration_number="H9999999",
start_date=datetime.now(),
expiry_date=datetime.now() + timedelta(days=365),
)
registration.save()
doc = Document(
registration_id=registration.id,
file_name="test.pdf",
file_type="application/pdf",
path="test-path-key",
document_type=Document.DocumentType.BC_DRIVERS_LICENSE,
added_on=None,
)
doc.save()
# Ensure doc has created (from BaseModel)
registration.documents = [doc]
# Minimal registration has no rental_property; avoid populate_host_registration_details
result = RegistrationSerializer.serialize(registration)
assert "documents" in result
assert len(result["documents"]) == 1
assert result["documents"][0].get("addedOn") is not None
assert result["documents"][0]["fileKey"] == "test-path-key"


@patch("strr_api.services.strr_pay.create_invoice", return_value=MOCK_INVOICE_RESPONSE)
def test_cancel_registration(session, client, jwt):
with open(CREATE_HOST_REGISTRATION_REQUEST) as f:
Expand Down
Loading
Loading