diff --git a/app.py b/app.py index 7318ccd..7e1fb9d 100644 --- a/app.py +++ b/app.py @@ -134,6 +134,16 @@ except ImportError as e: SEO_AUDIT_AVAILABLE = False logger.warning(f"SEO audit service not available: {e}") +# GBP (Google Business Profile) audit service +try: + from gbp_audit_service import GBPAuditService, audit_company as gbp_audit_company, get_company_audit as gbp_get_company_audit + GBP_AUDIT_AVAILABLE = True + GBP_AUDIT_VERSION = '1.0' +except ImportError as e: + GBP_AUDIT_AVAILABLE = False + GBP_AUDIT_VERSION = None + logger.warning(f"GBP audit service not available: {e}") + # Initialize Flask app app = Flask(__name__) app.config['SECRET_KEY'] = os.getenv('SECRET_KEY', 'dev-secret-key-change-in-production') @@ -3624,6 +3634,401 @@ def admin_seo(): db.close() +# ============================================================ +# GBP (GOOGLE BUSINESS PROFILE) AUDIT API +# ============================================================ + +@app.route('/api/gbp/audit/health') +def api_gbp_audit_health(): + """ + API: Health check for GBP audit service. + + Returns service status and version information. + Used by monitoring systems to verify service availability. + """ + if GBP_AUDIT_AVAILABLE: + return jsonify({ + 'status': 'ok', + 'service': 'gbp_audit', + 'version': GBP_AUDIT_VERSION, + 'available': True + }), 200 + else: + return jsonify({ + 'status': 'unavailable', + 'service': 'gbp_audit', + 'available': False, + 'error': 'GBP audit service not loaded' + }), 503 + + +@app.route('/api/gbp/audit', methods=['GET']) +def api_gbp_audit_get(): + """ + API: Get GBP audit results for a company. + + Query parameters: + - company_id: Company ID (integer) OR + - slug: Company slug (string) + + Returns: + - Latest audit results with completeness score and recommendations + - 404 if company not found + - 404 if no audit exists for the company + + Example: GET /api/gbp/audit?company_id=26 + Example: GET /api/gbp/audit?slug=pixlab-sp-z-o-o + """ + if not GBP_AUDIT_AVAILABLE: + return jsonify({ + 'success': False, + 'error': 'Usługa audytu GBP jest niedostępna.' + }), 503 + + company_id = request.args.get('company_id', type=int) + slug = request.args.get('slug') + + if not company_id and not slug: + return jsonify({ + 'success': False, + 'error': 'Podaj company_id lub slug firmy.' + }), 400 + + db = SessionLocal() + try: + # Find company + if company_id: + company = db.query(Company).filter_by(id=company_id, status='active').first() + else: + company = db.query(Company).filter_by(slug=slug, status='active').first() + + if not company: + return jsonify({ + 'success': False, + 'error': 'Firma nie znaleziona lub nieaktywna.' + }), 404 + + # Get latest audit + audit = gbp_get_company_audit(db, company.id) + + if not audit: + return jsonify({ + 'success': False, + 'error': f'Brak wyników audytu GBP dla firmy "{company.name}". Uruchom audyt używając POST /api/gbp/audit.', + 'company_id': company.id, + 'company_name': company.name + }), 404 + + # Build response + return jsonify({ + 'success': True, + 'company_id': company.id, + 'company_name': company.name, + 'company_slug': company.slug, + 'audit': { + 'id': audit.id, + 'audit_date': audit.audit_date.isoformat() if audit.audit_date else None, + 'completeness_score': audit.completeness_score, + 'score_category': audit.score_category, + 'fields_status': audit.fields_status, + 'recommendations': audit.recommendations, + 'has_name': audit.has_name, + 'has_address': audit.has_address, + 'has_phone': audit.has_phone, + 'has_website': audit.has_website, + 'has_hours': audit.has_hours, + 'has_categories': audit.has_categories, + 'has_photos': audit.has_photos, + 'has_description': audit.has_description, + 'has_services': audit.has_services, + 'has_reviews': audit.has_reviews, + 'photo_count': audit.photo_count, + 'review_count': audit.review_count, + 'average_rating': float(audit.average_rating) if audit.average_rating else None, + 'google_place_id': audit.google_place_id, + 'audit_source': audit.audit_source, + 'audit_version': audit.audit_version + } + }), 200 + + except Exception as e: + logger.error(f"Error fetching GBP audit: {e}") + return jsonify({ + 'success': False, + 'error': f'Błąd podczas pobierania audytu: {str(e)}' + }), 500 + finally: + db.close() + + +@app.route('/api/gbp/audit/') +def api_gbp_audit_by_slug(slug): + """ + API: Get GBP audit results for a company by slug. + Convenience endpoint that uses slug from URL path. + + Example: GET /api/gbp/audit/pixlab-sp-z-o-o + """ + if not GBP_AUDIT_AVAILABLE: + return jsonify({ + 'success': False, + 'error': 'Usługa audytu GBP jest niedostępna.' + }), 503 + + db = SessionLocal() + try: + company = db.query(Company).filter_by(slug=slug, status='active').first() + + if not company: + return jsonify({ + 'success': False, + 'error': f'Firma o slug "{slug}" nie znaleziona.' + }), 404 + + audit = gbp_get_company_audit(db, company.id) + + if not audit: + return jsonify({ + 'success': False, + 'error': f'Brak wyników audytu GBP dla firmy "{company.name}".', + 'company_id': company.id, + 'company_name': company.name + }), 404 + + return jsonify({ + 'success': True, + 'company_id': company.id, + 'company_name': company.name, + 'company_slug': company.slug, + 'audit': { + 'id': audit.id, + 'audit_date': audit.audit_date.isoformat() if audit.audit_date else None, + 'completeness_score': audit.completeness_score, + 'score_category': audit.score_category, + 'fields_status': audit.fields_status, + 'recommendations': audit.recommendations, + 'photo_count': audit.photo_count, + 'review_count': audit.review_count, + 'average_rating': float(audit.average_rating) if audit.average_rating else None + } + }), 200 + + finally: + db.close() + + +@app.route('/api/gbp/audit', methods=['POST']) +@login_required +@limiter.limit("20 per hour") +def api_gbp_audit_trigger(): + """ + API: Run GBP audit for a company. + + This endpoint runs a completeness audit for Google Business Profile data, + checking fields like name, address, phone, website, hours, categories, + photos, description, services, and reviews. + + Request JSON body: + - company_id: Company ID (integer) OR + - slug: Company slug (string) + - save: Whether to save results to database (default: true) + + Returns: + - Success: Audit results with completeness score and recommendations + - Error: Error message with status code + + Access: + - Members can audit their own company + - Admins can audit any company + + Rate limited to 20 requests per hour per user. + """ + if not GBP_AUDIT_AVAILABLE: + return jsonify({ + 'success': False, + 'error': 'Usługa audytu GBP jest niedostępna. Sprawdź konfigurację serwera.' + }), 503 + + # Parse request data + data = request.get_json() + if not data: + return jsonify({ + 'success': False, + 'error': 'Brak danych w żądaniu. Podaj company_id lub slug.' + }), 400 + + company_id = data.get('company_id') + slug = data.get('slug') + save_result = data.get('save', True) + + if not company_id and not slug: + return jsonify({ + 'success': False, + 'error': 'Podaj company_id lub slug firmy do audytu.' + }), 400 + + db = SessionLocal() + try: + # Find company by ID or slug + if company_id: + company = db.query(Company).filter_by(id=company_id, status='active').first() + else: + company = db.query(Company).filter_by(slug=slug, status='active').first() + + if not company: + return jsonify({ + 'success': False, + 'error': 'Firma nie znaleziona lub nieaktywna.' + }), 404 + + # Check access: admin can audit any company, member only their own + if not current_user.is_admin: + # Check if user is associated with this company + if current_user.company_id != company.id: + return jsonify({ + 'success': False, + 'error': 'Brak uprawnień. Możesz audytować tylko własną firmę.' + }), 403 + + logger.info(f"GBP audit triggered by {current_user.email} for company: {company.name} (ID: {company.id})") + + try: + # Run the audit + result = gbp_audit_company(db, company.id, save=save_result) + + # Build field status for response + fields_response = {} + for field_name, field_status in result.fields.items(): + fields_response[field_name] = { + 'status': field_status.status, + 'value': str(field_status.value) if field_status.value is not None else None, + 'score': field_status.score, + 'max_score': field_status.max_score, + 'recommendation': field_status.recommendation + } + + # Determine score category + score = result.completeness_score + if score >= 90: + score_category = 'excellent' + elif score >= 70: + score_category = 'good' + elif score >= 50: + score_category = 'needs_work' + else: + score_category = 'poor' + + return jsonify({ + 'success': True, + 'message': f'Audyt GBP dla firmy "{company.name}" został zakończony pomyślnie.', + 'company_id': company.id, + 'company_name': company.name, + 'company_slug': company.slug, + 'audit_version': GBP_AUDIT_VERSION, + 'triggered_by': current_user.email, + 'triggered_at': datetime.now().isoformat(), + 'saved': save_result, + 'audit': { + 'completeness_score': result.completeness_score, + 'score_category': score_category, + 'fields_status': fields_response, + 'recommendations': result.recommendations, + 'photo_count': result.photo_count, + 'logo_present': result.logo_present, + 'cover_photo_present': result.cover_photo_present, + 'review_count': result.review_count, + 'average_rating': float(result.average_rating) if result.average_rating else None, + 'google_place_id': result.google_place_id + } + }), 200 + + except ValueError as e: + return jsonify({ + 'success': False, + 'error': str(e), + 'company_id': company.id if company else None + }), 400 + except Exception as e: + logger.error(f"GBP audit error for company {company.id}: {e}") + return jsonify({ + 'success': False, + 'error': f'Błąd podczas wykonywania audytu: {str(e)}', + 'company_id': company.id, + 'company_name': company.name + }), 500 + + finally: + db.close() + + +# ============================================================ +# GBP AUDIT USER-FACING DASHBOARD +# ============================================================ + +@app.route('/audit/gbp/') +@login_required +def gbp_audit_dashboard(slug): + """ + User-facing GBP audit dashboard for a specific company. + + Displays Google Business Profile completeness audit results with: + - Overall completeness score (0-100) + - Field-by-field status breakdown + - AI-generated improvement recommendations + - Historical audit data + + Access control: + - Admin users can view audit for any company + - Regular users can only view audit for their own company + + Args: + slug: Company slug identifier + + Returns: + Rendered gbp_audit.html template with company and audit data + """ + if not GBP_AUDIT_AVAILABLE: + flash('Usługa audytu Google Business Profile jest tymczasowo niedostępna.', 'error') + return redirect(url_for('dashboard')) + + db = SessionLocal() + try: + # Find company by slug + company = db.query(Company).filter_by(slug=slug, status='active').first() + + if not company: + flash('Firma nie została znaleziona.', 'error') + return redirect(url_for('dashboard')) + + # Access control: admin can view any company, member only their own + if not current_user.is_admin: + if current_user.company_id != company.id: + flash('Brak uprawnień. Możesz przeglądać audyt tylko własnej firmy.', 'error') + return redirect(url_for('dashboard')) + + # Get latest audit for this company + audit = gbp_get_company_audit(db, company.id) + + # If no audit exists, we still render the page (template handles this) + # The user can trigger an audit from the dashboard + + # Determine if user can run audit (admin or company owner) + can_audit = current_user.is_admin or current_user.company_id == company.id + + logger.info(f"GBP audit dashboard viewed by {current_user.email} for company: {company.name}") + + return render_template('gbp_audit.html', + company=company, + audit=audit, + can_audit=can_audit, + gbp_audit_available=GBP_AUDIT_AVAILABLE, + gbp_audit_version=GBP_AUDIT_VERSION + ) + + finally: + db.close() + + @app.route('/api/check-email', methods=['POST']) def api_check_email(): """API: Check if email is available""" diff --git a/database.py b/database.py index 2c80e21..b569f4e 100644 --- a/database.py +++ b/database.py @@ -13,10 +13,11 @@ Models: - CompanyDigitalMaturity: Digital maturity scores and benchmarking - CompanyWebsiteAnalysis: Website analysis and SEO metrics - MaturityAssessment: Historical tracking of maturity scores +- GBPAudit: Google Business Profile audit results Author: Norda Biznes Development Team Created: 2025-11-23 -Updated: 2025-11-26 (Digital Maturity Platform - ETAP 1) +Updated: 2026-01-08 (GBP Audit Tool) """ import os @@ -1129,6 +1130,89 @@ class UserNotification(Base): self.read_at = datetime.now() +# ============================================================ +# GOOGLE BUSINESS PROFILE AUDIT +# ============================================================ + +class GBPAudit(Base): + """ + Google Business Profile audit results for companies. + Tracks completeness scores and provides improvement recommendations. + """ + __tablename__ = 'gbp_audits' + + id = Column(Integer, primary_key=True) + company_id = Column(Integer, ForeignKey('companies.id', ondelete='CASCADE'), nullable=False, index=True) + + # Audit timestamp + audit_date = Column(DateTime, default=datetime.now, nullable=False, index=True) + + # Completeness scoring (0-100) + completeness_score = Column(Integer) + + # Field-by-field status tracking + # Example: {"name": {"status": "complete", "value": "Company Name"}, "phone": {"status": "missing"}, ...} + fields_status = Column(JSONB) + + # AI-generated recommendations + # Example: [{"priority": "high", "field": "description", "recommendation": "Add a detailed business description..."}, ...] + recommendations = Column(JSONB) + + # Individual field scores (for detailed breakdown) + has_name = Column(Boolean, default=False) + has_address = Column(Boolean, default=False) + has_phone = Column(Boolean, default=False) + has_website = Column(Boolean, default=False) + has_hours = Column(Boolean, default=False) + has_categories = Column(Boolean, default=False) + has_photos = Column(Boolean, default=False) + has_description = Column(Boolean, default=False) + has_services = Column(Boolean, default=False) + has_reviews = Column(Boolean, default=False) + + # Photo counts + photo_count = Column(Integer, default=0) + logo_present = Column(Boolean, default=False) + cover_photo_present = Column(Boolean, default=False) + + # Review metrics + review_count = Column(Integer, default=0) + average_rating = Column(Numeric(2, 1)) + + # Google Place data + google_place_id = Column(String(100)) + google_maps_url = Column(String(500)) + + # Audit metadata + audit_source = Column(String(50), default='manual') # manual, automated, api + audit_version = Column(String(20), default='1.0') + audit_errors = Column(Text) + + # Timestamps + created_at = Column(DateTime, default=datetime.now) + updated_at = Column(DateTime, default=datetime.now, onupdate=datetime.now) + + # Relationship + company = relationship('Company', backref='gbp_audits') + + def __repr__(self): + return f'' + + @property + def score_category(self): + """Return score category: excellent, good, needs_work, poor""" + if self.completeness_score is None: + return 'unknown' + if self.completeness_score >= 90: + return 'excellent' + elif self.completeness_score >= 70: + return 'good' + elif self.completeness_score >= 50: + return 'needs_work' + else: + return 'poor' + + # ============================================================ # MEMBERSHIP FEES # ============================================================ diff --git a/database/migrations/add_gbp_audit.sql b/database/migrations/add_gbp_audit.sql new file mode 100644 index 0000000..6778e72 --- /dev/null +++ b/database/migrations/add_gbp_audit.sql @@ -0,0 +1,216 @@ +-- ============================================================ +-- NordaBiz - Migration: Google Business Profile (GBP) Audit Tables +-- ============================================================ +-- Created: 2026-01-08 +-- Description: +-- - Creates gbp_audits table for storing GBP completeness audit results +-- - Tracks field-by-field status with JSONB for flexibility +-- - Stores AI-generated recommendations +-- - Includes indexes and helpful views +-- +-- Usage: +-- PostgreSQL: psql -h localhost -U nordabiz_app -d nordabiz -f add_gbp_audit.sql +-- SQLite: Not fully supported (JSONB columns) +-- ============================================================ + +-- ============================================================ +-- 1. MAIN GBP_AUDITS TABLE +-- ============================================================ + +CREATE TABLE IF NOT EXISTS gbp_audits ( + id SERIAL PRIMARY KEY, + + -- Company reference + company_id INTEGER NOT NULL REFERENCES companies(id) ON DELETE CASCADE, + + -- Audit timestamp + audit_date TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + + -- Completeness scoring (0-100) + completeness_score INTEGER, + + -- Field-by-field status tracking (JSONB) + -- Example: {"name": {"status": "complete", "value": "Company Name"}, "phone": {"status": "missing"}, ...} + fields_status JSONB, + + -- AI-generated recommendations (JSONB) + -- Example: [{"priority": "high", "field": "description", "recommendation": "Add a detailed business description..."}, ...] + recommendations JSONB, + + -- Individual field completion flags + has_name BOOLEAN DEFAULT FALSE, + has_address BOOLEAN DEFAULT FALSE, + has_phone BOOLEAN DEFAULT FALSE, + has_website BOOLEAN DEFAULT FALSE, + has_hours BOOLEAN DEFAULT FALSE, + has_categories BOOLEAN DEFAULT FALSE, + has_photos BOOLEAN DEFAULT FALSE, + has_description BOOLEAN DEFAULT FALSE, + has_services BOOLEAN DEFAULT FALSE, + has_reviews BOOLEAN DEFAULT FALSE, + + -- Photo metrics + photo_count INTEGER DEFAULT 0, + logo_present BOOLEAN DEFAULT FALSE, + cover_photo_present BOOLEAN DEFAULT FALSE, + + -- Review metrics + review_count INTEGER DEFAULT 0, + average_rating NUMERIC(2, 1), + + -- Google Place integration + google_place_id VARCHAR(100), + google_maps_url VARCHAR(500), + + -- Audit metadata + audit_source VARCHAR(50) DEFAULT 'manual', -- manual, automated, api + audit_version VARCHAR(20) DEFAULT '1.0', + audit_errors TEXT, + + -- Timestamps + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP +); + +COMMENT ON TABLE gbp_audits IS 'Google Business Profile completeness audit results'; +COMMENT ON COLUMN gbp_audits.completeness_score IS 'Overall GBP completeness score 0-100'; +COMMENT ON COLUMN gbp_audits.fields_status IS 'Field-by-field status with values as JSON'; +COMMENT ON COLUMN gbp_audits.recommendations IS 'AI-generated improvement recommendations as JSON array'; +COMMENT ON COLUMN gbp_audits.audit_source IS 'How audit was triggered: manual, automated, api'; +COMMENT ON COLUMN gbp_audits.google_place_id IS 'Google Places API place_id for verification'; +COMMENT ON COLUMN gbp_audits.google_maps_url IS 'Direct link to Google Maps listing'; + +-- ============================================================ +-- 2. INDEXES FOR PERFORMANCE +-- ============================================================ + +CREATE INDEX IF NOT EXISTS idx_gbp_audits_company ON gbp_audits(company_id); +CREATE INDEX IF NOT EXISTS idx_gbp_audits_date ON gbp_audits(audit_date); +CREATE INDEX IF NOT EXISTS idx_gbp_audits_score ON gbp_audits(completeness_score); +CREATE INDEX IF NOT EXISTS idx_gbp_audits_company_date ON gbp_audits(company_id, audit_date DESC); + +-- ============================================================ +-- 3. UPDATE TRIGGER FOR updated_at +-- ============================================================ + +CREATE OR REPLACE FUNCTION gbp_audits_update_timestamp() +RETURNS TRIGGER AS $$ +BEGIN + NEW.updated_at = CURRENT_TIMESTAMP; + RETURN NEW; +END; +$$ LANGUAGE plpgsql; + +DROP TRIGGER IF EXISTS trigger_gbp_audits_update ON gbp_audits; +CREATE TRIGGER trigger_gbp_audits_update + BEFORE UPDATE ON gbp_audits + FOR EACH ROW + EXECUTE FUNCTION gbp_audits_update_timestamp(); + +-- ============================================================ +-- 4. GBP AUDIT OVERVIEW VIEW +-- ============================================================ + +CREATE OR REPLACE VIEW v_company_gbp_overview AS +SELECT + c.id, + c.name, + c.slug, + c.website, + cat.name as category_name, + ga.completeness_score, + ga.has_name, + ga.has_address, + ga.has_phone, + ga.has_website, + ga.has_hours, + ga.has_categories, + ga.has_photos, + ga.has_description, + ga.has_services, + ga.has_reviews, + ga.photo_count, + ga.review_count, + ga.average_rating, + ga.google_place_id, + ga.audit_date, + ga.audit_source, + -- Score category + CASE + WHEN ga.completeness_score >= 90 THEN 'excellent' + WHEN ga.completeness_score >= 70 THEN 'good' + WHEN ga.completeness_score >= 50 THEN 'needs_work' + WHEN ga.completeness_score IS NOT NULL THEN 'poor' + ELSE 'not_audited' + END as score_category +FROM companies c +LEFT JOIN categories cat ON c.category_id = cat.id +LEFT JOIN LATERAL ( + SELECT * FROM gbp_audits + WHERE company_id = c.id + ORDER BY audit_date DESC + LIMIT 1 +) ga ON TRUE +ORDER BY ga.completeness_score DESC NULLS LAST; + +COMMENT ON VIEW v_company_gbp_overview IS 'Latest GBP audit results per company for dashboard'; + +-- ============================================================ +-- 5. GBP AUDIT HISTORY VIEW +-- ============================================================ + +CREATE OR REPLACE VIEW v_gbp_audit_history AS +SELECT + ga.id as audit_id, + c.id as company_id, + c.name as company_name, + c.slug as company_slug, + ga.completeness_score, + ga.audit_date, + ga.audit_source, + ga.audit_version, + -- Previous score for comparison + LAG(ga.completeness_score) OVER ( + PARTITION BY ga.company_id + ORDER BY ga.audit_date + ) as previous_score, + -- Score change + ga.completeness_score - LAG(ga.completeness_score) OVER ( + PARTITION BY ga.company_id + ORDER BY ga.audit_date + ) as score_change +FROM gbp_audits ga +JOIN companies c ON ga.company_id = c.id +ORDER BY ga.audit_date DESC; + +COMMENT ON VIEW v_gbp_audit_history IS 'GBP audit history with score trend tracking'; + +-- ============================================================ +-- 6. GRANTS FOR APPLICATION USER +-- ============================================================ + +-- Grant permissions on table +GRANT SELECT, INSERT, UPDATE, DELETE ON TABLE gbp_audits TO nordabiz_app; + +-- Grant permissions on sequence +GRANT USAGE, SELECT ON SEQUENCE gbp_audits_id_seq TO nordabiz_app; + +-- Grant permissions on views +GRANT SELECT ON v_company_gbp_overview TO nordabiz_app; +GRANT SELECT ON v_gbp_audit_history TO nordabiz_app; + +-- ============================================================ +-- MIGRATION COMPLETE +-- ============================================================ + +-- Verify migration (PostgreSQL only) +DO $$ +BEGIN + RAISE NOTICE 'GBP Audit migration completed successfully!'; + RAISE NOTICE 'Created:'; + RAISE NOTICE ' - Table: gbp_audits'; + RAISE NOTICE ' - Indexes: company_id, audit_date, completeness_score, company_date'; + RAISE NOTICE ' - Trigger: updated_at auto-update'; + RAISE NOTICE ' - Views: v_company_gbp_overview, v_gbp_audit_history'; + RAISE NOTICE ' - Grants: nordabiz_app permissions'; +END $$; diff --git a/gbp_audit_service.py b/gbp_audit_service.py new file mode 100644 index 0000000..dd26919 --- /dev/null +++ b/gbp_audit_service.py @@ -0,0 +1,1065 @@ +""" +GBP Audit Service for Norda Biznes Hub +======================================= + +Google Business Profile completeness audit service with: +- Field-by-field completeness checking +- Weighted scoring algorithm +- AI-powered recommendations (via Gemini) +- Historical tracking + +Inspired by Localo.com audit features. + +Author: Norda Biznes Development Team +Created: 2026-01-08 +""" + +import json +import logging +from dataclasses import dataclass, field +from datetime import datetime +from decimal import Decimal +from typing import Dict, List, Optional, Any + +from sqlalchemy.orm import Session + +from database import Company, GBPAudit, CompanyWebsiteAnalysis, SessionLocal +import gemini_service + +# Configure logging +logger = logging.getLogger(__name__) + + +# Field weights for completeness scoring (total = 100) +FIELD_WEIGHTS = { + 'name': 10, # Business name - essential + 'address': 10, # Full address - essential for local SEO + 'phone': 8, # Contact phone - important + 'website': 8, # Business website - important + 'hours': 8, # Opening hours - important for customers + 'categories': 10, # Business categories - essential for discovery + 'photos': 15, # Photos - high impact on engagement + 'description': 12, # Business description - important for SEO + 'services': 10, # Services list - important for discovery + 'reviews': 9, # Review presence and rating - trust factor +} + +# Photo requirements for optimal GBP profile +PHOTO_REQUIREMENTS = { + 'minimum': 3, # Minimum photos for basic completeness + 'recommended': 10, # Recommended for good profile + 'optimal': 25, # Optimal for excellent profile +} + +# Review thresholds +REVIEW_THRESHOLDS = { + 'minimum': 1, # At least 1 review + 'good': 5, # Good number of reviews + 'excellent': 20, # Excellent review count +} + + +@dataclass +class FieldStatus: + """Status of a single GBP field""" + field_name: str + status: str # 'complete', 'partial', 'missing' + value: Optional[Any] = None + score: float = 0.0 + max_score: float = 0.0 + recommendation: Optional[str] = None + + +@dataclass +class AuditResult: + """Complete GBP audit result""" + company_id: int + completeness_score: int + fields: Dict[str, FieldStatus] = field(default_factory=dict) + recommendations: List[Dict[str, Any]] = field(default_factory=list) + photo_count: int = 0 + logo_present: bool = False + cover_photo_present: bool = False + review_count: int = 0 + average_rating: Optional[Decimal] = None + google_place_id: Optional[str] = None + google_maps_url: Optional[str] = None + audit_errors: Optional[str] = None + + +class GBPAuditService: + """Service for auditing Google Business Profile completeness""" + + def __init__(self, db: Session): + """ + Initialize GBP Audit service. + + Args: + db: SQLAlchemy database session + """ + self.db = db + + def audit_company(self, company_id: int) -> AuditResult: + """ + Run full GBP audit for a company. + + Args: + company_id: ID of the company to audit + + Returns: + AuditResult with completeness score and field details + """ + company = self.db.query(Company).filter(Company.id == company_id).first() + if not company: + raise ValueError(f"Company with id {company_id} not found") + + # Get latest website analysis for Google Business data + website_analysis = self.db.query(CompanyWebsiteAnalysis).filter( + CompanyWebsiteAnalysis.company_id == company_id + ).order_by(CompanyWebsiteAnalysis.analyzed_at.desc()).first() + + # Audit each field + fields = {} + total_score = 0.0 + recommendations = [] + + # Name check + fields['name'] = self._check_name(company) + total_score += fields['name'].score + + # Address check + fields['address'] = self._check_address(company) + total_score += fields['address'].score + + # Phone check + fields['phone'] = self._check_phone(company) + total_score += fields['phone'].score + + # Website check + fields['website'] = self._check_website(company) + total_score += fields['website'].score + + # Hours check (from website analysis if available) + fields['hours'] = self._check_hours(company, website_analysis) + total_score += fields['hours'].score + + # Categories check + fields['categories'] = self._check_categories(company) + total_score += fields['categories'].score + + # Photos check (from website analysis) + fields['photos'] = self._check_photos(company, website_analysis) + total_score += fields['photos'].score + + # Description check + fields['description'] = self._check_description(company) + total_score += fields['description'].score + + # Services check + fields['services'] = self._check_services(company) + total_score += fields['services'].score + + # Reviews check (from website analysis) + fields['reviews'] = self._check_reviews(company, website_analysis) + total_score += fields['reviews'].score + + # Build recommendations from fields with issues + for field_name, field_status in fields.items(): + if field_status.recommendation: + priority = self._get_priority(field_status) + recommendations.append({ + 'priority': priority, + 'field': field_name, + 'recommendation': field_status.recommendation, + 'impact': FIELD_WEIGHTS.get(field_name, 0) + }) + + # Sort recommendations by priority and impact + priority_order = {'high': 0, 'medium': 1, 'low': 2} + recommendations.sort(key=lambda x: (priority_order.get(x['priority'], 3), -x['impact'])) + + # Extract Google Business data from website analysis + google_place_id = None + google_maps_url = None + review_count = 0 + average_rating = None + + if website_analysis: + google_place_id = website_analysis.google_place_id + review_count = website_analysis.google_reviews_count or 0 + average_rating = website_analysis.google_rating + + # Create result + result = AuditResult( + company_id=company_id, + completeness_score=round(total_score), + fields=fields, + recommendations=recommendations, + photo_count=fields['photos'].value if isinstance(fields['photos'].value, int) else 0, + logo_present=False, # Would need specific logo detection + cover_photo_present=False, # Would need specific cover detection + review_count=review_count, + average_rating=average_rating, + google_place_id=google_place_id, + google_maps_url=google_maps_url + ) + + return result + + def save_audit(self, result: AuditResult, source: str = 'manual') -> GBPAudit: + """ + Save audit result to database. + + Args: + result: AuditResult to save + source: Audit source ('manual', 'automated', 'api') + + Returns: + Saved GBPAudit record + """ + # Convert fields to JSON-serializable format + fields_status = {} + for name, field_status in result.fields.items(): + fields_status[name] = { + 'status': field_status.status, + 'value': str(field_status.value) if field_status.value is not None else None, + 'score': field_status.score, + 'max_score': field_status.max_score + } + + # Create audit record + audit = GBPAudit( + company_id=result.company_id, + audit_date=datetime.now(), + completeness_score=result.completeness_score, + fields_status=fields_status, + recommendations=result.recommendations, + has_name=result.fields.get('name', FieldStatus('name', 'missing')).status == 'complete', + has_address=result.fields.get('address', FieldStatus('address', 'missing')).status == 'complete', + has_phone=result.fields.get('phone', FieldStatus('phone', 'missing')).status == 'complete', + has_website=result.fields.get('website', FieldStatus('website', 'missing')).status == 'complete', + has_hours=result.fields.get('hours', FieldStatus('hours', 'missing')).status == 'complete', + has_categories=result.fields.get('categories', FieldStatus('categories', 'missing')).status == 'complete', + has_photos=result.fields.get('photos', FieldStatus('photos', 'missing')).status in ['complete', 'partial'], + has_description=result.fields.get('description', FieldStatus('description', 'missing')).status == 'complete', + has_services=result.fields.get('services', FieldStatus('services', 'missing')).status == 'complete', + has_reviews=result.fields.get('reviews', FieldStatus('reviews', 'missing')).status in ['complete', 'partial'], + photo_count=result.photo_count, + logo_present=result.logo_present, + cover_photo_present=result.cover_photo_present, + review_count=result.review_count, + average_rating=result.average_rating, + google_place_id=result.google_place_id, + google_maps_url=result.google_maps_url, + audit_source=source, + audit_version='1.0', + audit_errors=result.audit_errors + ) + + self.db.add(audit) + self.db.commit() + self.db.refresh(audit) + + logger.info(f"GBP audit saved for company {result.company_id}: score={result.completeness_score}") + return audit + + def get_latest_audit(self, company_id: int) -> Optional[GBPAudit]: + """ + Get the most recent audit for a company. + + Args: + company_id: Company ID + + Returns: + Latest GBPAudit or None + """ + return self.db.query(GBPAudit).filter( + GBPAudit.company_id == company_id + ).order_by(GBPAudit.audit_date.desc()).first() + + def get_audit_history(self, company_id: int, limit: int = 10) -> List[GBPAudit]: + """ + Get audit history for a company. + + Args: + company_id: Company ID + limit: Maximum number of audits to return + + Returns: + List of GBPAudit records ordered by date descending + """ + return self.db.query(GBPAudit).filter( + GBPAudit.company_id == company_id + ).order_by(GBPAudit.audit_date.desc()).limit(limit).all() + + # === Field Check Methods === + + def _check_name(self, company: Company) -> FieldStatus: + """Check business name completeness""" + max_score = FIELD_WEIGHTS['name'] + + if company.name and len(company.name.strip()) >= 3: + return FieldStatus( + field_name='name', + status='complete', + value=company.name, + score=max_score, + max_score=max_score + ) + + return FieldStatus( + field_name='name', + status='missing', + score=0, + max_score=max_score, + recommendation='Dodaj nazwę firmy do wizytówki Google. Nazwa powinna być oficjalną nazwą firmy.' + ) + + def _check_address(self, company: Company) -> FieldStatus: + """Check address completeness""" + max_score = FIELD_WEIGHTS['address'] + + # Check all address components + has_street = bool(company.address_street) + has_city = bool(company.address_city) + has_postal = bool(company.address_postal) + + if has_street and has_city and has_postal: + return FieldStatus( + field_name='address', + status='complete', + value=company.address_full or f"{company.address_street}, {company.address_postal} {company.address_city}", + score=max_score, + max_score=max_score + ) + + if has_city or has_street: + partial_score = max_score * 0.5 + return FieldStatus( + field_name='address', + status='partial', + value=company.address_city or company.address_street, + score=partial_score, + max_score=max_score, + recommendation='Uzupełnij pełny adres firmy (ulica, kod pocztowy, miasto) dla lepszej widoczności w mapach.' + ) + + return FieldStatus( + field_name='address', + status='missing', + score=0, + max_score=max_score, + recommendation='Dodaj adres firmy do wizytówki Google. Pełny adres jest kluczowy dla lokalnego SEO.' + ) + + def _check_phone(self, company: Company) -> FieldStatus: + """Check phone number presence""" + max_score = FIELD_WEIGHTS['phone'] + + if company.phone and len(company.phone.strip()) >= 9: + return FieldStatus( + field_name='phone', + status='complete', + value=company.phone, + score=max_score, + max_score=max_score + ) + + # Check contacts relationship for additional phones + if hasattr(company, 'contacts') and company.contacts: + phones = [c for c in company.contacts if c.contact_type == 'phone'] + if phones: + return FieldStatus( + field_name='phone', + status='complete', + value=phones[0].value, + score=max_score, + max_score=max_score + ) + + return FieldStatus( + field_name='phone', + status='missing', + score=0, + max_score=max_score, + recommendation='Dodaj numer telefonu do wizytówki. Klienci oczekują możliwości bezpośredniego kontaktu.' + ) + + def _check_website(self, company: Company) -> FieldStatus: + """Check website presence""" + max_score = FIELD_WEIGHTS['website'] + + if company.website and company.website.strip().startswith(('http://', 'https://')): + return FieldStatus( + field_name='website', + status='complete', + value=company.website, + score=max_score, + max_score=max_score + ) + + if company.website: + # Has website but might not be properly formatted + return FieldStatus( + field_name='website', + status='partial', + value=company.website, + score=max_score * 0.7, + max_score=max_score, + recommendation='Upewnij się, że adres strony internetowej zawiera protokół (https://).' + ) + + return FieldStatus( + field_name='website', + status='missing', + score=0, + max_score=max_score, + recommendation='Dodaj stronę internetową firmy. Link do strony zwiększa wiarygodność i ruch.' + ) + + def _check_hours(self, company: Company, analysis: Optional[CompanyWebsiteAnalysis]) -> FieldStatus: + """Check opening hours presence""" + max_score = FIELD_WEIGHTS['hours'] + + # Hours are typically not stored in Company model directly + # We would need to check Google Business data or a dedicated field + # For now, we check if there's any indicator of hours being set + + # This is a placeholder - in production, you'd check: + # 1. Google Business API data + # 2. Scraped hours from website + # 3. Dedicated hours field in database + + # Check if we have any business status from Google + if analysis and analysis.google_business_status: + return FieldStatus( + field_name='hours', + status='complete', + value='Godziny dostępne w Google', + score=max_score, + max_score=max_score + ) + + return FieldStatus( + field_name='hours', + status='missing', + score=0, + max_score=max_score, + recommendation='Dodaj godziny otwarcia firmy. Klienci chcą wiedzieć, kiedy mogą Cię odwiedzić.' + ) + + def _check_categories(self, company: Company) -> FieldStatus: + """Check business category completeness""" + max_score = FIELD_WEIGHTS['categories'] + + # Check if company has a category assigned + if company.category_id and company.category: + return FieldStatus( + field_name='categories', + status='complete', + value=company.category.name if company.category else None, + score=max_score, + max_score=max_score + ) + + return FieldStatus( + field_name='categories', + status='missing', + score=0, + max_score=max_score, + recommendation='Wybierz główną kategorię działalności. Kategoria pomaga klientom znaleźć Twoją firmę.' + ) + + def _check_photos(self, company: Company, analysis: Optional[CompanyWebsiteAnalysis]) -> FieldStatus: + """Check photo completeness""" + max_score = FIELD_WEIGHTS['photos'] + + # Photo count would typically come from: + # 1. Google Business API + # 2. Scraped data + # 3. Company photo gallery in our system + + # For now, we estimate based on website analysis + photo_count = 0 + if analysis and analysis.total_images: + # Rough estimate: website images might indicate business has photos + photo_count = min(analysis.total_images, 30) # Cap at reasonable number + + if photo_count >= PHOTO_REQUIREMENTS['recommended']: + return FieldStatus( + field_name='photos', + status='complete', + value=photo_count, + score=max_score, + max_score=max_score + ) + + if photo_count >= PHOTO_REQUIREMENTS['minimum']: + partial_score = max_score * (photo_count / PHOTO_REQUIREMENTS['recommended']) + return FieldStatus( + field_name='photos', + status='partial', + value=photo_count, + score=min(partial_score, max_score * 0.7), + max_score=max_score, + recommendation=f'Dodaj więcej zdjęć firmy. Zalecane minimum to {PHOTO_REQUIREMENTS["recommended"]} zdjęć.' + ) + + return FieldStatus( + field_name='photos', + status='missing', + value=photo_count, + score=0, + max_score=max_score, + recommendation='Dodaj zdjęcia firmy (logo, wnętrze, zespół, produkty). Wizytówki ze zdjęciami mają 42% więcej zapytań o wskazówki dojazdu.' + ) + + def _check_description(self, company: Company) -> FieldStatus: + """Check business description completeness""" + max_score = FIELD_WEIGHTS['description'] + + # Check short and full descriptions + desc = company.description_full or company.description_short + + if desc and len(desc.strip()) >= 100: + return FieldStatus( + field_name='description', + status='complete', + value=desc[:100] + '...' if len(desc) > 100 else desc, + score=max_score, + max_score=max_score + ) + + if desc and len(desc.strip()) >= 30: + return FieldStatus( + field_name='description', + status='partial', + value=desc, + score=max_score * 0.5, + max_score=max_score, + recommendation='Rozbuduj opis firmy. Dobry opis powinien mieć minimum 100-200 znaków i zawierać słowa kluczowe.' + ) + + return FieldStatus( + field_name='description', + status='missing', + score=0, + max_score=max_score, + recommendation='Dodaj szczegółowy opis firmy. Opisz czym się zajmujesz, jakie usługi oferujesz i co Cię wyróżnia.' + ) + + def _check_services(self, company: Company) -> FieldStatus: + """Check services list completeness""" + max_score = FIELD_WEIGHTS['services'] + + # Check company services relationship + service_count = 0 + if hasattr(company, 'services') and company.services: + service_count = len(company.services) + + # Also check services_offered text field + has_services_text = bool(company.services_offered and len(company.services_offered.strip()) > 10) + + if service_count >= 3 or has_services_text: + return FieldStatus( + field_name='services', + status='complete', + value=service_count if service_count else 'W opisie', + score=max_score, + max_score=max_score + ) + + if service_count >= 1: + return FieldStatus( + field_name='services', + status='partial', + value=service_count, + score=max_score * 0.5, + max_score=max_score, + recommendation='Dodaj więcej usług do wizytówki. Zalecane jest minimum 3-5 głównych usług.' + ) + + return FieldStatus( + field_name='services', + status='missing', + score=0, + max_score=max_score, + recommendation='Dodaj listę usług lub produktów. Pomaga to klientom zrozumieć Twoją ofertę.' + ) + + def _check_reviews(self, company: Company, analysis: Optional[CompanyWebsiteAnalysis]) -> FieldStatus: + """Check reviews presence and quality""" + max_score = FIELD_WEIGHTS['reviews'] + + review_count = 0 + rating = None + + if analysis: + review_count = analysis.google_reviews_count or 0 + rating = analysis.google_rating + + if review_count >= REVIEW_THRESHOLDS['good'] and rating and float(rating) >= 4.0: + return FieldStatus( + field_name='reviews', + status='complete', + value=f'{review_count} opinii, ocena {rating}', + score=max_score, + max_score=max_score + ) + + if review_count >= REVIEW_THRESHOLDS['minimum']: + partial_score = max_score * 0.6 + return FieldStatus( + field_name='reviews', + status='partial', + value=f'{review_count} opinii' + (f', ocena {rating}' if rating else ''), + score=partial_score, + max_score=max_score, + recommendation='Zachęcaj klientów do zostawiania opinii. Więcej pozytywnych recenzji zwiększa zaufanie.' + ) + + return FieldStatus( + field_name='reviews', + status='missing', + value=review_count, + score=0, + max_score=max_score, + recommendation='Zbieraj opinie od klientów. Wizytówki z opiniami są bardziej wiarygodne i lepiej widoczne.' + ) + + def _get_priority(self, field_status: FieldStatus) -> str: + """Determine recommendation priority based on field importance and status""" + weight = FIELD_WEIGHTS.get(field_status.field_name, 0) + + if field_status.status == 'missing': + if weight >= 10: + return 'high' + elif weight >= 8: + return 'medium' + else: + return 'low' + elif field_status.status == 'partial': + if weight >= 10: + return 'medium' + else: + return 'low' + + return 'low' + + # === AI-Powered Recommendations === + + def generate_ai_recommendations( + self, + company: Company, + result: AuditResult, + user_id: Optional[int] = None + ) -> List[Dict[str, Any]]: + """ + Generate AI-powered recommendations using Gemini. + + Args: + company: Company being audited + result: AuditResult from the audit + user_id: Optional user ID for cost tracking + + Returns: + List of AI-generated recommendation dicts with keys: + - priority: 'high', 'medium', 'low' + - field: field name this applies to + - recommendation: AI-generated recommendation text + - action_steps: list of specific action steps + - expected_impact: description of expected improvement + """ + service = gemini_service.get_gemini_service() + if not service: + logger.warning("Gemini service not available - using static recommendations") + return result.recommendations + + try: + # Build context for AI + prompt = self._build_ai_recommendation_prompt(company, result) + + # Call Gemini with cost tracking + response_text = service.generate_text( + prompt=prompt, + feature='gbp_audit_ai', + user_id=user_id, + temperature=0.7, + max_tokens=2000 + ) + + # Parse AI response + ai_recommendations = self._parse_ai_recommendations(response_text, result) + + logger.info( + f"AI recommendations generated for company {company.id}: " + f"{len(ai_recommendations)} recommendations" + ) + + return ai_recommendations + + except Exception as e: + logger.error(f"AI recommendation generation failed: {e}") + # Fall back to static recommendations + return result.recommendations + + def _build_ai_recommendation_prompt( + self, + company: Company, + result: AuditResult + ) -> str: + """ + Build prompt for Gemini to generate personalized recommendations. + + Args: + company: Company being audited + result: AuditResult with field statuses + + Returns: + Formatted prompt string + """ + # Build field status summary + field_summary = [] + for field_name, field_status in result.fields.items(): + status_emoji = { + 'complete': '✅', + 'partial': '⚠️', + 'missing': '❌' + }.get(field_status.status, '❓') + + field_summary.append( + f"- {field_name}: {status_emoji} {field_status.status} " + f"({field_status.score:.1f}/{field_status.max_score:.1f} pkt)" + ) + + # Get category info + category_name = company.category.name if company.category else 'Nieznana' + + prompt = f"""Jesteś ekspertem od Google Business Profile (Wizytówki Google) i lokalnego SEO. + +FIRMA: {company.name} +BRANŻA: {category_name} +MIASTO: {company.address_city or 'Nieznane'} +WYNIK AUDYTU: {result.completeness_score}/100 + +STATUS PÓL WIZYTÓWKI: +{chr(10).join(field_summary)} + +LICZBA ZDJĘĆ: {result.photo_count} +LICZBA OPINII: {result.review_count} +OCENA: {result.average_rating or 'Brak'} + +ZADANIE: +Wygeneruj 3-5 spersonalizowanych rekomendacji dla tej firmy, aby poprawić jej wizytówkę Google. + +WYMAGANIA: +1. Każda rekomendacja powinna być konkretna i dostosowana do branży firmy +2. Skup się na polach z najniższymi wynikami +3. Podaj praktyczne kroki do wykonania +4. Używaj języka polskiego + +ZWRÓĆ ODPOWIEDŹ W FORMACIE JSON (TYLKO JSON, BEZ MARKDOWN): +[ + {{ + "priority": "high|medium|low", + "field": "nazwa_pola", + "recommendation": "Krótki opis co poprawić", + "action_steps": ["Krok 1", "Krok 2", "Krok 3"], + "expected_impact": "Opis spodziewanej poprawy" + }} +] + +Priorytety: +- high: kluczowe pola (name, address, categories, description) +- medium: ważne pola (phone, website, photos, services) +- low: dodatkowe pola (hours, reviews) + +Odpowiedź (TYLKO JSON):""" + + return prompt + + def _parse_ai_recommendations( + self, + response_text: str, + fallback_result: AuditResult + ) -> List[Dict[str, Any]]: + """ + Parse AI response into structured recommendations. + + Args: + response_text: Raw text from Gemini + fallback_result: AuditResult to use for fallback + + Returns: + List of recommendation dicts + """ + try: + # Clean up response - remove markdown code blocks if present + cleaned = response_text.strip() + if cleaned.startswith('```'): + # Remove markdown code block markers + lines = cleaned.split('\n') + # Find JSON content between ``` markers + json_lines = [] + in_json = False + for line in lines: + if line.startswith('```') and not in_json: + in_json = True + continue + elif line.startswith('```') and in_json: + break + elif in_json: + json_lines.append(line) + cleaned = '\n'.join(json_lines) + + # Parse JSON + recommendations = json.loads(cleaned) + + # Validate and enhance recommendations + valid_recommendations = [] + valid_priorities = {'high', 'medium', 'low'} + valid_fields = set(FIELD_WEIGHTS.keys()) + + for rec in recommendations: + if not isinstance(rec, dict): + continue + + # Validate priority + priority = rec.get('priority', 'medium') + if priority not in valid_priorities: + priority = 'medium' + + # Validate field + field = rec.get('field', 'general') + if field not in valid_fields: + field = 'general' + + # Get impact score from field weights + impact = FIELD_WEIGHTS.get(field, 5) + + valid_recommendations.append({ + 'priority': priority, + 'field': field, + 'recommendation': rec.get('recommendation', ''), + 'action_steps': rec.get('action_steps', []), + 'expected_impact': rec.get('expected_impact', ''), + 'impact': impact, + 'source': 'ai' + }) + + if valid_recommendations: + # Sort by priority and impact + priority_order = {'high': 0, 'medium': 1, 'low': 2} + valid_recommendations.sort( + key=lambda x: (priority_order.get(x['priority'], 3), -x['impact']) + ) + return valid_recommendations + + except json.JSONDecodeError as e: + logger.warning(f"Failed to parse AI recommendations JSON: {e}") + except Exception as e: + logger.warning(f"Error processing AI recommendations: {e}") + + # Return fallback recommendations with source marker + fallback = [] + for rec in fallback_result.recommendations: + rec_copy = dict(rec) + rec_copy['source'] = 'static' + rec_copy['action_steps'] = [] + rec_copy['expected_impact'] = '' + fallback.append(rec_copy) + + return fallback + + def audit_with_ai( + self, + company_id: int, + user_id: Optional[int] = None + ) -> AuditResult: + """ + Run full GBP audit with AI-powered recommendations. + + Args: + company_id: ID of the company to audit + user_id: Optional user ID for cost tracking + + Returns: + AuditResult with AI-enhanced recommendations + """ + # Run standard audit + result = self.audit_company(company_id) + + # Get company for AI context + company = self.db.query(Company).filter(Company.id == company_id).first() + if not company: + return result + + # Generate AI recommendations + ai_recommendations = self.generate_ai_recommendations( + company=company, + result=result, + user_id=user_id + ) + + # Replace static recommendations with AI-generated ones + result.recommendations = ai_recommendations + + return result + + +# === Convenience Functions === + +def audit_company(db: Session, company_id: int, save: bool = True) -> AuditResult: + """ + Audit a company's GBP completeness. + + Args: + db: Database session + company_id: Company ID to audit + save: Whether to save audit to database + + Returns: + AuditResult with completeness score and recommendations + """ + service = GBPAuditService(db) + result = service.audit_company(company_id) + + if save: + service.save_audit(result) + + return result + + +def get_company_audit(db: Session, company_id: int) -> Optional[GBPAudit]: + """ + Get the latest audit for a company. + + Args: + db: Database session + company_id: Company ID + + Returns: + Latest GBPAudit or None + """ + service = GBPAuditService(db) + return service.get_latest_audit(company_id) + + +def audit_company_with_ai( + db: Session, + company_id: int, + save: bool = True, + user_id: Optional[int] = None +) -> AuditResult: + """ + Audit a company's GBP completeness with AI-powered recommendations. + + Args: + db: Database session + company_id: Company ID to audit + save: Whether to save audit to database + user_id: Optional user ID for cost tracking + + Returns: + AuditResult with AI-enhanced recommendations + """ + service = GBPAuditService(db) + result = service.audit_with_ai(company_id, user_id=user_id) + + if save: + service.save_audit(result, source='ai') + + return result + + +def batch_audit_companies( + db: Session, + company_ids: Optional[List[int]] = None, + save: bool = True +) -> Dict[int, AuditResult]: + """ + Audit multiple companies. + + Args: + db: Database session + company_ids: List of company IDs (None = all active companies) + save: Whether to save audits to database + + Returns: + Dict mapping company_id to AuditResult + """ + service = GBPAuditService(db) + + # Get companies to audit + if company_ids is None: + companies = db.query(Company).filter(Company.status == 'active').all() + company_ids = [c.id for c in companies] + + results = {} + for company_id in company_ids: + try: + result = service.audit_company(company_id) + if save: + service.save_audit(result, source='automated') + results[company_id] = result + except Exception as e: + logger.error(f"Failed to audit company {company_id}: {e}") + + return results + + +# === Main for Testing === + +if __name__ == '__main__': + import sys + + # Test the service + logging.basicConfig(level=logging.INFO) + + # Check for --ai flag to test AI recommendations + use_ai = '--ai' in sys.argv + + db = SessionLocal() + try: + # Get first active company + company = db.query(Company).filter(Company.status == 'active').first() + if company: + print(f"\nAuditing company: {company.name} (ID: {company.id})") + print("-" * 50) + + if use_ai: + print("\n[AI MODE] Generating AI-powered recommendations...") + result = audit_company_with_ai(db, company.id, save=False) + else: + result = audit_company(db, company.id, save=False) + + print(f"\nCompleteness Score: {result.completeness_score}/100") + print(f"\nField Status:") + for name, field in result.fields.items(): + status_icon = {'complete': '✅', 'partial': '⚠️', 'missing': '❌'}.get(field.status, '?') + print(f" {status_icon} {name}: {field.status} ({field.score:.1f}/{field.max_score:.1f})") + + print(f"\nRecommendations ({len(result.recommendations)}):") + for rec in result.recommendations[:5]: + source = rec.get('source', 'static') + source_label = '[AI]' if source == 'ai' else '[STATIC]' + print(f"\n {source_label} [{rec['priority'].upper()}] {rec['field']}:") + print(f" {rec['recommendation']}") + + # Print AI-specific fields if present + if rec.get('action_steps'): + print(" Action steps:") + for step in rec['action_steps']: + print(f" • {step}") + + if rec.get('expected_impact'): + print(f" Expected impact: {rec['expected_impact']}") + else: + print("No active companies found") + + print("\n" + "-" * 50) + print("Usage: python gbp_audit_service.py [--ai]") + print(" --ai Generate AI-powered recommendations using Gemini") + + finally: + db.close() diff --git a/templates/company_detail.html b/templates/company_detail.html index fe5cadf..b1edcbe 100755 --- a/templates/company_detail.html +++ b/templates/company_detail.html @@ -281,6 +281,10 @@ .contact-bar-item.social-tiktok { color: #000000; border-color: #000000; } .contact-bar-item.social-tiktok:hover { background: #000000; color: white; } + /* GBP Audit link - styled as action button */ + .contact-bar-item.gbp-audit { color: #4285f4; border-color: #4285f4; background: rgba(66, 133, 244, 0.05); } + .contact-bar-item.gbp-audit:hover { background: #4285f4; color: white; } + @media (max-width: 768px) { .contact-bar { justify-content: center; @@ -483,6 +487,18 @@ TikTok {% endif %} + + {# GBP Audit link - visible to admins (all profiles) or regular users (own company only) #} + {% if current_user.is_authenticated %} + {% if current_user.is_admin or (current_user.company_id and current_user.company_id == company.id) %} + + + + + Audyt GBP + + {% endif %} + {% endif %} diff --git a/templates/gbp_audit.html b/templates/gbp_audit.html new file mode 100644 index 0000000..ac0813e --- /dev/null +++ b/templates/gbp_audit.html @@ -0,0 +1,908 @@ +{% extends "base.html" %} + +{% block title %}Audyt Google Business Profile - {{ company.name }} - Norda Biznes Hub{% endblock %} + +{% block extra_css %} + +{% endblock %} + +{% block content %} + + + +
+
+

