This commit is contained in:
Daniel Roth 2026-03-27 16:37:53 +00:00 committed by GitHub
commit da780d3e30
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
8 changed files with 272 additions and 52 deletions

View file

@ -65,6 +65,19 @@ class Settings(BaseSettings):
ORDNANCE_SURVEY_API_KEY: str = "changeme" ORDNANCE_SURVEY_API_KEY: str = "changeme"
# Sharepoint
SHAREPOINT_CLIENT_ID: str = "changeme"
SHAREPOINT_CLIENT_SECRET: str = "changeme"
SHAREPOINT_TENANT_ID: str = "changeme"
DOMNA_SHAREPOINT_ID: str = "changeme"
OSMOSIS_ACD_SHAREPOINT_ID: str = "changeme"
PRIVATE_PAY_SHAREPOINT_ID: str = "changeme"
SOCIAL_HOUSING_WAVE_3_SHAREPOINT_ID: str = "changeme"
# Pas Hub
PASHUB_EMAIL: str = "changeme"
PASHUB_PASSWORD: str = "changeme"
# Optional AWS creds (only required in local) # Optional AWS creds (only required in local)
AWS_ACCESS_KEY_ID: Optional[str] = None AWS_ACCESS_KEY_ID: Optional[str] = None
AWS_SECRET_KEY_ID: Optional[str] = None AWS_SECRET_KEY_ID: Optional[str] = None

View file

@ -0,0 +1,44 @@
import enum
from sqlalchemy import TIMESTAMP, BigInteger, Column, Text, Enum as SqlEnum
from backend.app.db.base import Base
class FileTypeEnum(enum.Enum):
PHOTO_PACK = "photo_pack"
SITE_NOTE = "site_note"
RD_SAP_SITE_NOTE = "rd_sap_site_note"
PAS_2023_VENTILATION = "pas_2023_ventilation"
PAS_2023_CONDITION = "pas_2023_condition"
PAS_SIGNIFICANCE = "pas_significance"
PAR_PHOTO_PACK = "par_photo_pack"
PAS_2023_PROPERTY = "pas_2023_property"
PAS_2023_OCCUPANCY = "pas_2023_occupancy"
class FileSourceEnum(enum.Enum):
PAS_HUB = "pas hub"
SHAREPOINT = "sharepoint"
HUBSPOT = "hubspot"
class UploadedFile(Base):
__tablename__ = "uploaded_files"
id = Column(BigInteger, primary_key=True, autoincrement=True)
s3_file_bucket = Column(Text, nullable=False)
s3_file_key = Column(Text, nullable=False)
s3_upload_timestamp = Column(TIMESTAMP(timezone=True), nullable=False)
landlord_property_id = Column(Text, nullable=True)
uprn = Column(BigInteger, nullable=True)
hubspot_listing_id = Column(BigInteger, nullable=True)
file_type = Column(
SqlEnum(FileTypeEnum, name="file_type", create_type=False), nullable=True
)
file_source = Column(
SqlEnum(FileSourceEnum, name="file_source", create_type=False), nullable=True
)

View file

