From 70e40d133b9ffc1549abef1e8af357480a4796f7 Mon Sep 17 00:00:00 2001 From: Maciej Pienczyn Date: Sun, 8 Feb 2026 15:55:02 +0100 Subject: [PATCH] feat(oauth): Add OAuth integration UI, API clients, and audit enrichment (Phase 3) - Company settings page with 4 OAuth cards (GBP, Search Console, Facebook, Instagram) - 3 API service clients: GBP Management, Search Console, Facebook Graph - OAuth enrichment in GBP audit (owner responses, posts), social media (FB/IG Graph API), and SEO prompt (Search Console data) - Fix OAuth callback redirects to point to company settings page - All integrations have graceful fallback when no OAuth credentials configured Co-Authored-By: Claude Opus 4.6 --- audit_ai_service.py | 33 ++ blueprints/admin/routes_companies.py | 32 ++ blueprints/api/routes_oauth.py | 64 ++- facebook_graph_service.py | 123 +++++ gbp_audit_service.py | 40 +- gbp_management_service.py | 115 +++++ scripts/social_media_audit.py | 50 +++ search_console_service.py | 149 +++++++ templates/admin/company_settings.html | 617 ++++++++++++++++++++++++++ 9 files changed, 1214 insertions(+), 9 deletions(-) create mode 100644 facebook_graph_service.py create mode 100644 gbp_management_service.py create mode 100644 search_console_service.py create mode 100644 templates/admin/company_settings.html diff --git a/audit_ai_service.py b/audit_ai_service.py index f950480..7df9b36 100644 --- a/audit_ai_service.py +++ b/audit_ai_service.py @@ -156,6 +156,25 @@ def _collect_seo_data(db, company) -> dict: except Exception as e: logger.warning(f"CrUX error for {company.website}: {e}") + # Search Console data via OAuth (if available) + search_console_data = {} + try: + from oauth_service import OAuthService + from search_console_service import SearchConsoleService + + oauth = OAuthService() + gsc_token = oauth.get_valid_token(db, company.id, 'google', 'search_console') + if gsc_token and company.website: + gsc = SearchConsoleService(gsc_token) + analytics = gsc.get_search_analytics(company.website, days=28) + if analytics: + search_console_data = analytics + logger.info(f"Search Console data for company {company.id}: {analytics.get('clicks', 0)} clicks") + except ImportError: + pass + except Exception as e: + logger.warning(f"Search Console data collection failed: {e}") + # Persist live-collected data to DB for dashboard display try: if security_headers: @@ -262,6 +281,13 @@ def _collect_seo_data(db, company) -> dict: 'crux_lcp_good_pct': crux_data.get('crux_lcp_ms_good_pct'), 'crux_inp_good_pct': crux_data.get('crux_inp_ms_good_pct'), 'crux_period_end': crux_data.get('crux_period_end'), + # Search Console (OAuth) + 'gsc_clicks': search_console_data.get('clicks'), + 'gsc_impressions': search_console_data.get('impressions'), + 'gsc_ctr': search_console_data.get('ctr'), + 'gsc_avg_position': search_console_data.get('position'), + 'gsc_top_queries': search_console_data.get('top_queries', []), + 'gsc_period_days': search_console_data.get('period_days', 28), } @@ -484,6 +510,13 @@ Dane strukturalne: - Pola LocalBusiness Schema: {data.get('local_business_schema_fields', 'brak danych')} - Język strony (html lang): {data.get('html_lang', 'brak')} +Search Console (dane z Google Search Console, ostatnie {data.get('gsc_period_days', 28)} dni): +- Kliknięcia: {data.get('gsc_clicks', 'brak danych (wymaga połączenia OAuth)')} +- Wyświetlenia: {data.get('gsc_impressions', 'brak danych')} +- CTR: {data.get('gsc_ctr', 'brak danych')}% +- Średnia pozycja: {data.get('gsc_avg_position', 'brak danych')} +- Top zapytania: {', '.join(q.get('query', '') for q in (data.get('gsc_top_queries') or [])[:5]) or 'brak danych'} + Social & Analytics: - Open Graph: {'tak' if data.get('has_og_tags') else 'NIE'} - Twitter Cards: {'tak' if data.get('has_twitter_cards') else 'NIE'} diff --git a/blueprints/admin/routes_companies.py b/blueprints/admin/routes_companies.py index daf9a3c..5af1927 100644 --- a/blueprints/admin/routes_companies.py +++ b/blueprints/admin/routes_companies.py @@ -599,3 +599,35 @@ def admin_companies_export(): ) finally: db.close() + + +@bp.route('/companies//settings') +@login_required +@role_required(SystemRole.OFFICE_MANAGER) +def company_settings(company_id): + """Company settings page with OAuth integrations UI.""" + db = SessionLocal() + try: + company = db.query(Company).filter(Company.id == company_id).first() + if not company: + flash('Firma nie istnieje', 'error') + return redirect(url_for('admin.admin_companies')) + + from oauth_service import OAuthService + oauth = OAuthService() + connections = oauth.get_connected_services(db, company_id) + + # Check if OAuth credentials are configured + oauth_available = { + 'google': bool(oauth.google_client_id), + 'meta': bool(oauth.meta_app_id), + } + + return render_template( + 'admin/company_settings.html', + company=company, + connections=connections, + oauth_available=oauth_available, + ) + finally: + db.close() diff --git a/blueprints/api/routes_oauth.py b/blueprints/api/routes_oauth.py index ab0d64e..b3dd50d 100644 --- a/blueprints/api/routes_oauth.py +++ b/blueprints/api/routes_oauth.py @@ -68,18 +68,33 @@ def oauth_callback(provider): state = request.args.get('state') error = request.args.get('error') + # Parse state early to get company_id for redirects + # State format: company_id:user_id:service:random + state_company_id = None + if state: + try: + state_company_id = int(state.split(':')[0]) + except (ValueError, IndexError): + pass + + def settings_redirect(params): + """Redirect to company settings page with query params.""" + if state_company_id: + return redirect(f'/admin/companies/{state_company_id}/settings?{params}') + return redirect(f'/admin/companies?{params}') + if error: logger.warning(f"OAuth error from {provider}: {error}") - return redirect(f'/admin/company?oauth_error={error}') + return settings_redirect(f'oauth_error={error}') if not code or not state: - return redirect('/admin/company?oauth_error=missing_params') + return settings_redirect('oauth_error=missing_params') # Validate state saved_state = session.pop('oauth_state', None) if not saved_state or saved_state != state: logger.warning(f"OAuth state mismatch for {provider}") - return redirect('/admin/company?oauth_error=invalid_state') + return settings_redirect('oauth_error=invalid_state') # Parse state: company_id:user_id:service:random try: @@ -88,18 +103,18 @@ def oauth_callback(provider): user_id = int(parts[1]) service = parts[2] except (ValueError, IndexError): - return redirect('/admin/company?oauth_error=invalid_state_format') + return settings_redirect('oauth_error=invalid_state_format') # Verify user owns this company if current_user.id != user_id or current_user.company_id != company_id: - return redirect('/admin/company?oauth_error=unauthorized') + return redirect(f'/admin/companies/{company_id}/settings?oauth_error=unauthorized') # Exchange code for token oauth = OAuthService() token_data = oauth.exchange_code(provider, code) if not token_data: - return redirect('/admin/company?oauth_error=token_exchange_failed') + return redirect(f'/admin/companies/{company_id}/settings?oauth_error=token_exchange_failed') # Save token db = SessionLocal() @@ -107,9 +122,9 @@ def oauth_callback(provider): success = oauth.save_token(db, company_id, user_id, provider, service, token_data) if success: logger.info(f"OAuth connected: {provider}/{service} for company {company_id} by user {user_id}") - return redirect(f'/admin/company?oauth_success={provider}/{service}') + return redirect(f'/admin/companies/{company_id}/settings?oauth_success={provider}/{service}') else: - return redirect('/admin/company?oauth_error=save_failed') + return redirect(f'/admin/companies/{company_id}/settings?oauth_error=save_failed') finally: db.close() @@ -182,3 +197,36 @@ def oauth_disconnect(provider, service): return jsonify({'success': False, 'error': 'Nie znaleziono połączenia'}), 404 finally: db.close() + + +@bp.route('/oauth/google/discover-locations', methods=['POST']) +@login_required +def oauth_discover_gbp_locations(): + """Auto-discover GBP locations after OAuth connection.""" + if not current_user.company_id: + return jsonify({'success': False, 'error': 'Brak firmy'}), 403 + + from oauth_service import OAuthService + oauth = OAuthService() + db = SessionLocal() + try: + token = oauth.get_valid_token(db, current_user.company_id, 'google', 'gbp') + if not token: + return jsonify({'success': False, 'error': 'Brak połączenia GBP'}), 404 + + try: + from gbp_management_service import GBPManagementService + gbp = GBPManagementService(token) + accounts = gbp.list_accounts() + locations = [] + for acc in accounts: + acc_locations = gbp.list_locations(acc.get('name', '')) + locations.extend(acc_locations) + return jsonify({'success': True, 'locations': locations}) + except ImportError: + return jsonify({'success': False, 'error': 'Serwis GBP Management niedostępny'}), 503 + except Exception as e: + logger.error(f"GBP discover locations error: {e}") + return jsonify({'success': False, 'error': 'Błąd podczas wyszukiwania lokalizacji'}), 500 + finally: + db.close() diff --git a/facebook_graph_service.py b/facebook_graph_service.py new file mode 100644 index 0000000..f2737c8 --- /dev/null +++ b/facebook_graph_service.py @@ -0,0 +1,123 @@ +""" +Facebook + Instagram Graph API Client +====================================== + +Uses OAuth 2.0 page tokens to access Facebook Page and Instagram Business data. + +API docs: https://developers.facebook.com/docs/graph-api/ +""" + +import logging +from datetime import datetime, timedelta +from typing import Dict, List, Optional + +import requests + +logger = logging.getLogger(__name__) + + +class FacebookGraphService: + """Facebook + Instagram Graph API client.""" + + BASE_URL = "https://graph.facebook.com/v21.0" + + def __init__(self, access_token: str): + self.access_token = access_token + self.session = requests.Session() + self.session.timeout = 15 + + def _get(self, endpoint: str, params: dict = None) -> Optional[Dict]: + """Make authenticated GET request.""" + params = params or {} + params['access_token'] = self.access_token + try: + resp = self.session.get(f"{self.BASE_URL}/{endpoint}", params=params) + resp.raise_for_status() + return resp.json() + except Exception as e: + logger.error(f"Facebook API {endpoint} failed: {e}") + return None + + def get_managed_pages(self) -> List[Dict]: + """Get Facebook pages managed by the authenticated user.""" + data = self._get('me/accounts', {'fields': 'id,name,category,access_token,fan_count'}) + return data.get('data', []) if data else [] + + def get_page_info(self, page_id: str) -> Optional[Dict]: + """Get detailed page information.""" + return self._get(page_id, { + 'fields': 'id,name,fan_count,category,link,about,website,phone,single_line_address,followers_count' + }) + + def get_page_insights(self, page_id: str, days: int = 28) -> Dict: + """Get page insights (impressions, engaged users, reactions). + + Note: Requires page access token, not user access token. + The page token should be stored during OAuth connection. + """ + since = datetime.now() - timedelta(days=days) + until = datetime.now() + + metrics = 'page_impressions,page_engaged_users,page_fans,page_views_total' + data = self._get(f'{page_id}/insights', { + 'metric': metrics, + 'period': 'day', + 'since': int(since.timestamp()), + 'until': int(until.timestamp()), + }) + + if not data: + return {} + + result = {} + for metric in data.get('data', []): + name = metric.get('name', '') + values = metric.get('values', []) + if values: + total = sum(v.get('value', 0) for v in values if isinstance(v.get('value'), (int, float))) + result[name] = total + + return result + + def get_instagram_account(self, page_id: str) -> Optional[str]: + """Get linked Instagram Business account ID from a Facebook Page.""" + data = self._get(page_id, {'fields': 'instagram_business_account'}) + if data and 'instagram_business_account' in data: + return data['instagram_business_account'].get('id') + return None + + def get_ig_media_insights(self, ig_account_id: str, days: int = 28) -> Dict: + """Get Instagram account insights. + + Returns follower_count, media_count, and recent media engagement. + """ + result = {} + + # Basic account info + account_data = self._get(ig_account_id, { + 'fields': 'followers_count,media_count,username,biography' + }) + if account_data: + result['followers_count'] = account_data.get('followers_count', 0) + result['media_count'] = account_data.get('media_count', 0) + result['username'] = account_data.get('username', '') + + # Account insights (reach, impressions) + since = datetime.now() - timedelta(days=days) + until = datetime.now() + insights_data = self._get(f'{ig_account_id}/insights', { + 'metric': 'impressions,reach,follower_count', + 'period': 'day', + 'since': int(since.timestamp()), + 'until': int(until.timestamp()), + }) + + if insights_data: + for metric in insights_data.get('data', []): + name = metric.get('name', '') + values = metric.get('values', []) + if values: + total = sum(v.get('value', 0) for v in values if isinstance(v.get('value'), (int, float))) + result[f'ig_{name}_total'] = total + + return result diff --git a/gbp_audit_service.py b/gbp_audit_service.py index fbf6d9e..40d3b5d 100644 --- a/gbp_audit_service.py +++ b/gbp_audit_service.py @@ -23,7 +23,7 @@ from typing import Dict, List, Optional, Any from sqlalchemy.orm import Session -from database import Company, GBPAudit, GBPReview, CompanyWebsiteAnalysis, SessionLocal +from database import Company, GBPAudit, GBPReview, CompanyWebsiteAnalysis, SessionLocal, OAuthToken import gemini_service try: @@ -1895,6 +1895,44 @@ def fetch_google_business_data( details_msg.append(f'+{sum(len(v) for v in attributes.values() if isinstance(v, dict))} atrybutów') result['steps'][-1]['message'] = ', '.join(details_msg) if details_msg else 'Pobrano dane' + # OAuth: Try GBP Management API for owner-specific data + try: + from oauth_service import OAuthService + from gbp_management_service import GBPManagementService + + oauth = OAuthService() + gbp_token = oauth.get_valid_token(db, company_id, 'google', 'gbp') + if gbp_token: + token_record = db.query(OAuthToken).filter( + OAuthToken.company_id == company_id, + OAuthToken.provider == 'google', + OAuthToken.service == 'gbp', + OAuthToken.is_active == True, + ).first() + location_name = None + if token_record and token_record.metadata_json: + location_name = token_record.metadata_json.get('location_name') + + if location_name: + gbp_mgmt = GBPManagementService(gbp_token) + reviews = gbp_mgmt.get_reviews(location_name) + if reviews: + owner_responses = sum(1 for r in reviews if r.get('reviewReply')) + result['data']['google_owner_responses_count'] = owner_responses + result['data']['google_total_reviews_with_replies'] = len(reviews) + result['data']['google_review_response_rate'] = round( + owner_responses / len(reviews) * 100, 1 + ) if reviews else 0 + + posts = gbp_mgmt.get_local_posts(location_name) + if posts: + result['data']['google_posts_count'] = len(posts) + result['data']['google_posts_data'] = posts[:10] + + logger.info(f"OAuth GBP enrichment: {len(reviews or [])} reviews, {len(posts or [])} posts for company {company_id}") + except Exception as e: + logger.warning(f"OAuth GBP enrichment failed for company {company_id}: {e}") + # Step 3: Save to database result['steps'].append({ 'step': 'save_data', diff --git a/gbp_management_service.py b/gbp_management_service.py new file mode 100644 index 0000000..5933ca8 --- /dev/null +++ b/gbp_management_service.py @@ -0,0 +1,115 @@ +""" +Google Business Profile Management API Client +============================================== + +Uses OAuth 2.0 access tokens to access owner-specific GBP data +not available through the public Places API (e.g., owner responses to reviews, posts). + +API docs: https://developers.google.com/my-business/reference/rest +""" + +import logging +from typing import List, Dict, Optional + +import requests + +logger = logging.getLogger(__name__) + + +class GBPManagementService: + """Google Business Profile Management API client.""" + + ACCOUNT_MGMT_URL = "https://mybusinessaccountmanagement.googleapis.com/v1" + BUSINESS_INFO_URL = "https://mybusinessbusinessinformation.googleapis.com/v1" + + def __init__(self, access_token: str): + self.session = requests.Session() + self.session.headers.update({ + 'Authorization': f'Bearer {access_token}', + 'Content-Type': 'application/json', + }) + self.session.timeout = 15 + + def list_accounts(self) -> List[Dict]: + """List all GBP accounts for the authenticated user.""" + try: + resp = self.session.get(f"{self.ACCOUNT_MGMT_URL}/accounts") + resp.raise_for_status() + return resp.json().get('accounts', []) + except Exception as e: + logger.error(f"GBP list_accounts failed: {e}") + return [] + + def list_locations(self, account_name: str) -> List[Dict]: + """List locations for a GBP account. + + Args: + account_name: Full account resource name (e.g., 'accounts/123456') + """ + try: + resp = self.session.get( + f"{self.BUSINESS_INFO_URL}/{account_name}/locations", + params={'readMask': 'name,title,storefrontAddress,metadata'} + ) + resp.raise_for_status() + return resp.json().get('locations', []) + except Exception as e: + logger.error(f"GBP list_locations failed for {account_name}: {e}") + return [] + + def get_reviews(self, location_name: str, max_results: int = 50) -> List[Dict]: + """Get reviews INCLUDING owner responses (reviewReply). + + This is the key advantage over Places API — reviewReply contains + the business owner's response to each review. + + Args: + location_name: Full location resource name (e.g., 'accounts/123/locations/456') + max_results: Max reviews to return + """ + try: + reviews = [] + page_token = None + while len(reviews) < max_results: + params = {'pageSize': min(50, max_results - len(reviews))} + if page_token: + params['pageToken'] = page_token + resp = self.session.get( + f"{self.ACCOUNT_MGMT_URL}/{location_name}/reviews", + params=params + ) + resp.raise_for_status() + data = resp.json() + reviews.extend(data.get('reviews', [])) + page_token = data.get('nextPageToken') + if not page_token: + break + return reviews + except Exception as e: + logger.error(f"GBP get_reviews failed for {location_name}: {e}") + return [] + + def get_local_posts(self, location_name: str) -> List[Dict]: + """Get Google Posts for a location.""" + try: + resp = self.session.get( + f"{self.ACCOUNT_MGMT_URL}/{location_name}/localPosts", + params={'pageSize': 20} + ) + resp.raise_for_status() + return resp.json().get('localPosts', []) + except Exception as e: + logger.error(f"GBP get_local_posts failed for {location_name}: {e}") + return [] + + def match_location_by_place_id(self, account_name: str, place_id: str) -> Optional[Dict]: + """Find a location that matches a Google Place ID. + + Iterates locations and checks metadata.placeId. + """ + locations = self.list_locations(account_name) + for loc in locations: + metadata = loc.get('metadata', {}) + if metadata.get('placeId') == place_id: + return loc + return None diff --git a/scripts/social_media_audit.py b/scripts/social_media_audit.py index 6102665..86fa1f0 100644 --- a/scripts/social_media_audit.py +++ b/scripts/social_media_audit.py @@ -1238,6 +1238,56 @@ class SocialMediaAuditor: result['social_media'] = website_social logger.info(f"Total social media profiles found: {len(website_social)} - {list(website_social.keys())}") + # OAuth: Try Facebook/Instagram Graph API for authenticated data + try: + from oauth_service import OAuthService + from facebook_graph_service import FacebookGraphService + from database import SessionLocal as OAuthSessionLocal, OAuthToken + + oauth = OAuthService() + company_id = company.get('id') + if company_id: + oauth_db = OAuthSessionLocal() + try: + fb_token = oauth.get_valid_token(oauth_db, company_id, 'meta', 'facebook') + if fb_token: + fb_service = FacebookGraphService(fb_token) + token_rec = oauth_db.query(OAuthToken).filter( + OAuthToken.company_id == company_id, + OAuthToken.provider == 'meta', + OAuthToken.service == 'facebook', + OAuthToken.is_active == True, + ).first() + page_id = token_rec.account_id if token_rec else None + + if page_id: + page_info = fb_service.get_page_info(page_id) + if page_info: + result['oauth_facebook'] = { + 'fan_count': page_info.get('fan_count'), + 'category': page_info.get('category'), + 'data_source': 'oauth_api', + } + insights = fb_service.get_page_insights(page_id) + if insights: + result['oauth_facebook_insights'] = insights + + ig_id = fb_service.get_instagram_account(page_id) + if ig_id: + ig_insights = fb_service.get_ig_media_insights(ig_id) + if ig_insights: + result['oauth_instagram'] = { + **ig_insights, + 'data_source': 'oauth_api', + } + logger.info(f"OAuth Facebook/IG enrichment done for company {company_id}") + finally: + oauth_db.close() + except ImportError: + pass # Services not yet available + except Exception as e: + logger.warning(f"OAuth social media enrichment failed: {e}") + # 5. Enrich social media profiles with additional data enriched_profiles = {} for platform, url in website_social.items(): diff --git a/search_console_service.py b/search_console_service.py new file mode 100644 index 0000000..90c7b32 --- /dev/null +++ b/search_console_service.py @@ -0,0 +1,149 @@ +""" +Google Search Console API Client +================================= + +Uses OAuth 2.0 to fetch search analytics data (clicks, impressions, CTR, positions). + +API docs: https://developers.google.com/webmaster-tools/v3/searchanalytics +""" + +import logging +from datetime import datetime, timedelta +from typing import Dict, List, Optional + +import requests + +logger = logging.getLogger(__name__) + + +class SearchConsoleService: + """Google Search Console API client.""" + + BASE_URL = "https://www.googleapis.com/webmasters/v3" + + def __init__(self, access_token: str): + self.session = requests.Session() + self.session.headers.update({ + 'Authorization': f'Bearer {access_token}', + 'Content-Type': 'application/json', + }) + self.session.timeout = 15 + + def list_sites(self) -> List[Dict]: + """List verified sites in Search Console.""" + try: + resp = self.session.get(f"{self.BASE_URL}/sites") + resp.raise_for_status() + return resp.json().get('siteEntry', []) + except Exception as e: + logger.error(f"Search Console list_sites failed: {e}") + return [] + + def _normalize_site_url(self, url: str) -> Optional[str]: + """Try to find matching site URL in Search Console. + + Search Console uses exact URL format — with/without trailing slash, + http/https, www/no-www. Try common variants. + """ + sites = self.list_sites() + site_urls = [s.get('siteUrl', '') for s in sites] + + # Direct match + if url in site_urls: + return url + + # Try variants + variants = [url] + if not url.endswith('/'): + variants.append(url + '/') + if url.startswith('https://'): + variants.append(url.replace('https://', 'http://')) + if url.startswith('http://'): + variants.append(url.replace('http://', 'https://')) + # www variants + for v in list(variants): + if '://www.' in v: + variants.append(v.replace('://www.', '://')) + else: + variants.append(v.replace('://', '://www.')) + + for v in variants: + if v in site_urls: + return v + + return None + + def get_search_analytics(self, site_url: str, days: int = 28) -> Dict: + """Get search analytics for a site. + + Returns: + Dict with keys: clicks, impressions, ctr, position, + top_queries (list), top_pages (list), period_days + """ + normalized = self._normalize_site_url(site_url) + if not normalized: + logger.warning(f"Site {site_url} not found in Search Console") + return {} + + end_date = datetime.now() - timedelta(days=3) # SC data has ~3 day delay + start_date = end_date - timedelta(days=days) + + try: + # Totals + resp = self.session.post( + f"{self.BASE_URL}/sites/{requests.utils.quote(normalized, safe='')}/searchAnalytics/query", + json={ + 'startDate': start_date.strftime('%Y-%m-%d'), + 'endDate': end_date.strftime('%Y-%m-%d'), + 'dimensions': [], + } + ) + resp.raise_for_status() + rows = resp.json().get('rows', []) + + totals = rows[0] if rows else {} + result = { + 'clicks': totals.get('clicks', 0), + 'impressions': totals.get('impressions', 0), + 'ctr': round(totals.get('ctr', 0) * 100, 2), + 'position': round(totals.get('position', 0), 1), + 'period_days': days, + } + + # Top queries + resp_q = self.session.post( + f"{self.BASE_URL}/sites/{requests.utils.quote(normalized, safe='')}/searchAnalytics/query", + json={ + 'startDate': start_date.strftime('%Y-%m-%d'), + 'endDate': end_date.strftime('%Y-%m-%d'), + 'dimensions': ['query'], + 'rowLimit': 10, + } + ) + if resp_q.status_code == 200: + result['top_queries'] = [ + {'query': r['keys'][0], 'clicks': r.get('clicks', 0), 'impressions': r.get('impressions', 0)} + for r in resp_q.json().get('rows', []) + ] + + # Top pages + resp_p = self.session.post( + f"{self.BASE_URL}/sites/{requests.utils.quote(normalized, safe='')}/searchAnalytics/query", + json={ + 'startDate': start_date.strftime('%Y-%m-%d'), + 'endDate': end_date.strftime('%Y-%m-%d'), + 'dimensions': ['page'], + 'rowLimit': 10, + } + ) + if resp_p.status_code == 200: + result['top_pages'] = [ + {'page': r['keys'][0], 'clicks': r.get('clicks', 0), 'impressions': r.get('impressions', 0)} + for r in resp_p.json().get('rows', []) + ] + + return result + + except Exception as e: + logger.error(f"Search Console analytics failed for {site_url}: {e}") + return {} diff --git a/templates/admin/company_settings.html b/templates/admin/company_settings.html new file mode 100644 index 0000000..65cb8c6 --- /dev/null +++ b/templates/admin/company_settings.html @@ -0,0 +1,617 @@ +{% extends "base.html" %} + +{% block title %}Ustawienia firmy - {{ company.name }} - Norda Biznes Partner{% endblock %} + +{% block extra_css %} + .settings-header { + margin-bottom: var(--spacing-xl); + } + + .settings-header h1 { + font-size: var(--font-size-3xl); + color: var(--text-primary); + margin: 0; + } + + .settings-header .breadcrumb { + display: flex; + align-items: center; + gap: var(--spacing-xs); + margin-bottom: var(--spacing-sm); + font-size: var(--font-size-sm); + color: var(--text-secondary); + } + + .settings-header .breadcrumb a { + color: var(--primary); + text-decoration: none; + } + + .settings-header .breadcrumb a:hover { + text-decoration: underline; + } + + .settings-header .breadcrumb svg { + width: 14px; + height: 14px; + color: var(--text-muted); + } + + .settings-subtitle { + margin: var(--spacing-xs) 0 0 0; + color: var(--text-secondary); + } + + /* OAuth Cards Grid */ + .oauth-cards { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(320px, 1fr)); + gap: var(--spacing-lg); + margin-bottom: var(--spacing-xl); + } + + .oauth-card { + background: white; + border-radius: var(--radius-lg); + box-shadow: var(--shadow-sm); + padding: var(--spacing-lg); + border: 1px solid var(--border); + transition: var(--transition); + } + + .oauth-card:hover { + box-shadow: var(--shadow); + } + + .oauth-card-header { + display: flex; + align-items: center; + gap: var(--spacing-md); + margin-bottom: var(--spacing-md); + } + + .oauth-card-icon { + width: 44px; + height: 44px; + border-radius: var(--radius); + display: flex; + align-items: center; + justify-content: center; + flex-shrink: 0; + } + + .oauth-card-icon.google { + background: #f0f7ff; + color: #4285F4; + } + + .oauth-card-icon.meta { + background: #f0f0ff; + color: #1877F2; + } + + .oauth-card-icon svg { + width: 24px; + height: 24px; + } + + .oauth-card-title { + font-size: var(--font-size-lg); + font-weight: 600; + color: var(--text-primary); + margin: 0; + } + + .oauth-card-desc { + font-size: var(--font-size-sm); + color: var(--text-secondary); + margin: 0 0 var(--spacing-md) 0; + line-height: 1.5; + } + + /* Status badges */ + .oauth-status { + display: inline-flex; + align-items: center; + gap: var(--spacing-xs); + padding: var(--spacing-xs) var(--spacing-sm); + border-radius: var(--radius); + font-size: var(--font-size-sm); + font-weight: 500; + margin-bottom: var(--spacing-md); + } + + .oauth-status.connected { + background: var(--success-light, #dcfce7); + color: var(--success, #16a34a); + } + + .oauth-status.unavailable { + background: var(--surface); + color: var(--text-muted); + } + + .oauth-status.expired { + background: var(--warning-light, #fef3c7); + color: var(--warning, #d97706); + } + + .oauth-status svg { + width: 14px; + height: 14px; + } + + .oauth-account-name { + font-size: var(--font-size-sm); + color: var(--text-secondary); + margin-bottom: var(--spacing-md); + } + + .oauth-card-actions { + display: flex; + gap: var(--spacing-sm); + flex-wrap: wrap; + } + + .btn-oauth { + display: inline-flex; + align-items: center; + gap: var(--spacing-xs); + padding: var(--spacing-sm) var(--spacing-md); + border-radius: var(--radius); + font-size: var(--font-size-sm); + font-weight: 500; + cursor: pointer; + transition: var(--transition); + border: none; + text-decoration: none; + } + + .btn-oauth.connect { + background: var(--primary); + color: white; + } + + .btn-oauth.connect:hover { + opacity: 0.9; + } + + .btn-oauth.disconnect { + background: transparent; + color: var(--error, #dc2626); + border: 1px solid var(--error, #dc2626); + } + + .btn-oauth.disconnect:hover { + background: var(--error, #dc2626); + color: white; + } + + .btn-oauth.discover { + background: transparent; + color: var(--primary); + border: 1px solid var(--primary); + } + + .btn-oauth.discover:hover { + background: var(--primary); + color: white; + } + + .btn-oauth:disabled { + opacity: 0.5; + cursor: not-allowed; + } + + .btn-oauth svg { + width: 16px; + height: 16px; + } + + /* Toast notification */ + .toast { + position: fixed; + top: var(--spacing-lg); + right: var(--spacing-lg); + padding: var(--spacing-md) var(--spacing-lg); + border-radius: var(--radius); + font-size: var(--font-size-sm); + font-weight: 500; + z-index: 9999; + animation: toastIn 0.3s ease-out; + box-shadow: var(--shadow-lg); + } + + .toast.success { + background: var(--success, #16a34a); + color: white; + } + + .toast.error { + background: var(--error, #dc2626); + color: white; + } + + @keyframes toastIn { + from { opacity: 0; transform: translateY(-20px); } + to { opacity: 1; transform: translateY(0); } + } + + /* Section title */ + .section-title { + font-size: var(--font-size-xl); + font-weight: 600; + color: var(--text-primary); + margin: 0 0 var(--spacing-md) 0; + } + + .section-desc { + color: var(--text-secondary); + margin: 0 0 var(--spacing-lg) 0; + font-size: var(--font-size-sm); + } + + /* Back link */ + .back-link { + display: inline-flex; + align-items: center; + gap: var(--spacing-xs); + color: var(--primary); + text-decoration: none; + font-size: var(--font-size-sm); + margin-bottom: var(--spacing-md); + } + + .back-link:hover { + text-decoration: underline; + } + + .back-link svg { + width: 16px; + height: 16px; + } +{% endblock %} + +{% block content %} +
+ +
+ +