Audyt Google Business Profile

+

{{ company.name }}

+
+ + + + Analiza kompletnosci wizytowki Google dla lokalnego SEO +
+
+
+ + + + + Profil firmy + + {% if can_audit %} + + {% endif %} +
+
+ +{% if audit %} + +
+
+ {{ audit.completeness_score }} + / 100 +
+
+
+ {% if audit.completeness_score >= 90 %} + Doskonaly profil + {% elif audit.completeness_score >= 70 %} + Dobry profil + {% elif audit.completeness_score >= 50 %} + Sredni profil + {% else %} + Profil wymaga pracy + {% endif %} +
+

+ {% if audit.completeness_score >= 90 %} + Twoja wizytowka Google jest bardzo dobrze zoptymalizowana. Utrzymaj wysoki standard i monitoruj opinie klientow. + {% elif audit.completeness_score >= 70 %} + Profil jest w dobrym stanie, ale sa obszary do poprawy. Skupienie sie na rekomendacjach zwiekszy widocznosc. + {% elif audit.completeness_score >= 50 %} + Wizytowka wymaga uzupelnienia. Wdrozenie ponizszych rekomendacji znaczaco poprawi lokalne SEO. + {% else %} + Wizytowka jest niekompletna i traci potencjalnych klientow. Priorytetowo uzupelnij brakujace informacje. + {% endif %} +

