nexus-2/backend/api/google.py
2026-01-26 10:12:01 -05:00

389 lines
14 KiB
Python

import io
import logging
import os
import re
from django.utils import timezone
from googleapiclient.discovery import build
from googleapiclient.http import MediaIoBaseDownload, MediaIoBaseUpload
from google.oauth2 import service_account
from google.auth.exceptions import GoogleAuthError
from googleapiclient.errors import HttpError
from .models import Punchlist
logger = logging.getLogger(__name__)
# Configuration from environment
CALENDAR_ID = os.getenv('GOOGLE_CALENDAR_ID', 'primary')
IMPERSONATOR_EMAIL = os.getenv('GOOGLE_IMPERSONATOR_EMAIL')
TIME_ZONE = os.getenv('TIME_ZONE', 'America/Detroit')
# Punchlist Google Drive configuration from environment
PUNCHLIST_TEMPLATE = os.getenv('PUNCHLIST_TEMPLATE_ID', '')
PUNCHLIST_FOLDER = os.getenv('PUNCHLIST_FOLDER_ID', '')
def create_event(key, data):
"""
Create an event on the project calendar using the Google API
Args:
key (dict): Service account credentials dictionary
data (dict): Event data with the following structure:
- summary (str): Event title
- description (str): Event description
- start (str): ISO format start datetime
- end (str): ISO format end datetime
- location (str): Event location
- attendees (list): List of dicts with 'email' keys
- reminders (list, optional): List of dicts with 'method' and 'minutes' keys
Returns:
dict: Created event data or error information
"""
service_account_key = key
# Set up calendar details from environment
calendar_id = CALENDAR_ID
impersonator = IMPERSONATOR_EMAIL
# Validate required fields
required_fields = ['summary', 'start', 'end']
missing_fields = [field for field in required_fields if field not in data]
if missing_fields:
error_msg = f"Missing required fields: {', '.join(missing_fields)}"
logger.error(error_msg)
return {"error": error_msg}
# Construct event data
event = {
'summary': data.get('summary'),
'description': data.get('description', ''),
'start': {
'dateTime': data.get('start'),
'timeZone': TIME_ZONE,
},
'end': {
'dateTime': data.get('end'),
'timeZone': TIME_ZONE,
},
'location': data.get('location', ''),
'attendees': data.get('attendees', []),
'reminders': {
'useDefault': False if data.get('reminders') else True,
'overrides': data.get('reminders', []) if data.get('reminders') else [],
}
}
try:
# Set up authentication
scopes = ['https://www.googleapis.com/auth/calendar']
credentials = service_account.Credentials.from_service_account_info(
service_account_key, scopes=scopes, subject=impersonator
)
# Build and call the service
service = build('calendar', 'v3', credentials=credentials)
created_event = service.events().insert(calendarId=calendar_id, body=event).execute()
logger.info(f"Event created: {created_event.get('htmlLink')}")
return created_event
except GoogleAuthError as e:
logger.error(f"Authentication error: {e}")
return {"error": "Authentication failed", "details": str(e)}
except HttpError as e:
logger.error(f"Google API error: {e}")
return {"error": "Google Calendar API error", "details": str(e)}
except Exception as e:
logger.error(f"Unexpected error creating event: {e}")
return {"error": "Unexpected error creating event", "details": str(e)}
# Field mapping for punchlist export to Google Sheets
# Maps model field names to spreadsheet cell references
# Customize this mapping to match your Google Sheets template
PUNCHLIST_FIELD_MAPPING = {
'account_name': 'B4',
'date': 'D4',
# Front area
'front_ceiling': 'D8',
'front_vents': 'D9',
'front_fixtures': 'D10',
'front_counter': 'D11',
# Main work area
'main_equipment': 'D15',
'main_equipment_disassemble': 'D16',
'main_equipment_reassemble': 'D17',
'main_equipment_test': 'D18',
'main_equipment_exterior': 'D19',
'main_walls': 'D20',
'main_fixtures': 'D21',
'main_ceiling': 'D22',
'main_vents': 'D23',
'main_floors': 'D24',
# Equipment stations
'equip_station_1': 'D28',
'equip_station_2': 'D29',
'equip_station_3': 'D30',
'equip_station_4': 'D31',
'equip_station_5': 'D32',
'equip_station_6': 'D33',
'equip_station_7': 'D34',
'equip_sinks': 'D35',
'equip_dispensers': 'D36',
'equip_other': 'D37',
# Back area
'back_ceiling': 'D41',
'back_vents': 'D42',
# End of visit
'end_trash': 'D46',
'end_clean': 'D47',
'end_secure': 'D48',
# Notes
'notes': 'A52'
}
def get_google_service(key):
"""Create and return Google API service"""
credentials = service_account.Credentials.from_service_account_info(
key,
scopes=['https://www.googleapis.com/auth/drive', 'https://www.googleapis.com/auth/spreadsheets']
)
drive_service = build('drive', 'v3', credentials=credentials)
sheets_service = build('sheets', 'v4', credentials=credentials)
return drive_service, sheets_service
def export_punchlist_to_sheet(key, punchlist_id, template_id=None):
"""
Export a punchlist to Google Sheets and generate a PDF
Args:
key: Service account credentials dictionary
punchlist_id: The ID of the punchlist to export
template_id: Optional template ID, uses default if not provided
Returns:
dict: URLs to the created sheet and PDF
"""
# Get the punchlist data
try:
punchlist = Punchlist.objects.get(id=punchlist_id)
if punchlist.sheet_url and punchlist.pdf_url:
# Try to verify files still exist in Google Drive
try:
drive_service, _ = get_google_service(key)
# Extract file IDs from URLs
sheet_id = extract_file_id_from_url(punchlist.sheet_url)
pdf_id = extract_file_id_from_url(punchlist.pdf_url)
# Check if files exist
sheet_exists = check_file_exists(drive_service, sheet_id)
pdf_exists = check_file_exists(drive_service, pdf_id)
if sheet_exists and pdf_exists:
return {
'success': True,
'already_exported': True,
'sheetUrl': punchlist.sheet_url,
'pdfUrl': punchlist.pdf_url,
'message': 'Files already exist'
}
except Exception:
# If there's an error checking, proceed with creating new files
pass
except Punchlist.DoesNotExist:
return {
'success': False,
'message': f'Punchlist with ID {punchlist_id} not found'
}
# Get account and project info
account_name = punchlist.account.name if punchlist.account else 'Unknown Account'
service_date = punchlist.project.date if punchlist.project else punchlist.date
# Get Google services
drive_service, sheets_service = get_google_service(key)
# Use provided template ID or default
template_id = template_id or PUNCHLIST_TEMPLATE
if not template_id:
return {
'success': False,
'message': 'No punchlist template ID configured'
}
try:
# 1. Copy the template
copied_file = drive_service.files().copy(
fileId=template_id,
body={
'name': f'Punchlist - {account_name} - {service_date}',
'parents': [PUNCHLIST_FOLDER] if PUNCHLIST_FOLDER else []
}
).execute()
new_sheet_id = copied_file['id']
# 2. Prepare data for the sheet
value_range_body = {
'valueInputOption': 'USER_ENTERED',
'data': []
}
# Map punchlist data to sheet cells
data_mapping = {
'account_name': account_name,
'date': service_date.strftime('%m/%d/%Y') if service_date else '',
'front_ceiling': 'X' if punchlist.front_ceiling else '',
'front_vents': 'X' if punchlist.front_vents else '',
'front_fixtures': 'X' if punchlist.front_fixtures else '',
'front_counter': 'X' if punchlist.front_counter else '',
'main_equipment': punchlist.main_equipment.upper() if punchlist.main_equipment else '',
'main_equipment_disassemble': 'X' if punchlist.main_equipment_disassemble else '',
'main_equipment_reassemble': 'X' if punchlist.main_equipment_reassemble else '',
'main_equipment_test': 'X' if punchlist.main_equipment_test else '',
'main_equipment_exterior': 'X' if punchlist.main_equipment_exterior else '',
'main_walls': 'X' if punchlist.main_walls else '',
'main_fixtures': 'X' if punchlist.main_fixtures else '',
'main_ceiling': 'X' if punchlist.main_ceiling else '',
'main_vents': 'X' if punchlist.main_vents else '',
'main_floors': 'X' if punchlist.main_floors else '',
'equip_station_1': 'X' if punchlist.equip_station_1 else '',
'equip_station_2': 'X' if punchlist.equip_station_2 else '',
'equip_station_3': 'X' if punchlist.equip_station_3 else '',
'equip_station_4': 'X' if punchlist.equip_station_4 else '',
'equip_station_5': 'X' if punchlist.equip_station_5 else '',
'equip_station_6': 'X' if punchlist.equip_station_6 else '',
'equip_station_7': 'X' if punchlist.equip_station_7 else '',
'equip_sinks': 'X' if punchlist.equip_sinks else '',
'equip_dispensers': 'X' if punchlist.equip_dispensers else '',
'equip_other': 'X' if punchlist.equip_other else '',
'back_ceiling': 'X' if punchlist.back_ceiling else '',
'back_vents': 'X' if punchlist.back_vents else '',
'end_trash': 'X' if punchlist.end_trash else '',
'end_clean': 'X' if punchlist.end_clean else '',
'end_secure': 'X' if punchlist.end_secure else '',
'notes': punchlist.notes or ''
}
# Create value range objects for each cell
for field, cell in PUNCHLIST_FIELD_MAPPING.items():
if field in data_mapping:
value = data_mapping[field]
# Extract sheet name and cell reference
if '!' in cell:
sheet_name, cell_ref = cell.split('!')
else:
sheet_name = 'Sheet1' # Default sheet name
cell_ref = cell
value_range_body['data'].append({
'range': f"'{sheet_name}'!{cell_ref}",
'values': [[value]]
})
# 3. Update the sheet with data
sheets_service.spreadsheets().values().batchUpdate(
spreadsheetId=new_sheet_id,
body=value_range_body
).execute()
# 4. Export as PDF
request = drive_service.files().export_media(
fileId=new_sheet_id,
mimeType='application/pdf'
)
# Create a BytesIO object to store the PDF
pdf_file = io.BytesIO()
downloader = MediaIoBaseDownload(pdf_file, request)
# Download the PDF
done = False
while not done:
status, done = downloader.next_chunk()
# 5. Upload the PDF to Drive
pdf_metadata = {
'name': f'Punchlist - {account_name} - {service_date}.pdf',
'mimeType': 'application/pdf',
}
if PUNCHLIST_FOLDER:
pdf_metadata['parents'] = [PUNCHLIST_FOLDER]
pdf_file.seek(0)
pdf_media = MediaIoBaseUpload(pdf_file, mimetype='application/pdf')
uploaded_pdf = drive_service.files().create(
body=pdf_metadata,
media_body=pdf_media,
fields='id,webViewLink'
).execute()
# 6. Make both files accessible via link
for file_id in [new_sheet_id, uploaded_pdf['id']]:
drive_service.permissions().create(
fileId=file_id,
body={
'type': 'anyone',
'role': 'reader'
}
).execute()
# Get the web view links
sheet_file = drive_service.files().get(
fileId=new_sheet_id,
fields='webViewLink'
).execute()
punchlist.sheet_url = sheet_file['webViewLink']
punchlist.pdf_url = uploaded_pdf['webViewLink']
punchlist.exported_at = timezone.now()
punchlist.save()
return {
'success': True,
'sheetUrl': sheet_file['webViewLink'],
'pdfUrl': uploaded_pdf['webViewLink']
}
except Exception as e:
return {
'success': False,
'message': f'Error exporting punchlist: {str(e)}'
}
def extract_file_id_from_url(url):
"""Extract Google Drive file ID from a sharing URL"""
if not url:
return None
# Google Drive URLs typically have the format:
# https://drive.google.com/file/d/{FILE_ID}/view or
# https://docs.google.com/spreadsheets/d/{FILE_ID}/edit
match = re.search(r'\/d\/([a-zA-Z0-9_-]+)', url)
if match:
return match.group(1)
# For spreadsheet URLs
match = re.search(r'\/spreadsheets\/d\/([a-zA-Z0-9_-]+)', url)
if match:
return match.group(1)
return None
def check_file_exists(drive_service, file_id):
"""Check if a file exists in Google Drive"""
if not file_id:
return False
try:
drive_service.files().get(fileId=file_id).execute()
return True
except Exception:
return False