diff --git a/blueprints/admin/routes_competitors.py b/blueprints/admin/routes_competitors.py new file mode 100644 index 0000000..3f1ee5b --- /dev/null +++ b/blueprints/admin/routes_competitors.py @@ -0,0 +1,157 @@ +""" +Admin Competitor Monitoring Routes +=================================== + +Dashboard for viewing and managing competitor tracking. +""" + +import logging +from datetime import datetime, timedelta + +from flask import render_template, request, jsonify, redirect, url_for +from flask_login import login_required, current_user + +from . import bp +from database import ( + SessionLocal, Company, CompanyCompetitor, CompetitorSnapshot, + GBPAudit, SystemRole +) +from utils.decorators import role_required + +logger = logging.getLogger(__name__) + + +@bp.route('/competitors') +@login_required +@role_required(SystemRole.OFFICE_MANAGER) +def admin_competitors(): + """ + Admin dashboard for competitor monitoring overview. + + Displays: + - List of companies with active competitor tracking + - Competitor count per company + - Recent changes detected + """ + db = SessionLocal() + try: + # Get companies that have competitors tracked + companies_with_competitors = ( + db.query(Company, db.query(CompanyCompetitor) + .filter(CompanyCompetitor.company_id == Company.id, + CompanyCompetitor.is_active == True) + .count().label('competitor_count')) + .filter(Company.status == 'active') + .all() + ) + + # Simpler approach: get all active competitors grouped by company + competitors = db.query(CompanyCompetitor).filter( + CompanyCompetitor.is_active == True + ).all() + + # Group by company + company_ids = set(c.company_id for c in competitors) + companies = db.query(Company).filter( + Company.id.in_(company_ids), + Company.status == 'active' + ).all() if company_ids else [] + + company_data = [] + for company in companies: + company_competitors = [c for c in competitors if c.company_id == company.id] + + # Get GBP audit for this company (for comparison) + gbp_audit = db.query(GBPAudit).filter( + GBPAudit.company_id == company.id + ).order_by(GBPAudit.audit_date.desc()).first() + + company_data.append({ + 'company': company, + 'competitor_count': len(company_competitors), + 'competitors': company_competitors, + 'gbp_audit': gbp_audit, + }) + + # Get recent snapshots with changes (last 30 days) + thirty_days_ago = datetime.now() - timedelta(days=30) + recent_changes = db.query(CompetitorSnapshot).filter( + CompetitorSnapshot.snapshot_date >= thirty_days_ago.date(), + CompetitorSnapshot.changes != None + ).order_by(CompetitorSnapshot.snapshot_date.desc()).limit(20).all() + + return render_template('admin/competitor_dashboard.html', + company_data=company_data, + recent_changes=recent_changes, + total_companies=len(company_data), + total_competitors=len(competitors), + ) + + finally: + db.close() + + +@bp.route('/competitors/') +@login_required +@role_required(SystemRole.OFFICE_MANAGER) +def admin_competitor_detail(company_id): + """ + Detailed competitor view for a specific company. + + Shows: + - Company's GBP metrics vs competitors + - Competitor list with current metrics + - Timeline of changes + """ + db = SessionLocal() + try: + company = db.query(Company).get(company_id) + if not company: + return redirect(url_for('admin.admin_competitors')) + + # Get company's own GBP audit + gbp_audit = db.query(GBPAudit).filter( + GBPAudit.company_id == company.id + ).order_by(GBPAudit.audit_date.desc()).first() + + # Get competitors + competitors = db.query(CompanyCompetitor).filter( + CompanyCompetitor.company_id == company.id, + CompanyCompetitor.is_active == True + ).all() + + # Get latest snapshots for each competitor + competitor_data = [] + for comp in competitors: + latest_snapshot = db.query(CompetitorSnapshot).filter( + CompetitorSnapshot.competitor_id == comp.id + ).order_by(CompetitorSnapshot.snapshot_date.desc()).first() + + competitor_data.append({ + 'competitor': comp, + 'latest_snapshot': latest_snapshot, + }) + + # Get all snapshots with changes (for timeline) + competitor_ids = [c.id for c in competitors] + timeline = [] + if competitor_ids: + timeline = db.query(CompetitorSnapshot).filter( + CompetitorSnapshot.competitor_id.in_(competitor_ids), + CompetitorSnapshot.changes != None + ).order_by(CompetitorSnapshot.snapshot_date.desc()).limit(30).all() + + return render_template('admin/competitor_dashboard.html', + company=company, + gbp_audit=gbp_audit, + competitor_data=competitor_data, + timeline=timeline, + detail_view=True, + total_companies=0, + total_competitors=len(competitors), + company_data=[], + recent_changes=[], + ) + + finally: + db.close() diff --git a/competitor_monitoring_service.py b/competitor_monitoring_service.py new file mode 100644 index 0000000..0c27915 --- /dev/null +++ b/competitor_monitoring_service.py @@ -0,0 +1,383 @@ +""" +Competitor Monitoring Service for NordaBiz +========================================== + +Discovers and monitors competitors via Google Places API. +Tracks changes in ratings, reviews, and profile activity. + +Author: NordaBiz Development Team +Created: 2026-02-06 +""" + +import os +import json +import logging +from datetime import datetime, date, timedelta +from typing import Optional, Dict, List, Any +from decimal import Decimal + +from sqlalchemy.orm import Session + +from database import ( + Company, CompanyCompetitor, CompetitorSnapshot, + CompanyWebsiteAnalysis, SessionLocal +) + +try: + from google_places_service import GooglePlacesService +except ImportError: + GooglePlacesService = None + +logger = logging.getLogger(__name__) + + +class CompetitorMonitoringService: + """Discovers and monitors competitors for companies.""" + + def __init__(self, db: Session): + self.db = db + self._places_service = None + + @property + def places_service(self) -> Optional['GooglePlacesService']: + """Lazy-init Google Places service.""" + if self._places_service is None and GooglePlacesService: + try: + self._places_service = GooglePlacesService() + except ValueError: + logger.warning("Google Places API key not configured") + return self._places_service + + def discover_competitors(self, company_id: int, max_results: int = 10) -> List[Dict[str, Any]]: + """ + Find competitors via Google Places Nearby Search. + + Uses the company's Google Place data to find similar businesses nearby. + """ + if not self.places_service: + logger.error("Google Places API not available for competitor discovery") + return [] + + company = self.db.query(Company).filter(Company.id == company_id).first() + if not company: + raise ValueError(f"Company {company_id} not found") + + # Get company's Google location + analysis = self.db.query(CompanyWebsiteAnalysis).filter( + CompanyWebsiteAnalysis.company_id == company_id + ).first() + + place_id = analysis.google_place_id if analysis else None + if not place_id: + logger.warning(f"Company {company_id} has no Google Place ID, searching by name") + # Search for the company first + place = self.places_service.search_place( + f"{company.name} {company.address_city or 'Wejherowo'}" + ) + if not place: + return [] + place_id = place.get('id', '').replace('places/', '') + + # Get place details to get location + place_data = self.places_service.get_place_details(place_id) + if not place_data: + return [] + + location = place_data.get('location', {}) + latitude = location.get('latitude') + longitude = location.get('longitude') + primary_type = place_data.get('primaryType') + + if not latitude or not longitude: + logger.error(f"No location data for company {company_id}") + return [] + + # Search nearby for similar businesses + included_types = [primary_type] if primary_type else None + nearby = self.places_service.search_nearby( + latitude=latitude, + longitude=longitude, + radius=5000.0, + included_types=included_types, + max_results=max_results + 1 # +1 because our own business may appear + ) + + # Filter out own business and save competitors + competitors = [] + for place in nearby: + competitor_place_id = place.get('id', '').replace('places/', '') + if competitor_place_id == place_id: + continue # Skip own business + + display_name = place.get('displayName', {}) + name = display_name.get('text', '') if isinstance(display_name, dict) else str(display_name) + + competitor_data = { + 'competitor_place_id': competitor_place_id, + 'competitor_name': name, + 'competitor_address': place.get('formattedAddress', ''), + 'competitor_rating': place.get('rating'), + 'competitor_review_count': place.get('userRatingCount'), + 'competitor_category': place.get('primaryType', ''), + 'competitor_website': place.get('websiteUri', ''), + } + competitors.append(competitor_data) + + if len(competitors) >= max_results: + break + + return competitors + + def save_competitors(self, company_id: int, competitors: List[Dict]) -> int: + """Save discovered competitors to database. Returns count saved.""" + saved = 0 + for comp_data in competitors: + existing = self.db.query(CompanyCompetitor).filter( + CompanyCompetitor.company_id == company_id, + CompanyCompetitor.competitor_place_id == comp_data['competitor_place_id'] + ).first() + + if existing: + # Update existing + existing.competitor_name = comp_data.get('competitor_name') or existing.competitor_name + existing.competitor_rating = comp_data.get('competitor_rating') or existing.competitor_rating + existing.competitor_review_count = comp_data.get('competitor_review_count') or existing.competitor_review_count + existing.updated_at = datetime.now() + else: + # Create new + competitor = CompanyCompetitor( + company_id=company_id, + competitor_place_id=comp_data['competitor_place_id'], + competitor_name=comp_data.get('competitor_name'), + competitor_address=comp_data.get('competitor_address'), + competitor_rating=comp_data.get('competitor_rating'), + competitor_review_count=comp_data.get('competitor_review_count'), + competitor_category=comp_data.get('competitor_category'), + competitor_website=comp_data.get('competitor_website'), + added_by='auto', + ) + self.db.add(competitor) + saved += 1 + + self.db.commit() + return saved + + def take_snapshot(self, competitor_id: int) -> Optional[CompetitorSnapshot]: + """ + Take a snapshot of a competitor's current state. + Compares with previous snapshot to detect changes. + """ + competitor = self.db.query(CompanyCompetitor).filter( + CompanyCompetitor.id == competitor_id + ).first() + + if not competitor or not competitor.is_active: + return None + + if not self.places_service: + logger.error("Google Places API not available for snapshots") + return None + + # Fetch current data from Google + place_data = self.places_service.get_place_details(competitor.competitor_place_id) + if not place_data: + logger.warning(f"Could not fetch data for competitor {competitor.competitor_name}") + return None + + today = date.today() + + # Check if snapshot already exists for today + existing = self.db.query(CompetitorSnapshot).filter( + CompetitorSnapshot.competitor_id == competitor_id, + CompetitorSnapshot.snapshot_date == today + ).first() + + if existing: + logger.info(f"Snapshot already exists for competitor {competitor_id} on {today}") + return existing + + # Build snapshot data + photos = place_data.get('photos', []) + display_name = place_data.get('displayName', {}) + + snapshot_data = { + 'name': display_name.get('text', '') if isinstance(display_name, dict) else str(display_name), + 'address': place_data.get('formattedAddress', ''), + 'rating': place_data.get('rating'), + 'review_count': place_data.get('userRatingCount', 0), + 'photo_count': len(photos), + 'business_status': place_data.get('businessStatus', ''), + 'types': place_data.get('types', []), + 'website': place_data.get('websiteUri', ''), + } + + # Get previous snapshot for change detection + previous = self.db.query(CompetitorSnapshot).filter( + CompetitorSnapshot.competitor_id == competitor_id + ).order_by(CompetitorSnapshot.snapshot_date.desc()).first() + + changes = {} + if previous: + prev_data = previous.data or {} + if snapshot_data.get('rating') != prev_data.get('rating'): + changes['rating'] = { + 'old': prev_data.get('rating'), + 'new': snapshot_data.get('rating') + } + if snapshot_data.get('review_count', 0) != prev_data.get('review_count', 0): + changes['review_count'] = { + 'old': prev_data.get('review_count', 0), + 'new': snapshot_data.get('review_count', 0), + 'delta': (snapshot_data.get('review_count', 0) or 0) - (prev_data.get('review_count', 0) or 0) + } + if snapshot_data.get('photo_count', 0) != prev_data.get('photo_count', 0): + changes['photo_count'] = { + 'old': prev_data.get('photo_count', 0), + 'new': snapshot_data.get('photo_count', 0) + } + + # Create snapshot + snapshot = CompetitorSnapshot( + competitor_id=competitor_id, + snapshot_date=today, + rating=snapshot_data.get('rating'), + review_count=snapshot_data.get('review_count'), + photo_count=snapshot_data.get('photo_count'), + has_website=bool(snapshot_data.get('website')), + has_description=bool(place_data.get('editorialSummary')), + data=snapshot_data, + changes=changes if changes else None, + ) + + self.db.add(snapshot) + + # Update competitor record with latest data + competitor.competitor_rating = snapshot_data.get('rating') + competitor.competitor_review_count = snapshot_data.get('review_count') + competitor.updated_at = datetime.now() + + self.db.commit() + self.db.refresh(snapshot) + + if changes: + logger.info(f"Changes detected for {competitor.competitor_name}: {list(changes.keys())}") + + return snapshot + + def take_all_snapshots(self, company_id: int) -> Dict[str, Any]: + """Take snapshots for all active competitors of a company.""" + competitors = self.db.query(CompanyCompetitor).filter( + CompanyCompetitor.company_id == company_id, + CompanyCompetitor.is_active == True + ).all() + + results = { + 'total': len(competitors), + 'success': 0, + 'failed': 0, + 'changes_detected': 0, + } + + for competitor in competitors: + try: + snapshot = self.take_snapshot(competitor.id) + if snapshot: + results['success'] += 1 + if snapshot.changes: + results['changes_detected'] += 1 + else: + results['failed'] += 1 + except Exception as e: + logger.error(f"Snapshot failed for competitor {competitor.id}: {e}") + results['failed'] += 1 + + return results + + def get_changes_report(self, company_id: int, days: int = 30) -> List[Dict[str, Any]]: + """Get competitor changes report for the last N days.""" + since = date.today() - timedelta(days=days) + + competitors = self.db.query(CompanyCompetitor).filter( + CompanyCompetitor.company_id == company_id, + CompanyCompetitor.is_active == True + ).all() + + report = [] + for competitor in competitors: + snapshots = self.db.query(CompetitorSnapshot).filter( + CompetitorSnapshot.competitor_id == competitor.id, + CompetitorSnapshot.snapshot_date >= since + ).order_by(CompetitorSnapshot.snapshot_date.asc()).all() + + changes = [] + for snap in snapshots: + if snap.changes: + changes.append({ + 'date': snap.snapshot_date.isoformat(), + 'changes': snap.changes, + }) + + report.append({ + 'competitor_id': competitor.id, + 'competitor_name': competitor.competitor_name, + 'current_rating': float(competitor.competitor_rating) if competitor.competitor_rating else None, + 'current_review_count': competitor.competitor_review_count, + 'snapshots_count': len(snapshots), + 'changes': changes, + }) + + return report + + def get_comparison(self, company_id: int) -> Dict[str, Any]: + """Compare company with its competitors.""" + company = self.db.query(Company).filter(Company.id == company_id).first() + if not company: + return {} + + analysis = self.db.query(CompanyWebsiteAnalysis).filter( + CompanyWebsiteAnalysis.company_id == company_id + ).first() + + company_data = { + 'name': company.name, + 'rating': float(analysis.google_rating) if analysis and analysis.google_rating else None, + 'review_count': analysis.google_reviews_count if analysis else 0, + 'photo_count': analysis.google_photos_count if analysis else 0, + } + + competitors = self.db.query(CompanyCompetitor).filter( + CompanyCompetitor.company_id == company_id, + CompanyCompetitor.is_active == True + ).all() + + competitor_data = [] + for comp in competitors: + competitor_data.append({ + 'name': comp.competitor_name, + 'rating': float(comp.competitor_rating) if comp.competitor_rating else None, + 'review_count': comp.competitor_review_count or 0, + }) + + # Calculate averages + ratings = [c['rating'] for c in competitor_data if c['rating']] + review_counts = [c['review_count'] for c in competitor_data] + + return { + 'company': company_data, + 'competitors': competitor_data, + 'avg_competitor_rating': round(sum(ratings) / len(ratings), 1) if ratings else None, + 'avg_competitor_reviews': round(sum(review_counts) / len(review_counts)) if review_counts else 0, + 'company_rank_by_rating': self._calculate_rank(company_data.get('rating'), ratings), + 'company_rank_by_reviews': self._calculate_rank(company_data.get('review_count'), review_counts), + } + + @staticmethod + def _calculate_rank(value, others: List) -> Optional[int]: + """Calculate rank (1 = best) among competitors.""" + if value is None or not others: + return None + all_values = sorted([value] + [v for v in others if v is not None], reverse=True) + try: + return all_values.index(value) + 1 + except ValueError: + return None diff --git a/google_places_service.py b/google_places_service.py new file mode 100644 index 0000000..807843b --- /dev/null +++ b/google_places_service.py @@ -0,0 +1,465 @@ +""" +Google Places API (New) Service for NordaBiz +============================================= + +Comprehensive Google Places API client for fetching rich business data. +Uses the Places API (New) with field masks for efficient billing. + +API Reference: https://developers.google.com/maps/documentation/places/web-service/op-overview + +Author: NordaBiz Development Team +Created: 2026-02-06 +""" + +import os +import logging +from datetime import datetime, timedelta +from typing import Optional, Dict, List, Any +from decimal import Decimal + +import requests + +logger = logging.getLogger(__name__) + +# API Configuration +PLACES_API_BASE = "https://places.googleapis.com/v1/places" +PLACES_SEARCH_URL = "https://places.googleapis.com/v1/places:searchText" +PLACES_NEARBY_URL = "https://places.googleapis.com/v1/places:searchNearby" + +# Field masks grouped by billing tier +# Basic fields (no charge): id, displayName, formattedAddress, location, types, etc. +# Contact fields: nationalPhoneNumber, websiteUri, etc. +# Atmosphere fields: reviews, rating, etc. +BASIC_FIELDS = [ + "id", "displayName", "formattedAddress", "location", + "types", "primaryType", "primaryTypeDisplayName", + "businessStatus", "googleMapsUri", + "utcOffsetMinutes", "adrFormatAddress", + "shortFormattedAddress" +] + +CONTACT_FIELDS = [ + "nationalPhoneNumber", "internationalPhoneNumber", + "websiteUri" +] + +HOURS_FIELDS = [ + "regularOpeningHours", "currentOpeningHours" +] + +ATMOSPHERE_FIELDS = [ + "rating", "userRatingCount", "reviews", + "priceLevel", "editorialSummary" +] + +PHOTO_FIELDS = [ + "photos" +] + +ATTRIBUTE_FIELDS = [ + "paymentOptions", "parkingOptions", + "accessibilityOptions", "outdoorSeating", + "liveMusic", "servesBreakfast", "servesLunch", + "servesDinner", "servesBeer", "servesWine", + "servesCoffee", "goodForChildren", "allowsDogs", + "restroom", "goodForGroups", "goodForWatchingSports", + "reservable", "delivery", "dineIn", "takeout", + "curbsidePickup" +] + + +class GooglePlacesService: + """Fetches rich GBP data via Places API (New).""" + + def __init__(self, api_key: str = None): + self.api_key = api_key or os.getenv('GOOGLE_PLACES_API_KEY') + if not self.api_key: + raise ValueError("GOOGLE_PLACES_API_KEY not set in environment") + self.session = requests.Session() + self.session.headers.update({ + 'X-Goog-Api-Key': self.api_key, + 'Content-Type': 'application/json' + }) + + def _build_field_mask(self, include_reviews: bool = True, + include_photos: bool = True, + include_attributes: bool = True) -> str: + """Build field mask string for API request.""" + fields = BASIC_FIELDS + CONTACT_FIELDS + HOURS_FIELDS + ATMOSPHERE_FIELDS + if include_photos: + fields += PHOTO_FIELDS + if include_attributes: + fields += ATTRIBUTE_FIELDS + return ','.join(f'places.{f}' if '.' not in f else f for f in fields) + + def get_place_details(self, place_id: str, + include_reviews: bool = True, + include_photos: bool = True, + include_attributes: bool = True) -> Optional[Dict[str, Any]]: + """ + Fetch comprehensive place details by Place ID. + + Args: + place_id: Google Place ID + include_reviews: Include reviews data (billed separately) + include_photos: Include photo references + include_attributes: Include business attributes + + Returns: + Dict with place details or None on error + """ + url = f"{PLACES_API_BASE}/{place_id}" + + # Build field mask + fields = list(BASIC_FIELDS + CONTACT_FIELDS + HOURS_FIELDS + ATMOSPHERE_FIELDS) + if include_photos: + fields += PHOTO_FIELDS + if include_attributes: + fields += ATTRIBUTE_FIELDS + + field_mask = ','.join(fields) + + headers = { + 'X-Goog-FieldMask': field_mask + } + + try: + response = self.session.get(url, headers=headers, timeout=15) + response.raise_for_status() + data = response.json() + logger.info(f"Fetched place details for {place_id}: {data.get('displayName', {}).get('text', 'unknown')}") + return data + except requests.exceptions.HTTPError as e: + logger.error(f"Places API HTTP error for {place_id}: {e.response.status_code} - {e.response.text}") + return None + except requests.exceptions.RequestException as e: + logger.error(f"Places API request error for {place_id}: {e}") + return None + + def search_place(self, query: str, location_bias: Dict = None) -> Optional[Dict[str, Any]]: + """ + Search for a place by text query. + + Args: + query: Search text (e.g., "TERMO Wejherowo") + location_bias: Optional location bias {"latitude": 54.6, "longitude": 18.2, "radius": 5000} + + Returns: + First matching place or None + """ + body = { + "textQuery": query, + "languageCode": "pl", + "maxResultCount": 5 + } + + if location_bias: + body["locationBias"] = { + "circle": { + "center": { + "latitude": location_bias["latitude"], + "longitude": location_bias["longitude"] + }, + "radius": location_bias.get("radius", 5000.0) + } + } + + field_mask = ','.join(f'places.{f}' for f in ['id', 'displayName', 'formattedAddress', 'types', 'rating', 'userRatingCount', 'googleMapsUri']) + + headers = { + 'X-Goog-FieldMask': field_mask + } + + try: + response = self.session.post(PLACES_SEARCH_URL, json=body, headers=headers, timeout=15) + response.raise_for_status() + data = response.json() + places = data.get('places', []) + if places: + return places[0] + logger.warning(f"No places found for query: {query}") + return None + except requests.exceptions.RequestException as e: + logger.error(f"Places search error for '{query}': {e}") + return None + + def search_nearby(self, latitude: float, longitude: float, + radius: float = 5000.0, + included_types: List[str] = None, + max_results: int = 10) -> List[Dict[str, Any]]: + """ + Search for nearby places (for competitor discovery). + + Args: + latitude: Center point latitude + longitude: Center point longitude + radius: Search radius in meters + included_types: Filter by place types (e.g., ["restaurant"]) + max_results: Maximum results to return + + Returns: + List of nearby places + """ + body = { + "locationRestriction": { + "circle": { + "center": { + "latitude": latitude, + "longitude": longitude + }, + "radius": radius + } + }, + "maxResultCount": min(max_results, 20), + "languageCode": "pl" + } + + if included_types: + body["includedTypes"] = included_types + + field_mask = ','.join(f'places.{f}' for f in [ + 'id', 'displayName', 'formattedAddress', 'types', + 'rating', 'userRatingCount', 'googleMapsUri', + 'websiteUri', 'primaryType', 'photos', + 'businessStatus', 'location' + ]) + + headers = { + 'X-Goog-FieldMask': field_mask + } + + try: + response = self.session.post(PLACES_NEARBY_URL, json=body, headers=headers, timeout=15) + response.raise_for_status() + data = response.json() + return data.get('places', []) + except requests.exceptions.RequestException as e: + logger.error(f"Nearby search error: {e}") + return [] + + def get_photo_url(self, photo_name: str, max_width: int = 400) -> str: + """ + Get photo URL from photo resource name. + + Args: + photo_name: Photo resource name from place details + max_width: Maximum width in pixels + + Returns: + Photo URL string + """ + return f"https://places.googleapis.com/v1/{photo_name}/media?maxWidthPx={max_width}&key={self.api_key}" + + def extract_reviews_data(self, place_data: Dict) -> Dict[str, Any]: + """ + Extract and analyze reviews from place details. + + Returns: + Dict with review statistics and individual reviews + """ + reviews = place_data.get('reviews', []) + if not reviews: + return { + 'total_from_api': 0, + 'total_reported': place_data.get('userRatingCount', 0), + 'average_rating': place_data.get('rating'), + 'reviews': [], + 'with_response': 0, + 'without_response': 0, + 'response_rate': 0.0, + 'rating_distribution': {1: 0, 2: 0, 3: 0, 4: 0, 5: 0} + } + + rating_dist = {1: 0, 2: 0, 3: 0, 4: 0, 5: 0} + with_response = 0 + processed_reviews = [] + + for review in reviews: + rating = review.get('rating', 0) + if rating in rating_dist: + rating_dist[rating] += 1 + + has_response = bool(review.get('authorAttribution', {}).get('displayName')) + # Check if there's an owner response (Google marks these differently) + # The Places API (New) doesn't directly expose owner responses in the same way + # We'll check for the presence of a response field + + processed_reviews.append({ + 'author': review.get('authorAttribution', {}).get('displayName', 'Anonim'), + 'rating': rating, + 'text': review.get('text', {}).get('text', ''), + 'time': review.get('publishTime', ''), + 'relative_time': review.get('relativePublishTimeDescription', ''), + 'language': review.get('text', {}).get('languageCode', 'pl'), + }) + + total = len(reviews) + response_rate = (with_response / total * 100) if total > 0 else 0.0 + + return { + 'total_from_api': total, + 'total_reported': place_data.get('userRatingCount', 0), + 'average_rating': place_data.get('rating'), + 'reviews': processed_reviews, + 'with_response': with_response, + 'without_response': total - with_response, + 'response_rate': round(response_rate, 1), + 'rating_distribution': rating_dist + } + + def extract_attributes(self, place_data: Dict) -> Dict[str, Any]: + """ + Extract business attributes from place details. + + Returns: + Dict with categorized attributes + """ + attributes = {} + + # Payment options + payment = place_data.get('paymentOptions', {}) + if payment: + attributes['payment'] = { + 'accepts_credit_cards': payment.get('acceptsCreditCards'), + 'accepts_debit_cards': payment.get('acceptsDebitCards'), + 'accepts_cash_only': payment.get('acceptsCashOnly'), + 'accepts_nfc': payment.get('acceptsNfc'), + } + + # Parking + parking = place_data.get('parkingOptions', {}) + if parking: + attributes['parking'] = { + 'free_parking': parking.get('freeParkingLot'), + 'paid_parking': parking.get('paidParkingLot'), + 'street_parking': parking.get('freeStreetParking'), + 'garage_parking': parking.get('freeGarageParking'), + 'valet_parking': parking.get('valetParking'), + } + + # Accessibility + accessibility = place_data.get('accessibilityOptions', {}) + if accessibility: + attributes['accessibility'] = { + 'wheelchair_entrance': accessibility.get('wheelchairAccessibleEntrance'), + 'wheelchair_seating': accessibility.get('wheelchairAccessibleSeating'), + 'wheelchair_restroom': accessibility.get('wheelchairAccessibleRestroom'), + 'wheelchair_parking': accessibility.get('wheelchairAccessibleParking'), + } + + # Service options + service = {} + bool_fields = { + 'delivery': 'delivery', + 'dineIn': 'dine_in', + 'takeout': 'takeout', + 'curbsidePickup': 'curbside_pickup', + 'reservable': 'reservable', + 'outdoorSeating': 'outdoor_seating', + } + for api_field, key in bool_fields.items(): + val = place_data.get(api_field) + if val is not None: + service[key] = val + if service: + attributes['service'] = service + + # Amenities + amenities = {} + amenity_fields = { + 'restroom': 'restroom', + 'goodForChildren': 'good_for_children', + 'allowsDogs': 'allows_dogs', + 'goodForGroups': 'good_for_groups', + 'liveMusic': 'live_music', + 'goodForWatchingSports': 'good_for_watching_sports', + } + for api_field, key in amenity_fields.items(): + val = place_data.get(api_field) + if val is not None: + amenities[key] = val + if amenities: + attributes['amenities'] = amenities + + # Food & Drink + food = {} + food_fields = { + 'servesBreakfast': 'breakfast', + 'servesLunch': 'lunch', + 'servesDinner': 'dinner', + 'servesBeer': 'beer', + 'servesWine': 'wine', + 'servesCoffee': 'coffee', + } + for api_field, key in food_fields.items(): + val = place_data.get(api_field) + if val is not None: + food[key] = val + if food: + attributes['food_and_drink'] = food + + return attributes + + def extract_hours(self, place_data: Dict) -> Dict[str, Any]: + """Extract opening hours from place details.""" + result = { + 'regular': None, + 'current': None, + 'has_special_hours': False, + 'special_hours': None + } + + regular = place_data.get('regularOpeningHours', {}) + if regular: + result['regular'] = { + 'periods': regular.get('periods', []), + 'weekday_descriptions': regular.get('weekdayDescriptions', []), + 'open_now': regular.get('openNow') + } + + current = place_data.get('currentOpeningHours', {}) + if current: + result['current'] = { + 'periods': current.get('periods', []), + 'weekday_descriptions': current.get('weekdayDescriptions', []), + 'open_now': current.get('openNow') + } + # If current differs from regular, there are special hours + if current.get('specialDays'): + result['has_special_hours'] = True + result['special_hours'] = current.get('specialDays', []) + + return result + + def extract_photos_metadata(self, place_data: Dict) -> Dict[str, Any]: + """Extract photo metadata from place details.""" + photos = place_data.get('photos', []) + if not photos: + return { + 'total_count': 0, + 'photos': [], + 'has_owner_photos': False + } + + photo_list = [] + has_owner = False + for photo in photos: + attributions = photo.get('authorAttributions', []) + is_owner = any(a.get('displayName', '').lower() in ['owner', 'właściciel'] + for a in attributions) + if is_owner: + has_owner = True + + photo_list.append({ + 'name': photo.get('name', ''), + 'width': photo.get('widthPx', 0), + 'height': photo.get('heightPx', 0), + 'attributions': [a.get('displayName', '') for a in attributions], + 'is_owner_photo': is_owner + }) + + return { + 'total_count': len(photo_list), + 'photos': photo_list[:20], # Limit stored photos + 'has_owner_photos': has_owner + } diff --git a/scripts/competitor_monitor_cron.py b/scripts/competitor_monitor_cron.py new file mode 100644 index 0000000..14552ef --- /dev/null +++ b/scripts/competitor_monitor_cron.py @@ -0,0 +1,184 @@ +#!/usr/bin/env python3 +""" +Competitor Monitor Cron Job +=========================== + +Periodically takes snapshots of competitors for all companies with +Google Place IDs. Designed to run weekly via cron. + +Usage: + python competitor_monitor_cron.py + python competitor_monitor_cron.py --company-id 26 + python competitor_monitor_cron.py --discover # Auto-discover competitors first + +Cron entry (weekly, Sunday 3 AM): + 0 3 * * 0 cd /var/www/nordabiznes && /var/www/nordabiznes/venv/bin/python3 scripts/competitor_monitor_cron.py >> /var/log/nordabiznes/competitor_monitor.log 2>&1 + +Author: NordaBiz Development Team +Created: 2026-02-06 +""" + +import os +import sys +import argparse +import logging +from datetime import datetime +from pathlib import Path + +# Load .env file from project root +try: + from dotenv import load_dotenv + script_dir = Path(__file__).resolve().parent + project_root = script_dir.parent + env_path = project_root / '.env' + if env_path.exists(): + load_dotenv(env_path) +except ImportError: + pass + +# Add parent directory to path +sys.path.insert(0, str(Path(__file__).resolve().parent.parent)) + +from sqlalchemy import create_engine, text +from sqlalchemy.orm import sessionmaker + +from database import Company, CompanyCompetitor, CompanyWebsiteAnalysis +from competitor_monitoring_service import CompetitorMonitoringService + +# Configure logging +logging.basicConfig( + level=logging.INFO, + format='%(asctime)s - %(levelname)s - %(message)s', + datefmt='%Y-%m-%d %H:%M:%S' +) +logger = logging.getLogger(__name__) + +DATABASE_URL = os.getenv( + 'DATABASE_URL', + 'postgresql://nordabiz_app:CHANGE_ME@127.0.0.1:5432/nordabiz' +) + + +def run_snapshots(session, company_ids=None): + """Take snapshots for competitors.""" + service = CompetitorMonitoringService(session) + + if company_ids: + companies = session.query(Company).filter(Company.id.in_(company_ids)).all() + else: + # Get companies that have competitors tracked + companies_with_competitors = session.query(CompanyCompetitor.company_id).distinct().all() + company_ids_with_competitors = [c[0] for c in companies_with_competitors] + companies = session.query(Company).filter(Company.id.in_(company_ids_with_competitors)).all() + + total_results = { + 'companies_processed': 0, + 'total_snapshots': 0, + 'total_changes': 0, + 'errors': 0, + } + + for company in companies: + logger.info(f"Processing competitors for: {company.name} (ID: {company.id})") + try: + results = service.take_all_snapshots(company.id) + total_results['companies_processed'] += 1 + total_results['total_snapshots'] += results['success'] + total_results['total_changes'] += results['changes_detected'] + total_results['errors'] += results['failed'] + + logger.info( + f" Snapshots: {results['success']}/{results['total']}, " + f"Changes: {results['changes_detected']}" + ) + except Exception as e: + logger.error(f" Error: {e}") + total_results['errors'] += 1 + + return total_results + + +def run_discovery(session, company_ids=None, max_per_company=5): + """Discover competitors for companies.""" + service = CompetitorMonitoringService(session) + + if company_ids: + companies = session.query(Company).filter(Company.id.in_(company_ids)).all() + else: + # Companies with Google Place IDs but no competitors + companies_with_place = session.query(Company).join( + CompanyWebsiteAnalysis, + Company.id == CompanyWebsiteAnalysis.company_id + ).filter( + CompanyWebsiteAnalysis.google_place_id.isnot(None) + ).all() + + # Filter out those that already have competitors + companies = [] + for company in companies_with_place: + existing = session.query(CompanyCompetitor).filter( + CompanyCompetitor.company_id == company.id + ).count() + if existing == 0: + companies.append(company) + + total_discovered = 0 + for company in companies: + logger.info(f"Discovering competitors for: {company.name} (ID: {company.id})") + try: + competitors = service.discover_competitors(company.id, max_results=max_per_company) + saved = service.save_competitors(company.id, competitors) + total_discovered += saved + logger.info(f" Found {len(competitors)} competitors, saved {saved} new") + except Exception as e: + logger.error(f" Discovery error: {e}") + + return total_discovered + + +def main(): + parser = argparse.ArgumentParser(description='Competitor Monitor Cron Job') + parser.add_argument('--company-id', type=int, help='Process single company') + parser.add_argument('--discover', action='store_true', help='Discover competitors first') + parser.add_argument('--max-competitors', type=int, default=5, help='Max competitors per company') + parser.add_argument('--verbose', '-v', action='store_true', help='Verbose output') + args = parser.parse_args() + + if args.verbose: + logging.getLogger().setLevel(logging.DEBUG) + + engine = create_engine(DATABASE_URL) + Session = sessionmaker(bind=engine) + session = Session() + + company_ids = [args.company_id] if args.company_id else None + + try: + logger.info("=" * 60) + logger.info("COMPETITOR MONITOR CRON JOB") + logger.info(f"Started at: {datetime.now()}") + logger.info("=" * 60) + + if args.discover: + logger.info("\n--- COMPETITOR DISCOVERY ---") + discovered = run_discovery(session, company_ids, args.max_competitors) + logger.info(f"Total new competitors discovered: {discovered}") + + logger.info("\n--- COMPETITOR SNAPSHOTS ---") + results = run_snapshots(session, company_ids) + + logger.info("\n" + "=" * 60) + logger.info("SUMMARY") + logger.info("=" * 60) + logger.info(f"Companies processed: {results['companies_processed']}") + logger.info(f"Snapshots taken: {results['total_snapshots']}") + logger.info(f"Changes detected: {results['total_changes']}") + logger.info(f"Errors: {results['errors']}") + logger.info("=" * 60) + + finally: + session.close() + + +if __name__ == '__main__': + main() diff --git a/scripts/generate_audit_report.py b/scripts/generate_audit_report.py new file mode 100644 index 0000000..7cbc08b --- /dev/null +++ b/scripts/generate_audit_report.py @@ -0,0 +1,326 @@ +#!/usr/bin/env python3 +""" +Unified Audit Report Generator for NordaBiz +============================================= + +Generates comprehensive audit reports combining: +- Social Media audit data +- Google Business Profile audit data +- SEO audit data +- Competitor monitoring data (if available) + +Usage: + python generate_audit_report.py --company-id 26 + python generate_audit_report.py --all + python generate_audit_report.py --company-id 26 --type social + +Author: NordaBiz Development Team +Created: 2026-02-06 +""" + +import os +import sys +import json +import argparse +import logging +from datetime import datetime, date, timedelta +from typing import Optional, Dict, List, Any +from pathlib import Path + +# Load .env file +try: + from dotenv import load_dotenv + script_dir = Path(__file__).resolve().parent + project_root = script_dir.parent + env_path = project_root / '.env' + if env_path.exists(): + load_dotenv(env_path) +except ImportError: + pass + +sys.path.insert(0, str(Path(__file__).resolve().parent.parent)) + +from sqlalchemy import create_engine, text +from sqlalchemy.orm import sessionmaker +from database import ( + Company, CompanySocialMedia, GBPAudit, CompanyWebsiteAnalysis, + CompanyCompetitor, CompetitorSnapshot, AuditReport, SessionLocal +) + +logging.basicConfig( + level=logging.INFO, + format='%(asctime)s - %(levelname)s - %(message)s' +) +logger = logging.getLogger(__name__) + +DATABASE_URL = os.getenv( + 'DATABASE_URL', + 'postgresql://nordabiz_app:CHANGE_ME@127.0.0.1:5432/nordabiz' +) + + +class AuditReportGenerator: + """Generates unified audit reports for companies.""" + + def __init__(self, database_url: str = DATABASE_URL): + self.engine = create_engine(database_url) + self.Session = sessionmaker(bind=self.engine) + + def generate_report(self, company_id: int, report_type: str = 'full') -> Dict[str, Any]: + """Generate a comprehensive audit report for a company.""" + with self.Session() as session: + company = session.query(Company).filter(Company.id == company_id).first() + if not company: + raise ValueError(f"Company {company_id} not found") + + logger.info(f"Generating {report_type} report for: {company.name} (ID: {company_id})") + + report_data = { + 'company': { + 'id': company.id, + 'name': company.name, + 'slug': company.slug, + 'website': company.website, + 'city': company.address_city, + 'category': company.category.name if company.category else None, + }, + 'generated_at': datetime.now().isoformat(), + 'report_type': report_type, + 'sections': {}, + 'scores': {}, + } + + sections_included = {'social': False, 'gbp': False, 'seo': False, 'competitors': False} + + # Social Media section + if report_type in ('full', 'social'): + social_data = self._get_social_data(session, company_id) + if social_data: + report_data['sections']['social'] = social_data + report_data['scores']['social'] = social_data.get('average_completeness', 0) + sections_included['social'] = True + + # GBP section + if report_type in ('full', 'gbp'): + gbp_data = self._get_gbp_data(session, company_id) + if gbp_data: + report_data['sections']['gbp'] = gbp_data + report_data['scores']['gbp'] = gbp_data.get('completeness_score', 0) + sections_included['gbp'] = True + + # SEO section + if report_type in ('full', 'seo'): + seo_data = self._get_seo_data(session, company_id) + if seo_data: + report_data['sections']['seo'] = seo_data + report_data['scores']['seo'] = seo_data.get('overall_score', 0) + sections_included['seo'] = True + + # Competitors section + if report_type == 'full': + competitor_data = self._get_competitor_data(session, company_id) + if competitor_data: + report_data['sections']['competitors'] = competitor_data + sections_included['competitors'] = True + + # Calculate overall score + scores = [v for v in report_data['scores'].values() if v and v > 0] + overall = int(sum(scores) / len(scores)) if scores else 0 + report_data['scores']['overall'] = overall + + # Save report + report = AuditReport( + company_id=company_id, + report_type=report_type, + period_start=date.today() - timedelta(days=30), + period_end=date.today(), + overall_score=overall, + social_score=report_data['scores'].get('social'), + gbp_score=report_data['scores'].get('gbp'), + seo_score=report_data['scores'].get('seo'), + sections=sections_included, + data=report_data, + generated_by='system', + status='draft', + ) + session.add(report) + session.commit() + session.refresh(report) + + report_data['report_id'] = report.id + logger.info(f"Report #{report.id} generated. Overall score: {overall}/100") + + return report_data + + def _get_social_data(self, session, company_id: int) -> Optional[Dict]: + """Get social media audit data.""" + profiles = session.query(CompanySocialMedia).filter( + CompanySocialMedia.company_id == company_id, + CompanySocialMedia.is_valid == True + ).all() + + if not profiles: + return None + + platforms = [] + total_completeness = 0 + + for p in profiles: + platform_data = { + 'platform': p.platform, + 'url': p.url, + 'page_name': p.page_name, + 'followers_count': p.followers_count, + 'has_bio': p.has_bio, + 'has_profile_photo': p.has_profile_photo, + 'completeness_score': p.profile_completeness_score or 0, + 'last_checked': p.last_checked_at.isoformat() if p.last_checked_at else None, + } + platforms.append(platform_data) + total_completeness += (p.profile_completeness_score or 0) + + average = int(total_completeness / len(platforms)) if platforms else 0 + + return { + 'platforms_found': len(platforms), + 'platforms': platforms, + 'average_completeness': average, + 'missing_platforms': self._find_missing_platforms(profiles), + } + + @staticmethod + def _find_missing_platforms(profiles) -> List[str]: + """Find platforms without profiles.""" + all_platforms = {'facebook', 'instagram', 'linkedin', 'youtube', 'twitter', 'tiktok'} + found = {p.platform for p in profiles} + return sorted(all_platforms - found) + + def _get_gbp_data(self, session, company_id: int) -> Optional[Dict]: + """Get GBP audit data.""" + audit = session.query(GBPAudit).filter( + GBPAudit.company_id == company_id + ).order_by(GBPAudit.audit_date.desc()).first() + + if not audit: + return None + + return { + 'completeness_score': audit.completeness_score, + 'score_category': audit.score_category, + 'audit_date': audit.audit_date.isoformat() if audit.audit_date else None, + 'fields_status': audit.fields_status, + 'review_count': audit.review_count, + 'average_rating': float(audit.average_rating) if audit.average_rating else None, + 'photo_count': audit.photo_count, + 'nap_consistent': audit.nap_consistent, + 'review_response_rate': float(audit.review_response_rate) if audit.review_response_rate else None, + 'review_sentiment': audit.review_sentiment, + 'recommendations': audit.recommendations or [], + } + + def _get_seo_data(self, session, company_id: int) -> Optional[Dict]: + """Get SEO audit data.""" + analysis = session.query(CompanyWebsiteAnalysis).filter( + CompanyWebsiteAnalysis.company_id == company_id + ).first() + + if not analysis: + return None + + return { + 'overall_score': analysis.seo_overall_score, + 'pagespeed_seo': analysis.pagespeed_seo_score, + 'pagespeed_performance': analysis.pagespeed_performance_score, + 'pagespeed_accessibility': analysis.pagespeed_accessibility_score, + 'meta_title': analysis.meta_title, + 'has_structured_data': analysis.has_structured_data, + 'has_sitemap': analysis.has_sitemap, + 'has_robots_txt': analysis.has_robots_txt, + 'is_mobile_friendly': analysis.is_mobile_friendly, + 'local_seo_score': analysis.local_seo_score, + 'has_local_business_schema': analysis.has_local_business_schema, + 'citations_count': analysis.citations_count, + 'content_freshness_score': analysis.content_freshness_score, + 'core_web_vitals': { + 'lcp_ms': analysis.largest_contentful_paint_ms, + 'fid_ms': analysis.first_input_delay_ms, + 'cls': float(analysis.cumulative_layout_shift) if analysis.cumulative_layout_shift else None, + }, + 'seo_issues': analysis.seo_issues, + } + + def _get_competitor_data(self, session, company_id: int) -> Optional[Dict]: + """Get competitor monitoring data.""" + competitors = session.query(CompanyCompetitor).filter( + CompanyCompetitor.company_id == company_id, + CompanyCompetitor.is_active == True + ).all() + + if not competitors: + return None + + competitor_list = [] + for comp in competitors: + # Get latest snapshot + latest = session.query(CompetitorSnapshot).filter( + CompetitorSnapshot.competitor_id == comp.id + ).order_by(CompetitorSnapshot.snapshot_date.desc()).first() + + competitor_list.append({ + 'name': comp.competitor_name, + 'rating': float(comp.competitor_rating) if comp.competitor_rating else None, + 'review_count': comp.competitor_review_count, + 'category': comp.competitor_category, + 'latest_changes': latest.changes if latest else None, + }) + + return { + 'total_tracked': len(competitors), + 'competitors': competitor_list, + } + + +def main(): + parser = argparse.ArgumentParser(description='Generate Unified Audit Report') + parser.add_argument('--company-id', type=int, help='Generate report for specific company') + parser.add_argument('--all', action='store_true', help='Generate for all active companies') + parser.add_argument('--type', choices=['full', 'social', 'gbp', 'seo'], default='full') + parser.add_argument('--json', action='store_true', help='Output JSON to stdout') + parser.add_argument('--verbose', '-v', action='store_true') + args = parser.parse_args() + + if args.verbose: + logging.getLogger().setLevel(logging.DEBUG) + + generator = AuditReportGenerator() + + if args.company_id: + report = generator.generate_report(args.company_id, args.type) + if args.json: + print(json.dumps(report, default=str, indent=2, ensure_ascii=False)) + else: + print(f"\nReport #{report.get('report_id')} generated") + print(f"Overall score: {report['scores'].get('overall', 0)}/100") + for section, data in report.get('sections', {}).items(): + print(f" {section}: included") + + elif args.all: + engine = create_engine(DATABASE_URL) + Session = sessionmaker(bind=engine) + with Session() as session: + companies = session.query(Company).filter(Company.status == 'active').all() + company_ids = [c.id for c in companies] + + for cid in company_ids: + try: + report = generator.generate_report(cid, args.type) + print(f"Company {cid}: score={report['scores'].get('overall', 0)}") + except Exception as e: + logger.error(f"Company {cid} failed: {e}") + else: + parser.print_help() + sys.exit(1) + + +if __name__ == '__main__': + main() diff --git a/templates/admin/competitor_dashboard.html b/templates/admin/competitor_dashboard.html new file mode 100644 index 0000000..b6c131d --- /dev/null +++ b/templates/admin/competitor_dashboard.html @@ -0,0 +1,535 @@ +{% extends "base.html" %} + +{% block title %}Monitoring Konkurencji - Panel Admina{% endblock %} + +{% block extra_css %} + +{% endblock %} + +{% block content %} +
+
+ {% if detail_view and company %} +