Ustawienia firmy

+

{{ company.name }} — integracje z zewnętrznymi serwisami

+
+ + +

Połączenia OAuth

+

Połącz konta zewnętrznych serwisów, aby wzbogacić audyty o dodatkowe dane. Każdy serwis wymaga osobnej autoryzacji.

+ +
+ + {% set gbp_conn = connections.get('google/gbp', {}) %} +
+
+
+ +
+

Google Business Profile

+
+

Opinie z odpowiedziami właściciela, posty, zdjęcia, Q&A i statystyki widoczności wizytówki Google.

+ + {% if gbp_conn.get('connected') %} + {% if gbp_conn.get('is_expired') %} +
+ + Token wygasł — wymagane ponowne połączenie +
+ {% else %} +
+ + Połączono +
+ {% endif %} + {% if gbp_conn.get('account_name') %} + + {% endif %} +
+ + +
+ {% elif oauth_available.get('google') %} +
+ +
+ {% else %} +
+ + Niedostępne — wymaga konfiguracji administratora +
+ {% endif %} +
+ + + {% set sc_conn = connections.get('google/search_console', {}) %} +
+
+
+ +
+

Google Search Console

+
+

Zapytania wyszukiwania, CTR, średnia pozycja, indeksowanie stron i dane o wydajności w Google.