@ -1,4 +1,7 @@
from enum import Enum from enum import Enum
from typing import Optional
from backend.app.db.models.uploaded_file import FileTypeEnum
class CoreFiles(Enum): class CoreFiles(Enum):
@ -11,3 +14,23 @@ class CoreFiles(Enum):
PAR_PHOTOPACK = "PAR Photo Pack" PAR_PHOTOPACK = "PAR Photo Pack"
PAS2023_PROPERTY = "PAS 2023 Property Assessment Report" PAS2023_PROPERTY = "PAS 2023 Property Assessment Report"
PAS2023_OCCUPANCY = "PAS 2023 Occupancy Assessment Report" PAS2023_OCCUPANCY = "PAS 2023 Occupancy Assessment Report"
CORE_TO_FILETYPE_MAP = {
CoreFiles.PHOTOPACK: FileTypeEnum.PHOTO_PACK.value,
CoreFiles.SITENOTE: FileTypeEnum.SITE_NOTE.value,
CoreFiles.RDSAP_SITENOTE: FileTypeEnum.RD_SAP_SITE_NOTE.value,
CoreFiles.PAS2023_VENTILATION: FileTypeEnum.PAS_2023_VENTILATION.value,
CoreFiles.PAS2023_CONDITION: FileTypeEnum.PAS_2023_CONDITION.value,
CoreFiles.PAS_SIGNIFICANCE: FileTypeEnum.PAS_SIGNIFICANCE.value,
CoreFiles.PAR_PHOTOPACK: FileTypeEnum.PAR_PHOTO_PACK.value,
CoreFiles.PAS2023_PROPERTY: FileTypeEnum.PAS_2023_PROPERTY.value,
CoreFiles.PAS2023_OCCUPANCY: FileTypeEnum.PAS_2023_OCCUPANCY.value,
}
def infer_file_type(filename: str) -> Optional[str]:
for core_file, file_type in CORE_TO_FILETYPE_MAP.items():
if filename.startswith(core_file.value):
return file_type
return None

View file