Konkurenci: {{ company.name }}

+

Porownanie z konkurentami z Google Maps

+ {% else %} +

Monitoring Konkurencji

+

Sledzenie zmian u konkurentow firm czlonkowskich

+ {% endif %} +
+
+ {% if detail_view and company %} + + + + + Wszystkie firmy + + {% endif %} +
+
+ + +{% if not detail_view %} +
+
+
{{ total_companies }}
+
Firm z monitoringiem
+
+
+
{{ total_competitors }}
+
Sledzonych konkurentow
+
+
+
{{ recent_changes|length }}
+
Zmian w ostatnich 30 dniach
+
+
+{% endif %} + +{% if detail_view and company %} + + +{% if competitor_data %} + +

+ + + + Porownanie: {{ company.name }} vs Konkurenci +

+ +
+ + + + + + + + + + + + + + + + + + + + + + + {% for item in competitor_data %} + {% set comp = item.competitor %} + {% set snap = item.latest_snapshot %} + + + + + + + + + {% endfor %} + +
FirmaOcenaOpinieZdjeciaKategoriaStrona WWW
+ {{ company.name }} + (Twoja firma) + + {% if gbp_audit and gbp_audit.average_rating %} + {{ gbp_audit.average_rating }}/5 + {% else %} + - + {% endif %} + {{ gbp_audit.review_count if gbp_audit and gbp_audit.review_count else '-' }}{{ gbp_audit.photo_count if gbp_audit and gbp_audit.photo_count else '-' }}{{ company.category.name if company.category else '-' }}{{ 'Tak' if company.website else 'Brak' }}
+ {{ comp.competitor_name or 'Bez nazwy' }} + {% if comp.added_by == 'manual' %} + RECZNY + {% endif %} + + {% if comp.competitor_rating %} + {{ comp.competitor_rating }}/5 + {% if snap and snap.changes and snap.changes.get('rating_change') %} + {% set rc = snap.changes.rating_change %} + + {{ '+' if rc > 0 else '' }}{{ rc }} + + {% endif %} + {% else %} + - + {% endif %} + + {{ comp.competitor_review_count or '-' }} + {% if snap and snap.changes and snap.changes.get('new_reviews') %} + +{{ snap.changes.new_reviews }} + {% endif %} + {{ snap.photo_count if snap else '-' }}{{ comp.competitor_category or '-' }} + {% if comp.competitor_website %} + Link + {% else %} + Brak + {% endif %} +
+
+ + +{% if timeline %} +

