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

- 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:
Maciej Pienczyn 2026-02-07 12:00:54 +01:00
parent 42ddeabf2a
commit 02696c59a4
6 changed files with 2050 additions and 0 deletions

View 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()

View 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
View 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
}

View 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()

View 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()

View 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 %}