+
+
+ + + + Ostatni audyt: {{ audit.audit_date.strftime('%d.%m.%Y %H:%M') if audit.audit_date else 'Brak danych' }} +
+ {% if audit.review_count %} +
+ + + + {{ audit.review_count }} opinii{% if audit.average_rating %} ({{ audit.average_rating }}/5){% endif %} +
+ {% endif %} +
+
+
+ + +
+

+ + + + Status pol wizytowki +

+ +
+
+
+ Kompletne +
+
+
+ Czesciowe +
+
+
+ Brakujace +
+
+ +
+ {% set field_icons = { + 'name': '', + 'address': '', + 'phone': '', + 'website': '', + 'hours': '', + 'categories': '', + 'photos': '', + 'description': '', + 'services': '', + 'reviews': '' + } %} + {% set field_names_pl = { + 'name': 'Nazwa firmy', + 'address': 'Adres', + 'phone': 'Telefon', + 'website': 'Strona WWW', + 'hours': 'Godziny otwarcia', + 'categories': 'Kategorie', + 'photos': 'Zdjecia', + 'description': 'Opis', + 'services': 'Uslugi', + 'reviews': 'Opinie' + } %} + {% set status_names = { + 'complete': 'Kompletne', + 'partial': 'Czesciowe', + 'missing': 'Brakuje' + } %} + + {% for field_name, field_data in audit.fields_status.items() %} +
+
+ + + {{ field_icons.get(field_name, '')|safe }} + + {{ field_names_pl.get(field_name, field_name) }} + + + {{ status_names.get(field_data.status, field_data.status) }} + +
+ {% if field_data.value %} +
{{ field_data.value }}
+ {% endif %} +
+
+
+
+ {{ field_data.score|round(1) }}/{{ field_data.max_score }} +
+
+ {% endfor %} +
+
+ + +{% if audit.recommendations %} +
+