@ -1,13 +1,22 @@
from datetime import datetime, timezone
import os import os
import re import re
from typing import Any, Dict, List, Mapping from typing import Any, Dict, List, Mapping, Optional
from openpyxl import load_workbook from openpyxl import load_workbook
from backend.app.config import get_settings
from backend.app.db.connection import db_session
from backend.app.db.models.uploaded_file import (
FileSourceEnum,
UploadedFile,
)
from backend.pashub_fetcher.core_files import infer_file_type
from backend.pashub_fetcher.job import Job from backend.pashub_fetcher.job import Job
from backend.pashub_fetcher.pashub_client import PashubClient, UnauthorizedError from backend.pashub_fetcher.pashub_client import PashubClient, UnauthorizedError
from backend.pashub_fetcher.sharepoint_subfolders import SharepointSubfolders from backend.pashub_fetcher.sharepoint_subfolders import SharepointSubfolders
from backend.pashub_fetcher.token_getter import get_token_from_local_storage from backend.pashub_fetcher.token_getter import get_token_from_local_storage
from utils.logger import setup_logger from utils.logger import setup_logger
from utils.s3 import upload_file_to_s3
from utils.sharepoint.domna_sharepoint_client import DomnaSharepointClient from utils.sharepoint.domna_sharepoint_client import DomnaSharepointClient
from utils.sharepoint.domna_sites import DomnaSites from utils.sharepoint.domna_sites import DomnaSites
@ -39,88 +48,165 @@ def extract_jobs(filepath: str) -> List[Job]:
if not name or not link: if not name or not link:
continue continue
link = str(link) match = re.search(r"/jobs/([0-9a-fA-F\-]+)/", str(link))
match = re.search(r"/jobs/([0-9a-fA-F\-]+)/", link)
if not match: if not match:
continue continue
job_id = match.group(1) jobs.append(
{
jobs.append({"id": job_id, "address": str(name)}) "id": match.group(1),
"address": str(name),
}
)
return jobs return jobs
def get_pashub_client(email: str, password: str) -> PashubClient:
token = get_token_from_local_storage(email, password)
logger.info("Token extracted successfully")
return PashubClient(token=token)
def upload_job_to_sharepoint(
sharepoint_client: DomnaSharepointClient,
base_path: str,
job: Job,
job_files: List[str],
) -> None:
job_path = f"{base_path}/{job['address']}"
# Create main job folder
sharepoint_client.makedir(job["address"], base_path)
# Create subfolders
for folder in SharepointSubfolders:
sharepoint_client.makedir(folder.value, job_path)
# Upload into assessment folder
assessment_path = f"{job_path}/{SharepointSubfolders.ASSESSMENT.value}"
for file_path in job_files:
filename = file_path.split("/")[-1]
sharepoint_client.upload_file(
file_path,
assessment_path,
filename,
)
def upload_job_to_s3_and_update_db(job_files: List[str], uprn: str) -> None:
bucket = "retrofit-energy-assessments-dev"
base_path = f"documents/uprn/{uprn}"
uploaded_files: List[UploadedFile] = []
for file_path in job_files:
filename = os.path.basename(file_path)
file_key = f"{base_path}/{filename}"
upload_file_to_s3(file_path, bucket, file_key)
# load row to db
uploaded_files.append(
UploadedFile(
s3_file_bucket=bucket,
s3_file_key=file_key,
s3_upload_timestamp=datetime.now(timezone.utc),
uprn=int(uprn),
file_source=FileSourceEnum.PAS_HUB.value,
file_type=infer_file_type(filename),
)
)
with db_session() as session:
session.add_all(uploaded_files)
session.commit()
pass
def process_job(
job: Job,
pashub_client: PashubClient,
sharepoint_client: DomnaSharepointClient,
base_path: str,
) -> List[str]:
job_id = job["id"]
uprn: Optional[str] = pashub_client.get_uprn_by_job_id(job_id)
if uprn:
logger.info(f"Got UPRN {uprn} for job {job_id}")
else:
logger.info(f"No UPRN found for job {job_id}")
job_files: List[str] = pashub_client.get_core_evidence_files_by_job_id(job_id)
if uprn:
logger.info("Uploading files to s3")
upload_job_to_s3_and_update_db(job_files, uprn)
# add record of new file in db
upload_job_to_sharepoint(sharepoint_client, base_path, job, job_files)
return job_files
def handler(event: Mapping[str, Any], context: Any) -> None: def handler(event: Mapping[str, Any], context: Any) -> None:
settings = get_settings()
BASE_DIR = os.path.dirname(os.path.dirname(__file__)) BASE_DIR = os.path.dirname(os.path.dirname(__file__))
filepath = os.path.join(BASE_DIR, "Watford_Warm_Homes_Wave_3_RA Downloads .xlsx") filepath = os.path.join(BASE_DIR, "Watford_Warm_Homes_Wave_3_RA Downloads .xlsx")
jobs: List[Job] = extract_jobs(filepath) jobs: List[Job] = extract_jobs(filepath)
logger.info("Successfully loaded jobs from spreadsheet") logger.info("Successfully loaded jobs from spreadsheet")
pas_hub_email = "random@test.com" pas_hub_email = settings.PASHUB_EMAIL
pas_hub_password = "my_fake_password" pas_hub_password = settings.PASHUB_PASSWORD
try: pashub_client = get_pashub_client(pas_hub_email, pas_hub_password)
token: str = get_token_from_local_storage(pas_hub_email, pas_hub_password)
logger.info(f"Token extracted successfully")
except:
logger.error("Error getting auth token from Pas Hub")
raise
pashub_client = PashubClient(token=token)
sharepoint_client = DomnaSharepointClient( sharepoint_client = DomnaSharepointClient(
sharepoint_location=DomnaSites.SOCIAL_HOUSING_WAVE_3 sharepoint_location=DomnaSites.SOCIAL_HOUSING_WAVE_3
) )
BASE_PATH = "/Osmosis-ACD Projects/Watford Warm Homes/Watford Property Folders (Shared with Client)"
saved_file_paths: List[str] = [] saved_file_paths: List[str] = []
BASE_PATH = "/Osmosis-ACD Projects/Watford Warm Homes/Watford Property Folders (Shared with Client)" # TODO: get from request body
for job in jobs: for job in jobs:
try: try:
job_files: List[str] = pashub_client.get_core_evidence_files_by_job_id( files = process_job(
job["id"] job,
pashub_client,
sharepoint_client,
BASE_PATH,
) )
saved_file_paths.extend(files)
# Upload files to sharepoint
job_path = f"{BASE_PATH}/{job['address']}"
sharepoint_client.makedir(job["address"], BASE_PATH)
for folder in SharepointSubfolders:
sharepoint_client.makedir(folder.value, job_path)
assessment_path = f"{job_path}/{SharepointSubfolders.ASSESSMENT.value}"
for file_path in job_files:
filename = file_path.split("/")[-1]
sharepoint_client.upload_file(
file_path,
assessment_path,
filename,
)
saved_file_paths.extend(job_files)
except UnauthorizedError: except UnauthorizedError:
logger.warning("Token expired - refreshing") logger.warning("Token expired - refreshing")
token = get_token_from_local_storage(pas_hub_email, pas_hub_password) pashub_client = get_pashub_client(
pas_hub_email,
pashub_client = PashubClient(token=token) pas_hub_password,
# retry once
saved_file_paths.extend(
pashub_client.get_core_evidence_files_by_job_id(job["id"])
) )
print(f"saved {len(saved_file_paths)} files") # retry once
files = process_job(
job,
pashub_client,
sharepoint_client,
BASE_PATH,
)
saved_file_paths.extend(files)
logger.info(f"Saved {len(saved_file_paths)} files")
if __name__ == "__main__": if __name__ == "__main__":
event = {"Records": [{"body": "{}"}]} event = {"Records": [{"body": "{}"}]}
handler(event, None) handler(event, None)

