# social/views.py from django.shortcuts import render, redirect, get_object_or_404 from django.contrib.auth.decorators import login_required from django.http import HttpResponse, JsonResponse from django.contrib import messages from django.core.paginator import Paginator from django.utils import timezone from django.utils.dateparse import parse_datetime from django.views.decorators.csrf import csrf_exempt from django.conf import settings import json import logging import requests import csv from apps.social.models import SocialAccount, SocialContent, SocialComment, SocialReply from apps.social.services.linkedin import LinkedInService, LinkedInAPIError from apps.social.services.google import GoogleBusinessService, GoogleAPIError from apps.social.services.meta import MetaService, MetaAPIError from apps.social.services.tiktok import TikTokService, TikTokAPIError from apps.social.services.x import XService, XAPIError from apps.social.services.youtube import YouTubeService, YouTubeAPIError from apps.social.tasks.linkedin import sync_new_comments_task as li_sync from apps.social.tasks.google import sync_single_account as go_sync from apps.social.tasks.meta import meta_historical_backfill_task as meta_sync,meta_poll_new_comments_task as meta_poll from apps.social.tasks.tiktok import extract_all_comments_task as tt_full_sync, poll_new_comments_task as tt_poll from apps.social.tasks.x import extract_all_replies_task as x_full_sync, poll_new_replies_task as x_poll from apps.social.tasks.youtube import deep_historical_backfill_task as yt_full_sync, poll_new_comments_task as yt_poll from apps.social.tasks.ai import analyze_pending_comments_task, analyze_comment_task, reanalyze_comment_task from apps.social.services.ai_service import OpenRouterService # CRITICAL FIX: Import Google/YouTube clients to fetch necessary IDs during Auth from google.oauth2.credentials import Credentials from googleapiclient.discovery import build from google_auth_oauthlib.flow import Flow logger = logging.getLogger(__name__) PLATFORM_NAMES = { 'LI': 'LinkedIn', 'GO': 'Google Reviews', 'META': 'Meta (Facebook/Instagram)', 'TT': 'TikTok', 'X': 'X (Twitter)', 'YT': 'YouTube' } # For source_platform display only SOURCE_PLATFORM_NAMES = { 'FB': 'Facebook', 'IG': 'Instagram' } @login_required def dashboard(request, platform_type=None): """ Unified dashboard showing accounts by platform """ # FIX: Using 'owner' to match Model accounts_qs = SocialAccount.objects.filter(owner=request.user) if platform_type: accounts_qs = accounts_qs.filter(platform_type=platform_type) accounts = {} for acc in accounts_qs: is_expired = timezone.now() >= acc.expires_at if acc.expires_at else False accounts[acc.platform_type] = { 'account': acc, 'is_expired': is_expired, 'status_msg': 'Active' if acc.is_active else 'Inactive', 'platform_name': PLATFORM_NAMES.get(acc.platform_type, acc.platform_type) } return render(request, 'social/dashboard.html', { 'accounts': accounts, 'selected_platform': platform_type, 'platform_names': PLATFORM_NAMES }) @login_required def export_comments_csv(request, platform_type): """ Export all comments for a specific platform to CSV with detailed fields Supports the same filters as comments_list view """ account = get_object_or_404( SocialAccount, owner=request.user, platform_type=platform_type ) comments_qs = SocialComment.objects.filter( account=account, platform_type=platform_type ).order_by('-created_at') # Apply the same filters as comments_list search_query = request.GET.get('search') if search_query: comments_qs = comments_qs.filter(text__icontains=search_query) sentiment_filter = request.GET.get('sentiment') if sentiment_filter in ['positive', 'neutral', 'negative']: comments_qs = comments_qs.filter( ai_analysis__sentiment__classification__en=sentiment_filter ) sync_filter = request.GET.get('sync_via_webhook') if sync_filter == 'true': comments_qs = comments_qs.filter(synced_via_webhook=True) elif sync_filter == 'false': comments_qs = comments_qs.filter(synced_via_webhook=False) source_filter = request.GET.get('source_platform') if source_filter in ['FB', 'IG']: comments_qs = comments_qs.filter(source_platform=source_filter) # Create CSV response response = HttpResponse(content_type='text/csv') response['Content-Disposition'] = f'attachment; filename="{platform_type}_comments_{timezone.now().strftime("%Y%m%d_%H%M%S")}.csv"' writer = csv.writer(response) # Write header row with all fields header = [ 'Comment ID', 'Author Name', 'Author ID', 'Comment Text', 'Created At', 'Added At', 'Platform Type', 'Source Platform', 'Like Count', 'Reply Count', 'Rating', 'Media URL', 'Sentiment (EN)', 'Sentiment (AR)', 'Sentiment Score', 'Sentiment Confidence', 'Sentiment Urgency (EN)', 'Sentiment Urgency (AR)', 'Summary (EN)', 'Summary (AR)', 'Keywords (EN)', 'Keywords (AR)', 'Topics (EN)', 'Topics (AR)', 'Emotions', 'Primary Concern (EN)', 'Primary Concern (AR)', 'Affected Department (EN)', 'Affected Department (AR)', 'SQI: Clinical Care', 'SQI: Staff Behavior', 'SQI: Facility Condition', 'SQI: Wait Time', 'SQI: Communication', 'SQI: Overall Experience', 'Complaint Type (EN)', 'Complaint Type (AR)', 'Requires Follow-up', 'Follow-up Priority (EN)', 'Follow-up Priority (AR)', 'Recommended Actions (EN)', 'Recommended Actions (AR)', 'Patient Satisfaction Score', 'NPS Likelihood', 'Retention Risk Level', 'Retention Risk Score', 'Reputation Impact Level', 'Reputation Impact Score', 'Compliance Concerns Present', 'Compliance Concern Types', 'Patient Journey Touchpoints', 'Mentions Competitors', 'Unique Selling Points (EN)', 'Unique Selling Points (AR)', 'Improvement Opportunities (EN)', 'Improvement Opportunities (AR)' ] writer.writerow(header) # Write data rows for comment in comments_qs: # Extract AI analysis data safely ai_data = comment.ai_analysis or {} # Sentiment data sentiment_data = ai_data.get('sentiment', {}) sentiment_en = sentiment_data.get('classification', {}).get('en', '') sentiment_ar = sentiment_data.get('classification', {}).get('ar', '') sentiment_score = sentiment_data.get('score', '') sentiment_confidence = sentiment_data.get('confidence', '') sentiment_urgency_en = sentiment_data.get('urgency_level', {}).get('en', '') sentiment_urgency_ar = sentiment_data.get('urgency_level', {}).get('ar', '') # Actionable Insights data actionable_data = ai_data.get('actionable_insights', {}) primary_concern_en = actionable_data.get('primary_concern', {}).get('en', '') primary_concern_ar = actionable_data.get('primary_concern', {}).get('ar', '') affected_dept_en = actionable_data.get('affected_department', {}).get('en', '') affected_dept_ar = actionable_data.get('affected_department', {}).get('ar', '') # Service Quality Indicators sqi = actionable_data.get('service_quality_indicators', {}) sqi_clinical = sqi.get('clinical_care', '') sqi_staff = sqi.get('staff_behavior', '') sqi_facility = sqi.get('facility_condition', '') sqi_wait = sqi.get('wait_time', '') sqi_communication = sqi.get('communication', '') sqi_overall = sqi.get('overall_experience', '') # Complaint and follow-up complaint_type_en = actionable_data.get('complaint_type', {}).get('en', '') complaint_type_ar = actionable_data.get('complaint_type', {}).get('ar', '') requires_followup = actionable_data.get('requires_followup', '') followup_priority_en = actionable_data.get('followup_priority', {}).get('en', '') followup_priority_ar = actionable_data.get('followup_priority', {}).get('ar', '') recommended_actions_en = '; '.join(actionable_data.get('recommended_actions', {}).get('en', [])) recommended_actions_ar = '; '.join(actionable_data.get('recommended_actions', {}).get('ar', [])) # Business Intelligence bi_data = ai_data.get('business_intelligence', {}) patient_satisfaction = bi_data.get('patient_satisfaction_score', '') nps_likelihood = bi_data.get('nps_likelihood', '') retention_risk_level = bi_data.get('retention_risk', {}).get('level', '') retention_risk_score = bi_data.get('retention_risk', {}).get('score', '') reputation_impact_level = bi_data.get('reputation_impact', {}).get('level', '') reputation_impact_score = bi_data.get('reputation_impact', {}).get('score', '') compliance_present = bi_data.get('compliance_concerns', {}).get('present', '') compliance_types = ', '.join(bi_data.get('compliance_concerns', {}).get('types', [])) # Patient Journey journey_data = ai_data.get('patient_journey', {}) touchpoints = journey_data.get('touchpoints', {}) journey_touchpoints = ', '.join([k for k, v in touchpoints.items() if v]) # Competitive Insights competitive_data = ai_data.get('competitive_insights', {}) mentions_competitors = competitive_data.get('mentions_competitors', '') usp_en = ', '.join(competitive_data.get('unique_selling_points', {}).get('en', [])) usp_ar = ', '.join(competitive_data.get('unique_selling_points', {}).get('ar', [])) improvement_opp_en = ', '.join(competitive_data.get('improvement_opportunities', {}).get('en', [])) improvement_opp_ar = ', '.join(competitive_data.get('improvement_opportunities', {}).get('ar', [])) # Summary data summary_en = ai_data.get('summaries', {}).get('en', '') summary_ar = ai_data.get('summaries', {}).get('ar', '') # Keywords data (comma-separated) keywords_en = ', '.join(ai_data.get('keywords', {}).get('en', [])) keywords_ar = ', '.join(ai_data.get('keywords', {}).get('ar', [])) # Topics data (comma-separated) topics_en = ', '.join(ai_data.get('topics', {}).get('en', [])) topics_ar = ', '.join(ai_data.get('topics', {}).get('ar', [])) # Emotions data (format: emotion1:score1, emotion2:score2) emotions_data = ai_data.get('emotions', {}) emotions_list = [] for k, v in emotions_data.items(): # Handle both numeric values and nested structures if isinstance(v, (int, float)): emotions_list.append(f"{k}:{v:.2f}") else: # If value is not numeric, convert to string representation emotions_list.append(f"{k}:{str(v)}") emotions = ', '.join(emotions_list) row = [ comment.comment_id, comment.author_name, comment.author_id or '', comment.text, comment.created_at.strftime('%Y-%m-%d %H:%M:%S') if comment.created_at else '', comment.added_at.strftime('%Y-%m-%d %H:%M:%S') if comment.added_at else '', comment.platform_type, comment.source_platform or '', comment.like_count, comment.reply_count, comment.rating or '', comment.media_url or '', sentiment_en, sentiment_ar, sentiment_score, sentiment_confidence, sentiment_urgency_en, sentiment_urgency_ar, summary_en, summary_ar, keywords_en, keywords_ar, topics_en, topics_ar, emotions, primary_concern_en, primary_concern_ar, affected_dept_en, affected_dept_ar, sqi_clinical, sqi_staff, sqi_facility, sqi_wait, sqi_communication, sqi_overall, complaint_type_en, complaint_type_ar, requires_followup, followup_priority_en, followup_priority_ar, recommended_actions_en, recommended_actions_ar, patient_satisfaction, nps_likelihood, retention_risk_level, retention_risk_score, reputation_impact_level, reputation_impact_score, compliance_present, compliance_types, journey_touchpoints, mentions_competitors, usp_en, usp_ar, improvement_opp_en, improvement_opp_ar ] writer.writerow(row) return response @login_required def comments_list(request, platform_type): """ List all comments for a specific platform with AI analysis filtering """ account = get_object_or_404( SocialAccount, owner=request.user, platform_type=platform_type ) comments_qs = SocialComment.objects.filter( account=account, platform_type=platform_type ).order_by('-created_at') search_query = request.GET.get('search') if search_query: comments_qs = comments_qs.filter(text__icontains=search_query) # Sentiment filter - filter by AI analysis sentiment sentiment_filter = request.GET.get('sentiment') if sentiment_filter in ['positive', 'neutral', 'negative']: # Filter by JSON field containing sentiment classification comments_qs = comments_qs.filter( ai_analysis__sentiment__classification__en=sentiment_filter ) sync_filter = request.GET.get('sync_via_webhook') if sync_filter == 'true': comments_qs = comments_qs.filter(synced_via_webhook=True) elif sync_filter == 'false': comments_qs = comments_qs.filter(synced_via_webhook=False) # Source platform filter for Meta (FB/IG) source_filter = request.GET.get('source_platform') if source_filter in ['FB', 'IG']: comments_qs = comments_qs.filter(source_platform=source_filter) paginator = Paginator(comments_qs, 20) page_number = request.GET.get('page') page_obj = paginator.get_page(page_number) return render(request, 'social/comments_list.html', { 'account': account, 'page_obj': page_obj, 'platform_type': platform_type, 'platform_name': PLATFORM_NAMES.get(platform_type, platform_type), 'search_query': search_query, 'sentiment_filter': sentiment_filter, 'sync_filter': sync_filter }) # Corrected comment_detail view for views.py @login_required def comment_detail(request, platform_type, comment_id): """ Show comment detail and allow reply """ comment = get_object_or_404( SocialComment, comment_id=comment_id, platform_type=platform_type, account__owner=request.user ) if request.method == "POST": text = request.POST.get('text') if not text: messages.error(request, "Reply text cannot be empty.") else: try: res = None account = comment.account if platform_type == 'LI': res = LinkedInService.post_reply(account, comment.content_id, text) elif platform_type == 'GO': res = GoogleBusinessService.reply_to_review(account, comment.content_id, text) elif platform_type == 'META': # FIXED: Use correct method signature # Get access token (may be stored in content_data for page-specific token) access_token = comment.content.content_data.get('access_token', comment.account.access_token) res = MetaService.post_reply(comment_id, access_token,comment.source_platform, text) elif platform_type == 'TT': # UPDATED: Pass video_id (ad_id) and comment_id ad_id = comment.content.content_id res = TikTokService.reply_to_comment(account, ad_id, comment.comment_id, text) elif platform_type == 'X': res = XService.post_reply(account, comment.content_id, text) elif platform_type == 'YT': res = YouTubeService.post_reply(account, comment_id, text) if res: # Extract ID based on platform specific response structure reply_id = None if platform_type == 'TT': # TikTok Business API returns: {"comment_id": "..."} reply_id = res.get('comment_id') elif platform_type == 'GO': # Google returns the resource name in 'name' reply_id = res.get('name') else: # LinkedIn, Meta, X usually return 'id' reply_id = res.get('id') if reply_id: SocialReply.objects.create( platform_type=platform_type, account=account, comment=comment, reply_id=reply_id, author_name="You", text=text, created_at=timezone.now(), reply_data=res ) messages.success(request, "Reply posted successfully!") return redirect('social:comments_list', platform_type=platform_type) else: # We got a response but couldn't find the ID logger.warning(f"Reply success but ID missing in response: {res}") messages.error(request, "Reply posted but ID tracking failed.") else: messages.error(request, "Reply failed: No response from API.") except Exception as e: messages.error(request, f"API Error: {str(e)}") # if res and (res.get('id') or isinstance(res, dict) and 'id' in str(res)): # SocialReply.objects.create( # platform_type=platform_type, # account=account, # comment=comment, # reply_id=res.get('id'), # author_name="You", # text=text, # created_at=timezone.now(), # reply_data=res # ) # messages.success(request, "Reply posted successfully!") # return redirect('social:comments_list', platform_type=platform_type) # else: # messages.error(request, "Reply posted but ID tracking failed.") # except Exception as e: # messages.error(request, f"API Error: {str(e)}") # Get all replies for this comment replies = comment.replies.all() return render(request, 'social/comment_detail.html', { 'comment': comment, 'replies': replies, 'platform_type': platform_type, 'platform_name': PLATFORM_NAMES.get(platform_type, platform_type) }) @login_required def manual_sync(request, platform_type, sync_type='delta'): """ Trigger manual sync for a specific platform """ account = get_object_or_404( SocialAccount, owner=request.user, platform_type=platform_type ) try: if platform_type == 'LI': li_sync.delay(account.id) messages.success(request, "LinkedIn sync started (check back in 2 mins)") elif platform_type == 'GO': go_sync(account.id) messages.success(request, "Google Reviews sync completed") elif platform_type == 'META': # meta_sync.delay(account.id) # messages.success(request, "Meta (Facebook & Instagram) sync started") if sync_type == 'full': meta_sync.delay(account.id) messages.success(request, "Meta full sync started (may take a while)") else: meta_poll.delay() messages.success(request, "Meta delta sync started") elif platform_type == 'TT': if sync_type == 'full': tt_full_sync.delay(account.id) messages.success(request, "TikTok full sync started (may take a while)") else: tt_poll.delay() messages.success(request, "TikTok delta sync started") elif platform_type == 'X': if sync_type == 'full': x_full_sync.delay(account.id) messages.success(request, "X full sync started (may take a while)") else: x_poll.delay() messages.success(request, "X delta sync started") elif platform_type == 'YT': if sync_type == 'full': yt_full_sync.delay(account.id) messages.success(request, "YouTube full sync started (may take a while)") else: yt_poll.delay() messages.success(request, "YouTube delta sync started") except Exception as e: messages.error(request, f"Sync failed: {str(e)}") return redirect('social:dashboard') @login_required def auth_start(request, platform_type): auth_url = None try: if platform_type == 'LI': auth_url = LinkedInService.get_auth_url() elif platform_type == 'GO': auth_url = GoogleBusinessService.get_auth_url() elif platform_type == 'META': auth_url = MetaService.get_auth_url() elif platform_type == 'TT': auth_url = TikTokService.get_auth_url() elif platform_type == 'X': verifier, challenge, state = XService.generate_auth_params() request.session['x_code_verifier'] = verifier request.session['x_state'] = state auth_url = XService.get_auth_url(challenge, state) elif platform_type == 'YT': auth_url, _ = YouTubeService.get_auth_url() if auth_url: return redirect(auth_url) else: messages.error(request, f"OAuth not configured for {PLATFORM_NAMES.get(platform_type)}") return redirect('social:dashboard') except Exception as e: messages.error(request, f"Auth failed: {str(e)}") return redirect('social:dashboard') @login_required def auth_callback(request, platform_type): if "error" in request.GET: messages.error(request, f"Auth Failed: {request.GET.get('error_description')}") return redirect('social:dashboard') code = request.GET.get('code') state = request.GET.get('state') try: if platform_type == 'LI': token_data = LinkedInService.exchange_code_for_token(code) headers = {"Authorization": f"Bearer {token_data['access_token']}"} org_res = requests.get( f"https://api.linkedin.com/v2/organizationalEntityAcls?q=roleAssignee", headers=headers ) if org_res.status_code != 200: raise Exception("Failed to fetch Org") org_urn = org_res.json().get('elements', [{}])[0].get('organizationalTarget') if not org_urn: raise Exception("No Org found") acc_exp = timezone.now() + timezone.timedelta(seconds=token_data['expires_in']) SocialAccount.objects.update_or_create( owner=request.user, platform_type='LI', defaults={ "platform_id": org_urn, "access_token": token_data['access_token'], "refresh_token": token_data['refresh_token'], "is_active": True, "expires_at": acc_exp, "name": f"LinkedIn Account" } ) messages.success(request, "Connected to LinkedIn!") elif platform_type == 'GO': # FIX: Google Auth - Fetch Account Name from API token_data = GoogleBusinessService.exchange_code_for_token(code) # Build service to get account name (e.g. accounts/123456) creds = Credentials.from_authorized_user_info(token_data) service = build('mybusinessaccountmanagement', 'v1', credentials=creds) accounts = service.accounts().list().execute() if not accounts.get('accounts'): raise Exception("No Google Business Account found. Make sure you are admin.") # Get the first account (User can select multiple in a full app, but keeping simple here) google_account_name = accounts['accounts'][0]['name'] acc_exp = timezone.now() + timezone.timedelta(days=30) SocialAccount.objects.update_or_create( owner=request.user, platform_type='GO', defaults={ "platform_id": google_account_name, # Save 'accounts/...' ID "access_token": json.dumps(token_data), "is_active": True, "expires_at": acc_exp, "name": "Google Business" } ) messages.success(request, "Connected to Google Business!") # elif platform_type == 'META': # token_data = MetaService.exchange_code_for_token(code) # res = requests.get( # f"https://graph.facebook.com/me?access_token={token_data['access_token']}&fields=name,id" # ) # user_data = res.json() # acc_exp = timezone.now() + timezone.timedelta(seconds=token_data.get('expires_in', 5184000)) # SocialAccount.objects.update_or_create( # owner=request.user, # platform_type='META', # defaults={ # "platform_id": user_data.get('id'), # "access_token": token_data['access_token'], # "is_active": True, # "expires_at": acc_exp, # "name": user_data.get('name', "Meta Account") # } # ) # messages.success(request, "Connected to Meta (Facebook & Instagram)!") elif platform_type == 'META': token_data = MetaService.exchange_code_for_tokens(code) # FIXED: Use BASE_GRAPH_URL from utils for consistency from apps.social.utils.meta import BASE_GRAPH_URL # FIXED: Better error handling res = requests.get( f"{BASE_GRAPH_URL}/me", params={ "access_token": token_data['access_token'], "fields": "name,id" } ) # Check for HTTP errors if res.status_code != 200: raise Exception(f"Failed to fetch user info: {res.text}") user_data = res.json() # Check for API errors if 'error' in user_data: error_msg = user_data['error'].get('message', 'Unknown error') raise Exception(f"API Error: {error_msg}") # Use the expires_at datetime returned by MetaService acc_exp = token_data.get('expires_at') # Save account SocialAccount.objects.update_or_create( owner=request.user, platform_type='META', defaults={ "platform_id": user_data.get('id'), "access_token": token_data['access_token'], "is_active": True, "expires_at": acc_exp, "name": user_data.get('name', "Meta Account") } ) messages.success(request, "Connected to Meta (Facebook & Instagram)!") elif platform_type == 'TT': token_data = TikTokService.exchange_code_for_token(code) # BUSINESS API SPECIFIC: # The token data must contain advertiser_ids. # If not present, you might need to call 'advertiser/get/' endpoint here. advertiser_ids = token_data.get('advertiser_ids', []) if not advertiser_ids: messages.error(request, "No Advertiser ID found for this TikTok account.") return redirect('social:dashboard') # We pick the first Advertiser ID associated with the user advertiser_id = advertiser_ids[0] acc_exp = timezone.now() + timezone.timedelta(seconds=token_data.get('expires_in', 86400)) SocialAccount.objects.update_or_create( owner=request.user, platform_type='TT', defaults={ "platform_id": advertiser_id, # CRITICAL: Save Advertiser ID here "access_token": token_data['access_token'], "refresh_token": token_data['refresh_token'], "is_active": True, "expires_at": acc_exp, "name": "TikTok Ads Account" } ) messages.success(request, "Connected to TikTok Ads!") elif platform_type == 'X': verifier = request.session.get('x_code_verifier') state_check = request.session.get('x_state') if not verifier or state != state_check: raise Exception("Invalid state") token_data = XService.exchange_code_for_token(code, verifier) # Need to create temp account to use _make_request, or just call raw requests # Creating temp dict is safer to reuse logic temp_acc = SocialAccount(platform_type='X', access_token=token_data['access_token']) user_info = XService._make_request( f"users/me", temp_acc, "GET" ).get('data', {}) acc_exp = timezone.now() + timezone.timedelta(seconds=token_data.get('expires_in', 7200)) SocialAccount.objects.update_or_create( owner=request.user, platform_type='X', defaults={ "platform_id": user_info.get('id'), "access_token": token_data['access_token'], "refresh_token": token_data.get('refresh_token'), "is_active": True, "expires_at": acc_exp, "name": user_info.get('username', 'X Account') } ) messages.success(request, "Connected to X!") elif platform_type == 'YT': # FIX: YouTube Auth - Fetch Uploads Playlist ID token_data = YouTubeService.exchange_code_for_token(code) creds = Credentials.from_authorized_user_info(token_data) youtube = build('youtube', 'v3', credentials=creds) request_obj = youtube.channels().list(part="snippet,contentDetails", mine=True) response = request_obj.execute() if not response.get('items'): raise Exception("No YouTube channel found for this account") channel = response['items'][0] # Get the uploads playlist ID from contentDetails uploads_playlist_id = channel['contentDetails']['relatedPlaylists']['uploads'] # Inject this ID into the credentials_json so the background task can use it token_data['uploads_playlist_id'] = uploads_playlist_id # Fix: Google OAuth credentials use 'expiry' as a timestamp, not duration in seconds expiry = token_data.get('expiry') if expiry: acc_exp = parse_datetime(expiry) else: acc_exp = timezone.now() + timezone.timedelta(seconds=3600) SocialAccount.objects.update_or_create( owner=request.user, platform_type='YT', defaults={ "platform_id": channel.get('id'), "credentials_json": token_data, # Save with playlist_id inside "is_active": True, "expires_at": acc_exp, "name": channel.get('snippet', {}).get('title', 'YouTube Channel') } ) messages.success(request, "Connected to YouTube!") # Clean up session if 'x_code_verifier' in request.session: del request.session['x_code_verifier'] if 'x_state' in request.session: del request.session['x_state'] except Exception as e: logger.error(f"Auth callback error for {platform_type}: {str(e)}", exc_info=True) messages.error(request, f"Connection failed: {str(e)}") return redirect('social:dashboard') @csrf_exempt def meta_webhook(request): if request.method == 'GET': # ... (keep existing logic) ... mode = request.GET.get('hub.mode') token = request.GET.get('hub.verify_token') challenge = request.GET.get('hub.challenge') # FIX 1: Use settings variable if mode == 'subscribe' and token == settings.META_WEBHOOK_VERIFY_TOKEN: return HttpResponse(challenge) return HttpResponse(status=403) if request.method == 'POST': try: body_raw = request.body received_sig = request.headers.get('x-hub-signature-256') if not received_sig: return HttpResponse(status=403) is_valid = MetaService.verify_webhook_signature( received_sig, body_raw, settings.META_APP_SECRET # FIX 2: Use settings variable ) if not is_valid: return HttpResponse(status=403) payload = json.loads(body_raw) for entry in payload.get('entry', []): for change in entry.get('changes', []): if change.get('field') == 'comments': value = change.get('value', {}) comment_id = value.get('id') page_id = value.get('from', {}).get('id') if comment_id and page_id: # FIX 3: Reliability Issue # We cannot match page_id directly to DB because DB stores User ID. # We assume this webhook belongs to the first active Meta account found. # If you support multiple users, you would need a SocialPage mapping table. account = SocialAccount.objects.filter(platform_type='META', is_active=True).first() if account: from apps.social.tasks.meta import process_webhook_comment_task # Pass the page_id so we can find the correct token later process_webhook_comment_task.delay(comment_id, page_id, account.id) else: logger.warning("Webhook received but no active Meta account found in DB.") return HttpResponse(status=200) except Exception as e: logger.error(f"Webhook Error: {e}") return HttpResponse(status=200) return HttpResponse(status=405) @csrf_exempt def linkedin_webhook(request): """ Handle LinkedIn Webhooks (For Partner/Enterprise Accounts). Standard accounts will not trigger this. """ if request.method == 'GET': # Handshake verification mode = request.GET.get('hub.mode') token = request.GET.get('hub.verify_token') challenge = request.GET.get('hub.challenge') # Ensure this string matches what you set in LinkedIn Developer Portal if mode == 'subscribe' and token == settings.LINKEDIN_WEBHOOK_VERIFY_TOKEN: return HttpResponse(challenge) return HttpResponse(status=403) if request.method == 'POST': try: body_raw = request.body received_sig = request.headers.get('X-Li-Signature') if not received_sig: return HttpResponse(status=403) # 1. Verify Signature is_valid = LinkedInService.verify_webhook_signature( received_sig, body_raw, settings.LINKEDIN_CLIENT_SECRET ) if not is_valid: return HttpResponse(status=403) payload = json.loads(body_raw) # 2. Process Events # LinkedIn sends a list of events in the payload for event in payload: # We are primarily interested in new comments if event.get('event') == 'commentCreated': resource_urn = event.get('resourceUrn') # Resource URN format: urn:li:comment:(POST_URN, COMMENT_ID) # Example: urn:li:comment:(urn:li:ugcPost:67890, 12345) if resource_urn and ":comment:(" in resource_urn: # Parse the URN to extract the Post ID and Comment ID try: # Extract inner part: (urn:li:ugcPost:67890, 12345) inner_part = resource_urn.split(":comment:(")[1].split(")")[0] urn_parts = [p.strip() for p in inner_part.split(",")] if len(urn_parts) >= 2: post_urn = urn_parts[0] comment_id = urn_parts[1] # Find the local Post object to identify the Account content = SocialContent.objects.filter(content_id=post_urn).first() if content and content.account: # Trigger background task to fetch full comment details from apps.social.tasks.linkedin import process_webhook_comment_task process_webhook_comment_task.delay( content.account.id, post_urn, comment_id ) else: logger.warning(f"LinkedIn Webhook: Post {post_urn} not found in DB.") except Exception as parse_error: logger.error(f"LinkedIn Webhook Parse Error: {parse_error}") return HttpResponse(status=200) except Exception as e: logger.critical(f"LinkedIn Webhook System Error: {str(e)}", exc_info=True) # Always return 200 to LinkedIn to prevent retries on internal server errors return HttpResponse(status=200) return HttpResponse(status=405)