+ + {% if sc_conn.get('connected') %} + {% if sc_conn.get('is_expired') %} +
+ + Token wygasł — wymagane ponowne połączenie +
+ {% else %} +
+ + Połączono +
+ {% endif %} + {% if sc_conn.get('account_name') %} + + {% endif %} +
+ +
+ {% elif oauth_available.get('google') %} +
+ +
+ {% else %} +
+ + Niedostępne — wymaga konfiguracji administratora +
+ {% endif %} +
+ + + {% set fb_conn = connections.get('meta/facebook', {}) %} +
+
+
+ +
+

Facebook

+
+

Zasięg strony, impressions, engagement, dane demograficzne odbiorców i statystyki postów.

+ + {% if fb_conn.get('connected') %} + {% if fb_conn.get('is_expired') %} +
+ + Token wygasł — wymagane ponowne połączenie +
+ {% else %} +
+ + Połączono +
+ {% endif %} + {% if fb_conn.get('account_name') %} + + {% endif %} +
+ +
+ {% elif oauth_available.get('meta') %} +
+ +
+ {% else %} +
+ + Niedostępne — wymaga konfiguracji administratora +
+ {% endif %} +
+ + + {% set ig_conn = connections.get('meta/instagram', {}) %} +
+
+
+ +
+

Instagram