+ + + + Rekomendacje ({{ audit.recommendations|length }}) +

+ +
+ {% for rec in audit.recommendations %} +
+
+ {% if rec.priority == 'high' %} + + + + {% elif rec.priority == 'medium' %} + + + + {% else %} + + + + {% endif %} +
+
+
{{ field_names_pl.get(rec.field, rec.field) }}
+
{{ rec.recommendation }}
+
+
+ + + + +{{ rec.impact }} pkt +
+
+ {% endfor %} +
+
+{% endif %} + +{% else %} + +
+ + + +

Brak danych audytu

+

Nie przeprowadzono jeszcze audytu wizytowki Google dla tej firmy. Uruchom audyt, aby sprawdzic kompletnosc profilu.

+ {% if can_audit %} + + {% endif %} +
+{% endif %} + + +
+
+
Trwa analiza wizytowki Google...
+
+ + + +{% endblock %} + +{% block extra_js %} +const csrfToken = '{{ csrf_token() }}'; +const companySlug = '{{ company.slug }}'; + +function showLoading() { + document.getElementById('loadingOverlay').classList.add('active'); +} + +function hideLoading() { + document.getElementById('loadingOverlay').classList.remove('active'); +} + +function showInfoModal(title, body, isSuccess) { + document.getElementById('modalTitle').textContent = title; + document.getElementById('modalBody').textContent = body; + const icon = document.getElementById('modalIcon'); + icon.className = 'modal-icon ' + (isSuccess ? 'success' : 'info'); + document.getElementById('infoModal').classList.add('active'); +} + +function closeInfoModal() { + document.getElementById('infoModal').classList.remove('active'); +} + +document.getElementById('infoModal')?.addEventListener('click', (e) => { + if (e.target.id === 'infoModal') closeInfoModal(); +}); + +async function runAudit() { + const btn = document.getElementById('runAuditBtn'); + if (btn) { + btn.disabled = true; + } + showLoading(); + + try { + const response = await fetch('/api/gbp/audit', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'X-CSRFToken': csrfToken + }, + body: JSON.stringify({ slug: companySlug }) + }); + + const data = await response.json(); + hideLoading(); + + if (response.ok && data.success) { + showInfoModal('Audyt zakonczone', 'Audyt wizytowki Google zostal zakonczony pomyslnie. Strona zostanie odswiezona.', true); + setTimeout(() => location.reload(), 1500); + } else { + showInfoModal('Blad', data.error || 'Wystapil nieznany blad podczas audytu.', false); + if (btn) btn.disabled = false; + } + } catch (error) { + hideLoading(); + showInfoModal('Blad polaczenia', 'Nie udalo sie polaczyc z serwerem: ' + error.message, false); + if (btn) btn.disabled = false; + } +} +{% endblock %}