Files
demo_sites/builder/services.py
2026-05-17 18:29:30 -05:00

710 lines
25 KiB
Python

import html
import json
import shutil
import zipfile
from pathlib import Path
import requests
from django.conf import settings
from django.utils.text import slugify
from .models import ClientSite
from .template_registry import (
COPY_EXCLUDE_DIR_NAMES,
get_template_config,
resolve_source_path,
)
PERSONALIZE_EXTENSIONS = {".html", ".htm", ".css", ".js", ".jsx", ".md", ".txt", ".php"}
class SiteGenerator:
def __init__(self) -> None:
self.ollama_url = settings.OLLAMA_URL.rstrip("/")
self.ollama_model = settings.OLLAMA_MODEL
def build_all(self, site: ClientSite) -> ClientSite:
site_root = Path(settings.MEDIA_ROOT) / "generated" / str(site.id)
if site_root.exists():
shutil.rmtree(site_root)
site_root.mkdir(parents=True, exist_ok=True)
ai_payload = self._generate_ai_payload(site)
basic_zip = self._build_basic_site(site, ai_payload, site_root)
django_zip = self._build_django_demo(site, ai_payload, site_root)
site.ai_payload = ai_payload
site.basic_zip_path = str(basic_zip.relative_to(settings.MEDIA_ROOT))
site.django_zip_path = str(django_zip.relative_to(settings.MEDIA_ROOT))
site.status = ClientSite.Status.READY
site.last_error = ""
site.save(
update_fields=[
"ai_payload",
"basic_zip_path",
"django_zip_path",
"status",
"last_error",
"updated_at",
]
)
return site
def _generate_ai_payload(self, site: ClientSite) -> dict:
fallback = {
"tagline": f"{site.business_name} helps local customers with trusted service.",
"hero_headline": f"Welcome to {site.business_name}",
"hero_subheadline": site.business_description,
"services": [
"Professional support tailored to your business goals",
"Friendly service with clear communication",
"Fast turnaround with quality-focused delivery",
],
"testimonials": [
{"name": "Jordan T.", "quote": f"{site.business_name} made process simple and stress-free."},
{"name": "Casey M.", "quote": "Great communication, clear pricing, excellent final result."},
{"name": "Alex R.", "quote": "Reliable team. We will keep using them for future needs."},
],
"contact_prompt": "Tell us what you need and we will respond quickly.",
}
prompt = (
"You are website copy assistant. Respond in valid JSON only. "
"Use keys: tagline, hero_headline, hero_subheadline, services, testimonials, contact_prompt. "
"services must be 3 short strings. testimonials must be array of 3 objects with name and quote."
f"\nBusiness name: {site.business_name}"
f"\nBusiness description: {site.business_description}"
f"\nAddress: {site.business_address}"
f"\nPhone: {site.business_phone}"
f"\nOld website: {site.old_website or 'n/a'}"
)
try:
response = requests.post(
f"{self.ollama_url}/api/generate",
json={"model": self.ollama_model, "prompt": prompt, "stream": False},
timeout=20,
)
response.raise_for_status()
model_text = response.json().get("response", "").strip()
parsed = json.loads(model_text)
if not isinstance(parsed, dict):
return fallback
return {
"tagline": parsed.get("tagline") or fallback["tagline"],
"hero_headline": parsed.get("hero_headline") or fallback["hero_headline"],
"hero_subheadline": parsed.get("hero_subheadline") or fallback["hero_subheadline"],
"services": parsed.get("services") or fallback["services"],
"testimonials": parsed.get("testimonials") or fallback["testimonials"],
"contact_prompt": parsed.get("contact_prompt") or fallback["contact_prompt"],
}
except Exception:
return fallback
def _build_basic_site(self, site: ClientSite, ai_payload: dict, root: Path) -> Path:
basic_dir = root / "basic_site"
if basic_dir.exists():
shutil.rmtree(basic_dir)
source_path = resolve_source_path(site.template_option.source_folder)
if source_path.exists() and source_path.is_dir():
self._copy_client_template(source_path, basic_dir)
config = get_template_config(site.template_option.slug) or {}
self._personalize_template_tree(basic_dir, site, ai_payload, config)
self._write_client_readme(basic_dir, site, ai_payload, config)
basic_zip = root / "basic_site.zip"
self._zip_dir(basic_dir, basic_zip)
return basic_zip
basic_dir.mkdir(parents=True, exist_ok=True)
safe_name = html.escape(site.business_name)
safe_phone = html.escape(site.business_phone)
safe_address = html.escape(site.business_address)
safe_description = html.escape(site.business_description)
safe_tagline = html.escape(ai_payload["tagline"])
hero_title = html.escape(ai_payload["hero_headline"])
hero_sub = html.escape(ai_payload["hero_subheadline"])
services = ai_payload.get("services", [])
testimonials = ai_payload.get("testimonials", [])
nav = """
<nav>
<a href="index.html">Home</a>
<a href="about.html">About</a>
<a href="testimonials.html">Testimonials</a>
<a href="contact.html">Contact</a>
</nav>
"""
index_html = f"""<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>{safe_name} | Home</title>
<link rel="stylesheet" href="styles.css">
</head>
<body>
{nav}
<header class="hero">
<h1>{hero_title}</h1>
<p>{hero_sub}</p>
<span class="tagline">{safe_tagline}</span>
</header>
<section>
<h2>What We Offer</h2>
<ul>
<li>{html.escape(services[0] if len(services) > 0 else "Quality-focused support")}</li>
<li>{html.escape(services[1] if len(services) > 1 else "Local-first service model")}</li>
<li>{html.escape(services[2] if len(services) > 2 else "Clear process from start to finish")}</li>
</ul>
</section>
</body>
</html>
"""
about_html = f"""<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>{safe_name} | About</title>
<link rel="stylesheet" href="styles.css">
</head>
<body>
{nav}
<main>
<h1>About {safe_name}</h1>
<p>{safe_description}</p>
<p>Address: {safe_address}</p>
<p>Phone: {safe_phone}</p>
</main>
</body>
</html>
"""
t1 = testimonials[0] if len(testimonials) > 0 and isinstance(testimonials[0], dict) else {"name": "Client", "quote": "Great result."}
t2 = testimonials[1] if len(testimonials) > 1 and isinstance(testimonials[1], dict) else {"name": "Client", "quote": "Excellent service."}
t3 = testimonials[2] if len(testimonials) > 2 and isinstance(testimonials[2], dict) else {"name": "Client", "quote": "Would recommend."}
testimonials_html = f"""<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>{safe_name} | Testimonials</title>
<link rel="stylesheet" href="styles.css">
</head>
<body>
{nav}
<main>
<h1>Testimonials</h1>
<blockquote>"{html.escape(str(t1.get("quote", "Great result.")))}" <cite>- {html.escape(str(t1.get("name", "Client")))}</cite></blockquote>
<blockquote>"{html.escape(str(t2.get("quote", "Excellent service.")))}" <cite>- {html.escape(str(t2.get("name", "Client")))}</cite></blockquote>
<blockquote>"{html.escape(str(t3.get("quote", "Would recommend.")))}" <cite>- {html.escape(str(t3.get("name", "Client")))}</cite></blockquote>
</main>
</body>
</html>
"""
contact_html = f"""<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>{safe_name} | Contact</title>
<link rel="stylesheet" href="styles.css">
</head>
<body>
{nav}
<main>
<h1>Contact Us</h1>
<p>{html.escape(ai_payload["contact_prompt"])}</p>
<p>Phone: {safe_phone}</p>
<p>Address: {safe_address}</p>
</main>
</body>
</html>
"""
styles = """
body{font-family:Arial,sans-serif;margin:0;background:#f6f7fb;color:#1f2937;}
nav{display:flex;gap:1rem;padding:1rem 2rem;background:#111827;}
nav a{color:#fff;text-decoration:none;font-weight:600;}
.hero{padding:4rem 2rem;background:#2563eb;color:white;}
main,section{max-width:900px;margin:2rem auto;padding:0 1rem;}
blockquote{background:#fff;padding:1rem;border-left:4px solid #2563eb;border-radius:6px;}
.tagline{display:inline-block;margin-top:1rem;background:#1d4ed8;padding:.25rem .75rem;border-radius:999px;}
"""
(basic_dir / "index.html").write_text(index_html, encoding="utf-8")
(basic_dir / "about.html").write_text(about_html, encoding="utf-8")
(basic_dir / "testimonials.html").write_text(testimonials_html, encoding="utf-8")
(basic_dir / "contact.html").write_text(contact_html, encoding="utf-8")
(basic_dir / "styles.css").write_text(styles, encoding="utf-8")
basic_zip = root / "basic_site.zip"
self._zip_dir(basic_dir, basic_zip)
return basic_zip
def _copy_client_template(self, source_path: Path, destination: Path) -> None:
def ignore(_directory: str, names: list[str]) -> list[str]:
ignored: list[str] = []
for name in names:
if name in COPY_EXCLUDE_DIR_NAMES:
ignored.append(name)
elif name.endswith(".zip"):
ignored.append(name)
return ignored
shutil.copytree(source_path, destination, ignore=ignore)
def _personalize_template_tree(
self,
target_dir: Path,
site: ClientSite,
ai_payload: dict,
config: dict,
) -> None:
replacements = self._build_replacements(site, ai_payload, config)
for file_path in target_dir.rglob("*"):
if not file_path.is_file():
continue
if file_path.suffix.lower() not in PERSONALIZE_EXTENSIONS:
continue
if "bootstrap-icons" in file_path.parts and file_path.suffix == ".json":
continue
try:
content = file_path.read_text(encoding="utf-8")
except UnicodeDecodeError:
continue
updated = content
for old, new in replacements.items():
if old and new and old in updated:
updated = updated.replace(old, new)
if updated != content:
file_path.write_text(updated, encoding="utf-8")
info_path = target_dir / "client-site-info.json"
info_path.write_text(
json.dumps(
{
"business_name": site.business_name,
"business_address": site.business_address,
"business_phone": site.business_phone,
"business_description": site.business_description,
"old_website": site.old_website,
"ai_payload": ai_payload,
},
indent=2,
),
encoding="utf-8",
)
def _build_replacements(self, site: ClientSite, ai_payload: dict, config: dict) -> dict[str, str]:
replacements: dict[str, str] = {}
business_name = site.business_name
hero_headline = ai_payload.get("hero_headline", business_name)
hero_subheadline = ai_payload.get("hero_subheadline", site.business_description)
for token in config.get("brand_tokens", []):
replacements[token] = business_name
hero_strings = config.get("hero_strings", [])
if hero_strings:
replacements[hero_strings[0]] = hero_headline
for hero_string in hero_strings[1:]:
replacements[hero_string] = hero_subheadline
replacements.update(
{
"555-0100": site.business_phone,
"555-0101": site.business_phone,
"(555) 010-0100": site.business_phone,
"123 Main Street": site.business_address,
"San Francisco, California": site.business_address,
}
)
if site.old_website:
replacements["https://example.com"] = site.old_website
replacements["http://example.com"] = site.old_website
return replacements
def _write_client_readme(
self,
target_dir: Path,
site: ClientSite,
ai_payload: dict,
config: dict,
) -> None:
kind = config.get("kind", "static_html")
run_hint = "Open index.html in a browser."
if kind == "nextjs":
run_hint = "Run: npm install && npm run dev"
elif kind == "static_html":
run_hint = "Open index.html (or another index-*.html page) in a browser."
readme = f"""# {site.business_name} — generated site package
Template: {site.template_option.name}
Type: {kind}
## Business details
- Name: {site.business_name}
- Phone: {site.business_phone}
- Address: {site.business_address}
- Description: {site.business_description}
"""
if site.old_website:
readme += f"- Previous website: {site.old_website}\n"
readme += f"""
## AI-generated copy
- Tagline: {ai_payload.get("tagline", "")}
- Headline: {ai_payload.get("hero_headline", "")}
- Subheadline: {ai_payload.get("hero_subheadline", "")}
## How to preview
{run_hint}
See client-site-info.json for full generation payload.
"""
(target_dir / "README-CLIENT.md").write_text(readme, encoding="utf-8")
def _build_django_demo(self, site: ClientSite, ai_payload: dict, root: Path) -> Path:
project_slug = slugify(site.business_name)[:30] or "client-site"
django_dir = root / "django_demo"
django_dir.mkdir(parents=True, exist_ok=True)
files = {
"manage.py": self._django_manage_py(),
"client_demo/__init__.py": "",
"client_demo/settings.py": self._django_settings_py(site),
"client_demo/urls.py": self._django_urls_py(),
"client_demo/wsgi.py": self._django_wsgi_py(),
"shop/__init__.py": "",
"shop/apps.py": self._shop_apps_py(),
"shop/models.py": self._shop_models_py(),
"shop/forms.py": self._shop_forms_py(),
"shop/urls.py": self._shop_urls_py(),
"shop/views.py": self._shop_views_py(ai_payload),
"shop/admin.py": self._shop_admin_py(),
"shop/migrations/__init__.py": "",
"templates/base.html": self._shop_base_template(site),
"templates/shop/home.html": self._shop_home_template(),
"templates/shop/products.html": self._shop_products_template(),
"templates/shop/cart.html": self._shop_cart_template(),
"templates/shop/contact.html": self._shop_contact_template(),
"README.md": self._django_readme(project_slug),
}
for relative_path, content in files.items():
destination = django_dir / relative_path
destination.parent.mkdir(parents=True, exist_ok=True)
destination.write_text(content, encoding="utf-8")
django_zip = root / "django_demo.zip"
self._zip_dir(django_dir, django_zip)
return django_zip
def _zip_dir(self, directory: Path, output_zip: Path) -> None:
with zipfile.ZipFile(output_zip, "w", zipfile.ZIP_DEFLATED) as zipf:
for file_path in directory.rglob("*"):
if file_path.is_file():
zipf.write(file_path, file_path.relative_to(directory))
def _django_manage_py(self) -> str:
return """#!/usr/bin/env python
import os
import sys
if __name__ == "__main__":
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "client_demo.settings")
from django.core.management import execute_from_command_line
execute_from_command_line(sys.argv)
"""
def _django_settings_py(self, site: ClientSite) -> str:
return f"""from pathlib import Path
BASE_DIR = Path(__file__).resolve().parent.parent
SECRET_KEY = "replace-me"
DEBUG = True
ALLOWED_HOSTS = ["*"]
INSTALLED_APPS = [
"django.contrib.admin",
"django.contrib.auth",
"django.contrib.contenttypes",
"django.contrib.sessions",
"django.contrib.messages",
"django.contrib.staticfiles",
"shop",
]
MIDDLEWARE = [
"django.middleware.security.SecurityMiddleware",
"django.contrib.sessions.middleware.SessionMiddleware",
"django.middleware.common.CommonMiddleware",
"django.middleware.csrf.CsrfViewMiddleware",
"django.contrib.auth.middleware.AuthenticationMiddleware",
"django.contrib.messages.middleware.MessageMiddleware",
"django.middleware.clickjacking.XFrameOptionsMiddleware",
]
ROOT_URLCONF = "client_demo.urls"
TEMPLATES = [{{
"BACKEND": "django.template.backends.django.DjangoTemplates",
"DIRS": [BASE_DIR / "templates"],
"APP_DIRS": True,
"OPTIONS": {{"context_processors": [
"django.template.context_processors.request",
"django.contrib.auth.context_processors.auth",
"django.contrib.messages.context_processors.messages",
]}},
}}]
WSGI_APPLICATION = "client_demo.wsgi.application"
DATABASES = {{"default": {{"ENGINE": "django.db.backends.sqlite3", "NAME": BASE_DIR / "db.sqlite3"}}}}
LANGUAGE_CODE = "en-us"
TIME_ZONE = "UTC"
USE_I18N = True
USE_TZ = True
STATIC_URL = "static/"
DEFAULT_AUTO_FIELD = "django.db.models.BigAutoField"
EMAIL_BACKEND = "django.core.mail.backends.console.EmailBackend"
DEFAULT_FROM_EMAIL = "no-reply@example.com"
BUSINESS_NAME = {site.business_name!r}
BUSINESS_PHONE = {site.business_phone!r}
BUSINESS_ADDRESS = {site.business_address!r}
"""
def _django_urls_py(self) -> str:
return """from django.contrib import admin
from django.urls import include, path
urlpatterns = [
path("admin/", admin.site.urls),
path("", include("shop.urls")),
]
"""
def _django_wsgi_py(self) -> str:
return """import os
from django.core.wsgi import get_wsgi_application
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "client_demo.settings")
application = get_wsgi_application()
"""
def _shop_apps_py(self) -> str:
return """from django.apps import AppConfig
class ShopConfig(AppConfig):
default_auto_field = "django.db.models.BigAutoField"
name = "shop"
"""
def _shop_models_py(self) -> str:
return """from django.db import models
class Product(models.Model):
name = models.CharField(max_length=120)
description = models.TextField(blank=True)
price = models.DecimalField(max_digits=10, decimal_places=2)
is_active = models.BooleanField(default=True)
def __str__(self):
return self.name
"""
def _shop_forms_py(self) -> str:
return """from django import forms
class ContactForm(forms.Form):
name = forms.CharField(max_length=100)
email = forms.EmailField()
message = forms.CharField(widget=forms.Textarea(attrs={"rows": 5}))
"""
def _shop_urls_py(self) -> str:
return """from django.urls import path
from . import views
urlpatterns = [
path("", views.home, name="home"),
path("products/", views.products, name="products"),
path("cart/add/<int:product_id>/", views.add_to_cart, name="add_to_cart"),
path("cart/", views.cart, name="cart"),
path("contact/", views.contact, name="contact"),
]
"""
def _shop_views_py(self, ai_payload: dict) -> str:
headline = ai_payload.get("hero_headline", "Welcome")
subheadline = ai_payload.get("hero_subheadline", "")
return f"""from django.conf import settings
from django.contrib import messages
from django.core.mail import send_mail
from django.shortcuts import get_object_or_404, redirect, render
from .forms import ContactForm
from .models import Product
def home(request):
return render(request, "shop/home.html", {{
"headline": {headline!r},
"subheadline": {subheadline!r},
}})
def products(request):
return render(request, "shop/products.html", {{"products": Product.objects.filter(is_active=True)}})
def add_to_cart(request, product_id):
product = get_object_or_404(Product, id=product_id, is_active=True)
cart_data = request.session.get("cart", {{}})
key = str(product.id)
cart_data[key] = cart_data.get(key, 0) + 1
request.session["cart"] = cart_data
messages.success(request, f"Added {{product.name}} to cart.")
return redirect("products")
def cart(request):
cart_data = request.session.get("cart", {{}})
items = []
total = 0
for product_id, qty in cart_data.items():
product = Product.objects.filter(id=product_id).first()
if not product:
continue
line_total = product.price * qty
items.append({{"product": product, "qty": qty, "line_total": line_total}})
total += line_total
return render(request, "shop/cart.html", {{"items": items, "total": total}})
def contact(request):
if request.method == "POST":
form = ContactForm(request.POST)
if form.is_valid():
subject = f"Contact request for {{settings.BUSINESS_NAME}}"
body = (
f"Name: {{form.cleaned_data['name']}}\\n"
f"Email: {{form.cleaned_data['email']}}\\n\\n"
f"Message:\\n{{form.cleaned_data['message']}}"
)
send_mail(subject, body, settings.DEFAULT_FROM_EMAIL, [settings.DEFAULT_FROM_EMAIL])
messages.success(request, "Message sent.")
return redirect("contact")
else:
form = ContactForm()
return render(request, "shop/contact.html", {{"form": form}})
"""
def _shop_admin_py(self) -> str:
return """from django.contrib import admin
from .models import Product
admin.site.register(Product)
"""
def _shop_base_template(self, site: ClientSite) -> str:
return f"""<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>{html.escape(site.business_name)} Demo</title>
<style>
body{{font-family:Arial,sans-serif;margin:0;background:#f5f6fb;color:#1f2937}}
nav{{background:#111827;padding:1rem;display:flex;gap:1rem}}
nav a{{color:white;text-decoration:none}}
.wrap{{max-width:960px;margin:0 auto;padding:2rem 1rem}}
.card{{background:white;padding:1rem;border-radius:8px;margin-bottom:1rem}}
.btn{{display:inline-block;background:#2563eb;color:white;padding:.5rem .75rem;border-radius:6px;text-decoration:none;border:none}}
</style>
</head>
<body>
<nav>
<a href="/">Home</a>
<a href="/products/">Products</a>
<a href="/cart/">Cart</a>
<a href="/contact/">Contact</a>
</nav>
<div class="wrap">
{{% if messages %}}
{{% for message in messages %}}<p class="card">{{{{ message }}}}</p>{{% endfor %}}
{{% endif %}}
{{% block content %}}{{% endblock %}}
</div>
</body>
</html>
"""
def _shop_home_template(self) -> str:
return """{% extends "base.html" %}
{% block content %}
<section class="card">
<h1>{{ headline }}</h1>
<p>{{ subheadline }}</p>
<a class="btn" href="/products/">Shop now</a>
</section>
{% endblock %}
"""
def _shop_products_template(self) -> str:
return """{% extends "base.html" %}
{% block content %}
<h1>Products</h1>
{% for product in products %}
<article class="card">
<h3>{{ product.name }}</h3>
<p>{{ product.description }}</p>
<p><strong>${{ product.price }}</strong></p>
<a class="btn" href="/cart/add/{{ product.id }}/">Add to cart</a>
</article>
{% empty %}
<p class="card">No products yet. Add from admin panel.</p>
{% endfor %}
{% endblock %}
"""
def _shop_cart_template(self) -> str:
return """{% extends "base.html" %}
{% block content %}
<h1>Your Cart</h1>
{% for item in items %}
<div class="card">
<strong>{{ item.product.name }}</strong> x {{ item.qty }} = ${{ item.line_total }}
</div>
{% empty %}
<p class="card">Cart empty.</p>
{% endfor %}
<p><strong>Total: ${{ total }}</strong></p>
{% endblock %}
"""
def _shop_contact_template(self) -> str:
return """{% extends "base.html" %}
{% block content %}
<h1>Contact</h1>
<form method="post" class="card">
{% csrf_token %}
{{ form.as_p }}
<button class="btn" type="submit">Send message</button>
</form>
{% endblock %}
"""
def _django_readme(self, project_slug: str) -> str:
return f"""# {project_slug}
Generated Django demo storefront.
## Run
1. pip install django
2. python manage.py migrate
3. python manage.py createsuperuser
4. python manage.py runserver
## Basic static site
Download the basic HTML package from the builder dashboard for the matching template design.
"""