View file

@ -1,7 +1,23 @@
from typing import Any, Mapping from typing import Any, Mapping
import json import json
from utils.logger import setup_logger
logger = setup_logger()
def handler(event: Mapping[str, Any], context: Any) -> None: def handler(event: Mapping[str, Any], context: Any) -> None:
print("Received event:") logger.info("Received event:")
print(json.dumps(event, indent=2))
for record in event.get("Records", []):
body_str = record.get("body", "")
try:
body_obj = json.loads(body_str)
except json.JSONDecodeError:
logger.error("Failed to parse body as JSON")
logger.info(body_str)
continue
logger.info("Parsed message body:")
logger.info(json.dumps(body_obj, indent=2))

View file

@ -71,6 +71,21 @@ class PashubClient:
return saved_files return saved_files
def get_uprn_by_job_id(self, job_id: str) -> Optional[str]:
logger.info(f"Getting UPRN for job ID {job_id}")
url = f"{self.base}/jobs/{job_id}"
r = self.session.get(url)
if r.status_code == 401:
raise UnauthorizedError("Token expired or invalid")
r.raise_for_status()
try:
return r.json()["uprn"]
except Exception:
return None
def _get_core_file_type(self, file: EvidenceFileData) -> Optional[CoreFiles]: def _get_core_file_type(self, file: EvidenceFileData) -> Optional[CoreFiles]:
for core_file in CoreFiles: for core_file in CoreFiles:
if file.file_name.startswith(core_file.value): if file.file_name.startswith(core_file.value):

View file

@ -0,0 +1,12 @@
from typing import Optional
from pydantic import BaseModel
class PashubToAraTriggerRequest(BaseModel):
pashub_job_id: str
address: Optional[str] = None
sharepoint_link: Optional[str] = None
uprn: Optional[str] = None
landlord_property_id: Optional[str] = None

View file

@ -472,3 +472,14 @@ def list_xmls_in_s3_folder(bucket_name, folder_name):
f"Failed to list XML files in folder {folder_name} in bucket {bucket_name}: {str(e)}" f"Failed to list XML files in folder {folder_name} in bucket {bucket_name}: {str(e)}"
) )
return [] return []
def upload_file_to_s3(file_path: str, bucket_name: str, file_key: str) -> None:
try:
s3 = boto3.resource("s3")
bucket = s3.Bucket(bucket_name)
bucket.upload_file(file_path, file_key)
logger.info(f"Uploaded {file_path} to s3://{bucket_name}/{file_key}")
except Exception as e:
logger.error(f"Failed to upload {file_path} to S3: {e}")
raise