From 58e27915cb8d05b6c9d824318806e51fd7d6a117 Mon Sep 17 00:00:00 2001 From: Jun-te Kim Date: Tue, 1 Jul 2025 23:36:31 +0000 Subject: [PATCH] save current progress --- etl/hubSpotClient/hubspot.py | 89 ++++++++++++++++++++++++-- etl/hubSpotClient/types.py | 31 +++++---- etl/hubspot_verification_to_db_load.py | 23 +++---- etl/scraper/scraper.py | 1 - etl/utils/utils.py | 55 ++++++++++------ 5 files changed, 149 insertions(+), 50 deletions(-) diff --git a/etl/hubSpotClient/hubspot.py b/etl/hubSpotClient/hubspot.py index a05c309..c2d6af6 100644 --- a/etl/hubSpotClient/hubspot.py +++ b/etl/hubSpotClient/hubspot.py @@ -7,7 +7,10 @@ import time from pydantic import ValidationError from etl.utils.logger import Logger import logging - +import traceback +from hubspot.crm.objects.notes import SimplePublicObjectInput as NoteInput +from hubspot.crm.associations import BatchInputPublicAssociation, PublicAssociation +import time class DealStage(Enum): @@ -41,6 +44,7 @@ class HubSpotClient(): except Exception as e: return "Unknown Deal" # Fallback if the deal name is not found + def get_listings_from_deals_id(self, deals_id): from hubspot.crm.objects import PublicObjectSearchRequest found_notes = [] @@ -205,6 +209,9 @@ class HubSpotClient(): for deal in found_deals: domna_id, landlord_id, uprn = self.get_domna_and_landlord_id(deal.id) try: + deal_name = deal.properties['dealname'] + self.logger.info(f"Validating <{deal_name}>") + input all_deals.append(SubmissionInfoFromDeal( deal_id= deal.properties["hs_object_id"], deal_name=deal.properties["dealname"], @@ -220,13 +227,46 @@ class HubSpotClient(): uprn = uprn, )) except Exception as e: + def format_error_note(e): + note_text = "⚠️ Error occurred while verifying deal data:

" + + if hasattr(e, "errors") and callable(e.errors): + note_text += "❌ Validation Errors:
" + for error in e.errors(): + loc = error.get('loc', 'N/A') + msg = error.get('msg', 'N/A') + error_type = error.get('type', 'N/A') + error_input = error.get('input', 'N/A') + + note_text += ( + f"• Field: {loc}
" + f"  - Message: {msg}
" + f"  - Type: {error_type}
" + f"  - Input: {error_input}

" + ) + else: + note_text += ( + "❗ Non-validation error:
" + f"
{str(e)}

