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