feat: Add new services, scripts, and competitor dashboard
Some checks are pending
NordaBiz Tests / Unit & Integration Tests (push) Waiting to run
NordaBiz Tests / E2E Tests (Playwright) (push) Blocked by required conditions
NordaBiz Tests / Smoke Tests (Production) (push) Blocked by required conditions
NordaBiz Tests / Send Failure Notification (push) Blocked by required conditions
Some checks are pending
NordaBiz Tests / Unit & Integration Tests (push) Waiting to run
NordaBiz Tests / E2E Tests (Playwright) (push) Blocked by required conditions
NordaBiz Tests / Smoke Tests (Production) (push) Blocked by required conditions
NordaBiz Tests / Send Failure Notification (push) Blocked by required conditions
- google_places_service.py: Google Places API integration - competitor_monitoring_service.py: Competitor tracking service - scripts/competitor_monitor_cron.py, scripts/generate_audit_report.py - blueprints/admin/routes_competitors.py, templates/admin/competitor_dashboard.html Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
42ddeabf2a
commit
02696c59a4
157
blueprints/admin/routes_competitors.py
Normal file
157
blueprints/admin/routes_competitors.py
Normal file
@ -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/<int:company_id>')
|
||||||
|
@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()
|
||||||
383
competitor_monitoring_service.py
Normal file
383
competitor_monitoring_service.py
Normal file
@ -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
|
||||||
465
google_places_service.py
Normal file
465
google_places_service.py
Normal file
@ -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
|
||||||
|
}
|
||||||
184
scripts/competitor_monitor_cron.py
Normal file
184
scripts/competitor_monitor_cron.py
Normal file
@ -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()
|
||||||
326
scripts/generate_audit_report.py
Normal file
326
scripts/generate_audit_report.py
Normal file
@ -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()
|
||||||
535
templates/admin/competitor_dashboard.html
Normal file
535
templates/admin/competitor_dashboard.html
Normal file
@ -0,0 +1,535 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
|
||||||
|
{% block title %}Monitoring Konkurencji - Panel Admina{% endblock %}
|
||||||
|
|
||||||
|
{% block extra_css %}
|
||||||
|
<style>
|
||||||
|
.dashboard-header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: flex-start;
|
||||||
|
margin-bottom: var(--spacing-xl);
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: var(--spacing-md);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dashboard-header h1 {
|
||||||
|
font-size: var(--font-size-2xl);
|
||||||
|
color: var(--text-primary);
|
||||||
|
margin-bottom: var(--spacing-xs);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dashboard-header p {
|
||||||
|
color: var(--text-secondary);
|
||||||
|
font-size: var(--font-size-sm);
|
||||||
|
}
|
||||||
|
|
||||||
|
.stats-row {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
|
||||||
|
gap: var(--spacing-md);
|
||||||
|
margin-bottom: var(--spacing-xl);
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-card {
|
||||||
|
background: var(--surface);
|
||||||
|
border-radius: var(--radius-lg);
|
||||||
|
padding: var(--spacing-lg);
|
||||||
|
box-shadow: var(--shadow-sm);
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-value {
|
||||||
|
font-size: var(--font-size-2xl);
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-label {
|
||||||
|
font-size: var(--font-size-sm);
|
||||||
|
color: var(--text-secondary);
|
||||||
|
margin-top: var(--spacing-xs);
|
||||||
|
}
|
||||||
|
|
||||||
|
.section-title {
|
||||||
|
font-size: var(--font-size-xl);
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text-primary);
|
||||||
|
margin-bottom: var(--spacing-md);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--spacing-sm);
|
||||||
|
}
|
||||||
|
|
||||||
|
.company-competitor-card {
|
||||||
|
background: var(--surface);
|
||||||
|
border-radius: var(--radius-lg);
|
||||||
|
padding: var(--spacing-lg);
|
||||||
|
box-shadow: var(--shadow-sm);
|
||||||
|
margin-bottom: var(--spacing-md);
|
||||||
|
border-left: 4px solid var(--primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.company-competitor-header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
margin-bottom: var(--spacing-md);
|
||||||
|
}
|
||||||
|
|
||||||
|
.company-competitor-header h3 {
|
||||||
|
font-size: var(--font-size-lg);
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.competitor-count-badge {
|
||||||
|
font-size: var(--font-size-sm);
|
||||||
|
padding: var(--spacing-xs) var(--spacing-sm);
|
||||||
|
background: var(--bg-tertiary);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.competitors-table {
|
||||||
|
width: 100%;
|
||||||
|
border-collapse: collapse;
|
||||||
|
font-size: var(--font-size-sm);
|
||||||
|
}
|
||||||
|
|
||||||
|
.competitors-table th {
|
||||||
|
text-align: left;
|
||||||
|
padding: var(--spacing-sm) var(--spacing-md);
|
||||||
|
color: var(--text-secondary);
|
||||||
|
font-weight: 500;
|
||||||
|
border-bottom: 2px solid var(--border);
|
||||||
|
font-size: var(--font-size-xs);
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.competitors-table td {
|
||||||
|
padding: var(--spacing-sm) var(--spacing-md);
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.competitors-table tr:last-child td {
|
||||||
|
border-bottom: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.competitors-table tr:hover td {
|
||||||
|
background: var(--bg-tertiary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.rating-stars {
|
||||||
|
color: #f59e0b;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.comparison-row {
|
||||||
|
background: rgba(168, 85, 247, 0.05);
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.comparison-row td {
|
||||||
|
border-bottom: 2px solid var(--primary) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.change-badge {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 2px;
|
||||||
|
font-size: var(--font-size-xs);
|
||||||
|
padding: 2px 6px;
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
}
|
||||||
|
|
||||||
|
.change-badge.positive {
|
||||||
|
background: #dcfce7;
|
||||||
|
color: #166534;
|
||||||
|
}
|
||||||
|
|
||||||
|
.change-badge.negative {
|
||||||
|
background: #fee2e2;
|
||||||
|
color: #991b1b;
|
||||||
|
}
|
||||||
|
|
||||||
|
.change-badge.neutral {
|
||||||
|
background: var(--bg-tertiary);
|
||||||
|
color: var(--text-tertiary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.timeline-list {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--spacing-sm);
|
||||||
|
}
|
||||||
|
|
||||||
|
.timeline-item {
|
||||||
|
background: var(--surface);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
padding: var(--spacing-md);
|
||||||
|
box-shadow: var(--shadow-sm);
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: var(--spacing-md);
|
||||||
|
}
|
||||||
|
|
||||||
|
.timeline-date {
|
||||||
|
font-size: var(--font-size-xs);
|
||||||
|
color: var(--text-tertiary);
|
||||||
|
min-width: 80px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.timeline-content {
|
||||||
|
flex: 1;
|
||||||
|
font-size: var(--font-size-sm);
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.timeline-content strong {
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-state {
|
||||||
|
text-align: center;
|
||||||
|
padding: var(--spacing-2xl);
|
||||||
|
background: var(--surface);
|
||||||
|
border-radius: var(--radius-lg);
|
||||||
|
box-shadow: var(--shadow);
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-state svg {
|
||||||
|
width: 64px;
|
||||||
|
height: 64px;
|
||||||
|
color: var(--text-tertiary);
|
||||||
|
margin-bottom: var(--spacing-md);
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-state h3 {
|
||||||
|
color: var(--text-primary);
|
||||||
|
margin-bottom: var(--spacing-sm);
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-state p {
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--spacing-xs);
|
||||||
|
padding: var(--spacing-sm) var(--spacing-md);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
font-weight: 500;
|
||||||
|
font-size: var(--font-size-sm);
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
border: 1px solid transparent;
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-primary {
|
||||||
|
background: var(--primary);
|
||||||
|
color: white;
|
||||||
|
border-color: var(--primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-primary:hover {
|
||||||
|
opacity: 0.9;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-outline {
|
||||||
|
background: transparent;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
border-color: var(--border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-outline:hover {
|
||||||
|
background: var(--bg-tertiary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-sm {
|
||||||
|
padding: var(--spacing-xs) var(--spacing-sm);
|
||||||
|
font-size: var(--font-size-xs);
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.competitors-table {
|
||||||
|
display: block;
|
||||||
|
overflow-x: auto;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<div class="dashboard-header">
|
||||||
|
<div>
|
||||||
|
{% if detail_view and company %}
|
||||||
|
<h1>Konkurenci: {{ company.name }}</h1>
|
||||||
|
<p>Porownanie z konkurentami z Google Maps</p>
|
||||||
|
{% else %}
|
||||||
|
<h1>Monitoring Konkurencji</h1>
|
||||||
|
<p>Sledzenie zmian u konkurentow firm czlonkowskich</p>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
{% if detail_view and company %}
|
||||||
|
<a href="{{ url_for('admin.admin_competitors') }}" class="btn btn-outline btn-sm">
|
||||||
|
<svg width="16" height="16" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 19l-7-7m0 0l7-7m-7 7h18"/>
|
||||||
|
</svg>
|
||||||
|
Wszystkie firmy
|
||||||
|
</a>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Stats -->
|
||||||
|
{% if not detail_view %}
|
||||||
|
<div class="stats-row">
|
||||||
|
<div class="stat-card">
|
||||||
|
<div class="stat-value">{{ total_companies }}</div>
|
||||||
|
<div class="stat-label">Firm z monitoringiem</div>
|
||||||
|
</div>
|
||||||
|
<div class="stat-card">
|
||||||
|
<div class="stat-value">{{ total_competitors }}</div>
|
||||||
|
<div class="stat-label">Sledzonych konkurentow</div>
|
||||||
|
</div>
|
||||||
|
<div class="stat-card">
|
||||||
|
<div class="stat-value">{{ recent_changes|length }}</div>
|
||||||
|
<div class="stat-label">Zmian w ostatnich 30 dniach</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{% if detail_view and company %}
|
||||||
|
<!-- DETAIL VIEW: Single company vs competitors -->
|
||||||
|
|
||||||
|
{% if competitor_data %}
|
||||||
|
<!-- Comparison Table -->
|
||||||
|
<h2 class="section-title">
|
||||||
|
<svg width="24" height="24" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z"/>
|
||||||
|
</svg>
|
||||||
|
Porownanie: {{ company.name }} vs Konkurenci
|
||||||
|
</h2>
|
||||||
|
|
||||||
|
<div style="background: var(--surface); border-radius: var(--radius-lg); box-shadow: var(--shadow); overflow: hidden; margin-bottom: var(--spacing-xl);">
|
||||||
|
<table class="competitors-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Firma</th>
|
||||||
|
<th>Ocena</th>
|
||||||
|
<th>Opinie</th>
|
||||||
|
<th>Zdjecia</th>
|
||||||
|
<th>Kategoria</th>
|
||||||
|
<th>Strona WWW</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<!-- Company's own data -->
|
||||||
|
<tr class="comparison-row">
|
||||||
|
<td>
|
||||||
|
<strong>{{ company.name }}</strong>
|
||||||
|
<span style="font-size: var(--font-size-xs); color: var(--primary); margin-left: 4px;">(Twoja firma)</span>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
{% if gbp_audit and gbp_audit.average_rating %}
|
||||||
|
<span class="rating-stars">{{ gbp_audit.average_rating }}/5</span>
|
||||||
|
{% else %}
|
||||||
|
<span style="color: var(--text-tertiary);">-</span>
|
||||||
|
{% endif %}
|
||||||
|
</td>
|
||||||
|
<td>{{ gbp_audit.review_count if gbp_audit and gbp_audit.review_count else '-' }}</td>
|
||||||
|
<td>{{ gbp_audit.photo_count if gbp_audit and gbp_audit.photo_count else '-' }}</td>
|
||||||
|
<td style="font-size: var(--font-size-xs);">{{ company.category.name if company.category else '-' }}</td>
|
||||||
|
<td style="font-size: var(--font-size-xs);">{{ 'Tak' if company.website else 'Brak' }}</td>
|
||||||
|
</tr>
|
||||||
|
<!-- Competitors -->
|
||||||
|
{% for item in competitor_data %}
|
||||||
|
{% set comp = item.competitor %}
|
||||||
|
{% set snap = item.latest_snapshot %}
|
||||||
|
<tr>
|
||||||
|
<td>
|
||||||
|
{{ comp.competitor_name or 'Bez nazwy' }}
|
||||||
|
{% if comp.added_by == 'manual' %}
|
||||||
|
<span style="font-size: 9px; padding: 1px 4px; background: #dbeafe; color: #1e40af; border-radius: 2px; margin-left: 4px;">RECZNY</span>
|
||||||
|
{% endif %}
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
{% if comp.competitor_rating %}
|
||||||
|
<span class="rating-stars">{{ comp.competitor_rating }}/5</span>
|
||||||
|
{% if snap and snap.changes and snap.changes.get('rating_change') %}
|
||||||
|
{% set rc = snap.changes.rating_change %}
|
||||||
|
<span class="change-badge {{ 'positive' if rc > 0 else 'negative' }}">
|
||||||
|
{{ '+' if rc > 0 else '' }}{{ rc }}
|
||||||
|
</span>
|
||||||
|
{% endif %}
|
||||||
|
{% else %}
|
||||||
|
<span style="color: var(--text-tertiary);">-</span>
|
||||||
|
{% endif %}
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
{{ comp.competitor_review_count or '-' }}
|
||||||
|
{% if snap and snap.changes and snap.changes.get('new_reviews') %}
|
||||||
|
<span class="change-badge positive">+{{ snap.changes.new_reviews }}</span>
|
||||||
|
{% endif %}
|
||||||
|
</td>
|
||||||
|
<td>{{ snap.photo_count if snap else '-' }}</td>
|
||||||
|
<td style="font-size: var(--font-size-xs);">{{ comp.competitor_category or '-' }}</td>
|
||||||
|
<td style="font-size: var(--font-size-xs);">
|
||||||
|
{% if comp.competitor_website %}
|
||||||
|
<a href="{{ comp.competitor_website }}" target="_blank" rel="noopener" style="color: var(--primary);">Link</a>
|
||||||
|
{% else %}
|
||||||
|
Brak
|
||||||
|
{% endif %}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Timeline of Changes -->
|
||||||
|
{% if timeline %}
|
||||||
|
<h2 class="section-title">
|
||||||
|
<svg width="24" height="24" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z"/>
|
||||||
|
</svg>
|
||||||
|
Ostatnie zmiany
|
||||||
|
</h2>
|
||||||
|
|
||||||
|
<div class="timeline-list">
|
||||||
|
{% for snap in timeline %}
|
||||||
|
<div class="timeline-item">
|
||||||
|
<div class="timeline-date">{{ snap.snapshot_date.strftime('%d.%m') }}</div>
|
||||||
|
<div class="timeline-content">
|
||||||
|
<strong>{{ snap.competitor.competitor_name }}</strong>
|
||||||
|
{% 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 %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{% else %}
|
||||||
|
<!-- No competitors -->
|
||||||
|
<div class="empty-state">
|
||||||
|
<svg fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M17 20h5v-2a3 3 0 00-5.356-1.857M17 20H7m10 0v-2c0-.656-.126-1.283-.356-1.857M7 20H2v-2a3 3 0 015.356-1.857M7 20v-2c0-.656.126-1.283.356-1.857m0 0a5.002 5.002 0 019.288 0M15 7a3 3 0 11-6 0 3 3 0 016 0zm6 3a2 2 0 11-4 0 2 2 0 014 0zM7 10a2 2 0 11-4 0 2 2 0 014 0z"/>
|
||||||
|
</svg>
|
||||||
|
<h3>Brak sledzonych konkurentow</h3>
|
||||||
|
<p>Dla firmy {{ company.name }} nie dodano jeszcze konkurentow. Uruchom skrypt discovery, aby automatycznie znalezc konkurentow w okolicy.</p>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{% else %}
|
||||||
|
<!-- OVERVIEW: All companies with competitors -->
|
||||||
|
|
||||||
|
{% if company_data %}
|
||||||
|
{% for item in company_data %}
|
||||||
|
<div class="company-competitor-card">
|
||||||
|
<div class="company-competitor-header">
|
||||||
|
<h3>
|
||||||
|
<a href="{{ url_for('admin.admin_competitor_detail', company_id=item.company.id) }}" style="color: var(--text-primary); text-decoration: none;">
|
||||||
|
{{ item.company.name }}
|
||||||
|
</a>
|
||||||
|
</h3>
|
||||||
|
<div style="display: flex; align-items: center; gap: var(--spacing-sm);">
|
||||||
|
<span class="competitor-count-badge">{{ item.competitor_count }} konkurentow</span>
|
||||||
|
<a href="{{ url_for('admin.admin_competitor_detail', company_id=item.company.id) }}" class="btn btn-outline btn-sm">Szczegoly</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% if item.competitors %}
|
||||||
|
<table class="competitors-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Konkurent</th>
|
||||||
|
<th>Ocena</th>
|
||||||
|
<th>Opinie</th>
|
||||||
|
<th>Kategoria</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{% for comp in item.competitors[:5] %}
|
||||||
|
<tr>
|
||||||
|
<td>{{ comp.competitor_name or 'Bez nazwy' }}</td>
|
||||||
|
<td><span class="rating-stars">{{ comp.competitor_rating or '-' }}{% if comp.competitor_rating %}/5{% endif %}</span></td>
|
||||||
|
<td>{{ comp.competitor_review_count or '-' }}</td>
|
||||||
|
<td style="font-size: var(--font-size-xs);">{{ comp.competitor_category or '-' }}</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
{% if item.competitors|length > 5 %}
|
||||||
|
<tr>
|
||||||
|
<td colspan="4" style="text-align: center; color: var(--text-tertiary); font-style: italic;">
|
||||||
|
...i {{ item.competitors|length - 5 }} wiecej
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{% endif %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
|
||||||
|
{% else %}
|
||||||
|
<!-- No companies with competitors -->
|
||||||
|
<div class="empty-state">
|
||||||
|
<svg fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M17 20h5v-2a3 3 0 00-5.356-1.857M17 20H7m10 0v-2c0-.656-.126-1.283-.356-1.857M7 20H2v-2a3 3 0 015.356-1.857M7 20v-2c0-.656.126-1.283.356-1.857m0 0a5.002 5.002 0 019.288 0M15 7a3 3 0 11-6 0 3 3 0 016 0zm6 3a2 2 0 11-4 0 2 2 0 014 0zM7 10a2 2 0 11-4 0 2 2 0 014 0z"/>
|
||||||
|
</svg>
|
||||||
|
<h3>Brak monitorowanych konkurentow</h3>
|
||||||
|
<p>Zaden z firm czlonkowskich nie ma jeszcze sledzonych konkurentow. Uruchom skrypt <code>scripts/competitor_monitor_cron.py --discover</code>, aby automatycznie znalezc konkurentow dla firm z profilem Google Business.</p>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<!-- Recent Changes -->
|
||||||
|
{% if recent_changes %}
|
||||||
|
<h2 class="section-title" style="margin-top: var(--spacing-xl);">
|
||||||
|
<svg width="24" height="24" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z"/>
|
||||||
|
</svg>
|
||||||
|
Ostatnie zmiany (30 dni)
|
||||||
|
</h2>
|
||||||
|
|
||||||
|
<div class="timeline-list">
|
||||||
|
{% for snap in recent_changes %}
|
||||||
|
<div class="timeline-item">
|
||||||
|
<div class="timeline-date">{{ snap.snapshot_date.strftime('%d.%m.%Y') }}</div>
|
||||||
|
<div class="timeline-content">
|
||||||
|
<strong>{{ snap.competitor.competitor_name }}</strong>
|
||||||
|
{% 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 %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{% endif %}
|
||||||
|
{% endblock %}
|
||||||
Loading…
Reference in New Issue
Block a user