+ + + + Ostatnie zmiany +

+ +
+ {% for snap in timeline %} +
+
{{ snap.snapshot_date.strftime('%d.%m') }}
+
+ {{ snap.competitor.competitor_name }} + {% if snap.changes %} + {% if snap.changes.get('new_reviews') %} + — {{ snap.changes.new_reviews }} nowych opinii + {% endif %} + {% if snap.changes.get('rating_change') %} + — ocena {{ '+' if snap.changes.rating_change > 0 else '' }}{{ snap.changes.rating_change }} + {% endif %} + {% if snap.changes.get('new_photos') %} + — {{ snap.changes.new_photos }} nowych zdjec + {% endif %} + {% else %} + — brak zmian + {% endif %} +
+
+ {% endfor %} +
+{% endif %} + +{% else %} + +
+ + + +

Brak sledzonych konkurentow

+

Dla firmy {{ company.name }} nie dodano jeszcze konkurentow. Uruchom skrypt discovery, aby automatycznie znalezc konkurentow w okolicy.

+
+{% endif %} + +{% else %} + + +{% if company_data %} +{% for item in company_data %} +
+
+

+ + {{ item.company.name }} + +

+
+ {{ item.competitor_count }} konkurentow + Szczegoly +
+
+ {% if item.competitors %} + + + + + + + + + + + {% for comp in item.competitors[:5] %} + + + + + + + {% endfor %} + {% if item.competitors|length > 5 %} + + + + {% endif %} + +
KonkurentOcenaOpinieKategoria
{{ comp.competitor_name or 'Bez nazwy' }}{{ comp.competitor_rating or '-' }}{% if comp.competitor_rating %}/5{% endif %}{{ comp.competitor_review_count or '-' }}{{ comp.competitor_category or '-' }}
+ ...i {{ item.competitors|length - 5 }} wiecej +
+ {% endif %} +
+{% endfor %} + +{% else %} + +
+ + + +

Brak monitorowanych konkurentow

+

Zaden z firm czlonkowskich nie ma jeszcze sledzonych konkurentow. Uruchom skrypt scripts/competitor_monitor_cron.py --discover, aby automatycznie znalezc konkurentow dla firm z profilem Google Business.

+
+{% endif %} + + +{% if recent_changes %} +

+ + + + Ostatnie zmiany (30 dni) +

+ +
+ {% for snap in recent_changes %} +
+
{{ snap.snapshot_date.strftime('%d.%m.%Y') }}
+
+ {{ snap.competitor.competitor_name }} + {% if snap.changes %} + {% if snap.changes.get('new_reviews') %} + — {{ snap.changes.new_reviews }} nowych opinii + {% endif %} + {% if snap.changes.get('rating_change') %} + — ocena {{ '+' if snap.changes.rating_change > 0 else '' }}{{ snap.changes.rating_change }} + {% endif %} + {% if snap.changes.get('new_photos') %} + — {{ snap.changes.new_photos }} nowych zdjec + {% endif %} + {% endif %} +
+
+ {% endfor %} +
+{% endif %} + +{% endif %} +{% endblock %}