" + ) + + note_text += ( + "🛠️ Please review this error and take necessary actions.
" + "Contact Jun-te if help is needed: +44 7519 530 549 or via Teams." + ) + + return note_text + deal_id = deal.properties['hs_object_id'] - self.logger.info(f"Deal <{deal_id}> not valid") + if hasattr(e, "errors"): + for error in e.errors(): + self.add_note_to_deal(deal_id, format_error_note(e)) + else: + self.logger.error(f"Non-validation error occurred: {str(e)}", exc_info=True) + + + self.logger.info(f"Deal name <{deal_name}> moving to 'needs additional information'") self.move_deals_to_different_stage([deal_id], DealStage.NEEDS_ADDITIONAL_INFORMATION_FROM_ASSESSOR.value) - - - - return all_deals @@ -250,3 +290,40 @@ class HubSpotClient(): simple_public_object_input=deal_properties ) self.logger.info(f"Deal {deal_id} moved to stage with ID {to_stage_id}.") + + + + def add_note_to_deal(self, deal_id, note_text): + try: + # Generate current time in milliseconds since epoch + hs_timestamp = int(time.time() * 1000) + + # Step 1: Create the note with hs_timestamp + note = NoteInput( + properties={ + "hs_note_body": note_text, + "hs_timestamp": hs_timestamp # Required field in your HubSpot setup + } + ) + created_note = self.client.crm.objects.notes.basic_api.create(note) + note_id = created_note.id + + # Step 2: Associate the note to the deal + association = PublicAssociation( + _from=note_id, + to=deal_id, + type="note_to_deal" + ) + + self.client.crm.associations.batch_api.create( + 'notes', + 'deals', + batch_input_public_association=BatchInputPublicAssociation( + inputs=[association] + ) + ) + + self.logger.info(f"📝 Note added to deal {deal_id}: {note_text}") + + except Exception as e: + self.logger.error(f"❌ Failed to add note to deal {deal_id}: {e}", exc_info=True) diff --git a/etl/hubSpotClient/types.py b/etl/hubSpotClient/types.py index 22ff6ff..e77acde 100644 --- a/etl/hubSpotClient/types.py +++ b/etl/hubSpotClient/types.py @@ -2,10 +2,14 @@ from sqlmodel import Field, SQLModel from sqlalchemy import Column from sqlalchemy.dialects.postgresql import UUID import uuid -from pydantic import Field, field_validator, ValidationError, model_validator +from pydantic import Field, field_validator, model_validator from etl.utils.utils import get_sharepoint_path from etl.scraper.scraper import SharePointScraper, SharePointInstaller + + + + def string_to_installer(installer): if installer.upper() == "J & J CRUMP": return SharePointInstaller.JJC @@ -40,19 +44,24 @@ class SubmissionInfoFromDeal(BaseModel): @field_validator('post_sap_score', 'no_of_wet_rooms') @classmethod - def must_be_non_negative(cls, v): + def must_be_non_negative(cls, v, info): if v < 0: - raise ValidationError("Must be non-negative for Post Sap Score") + raise ValueError(f"{info.field_name} must be non-negative") return v @model_validator(mode="after") def check_submission_folder_path(self): - path = get_sharepoint_path(self.submission_folder_path) - installer = string_to_installer(self.installer) - sp = SharePointScraper(installer) - files = sp.get_folders_in_path(path) - if "value" in files: - if len(files["value"]) > 0: + errors = [] + + try: + path = get_sharepoint_path(self.submission_folder_path) + installer = string_to_installer(self.installer) + sp = SharePointScraper(installer) + files = sp.get_folders_in_path(path) + + if "value" in files and len(files["value"]) > 0: return self - - raise RuntimeError("Sharepoint URL invalid") + raise ValueError(f"SharePoint folder is empty: {self.submission_folder_path}") + + except Exception as e: + raise ValueError(f"Error accessing SharePoint path: {self.submission_folder_path}. Error: {str(e)}") \ No newline at end of file diff --git a/etl/hubspot_verification_to_db_load.py b/etl/hubspot_verification_to_db_load.py index bb42922..5553196 100644 --- a/etl/hubspot_verification_to_db_load.py +++ b/etl/hubspot_verification_to_db_load.py @@ -1,18 +1,15 @@ import os -import pprint -from etl.hubSpotClient.hubspot import DealStage +from pprint import pprint -# Local development, comment this out to be production +os.environ["SHAREPOINT_CLIENT_ID"] = "895e3b77-b1d7-43ec-b18f-dcfe07cdfeaf" +os.environ["SHAREPOINT_CLIENT_SECRET"] = "SOf8Q~-is4wdQiqvEEm9FlJQRAY9ELGaj5Qz-a6E" +os.environ["SHAREPOINT_TENANT_ID"] = "c3f7519c-2719-4547-af04-6da6cbfd8f8f" +os.environ["SOUTH_COAST_INSULATION_SERVICE_SHAREPOINT_ID"] = "b5a51507-9427-4ee0-b03e-90ec7681e2d3" +os.environ["JJC_SERVICE_SHAREPOINT_ID"] = "7fdd0485-bbf3-4b29-b30f-98c81c2a6284" + +from etl.hubSpotClient.hubspot import DealStage, HubSpotClient +# Local development os.environ["DATABASE_URL"] = "postgresql://postgres:makingwarmhomes@db:5432/postgres" -from etl.db.hubSpotLoad import HubspotTodb - - -hubspotClient = HubspotTodb() +hubspotClient = HubSpotClient() deals = hubspotClient.get_deals_from_deal_stage(DealStage.SURVEYED_COMPLETE_NEEDS_SIGN_OFF) -pprint(deals) - - - - - diff --git a/etl/scraper/scraper.py b/etl/scraper/scraper.py index 6cbfd37..550e05d 100644 --- a/etl/scraper/scraper.py +++ b/etl/scraper/scraper.py @@ -13,7 +13,6 @@ from datetime import datetime, timedelta def previous_monday(): today = datetime.today() - print(f"Todays date is {today}") monday = today - timedelta(days=today.weekday()) # weekday() = 0 for Monday return f"W.C. {monday.strftime('%d.%m.%Y')}" diff --git a/etl/utils/utils.py b/etl/utils/utils.py index 286b645..584381a 100644 --- a/etl/utils/utils.py +++ b/etl/utils/utils.py @@ -1,23 +1,40 @@ from urllib.parse import unquote +class SharePointURLError(ValueError): + """Custom error for SharePoint URL parsing issues.""" + pass + + def get_sharepoint_path(url): - url_parts = url.split('/') - # Find the index of 'Forms' - forms_index = url_parts.index('Forms') - # Get the part after 'Forms' - after_forms = url_parts[forms_index + 1] - - # Find 'id=' and extract after it - if 'id=' in after_forms: - id_part = after_forms.split('id=')[1] - # Only keep the path before '&' (to ignore other parameters) - id_path = id_part.split('&')[0] - # Decode the path - decoded_path = unquote(id_path) - # Now, remove the leading '/sites/xxx/Shared Documents/' part - parts = decoded_path.split('Shared Documents') - if len(parts) > 1: - final_path = parts[1].strip('/') - return final_path + try: + url_parts = url.split('/') + + # Find the index of 'Forms' + forms_index = url_parts.index('Forms') + + # Get the part after 'Forms' + after_forms = url_parts[forms_index + 1] + + # Find 'id=' and extract after it + if 'id=' in after_forms: + id_part = after_forms.split('id=')[1] + + # Only keep the path before '&' (to ignore other parameters) + id_path = id_part.split('&')[0] + + # Decode the path + decoded_path = unquote(id_path) + + # Remove the leading '/sites/xxx/Shared Documents/' part + parts = decoded_path.split('Shared Documents') + + if len(parts) > 1: + final_path = parts[1].strip('/') + return final_path + else: + return decoded_path.strip('/') else: - return decoded_path.strip('/') \ No newline at end of file + raise SharePointURLError(f"The URL does not contain 'id=' parameter. URL: {url}") + + except (IndexError, ValueError) as e: + raise SharePointURLError(f"Error parsing SharePoint URL: {url}. Reason: {e}") \ No newline at end of file