This commit is contained in:
oliver
2026-08-28 10:11:11 -03:00
parent f533ff8e36
commit 585951c8ce
1380 changed files with 1540268 additions and 0 deletions
+8
View File
@@ -0,0 +1,8 @@
"""
Odoo Directory CLI
A Python CLI tool that generates static XML/HTML pages for Odoo partners,
integrating with the existing odoo-expertos.com static website.
"""
__version__ = "0.1.0"
+8
View File
@@ -0,0 +1,8 @@
"""
Allow running the package as a module: python -m directory
"""
from .cli import main
if __name__ == "__main__":
main()
+225
View File
@@ -0,0 +1,225 @@
"""
Odoo Directory CLI - Main entry point.
A Python CLI tool that generates static XML/HTML pages for Odoo partners,
integrating with the existing odoo-expertos.com static website.
Usage:
python -m directory.cli setup
python -m directory.cli plan --country DE --cities berlin muenchen
python -m directory.cli execute --plan <plan-id>
python -m directory.cli export --country DE
python -m directory.cli deploy
"""
import logging
from typing import List, Optional
import typer
from rich.console import Console
from .config import get_settings
from .utils.logger import setup_logging
from .commands.setup import setup_command, print_schema
from .commands.plan import plan_command, list_plans, show_plan
from .commands.execute import execute_command
from .commands.export import export_command
from .commands.deploy import deploy_command, status_command
# Create CLI app
app = typer.Typer(
name="odoo-directory",
help="Generate static pages for Odoo partner directory.",
add_completion=False,
)
console = Console()
# ==================== Setup Commands ====================
@app.command()
def setup(
seed_tags: bool = typer.Option(True, help="Seed default tags"),
seed_germany: bool = typer.Option(True, help="Seed Germany cities"),
schema: bool = typer.Option(False, "--schema", help="Print SQL schema only"),
):
"""
Initialize database with schema and seed data.
Run this once when setting up a new installation.
"""
setup_logging()
if schema:
print_schema()
return
setup_command(seed_tags=seed_tags, seed_germany=seed_germany)
# ==================== Plan Commands ====================
@app.command()
def plan(
country: str = typer.Option(..., "--country", "-c", help="Country code (e.g., DE)"),
cities: Optional[List[str]] = typer.Option(None, "--cities", help="City slugs to include"),
name: Optional[str] = typer.Option(None, "--name", "-n", help="Plan name"),
):
"""
Create an execution plan for a country.
Specify which cities to process with --cities or process all cities
in the country by default.
"""
setup_logging()
plan_command(country=country, cities=cities, name=name)
@app.command("plans")
def plans_list():
"""List all execution plans."""
setup_logging()
list_plans()
@app.command("plan-show")
def plan_show(
plan_id: str = typer.Argument(..., help="Plan UUID"),
):
"""Show details of a specific plan."""
setup_logging()
show_plan(plan_id)
# ==================== Execute Commands ====================
@app.command()
def execute(
plan_id: str = typer.Option(..., "--plan", "-p", help="Plan UUID"),
step: Optional[str] = typer.Option(None, "--step", "-s", help="Specific step: scrape, enrich, content"),
city: Optional[str] = typer.Option(None, "--city", help="Specific city slug"),
dry_run: bool = typer.Option(False, "--dry-run", help="Don't make actual API calls"),
):
"""
Execute a plan or specific step.
Runs the pipeline: scrape -> enrich -> content generation.
"""
setup_logging()
execute_command(plan_id=plan_id, step=step, city=city, dry_run=dry_run)
# ==================== Export Commands ====================
@app.command()
def export(
country: str = typer.Option(..., "--country", "-c", help="Country code"),
city: Optional[str] = typer.Option(None, "--city", help="Specific city slug"),
output: Optional[str] = typer.Option(None, "--output", "-o", help="Output directory"),
):
"""
Export static files for a country.
Generates HTML pages, sitemaps, and tag JSON files.
"""
setup_logging()
export_command(country=country, city=city, output_dir=output)
# ==================== Deploy Commands ====================
@app.command()
def deploy(
message: Optional[str] = typer.Option(None, "--message", "-m", help="Commit message"),
no_push: bool = typer.Option(False, "--no-push", help="Don't push to remote"),
):
"""
Deploy changes via Git.
Commits exported files and pushes to trigger Vercel deployment.
"""
setup_logging()
deploy_command(message=message, push=not no_push)
@app.command()
def status():
"""Show git status of the project."""
setup_logging()
status_command()
# ==================== Utility Commands ====================
@app.command()
def info():
"""Show configuration and environment info."""
setup_logging()
console.print("\n[bold blue]Odoo Directory CLI[/bold blue]\n")
try:
settings = get_settings()
console.print("[bold]Configuration:[/bold]")
console.print(f" Export directory: {settings.export_dir}")
console.print(f" Images directory: {settings.images_dir}")
console.print(f" Templates directory: {settings.templates_dir}")
console.print(f" Log level: {settings.log_level}")
console.print(f" Batch size: {settings.batch_size}")
console.print("\n[bold]API Status:[/bold]")
console.print(f" Supabase URL: {settings.supabase_url[:30]}...")
console.print(f" Supabase Key: {'*' * 10}... (configured)")
console.print(f" Apify Token: {'*' * 10}... (configured)")
console.print(f" Firecrawl Key: {'*' * 10}... (configured)")
console.print(f" Anthropic Key: {'*' * 10}... (configured)")
except Exception as e:
console.print(f"\n[red]Configuration error: {e}[/red]")
console.print("\nMake sure you have a .env file with all required variables.")
console.print("Copy .env.example to .env and fill in your API keys.")
@app.command()
def test_connection():
"""Test connections to all external services."""
setup_logging()
console.print("\n[bold blue]Testing Connections[/bold blue]\n")
# Test Supabase
console.print("Testing Supabase...")
try:
from .services.supabase import SupabaseService
db = SupabaseService()
regions = db.list_regions()
console.print(f" [green]Connected! Found {len(regions)} regions.[/green]")
except Exception as e:
console.print(f" [red]Failed: {e}[/red]")
# Test Anthropic
console.print("Testing Anthropic...")
try:
from anthropic import Anthropic
settings = get_settings()
client = Anthropic(api_key=settings.anthropic_api_key)
# Just verify the key format
console.print(" [green]API key configured.[/green]")
except Exception as e:
console.print(f" [red]Failed: {e}[/red]")
# Note about other services
console.print("\nNote: Apify and Firecrawl are tested during actual execution.")
# ==================== Main Entry ====================
def main():
"""Main entry point for the CLI."""
app()
if __name__ == "__main__":
main()
+15
View File
@@ -0,0 +1,15 @@
"""CLI commands for Odoo Directory."""
from .setup import setup_command
from .plan import plan_command
from .execute import execute_command
from .export import export_command
from .deploy import deploy_command
__all__ = [
"setup_command",
"plan_command",
"execute_command",
"export_command",
"deploy_command",
]
+148
View File
@@ -0,0 +1,148 @@
"""
Deploy command for pushing changes to Git.
"""
import logging
import subprocess
from pathlib import Path
from datetime import datetime
from typing import Optional
import typer
from rich.console import Console
from ..config import get_settings
logger = logging.getLogger(__name__)
console = Console()
def deploy_command(
message: Optional[str] = None,
push: bool = True,
) -> None:
"""
Deploy exported files via Git.
Adds all changes in the export directory, commits them,
and optionally pushes to the remote repository.
Args:
message: Custom commit message (auto-generated if not provided)
push: Whether to push to remote after commit
"""
console.print("\n[bold blue]Deploying changes[/bold blue]\n")
settings = get_settings()
project_root = Path(settings.export_dir).parent
# Verify we're in a git repo
if not (project_root / ".git").exists():
console.print("[red]Not a git repository![/red]")
console.print(f"Expected .git directory in {project_root}")
raise typer.Exit(1)
try:
# Check for changes
result = subprocess.run(
["git", "status", "--porcelain"],
cwd=project_root,
capture_output=True,
text=True,
check=True,
)
if not result.stdout.strip():
console.print("[yellow]No changes to deploy.[/yellow]")
return
# Show changes
console.print("[bold]Changes to deploy:[/bold]")
console.print(result.stdout)
# Confirm deployment
if not typer.confirm("Deploy these changes?"):
console.print("[yellow]Deployment cancelled.[/yellow]")
raise typer.Exit(0)
# Generate commit message if not provided
if not message:
timestamp = datetime.now().strftime("%Y-%m-%d %H:%M")
message = f"Directory update: {timestamp}"
# Stage changes
console.print("Staging changes...")
subprocess.run(
["git", "add", "export/", "static/images/companies/"],
cwd=project_root,
check=True,
)
# Commit
console.print(f"Committing: {message}")
subprocess.run(
["git", "commit", "-m", message],
cwd=project_root,
check=True,
)
# Push if requested
if push:
console.print("Pushing to remote...")
subprocess.run(
["git", "push"],
cwd=project_root,
check=True,
)
console.print("[green]Changes pushed to remote.[/green]")
else:
console.print("[yellow]Changes committed locally but not pushed.[/yellow]")
console.print("\n[bold green]Deployment complete![/bold green]")
# Show deployment info
console.print("\nVercel will automatically deploy the changes.")
console.print("Check deployment status at: https://vercel.com/dashboard")
except subprocess.CalledProcessError as e:
console.print(f"[red]Git command failed: {e}[/red]")
if e.stderr:
console.print(f"Error: {e.stderr}")
raise typer.Exit(1)
except Exception as e:
console.print(f"[red]Deployment failed: {e}[/red]")
logger.exception("Deployment error")
raise typer.Exit(1)
def status_command() -> None:
"""Show git status of the project."""
settings = get_settings()
project_root = Path(settings.export_dir).parent
try:
# Git status
result = subprocess.run(
["git", "status"],
cwd=project_root,
capture_output=True,
text=True,
check=True,
)
console.print(result.stdout)
# Last commit
console.print("\n[bold]Last commit:[/bold]")
result = subprocess.run(
["git", "log", "-1", "--oneline"],
cwd=project_root,
capture_output=True,
text=True,
check=True,
)
console.print(result.stdout)
except subprocess.CalledProcessError as e:
console.print(f"[red]Git command failed: {e}[/red]")
raise typer.Exit(1)
+341
View File
@@ -0,0 +1,341 @@
"""
Execute command for running pipeline steps.
"""
import logging
from datetime import datetime
from typing import Optional, List
from uuid import UUID
import re
import typer
from rich.console import Console
from rich.progress import Progress, SpinnerColumn, TextColumn, BarColumn
from ..services.supabase import SupabaseService
from ..services.apify import ApifyService
from ..services.firecrawl import FirecrawlService
from ..services.haiku import HaikuService
from ..services.images import ImageService
from ..services.tags import TagAggregationService
from ..models.schemas import Company, StepStatus, TagCategory
logger = logging.getLogger(__name__)
console = Console()
def execute_command(
plan_id: str,
step: Optional[str] = None,
city: Optional[str] = None,
dry_run: bool = False,
) -> None:
"""
Execute an execution plan or specific step.
Runs the pipeline to scrape, enrich, and generate content for
Odoo partners.
Args:
plan_id: UUID of the execution plan
step: Specific step to run (scrape, enrich, content, export)
city: Specific city slug to process
dry_run: If True, don't make actual API calls
"""
console.print(f"\n[bold blue]Executing Plan: {plan_id}[/bold blue]\n")
db = SupabaseService()
try:
plan_uuid = UUID(plan_id)
except ValueError:
console.print(f"[red]Invalid plan ID: {plan_id}[/red]")
raise typer.Exit(1)
plan = db.get_execution_plan(plan_uuid)
if not plan:
console.print(f"[red]Plan not found: {plan_id}[/red]")
raise typer.Exit(1)
# Get country info
country = db.get_country_by_code(plan.country_code)
if not country:
console.print(f"[red]Country not found: {plan.country_code}[/red]")
raise typer.Exit(1)
# Filter cities if specified
city_slugs = plan.cities
if city:
if city not in city_slugs:
console.print(f"[red]City {city} not in plan[/red]")
raise typer.Exit(1)
city_slugs = [city]
# Determine steps to run
if step:
steps_to_run = [step]
else:
steps_to_run = ["scrape", "enrich", "content"]
console.print(f"Country: {country.name}")
console.print(f"Cities: {len(city_slugs)}")
console.print(f"Steps: {', '.join(steps_to_run)}")
console.print(f"Dry run: {dry_run}")
console.print()
# Update plan status
if not dry_run:
db.update_execution_plan(
plan_uuid, {"status": "running", "started_at": datetime.utcnow().isoformat()}
)
# Run each step
for step_name in steps_to_run:
console.print(f"\n[bold cyan]Running step: {step_name}[/bold cyan]\n")
for city_slug in city_slugs:
city_obj = db.get_city_by_slug(city_slug, country.id)
if not city_obj:
console.print(f"[yellow]City not found: {city_slug}[/yellow]")
continue
try:
if step_name == "scrape":
_run_scrape_step(db, city_obj, country, dry_run)
elif step_name == "enrich":
_run_enrich_step(db, city_obj, dry_run)
elif step_name == "content":
_run_content_step(db, city_obj, country, dry_run)
except Exception as e:
console.print(f"[red]Error in {step_name} for {city_slug}: {e}[/red]")
logger.exception(f"Step {step_name} failed for {city_slug}")
# Update plan status
if not dry_run:
db.update_execution_plan(
plan_uuid, {"status": "completed", "completed_at": datetime.utcnow().isoformat()}
)
console.print("\n[bold green]Execution complete![/bold green]")
def _run_scrape_step(db: SupabaseService, city, country, dry_run: bool) -> None:
"""Run the scrape step for a city."""
console.print(f"Scraping {city.name}...")
if dry_run:
console.print("[yellow]Dry run - skipping actual scrape[/yellow]")
return
apify = ApifyService()
# Scrape Google Maps
results = apify.scrape_city(
city_name=city.name,
language=country.language,
country=country.code,
)
# Deduplicate
results = apify.deduplicate_results(results)
console.print(f"Found {len(results)} unique companies")
# Save to database
saved = 0
for result in results:
# Check if already exists
if result.placeId:
existing = db.get_company_by_place_id(result.placeId)
if existing:
continue
# Create slug from name
slug = _create_slug(result.title)
# Extract location
lat = lng = None
if result.location:
lat = result.location.get("lat")
lng = result.location.get("lng")
company = Company(
city_id=city.id,
name=result.title,
slug=slug,
google_place_id=result.placeId,
address=result.address,
phone=result.phone,
website=result.website,
rating=result.rating,
review_count=result.reviewsCount,
latitude=lat,
longitude=lng,
status="scraped",
last_scraped_at=datetime.utcnow(),
)
db.create_company(company)
saved += 1
console.print(f"[green]Saved {saved} new companies[/green]")
def _run_enrich_step(db: SupabaseService, city, dry_run: bool) -> None:
"""Run the enrich step for companies in a city."""
console.print(f"Enriching companies in {city.name}...")
# Get companies that need enrichment
companies = db.list_companies(city_id=city.id, status="scraped", limit=100)
console.print(f"Found {len(companies)} companies to enrich")
if not companies or dry_run:
if dry_run:
console.print("[yellow]Dry run - skipping enrichment[/yellow]")
return
firecrawl = FirecrawlService()
with Progress(
SpinnerColumn(),
TextColumn("[progress.description]{task.description}"),
BarColumn(),
TextColumn("[progress.percentage]{task.percentage:>3.0f}%"),
console=console,
) as progress:
task = progress.add_task("Enriching...", total=len(companies))
for company in companies:
if not company.website:
progress.advance(task)
continue
try:
info = firecrawl.extract_company_info(company.website)
updates = {
"website_markdown": info.get("markdown"),
"logo_url": info.get("logo_url"),
"email": info.get("email") or company.email,
"status": "enriched",
"last_enriched_at": datetime.utcnow().isoformat(),
}
db.update_company(company.id, updates)
except Exception as e:
logger.warning(f"Failed to enrich {company.name}: {e}")
progress.advance(task)
console.print("[green]Enrichment complete[/green]")
def _run_content_step(db: SupabaseService, city, country, dry_run: bool) -> None:
"""Run the content generation step."""
console.print(f"Generating content for {city.name}...")
# Get companies that need content
companies = db.list_companies(city_id=city.id, status="enriched", limit=100)
console.print(f"Found {len(companies)} companies for content generation")
if not companies or dry_run:
if dry_run:
console.print("[yellow]Dry run - skipping content generation[/yellow]")
return
haiku = HaikuService()
tags_service = TagAggregationService(db)
with Progress(
SpinnerColumn(),
TextColumn("[progress.description]{task.description}"),
BarColumn(),
TextColumn("[progress.percentage]{task.percentage:>3.0f}%"),
console=console,
) as progress:
task = progress.add_task("Generating...", total=len(companies))
for company in companies:
try:
# Extract tags
tags_response = haiku.extract_tags(
company_name=company.name,
website_markdown=company.website_markdown,
address=company.address,
)
# Save tags
_save_company_tags(db, company.id, tags_response)
# Generate content
content = haiku.generate_company_content(
company_name=company.name,
city_name=city.name,
country_name=country.name,
website_markdown=company.website_markdown,
services=tags_response.services,
industries=tags_response.industries,
modules=tags_response.modules,
partner_level=tags_response.partner_level,
language=country.language,
)
updates = {
"description": content.description,
"content_html": content.content_html,
"meta_title": content.meta_title,
"meta_description": content.meta_description,
"status": "content_generated",
"last_content_at": datetime.utcnow().isoformat(),
}
db.update_company(company.id, updates)
except Exception as e:
logger.warning(f"Failed to generate content for {company.name}: {e}")
progress.advance(task)
# Aggregate tags for city
console.print("Aggregating city tags...")
tags_service.aggregate_city_tags(city.id)
console.print("[green]Content generation complete[/green]")
def _save_company_tags(db: SupabaseService, company_id: UUID, tags_response) -> None:
"""Save extracted tags to company."""
# Clear existing tags
db.remove_company_tags(company_id)
# Add service tags
for name in tags_response.services:
tag = db.get_or_create_tag(name, TagCategory.SERVICE)
db.add_company_tag(company_id, tag.id, tags_response.confidence)
# Add industry tags
for name in tags_response.industries:
tag = db.get_or_create_tag(name, TagCategory.INDUSTRY)
db.add_company_tag(company_id, tag.id, tags_response.confidence)
# Add module tags
for name in tags_response.modules:
tag = db.get_or_create_tag(name, TagCategory.MODULE)
db.add_company_tag(company_id, tag.id, tags_response.confidence)
# Add partner level if present
if tags_response.partner_level:
tag = db.get_or_create_tag(tags_response.partner_level, TagCategory.PARTNER_LEVEL)
db.add_company_tag(company_id, tag.id, tags_response.confidence)
def _create_slug(name: str) -> str:
"""Create URL-friendly slug from company name."""
# Convert to lowercase
slug = name.lower()
# Replace spaces and special chars with hyphens
slug = re.sub(r"[^a-z0-9]+", "-", slug)
# Remove leading/trailing hyphens
slug = slug.strip("-")
# Limit length
return slug[:100]
+511
View File
@@ -0,0 +1,511 @@
"""
Export command for generating static files.
"""
import json
import logging
from pathlib import Path
from typing import Optional, List, Dict, Any
from uuid import UUID
import typer
from rich.console import Console
from rich.progress import Progress, SpinnerColumn, TextColumn, BarColumn
from jinja2 import Environment, FileSystemLoader
from ..services.supabase import SupabaseService
from ..services.tags import TagAggregationService
from ..services.images import ImageService
from ..config import get_settings
logger = logging.getLogger(__name__)
console = Console()
def export_command(
country: str,
city: Optional[str] = None,
output_dir: Optional[str] = None,
) -> None:
"""
Export static XML/HTML files for a country or city.
Generates company pages, city hubs, country hubs, and sitemaps
from the database content.
Args:
country: Country code (e.g., DE)
city: Optional specific city slug
output_dir: Optional output directory override
"""
console.print(f"\n[bold blue]Exporting files for {country}[/bold blue]\n")
settings = get_settings()
db = SupabaseService()
tags_service = TagAggregationService(db)
image_service = ImageService()
# Setup output directory
if output_dir:
export_path = Path(output_dir)
else:
export_path = settings.export_dir
export_path.mkdir(parents=True, exist_ok=True)
# Get country info
country_obj = db.get_country_by_code(country.upper())
if not country_obj:
console.print(f"[red]Country not found: {country}[/red]")
raise typer.Exit(1)
# Setup Jinja environment
jinja_env = _setup_jinja_environment(settings.templates_dir)
# Get cities to export
all_cities = db.list_cities(country_id=country_obj.id)
if city:
cities_to_export = [c for c in all_cities if c.slug == city]
if not cities_to_export:
console.print(f"[red]City not found: {city}[/red]")
raise typer.Exit(1)
else:
cities_to_export = all_cities
console.print(f"Exporting {len(cities_to_export)} cities...")
# Export each city
total_companies = 0
with Progress(
SpinnerColumn(),
TextColumn("[progress.description]{task.description}"),
BarColumn(),
TextColumn("[progress.percentage]{task.percentage:>3.0f}%"),
console=console,
) as progress:
task = progress.add_task("Exporting...", total=len(cities_to_export))
for city_obj in cities_to_export:
city_companies = _export_city(
db=db,
tags_service=tags_service,
image_service=image_service,
jinja_env=jinja_env,
country=country_obj,
city=city_obj,
export_path=export_path,
)
total_companies += city_companies
progress.advance(task)
# Export country hub
_export_country_hub(
db=db,
tags_service=tags_service,
jinja_env=jinja_env,
country=country_obj,
cities=cities_to_export,
export_path=export_path,
)
# Export sitemaps
_export_sitemaps(
db=db,
country=country_obj,
cities=cities_to_export,
export_path=export_path,
)
# Export tag JSON files for frontend filtering
_export_tag_json(
db=db,
tags_service=tags_service,
country=country_obj,
cities=cities_to_export,
export_path=export_path,
)
console.print(f"\n[bold green]Export complete![/bold green]")
console.print(f" - Companies: {total_companies}")
console.print(f" - Cities: {len(cities_to_export)}")
console.print(f" - Output: {export_path}")
def _setup_jinja_environment(templates_dir: Path) -> Environment:
"""Setup Jinja2 templating environment."""
# Create templates directory if it doesn't exist
templates_dir.mkdir(parents=True, exist_ok=True)
# Create default templates if they don't exist
_ensure_templates_exist(templates_dir)
return Environment(
loader=FileSystemLoader(str(templates_dir)),
autoescape=True,
)
def _ensure_templates_exist(templates_dir: Path) -> None:
"""Create default templates if they don't exist."""
# Company template
company_template = templates_dir / "company.xml"
if not company_template.exists():
company_template.write_text(_DEFAULT_COMPANY_TEMPLATE)
# City hub template
city_template = templates_dir / "city_hub.xml"
if not city_template.exists():
city_template.write_text(_DEFAULT_CITY_TEMPLATE)
# Country hub template
country_template = templates_dir / "country_hub.xml"
if not country_template.exists():
country_template.write_text(_DEFAULT_COUNTRY_TEMPLATE)
def _export_city(
db: SupabaseService,
tags_service: TagAggregationService,
image_service: ImageService,
jinja_env: Environment,
country,
city,
export_path: Path,
) -> int:
"""Export all files for a city."""
# Create city directory
city_path = export_path / country.slug / city.slug
city_path.mkdir(parents=True, exist_ok=True)
# Get companies
companies = db.list_companies(
city_id=city.id,
status="content_generated",
limit=500,
)
# Export each company
company_template = jinja_env.get_template("company.xml")
for company in companies:
company_tags = db.get_company_tags(company.id)
context = {
"company": company,
"city": city,
"country": country,
"tags": company_tags,
"services": [t for t in company_tags if hasattr(t, "category") and t.category == "service"],
"industries": [t for t in company_tags if hasattr(t, "category") and t.category == "industry"],
"modules": [t for t in company_tags if hasattr(t, "category") and t.category == "module"],
}
content = company_template.render(**context)
company_file = city_path / f"{company.slug}.html"
company_file.write_text(content, encoding="utf-8")
# Export city hub
city_tags = tags_service.get_city_tags_by_category(city.id)
hub_template = jinja_env.get_template("city_hub.xml")
hub_context = {
"city": city,
"country": country,
"companies": companies,
"company_count": len(companies),
"tags": city_tags,
}
hub_content = hub_template.render(**hub_context)
hub_file = city_path / "index.html"
hub_file.write_text(hub_content, encoding="utf-8")
return len(companies)
def _export_country_hub(
db: SupabaseService,
tags_service: TagAggregationService,
jinja_env: Environment,
country,
cities: List,
export_path: Path,
) -> None:
"""Export country hub page."""
country_path = export_path / country.slug
country_path.mkdir(parents=True, exist_ok=True)
# Get aggregate data
total_companies = 0
city_data = []
for city in cities:
count = db.count_companies(city_id=city.id, status="content_generated")
total_companies += count
city_data.append({
"city": city,
"company_count": count,
})
# Sort cities by company count
city_data.sort(key=lambda x: x["company_count"], reverse=True)
country_tags = tags_service.get_country_tags_by_category(country.id)
# Render template
template = jinja_env.get_template("country_hub.xml")
content = template.render(
country=country,
cities=city_data,
city_count=len(cities),
company_count=total_companies,
tags=country_tags,
)
hub_file = country_path / "index.html"
hub_file.write_text(content, encoding="utf-8")
def _export_sitemaps(
db: SupabaseService,
country,
cities: List,
export_path: Path,
) -> None:
"""Export XML sitemaps."""
sitemap_path = export_path / "sitemaps"
sitemap_path.mkdir(parents=True, exist_ok=True)
base_url = "https://odoo-expertos.com"
# Company sitemap
urls = []
for city in cities:
companies = db.list_companies(
city_id=city.id,
status="content_generated",
limit=500,
)
for company in companies:
urls.append(
f"{base_url}/{country.slug}/{city.slug}/{company.slug}.html"
)
sitemap_xml = _generate_sitemap_xml(urls)
sitemap_file = sitemap_path / f"sitemap-companies-{country.code.lower()}.xml"
sitemap_file.write_text(sitemap_xml, encoding="utf-8")
# City sitemap
city_urls = [
f"{base_url}/{country.slug}/{city.slug}/"
for city in cities
]
city_sitemap = _generate_sitemap_xml(city_urls)
city_sitemap_file = sitemap_path / f"sitemap-cities-{country.code.lower()}.xml"
city_sitemap_file.write_text(city_sitemap, encoding="utf-8")
def _generate_sitemap_xml(urls: List[str]) -> str:
"""Generate sitemap XML content."""
lines = ['<?xml version="1.0" encoding="UTF-8"?>']
lines.append('<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">')
for url in urls:
lines.append(" <url>")
lines.append(f" <loc>{url}</loc>")
lines.append(" <changefreq>monthly</changefreq>")
lines.append(" <priority>0.7</priority>")
lines.append(" </url>")
lines.append("</urlset>")
return "\n".join(lines)
def _export_tag_json(
db: SupabaseService,
tags_service: TagAggregationService,
country,
cities: List,
export_path: Path,
) -> None:
"""Export tag aggregations as JSON for frontend filtering."""
json_path = export_path / "tags"
json_path.mkdir(parents=True, exist_ok=True)
# City tags
cities_path = json_path / "cities"
cities_path.mkdir(parents=True, exist_ok=True)
for city in cities:
city_json = tags_service.export_city_tags_json(city.id)
json_file = cities_path / f"{city.slug}.json"
json_file.write_text(json.dumps(city_json, indent=2), encoding="utf-8")
# Country tags
country_json = tags_service.export_country_tags_json(country.id)
country_file = json_path / f"{country.slug}.json"
country_file.write_text(json.dumps(country_json, indent=2), encoding="utf-8")
# Default templates
_DEFAULT_COMPANY_TEMPLATE = """<!DOCTYPE html>
<html lang="{{ country.language }}">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{{ company.meta_title or company.name }}</title>
<meta name="description" content="{{ company.meta_description or company.description }}">
<link rel="canonical" href="https://odoo-expertos.com/{{ country.slug }}/{{ city.slug }}/{{ company.slug }}.html">
</head>
<body>
<header>
<nav>
<a href="/{{ country.slug }}/">{{ country.name }}</a> &gt;
<a href="/{{ country.slug }}/{{ city.slug }}/">{{ city.name }}</a> &gt;
{{ company.name }}
</nav>
</header>
<main>
<article>
<h1>{{ company.name }}</h1>
{% if company.description %}
<p class="lead">{{ company.description }}</p>
{% endif %}
<div class="company-info">
{% if company.address %}
<p><strong>Address:</strong> {{ company.address }}</p>
{% endif %}
{% if company.phone %}
<p><strong>Phone:</strong> {{ company.phone }}</p>
{% endif %}
{% if company.website %}
<p><strong>Website:</strong> <a href="{{ company.website }}" rel="noopener">{{ company.website }}</a></p>
{% endif %}
{% if company.email %}
<p><strong>Email:</strong> {{ company.email }}</p>
{% endif %}
{% if company.rating %}
<p><strong>Rating:</strong> {{ company.rating }}/5 ({{ company.review_count }} reviews)</p>
{% endif %}
</div>
<div class="content">
{{ company.content_html | safe }}
</div>
</article>
</main>
<footer>
<p>&copy; Odoo Expertos - {{ city.name }}, {{ country.name }}</p>
</footer>
</body>
</html>
"""
_DEFAULT_CITY_TEMPLATE = """<!DOCTYPE html>
<html lang="{{ country.language }}">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Odoo Partners in {{ city.name }} | {{ country.name }}</title>
<meta name="description" content="Find {{ company_count }} certified Odoo partners in {{ city.name }}, {{ country.name }}. Compare services, expertise, and reviews.">
<link rel="canonical" href="https://odoo-expertos.com/{{ country.slug }}/{{ city.slug }}/">
</head>
<body>
<header>
<nav>
<a href="/{{ country.slug }}/">{{ country.name }}</a> &gt;
{{ city.name }}
</nav>
</header>
<main>
<h1>Odoo Partners in {{ city.name }}</h1>
<p>Discover {{ company_count }} certified Odoo partners in {{ city.name }}, {{ country.name }}.</p>
<section class="filters">
<h2>Filter by Services</h2>
<ul>
{% for tag in tags.services %}
<li>{{ tag.name }} ({{ tag.count }})</li>
{% endfor %}
</ul>
</section>
<section class="companies">
<h2>Odoo Partners</h2>
{% for company in companies %}
<article class="company-card">
<h3><a href="{{ company.slug }}.html">{{ company.name }}</a></h3>
{% if company.description %}
<p>{{ company.description }}</p>
{% endif %}
{% if company.rating %}
<p class="rating">{{ company.rating }}/5</p>
{% endif %}
</article>
{% endfor %}
</section>
</main>
<footer>
<p>&copy; Odoo Expertos - {{ city.name }}, {{ country.name }}</p>
</footer>
</body>
</html>
"""
_DEFAULT_COUNTRY_TEMPLATE = """<!DOCTYPE html>
<html lang="{{ country.language }}">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Odoo Partners in {{ country.name }}</title>
<meta name="description" content="Find {{ company_count }} certified Odoo partners in {{ city_count }} cities across {{ country.name }}.">
<link rel="canonical" href="https://odoo-expertos.com/{{ country.slug }}/">
</head>
<body>
<header>
<nav>
{{ country.name }}
</nav>
</header>
<main>
<h1>Odoo Partners in {{ country.name }}</h1>
<p>Discover {{ company_count }} certified Odoo partners in {{ city_count }} cities across {{ country.name }}.</p>
<section class="cities">
<h2>Cities</h2>
<ul>
{% for item in cities %}
<li>
<a href="{{ item.city.slug }}/">{{ item.city.name }}</a>
({{ item.company_count }} partners)
</li>
{% endfor %}
</ul>
</section>
<section class="filters">
<h2>Services</h2>
<ul>
{% for tag in tags.services %}
<li>{{ tag.name }} ({{ tag.company_count }} companies in {{ tag.city_count }} cities)</li>
{% endfor %}
</ul>
</section>
</main>
<footer>
<p>&copy; Odoo Expertos - {{ country.name }}</p>
</footer>
</body>
</html>
"""
+242
View File
@@ -0,0 +1,242 @@
"""
Plan command for creating execution plans.
"""
import logging
from typing import List, Optional
from uuid import UUID
import typer
from rich.console import Console
from rich.table import Table
from ..services.supabase import SupabaseService
from ..models.schemas import ExecutionPlan, PlanStep, StepStatus
from ..config import SEARCH_QUERIES
logger = logging.getLogger(__name__)
console = Console()
def plan_command(
country: str,
cities: Optional[List[str]] = None,
name: Optional[str] = None,
) -> Optional[UUID]:
"""
Create an execution plan for scraping Odoo partners.
This generates a plan that specifies which cities to scrape and
what steps to perform. The plan can then be executed with the
execute command.
Args:
country: Country code (e.g., DE for Germany)
cities: List of city slugs to include (default: all cities in country)
name: Optional name for the plan
Returns:
Plan UUID if successful
"""
console.print(f"\n[bold blue]Creating Execution Plan for {country}[/bold blue]\n")
db = SupabaseService()
# Verify country exists
country_obj = db.get_country_by_code(country.upper())
if not country_obj:
console.print(f"[red]Country not found: {country}[/red]")
console.print("Run 'setup' command first to seed country data.")
raise typer.Exit(1)
# Get cities
all_cities = db.list_cities(country_id=country_obj.id)
if cities:
# Filter to specified cities
city_slugs = [c.lower() for c in cities]
selected_cities = [c for c in all_cities if c.slug in city_slugs]
missing = set(city_slugs) - {c.slug for c in selected_cities}
if missing:
console.print(f"[yellow]Warning: Cities not found: {missing}[/yellow]")
else:
selected_cities = all_cities
if not selected_cities:
console.print("[red]No cities found for this country.[/red]")
raise typer.Exit(1)
# Generate plan name
if not name:
city_names = [c.slug for c in selected_cities[:3]]
name = f"{country.upper()}-{'-'.join(city_names)}"
if len(selected_cities) > 3:
name += f"-and-{len(selected_cities) - 3}-more"
# Estimate company counts based on queries
language = country_obj.language
queries_per_city = len(SEARCH_QUERIES.get(language, SEARCH_QUERIES["en"]))
estimated_companies = len(selected_cities) * queries_per_city * 15 # ~15 per query
# Show plan summary
table = Table(title="Execution Plan Summary")
table.add_column("Field", style="cyan")
table.add_column("Value", style="green")
table.add_row("Plan Name", name)
table.add_row("Country", f"{country_obj.name} ({country_obj.code})")
table.add_row("Language", country_obj.language)
table.add_row("Cities", str(len(selected_cities)))
table.add_row("Queries/City", str(queries_per_city))
table.add_row("Est. Companies", f"~{estimated_companies}")
console.print(table)
console.print()
# Show city list
console.print("[bold]Cities included:[/bold]")
for city in selected_cities:
console.print(f" - {city.name} ({city.slug})")
console.print()
# Confirm creation
if not typer.confirm("Create this plan?"):
console.print("[yellow]Plan creation cancelled.[/yellow]")
raise typer.Exit(0)
# Create the plan
plan = ExecutionPlan(
name=name,
country_code=country.upper(),
cities=[c.slug for c in selected_cities],
status=StepStatus.PENDING,
)
plan = db.create_execution_plan(plan)
console.print(f"\n[green]Plan created: {plan.id}[/green]")
# Create steps for each city
step_types = ["scrape", "enrich", "content", "export"]
for city in selected_cities:
for step_type in step_types:
step = PlanStep(
plan_id=plan.id,
step_name=step_type,
city_id=city.id,
status=StepStatus.PENDING,
)
db.create_plan_step(step)
console.print(
f"[green]Created {len(selected_cities) * len(step_types)} steps[/green]"
)
# Print next steps
console.print("\n[bold]Next steps:[/bold]")
console.print(f" 1. Run: python -m directory.cli execute --plan {plan.id}")
console.print(" 2. Or run step by step:")
console.print(f" python -m directory.cli execute --plan {plan.id} --step scrape")
console.print(
f" python -m directory.cli execute --plan {plan.id} --step enrich"
)
console.print(
f" python -m directory.cli execute --plan {plan.id} --step content"
)
console.print()
return plan.id
def list_plans() -> None:
"""List all execution plans."""
db = SupabaseService()
plans = db.list_execution_plans()
if not plans:
console.print("[yellow]No execution plans found.[/yellow]")
return
table = Table(title="Execution Plans")
table.add_column("ID", style="cyan", max_width=36)
table.add_column("Name", style="green")
table.add_column("Country", style="blue")
table.add_column("Cities", style="magenta")
table.add_column("Status", style="yellow")
table.add_column("Created", style="dim")
for plan in plans:
table.add_row(
str(plan.id),
plan.name,
plan.country_code,
str(len(plan.cities)),
plan.status.value if hasattr(plan.status, "value") else plan.status,
str(plan.created_at)[:16] if plan.created_at else "-",
)
console.print(table)
def show_plan(plan_id: str) -> None:
"""Show details of a specific plan."""
db = SupabaseService()
try:
plan_uuid = UUID(plan_id)
except ValueError:
console.print(f"[red]Invalid plan ID: {plan_id}[/red]")
raise typer.Exit(1)
plan = db.get_execution_plan(plan_uuid)
if not plan:
console.print(f"[red]Plan not found: {plan_id}[/red]")
raise typer.Exit(1)
# Plan info
console.print(f"\n[bold blue]Plan: {plan.name}[/bold blue]\n")
table = Table()
table.add_column("Field", style="cyan")
table.add_column("Value", style="green")
table.add_row("ID", str(plan.id))
table.add_row("Country", plan.country_code)
table.add_row("Cities", ", ".join(plan.cities))
table.add_row("Status", plan.status.value if hasattr(plan.status, "value") else plan.status)
table.add_row("Created", str(plan.created_at)[:19] if plan.created_at else "-")
table.add_row("Started", str(plan.started_at)[:19] if plan.started_at else "-")
table.add_row("Completed", str(plan.completed_at)[:19] if plan.completed_at else "-")
console.print(table)
# Steps
if plan.steps:
console.print("\n[bold]Steps:[/bold]")
steps_table = Table()
steps_table.add_column("Step", style="cyan")
steps_table.add_column("City", style="blue")
steps_table.add_column("Status", style="yellow")
steps_table.add_column("Progress", style="green")
for step in plan.steps:
city_slug = "-"
if step.city_id:
city = db.get_city_by_slug("", step.city_id)
# We don't have a get_city_by_id, so we'll just show the ID
city_slug = str(step.city_id)[:8]
progress = f"{step.items_processed}/{step.items_total}" if step.items_total else "-"
steps_table.add_row(
step.step_name,
city_slug,
step.status.value if hasattr(step.status, "value") else step.status,
progress,
)
console.print(steps_table)
+304
View File
@@ -0,0 +1,304 @@
"""
Setup command for initializing the database.
"""
import logging
from typing import Optional
import typer
from rich.console import Console
from rich.progress import Progress, SpinnerColumn, TextColumn
from ..services.supabase import SupabaseService
from ..models.schemas import Region, Country, City, Tag, TagCategory
from ..config import DEFAULT_TAGS, GERMANY_CITIES
logger = logging.getLogger(__name__)
console = Console()
# SQL schema for creating tables
SCHEMA_SQL = """
-- Enable UUID extension
CREATE EXTENSION IF NOT EXISTS "uuid-ossp";
-- Regions table
CREATE TABLE IF NOT EXISTS regions (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
name TEXT NOT NULL,
slug TEXT NOT NULL UNIQUE,
created_at TIMESTAMPTZ DEFAULT NOW()
);
-- Countries table
CREATE TABLE IF NOT EXISTS countries (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
region_id UUID REFERENCES regions(id),
name TEXT NOT NULL,
name_local TEXT,
code TEXT NOT NULL UNIQUE,
slug TEXT NOT NULL UNIQUE,
language TEXT NOT NULL,
created_at TIMESTAMPTZ DEFAULT NOW()
);
-- Cities table
CREATE TABLE IF NOT EXISTS cities (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
country_id UUID REFERENCES countries(id),
name TEXT NOT NULL,
name_local TEXT,
slug TEXT NOT NULL,
state TEXT,
population INTEGER,
latitude FLOAT,
longitude FLOAT,
created_at TIMESTAMPTZ DEFAULT NOW(),
UNIQUE(country_id, slug)
);
-- Tags table
CREATE TABLE IF NOT EXISTS tags (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
category TEXT NOT NULL,
name TEXT NOT NULL,
slug TEXT NOT NULL UNIQUE,
description TEXT,
created_at TIMESTAMPTZ DEFAULT NOW()
);
-- Companies table
CREATE TABLE IF NOT EXISTS companies (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
city_id UUID REFERENCES cities(id),
name TEXT NOT NULL,
slug TEXT NOT NULL,
google_place_id TEXT UNIQUE,
address TEXT,
phone TEXT,
website TEXT,
email TEXT,
rating FLOAT,
review_count INTEGER,
latitude FLOAT,
longitude FLOAT,
website_markdown TEXT,
logo_url TEXT,
description TEXT,
content_html TEXT,
meta_title TEXT,
meta_description TEXT,
status TEXT DEFAULT 'scraped',
last_scraped_at TIMESTAMPTZ,
last_enriched_at TIMESTAMPTZ,
last_content_at TIMESTAMPTZ,
created_at TIMESTAMPTZ DEFAULT NOW(),
updated_at TIMESTAMPTZ DEFAULT NOW()
);
-- Company tags junction table
CREATE TABLE IF NOT EXISTS company_tags (
company_id UUID REFERENCES companies(id) ON DELETE CASCADE,
tag_id UUID REFERENCES tags(id) ON DELETE CASCADE,
confidence FLOAT DEFAULT 1.0,
created_at TIMESTAMPTZ DEFAULT NOW(),
PRIMARY KEY (company_id, tag_id)
);
-- City tag aggregation
CREATE TABLE IF NOT EXISTS city_tags (
city_id UUID REFERENCES cities(id) ON DELETE CASCADE,
tag_id UUID REFERENCES tags(id) ON DELETE CASCADE,
company_count INTEGER DEFAULT 0,
PRIMARY KEY (city_id, tag_id)
);
-- Country tag aggregation
CREATE TABLE IF NOT EXISTS country_tags (
country_id UUID REFERENCES countries(id) ON DELETE CASCADE,
tag_id UUID REFERENCES tags(id) ON DELETE CASCADE,
company_count INTEGER DEFAULT 0,
city_count INTEGER DEFAULT 0,
PRIMARY KEY (country_id, tag_id)
);
-- Execution plans
CREATE TABLE IF NOT EXISTS execution_plans (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
name TEXT NOT NULL,
country_code TEXT NOT NULL,
cities TEXT[] NOT NULL,
status TEXT DEFAULT 'pending',
created_at TIMESTAMPTZ DEFAULT NOW(),
started_at TIMESTAMPTZ,
completed_at TIMESTAMPTZ
);
-- Plan steps
CREATE TABLE IF NOT EXISTS plan_steps (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
plan_id UUID REFERENCES execution_plans(id) ON DELETE CASCADE,
step_name TEXT NOT NULL,
city_id UUID REFERENCES cities(id),
status TEXT DEFAULT 'pending',
started_at TIMESTAMPTZ,
completed_at TIMESTAMPTZ,
error_message TEXT,
items_processed INTEGER DEFAULT 0,
items_total INTEGER DEFAULT 0
);
-- Error logs
CREATE TABLE IF NOT EXISTS error_logs (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
entity_type TEXT NOT NULL,
entity_id UUID,
operation TEXT NOT NULL,
error_message TEXT NOT NULL,
details JSONB DEFAULT '{}',
created_at TIMESTAMPTZ DEFAULT NOW()
);
-- Indexes for performance
CREATE INDEX IF NOT EXISTS idx_companies_city ON companies(city_id);
CREATE INDEX IF NOT EXISTS idx_companies_status ON companies(status);
CREATE INDEX IF NOT EXISTS idx_company_tags_company ON company_tags(company_id);
CREATE INDEX IF NOT EXISTS idx_company_tags_tag ON company_tags(tag_id);
CREATE INDEX IF NOT EXISTS idx_city_tags_city ON city_tags(city_id);
CREATE INDEX IF NOT EXISTS idx_country_tags_country ON country_tags(country_id);
CREATE INDEX IF NOT EXISTS idx_plan_steps_plan ON plan_steps(plan_id);
"""
def setup_command(
seed_tags: bool = True,
seed_germany: bool = True,
) -> None:
"""
Initialize the database with schema and seed data.
This command should be run once when setting up a new installation.
It creates all required tables and optionally seeds reference data.
Args:
seed_tags: Whether to seed default tags
seed_germany: Whether to seed Germany cities
"""
console.print("\n[bold blue]Odoo Directory Setup[/bold blue]\n")
db = SupabaseService()
with Progress(
SpinnerColumn(),
TextColumn("[progress.description]{task.description}"),
console=console,
) as progress:
# Note: Schema should be created in Supabase SQL Editor
# The CLI just verifies connectivity and seeds data
task = progress.add_task("Verifying database connection...", total=None)
try:
# Test connection by listing regions
regions = db.list_regions()
progress.update(task, description="[green]Database connected")
except Exception as e:
console.print(f"\n[red]Database connection failed: {e}[/red]")
console.print(
"\n[yellow]Please create tables using the SQL schema in Supabase SQL Editor.[/yellow]"
)
console.print("\nSchema SQL can be found in directory/commands/setup.py")
raise typer.Exit(1)
# Seed tags
if seed_tags:
task = progress.add_task("Seeding tags...", total=None)
tag_count = _seed_tags(db)
progress.update(task, description=f"[green]Seeded {tag_count} tags")
# Seed Germany
if seed_germany:
task = progress.add_task("Seeding Germany data...", total=None)
city_count = _seed_germany(db)
progress.update(task, description=f"[green]Seeded {city_count} cities")
console.print("\n[bold green]Setup complete![/bold green]\n")
def _seed_tags(db: SupabaseService) -> int:
"""Seed default tags into database."""
count = 0
# Map category names to enum values
category_map = {
"services": TagCategory.SERVICE,
"industries": TagCategory.INDUSTRY,
"modules": TagCategory.MODULE,
"partner_levels": TagCategory.PARTNER_LEVEL,
}
for category_name, tag_names in DEFAULT_TAGS.items():
category = category_map[category_name]
for name in tag_names:
slug = name.lower().replace(" ", "-").replace("&", "and")
existing = db.get_tag_by_slug(slug)
if not existing:
db.create_tag(
Tag(
category=category,
name=name,
slug=slug,
)
)
count += 1
return count
def _seed_germany(db: SupabaseService) -> int:
"""Seed Germany region, country, and cities."""
# Create Europe region if not exists
region = db.get_region_by_slug("europe")
if not region:
region = db.create_region(Region(name="Europe", slug="europe"))
# Create Germany if not exists
country = db.get_country_by_code("DE")
if not country:
country = db.create_country(
Country(
region_id=region.id,
name="Germany",
name_local="Deutschland",
code="DE",
slug="deutschland",
language="de",
)
)
# Create cities
count = 0
for city_data in GERMANY_CITIES:
existing = db.get_city_by_slug(city_data["slug"], country.id)
if not existing:
db.create_city(
City(
country_id=country.id,
name=city_data["name"],
slug=city_data["slug"],
state=city_data.get("state"),
)
)
count += 1
return count
def print_schema() -> None:
"""Print the SQL schema for manual creation."""
console.print("\n[bold]Database Schema SQL:[/bold]\n")
console.print(SCHEMA_SQL)
console.print(
"\n[yellow]Copy this SQL and run it in your Supabase SQL Editor.[/yellow]"
)
+161
View File
@@ -0,0 +1,161 @@
"""
Configuration management for Odoo Directory CLI.
Loads environment variables and provides typed configuration.
"""
import os
from pathlib import Path
from typing import Optional
from pydantic_settings import BaseSettings
from pydantic import Field
from dotenv import load_dotenv
# Load .env file from project root
PROJECT_ROOT = Path(__file__).parent.parent
load_dotenv(PROJECT_ROOT / ".env")
class Settings(BaseSettings):
"""Application settings loaded from environment variables."""
# Supabase
supabase_url: str = Field(..., alias="SUPABASE_URL")
supabase_key: str = Field(..., alias="SUPABASE_KEY")
# Apify
apify_token: str = Field(..., alias="APIFY_TOKEN")
# Firecrawl
firecrawl_api_key: str = Field(..., alias="FIRECRAWL_API_KEY")
# Anthropic
anthropic_api_key: str = Field(..., alias="ANTHROPIC_API_KEY")
# Application settings
log_level: str = Field(default="INFO", alias="LOG_LEVEL")
batch_size: int = Field(default=10, alias="BATCH_SIZE")
max_retries: int = Field(default=3, alias="MAX_RETRIES")
# Paths
export_dir: Path = Field(default=PROJECT_ROOT / "export")
images_dir: Path = Field(default=PROJECT_ROOT / "static" / "images" / "companies")
templates_dir: Path = Field(default=Path(__file__).parent / "templates")
class Config:
env_file = ".env"
env_file_encoding = "utf-8"
extra = "ignore"
def get_settings() -> Settings:
"""Get application settings singleton."""
return Settings()
# Apify actor IDs
APIFY_GOOGLE_MAPS_ACTOR = "nwua9Gu5YrADL7ZDj" # Google Maps Scraper
# Search query templates for different languages
SEARCH_QUERIES = {
"de": [
"Odoo Partner {city}",
"Odoo Implementierung {city}",
"Odoo Beratung {city}",
"Odoo ERP {city}",
"Odoo Entwicklung {city}",
],
"en": [
"Odoo Partner {city}",
"Odoo Implementation {city}",
"Odoo Consulting {city}",
"Odoo ERP {city}",
"Odoo Development {city}",
],
"es": [
"Odoo Partner {city}",
"Odoo Implementación {city}",
"Odoo Consultoría {city}",
"Odoo ERP {city}",
"Odoo Desarrollo {city}",
],
"fr": [
"Odoo Partenaire {city}",
"Odoo Implémentation {city}",
"Odoo Conseil {city}",
"Odoo ERP {city}",
"Odoo Développement {city}",
],
"pt": [
"Odoo Parceiro {city}",
"Odoo Implementação {city}",
"Odoo Consultoria {city}",
"Odoo ERP {city}",
"Odoo Desenvolvimento {city}",
],
"ar": [
"Odoo Partner {city}",
"Odoo شريك {city}",
"Odoo ERP {city}",
],
}
# Default tags for seeding
DEFAULT_TAGS = {
"services": [
"Implementation",
"Customization",
"Training",
"Support",
"Migration",
"Integration",
"Consulting",
"Hosting",
"Development",
],
"industries": [
"Manufacturing",
"Retail",
"E-commerce",
"Healthcare",
"Education",
"Finance",
"Logistics",
"Construction",
"Food & Beverage",
"Professional Services",
],
"modules": [
"Sales",
"CRM",
"Inventory",
"Accounting",
"Manufacturing",
"Website",
"E-commerce",
"HR",
"Project",
"Purchase",
"Point of Sale",
],
"partner_levels": [
"Ready Partner",
"Silver Partner",
"Gold Partner",
"Platinum Partner",
],
}
# Germany cities for initial rollout
GERMANY_CITIES = [
{"name": "Berlin", "slug": "berlin", "state": "Berlin"},
{"name": "München", "slug": "muenchen", "state": "Bayern"},
{"name": "Hamburg", "slug": "hamburg", "state": "Hamburg"},
{"name": "Köln", "slug": "koeln", "state": "Nordrhein-Westfalen"},
{"name": "Frankfurt am Main", "slug": "frankfurt", "state": "Hessen"},
{"name": "Stuttgart", "slug": "stuttgart", "state": "Baden-Württemberg"},
{"name": "Düsseldorf", "slug": "duesseldorf", "state": "Nordrhein-Westfalen"},
{"name": "Leipzig", "slug": "leipzig", "state": "Sachsen"},
{"name": "Dortmund", "slug": "dortmund", "state": "Nordrhein-Westfalen"},
{"name": "Essen", "slug": "essen", "state": "Nordrhein-Westfalen"},
]
+31
View File
@@ -0,0 +1,31 @@
"""Data models for Odoo Directory."""
from .schemas import (
Region,
Country,
City,
Tag,
Company,
CompanyTag,
CityTag,
CountryTag,
ExecutionPlan,
PlanStep,
StepStatus,
TagCategory,
)
__all__ = [
"Region",
"Country",
"City",
"Tag",
"Company",
"CompanyTag",
"CityTag",
"CountryTag",
"ExecutionPlan",
"PlanStep",
"StepStatus",
"TagCategory",
]
+244
View File
@@ -0,0 +1,244 @@
"""
Pydantic models for Odoo Directory data structures.
"""
from datetime import datetime
from enum import Enum
from typing import Optional, List, Dict, Any
from uuid import UUID
from pydantic import BaseModel, Field, HttpUrl
class TagCategory(str, Enum):
"""Tag category types."""
SERVICE = "service"
INDUSTRY = "industry"
MODULE = "module"
PARTNER_LEVEL = "partner_level"
class StepStatus(str, Enum):
"""Execution step status."""
PENDING = "pending"
RUNNING = "running"
COMPLETED = "completed"
FAILED = "failed"
SKIPPED = "skipped"
class Region(BaseModel):
"""Geographic region model."""
id: Optional[UUID] = None
name: str
slug: str
created_at: Optional[datetime] = None
class Config:
from_attributes = True
class Country(BaseModel):
"""Country model."""
id: Optional[UUID] = None
region_id: Optional[UUID] = None
name: str
name_local: Optional[str] = None
code: str # ISO 3166-1 alpha-2
slug: str
language: str # Primary language code
created_at: Optional[datetime] = None
class Config:
from_attributes = True
class City(BaseModel):
"""City model."""
id: Optional[UUID] = None
country_id: Optional[UUID] = None
name: str
name_local: Optional[str] = None
slug: str
state: Optional[str] = None
population: Optional[int] = None
latitude: Optional[float] = None
longitude: Optional[float] = None
created_at: Optional[datetime] = None
class Config:
from_attributes = True
class Tag(BaseModel):
"""Tag model for services, industries, modules, partner levels."""
id: Optional[UUID] = None
category: TagCategory
name: str
slug: str
description: Optional[str] = None
created_at: Optional[datetime] = None
class Config:
from_attributes = True
class Company(BaseModel):
"""Odoo partner company model."""
id: Optional[UUID] = None
city_id: Optional[UUID] = None
# Basic info (from Google Maps)
name: str
slug: str
google_place_id: Optional[str] = None
address: Optional[str] = None
phone: Optional[str] = None
website: Optional[str] = None
email: Optional[str] = None
# Google Maps data
rating: Optional[float] = None
review_count: Optional[int] = None
latitude: Optional[float] = None
longitude: Optional[float] = None
# Enriched data (from Firecrawl)
website_markdown: Optional[str] = None
logo_url: Optional[str] = None
# Generated content (from Haiku)
description: Optional[str] = None
content_html: Optional[str] = None
meta_title: Optional[str] = None
meta_description: Optional[str] = None
# Status tracking
status: str = "scraped" # scraped, enriched, content_generated, exported
last_scraped_at: Optional[datetime] = None
last_enriched_at: Optional[datetime] = None
last_content_at: Optional[datetime] = None
created_at: Optional[datetime] = None
updated_at: Optional[datetime] = None
class Config:
from_attributes = True
class CompanyTag(BaseModel):
"""Junction table for company-tag relationship."""
company_id: UUID
tag_id: UUID
confidence: Optional[float] = None # AI confidence score
created_at: Optional[datetime] = None
class Config:
from_attributes = True
class CityTag(BaseModel):
"""Aggregated tag counts per city."""
city_id: UUID
tag_id: UUID
company_count: int = 0
class Config:
from_attributes = True
class CountryTag(BaseModel):
"""Aggregated tag counts per country."""
country_id: UUID
tag_id: UUID
company_count: int = 0
city_count: int = 0
class Config:
from_attributes = True
class PlanStep(BaseModel):
"""Individual step in an execution plan."""
id: Optional[UUID] = None
plan_id: Optional[UUID] = None
step_name: str # scrape, enrich, content, export
city_id: Optional[UUID] = None
status: StepStatus = StepStatus.PENDING
started_at: Optional[datetime] = None
completed_at: Optional[datetime] = None
error_message: Optional[str] = None
items_processed: int = 0
items_total: int = 0
class Config:
from_attributes = True
class ExecutionPlan(BaseModel):
"""Execution plan for processing a country/region."""
id: Optional[UUID] = None
name: str
country_code: str
cities: List[str] # List of city slugs
status: StepStatus = StepStatus.PENDING
created_at: Optional[datetime] = None
started_at: Optional[datetime] = None
completed_at: Optional[datetime] = None
# Related steps
steps: List[PlanStep] = []
class Config:
from_attributes = True
# API Response models
class ApifyGoogleMapsResult(BaseModel):
"""Result from Apify Google Maps scraper."""
title: str
address: Optional[str] = None
phone: Optional[str] = None
website: Optional[str] = None
rating: Optional[float] = None
reviewsCount: Optional[int] = None
placeId: Optional[str] = None
url: Optional[str] = None
location: Optional[Dict[str, float]] = None
class Config:
extra = "ignore"
class FirecrawlResult(BaseModel):
"""Result from Firecrawl website scraper."""
markdown: Optional[str] = None
metadata: Optional[Dict[str, Any]] = None
images: Optional[List[str]] = None
class Config:
extra = "ignore"
class HaikuTagsResponse(BaseModel):
"""Response from Haiku tag extraction."""
services: List[str] = []
industries: List[str] = []
modules: List[str] = []
partner_level: Optional[str] = None
confidence: float = 0.0
class Config:
extra = "ignore"
class HaikuContentResponse(BaseModel):
"""Response from Haiku content generation."""
description: str
content_html: str
meta_title: str
meta_description: str
class Config:
extra = "ignore"
+17
View File
@@ -0,0 +1,17 @@
"""Service modules for external API integrations."""
from .supabase import SupabaseService
from .apify import ApifyService
from .firecrawl import FirecrawlService
from .haiku import HaikuService
from .images import ImageService
from .tags import TagAggregationService
__all__ = [
"SupabaseService",
"ApifyService",
"FirecrawlService",
"HaikuService",
"ImageService",
"TagAggregationService",
]
+224
View File
@@ -0,0 +1,224 @@
"""
Apify service for Google Maps scraping.
"""
import logging
from typing import List, Optional, Dict, Any
import time
import httpx
from ..config import get_settings, APIFY_GOOGLE_MAPS_ACTOR, SEARCH_QUERIES
from ..models.schemas import ApifyGoogleMapsResult
from ..utils.retry import retry_with_backoff
logger = logging.getLogger(__name__)
class ApifyService:
"""Service for interacting with Apify Google Maps scraper."""
BASE_URL = "https://api.apify.com/v2"
def __init__(self):
"""Initialize Apify client."""
settings = get_settings()
self.token = settings.apify_token
self.headers = {"Authorization": f"Bearer {self.token}"}
def _get_search_queries(self, city: str, language: str = "de") -> List[str]:
"""
Generate search queries for a city.
Args:
city: City name
language: Language code for queries
Returns:
List of search query strings
"""
templates = SEARCH_QUERIES.get(language, SEARCH_QUERIES["en"])
return [template.format(city=city) for template in templates]
@retry_with_backoff(max_attempts=3, exceptions=(httpx.HTTPError,))
def run_google_maps_scraper(
self,
search_queries: List[str],
max_results_per_query: int = 20,
language: str = "de",
country: str = "DE",
) -> str:
"""
Start a Google Maps scraper run.
Args:
search_queries: List of search queries
max_results_per_query: Max results per query
language: Language for results
country: Country code for search
Returns:
Run ID for tracking
"""
url = f"{self.BASE_URL}/acts/{APIFY_GOOGLE_MAPS_ACTOR}/runs"
payload = {
"searchStringsArray": search_queries,
"maxCrawledPlaces": max_results_per_query * len(search_queries),
"language": language,
"countryCode": country,
"maxReviews": 0, # Don't fetch reviews to save credits
"maxImages": 0, # Don't fetch images
"includeWebResults": False,
}
with httpx.Client(timeout=60.0) as client:
response = client.post(url, json=payload, headers=self.headers)
response.raise_for_status()
data = response.json()
run_id = data["data"]["id"]
logger.info(f"Started Apify run: {run_id}")
return run_id
def wait_for_run(
self, run_id: str, timeout: int = 300, poll_interval: int = 10
) -> str:
"""
Wait for an Apify run to complete.
Args:
run_id: Run ID to wait for
timeout: Maximum wait time in seconds
poll_interval: Time between status checks
Returns:
Final status of the run
"""
url = f"{self.BASE_URL}/actor-runs/{run_id}"
start_time = time.time()
while True:
if time.time() - start_time > timeout:
raise TimeoutError(f"Apify run {run_id} timed out after {timeout}s")
with httpx.Client(timeout=30.0) as client:
response = client.get(url, headers=self.headers)
response.raise_for_status()
data = response.json()
status = data["data"]["status"]
logger.debug(f"Run {run_id} status: {status}")
if status in ("SUCCEEDED", "FAILED", "ABORTED", "TIMED-OUT"):
return status
time.sleep(poll_interval)
def get_run_results(self, run_id: str) -> List[ApifyGoogleMapsResult]:
"""
Get results from a completed Apify run.
Args:
run_id: Run ID to get results for
Returns:
List of Google Maps results
"""
url = f"{self.BASE_URL}/actor-runs/{run_id}/dataset/items"
with httpx.Client(timeout=60.0) as client:
response = client.get(url, headers=self.headers)
response.raise_for_status()
data = response.json()
results = []
for item in data:
try:
result = ApifyGoogleMapsResult(
title=item.get("title", ""),
address=item.get("address"),
phone=item.get("phone"),
website=item.get("website"),
rating=item.get("totalScore"),
reviewsCount=item.get("reviewsCount"),
placeId=item.get("placeId"),
url=item.get("url"),
location=item.get("location"),
)
results.append(result)
except Exception as e:
logger.warning(f"Failed to parse result: {e}")
logger.info(f"Retrieved {len(results)} results from run {run_id}")
return results
def scrape_city(
self,
city_name: str,
language: str = "de",
country: str = "DE",
max_per_query: int = 20,
) -> List[ApifyGoogleMapsResult]:
"""
Scrape Google Maps for Odoo partners in a city.
This is the main high-level method that combines query generation,
running the scraper, and retrieving results.
Args:
city_name: Name of the city to scrape
language: Language code
country: Country code
max_per_query: Max results per search query
Returns:
List of Google Maps results
"""
queries = self._get_search_queries(city_name, language)
logger.info(f"Scraping {city_name} with {len(queries)} queries")
# Start the run
run_id = self.run_google_maps_scraper(
search_queries=queries,
max_results_per_query=max_per_query,
language=language,
country=country,
)
# Wait for completion
status = self.wait_for_run(run_id)
if status != "SUCCEEDED":
raise RuntimeError(f"Apify run failed with status: {status}")
# Get results
return self.get_run_results(run_id)
def deduplicate_results(
self, results: List[ApifyGoogleMapsResult]
) -> List[ApifyGoogleMapsResult]:
"""
Remove duplicate results based on place ID.
Args:
results: List of results to deduplicate
Returns:
Deduplicated list
"""
seen_place_ids = set()
unique_results = []
for result in results:
if result.placeId and result.placeId not in seen_place_ids:
seen_place_ids.add(result.placeId)
unique_results.append(result)
elif not result.placeId:
# Keep results without place ID but log warning
logger.warning(f"Result without place ID: {result.title}")
unique_results.append(result)
logger.info(
f"Deduplicated {len(results)} results to {len(unique_results)} unique"
)
return unique_results
+177
View File
@@ -0,0 +1,177 @@
"""
Firecrawl service for website scraping and enrichment.
"""
import logging
from typing import Optional, Dict, Any, List
import httpx
from ..config import get_settings
from ..models.schemas import FirecrawlResult
from ..utils.retry import retry_with_backoff
logger = logging.getLogger(__name__)
class FirecrawlService:
"""Service for scraping websites using Firecrawl."""
BASE_URL = "https://api.firecrawl.dev/v1"
def __init__(self):
"""Initialize Firecrawl client."""
settings = get_settings()
self.api_key = settings.firecrawl_api_key
self.headers = {
"Authorization": f"Bearer {self.api_key}",
"Content-Type": "application/json",
}
@retry_with_backoff(max_attempts=3, exceptions=(httpx.HTTPError,))
def scrape_url(
self,
url: str,
formats: Optional[List[str]] = None,
include_images: bool = True,
) -> FirecrawlResult:
"""
Scrape a single URL.
Args:
url: URL to scrape
formats: Output formats (default: markdown)
include_images: Whether to extract image URLs
Returns:
Scraped content
"""
if formats is None:
formats = ["markdown"]
endpoint = f"{self.BASE_URL}/scrape"
payload = {
"url": url,
"formats": formats,
}
with httpx.Client(timeout=60.0) as client:
response = client.post(endpoint, json=payload, headers=self.headers)
response.raise_for_status()
data = response.json()
if not data.get("success"):
raise RuntimeError(f"Firecrawl scrape failed: {data.get('error')}")
result_data = data.get("data", {})
# Extract images from metadata if available
images = []
if include_images:
metadata = result_data.get("metadata", {})
# Try to get og:image and other image sources
if metadata.get("ogImage"):
images.append(metadata["ogImage"])
if metadata.get("logo"):
images.append(metadata["logo"])
return FirecrawlResult(
markdown=result_data.get("markdown"),
metadata=result_data.get("metadata"),
images=images,
)
def extract_company_info(
self, url: str
) -> Dict[str, Any]:
"""
Extract structured company information from a website.
Args:
url: Company website URL
Returns:
Dictionary with extracted information
"""
try:
result = self.scrape_url(url, formats=["markdown"], include_images=True)
info = {
"markdown": result.markdown,
"images": result.images or [],
"logo_url": None,
"email": None,
"title": None,
"description": None,
}
# Extract metadata
if result.metadata:
info["title"] = result.metadata.get("title")
info["description"] = result.metadata.get("description")
if result.metadata.get("ogImage"):
info["logo_url"] = result.metadata["ogImage"]
# Try to find email in markdown content
if result.markdown:
import re
email_pattern = r"[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}"
emails = re.findall(email_pattern, result.markdown)
if emails:
# Filter out common non-company emails
company_emails = [
e for e in emails
if not any(
domain in e.lower()
for domain in ["example.com", "test.com", "email.com"]
)
]
if company_emails:
info["email"] = company_emails[0]
return info
except Exception as e:
logger.warning(f"Failed to scrape {url}: {e}")
return {
"markdown": None,
"images": [],
"logo_url": None,
"email": None,
"title": None,
"description": None,
"error": str(e),
}
def batch_scrape(
self, urls: List[str], batch_size: int = 5
) -> Dict[str, FirecrawlResult]:
"""
Scrape multiple URLs in batches.
Args:
urls: List of URLs to scrape
batch_size: Number of concurrent scrapes
Returns:
Dictionary mapping URLs to results
"""
results = {}
for i in range(0, len(urls), batch_size):
batch = urls[i : i + batch_size]
logger.info(f"Scraping batch {i // batch_size + 1} ({len(batch)} URLs)")
for url in batch:
try:
results[url] = self.scrape_url(url)
except Exception as e:
logger.error(f"Failed to scrape {url}: {e}")
results[url] = FirecrawlResult(
markdown=None,
metadata={"error": str(e)},
images=[],
)
return results
+359
View File
@@ -0,0 +1,359 @@
"""
Claude Haiku service for content generation.
"""
import json
import logging
from typing import Optional, List, Dict, Any
from anthropic import Anthropic
from ..config import get_settings
from ..models.schemas import HaikuTagsResponse, HaikuContentResponse, TagCategory
logger = logging.getLogger(__name__)
class HaikuService:
"""Service for AI content generation using Claude Haiku."""
def __init__(self):
"""Initialize Anthropic client."""
settings = get_settings()
self.client = Anthropic(api_key=settings.anthropic_api_key)
self.model = "claude-3-5-haiku-20241022"
def _call_haiku(
self,
system_prompt: str,
user_prompt: str,
max_tokens: int = 4096,
) -> str:
"""
Make a call to Claude Haiku.
Args:
system_prompt: System instructions
user_prompt: User message
max_tokens: Maximum response tokens
Returns:
Response text
"""
response = self.client.messages.create(
model=self.model,
max_tokens=max_tokens,
system=system_prompt,
messages=[{"role": "user", "content": user_prompt}],
)
return response.content[0].text
def extract_tags(
self,
company_name: str,
website_markdown: Optional[str],
address: Optional[str] = None,
) -> HaikuTagsResponse:
"""
Extract tags from company information.
Args:
company_name: Company name
website_markdown: Scraped website content
address: Company address
Returns:
Extracted tags with confidence
"""
system_prompt = """You are an expert at analyzing Odoo partner companies.
Extract relevant tags from the provided company information.
Respond with valid JSON only, no other text. Use this exact structure:
{
"services": ["list of services they offer"],
"industries": ["list of industries they serve"],
"modules": ["list of Odoo modules they specialize in"],
"partner_level": "Ready Partner|Silver Partner|Gold Partner|Platinum Partner or null",
"confidence": 0.0 to 1.0
}
Valid services: Implementation, Customization, Training, Support, Migration, Integration, Consulting, Hosting, Development
Valid industries: Manufacturing, Retail, E-commerce, Healthcare, Education, Finance, Logistics, Construction, Food & Beverage, Professional Services
Valid modules: Sales, CRM, Inventory, Accounting, Manufacturing, Website, E-commerce, HR, Project, Purchase, Point of Sale
Only include tags you can confidently identify from the content."""
user_prompt = f"""Company: {company_name}
Address: {address or "Not provided"}
Website content:
{website_markdown[:8000] if website_markdown else "No website content available"}
Extract tags for this Odoo partner company."""
try:
response = self._call_haiku(system_prompt, user_prompt, max_tokens=1024)
# Parse JSON response
data = json.loads(response)
return HaikuTagsResponse(**data)
except json.JSONDecodeError as e:
logger.error(f"Failed to parse tags response: {e}")
return HaikuTagsResponse(confidence=0.0)
except Exception as e:
logger.error(f"Tag extraction failed: {e}")
return HaikuTagsResponse(confidence=0.0)
def generate_company_content(
self,
company_name: str,
city_name: str,
country_name: str,
website_markdown: Optional[str],
services: List[str],
industries: List[str],
modules: List[str],
partner_level: Optional[str] = None,
language: str = "de",
) -> HaikuContentResponse:
"""
Generate SEO-optimized company page content.
Args:
company_name: Company name
city_name: City name
country_name: Country name
website_markdown: Scraped website content
services: List of service tags
industries: List of industry tags
modules: List of module tags
partner_level: Partner certification level
language: Output language code
Returns:
Generated content
"""
lang_instructions = {
"de": "Write all content in German (Deutsch).",
"en": "Write all content in English.",
"es": "Write all content in Spanish (Español).",
"fr": "Write all content in French (Français).",
"pt": "Write all content in Portuguese (Português).",
"ar": "Write all content in Arabic (العربية).",
}
system_prompt = f"""You are an expert SEO content writer for Odoo partner directories.
{lang_instructions.get(language, lang_instructions["en"])}
Create engaging, informative content about an Odoo partner company.
The content should be at least 800 words and SEO-optimized.
Respond with valid JSON only:
{{
"description": "2-3 sentence company description",
"content_html": "Full HTML content with headings, paragraphs, lists",
"meta_title": "SEO title (50-60 chars)",
"meta_description": "SEO meta description (150-160 chars)"
}}
Include:
- Company overview
- Services offered
- Industries served
- Odoo modules expertise
- Why choose this partner
- Location information
Use semantic HTML: <h2>, <h3>, <p>, <ul>, <li>, <strong>"""
tags_info = f"""
Services: {", ".join(services) if services else "Various Odoo services"}
Industries: {", ".join(industries) if industries else "Multiple industries"}
Modules: {", ".join(modules) if modules else "Various Odoo modules"}
Partner Level: {partner_level or "Odoo Partner"}"""
user_prompt = f"""Create content for:
Company: {company_name}
Location: {city_name}, {country_name}
{tags_info}
Website information:
{website_markdown[:6000] if website_markdown else "Limited information available"}
Generate comprehensive SEO content for this Odoo partner's directory page."""
try:
response = self._call_haiku(system_prompt, user_prompt, max_tokens=4096)
data = json.loads(response)
return HaikuContentResponse(**data)
except json.JSONDecodeError as e:
logger.error(f"Failed to parse content response: {e}")
# Return fallback content
return self._generate_fallback_content(
company_name, city_name, country_name, services, language
)
except Exception as e:
logger.error(f"Content generation failed: {e}")
return self._generate_fallback_content(
company_name, city_name, country_name, services, language
)
def _generate_fallback_content(
self,
company_name: str,
city_name: str,
country_name: str,
services: List[str],
language: str,
) -> HaikuContentResponse:
"""Generate basic fallback content when AI generation fails."""
services_text = ", ".join(services) if services else "Odoo implementation"
if language == "de":
description = f"{company_name} ist ein Odoo Partner in {city_name}, {country_name}."
content = f"""<h2>Über {company_name}</h2>
<p>{company_name} ist ein zertifizierter Odoo Partner mit Sitz in {city_name}.
Das Unternehmen bietet professionelle Odoo-Dienstleistungen für Unternehmen in der Region.</p>
<h3>Dienstleistungen</h3>
<p>Als Odoo Partner bietet {company_name} folgende Leistungen: {services_text}.</p>
<h3>Standort</h3>
<p>{company_name} befindet sich in {city_name}, {country_name} und betreut Kunden in der gesamten Region.</p>"""
meta_title = f"{company_name} - Odoo Partner in {city_name}"
meta_desc = f"{company_name} ist Ihr Odoo Partner in {city_name}. Professionelle ERP-Beratung und Implementierung."
else:
description = f"{company_name} is an Odoo partner located in {city_name}, {country_name}."
content = f"""<h2>About {company_name}</h2>
<p>{company_name} is a certified Odoo partner based in {city_name}.
The company provides professional Odoo services for businesses in the region.</p>
<h3>Services</h3>
<p>As an Odoo partner, {company_name} offers: {services_text}.</p>
<h3>Location</h3>
<p>{company_name} is located in {city_name}, {country_name} and serves clients throughout the region.</p>"""
meta_title = f"{company_name} - Odoo Partner in {city_name}"
meta_desc = f"{company_name} is your Odoo partner in {city_name}. Professional ERP consulting and implementation."
return HaikuContentResponse(
description=description,
content_html=content,
meta_title=meta_title[:60],
meta_description=meta_desc[:160],
)
def generate_city_hub_content(
self,
city_name: str,
country_name: str,
company_count: int,
top_services: List[str],
language: str = "de",
) -> Dict[str, str]:
"""
Generate city hub page content.
Args:
city_name: City name
country_name: Country name
company_count: Number of companies in city
top_services: Most common services
language: Output language
Returns:
Dictionary with content sections
"""
lang_instructions = {
"de": "Write in German.",
"en": "Write in English.",
}
system_prompt = f"""You are an SEO expert writing city hub pages for an Odoo partner directory.
{lang_instructions.get(language, "Write in English.")}
Create compelling content (1000+ words) about finding Odoo partners in this city.
Return valid JSON:
{{
"intro": "Introduction paragraph about Odoo partners in this city",
"content_html": "Full HTML content about Odoo services in this city",
"meta_title": "SEO title (50-60 chars)",
"meta_description": "Meta description (150-160 chars)"
}}"""
user_prompt = f"""City: {city_name}, {country_name}
Number of Odoo Partners: {company_count}
Top Services: {", ".join(top_services)}
Create engaging city hub content for this Odoo partner directory."""
try:
response = self._call_haiku(system_prompt, user_prompt)
return json.loads(response)
except Exception as e:
logger.error(f"City hub content generation failed: {e}")
return {
"intro": f"Find Odoo partners in {city_name}.",
"content_html": f"<p>Discover {company_count} Odoo partners in {city_name}, {country_name}.</p>",
"meta_title": f"Odoo Partners in {city_name}",
"meta_description": f"Find {company_count} certified Odoo partners in {city_name}, {country_name}.",
}
def generate_country_hub_content(
self,
country_name: str,
city_count: int,
company_count: int,
top_cities: List[str],
language: str = "de",
) -> Dict[str, str]:
"""
Generate country hub page content.
Args:
country_name: Country name
city_count: Number of cities with partners
company_count: Total companies in country
top_cities: Cities with most partners
language: Output language
Returns:
Dictionary with content sections
"""
lang_instructions = {
"de": "Write in German.",
"en": "Write in English.",
}
system_prompt = f"""You are an SEO expert writing country hub pages for an Odoo partner directory.
{lang_instructions.get(language, "Write in English.")}
Create comprehensive content (1200+ words) about Odoo partners in this country.
Return valid JSON:
{{
"intro": "Introduction about Odoo partners in this country",
"content_html": "Full HTML content about the Odoo ecosystem in this country",
"meta_title": "SEO title (50-60 chars)",
"meta_description": "Meta description (150-160 chars)"
}}"""
user_prompt = f"""Country: {country_name}
Number of Cities: {city_count}
Total Odoo Partners: {company_count}
Top Cities: {", ".join(top_cities)}
Create engaging country hub content for this Odoo partner directory."""
try:
response = self._call_haiku(system_prompt, user_prompt)
return json.loads(response)
except Exception as e:
logger.error(f"Country hub content generation failed: {e}")
return {
"intro": f"Find Odoo partners across {country_name}.",
"content_html": f"<p>Discover {company_count} Odoo partners in {city_count} cities across {country_name}.</p>",
"meta_title": f"Odoo Partners in {country_name}",
"meta_description": f"Find {company_count} certified Odoo partners in {city_count} cities across {country_name}.",
}
+223
View File
@@ -0,0 +1,223 @@
"""
Image processing service for company logos and images.
"""
import logging
from pathlib import Path
from typing import Optional, Tuple
from urllib.parse import urlparse
import hashlib
import httpx
from PIL import Image
import io
from ..config import get_settings
logger = logging.getLogger(__name__)
class ImageService:
"""Service for downloading and processing images."""
# Standard sizes for company images
SIZES = {
"thumbnail": (150, 150),
"medium": (300, 300),
"large": (600, 600),
}
def __init__(self):
"""Initialize image service."""
settings = get_settings()
self.images_dir = settings.images_dir
self.images_dir.mkdir(parents=True, exist_ok=True)
def _get_image_hash(self, url: str) -> str:
"""Generate a short hash for an image URL."""
return hashlib.md5(url.encode()).hexdigest()[:12]
def _get_image_path(
self, company_slug: str, image_type: str = "logo", size: str = "medium"
) -> Path:
"""
Get the path for storing a company image.
Args:
company_slug: Company slug for filename
image_type: Type of image (logo, photo)
size: Size variant (thumbnail, medium, large)
Returns:
Path to image file
"""
filename = f"{company_slug}_{image_type}_{size}.webp"
return self.images_dir / filename
def download_image(self, url: str) -> Optional[bytes]:
"""
Download an image from URL.
Args:
url: Image URL
Returns:
Image bytes or None if download failed
"""
try:
with httpx.Client(timeout=30.0, follow_redirects=True) as client:
response = client.get(url)
response.raise_for_status()
content_type = response.headers.get("content-type", "")
if not content_type.startswith("image/"):
logger.warning(f"URL is not an image: {url}")
return None
return response.content
except Exception as e:
logger.error(f"Failed to download image from {url}: {e}")
return None
def process_image(
self,
image_data: bytes,
size: Tuple[int, int],
quality: int = 85,
) -> Optional[bytes]:
"""
Process and convert an image to WebP format.
Args:
image_data: Raw image bytes
size: Target size (width, height)
quality: WebP quality (1-100)
Returns:
Processed image bytes or None if processing failed
"""
try:
# Open image
img = Image.open(io.BytesIO(image_data))
# Convert to RGB if necessary (for WebP)
if img.mode in ("RGBA", "P"):
# Create white background for transparency
background = Image.new("RGB", img.size, (255, 255, 255))
if img.mode == "P":
img = img.convert("RGBA")
background.paste(img, mask=img.split()[-1])
img = background
elif img.mode != "RGB":
img = img.convert("RGB")
# Resize maintaining aspect ratio
img.thumbnail(size, Image.Resampling.LANCZOS)
# Save to WebP
output = io.BytesIO()
img.save(output, format="WEBP", quality=quality, method=6)
return output.getvalue()
except Exception as e:
logger.error(f"Failed to process image: {e}")
return None
def save_company_logo(
self,
company_slug: str,
logo_url: str,
sizes: Optional[list] = None,
) -> dict:
"""
Download and save company logo in multiple sizes.
Args:
company_slug: Company slug for filename
logo_url: URL of the logo image
sizes: List of size names to generate (default: all)
Returns:
Dictionary mapping size names to file paths
"""
if sizes is None:
sizes = list(self.SIZES.keys())
saved_paths = {}
# Download original
image_data = self.download_image(logo_url)
if not image_data:
return saved_paths
# Process and save each size
for size_name in sizes:
if size_name not in self.SIZES:
continue
size = self.SIZES[size_name]
processed = self.process_image(image_data, size)
if processed:
path = self._get_image_path(company_slug, "logo", size_name)
path.write_bytes(processed)
saved_paths[size_name] = str(path)
logger.debug(f"Saved {size_name} logo: {path}")
return saved_paths
def get_logo_urls(self, company_slug: str, base_url: str = "") -> dict:
"""
Get URLs for company logo images.
Args:
company_slug: Company slug
base_url: Base URL for the images (e.g., /static/images/companies/)
Returns:
Dictionary mapping size names to URLs
"""
urls = {}
for size_name in self.SIZES:
path = self._get_image_path(company_slug, "logo", size_name)
if path.exists():
relative_path = path.relative_to(self.images_dir)
urls[size_name] = f"{base_url}{relative_path}"
return urls
def cleanup_company_images(self, company_slug: str) -> int:
"""
Remove all images for a company.
Args:
company_slug: Company slug
Returns:
Number of files removed
"""
removed = 0
for file in self.images_dir.glob(f"{company_slug}_*"):
file.unlink()
removed += 1
return removed
def get_storage_stats(self) -> dict:
"""
Get storage statistics for the images directory.
Returns:
Dictionary with stats
"""
total_size = 0
file_count = 0
for file in self.images_dir.glob("*.webp"):
total_size += file.stat().st_size
file_count += 1
return {
"file_count": file_count,
"total_size_mb": round(total_size / (1024 * 1024), 2),
"directory": str(self.images_dir),
}
+472
View File
@@ -0,0 +1,472 @@
"""
Supabase database service for Odoo Directory.
Handles all database operations.
"""
import logging
from typing import Optional, List, Dict, Any
from uuid import UUID
from supabase import create_client, Client
from ..config import get_settings
from ..models.schemas import (
Region,
Country,
City,
Tag,
Company,
CompanyTag,
CityTag,
CountryTag,
ExecutionPlan,
PlanStep,
TagCategory,
StepStatus,
)
logger = logging.getLogger(__name__)
class SupabaseService:
"""Service for Supabase database operations."""
def __init__(self):
"""Initialize Supabase client."""
settings = get_settings()
self.client: Client = create_client(
settings.supabase_url, settings.supabase_key
)
# ==================== Region Operations ====================
def create_region(self, region: Region) -> Region:
"""Create a new region."""
data = {"name": region.name, "slug": region.slug}
result = self.client.table("regions").insert(data).execute()
return Region(**result.data[0])
def get_region_by_slug(self, slug: str) -> Optional[Region]:
"""Get region by slug."""
result = (
self.client.table("regions").select("*").eq("slug", slug).execute()
)
if result.data:
return Region(**result.data[0])
return None
def list_regions(self) -> List[Region]:
"""List all regions."""
result = self.client.table("regions").select("*").execute()
return [Region(**r) for r in result.data]
# ==================== Country Operations ====================
def create_country(self, country: Country) -> Country:
"""Create a new country."""
data = {
"region_id": str(country.region_id) if country.region_id else None,
"name": country.name,
"name_local": country.name_local,
"code": country.code,
"slug": country.slug,
"language": country.language,
}
result = self.client.table("countries").insert(data).execute()
return Country(**result.data[0])
def get_country_by_code(self, code: str) -> Optional[Country]:
"""Get country by ISO code."""
result = (
self.client.table("countries")
.select("*")
.eq("code", code.upper())
.execute()
)
if result.data:
return Country(**result.data[0])
return None
def get_country_by_slug(self, slug: str) -> Optional[Country]:
"""Get country by slug."""
result = (
self.client.table("countries").select("*").eq("slug", slug).execute()
)
if result.data:
return Country(**result.data[0])
return None
def list_countries(self, region_id: Optional[UUID] = None) -> List[Country]:
"""List countries, optionally filtered by region."""
query = self.client.table("countries").select("*")
if region_id:
query = query.eq("region_id", str(region_id))
result = query.execute()
return [Country(**c) for c in result.data]
# ==================== City Operations ====================
def create_city(self, city: City) -> City:
"""Create a new city."""
data = {
"country_id": str(city.country_id) if city.country_id else None,
"name": city.name,
"name_local": city.name_local,
"slug": city.slug,
"state": city.state,
"population": city.population,
"latitude": city.latitude,
"longitude": city.longitude,
}
result = self.client.table("cities").insert(data).execute()
return City(**result.data[0])
def get_city_by_slug(
self, slug: str, country_id: Optional[UUID] = None
) -> Optional[City]:
"""Get city by slug, optionally filtered by country."""
query = self.client.table("cities").select("*").eq("slug", slug)
if country_id:
query = query.eq("country_id", str(country_id))
result = query.execute()
if result.data:
return City(**result.data[0])
return None
def list_cities(self, country_id: Optional[UUID] = None) -> List[City]:
"""List cities, optionally filtered by country."""
query = self.client.table("cities").select("*")
if country_id:
query = query.eq("country_id", str(country_id))
result = query.execute()
return [City(**c) for c in result.data]
# ==================== Tag Operations ====================
def create_tag(self, tag: Tag) -> Tag:
"""Create a new tag."""
data = {
"category": tag.category.value,
"name": tag.name,
"slug": tag.slug,
"description": tag.description,
}
result = self.client.table("tags").insert(data).execute()
return Tag(**result.data[0])
def get_tag_by_slug(self, slug: str) -> Optional[Tag]:
"""Get tag by slug."""
result = self.client.table("tags").select("*").eq("slug", slug).execute()
if result.data:
return Tag(**result.data[0])
return None
def list_tags(self, category: Optional[TagCategory] = None) -> List[Tag]:
"""List tags, optionally filtered by category."""
query = self.client.table("tags").select("*")
if category:
query = query.eq("category", category.value)
result = query.execute()
return [Tag(**t) for t in result.data]
def get_or_create_tag(
self, name: str, category: TagCategory, description: Optional[str] = None
) -> Tag:
"""Get existing tag or create new one."""
slug = name.lower().replace(" ", "-").replace("&", "and")
existing = self.get_tag_by_slug(slug)
if existing:
return existing
return self.create_tag(
Tag(category=category, name=name, slug=slug, description=description)
)
# ==================== Company Operations ====================
def create_company(self, company: Company) -> Company:
"""Create a new company."""
data = {
"city_id": str(company.city_id) if company.city_id else None,
"name": company.name,
"slug": company.slug,
"google_place_id": company.google_place_id,
"address": company.address,
"phone": company.phone,
"website": company.website,
"email": company.email,
"rating": company.rating,
"review_count": company.review_count,
"latitude": company.latitude,
"longitude": company.longitude,
"status": company.status,
}
result = self.client.table("companies").insert(data).execute()
return Company(**result.data[0])
def update_company(self, company_id: UUID, updates: Dict[str, Any]) -> Company:
"""Update a company."""
result = (
self.client.table("companies")
.update(updates)
.eq("id", str(company_id))
.execute()
)
return Company(**result.data[0])
def get_company_by_id(self, company_id: UUID) -> Optional[Company]:
"""Get company by ID."""
result = (
self.client.table("companies")
.select("*")
.eq("id", str(company_id))
.execute()
)
if result.data:
return Company(**result.data[0])
return None
def get_company_by_place_id(self, place_id: str) -> Optional[Company]:
"""Get company by Google Place ID (for deduplication)."""
result = (
self.client.table("companies")
.select("*")
.eq("google_place_id", place_id)
.execute()
)
if result.data:
return Company(**result.data[0])
return None
def list_companies(
self,
city_id: Optional[UUID] = None,
status: Optional[str] = None,
limit: int = 100,
offset: int = 0,
) -> List[Company]:
"""List companies with optional filters."""
query = self.client.table("companies").select("*")
if city_id:
query = query.eq("city_id", str(city_id))
if status:
query = query.eq("status", status)
query = query.range(offset, offset + limit - 1)
result = query.execute()
return [Company(**c) for c in result.data]
def count_companies(
self, city_id: Optional[UUID] = None, status: Optional[str] = None
) -> int:
"""Count companies with optional filters."""
query = self.client.table("companies").select("id", count="exact")
if city_id:
query = query.eq("city_id", str(city_id))
if status:
query = query.eq("status", status)
result = query.execute()
return result.count or 0
# ==================== Company Tag Operations ====================
def add_company_tag(
self, company_id: UUID, tag_id: UUID, confidence: float = 1.0
) -> CompanyTag:
"""Add a tag to a company."""
data = {
"company_id": str(company_id),
"tag_id": str(tag_id),
"confidence": confidence,
}
result = self.client.table("company_tags").insert(data).execute()
return CompanyTag(**result.data[0])
def get_company_tags(self, company_id: UUID) -> List[Tag]:
"""Get all tags for a company."""
result = (
self.client.table("company_tags")
.select("tag_id, tags(*)")
.eq("company_id", str(company_id))
.execute()
)
tags = []
for item in result.data:
if item.get("tags"):
tags.append(Tag(**item["tags"]))
return tags
def remove_company_tags(self, company_id: UUID) -> None:
"""Remove all tags from a company."""
self.client.table("company_tags").delete().eq(
"company_id", str(company_id)
).execute()
# ==================== City Tag Aggregation ====================
def upsert_city_tag(self, city_id: UUID, tag_id: UUID, count: int) -> CityTag:
"""Upsert city tag aggregation."""
data = {
"city_id": str(city_id),
"tag_id": str(tag_id),
"company_count": count,
}
result = (
self.client.table("city_tags")
.upsert(data, on_conflict="city_id,tag_id")
.execute()
)
return CityTag(**result.data[0])
def get_city_tags(self, city_id: UUID) -> List[Dict[str, Any]]:
"""Get all tag aggregations for a city with tag details."""
result = (
self.client.table("city_tags")
.select("*, tags(*)")
.eq("city_id", str(city_id))
.execute()
)
return result.data
# ==================== Country Tag Aggregation ====================
def upsert_country_tag(
self, country_id: UUID, tag_id: UUID, company_count: int, city_count: int
) -> CountryTag:
"""Upsert country tag aggregation."""
data = {
"country_id": str(country_id),
"tag_id": str(tag_id),
"company_count": company_count,
"city_count": city_count,
}
result = (
self.client.table("country_tags")
.upsert(data, on_conflict="country_id,tag_id")
.execute()
)
return CountryTag(**result.data[0])
def get_country_tags(self, country_id: UUID) -> List[Dict[str, Any]]:
"""Get all tag aggregations for a country with tag details."""
result = (
self.client.table("country_tags")
.select("*, tags(*)")
.eq("country_id", str(country_id))
.execute()
)
return result.data
# ==================== Execution Plan Operations ====================
def create_execution_plan(self, plan: ExecutionPlan) -> ExecutionPlan:
"""Create a new execution plan."""
data = {
"name": plan.name,
"country_code": plan.country_code,
"cities": plan.cities,
"status": plan.status.value,
}
result = self.client.table("execution_plans").insert(data).execute()
return ExecutionPlan(**result.data[0])
def update_execution_plan(
self, plan_id: UUID, updates: Dict[str, Any]
) -> ExecutionPlan:
"""Update an execution plan."""
result = (
self.client.table("execution_plans")
.update(updates)
.eq("id", str(plan_id))
.execute()
)
return ExecutionPlan(**result.data[0])
def get_execution_plan(self, plan_id: UUID) -> Optional[ExecutionPlan]:
"""Get execution plan by ID."""
result = (
self.client.table("execution_plans")
.select("*")
.eq("id", str(plan_id))
.execute()
)
if result.data:
plan = ExecutionPlan(**result.data[0])
# Load steps
steps_result = (
self.client.table("plan_steps")
.select("*")
.eq("plan_id", str(plan_id))
.execute()
)
plan.steps = [PlanStep(**s) for s in steps_result.data]
return plan
return None
def list_execution_plans(
self, status: Optional[StepStatus] = None
) -> List[ExecutionPlan]:
"""List execution plans."""
query = self.client.table("execution_plans").select("*")
if status:
query = query.eq("status", status.value)
result = query.execute()
return [ExecutionPlan(**p) for p in result.data]
# ==================== Plan Step Operations ====================
def create_plan_step(self, step: PlanStep) -> PlanStep:
"""Create a new plan step."""
data = {
"plan_id": str(step.plan_id) if step.plan_id else None,
"step_name": step.step_name,
"city_id": str(step.city_id) if step.city_id else None,
"status": step.status.value,
"items_processed": step.items_processed,
"items_total": step.items_total,
}
result = self.client.table("plan_steps").insert(data).execute()
return PlanStep(**result.data[0])
def update_plan_step(self, step_id: UUID, updates: Dict[str, Any]) -> PlanStep:
"""Update a plan step."""
result = (
self.client.table("plan_steps")
.update(updates)
.eq("id", str(step_id))
.execute()
)
return PlanStep(**result.data[0])
def get_plan_steps(
self, plan_id: UUID, step_name: Optional[str] = None
) -> List[PlanStep]:
"""Get steps for a plan."""
query = (
self.client.table("plan_steps").select("*").eq("plan_id", str(plan_id))
)
if step_name:
query = query.eq("step_name", step_name)
result = query.execute()
return [PlanStep(**s) for s in result.data]
# ==================== Error Logging ====================
def log_error(
self,
entity_type: str,
entity_id: Optional[UUID],
operation: str,
error_message: str,
details: Optional[Dict[str, Any]] = None,
) -> None:
"""Log an error to the database."""
data = {
"entity_type": entity_type,
"entity_id": str(entity_id) if entity_id else None,
"operation": operation,
"error_message": error_message,
"details": details or {},
}
try:
self.client.table("error_logs").insert(data).execute()
except Exception as e:
logger.error(f"Failed to log error to database: {e}")
+309
View File
@@ -0,0 +1,309 @@
"""
Tag aggregation service for cities and countries.
"""
import logging
from typing import Dict, List, Any
from uuid import UUID
from collections import defaultdict
from .supabase import SupabaseService
from ..models.schemas import TagCategory
logger = logging.getLogger(__name__)
class TagAggregationService:
"""Service for aggregating tags across cities and countries."""
def __init__(self, db: SupabaseService = None):
"""
Initialize tag aggregation service.
Args:
db: Supabase service instance (creates new one if not provided)
"""
self.db = db or SupabaseService()
def aggregate_city_tags(self, city_id: UUID) -> Dict[str, int]:
"""
Aggregate all company tags for a city.
Counts how many companies have each tag and stores in city_tags table.
Args:
city_id: City UUID
Returns:
Dictionary mapping tag slugs to company counts
"""
logger.info(f"Aggregating tags for city {city_id}")
# Get all companies in the city
companies = self.db.list_companies(city_id=city_id, limit=1000)
# Count tags across all companies
tag_counts = defaultdict(int)
tag_id_map = {}
for company in companies:
company_tags = self.db.get_company_tags(company.id)
for tag in company_tags:
tag_counts[tag.slug] += 1
tag_id_map[tag.slug] = tag.id
# Update city_tags table
for slug, count in tag_counts.items():
tag_id = tag_id_map[slug]
self.db.upsert_city_tag(city_id, tag_id, count)
logger.info(f"Aggregated {len(tag_counts)} tags for city {city_id}")
return dict(tag_counts)
def aggregate_country_tags(self, country_id: UUID) -> Dict[str, Dict[str, int]]:
"""
Aggregate all city tags for a country.
Rolls up city-level tag counts to country level.
Args:
country_id: Country UUID
Returns:
Dictionary mapping tag slugs to {company_count, city_count}
"""
logger.info(f"Aggregating tags for country {country_id}")
# Get all cities in the country
cities = self.db.list_cities(country_id=country_id)
# Aggregate across all cities
tag_stats = defaultdict(lambda: {"company_count": 0, "city_count": 0})
tag_id_map = {}
for city in cities:
city_tags = self.db.get_city_tags(city.id)
for ct in city_tags:
tag_data = ct.get("tags", {})
tag_slug = tag_data.get("slug")
tag_id = tag_data.get("id")
if tag_slug and tag_id:
company_count = ct.get("company_count", 0)
tag_stats[tag_slug]["company_count"] += company_count
tag_stats[tag_slug]["city_count"] += 1
tag_id_map[tag_slug] = tag_id
# Update country_tags table
for slug, stats in tag_stats.items():
tag_id = tag_id_map[slug]
self.db.upsert_country_tag(
country_id, tag_id, stats["company_count"], stats["city_count"]
)
logger.info(f"Aggregated {len(tag_stats)} tags for country {country_id}")
return dict(tag_stats)
def get_city_tags_by_category(
self, city_id: UUID
) -> Dict[str, List[Dict[str, Any]]]:
"""
Get city tags organized by category.
Args:
city_id: City UUID
Returns:
Dictionary with categories as keys and lists of tag info
"""
city_tags = self.db.get_city_tags(city_id)
result = {
"services": [],
"industries": [],
"modules": [],
"partner_levels": [],
}
for ct in city_tags:
tag_data = ct.get("tags", {})
category = tag_data.get("category")
company_count = ct.get("company_count", 0)
tag_info = {
"name": tag_data.get("name"),
"slug": tag_data.get("slug"),
"count": company_count,
}
if category == "service":
result["services"].append(tag_info)
elif category == "industry":
result["industries"].append(tag_info)
elif category == "module":
result["modules"].append(tag_info)
elif category == "partner_level":
result["partner_levels"].append(tag_info)
# Sort each category by count (descending)
for key in result:
result[key].sort(key=lambda x: x["count"], reverse=True)
return result
def get_country_tags_by_category(
self, country_id: UUID
) -> Dict[str, List[Dict[str, Any]]]:
"""
Get country tags organized by category.
Args:
country_id: Country UUID
Returns:
Dictionary with categories as keys and lists of tag info
"""
country_tags = self.db.get_country_tags(country_id)
result = {
"services": [],
"industries": [],
"modules": [],
"partner_levels": [],
}
for ct in country_tags:
tag_data = ct.get("tags", {})
category = tag_data.get("category")
tag_info = {
"name": tag_data.get("name"),
"slug": tag_data.get("slug"),
"company_count": ct.get("company_count", 0),
"city_count": ct.get("city_count", 0),
}
if category == "service":
result["services"].append(tag_info)
elif category == "industry":
result["industries"].append(tag_info)
elif category == "module":
result["modules"].append(tag_info)
elif category == "partner_level":
result["partner_levels"].append(tag_info)
# Sort each category by company count (descending)
for key in result:
result[key].sort(key=lambda x: x["company_count"], reverse=True)
return result
def export_city_tags_json(self, city_id: UUID) -> Dict[str, Any]:
"""
Export city tags as JSON for frontend filtering.
Args:
city_id: City UUID
Returns:
JSON-serializable dictionary
"""
tags_by_category = self.get_city_tags_by_category(city_id)
return {
"filters": {
"services": [
{"value": t["slug"], "label": t["name"], "count": t["count"]}
for t in tags_by_category["services"]
],
"industries": [
{"value": t["slug"], "label": t["name"], "count": t["count"]}
for t in tags_by_category["industries"]
],
"modules": [
{"value": t["slug"], "label": t["name"], "count": t["count"]}
for t in tags_by_category["modules"]
],
"partner_levels": [
{"value": t["slug"], "label": t["name"], "count": t["count"]}
for t in tags_by_category["partner_levels"]
],
}
}
def export_country_tags_json(self, country_id: UUID) -> Dict[str, Any]:
"""
Export country tags as JSON for frontend filtering.
Args:
country_id: Country UUID
Returns:
JSON-serializable dictionary
"""
tags_by_category = self.get_country_tags_by_category(country_id)
return {
"filters": {
"services": [
{
"value": t["slug"],
"label": t["name"],
"company_count": t["company_count"],
"city_count": t["city_count"],
}
for t in tags_by_category["services"]
],
"industries": [
{
"value": t["slug"],
"label": t["name"],
"company_count": t["company_count"],
"city_count": t["city_count"],
}
for t in tags_by_category["industries"]
],
"modules": [
{
"value": t["slug"],
"label": t["name"],
"company_count": t["company_count"],
"city_count": t["city_count"],
}
for t in tags_by_category["modules"]
],
"partner_levels": [
{
"value": t["slug"],
"label": t["name"],
"company_count": t["company_count"],
"city_count": t["city_count"],
}
for t in tags_by_category["partner_levels"]
],
}
}
def refresh_all_aggregations(self, country_id: UUID) -> None:
"""
Refresh all tag aggregations for a country.
First aggregates all city tags, then rolls up to country level.
Args:
country_id: Country UUID
"""
logger.info(f"Refreshing all aggregations for country {country_id}")
# Get all cities
cities = self.db.list_cities(country_id=country_id)
# Aggregate each city
for city in cities:
self.aggregate_city_tags(city.id)
# Aggregate country
self.aggregate_country_tags(country_id)
logger.info(f"Completed aggregation refresh for country {country_id}")
+1
View File
@@ -0,0 +1 @@
# Templates directory - Jinja2 templates are auto-generated on first run
+6
View File
@@ -0,0 +1,6 @@
"""Utility modules for Odoo Directory."""
from .logger import get_logger, setup_logging
from .retry import retry_with_backoff
__all__ = ["get_logger", "setup_logging", "retry_with_backoff"]
+53
View File
@@ -0,0 +1,53 @@
"""
Logging configuration for Odoo Directory CLI.
"""
import logging
import sys
from typing import Optional
from rich.console import Console
from rich.logging import RichHandler
# Global console for rich output
console = Console()
def setup_logging(level: str = "INFO") -> None:
"""
Configure logging with rich handler for pretty console output.
Args:
level: Log level (DEBUG, INFO, WARNING, ERROR, CRITICAL)
"""
logging.basicConfig(
level=level.upper(),
format="%(message)s",
datefmt="[%X]",
handlers=[
RichHandler(
console=console,
show_time=True,
show_path=False,
markup=True,
rich_tracebacks=True,
)
],
)
def get_logger(name: str, level: Optional[str] = None) -> logging.Logger:
"""
Get a logger instance with optional level override.
Args:
name: Logger name (usually __name__)
level: Optional level override
Returns:
Configured logger instance
"""
logger = logging.getLogger(name)
if level:
logger.setLevel(level.upper())
return logger
+92
View File
@@ -0,0 +1,92 @@
"""
Retry utilities with exponential backoff.
"""
import asyncio
from functools import wraps
from typing import Callable, TypeVar, Any, Tuple, Type
import logging
from tenacity import (
retry,
stop_after_attempt,
wait_exponential,
retry_if_exception_type,
before_sleep_log,
)
logger = logging.getLogger(__name__)
T = TypeVar("T")
def retry_with_backoff(
max_attempts: int = 3,
min_wait: float = 1.0,
max_wait: float = 60.0,
exceptions: Tuple[Type[Exception], ...] = (Exception,),
) -> Callable:
"""
Decorator for retrying functions with exponential backoff.
Args:
max_attempts: Maximum number of retry attempts
min_wait: Minimum wait time between retries (seconds)
max_wait: Maximum wait time between retries (seconds)
exceptions: Tuple of exception types to retry on
Returns:
Decorated function with retry logic
"""
return retry(
stop=stop_after_attempt(max_attempts),
wait=wait_exponential(multiplier=1, min=min_wait, max=max_wait),
retry=retry_if_exception_type(exceptions),
before_sleep=before_sleep_log(logger, logging.WARNING),
reraise=True,
)
async def async_retry_with_backoff(
func: Callable[..., T],
*args,
max_attempts: int = 3,
min_wait: float = 1.0,
max_wait: float = 60.0,
exceptions: Tuple[Type[Exception], ...] = (Exception,),
**kwargs,
) -> T:
"""
Async function with retry logic and exponential backoff.
Args:
func: Async function to call
*args: Positional arguments for func
max_attempts: Maximum number of retry attempts
min_wait: Minimum wait time between retries (seconds)
max_wait: Maximum wait time between retries (seconds)
exceptions: Tuple of exception types to retry on
**kwargs: Keyword arguments for func
Returns:
Result of the function call
"""
last_exception = None
wait_time = min_wait
for attempt in range(1, max_attempts + 1):
try:
return await func(*args, **kwargs)
except exceptions as e:
last_exception = e
if attempt < max_attempts:
logger.warning(
f"Attempt {attempt}/{max_attempts} failed: {e}. "
f"Retrying in {wait_time:.1f}s..."
)
await asyncio.sleep(wait_time)
wait_time = min(wait_time * 2, max_wait)
else:
logger.error(f"All {max_attempts} attempts failed: {e}")
raise last_exception