+
+

Stories, reels, engagement, zasięg postów i dane demograficzne obserwujących konto firmowe.

+ + {% if ig_conn.get('connected') %} + {% if ig_conn.get('is_expired') %} +
+ + Token wygasł — wymagane ponowne połączenie +
+ {% else %} +
+ + Połączono +
+ {% endif %} + {% if ig_conn.get('account_name') %} + + {% endif %} +
+ +
+ {% elif oauth_available.get('meta') %} +
+ +
+ {% else %} +
+ + Niedostępne — wymaga konfiguracji administratora +
+ {% endif %} +
+
+ + + + Powrót do szczegółów firmy + +
+{% endblock %} + +{% block extra_js %} + // OAuth Settings JS + var csrfToken = document.querySelector('meta[name="csrf-token"]').getAttribute('content'); + + function showToast(message, type) { + var existing = document.querySelector('.toast'); + if (existing) existing.remove(); + + var toast = document.createElement('div'); + toast.className = 'toast ' + (type || 'success'); + toast.textContent = message; + document.body.appendChild(toast); + + setTimeout(function() { + toast.style.opacity = '0'; + toast.style.transition = 'opacity 0.3s'; + setTimeout(function() { toast.remove(); }, 300); + }, 4000); + } + + function connectOAuth(provider, service) { + fetch('/api/oauth/connect/' + provider + '/' + service, { + method: 'POST', + headers: { + 'X-CSRFToken': csrfToken, + 'Content-Type': 'application/json' + } + }) + .then(function(r) { return r.json(); }) + .then(function(data) { + if (data.auth_url) { + window.location.href = data.auth_url; + } else { + showToast(data.error || 'Nie udało się rozpocząć autoryzacji', 'error'); + } + }) + .catch(function(err) { + showToast('Błąd połączenia: ' + err.message, 'error'); + }); + } + + function disconnectOAuth(provider, service) { + if (!confirm('Czy na pewno chcesz rozłączyć ten serwis? Audyty stracą dostęp do rozszerzonych danych.')) return; + + fetch('/api/oauth/disconnect/' + provider + '/' + service, { + method: 'POST', + headers: { + 'X-CSRFToken': csrfToken + } + }) + .then(function(r) { return r.json(); }) + .then(function(data) { + if (data.success) { + showToast('Serwis rozłączony pomyślnie'); + setTimeout(function() { location.reload(); }, 1000); + } else { + showToast(data.error || 'Nie udało się rozłączyć', 'error'); + } + }) + .catch(function(err) { + showToast('Błąd: ' + err.message, 'error'); + }); + } + + function discoverGBPLocations() { + var btn = document.querySelector('.btn-oauth.discover'); + if (btn) { + btn.disabled = true; + btn.innerHTML = ' Szukam...'; + } + + fetch('/api/oauth/google/discover-locations', { + method: 'POST', + headers: { + 'X-CSRFToken': csrfToken, + 'Content-Type': 'application/json' + } + }) + .then(function(r) { return r.json(); }) + .then(function(data) { + if (data.success) { + var count = data.locations ? data.locations.length : 0; + showToast('Znaleziono ' + count + ' lokalizacji GBP'); + } else { + showToast(data.error || 'Nie udało się wyszukać lokalizacji', 'error'); + } + if (btn) { + btn.disabled = false; + btn.innerHTML = ' Wykryj lokalizacje'; + } + }) + .catch(function(err) { + showToast('Błąd: ' + err.message, 'error'); + if (btn) { + btn.disabled = false; + btn.innerHTML = ' Wykryj lokalizacje'; + } + }); + } + + // Handle URL params for OAuth callback results + (function() { + var params = new URLSearchParams(window.location.search); + if (params.get('oauth_success')) { + showToast('Pomyślnie połączono: ' + params.get('oauth_success')); + // Clean URL + window.history.replaceState({}, document.title, window.location.pathname); + } + if (params.get('oauth_error')) { + var errorMap = { + 'missing_params': 'Brak wymaganych parametrów', + 'invalid_state': 'Nieprawidłowy token sesji', + 'invalid_state_format': 'Nieprawidłowy format tokenu', + 'unauthorized': 'Brak uprawnień', + 'token_exchange_failed': 'Wymiana tokenu nie powiodła się', + 'save_failed': 'Nie udało się zapisać tokenu' + }; + var error = params.get('oauth_error'); + showToast(errorMap[error] || 'Błąd OAuth: ' + error, 'error'); + window.history.replaceState({}, document.title, window.location.pathname); + } + })(); +{% endblock %}