mirror of
https://github.com/Hestia-Homes/Model.git
synced 2026-06-30 13:10:47 +00:00
Merge pull request #1060 from Hestia-Homes/feature/magicplan-trigger
MagicPlan lambda and trigger
This commit is contained in:
commit
bae0dc1ac6
24 changed files with 842 additions and 35 deletions
40
.github/workflows/deploy_terraform.yml
vendored
40
.github/workflows/deploy_terraform.yml
vendored
|
|
@ -537,11 +537,49 @@ jobs:
|
||||||
AWS_SECRET_ACCESS_KEY: ${{ secrets.DEV_AWS_SECRET_ACCESS_KEY }}
|
AWS_SECRET_ACCESS_KEY: ${{ secrets.DEV_AWS_SECRET_ACCESS_KEY }}
|
||||||
AWS_REGION: ${{ secrets.DEV_AWS_REGION }}
|
AWS_REGION: ${{ secrets.DEV_AWS_REGION }}
|
||||||
|
|
||||||
|
# ============================================================
|
||||||
|
# Build MagicPlan Lambda image
|
||||||
|
# ============================================================
|
||||||
|
magic_plan_image:
|
||||||
|
needs: [determine_stage, shared_terraform]
|
||||||
|
uses: ./.github/workflows/_build_image.yml
|
||||||
|
with:
|
||||||
|
ecr_repo: magic-plan-${{ needs.determine_stage.outputs.stage }}
|
||||||
|
dockerfile_path: backend/magic_plan/handler/Dockerfile
|
||||||
|
build_context: .
|
||||||
|
secrets:
|
||||||
|
AWS_ACCESS_KEY_ID: ${{ secrets.DEV_AWS_ACCESS_KEY_ID }}
|
||||||
|
AWS_SECRET_ACCESS_KEY: ${{ secrets.DEV_AWS_SECRET_ACCESS_KEY }}
|
||||||
|
AWS_REGION: ${{ secrets.DEV_AWS_REGION }}
|
||||||
|
|
||||||
|
# ============================================================
|
||||||
|
# Deploy MagicPlan Lambda
|
||||||
|
# ============================================================
|
||||||
|
magic_plan_lambda:
|
||||||
|
needs: [magic_plan_image, determine_stage]
|
||||||
|
uses: ./.github/workflows/_deploy_lambda.yml
|
||||||
|
with:
|
||||||
|
lambda_name: magic_plan
|
||||||
|
lambda_path: infrastructure/terraform/lambda/magic_plan
|
||||||
|
stage: ${{ needs.determine_stage.outputs.stage }}
|
||||||
|
ecr_repo: magic-plan-${{ needs.determine_stage.outputs.stage }}
|
||||||
|
image_digest: ${{ needs.magic_plan_image.outputs.image_digest }}
|
||||||
|
terraform_apply: ${{ needs.determine_stage.outputs.terraform_apply }}
|
||||||
|
secrets:
|
||||||
|
AWS_ACCESS_KEY_ID: ${{ secrets.DEV_AWS_ACCESS_KEY_ID }}
|
||||||
|
AWS_SECRET_ACCESS_KEY: ${{ secrets.DEV_AWS_SECRET_ACCESS_KEY }}
|
||||||
|
AWS_REGION: ${{ secrets.DEV_AWS_REGION }}
|
||||||
|
TF_VAR_db_host: ${{ secrets.DEV_DB_HOST }}
|
||||||
|
TF_VAR_db_name: ${{ secrets.DEV_DB_NAME }}
|
||||||
|
TF_VAR_db_port: ${{ secrets.DEV_DB_PORT }}
|
||||||
|
TF_VAR_magicplan_customer_id: ${{ secrets.MAGICPLAN_CUSTOMER_ID }}
|
||||||
|
TF_VAR_magicplan_api_key: ${{ secrets.MAGICPLAN_API_KEY }}
|
||||||
|
|
||||||
# ============================================================
|
# ============================================================
|
||||||
# Deploy Hubspot ETL Lambda
|
# Deploy Hubspot ETL Lambda
|
||||||
# ============================================================
|
# ============================================================
|
||||||
hubspot_etl_lambda:
|
hubspot_etl_lambda:
|
||||||
needs: [hubspot_etl_image, determine_stage, pashub_to_ara_lambda]
|
needs: [hubspot_etl_image, determine_stage, pashub_to_ara_lambda, magic_plan_lambda]
|
||||||
uses: ./.github/workflows/_deploy_lambda.yml
|
uses: ./.github/workflows/_deploy_lambda.yml
|
||||||
with:
|
with:
|
||||||
lambda_name: hubspot-etl-to-ara
|
lambda_name: hubspot-etl-to-ara
|
||||||
|
|
|
||||||
|
|
@ -39,6 +39,7 @@ class Settings(BaseSettings):
|
||||||
ENGINE_SQS_URL: str = "changeme"
|
ENGINE_SQS_URL: str = "changeme"
|
||||||
CATEGORISATION_SQS_URL: str = "changeme"
|
CATEGORISATION_SQS_URL: str = "changeme"
|
||||||
PASHUB_TO_ARA_SQS_URL: str = "changeme"
|
PASHUB_TO_ARA_SQS_URL: str = "changeme"
|
||||||
|
MAGICPLAN_SQS_URL: str = "changeme"
|
||||||
POSTCODE_SPLITTER_SQS_URL: str = "changeme"
|
POSTCODE_SPLITTER_SQS_URL: str = "changeme"
|
||||||
COMBINER_SQS_URL: str = "changeme"
|
COMBINER_SQS_URL: str = "changeme"
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -17,6 +17,7 @@ class FileTypeEnum(enum.Enum):
|
||||||
ECMK_SITE_NOTE = "ecmk_site_note"
|
ECMK_SITE_NOTE = "ecmk_site_note"
|
||||||
ECMK_RD_SAP_SITE_NOTE = "ecmk_rd_sap_site_note"
|
ECMK_RD_SAP_SITE_NOTE = "ecmk_rd_sap_site_note"
|
||||||
ECMK_SURVEY_XML = "ecmk_survey_xml"
|
ECMK_SURVEY_XML = "ecmk_survey_xml"
|
||||||
|
MAGIC_PLAN_JSON = "magic_plan_json"
|
||||||
|
|
||||||
|
|
||||||
class FileSourceEnum(enum.Enum):
|
class FileSourceEnum(enum.Enum):
|
||||||
|
|
@ -24,6 +25,7 @@ class FileSourceEnum(enum.Enum):
|
||||||
SHAREPOINT = "sharepoint"
|
SHAREPOINT = "sharepoint"
|
||||||
HUBSPOT = "hubspot"
|
HUBSPOT = "hubspot"
|
||||||
ECMK = "ecmk"
|
ECMK = "ecmk"
|
||||||
|
MAGIC_PLAN = "magic_plan"
|
||||||
|
|
||||||
|
|
||||||
class UploadedFile(Base):
|
class UploadedFile(Base):
|
||||||
|
|
|
||||||
|
|
@ -19,7 +19,8 @@ def handler(body: dict[str, Any], context: Any) -> str:
|
||||||
customer_id=settings.MAGICPLAN_CUSTOMER_ID,
|
customer_id=settings.MAGICPLAN_CUSTOMER_ID,
|
||||||
api_key=settings.MAGICPLAN_API_KEY,
|
api_key=settings.MAGICPLAN_API_KEY,
|
||||||
)
|
)
|
||||||
plan: Plan = MagicPlanService(client).run(payload.address, payload.uprn)
|
# TODO: read s3_bucket from env var so staging/prod use the correct bucket
|
||||||
|
plan: Plan = MagicPlanService(client, s3_bucket="retrofit-energy-assessments-dev").run(payload)
|
||||||
logger.info("Saved MagicPlan plan uid=%s", plan.uid)
|
logger.info("Saved MagicPlan plan uid=%s", plan.uid)
|
||||||
return plan.uid
|
return plan.uid
|
||||||
|
|
||||||
|
|
@ -28,7 +29,7 @@ if __name__ == "__main__":
|
||||||
event = {
|
event = {
|
||||||
"Records": [
|
"Records": [
|
||||||
{
|
{
|
||||||
"body": '{"address": "2 Laburnum Way Bromley BR2 8BZ"}',
|
"body": '{"address": "2 Laburnum Way Bromley BR2 8BZ", "hubspot_deal_id": "local-test-deal"}',
|
||||||
"messageId": "local-test",
|
"messageId": "local-test",
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
|
|
|
||||||
12
backend/magic_plan/handler/Dockerfile
Normal file
12
backend/magic_plan/handler/Dockerfile
Normal file
|
|
@ -0,0 +1,12 @@
|
||||||
|
FROM public.ecr.aws/lambda/python:3.11
|
||||||
|
|
||||||
|
WORKDIR /var/task
|
||||||
|
|
||||||
|
COPY backend/magic_plan/handler/requirements.txt .
|
||||||
|
RUN pip install --no-cache-dir -r requirements.txt
|
||||||
|
|
||||||
|
COPY utils/ utils/
|
||||||
|
COPY backend/ backend/
|
||||||
|
COPY datatypes/ datatypes/
|
||||||
|
|
||||||
|
CMD ["backend.magic_plan.handler.handler"]
|
||||||
7
backend/magic_plan/handler/requirements.txt
Normal file
7
backend/magic_plan/handler/requirements.txt
Normal file
|
|
@ -0,0 +1,7 @@
|
||||||
|
awslambdaric
|
||||||
|
requests
|
||||||
|
sqlalchemy==2.0.36
|
||||||
|
sqlmodel
|
||||||
|
psycopg2-binary==2.9.10
|
||||||
|
pydantic-settings==2.6.0
|
||||||
|
boto3==1.35.44
|
||||||
|
|
@ -17,8 +17,14 @@ class MagicPlanClient:
|
||||||
return PlansListResponse.model_validate(r.json()["data"])
|
return PlansListResponse.model_validate(r.json()["data"])
|
||||||
|
|
||||||
def get_plan(self, plan_id: str) -> MagicPlanPlan:
|
def get_plan(self, plan_id: str) -> MagicPlanPlan:
|
||||||
|
return MagicPlanPlan.model_validate(self._fetch_plan(plan_id).json()["data"])
|
||||||
|
|
||||||
|
def get_plan_raw(self, plan_id: str) -> bytes:
|
||||||
|
return self._fetch_plan(plan_id).content
|
||||||
|
|
||||||
|
def _fetch_plan(self, plan_id: str) -> requests.Response:
|
||||||
r = self._session.get(
|
r = self._session.get(
|
||||||
f"{_BASE_URL}/plans/{plan_id}", params={"key": self._api_key}
|
f"{_BASE_URL}/plans/{plan_id}", params={"key": self._api_key}
|
||||||
)
|
)
|
||||||
r.raise_for_status()
|
r.raise_for_status()
|
||||||
return MagicPlanPlan.model_validate(r.json()["data"])
|
return r
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,6 @@
|
||||||
|
import gzip
|
||||||
|
import json
|
||||||
|
from datetime import datetime, timezone
|
||||||
from typing import Optional
|
from typing import Optional
|
||||||
|
|
||||||
from datatypes.magicplan.api.response import (
|
from datatypes.magicplan.api.response import (
|
||||||
|
|
@ -10,33 +13,78 @@ from datatypes.magicplan.domain.models import Plan
|
||||||
|
|
||||||
from backend.app.db.connection import db_session
|
from backend.app.db.connection import db_session
|
||||||
from backend.app.db.functions.magic_plan_functions import save_plan
|
from backend.app.db.functions.magic_plan_functions import save_plan
|
||||||
|
from backend.app.db.models.uploaded_file import (
|
||||||
|
FileSourceEnum,
|
||||||
|
FileTypeEnum,
|
||||||
|
UploadedFile,
|
||||||
|
)
|
||||||
from backend.magic_plan.address_matcher import find_matching_plan
|
from backend.magic_plan.address_matcher import find_matching_plan
|
||||||
from backend.magic_plan.magic_plan_client import MagicPlanClient
|
from backend.magic_plan.magic_plan_client import MagicPlanClient
|
||||||
|
from backend.magic_plan.magic_plan_trigger_request import MagicPlanTriggerRequest
|
||||||
from utils.logger import setup_logger
|
from utils.logger import setup_logger
|
||||||
|
from utils.s3 import save_data_to_s3
|
||||||
|
|
||||||
logger = setup_logger()
|
logger = setup_logger()
|
||||||
|
|
||||||
|
|
||||||
class MagicPlanService:
|
class MagicPlanService:
|
||||||
def __init__(self, client: MagicPlanClient) -> None:
|
def __init__(self, client: MagicPlanClient, s3_bucket: str) -> None:
|
||||||
self._client = client
|
self._client = client
|
||||||
|
self._s3_bucket = s3_bucket
|
||||||
|
|
||||||
|
def run(self, request: MagicPlanTriggerRequest) -> Plan:
|
||||||
|
address = request.address
|
||||||
|
uprn = request.uprn
|
||||||
|
|
||||||
def run(self, address: str, uprn: Optional[str] = None) -> Plan:
|
|
||||||
if uprn is not None:
|
if uprn is not None:
|
||||||
logger.info("MagicPlanService.run uprn=%s", uprn)
|
logger.info("MagicPlanService.run uprn=%s", uprn)
|
||||||
|
|
||||||
plans_response: PlansListResponse = self._client.get_plans()
|
plans_response: PlansListResponse = self._client.get_plans()
|
||||||
matched: Optional[PlanSummary] = find_matching_plan(
|
matched: Optional[PlanSummary] = find_matching_plan(
|
||||||
plans_response.plans, address
|
plans_response.plans, address
|
||||||
) # TODO: use address2UPRN instead? or create AddressMatch domain class
|
)
|
||||||
|
|
||||||
if matched is None:
|
if matched is None:
|
||||||
raise ValueError(f"No MagicPlan found for address: {address!r}")
|
raise ValueError(f"No MagicPlan found for address: {address!r}")
|
||||||
|
|
||||||
magic_plan: MagicPlanPlan = self._client.get_plan(matched.id)
|
raw_bytes: bytes = self._client.get_plan_raw(matched.id)
|
||||||
|
magic_plan: MagicPlanPlan = MagicPlanPlan.model_validate(
|
||||||
|
json.loads(raw_bytes)["data"]
|
||||||
|
)
|
||||||
plan: Plan = map_plan(magic_plan)
|
plan: Plan = map_plan(magic_plan)
|
||||||
|
|
||||||
|
uploaded_file: UploadedFile = self._upload_raw_plan_json(
|
||||||
|
plan_id=matched.id,
|
||||||
|
raw_bytes=raw_bytes,
|
||||||
|
uprn=uprn,
|
||||||
|
hubspot_deal_id=request.hubspot_deal_id,
|
||||||
|
)
|
||||||
|
|
||||||
with db_session() as session:
|
with db_session() as session:
|
||||||
save_plan(session, plan)
|
save_plan(session, plan)
|
||||||
|
session.add(uploaded_file)
|
||||||
|
|
||||||
return plan
|
return plan
|
||||||
|
|
||||||
|
def _upload_raw_plan_json(
|
||||||
|
self,
|
||||||
|
plan_id: str,
|
||||||
|
raw_bytes: bytes,
|
||||||
|
uprn: Optional[str],
|
||||||
|
hubspot_deal_id: str,
|
||||||
|
) -> UploadedFile:
|
||||||
|
compressed = gzip.compress(raw_bytes)
|
||||||
|
if uprn is not None:
|
||||||
|
s3_key = f"documents/uprn/{uprn}/magic_plan_{plan_id}.json.gz"
|
||||||
|
else:
|
||||||
|
s3_key = f"documents/hubspot_deal_id/{hubspot_deal_id}/magic_plan_{plan_id}.json.gz"
|
||||||
|
save_data_to_s3(compressed, self._s3_bucket, s3_key)
|
||||||
|
return UploadedFile(
|
||||||
|
s3_file_bucket=self._s3_bucket,
|
||||||
|
s3_file_key=s3_key,
|
||||||
|
s3_upload_timestamp=datetime.now(timezone.utc),
|
||||||
|
uprn=int(uprn) if uprn is not None else None,
|
||||||
|
hubspot_deal_id=hubspot_deal_id,
|
||||||
|
file_source=FileSourceEnum.MAGIC_PLAN.value,
|
||||||
|
file_type=FileTypeEnum.MAGIC_PLAN_JSON.value,
|
||||||
|
)
|
||||||
|
|
|
||||||
|
|
@ -7,4 +7,5 @@ class MagicPlanTriggerRequest(BaseModel):
|
||||||
model_config = ConfigDict(extra="ignore")
|
model_config = ConfigDict(extra="ignore")
|
||||||
|
|
||||||
address: str
|
address: str
|
||||||
|
hubspot_deal_id: str
|
||||||
uprn: Optional[str] = None
|
uprn: Optional[str] = None
|
||||||
|
|
|
||||||
|
|
@ -172,3 +172,55 @@ def test_get_plan_propagates_http_error(
|
||||||
# Act / Assert
|
# Act / Assert
|
||||||
with pytest.raises(requests.HTTPError):
|
with pytest.raises(requests.HTTPError):
|
||||||
client.get_plan("some-id")
|
client.get_plan("some-id")
|
||||||
|
|
||||||
|
|
||||||
|
# --- get_plan_raw ---
|
||||||
|
|
||||||
|
|
||||||
|
def test_get_plan_raw_returns_bytes(
|
||||||
|
client: MagicPlanClient, mock_session: MagicMock
|
||||||
|
) -> None:
|
||||||
|
# Arrange
|
||||||
|
mock_session.get.return_value.content = b'{"data": "raw"}'
|
||||||
|
plan_id = "a7285ed1-878d-47eb-8aa6-85ef9e187516"
|
||||||
|
# Act
|
||||||
|
result = client.get_plan_raw(plan_id)
|
||||||
|
# Assert
|
||||||
|
assert isinstance(result, bytes)
|
||||||
|
|
||||||
|
|
||||||
|
def test_get_plan_raw_calls_correct_url(
|
||||||
|
client: MagicPlanClient, mock_session: MagicMock
|
||||||
|
) -> None:
|
||||||
|
# Arrange
|
||||||
|
mock_session.get.return_value.content = b"{}"
|
||||||
|
plan_id = "a7285ed1-878d-47eb-8aa6-85ef9e187516"
|
||||||
|
# Act
|
||||||
|
client.get_plan_raw(plan_id)
|
||||||
|
# Assert
|
||||||
|
mock_session.get.assert_called_once_with(
|
||||||
|
f"{BASE_URL}/plans/{plan_id}", params={"key": API_KEY}
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_get_plan_raw_calls_raise_for_status(
|
||||||
|
client: MagicPlanClient, mock_session: MagicMock
|
||||||
|
) -> None:
|
||||||
|
# Arrange
|
||||||
|
mock_session.get.return_value.content = b"{}"
|
||||||
|
# Act
|
||||||
|
client.get_plan_raw("a7285ed1-878d-47eb-8aa6-85ef9e187516")
|
||||||
|
# Assert
|
||||||
|
mock_session.get.return_value.raise_for_status.assert_called_once()
|
||||||
|
|
||||||
|
|
||||||
|
def test_get_plan_raw_propagates_http_error(
|
||||||
|
client: MagicPlanClient, mock_session: MagicMock
|
||||||
|
) -> None:
|
||||||
|
# Arrange
|
||||||
|
mock_session.get.return_value.raise_for_status.side_effect = requests.HTTPError(
|
||||||
|
"500"
|
||||||
|
)
|
||||||
|
# Act / Assert
|
||||||
|
with pytest.raises(requests.HTTPError):
|
||||||
|
client.get_plan_raw("some-id")
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
import json
|
import json
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from unittest.mock import MagicMock, patch
|
from unittest.mock import ANY, MagicMock, patch
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
|
|
@ -8,11 +8,18 @@ from datatypes.magicplan.api.response import MagicPlanPlan, PlanSummary
|
||||||
from datatypes.magicplan.domain.mapper import map_plan
|
from datatypes.magicplan.domain.mapper import map_plan
|
||||||
from datatypes.magicplan.domain.models import Plan
|
from datatypes.magicplan.domain.models import Plan
|
||||||
|
|
||||||
|
from backend.app.db.models.uploaded_file import (
|
||||||
|
FileSourceEnum,
|
||||||
|
FileTypeEnum,
|
||||||
|
UploadedFile,
|
||||||
|
)
|
||||||
from backend.magic_plan.magic_plan_client import MagicPlanClient
|
from backend.magic_plan.magic_plan_client import MagicPlanClient
|
||||||
from backend.magic_plan.magic_plan_service import MagicPlanService
|
from backend.magic_plan.magic_plan_service import MagicPlanService
|
||||||
|
from backend.magic_plan.magic_plan_trigger_request import MagicPlanTriggerRequest
|
||||||
|
|
||||||
FIXTURE_DIR = Path(__file__).parents[2] / "magic_plan"
|
FIXTURE_DIR = Path(__file__).parents[2] / "magic_plan"
|
||||||
PLAN_ID = "a7285ed1-878d-47eb-8aa6-85ef9e187516"
|
PLAN_ID = "a7285ed1-878d-47eb-8aa6-85ef9e187516"
|
||||||
|
S3_BUCKET = "test-bucket"
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture(scope="module")
|
@pytest.fixture(scope="module")
|
||||||
|
|
@ -41,11 +48,25 @@ def plan_summary() -> PlanSummary:
|
||||||
|
|
||||||
@pytest.fixture()
|
@pytest.fixture()
|
||||||
def mock_client() -> MagicMock:
|
def mock_client() -> MagicMock:
|
||||||
return MagicMock(spec=MagicPlanClient)
|
client = MagicMock(spec=MagicPlanClient)
|
||||||
|
client.get_plan_raw.return_value = (
|
||||||
|
FIXTURE_DIR / "magicplan_api_plan_response_example.json"
|
||||||
|
).read_bytes()
|
||||||
|
return client
|
||||||
|
|
||||||
|
|
||||||
def _make_service(mock_client: MagicMock) -> MagicPlanService:
|
def _make_service(mock_client: MagicMock) -> MagicPlanService:
|
||||||
return MagicPlanService(client=mock_client)
|
return MagicPlanService(client=mock_client, s3_bucket=S3_BUCKET)
|
||||||
|
|
||||||
|
|
||||||
|
def _make_request(
|
||||||
|
address: str = "2 Laburnum Way Bromley BR2 8BZ",
|
||||||
|
hubspot_deal_id: str = "deal-123",
|
||||||
|
uprn: str | None = None,
|
||||||
|
) -> MagicPlanTriggerRequest:
|
||||||
|
return MagicPlanTriggerRequest(
|
||||||
|
address=address, hubspot_deal_id=hubspot_deal_id, uprn=uprn
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
# --- no match ---
|
# --- no match ---
|
||||||
|
|
@ -57,7 +78,7 @@ def test_run_raises_when_no_plan_found(mock_client: MagicMock) -> None:
|
||||||
service = _make_service(mock_client)
|
service = _make_service(mock_client)
|
||||||
# Act / Assert
|
# Act / Assert
|
||||||
with pytest.raises(ValueError, match="No MagicPlan found"):
|
with pytest.raises(ValueError, match="No MagicPlan found"):
|
||||||
service.run("99 Nowhere Road London SW1A 1AA")
|
service.run(_make_request(address="99 Nowhere Road London SW1A 1AA"))
|
||||||
|
|
||||||
|
|
||||||
# --- match found ---
|
# --- match found ---
|
||||||
|
|
@ -78,10 +99,12 @@ def test_run_fetches_plan_with_matched_id(
|
||||||
return_value=plan_summary,
|
return_value=plan_summary,
|
||||||
), patch("backend.magic_plan.magic_plan_service.save_plan"), patch(
|
), patch("backend.magic_plan.magic_plan_service.save_plan"), patch(
|
||||||
"backend.magic_plan.magic_plan_service.db_session"
|
"backend.magic_plan.magic_plan_service.db_session"
|
||||||
|
), patch(
|
||||||
|
"backend.magic_plan.magic_plan_service.save_data_to_s3"
|
||||||
):
|
):
|
||||||
service.run("2 Laburnum Way Bromley BR2 8BZ")
|
service.run(_make_request())
|
||||||
# Assert
|
# Assert
|
||||||
mock_client.get_plan.assert_called_once_with(plan_summary.id)
|
mock_client.get_plan_raw.assert_called_once_with(plan_summary.id)
|
||||||
|
|
||||||
|
|
||||||
def test_run_returns_mapped_plan(
|
def test_run_returns_mapped_plan(
|
||||||
|
|
@ -99,8 +122,10 @@ def test_run_returns_mapped_plan(
|
||||||
return_value=plan_summary,
|
return_value=plan_summary,
|
||||||
), patch("backend.magic_plan.magic_plan_service.save_plan"), patch(
|
), patch("backend.magic_plan.magic_plan_service.save_plan"), patch(
|
||||||
"backend.magic_plan.magic_plan_service.db_session"
|
"backend.magic_plan.magic_plan_service.db_session"
|
||||||
|
), patch(
|
||||||
|
"backend.magic_plan.magic_plan_service.save_data_to_s3"
|
||||||
):
|
):
|
||||||
result = service.run("2 Laburnum Way Bromley BR2 8BZ")
|
result = service.run(_make_request())
|
||||||
# Assert
|
# Assert
|
||||||
assert isinstance(result, Plan)
|
assert isinstance(result, Plan)
|
||||||
assert result.uid == PLAN_ID
|
assert result.uid == PLAN_ID
|
||||||
|
|
@ -120,8 +145,10 @@ def test_run_calls_save_plan_with_mapped_plan(
|
||||||
return_value=plan_summary,
|
return_value=plan_summary,
|
||||||
), patch("backend.magic_plan.magic_plan_service.save_plan") as mock_save, patch(
|
), patch("backend.magic_plan.magic_plan_service.save_plan") as mock_save, patch(
|
||||||
"backend.magic_plan.magic_plan_service.db_session"
|
"backend.magic_plan.magic_plan_service.db_session"
|
||||||
|
), patch(
|
||||||
|
"backend.magic_plan.magic_plan_service.save_data_to_s3"
|
||||||
):
|
):
|
||||||
service.run("2 Laburnum Way Bromley BR2 8BZ")
|
service.run(_make_request())
|
||||||
# Assert — save_plan called with a Plan whose uid matches
|
# Assert — save_plan called with a Plan whose uid matches
|
||||||
call_args = mock_save.call_args
|
call_args = mock_save.call_args
|
||||||
saved_plan: Plan = call_args[0][1]
|
saved_plan: Plan = call_args[0][1]
|
||||||
|
|
@ -142,5 +169,105 @@ def test_run_accepts_uprn_without_error(
|
||||||
return_value=plan_summary,
|
return_value=plan_summary,
|
||||||
), patch("backend.magic_plan.magic_plan_service.save_plan"), patch(
|
), patch("backend.magic_plan.magic_plan_service.save_plan"), patch(
|
||||||
"backend.magic_plan.magic_plan_service.db_session"
|
"backend.magic_plan.magic_plan_service.db_session"
|
||||||
|
), patch(
|
||||||
|
"backend.magic_plan.magic_plan_service.save_data_to_s3"
|
||||||
):
|
):
|
||||||
service.run("2 Laburnum Way Bromley BR2 8BZ", uprn="100023336956")
|
service.run(_make_request(uprn="100023336956"))
|
||||||
|
|
||||||
|
|
||||||
|
# --- S3 upload ---
|
||||||
|
|
||||||
|
|
||||||
|
def test_run_uploads_to_s3_with_uprn_key(
|
||||||
|
mock_client: MagicMock,
|
||||||
|
api_magic_plan: MagicPlanPlan,
|
||||||
|
plan_summary: PlanSummary,
|
||||||
|
) -> None:
|
||||||
|
# Arrange
|
||||||
|
mock_client.get_plans.return_value.plans = [plan_summary]
|
||||||
|
request = _make_request(uprn="100023336956")
|
||||||
|
service = MagicPlanService(client=mock_client, s3_bucket=S3_BUCKET)
|
||||||
|
with patch(
|
||||||
|
"backend.magic_plan.magic_plan_service.find_matching_plan",
|
||||||
|
return_value=plan_summary,
|
||||||
|
), patch("backend.magic_plan.magic_plan_service.save_plan"), patch(
|
||||||
|
"backend.magic_plan.magic_plan_service.db_session"
|
||||||
|
), patch(
|
||||||
|
"backend.magic_plan.magic_plan_service.save_data_to_s3"
|
||||||
|
) as mock_s3:
|
||||||
|
# Act
|
||||||
|
service.run(request)
|
||||||
|
# Assert
|
||||||
|
mock_s3.assert_called_once_with(
|
||||||
|
ANY,
|
||||||
|
S3_BUCKET,
|
||||||
|
f"documents/uprn/100023336956/magic_plan_{plan_summary.id}.json.gz",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_run_uploads_to_s3_with_deal_id_key_when_uprn_absent(
|
||||||
|
mock_client: MagicMock,
|
||||||
|
api_magic_plan: MagicPlanPlan,
|
||||||
|
plan_summary: PlanSummary,
|
||||||
|
) -> None:
|
||||||
|
# Arrange
|
||||||
|
mock_client.get_plans.return_value.plans = [plan_summary]
|
||||||
|
mock_client.get_plan.return_value = api_magic_plan
|
||||||
|
request = _make_request(hubspot_deal_id="deal-456", uprn=None)
|
||||||
|
service = MagicPlanService(client=mock_client, s3_bucket=S3_BUCKET)
|
||||||
|
with patch(
|
||||||
|
"backend.magic_plan.magic_plan_service.find_matching_plan",
|
||||||
|
return_value=plan_summary,
|
||||||
|
), patch("backend.magic_plan.magic_plan_service.save_plan"), patch(
|
||||||
|
"backend.magic_plan.magic_plan_service.db_session"
|
||||||
|
), patch(
|
||||||
|
"backend.magic_plan.magic_plan_service.save_data_to_s3"
|
||||||
|
) as mock_s3:
|
||||||
|
# Act
|
||||||
|
service.run(request)
|
||||||
|
# Assert
|
||||||
|
mock_s3.assert_called_once_with(
|
||||||
|
ANY,
|
||||||
|
S3_BUCKET,
|
||||||
|
f"documents/hubspot_deal_id/deal-456/magic_plan_{plan_summary.id}.json.gz",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# --- UploadedFile record ---
|
||||||
|
|
||||||
|
|
||||||
|
def test_run_creates_uploaded_file_record(
|
||||||
|
mock_client: MagicMock,
|
||||||
|
api_magic_plan: MagicPlanPlan,
|
||||||
|
plan_summary: PlanSummary,
|
||||||
|
) -> None:
|
||||||
|
# Arrange
|
||||||
|
mock_client.get_plans.return_value.plans = [plan_summary]
|
||||||
|
mock_client.get_plan.return_value = api_magic_plan
|
||||||
|
request = _make_request(hubspot_deal_id="deal-789", uprn="100023336956")
|
||||||
|
service = MagicPlanService(client=mock_client, s3_bucket=S3_BUCKET)
|
||||||
|
mock_session = MagicMock()
|
||||||
|
with patch(
|
||||||
|
"backend.magic_plan.magic_plan_service.find_matching_plan",
|
||||||
|
return_value=plan_summary,
|
||||||
|
), patch("backend.magic_plan.magic_plan_service.save_plan"), patch(
|
||||||
|
"backend.magic_plan.magic_plan_service.db_session"
|
||||||
|
) as mock_db, patch(
|
||||||
|
"backend.magic_plan.magic_plan_service.save_data_to_s3"
|
||||||
|
):
|
||||||
|
mock_db.return_value.__enter__.return_value = mock_session
|
||||||
|
# Act
|
||||||
|
service.run(request)
|
||||||
|
# Assert
|
||||||
|
added_objects = [call.args[0] for call in mock_session.add.call_args_list]
|
||||||
|
uploaded_file = next(
|
||||||
|
(obj for obj in added_objects if isinstance(obj, UploadedFile)), None
|
||||||
|
)
|
||||||
|
assert uploaded_file is not None
|
||||||
|
assert uploaded_file.file_source == FileSourceEnum.MAGIC_PLAN.value
|
||||||
|
assert uploaded_file.file_type == FileTypeEnum.MAGIC_PLAN_JSON.value
|
||||||
|
assert uploaded_file.s3_file_bucket == S3_BUCKET
|
||||||
|
assert uploaded_file.s3_file_key == f"documents/uprn/100023336956/magic_plan_{plan_summary.id}.json.gz"
|
||||||
|
assert uploaded_file.s3_upload_timestamp is not None
|
||||||
|
assert uploaded_file.uprn == 100023336956
|
||||||
|
assert uploaded_file.hubspot_deal_id == "deal-789"
|
||||||
|
|
|
||||||
|
|
@ -6,17 +6,18 @@ from backend.magic_plan.magic_plan_trigger_request import MagicPlanTriggerReques
|
||||||
|
|
||||||
def test_valid_payload_with_address_only() -> None:
|
def test_valid_payload_with_address_only() -> None:
|
||||||
# Arrange
|
# Arrange
|
||||||
payload = {"address": "123 High St London SW1A 1AA"}
|
payload = {"address": "123 High St London SW1A 1AA", "hubspot_deal_id": "123456789"}
|
||||||
# Act
|
# Act
|
||||||
req = MagicPlanTriggerRequest.model_validate(payload)
|
req = MagicPlanTriggerRequest.model_validate(payload)
|
||||||
# Assert
|
# Assert
|
||||||
assert req.address == "123 High St London SW1A 1AA"
|
assert req.address == "123 High St London SW1A 1AA"
|
||||||
|
assert req.hubspot_deal_id == "123456789"
|
||||||
assert req.uprn is None
|
assert req.uprn is None
|
||||||
|
|
||||||
|
|
||||||
def test_valid_payload_with_uprn() -> None:
|
def test_valid_payload_with_uprn() -> None:
|
||||||
# Arrange
|
# Arrange
|
||||||
payload = {"address": "123 High St London SW1A 1AA", "uprn": "100023336956"}
|
payload = {"address": "123 High St London SW1A 1AA", "hubspot_deal_id": "123456789", "uprn": "100023336956"}
|
||||||
# Act
|
# Act
|
||||||
req = MagicPlanTriggerRequest.model_validate(payload)
|
req = MagicPlanTriggerRequest.model_validate(payload)
|
||||||
# Assert
|
# Assert
|
||||||
|
|
@ -25,7 +26,7 @@ def test_valid_payload_with_uprn() -> None:
|
||||||
|
|
||||||
def test_missing_address_raises() -> None:
|
def test_missing_address_raises() -> None:
|
||||||
# Arrange
|
# Arrange
|
||||||
payload = {"uprn": "100023336956"}
|
payload = {"hubspot_deal_id": "123456789", "uprn": "100023336956"}
|
||||||
# Act / Assert
|
# Act / Assert
|
||||||
with pytest.raises(ValidationError):
|
with pytest.raises(ValidationError):
|
||||||
MagicPlanTriggerRequest.model_validate(payload)
|
MagicPlanTriggerRequest.model_validate(payload)
|
||||||
|
|
@ -33,8 +34,16 @@ def test_missing_address_raises() -> None:
|
||||||
|
|
||||||
def test_extra_fields_ignored() -> None:
|
def test_extra_fields_ignored() -> None:
|
||||||
# Arrange
|
# Arrange
|
||||||
payload = {"address": "123 High St London SW1A 1AA", "unknown_field": "whatever"}
|
payload = {"address": "123 High St London SW1A 1AA", "hubspot_deal_id": "123456789", "unknown_field": "whatever"}
|
||||||
# Act
|
# Act
|
||||||
req = MagicPlanTriggerRequest.model_validate(payload)
|
req = MagicPlanTriggerRequest.model_validate(payload)
|
||||||
# Assert
|
# Assert
|
||||||
assert req.address == "123 High St London SW1A 1AA"
|
assert req.address == "123 High St London SW1A 1AA"
|
||||||
|
|
||||||
|
|
||||||
|
def test_missing_hubspot_deal_id_raises() -> None:
|
||||||
|
# Arrange
|
||||||
|
payload = {"address": "123 High St London SW1A 1AA"}
|
||||||
|
# Act / Assert
|
||||||
|
with pytest.raises(ValidationError):
|
||||||
|
MagicPlanTriggerRequest.model_validate(payload)
|
||||||
|
|
|
||||||
|
|
@ -162,6 +162,14 @@ class HubspotDealDiffer:
|
||||||
|
|
||||||
return False
|
return False
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def check_for_magicplan_trigger(
|
||||||
|
new_deal: Dict[str, str], old_deal: HubspotDealData
|
||||||
|
) -> bool:
|
||||||
|
new_outcome = (new_deal.get("outcome") or "").lower()
|
||||||
|
old_outcome = (old_deal.outcome or "").lower()
|
||||||
|
return new_outcome == "surveyed" and old_outcome != "surveyed"
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def _has_valid_pashub_link(new_pashub_link: str) -> bool:
|
def _has_valid_pashub_link(new_pashub_link: str) -> bool:
|
||||||
return bool(new_pashub_link)
|
return bool(new_pashub_link)
|
||||||
|
|
@ -178,7 +186,7 @@ class HubspotDealDiffer:
|
||||||
def _coordination_completed(
|
def _coordination_completed(
|
||||||
new_deal: Dict[str, str], old_deal: HubspotDealData
|
new_deal: Dict[str, str], old_deal: HubspotDealData
|
||||||
) -> bool:
|
) -> bool:
|
||||||
new_status: str = new_deal.get("coordination_status") or ""
|
new_status: str = new_deal.get("coordination_status__stage_1_") or ""
|
||||||
return (
|
return (
|
||||||
new_status != ""
|
new_status != ""
|
||||||
and new_status.lower() in HubspotDealDiffer.COORDINATION_COMPLETE
|
and new_status.lower() in HubspotDealDiffer.COORDINATION_COMPLETE
|
||||||
|
|
@ -187,7 +195,7 @@ class HubspotDealDiffer:
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def _design_completed(new_deal: Dict[str, str], old_deal: HubspotDealData) -> bool:
|
def _design_completed(new_deal: Dict[str, str], old_deal: HubspotDealData) -> bool:
|
||||||
new_status: str = new_deal.get("design_status") or ""
|
new_status: str = new_deal.get("retrofit_design_status") or ""
|
||||||
return (
|
return (
|
||||||
new_status != ""
|
new_status != ""
|
||||||
and new_status.lower() == HubspotDealDiffer.RETROFIT_DESIGN_COMPLETE
|
and new_status.lower() == HubspotDealDiffer.RETROFIT_DESIGN_COMPLETE
|
||||||
|
|
|
||||||
|
|
@ -56,6 +56,12 @@ def handler(body: dict[str, Any], context: Any) -> None:
|
||||||
f"Triggering Pas Hub file fetcher for HubSpot deal ID {hubspot_deal_id}"
|
f"Triggering Pas Hub file fetcher for HubSpot deal ID {hubspot_deal_id}"
|
||||||
)
|
)
|
||||||
_trigger_pashub_fetcher(sqs_client, hubspot_deal_id, hubspot_deal)
|
_trigger_pashub_fetcher(sqs_client, hubspot_deal_id, hubspot_deal)
|
||||||
|
|
||||||
|
if (hubspot_deal.get("outcome") or "").lower() == "surveyed":
|
||||||
|
logger.info(
|
||||||
|
f"Triggering MagicPlan fetcher for HubSpot deal ID {hubspot_deal_id}"
|
||||||
|
)
|
||||||
|
_trigger_magicplan_fetcher(sqs_client, hubspot_deal, listing, hubspot_deal_id)
|
||||||
else:
|
else:
|
||||||
# Deal already in db, check whether anything has changed
|
# Deal already in db, check whether anything has changed
|
||||||
logger.info(
|
logger.info(
|
||||||
|
|
@ -97,9 +103,34 @@ def handler(body: dict[str, Any], context: Any) -> None:
|
||||||
f"Not Triggering PasHub file fetcher for HubSpot deal ID {hubspot_deal_id}"
|
f"Not Triggering PasHub file fetcher for HubSpot deal ID {hubspot_deal_id}"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
if HubspotDealDiffer.check_for_magicplan_trigger(
|
||||||
|
new_deal=hubspot_deal, old_deal=db_deal
|
||||||
|
):
|
||||||
|
logger.info(
|
||||||
|
f"Triggering MagicPlan fetcher for HubSpot deal ID {hubspot_deal_id}"
|
||||||
|
)
|
||||||
|
_trigger_magicplan_fetcher(sqs_client, hubspot_deal, listing, hubspot_deal_id)
|
||||||
|
|
||||||
print("done")
|
print("done")
|
||||||
|
|
||||||
|
|
||||||
|
def _trigger_magicplan_fetcher(
|
||||||
|
sqs_client: Any, hubspot_deal: Dict[str, str], listing: Optional[dict[str, str]], hubspot_deal_id: str
|
||||||
|
) -> None:
|
||||||
|
message_body = {
|
||||||
|
"address": hubspot_deal.get("dealname"),
|
||||||
|
"hubspot_deal_id": hubspot_deal_id,
|
||||||
|
"uprn": listing.get("national_uprn") if listing else None,
|
||||||
|
}
|
||||||
|
response = sqs_client.send_message(
|
||||||
|
QueueUrl=get_settings().MAGICPLAN_SQS_URL,
|
||||||
|
MessageBody=json.dumps(message_body),
|
||||||
|
)
|
||||||
|
logger.info(
|
||||||
|
f"Sent message to MagicPlan queue. MessageId: {response['MessageId']}"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def _trigger_pashub_fetcher(
|
def _trigger_pashub_fetcher(
|
||||||
sqs_client: Any, deal_id: str, hubspot_deal: Dict[str, str]
|
sqs_client: Any, deal_id: str, hubspot_deal: Dict[str, str]
|
||||||
) -> None:
|
) -> None:
|
||||||
|
|
|
||||||
|
|
@ -109,7 +109,7 @@ def test_pashub_trigger__coordination_completed_and_pashub_link_set__returns_tru
|
||||||
new_deal = make_new_deal(
|
new_deal = make_new_deal(
|
||||||
deal_id,
|
deal_id,
|
||||||
pashub_link="www.google.co.uk",
|
pashub_link="www.google.co.uk",
|
||||||
coordination_status=coordination_status,
|
**{"coordination_status__stage_1_": coordination_status},
|
||||||
)
|
)
|
||||||
|
|
||||||
assert (
|
assert (
|
||||||
|
|
@ -156,7 +156,7 @@ def test_pashub_trigger__design_completed_and_pashub_link_set__returns_true() ->
|
||||||
new_deal = make_new_deal(
|
new_deal = make_new_deal(
|
||||||
deal_id,
|
deal_id,
|
||||||
pashub_link="www.google.co.uk",
|
pashub_link="www.google.co.uk",
|
||||||
design_status="uploaded",
|
retrofit_design_status="uploaded",
|
||||||
)
|
)
|
||||||
|
|
||||||
assert (
|
assert (
|
||||||
|
|
@ -177,7 +177,7 @@ def test_pashub_trigger__design_completed_and_pashub_link_not_set__returns_false
|
||||||
|
|
||||||
new_deal = make_new_deal(
|
new_deal = make_new_deal(
|
||||||
deal_id,
|
deal_id,
|
||||||
design_status="uploaded",
|
retrofit_design_status="uploaded",
|
||||||
)
|
)
|
||||||
|
|
||||||
assert (
|
assert (
|
||||||
|
|
@ -270,6 +270,79 @@ def test_pashub_trigger__coordination_design_lodgement_not_completed_and_pashub_
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ==========================
|
||||||
|
# MAGICPLAN TRIGGER TESTS
|
||||||
|
# ==========================
|
||||||
|
|
||||||
|
|
||||||
|
def test_magicplan_trigger__outcome_transitions_to_surveyed__returns_true() -> None:
|
||||||
|
deal_id = uuid.uuid4()
|
||||||
|
|
||||||
|
# Arrange
|
||||||
|
old_deal = make_old_deal(id=deal_id, outcome="assessed")
|
||||||
|
new_deal = make_new_deal(deal_id, outcome="surveyed")
|
||||||
|
|
||||||
|
# Act
|
||||||
|
result = HubspotDealDiffer.check_for_magicplan_trigger(
|
||||||
|
new_deal=new_deal,
|
||||||
|
old_deal=old_deal,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Assert
|
||||||
|
assert result is True
|
||||||
|
|
||||||
|
|
||||||
|
def test_magicplan_trigger__outcome_already_surveyed__returns_false() -> None:
|
||||||
|
deal_id = uuid.uuid4()
|
||||||
|
|
||||||
|
# Arrange
|
||||||
|
old_deal = make_old_deal(id=deal_id, outcome="surveyed")
|
||||||
|
new_deal = make_new_deal(deal_id, outcome="surveyed")
|
||||||
|
|
||||||
|
# Act
|
||||||
|
result = HubspotDealDiffer.check_for_magicplan_trigger(
|
||||||
|
new_deal=new_deal,
|
||||||
|
old_deal=old_deal,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Assert
|
||||||
|
assert result is False
|
||||||
|
|
||||||
|
|
||||||
|
def test_magicplan_trigger__outcome_transitions_to_non_surveyed__returns_false() -> None:
|
||||||
|
deal_id = uuid.uuid4()
|
||||||
|
|
||||||
|
# Arrange
|
||||||
|
old_deal = make_old_deal(id=deal_id, outcome="assessed")
|
||||||
|
new_deal = make_new_deal(deal_id, outcome="assessed")
|
||||||
|
|
||||||
|
# Act
|
||||||
|
result = HubspotDealDiffer.check_for_magicplan_trigger(
|
||||||
|
new_deal=new_deal,
|
||||||
|
old_deal=old_deal,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Assert
|
||||||
|
assert result is False
|
||||||
|
|
||||||
|
|
||||||
|
def test_magicplan_trigger__outcome_surveyed_uppercase__returns_true() -> None:
|
||||||
|
deal_id = uuid.uuid4()
|
||||||
|
|
||||||
|
# Arrange
|
||||||
|
old_deal = make_old_deal(id=deal_id, outcome="assessed")
|
||||||
|
new_deal = make_new_deal(deal_id, outcome="SURVEYED")
|
||||||
|
|
||||||
|
# Act
|
||||||
|
result = HubspotDealDiffer.check_for_magicplan_trigger(
|
||||||
|
new_deal=new_deal,
|
||||||
|
old_deal=old_deal,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Assert
|
||||||
|
assert result is True
|
||||||
|
|
||||||
|
|
||||||
# =======================
|
# =======================
|
||||||
# DB UPDATE TRIGGER TESTS
|
# DB UPDATE TRIGGER TESTS
|
||||||
# =======================
|
# =======================
|
||||||
|
|
|
||||||
227
etl/hubspot/tests/test_scraper_handler.py
Normal file
227
etl/hubspot/tests/test_scraper_handler.py
Normal file
|
|
@ -0,0 +1,227 @@
|
||||||
|
import json
|
||||||
|
import uuid
|
||||||
|
from typing import Any, Dict, Optional
|
||||||
|
from unittest.mock import MagicMock, patch
|
||||||
|
|
||||||
|
from backend.app.db.models.hubspot_deal_data import HubspotDealData
|
||||||
|
from etl.hubspot.scripts.scraper.main import handler
|
||||||
|
|
||||||
|
DEAL_NAME = "123 Main Street"
|
||||||
|
UPRN = "12345678"
|
||||||
|
DEAL_ID = "999"
|
||||||
|
PASHUB_LINK = "https://pashub.example.com/deal/999"
|
||||||
|
MAGICPLAN_QUEUE_URL = "https://sqs.eu-west-2.amazonaws.com/123/magic-plan-dev"
|
||||||
|
PASHUB_QUEUE_URL = "https://sqs.test/pashub"
|
||||||
|
|
||||||
|
|
||||||
|
def make_hubspot_deal(**kwargs: Any) -> Dict[str, Any]:
|
||||||
|
return {
|
||||||
|
"hs_object_id": DEAL_ID,
|
||||||
|
"dealname": DEAL_NAME,
|
||||||
|
"pashub_link": None,
|
||||||
|
**kwargs,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def make_db_deal(**kwargs: Any) -> HubspotDealData:
|
||||||
|
return HubspotDealData(
|
||||||
|
id=uuid.uuid4(),
|
||||||
|
deal_id=DEAL_ID,
|
||||||
|
**kwargs,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def run_handler(
|
||||||
|
hubspot_deal: Dict[str, Any],
|
||||||
|
db_deal: Optional[HubspotDealData],
|
||||||
|
listing: Optional[dict],
|
||||||
|
) -> MagicMock:
|
||||||
|
mock_sqs = MagicMock()
|
||||||
|
mock_sqs.send_message.return_value = {"MessageId": "test-id"}
|
||||||
|
|
||||||
|
with (
|
||||||
|
patch("etl.hubspot.scripts.scraper.main.HubspotDataToDb") as mock_db_cls,
|
||||||
|
patch("etl.hubspot.scripts.scraper.main.HubspotClient") as mock_hs_cls,
|
||||||
|
patch("etl.hubspot.scripts.scraper.main.boto3") as mock_boto3,
|
||||||
|
patch("etl.hubspot.scripts.scraper.main.get_settings") as mock_settings,
|
||||||
|
):
|
||||||
|
mock_db_cls.return_value.find_deal_with_deal_id.return_value = db_deal
|
||||||
|
mock_db_cls.return_value.upsert_deal.return_value = None
|
||||||
|
mock_hs_cls.return_value.get_deal_and_company_and_listing.return_value = (
|
||||||
|
hubspot_deal,
|
||||||
|
None,
|
||||||
|
listing,
|
||||||
|
)
|
||||||
|
mock_boto3.client.return_value = mock_sqs
|
||||||
|
mock_settings.return_value.MAGICPLAN_SQS_URL = MAGICPLAN_QUEUE_URL
|
||||||
|
mock_settings.return_value.PASHUB_TO_ARA_SQS_URL = PASHUB_QUEUE_URL
|
||||||
|
|
||||||
|
handler.__wrapped__({"hubspot_deal_id": DEAL_ID}, "")
|
||||||
|
|
||||||
|
return mock_sqs
|
||||||
|
|
||||||
|
|
||||||
|
# ====================================
|
||||||
|
# NEW DEAL PATH - MagicPlan trigger
|
||||||
|
# ====================================
|
||||||
|
|
||||||
|
|
||||||
|
def test_new_deal_surveyed__sends_magicplan_sqs() -> None:
|
||||||
|
# Arrange
|
||||||
|
hubspot_deal = make_hubspot_deal(outcome="surveyed")
|
||||||
|
listing = {"national_uprn": UPRN}
|
||||||
|
|
||||||
|
# Act
|
||||||
|
mock_sqs = run_handler(hubspot_deal=hubspot_deal, db_deal=None, listing=listing)
|
||||||
|
|
||||||
|
# Assert
|
||||||
|
mock_sqs.send_message.assert_called_once_with(
|
||||||
|
QueueUrl=MAGICPLAN_QUEUE_URL,
|
||||||
|
MessageBody=json.dumps(
|
||||||
|
{"address": DEAL_NAME, "hubspot_deal_id": DEAL_ID, "uprn": UPRN}
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_new_deal_not_surveyed__no_magicplan_sqs() -> None:
|
||||||
|
# Arrange
|
||||||
|
hubspot_deal = make_hubspot_deal(outcome="assessed")
|
||||||
|
|
||||||
|
# Act
|
||||||
|
mock_sqs = run_handler(hubspot_deal=hubspot_deal, db_deal=None, listing=None)
|
||||||
|
|
||||||
|
# Assert
|
||||||
|
mock_sqs.send_message.assert_not_called()
|
||||||
|
|
||||||
|
|
||||||
|
def test_new_deal_surveyed_no_listing__magicplan_sqs_uprn_is_null() -> None:
|
||||||
|
# Arrange
|
||||||
|
hubspot_deal = make_hubspot_deal(outcome="surveyed")
|
||||||
|
|
||||||
|
# Act
|
||||||
|
mock_sqs = run_handler(hubspot_deal=hubspot_deal, db_deal=None, listing=None)
|
||||||
|
|
||||||
|
# Assert
|
||||||
|
mock_sqs.send_message.assert_called_once_with(
|
||||||
|
QueueUrl=MAGICPLAN_QUEUE_URL,
|
||||||
|
MessageBody=json.dumps(
|
||||||
|
{"address": DEAL_NAME, "hubspot_deal_id": DEAL_ID, "uprn": None}
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ==========================================
|
||||||
|
# EXISTING DEAL PATH - MagicPlan trigger
|
||||||
|
# ==========================================
|
||||||
|
|
||||||
|
|
||||||
|
def test_existing_deal_surveyed_transition__sends_magicplan_sqs() -> None:
|
||||||
|
# Arrange
|
||||||
|
db_deal = make_db_deal(outcome="assessed")
|
||||||
|
hubspot_deal = make_hubspot_deal(outcome="surveyed")
|
||||||
|
listing = {"national_uprn": UPRN}
|
||||||
|
|
||||||
|
# Act
|
||||||
|
mock_sqs = run_handler(hubspot_deal=hubspot_deal, db_deal=db_deal, listing=listing)
|
||||||
|
|
||||||
|
# Assert
|
||||||
|
mock_sqs.send_message.assert_called_once_with(
|
||||||
|
QueueUrl=MAGICPLAN_QUEUE_URL,
|
||||||
|
MessageBody=json.dumps(
|
||||||
|
{"address": DEAL_NAME, "hubspot_deal_id": DEAL_ID, "uprn": UPRN}
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_existing_deal_already_surveyed__no_magicplan_sqs() -> None:
|
||||||
|
# Arrange
|
||||||
|
db_deal = make_db_deal(outcome="surveyed", dealname="Old Name")
|
||||||
|
hubspot_deal = make_hubspot_deal(outcome="surveyed", dealname="New Name")
|
||||||
|
|
||||||
|
# Act
|
||||||
|
mock_sqs = run_handler(hubspot_deal=hubspot_deal, db_deal=db_deal, listing=None)
|
||||||
|
|
||||||
|
# Assert
|
||||||
|
mock_sqs.send_message.assert_not_called()
|
||||||
|
|
||||||
|
|
||||||
|
# ====================================
|
||||||
|
# NEW DEAL PATH - PasHub trigger
|
||||||
|
# ====================================
|
||||||
|
|
||||||
|
|
||||||
|
def test_new_deal_with_pashub_link__sends_pashub_sqs() -> None:
|
||||||
|
# Arrange
|
||||||
|
hubspot_deal = make_hubspot_deal(pashub_link=PASHUB_LINK)
|
||||||
|
|
||||||
|
# Act
|
||||||
|
mock_sqs = run_handler(hubspot_deal=hubspot_deal, db_deal=None, listing=None)
|
||||||
|
|
||||||
|
# Assert
|
||||||
|
mock_sqs.send_message.assert_called_once_with(
|
||||||
|
QueueUrl=PASHUB_QUEUE_URL,
|
||||||
|
MessageBody=json.dumps(
|
||||||
|
{
|
||||||
|
"pashub_link": PASHUB_LINK,
|
||||||
|
"address": None,
|
||||||
|
"hubspot_deal_id": DEAL_ID,
|
||||||
|
"sharepoint_link": None,
|
||||||
|
"uprn": None,
|
||||||
|
"landlord_property_id": None,
|
||||||
|
"deal_stage": None,
|
||||||
|
}
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_new_deal_no_pashub_link__no_pashub_sqs() -> None:
|
||||||
|
# Arrange
|
||||||
|
hubspot_deal = make_hubspot_deal()
|
||||||
|
|
||||||
|
# Act
|
||||||
|
mock_sqs = run_handler(hubspot_deal=hubspot_deal, db_deal=None, listing=None)
|
||||||
|
|
||||||
|
# Assert
|
||||||
|
mock_sqs.send_message.assert_not_called()
|
||||||
|
|
||||||
|
|
||||||
|
# ==========================================
|
||||||
|
# EXISTING DEAL PATH - PasHub trigger
|
||||||
|
# ==========================================
|
||||||
|
|
||||||
|
|
||||||
|
def test_existing_deal_pashub_link_added__sends_pashub_sqs() -> None:
|
||||||
|
# Arrange
|
||||||
|
db_deal = make_db_deal(pashub_link=None)
|
||||||
|
hubspot_deal = make_hubspot_deal(pashub_link=PASHUB_LINK)
|
||||||
|
|
||||||
|
# Act
|
||||||
|
mock_sqs = run_handler(hubspot_deal=hubspot_deal, db_deal=db_deal, listing=None)
|
||||||
|
|
||||||
|
# Assert
|
||||||
|
mock_sqs.send_message.assert_called_once_with(
|
||||||
|
QueueUrl=PASHUB_QUEUE_URL,
|
||||||
|
MessageBody=json.dumps(
|
||||||
|
{
|
||||||
|
"pashub_link": PASHUB_LINK,
|
||||||
|
"address": None,
|
||||||
|
"hubspot_deal_id": DEAL_ID,
|
||||||
|
"sharepoint_link": None,
|
||||||
|
"uprn": None,
|
||||||
|
"landlord_property_id": None,
|
||||||
|
"deal_stage": None,
|
||||||
|
}
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_existing_deal_pashub_link_unchanged__no_pashub_sqs() -> None:
|
||||||
|
# Arrange
|
||||||
|
db_deal = make_db_deal(pashub_link=PASHUB_LINK, dealname="Old Name")
|
||||||
|
hubspot_deal = make_hubspot_deal(pashub_link=PASHUB_LINK, dealname="New Name")
|
||||||
|
|
||||||
|
# Act
|
||||||
|
mock_sqs = run_handler(hubspot_deal=hubspot_deal, db_deal=db_deal, listing=None)
|
||||||
|
|
||||||
|
# Assert
|
||||||
|
mock_sqs.send_message.assert_not_called()
|
||||||
|
|
@ -12,7 +12,16 @@ data "terraform_remote_state" "pashub_to_ara" {
|
||||||
config = {
|
config = {
|
||||||
bucket = "pashub-to-ara-terraform-state"
|
bucket = "pashub-to-ara-terraform-state"
|
||||||
key = "env:/${var.stage}/terraform.tfstate"
|
key = "env:/${var.stage}/terraform.tfstate"
|
||||||
region = "eu-west-2"
|
region = "eu-west-2"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
data "terraform_remote_state" "magic_plan" {
|
||||||
|
backend = "s3"
|
||||||
|
config = {
|
||||||
|
bucket = "magic-plan-hubspot-trigger-terraform-state"
|
||||||
|
key = "env:/${var.stage}/terraform.tfstate"
|
||||||
|
region = "eu-west-2"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -49,6 +58,7 @@ module "hubspot_deal_etl" {
|
||||||
HUBSPOT_API_KEY = var.hubspot_api_key
|
HUBSPOT_API_KEY = var.hubspot_api_key
|
||||||
|
|
||||||
PASHUB_TO_ARA_SQS_URL = data.terraform_remote_state.pashub_to_ara.outputs.pashub_to_ara_queue_url
|
PASHUB_TO_ARA_SQS_URL = data.terraform_remote_state.pashub_to_ara.outputs.pashub_to_ara_queue_url
|
||||||
|
MAGICPLAN_SQS_URL = data.terraform_remote_state.magic_plan.outputs.magic_plan_queue_url
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -76,4 +86,18 @@ module "hubspot_deal_etl_sqs_policy" {
|
||||||
resource "aws_iam_role_policy_attachment" "hubspot_deal_etl_sqs_send" {
|
resource "aws_iam_role_policy_attachment" "hubspot_deal_etl_sqs_send" {
|
||||||
role = module.hubspot_deal_etl.role_name
|
role = module.hubspot_deal_etl.role_name
|
||||||
policy_arn = module.hubspot_deal_etl_sqs_policy.policy_arn
|
policy_arn = module.hubspot_deal_etl_sqs_policy.policy_arn
|
||||||
|
}
|
||||||
|
|
||||||
|
module "hubspot_deal_etl_magicplan_sqs_policy" {
|
||||||
|
source = "../../modules/general_iam_policy"
|
||||||
|
|
||||||
|
policy_name = "hubspot-deal-etl-magicplan-sqs-send-${var.stage}"
|
||||||
|
policy_description = "Allow HubSpot ETL Lambda to send messages to MagicPlan queue"
|
||||||
|
actions = ["sqs:SendMessage"]
|
||||||
|
resources = [data.terraform_remote_state.magic_plan.outputs.magic_plan_queue_arn]
|
||||||
|
}
|
||||||
|
|
||||||
|
resource "aws_iam_role_policy_attachment" "hubspot_deal_etl_magicplan_sqs_send" {
|
||||||
|
role = module.hubspot_deal_etl.role_name
|
||||||
|
policy_arn = module.hubspot_deal_etl_magicplan_sqs_policy.policy_arn
|
||||||
}
|
}
|
||||||
46
infrastructure/terraform/lambda/magic_plan/main.tf
Normal file
46
infrastructure/terraform/lambda/magic_plan/main.tf
Normal file
|
|
@ -0,0 +1,46 @@
|
||||||
|
data "terraform_remote_state" "shared" {
|
||||||
|
backend = "s3"
|
||||||
|
config = {
|
||||||
|
bucket = "assessment-model-terraform-state"
|
||||||
|
key = "env:/${var.stage}/terraform.tfstate"
|
||||||
|
region = "eu-west-2"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
data "aws_secretsmanager_secret_version" "db_credentials" {
|
||||||
|
secret_id = "${var.stage}/assessment_model/db_credentials"
|
||||||
|
}
|
||||||
|
|
||||||
|
locals {
|
||||||
|
db_credentials = jsondecode(data.aws_secretsmanager_secret_version.db_credentials.secret_string)
|
||||||
|
}
|
||||||
|
|
||||||
|
resource "aws_iam_role_policy_attachment" "magic_plan_s3_write" {
|
||||||
|
role = module.lambda.role_name
|
||||||
|
policy_arn = data.terraform_remote_state.shared.outputs.energy_assessments_s3_write_arn
|
||||||
|
}
|
||||||
|
|
||||||
|
module "lambda" {
|
||||||
|
source = "../../modules/lambda_with_sqs"
|
||||||
|
|
||||||
|
name = "magic_plan"
|
||||||
|
stage = var.stage
|
||||||
|
|
||||||
|
image_uri = local.image_uri
|
||||||
|
|
||||||
|
maximum_concurrency = var.maximum_concurrency
|
||||||
|
reserved_concurrent_executions = var.reserved_concurrent_executions
|
||||||
|
batch_size = var.batch_size
|
||||||
|
|
||||||
|
environment = {
|
||||||
|
STAGE = var.stage
|
||||||
|
LOG_LEVEL = "info"
|
||||||
|
MAGICPLAN_CUSTOMER_ID = var.magicplan_customer_id
|
||||||
|
MAGICPLAN_API_KEY = var.magicplan_api_key
|
||||||
|
DB_USERNAME = local.db_credentials.db_assessment_model_username
|
||||||
|
DB_PASSWORD = local.db_credentials.db_assessment_model_password
|
||||||
|
DB_HOST = var.db_host
|
||||||
|
DB_NAME = var.db_name
|
||||||
|
DB_PORT = var.db_port
|
||||||
|
}
|
||||||
|
}
|
||||||
9
infrastructure/terraform/lambda/magic_plan/outputs.tf
Normal file
9
infrastructure/terraform/lambda/magic_plan/outputs.tf
Normal file
|
|
@ -0,0 +1,9 @@
|
||||||
|
output "magic_plan_queue_url" {
|
||||||
|
value = module.lambda.queue_url
|
||||||
|
description = "URL of the MagicPlan SQS queue"
|
||||||
|
}
|
||||||
|
|
||||||
|
output "magic_plan_queue_arn" {
|
||||||
|
value = module.lambda.queue_arn
|
||||||
|
description = "ARN of the MagicPlan SQS queue"
|
||||||
|
}
|
||||||
16
infrastructure/terraform/lambda/magic_plan/provider.tf
Normal file
16
infrastructure/terraform/lambda/magic_plan/provider.tf
Normal file
|
|
@ -0,0 +1,16 @@
|
||||||
|
terraform {
|
||||||
|
required_providers {
|
||||||
|
aws = {
|
||||||
|
source = "hashicorp/aws"
|
||||||
|
version = ">= 5.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
backend "s3" {
|
||||||
|
bucket = "magic-plan-hubspot-trigger-terraform-state"
|
||||||
|
key = "terraform.tfstate"
|
||||||
|
region = "eu-west-2"
|
||||||
|
}
|
||||||
|
|
||||||
|
required_version = ">= 1.2.0"
|
||||||
|
}
|
||||||
68
infrastructure/terraform/lambda/magic_plan/variables.tf
Normal file
68
infrastructure/terraform/lambda/magic_plan/variables.tf
Normal file
|
|
@ -0,0 +1,68 @@
|
||||||
|
variable "lambda_name" {
|
||||||
|
type = string
|
||||||
|
description = "Logical name of the lambda"
|
||||||
|
}
|
||||||
|
|
||||||
|
variable "stage" {
|
||||||
|
description = "Deployment stage (e.g. dev, prod)"
|
||||||
|
type = string
|
||||||
|
}
|
||||||
|
|
||||||
|
variable "ecr_repo_url" {
|
||||||
|
type = string
|
||||||
|
description = "ECR repository URL (no tag, no digest)"
|
||||||
|
}
|
||||||
|
|
||||||
|
variable "image_digest" {
|
||||||
|
type = string
|
||||||
|
description = "Image digest (sha256:...)"
|
||||||
|
}
|
||||||
|
|
||||||
|
variable "maximum_concurrency" {
|
||||||
|
type = number
|
||||||
|
default = null
|
||||||
|
description = "Maximum number of concurrent Lambda invocations from SQS (2-1000). null = no limit."
|
||||||
|
}
|
||||||
|
|
||||||
|
variable "reserved_concurrent_executions" {
|
||||||
|
type = number
|
||||||
|
default = 1
|
||||||
|
}
|
||||||
|
|
||||||
|
variable "batch_size" {
|
||||||
|
type = number
|
||||||
|
default = 1
|
||||||
|
}
|
||||||
|
|
||||||
|
locals {
|
||||||
|
image_uri = "${var.ecr_repo_url}@${var.image_digest}"
|
||||||
|
}
|
||||||
|
|
||||||
|
output "resolved_image_uri" {
|
||||||
|
value = local.image_uri
|
||||||
|
}
|
||||||
|
|
||||||
|
variable "magicplan_customer_id" {
|
||||||
|
type = string
|
||||||
|
sensitive = true
|
||||||
|
}
|
||||||
|
|
||||||
|
variable "magicplan_api_key" {
|
||||||
|
type = string
|
||||||
|
sensitive = true
|
||||||
|
}
|
||||||
|
|
||||||
|
variable "db_host" {
|
||||||
|
type = string
|
||||||
|
sensitive = true
|
||||||
|
}
|
||||||
|
|
||||||
|
variable "db_name" {
|
||||||
|
type = string
|
||||||
|
sensitive = true
|
||||||
|
}
|
||||||
|
|
||||||
|
variable "db_port" {
|
||||||
|
type = string
|
||||||
|
sensitive = true
|
||||||
|
}
|
||||||
|
|
@ -54,5 +54,5 @@ module "lambda" {
|
||||||
|
|
||||||
resource "aws_iam_role_policy_attachment" "pashub_to_ara_s3_write" {
|
resource "aws_iam_role_policy_attachment" "pashub_to_ara_s3_write" {
|
||||||
role = module.lambda.role_name
|
role = module.lambda.role_name
|
||||||
policy_arn = data.terraform_remote_state.shared.outputs.pashub_to_ara_s3_write_arn
|
policy_arn = data.terraform_remote_state.shared.outputs.energy_assessments_s3_write_arn
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -568,18 +568,18 @@ module "pashub_to_ara_registry" {
|
||||||
stage = var.stage
|
stage = var.stage
|
||||||
}
|
}
|
||||||
|
|
||||||
module "pashub_to_ara_s3_write" {
|
module "energy_assessments_s3_write" {
|
||||||
source = "../modules/s3_iam_policy"
|
source = "../modules/s3_iam_policy"
|
||||||
|
|
||||||
policy_name = "PashubToAraWriteS3"
|
policy_name = "EnergyAssessmentsWriteS3"
|
||||||
policy_description = "Allow PasHub to ARA Lambda to write to retrofit energy assessments bucket"
|
policy_description = "Allow lambdas to write to retrofit energy assessments bucket"
|
||||||
bucket_arns = ["arn:aws:s3:::retrofit-energy-assessments-${var.stage}"]
|
bucket_arns = ["arn:aws:s3:::retrofit-energy-assessments-${var.stage}"]
|
||||||
actions = ["s3:PutObject", "s3:AbortMultipartUpload"]
|
actions = ["s3:PutObject", "s3:AbortMultipartUpload"]
|
||||||
resource_paths = ["/*"]
|
resource_paths = ["/*"]
|
||||||
}
|
}
|
||||||
|
|
||||||
output "pashub_to_ara_s3_write_arn" {
|
output "energy_assessments_s3_write_arn" {
|
||||||
value = module.pashub_to_ara_s3_write.policy_arn
|
value = module.energy_assessments_s3_write.policy_arn
|
||||||
}
|
}
|
||||||
|
|
||||||
################################################
|
################################################
|
||||||
|
|
@ -745,4 +745,5 @@ module "magic_plan_client_registry" {
|
||||||
source = "../modules/container_registry"
|
source = "../modules/container_registry"
|
||||||
name = "magic-plan"
|
name = "magic-plan"
|
||||||
stage = var.stage
|
stage = var.stage
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,6 @@ pythonpath = .
|
||||||
log_cli = true
|
log_cli = true
|
||||||
log_cli_level = INFO
|
log_cli_level = INFO
|
||||||
addopts = --cov-report term-missing --cov=etl/epc --cov=recommendations --cov=backend --cov=etl/epc_clean --cov=etl/spatial
|
addopts = --cov-report term-missing --cov=etl/epc --cov=recommendations --cov=backend --cov=etl/epc_clean --cov=etl/spatial
|
||||||
testpaths = recommendations/tests backend/tests etl/epc/tests etl/epc_clean/tests etl/spatial/tests backend/condition/tests backend/address2UPRN/tests backend/onboarders/tests backend/categorisation/tests backend/export/tests etl/hubspot/tests backend/hubspot_trigger_orchestrator/tests datatypes/epc/schema/tests datatypes/epc/surveys/tests datatypes/epc/domain/tests backend/ecmk_fetcher/tests/ backend/pashub_fetcher/tests backend/documents_parser/tests backend/magic_plan/tests datatypes/magicplan/api/tests datatypes/magicplan/domain/tests backend/app/db/functions/tests
|
testpaths = recommendations/tests backend/tests etl/epc/tests etl/epc_clean/tests etl/spatial/tests backend/condition/tests backend/address2UPRN/tests backend/onboarders/tests backend/categorisation/tests backend/export/tests etl/hubspot/tests datatypes/epc/schema/tests datatypes/epc/surveys/tests datatypes/epc/domain/tests backend/ecmk_fetcher/tests/ backend/pashub_fetcher/tests backend/documents_parser/tests backend/magic_plan/tests datatypes/magicplan/api/tests datatypes/magicplan/domain/tests backend/app/db/functions/tests
|
||||||
markers =
|
markers =
|
||||||
integration: mark a test as an integration test
|
integration: mark a test as an integration test
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue