feat(hosting): add HostWizard platform module and fix migration chain
Some checks failed
CI / pytest (push) Failing after 49m20s
CI / validate (push) Successful in 24s
CI / dependency-scanning (push) Successful in 33s
CI / docs (push) Has been skipped
CI / deploy (push) Has been skipped
CI / ruff (push) Successful in 10s

- Add complete hosting module (models, routes, schemas, services, templates, migrations)
- Add HostWizard platform to init_production seed (code=hosting, domain=hostwizard.lu)
- Fix cms_002 migration down_revision to z_unique_subdomain_domain
- Fix prospecting_001 migration to chain after cms_002 (remove branch label)
- Add hosting/prospecting version_locations to alembic.ini
- Fix admin_services delete endpoint to use proper response model
- Add hostwizard.lu to deployment docs (DNS, Caddy, Cloudflare)
- Add hosting and prospecting user journey docs to mkdocs nav

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-03 19:34:56 +01:00
parent 784bcb9d23
commit 8b147f53c6
46 changed files with 3907 additions and 13 deletions

View File

@@ -1,7 +1,7 @@
"""add title_translations and content_translations to content_pages
Revision ID: cms_002
Revises: cms_001
Revises: z_unique_subdomain_domain
Create Date: 2026-03-02
"""
@@ -10,7 +10,7 @@ import sqlalchemy as sa
from alembic import op
revision = "cms_002"
down_revision = "cms_001"
down_revision = "z_unique_subdomain_domain"
branch_labels = None
depends_on = None

View File

@@ -0,0 +1,36 @@
# app/modules/hosting/__init__.py
"""
Hosting Module - Web hosting, domains, email, and website building.
This is a self-contained module providing:
- POC website lifecycle management (draft → live pipeline)
- Client service tracking (domains, email, SSL, hosting, maintenance)
- Integration with prospecting module for prospect → merchant conversion
- Dashboard with KPIs, status charts, and renewal alerts
Module Structure:
- models/ - Database models (hosted_site, client_service)
- services/ - Business logic (lifecycle, service management, stats)
- schemas/ - Pydantic DTOs
- routes/ - API and page routes (admin + public POC viewer)
- tasks/ - Celery background tasks for expiry checks
- exceptions.py - Module-specific exceptions
"""
def __getattr__(name: str):
"""Lazy import module components to avoid circular imports."""
if name == "hosting_module":
from app.modules.hosting.definition import hosting_module
return hosting_module
if name == "get_hosting_module_with_routers":
from app.modules.hosting.definition import (
get_hosting_module_with_routers,
)
return get_hosting_module_with_routers
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
__all__ = ["hosting_module", "get_hosting_module_with_routers"]

View File

@@ -0,0 +1,31 @@
# app/modules/hosting/config.py
"""
Module configuration.
Environment-based configuration using Pydantic Settings.
Settings are loaded from environment variables with HOSTING_ prefix.
Example:
HOSTING_DEFAULT_BILLING_PERIOD=annual
"""
from pydantic_settings import BaseSettings
class ModuleConfig(BaseSettings):
"""Configuration for hosting module."""
# Default billing period for new services
default_billing_period: str = "monthly"
# Days before expiry to trigger renewal alerts
renewal_alert_days: int = 30
# Default currency for pricing
default_currency: str = "EUR"
model_config = {"env_prefix": "HOSTING_"}
# Export for auto-discovery
config_class = ModuleConfig
config = ModuleConfig()

View File

@@ -0,0 +1,132 @@
# app/modules/hosting/definition.py
"""
Hosting module definition.
Web hosting, domains, email, and website building for Luxembourg businesses.
Manages POC → live website pipeline and operational service tracking.
Admin-only module for superadmin and platform admin users.
"""
from app.modules.base import (
MenuItemDefinition,
MenuSectionDefinition,
ModuleDefinition,
PermissionDefinition,
)
from app.modules.enums import FrontendType
def _get_admin_api_router():
"""Lazy import of admin API router to avoid circular imports."""
from app.modules.hosting.routes.api.admin import router
return router
def _get_admin_page_router():
"""Lazy import of admin page router to avoid circular imports."""
from app.modules.hosting.routes.pages.admin import router
return router
def _get_public_page_router():
"""Lazy import of public page router to avoid circular imports."""
from app.modules.hosting.routes.pages.public import router
return router
hosting_module = ModuleDefinition(
code="hosting",
name="Hosting",
description="Web hosting, domains, email, and website building for Luxembourg businesses.",
version="1.0.0",
is_core=False,
is_self_contained=True,
requires=["prospecting"],
permissions=[
PermissionDefinition(
id="hosting.view",
label_key="hosting.permissions.view",
description_key="hosting.permissions.view_desc",
category="hosting",
),
PermissionDefinition(
id="hosting.manage",
label_key="hosting.permissions.manage",
description_key="hosting.permissions.manage_desc",
category="hosting",
),
],
features=[
"hosting",
"domains",
"email",
"ssl",
"poc_sites",
],
menu_items={
FrontendType.ADMIN: [
"hosting-dashboard",
"hosting-sites",
"hosting-clients",
],
},
menus={
FrontendType.ADMIN: [
MenuSectionDefinition(
id="hosting",
label_key="hosting.menu.hosting",
icon="globe",
order=65,
items=[
MenuItemDefinition(
id="hosting-dashboard",
label_key="hosting.menu.dashboard",
icon="chart-bar",
route="/admin/hosting",
order=1,
),
MenuItemDefinition(
id="hosting-sites",
label_key="hosting.menu.sites",
icon="globe",
route="/admin/hosting/sites",
order=5,
),
MenuItemDefinition(
id="hosting-clients",
label_key="hosting.menu.clients",
icon="server",
route="/admin/hosting/clients",
order=10,
),
],
),
],
},
migrations_path="migrations",
services_path="app.modules.hosting.services",
models_path="app.modules.hosting.models",
schemas_path="app.modules.hosting.schemas",
exceptions_path="app.modules.hosting.exceptions",
templates_path="templates",
locales_path="locales",
tasks_path="app.modules.hosting.tasks",
)
def get_hosting_module_with_routers() -> ModuleDefinition:
"""
Get hosting module with routers attached.
Attaches routers lazily to avoid circular imports during module initialization.
"""
hosting_module.admin_api_router = _get_admin_api_router()
hosting_module.admin_page_router = _get_admin_page_router()
hosting_module.public_page_router = _get_public_page_router()
return hosting_module
__all__ = ["hosting_module", "get_hosting_module_with_routers"]

View File

@@ -0,0 +1,57 @@
# app/modules/hosting/exceptions.py
"""
Hosting module exceptions.
"""
from app.exceptions.base import (
BusinessLogicException,
ResourceNotFoundException,
)
class HostedSiteNotFoundException(ResourceNotFoundException): # noqa: MOD025
"""Raised when a hosted site is not found."""
def __init__(self, identifier: str):
super().__init__(
resource_type="HostedSite",
identifier=identifier,
)
class ClientServiceNotFoundException(ResourceNotFoundException): # noqa: MOD025
"""Raised when a client service is not found."""
def __init__(self, identifier: str):
super().__init__(
resource_type="ClientService",
identifier=identifier,
)
class DuplicateSlugException(BusinessLogicException): # noqa: MOD025
"""Raised when trying to create a site with a slug that already exists."""
def __init__(self, slug: str):
super().__init__(
message=f"A hosted site with slug '{slug}' already exists",
error_code="DUPLICATE_SLUG",
)
class InvalidStatusTransitionException(BusinessLogicException): # noqa: MOD025
"""Raised when attempting an invalid status transition."""
def __init__(self, current_status: str, target_status: str):
super().__init__(
message=f"Cannot transition from '{current_status}' to '{target_status}'",
error_code="INVALID_STATUS_TRANSITION",
)
__all__ = [
"HostedSiteNotFoundException",
"ClientServiceNotFoundException",
"DuplicateSlugException",
"InvalidStatusTransitionException",
]

View File

@@ -0,0 +1,22 @@
{
"hosting": {
"page_title": "Hosting",
"dashboard_title": "Hosting Dashboard",
"sites": "Webseiten",
"clients": "Kundendienste",
"loading": "Laden...",
"error_loading": "Fehler beim Laden der Daten"
},
"permissions": {
"view": "Hosting anzeigen",
"view_desc": "Zugriff auf gehostete Webseiten, Dienste und Statistiken",
"manage": "Hosting verwalten",
"manage_desc": "Gehostete Webseiten und Dienste erstellen, bearbeiten und verwalten"
},
"menu": {
"hosting": "Hosting",
"dashboard": "Dashboard",
"sites": "Webseiten",
"clients": "Kundendienste"
}
}

View File

@@ -0,0 +1,22 @@
{
"hosting": {
"page_title": "Hosting",
"dashboard_title": "Hosting Dashboard",
"sites": "Sites",
"clients": "Client Services",
"loading": "Loading...",
"error_loading": "Failed to load data"
},
"permissions": {
"view": "View Hosting",
"view_desc": "View hosted sites, services, and statistics",
"manage": "Manage Hosting",
"manage_desc": "Create, edit, and manage hosted sites and services"
},
"menu": {
"hosting": "Hosting",
"dashboard": "Dashboard",
"sites": "Sites",
"clients": "Client Services"
}
}

View File

@@ -0,0 +1,22 @@
{
"hosting": {
"page_title": "Hébergement",
"dashboard_title": "Tableau de bord Hébergement",
"sites": "Sites",
"clients": "Services clients",
"loading": "Chargement...",
"error_loading": "Erreur lors du chargement des données"
},
"permissions": {
"view": "Voir l'hébergement",
"view_desc": "Voir les sites hébergés, services et statistiques",
"manage": "Gérer l'hébergement",
"manage_desc": "Créer, modifier et gérer les sites hébergés et services"
},
"menu": {
"hosting": "Hébergement",
"dashboard": "Tableau de bord",
"sites": "Sites",
"clients": "Services clients"
}
}

View File

@@ -0,0 +1,22 @@
{
"hosting": {
"page_title": "Hosting",
"dashboard_title": "Hosting Dashboard",
"sites": "Websäiten",
"clients": "Client Servicer",
"loading": "Lueden...",
"error_loading": "Feeler beim Luede vun den Donnéeën"
},
"permissions": {
"view": "Hosting kucken",
"view_desc": "Zougang zu gehosten Websäiten, Servicer a Statistiken",
"manage": "Hosting geréieren",
"manage_desc": "Gehost Websäiten a Servicer erstellen, änneren a geréieren"
},
"menu": {
"hosting": "Hosting",
"dashboard": "Dashboard",
"sites": "Websäiten",
"clients": "Client Servicer"
}
}

View File

@@ -0,0 +1,125 @@
"""hosting: initial tables for hosted sites and client services
Revision ID: hosting_001
Revises: prospecting_001
Create Date: 2026-03-03
"""
import sqlalchemy as sa
from alembic import op
revision = "hosting_001"
down_revision = "prospecting_001"
branch_labels = None
depends_on = None
def upgrade() -> None:
# --- hosted_sites ---
op.create_table(
"hosted_sites",
sa.Column("id", sa.Integer(), primary_key=True, index=True),
sa.Column(
"store_id",
sa.Integer(),
sa.ForeignKey("stores.id", ondelete="CASCADE"),
nullable=False,
unique=True,
),
sa.Column(
"prospect_id",
sa.Integer(),
sa.ForeignKey("prospects.id", ondelete="SET NULL"),
nullable=True,
),
sa.Column(
"status",
sa.String(20),
nullable=False,
server_default="draft",
),
sa.Column("business_name", sa.String(255), nullable=False),
sa.Column("contact_name", sa.String(255), nullable=True),
sa.Column("contact_email", sa.String(255), nullable=True),
sa.Column("contact_phone", sa.String(50), nullable=True),
sa.Column("proposal_sent_at", sa.DateTime(), nullable=True),
sa.Column("proposal_accepted_at", sa.DateTime(), nullable=True),
sa.Column("went_live_at", sa.DateTime(), nullable=True),
sa.Column("proposal_notes", sa.Text(), nullable=True),
sa.Column("live_domain", sa.String(255), nullable=True, unique=True),
sa.Column("internal_notes", sa.Text(), nullable=True),
sa.Column(
"created_at",
sa.DateTime(),
server_default=sa.func.now(),
nullable=False,
),
sa.Column(
"updated_at",
sa.DateTime(),
server_default=sa.func.now(),
nullable=False,
),
)
op.create_index("ix_hosted_sites_status", "hosted_sites", ["status"])
op.create_index("ix_hosted_sites_prospect_id", "hosted_sites", ["prospect_id"])
# --- client_services ---
op.create_table(
"client_services",
sa.Column("id", sa.Integer(), primary_key=True, index=True),
sa.Column(
"hosted_site_id",
sa.Integer(),
sa.ForeignKey("hosted_sites.id", ondelete="CASCADE"),
nullable=False,
index=True,
),
sa.Column("service_type", sa.String(30), nullable=False),
sa.Column("name", sa.String(255), nullable=False),
sa.Column("description", sa.Text(), nullable=True),
sa.Column(
"status",
sa.String(20),
nullable=False,
server_default="pending",
),
sa.Column("billing_period", sa.String(20), nullable=True),
sa.Column("price_cents", sa.Integer(), nullable=True),
sa.Column("currency", sa.String(3), nullable=False, server_default="EUR"),
sa.Column(
"addon_product_id",
sa.Integer(),
sa.ForeignKey("addon_products.id", ondelete="SET NULL"),
nullable=True,
),
sa.Column("domain_name", sa.String(255), nullable=True),
sa.Column("registrar", sa.String(100), nullable=True),
sa.Column("mailbox_count", sa.Integer(), nullable=True),
sa.Column("expires_at", sa.DateTime(), nullable=True),
sa.Column("period_start", sa.DateTime(), nullable=True),
sa.Column("period_end", sa.DateTime(), nullable=True),
sa.Column("auto_renew", sa.Boolean(), nullable=False, server_default="true"),
sa.Column("notes", sa.Text(), nullable=True),
sa.Column(
"created_at",
sa.DateTime(),
server_default=sa.func.now(),
nullable=False,
),
sa.Column(
"updated_at",
sa.DateTime(),
server_default=sa.func.now(),
nullable=False,
),
)
op.create_index("ix_client_services_service_type", "client_services", ["service_type"])
op.create_index("ix_client_services_status", "client_services", ["status"])
op.create_index("ix_client_services_expires_at", "client_services", ["expires_at"])
def downgrade() -> None:
op.drop_table("client_services")
op.drop_table("hosted_sites")

View File

@@ -0,0 +1,20 @@
# app/modules/hosting/models/__init__.py
from app.modules.hosting.models.client_service import (
BillingPeriod,
ClientService,
ClientServiceStatus,
ServiceType,
)
from app.modules.hosting.models.hosted_site import (
HostedSite,
HostedSiteStatus,
)
__all__ = [
"HostedSite",
"HostedSiteStatus",
"ClientService",
"ServiceType",
"ClientServiceStatus",
"BillingPeriod",
]

View File

@@ -0,0 +1,97 @@
# app/modules/hosting/models/client_service.py
"""
ClientService model - operational tracking for services.
Complements billing StoreAddOn with operational details like domain expiry,
registrar info, and mailbox counts.
"""
import enum
from sqlalchemy import (
Boolean,
Column,
DateTime,
Enum,
ForeignKey,
Integer,
String,
Text,
)
from sqlalchemy.orm import relationship
from app.core.database import Base
from models.database.base import TimestampMixin
class ServiceType(str, enum.Enum):
DOMAIN = "domain"
EMAIL = "email"
SSL = "ssl"
HOSTING = "hosting"
WEBSITE_MAINTENANCE = "website_maintenance"
class ClientServiceStatus(str, enum.Enum):
PENDING = "pending"
ACTIVE = "active"
SUSPENDED = "suspended"
EXPIRED = "expired"
CANCELLED = "cancelled"
class BillingPeriod(str, enum.Enum):
MONTHLY = "monthly"
ANNUAL = "annual"
ONE_TIME = "one_time"
class ClientService(Base, TimestampMixin):
"""Represents an operational service for a hosted site."""
__tablename__ = "client_services"
id = Column(Integer, primary_key=True, index=True)
hosted_site_id = Column(
Integer,
ForeignKey("hosted_sites.id", ondelete="CASCADE"),
nullable=False,
index=True,
)
service_type = Column(Enum(ServiceType), nullable=False)
name = Column(String(255), nullable=False)
description = Column(Text, nullable=True)
status = Column(
Enum(ClientServiceStatus),
nullable=False,
default=ClientServiceStatus.PENDING,
)
# Billing
billing_period = Column(Enum(BillingPeriod), nullable=True)
price_cents = Column(Integer, nullable=True)
currency = Column(String(3), nullable=False, default="EUR")
addon_product_id = Column(
Integer,
ForeignKey("addon_products.id", ondelete="SET NULL"),
nullable=True,
)
# Domain-specific
domain_name = Column(String(255), nullable=True)
registrar = Column(String(100), nullable=True)
# Email-specific
mailbox_count = Column(Integer, nullable=True)
# Expiry and renewal
expires_at = Column(DateTime, nullable=True)
period_start = Column(DateTime, nullable=True)
period_end = Column(DateTime, nullable=True)
auto_renew = Column(Boolean, nullable=False, default=True)
# Notes
notes = Column(Text, nullable=True)
# Relationships
hosted_site = relationship("HostedSite", back_populates="client_services")

View File

@@ -0,0 +1,81 @@
# app/modules/hosting/models/hosted_site.py
"""
HostedSite model - links a Store to a Prospect and tracks the POC → live lifecycle.
Lifecycle: draft → poc_ready → proposal_sent → accepted → live → suspended | cancelled
"""
import enum
from sqlalchemy import Column, DateTime, Enum, ForeignKey, Integer, String, Text
from sqlalchemy.orm import relationship
from app.core.database import Base
from models.database.base import TimestampMixin
class HostedSiteStatus(str, enum.Enum):
DRAFT = "draft"
POC_READY = "poc_ready"
PROPOSAL_SENT = "proposal_sent"
ACCEPTED = "accepted"
LIVE = "live"
SUSPENDED = "suspended"
CANCELLED = "cancelled"
class HostedSite(Base, TimestampMixin):
"""Represents a hosted website linking a Store to a Prospect."""
__tablename__ = "hosted_sites"
id = Column(Integer, primary_key=True, index=True)
store_id = Column(
Integer,
ForeignKey("stores.id", ondelete="CASCADE"),
nullable=False,
unique=True,
)
prospect_id = Column(
Integer,
ForeignKey("prospects.id", ondelete="SET NULL"),
nullable=True,
)
status = Column(
Enum(HostedSiteStatus),
nullable=False,
default=HostedSiteStatus.DRAFT,
)
# Denormalized for dashboard display
business_name = Column(String(255), nullable=False)
contact_name = Column(String(255), nullable=True)
contact_email = Column(String(255), nullable=True)
contact_phone = Column(String(50), nullable=True)
# Lifecycle timestamps
proposal_sent_at = Column(DateTime, nullable=True)
proposal_accepted_at = Column(DateTime, nullable=True)
went_live_at = Column(DateTime, nullable=True)
# Proposal
proposal_notes = Column(Text, nullable=True)
# Denormalized from StoreDomain
live_domain = Column(String(255), nullable=True, unique=True)
# Internal notes
internal_notes = Column(Text, nullable=True)
# Relationships
store = relationship("Store", backref="hosted_site", uselist=False)
prospect = relationship("Prospect", backref="hosted_sites")
client_services = relationship(
"ClientService",
back_populates="hosted_site",
cascade="all, delete-orphan",
)
@property
def display_name(self) -> str:
return self.business_name or f"Site #{self.id}"

View File

@@ -0,0 +1 @@
# app/modules/hosting/routes/__init__.py

View File

@@ -0,0 +1 @@
# app/modules/hosting/routes/api/__init__.py

View File

@@ -0,0 +1,21 @@
# app/modules/hosting/routes/api/admin.py
"""
Hosting module admin API routes.
Aggregates all admin hosting routes:
- /sites/* - Hosted site CRUD and lifecycle
- /sites/{id}/services/* - Client service management
- /stats/* - Dashboard statistics
"""
from fastapi import APIRouter
from .admin_services import router as admin_services_router
from .admin_sites import router as admin_sites_router
from .admin_stats import router as admin_stats_router
router = APIRouter()
router.include_router(admin_sites_router, tags=["hosting-sites"])
router.include_router(admin_services_router, tags=["hosting-services"])
router.include_router(admin_stats_router, tags=["hosting-stats"])

View File

@@ -12,6 +12,7 @@ from app.api.deps import get_current_admin_api
from app.core.database import get_db
from app.modules.hosting.schemas.client_service import (
ClientServiceCreate,
ClientServiceDeleteResponse,
ClientServiceResponse,
ClientServiceUpdate,
)
@@ -59,7 +60,7 @@ def update_service(
return service
@router.delete("/{service_id}")
@router.delete("/{service_id}", response_model=ClientServiceDeleteResponse)
def delete_service(
site_id: int = Path(...),
service_id: int = Path(...),
@@ -69,4 +70,4 @@ def delete_service(
"""Delete a client service."""
client_service_service.delete(db, service_id)
db.commit()
return {"message": "Service deleted"} # noqa: API001
return ClientServiceDeleteResponse(message="Service deleted")

View File

@@ -0,0 +1,210 @@
# app/modules/hosting/routes/api/admin_sites.py
"""
Admin API routes for hosted site management.
"""
import logging
from math import ceil
from fastapi import APIRouter, Depends, Path, Query
from sqlalchemy.orm import Session
from app.api.deps import get_current_admin_api
from app.core.database import get_db
from app.modules.hosting.schemas.hosted_site import (
AcceptProposalRequest,
GoLiveRequest,
HostedSiteCreate,
HostedSiteDeleteResponse,
HostedSiteDetailResponse,
HostedSiteListResponse,
HostedSiteResponse,
HostedSiteUpdate,
SendProposalRequest,
)
from app.modules.hosting.services.hosted_site_service import hosted_site_service
from app.modules.tenancy.schemas.auth import UserContext
router = APIRouter(prefix="/sites")
logger = logging.getLogger(__name__)
def _to_response(site) -> HostedSiteResponse:
"""Convert a hosted site model to response schema."""
return HostedSiteResponse(
id=site.id,
store_id=site.store_id,
prospect_id=site.prospect_id,
status=site.status.value if hasattr(site.status, "value") else str(site.status),
business_name=site.business_name,
contact_name=site.contact_name,
contact_email=site.contact_email,
contact_phone=site.contact_phone,
proposal_sent_at=site.proposal_sent_at,
proposal_accepted_at=site.proposal_accepted_at,
went_live_at=site.went_live_at,
proposal_notes=site.proposal_notes,
live_domain=site.live_domain,
internal_notes=site.internal_notes,
created_at=site.created_at,
updated_at=site.updated_at,
)
@router.get("", response_model=HostedSiteListResponse)
def list_sites(
page: int = Query(1, ge=1),
per_page: int = Query(20, ge=1, le=100),
search: str | None = Query(None),
status: str | None = Query(None),
db: Session = Depends(get_db),
current_admin: UserContext = Depends(get_current_admin_api),
):
"""List hosted sites with filters and pagination."""
sites, total = hosted_site_service.get_all(
db, page=page, per_page=per_page, search=search, status=status,
)
return HostedSiteListResponse(
items=[_to_response(s) for s in sites],
total=total,
page=page,
per_page=per_page,
pages=ceil(total / per_page) if per_page else 0,
)
@router.get("/{site_id}", response_model=HostedSiteDetailResponse)
def get_site(
site_id: int = Path(...),
db: Session = Depends(get_db),
current_admin: UserContext = Depends(get_current_admin_api),
):
"""Get full hosted site detail with services."""
site = hosted_site_service.get_by_id(db, site_id)
return site
@router.post("", response_model=HostedSiteResponse)
def create_site(
data: HostedSiteCreate,
db: Session = Depends(get_db),
current_admin: UserContext = Depends(get_current_admin_api),
):
"""Create a new hosted site (auto-creates Store)."""
site = hosted_site_service.create(db, data.model_dump(exclude_none=True))
db.commit()
return _to_response(site)
@router.post("/from-prospect/{prospect_id}", response_model=HostedSiteResponse)
def create_from_prospect(
prospect_id: int = Path(...),
db: Session = Depends(get_db),
current_admin: UserContext = Depends(get_current_admin_api),
):
"""Create a hosted site pre-filled from prospect data."""
site = hosted_site_service.create_from_prospect(db, prospect_id)
db.commit()
return _to_response(site)
@router.put("/{site_id}", response_model=HostedSiteResponse)
def update_site(
data: HostedSiteUpdate,
site_id: int = Path(...),
db: Session = Depends(get_db),
current_admin: UserContext = Depends(get_current_admin_api),
):
"""Update a hosted site."""
site = hosted_site_service.update(db, site_id, data.model_dump(exclude_none=True))
db.commit()
return _to_response(site)
@router.delete("/{site_id}", response_model=HostedSiteDeleteResponse)
def delete_site(
site_id: int = Path(...),
db: Session = Depends(get_db),
current_admin: UserContext = Depends(get_current_admin_api),
):
"""Delete a hosted site."""
hosted_site_service.delete(db, site_id)
db.commit()
return HostedSiteDeleteResponse(message="Hosted site deleted")
# ── Lifecycle endpoints ────────────────────────────────────────────────
@router.post("/{site_id}/mark-poc-ready", response_model=HostedSiteResponse)
def mark_poc_ready(
site_id: int = Path(...),
db: Session = Depends(get_db),
current_admin: UserContext = Depends(get_current_admin_api),
):
"""Mark site as POC ready."""
site = hosted_site_service.mark_poc_ready(db, site_id)
db.commit()
return _to_response(site)
@router.post("/{site_id}/send-proposal", response_model=HostedSiteResponse)
def send_proposal(
data: SendProposalRequest,
site_id: int = Path(...),
db: Session = Depends(get_db),
current_admin: UserContext = Depends(get_current_admin_api),
):
"""Send proposal to prospect."""
site = hosted_site_service.send_proposal(db, site_id, notes=data.notes)
db.commit()
return _to_response(site)
@router.post("/{site_id}/accept", response_model=HostedSiteResponse)
def accept_proposal(
data: AcceptProposalRequest,
site_id: int = Path(...),
db: Session = Depends(get_db),
current_admin: UserContext = Depends(get_current_admin_api),
):
"""Accept proposal: create/link merchant, create subscription."""
site = hosted_site_service.accept_proposal(db, site_id, merchant_id=data.merchant_id)
db.commit()
return _to_response(site)
@router.post("/{site_id}/go-live", response_model=HostedSiteResponse)
def go_live(
data: GoLiveRequest,
site_id: int = Path(...),
db: Session = Depends(get_db),
current_admin: UserContext = Depends(get_current_admin_api),
):
"""Go live with a domain."""
site = hosted_site_service.go_live(db, site_id, domain=data.domain)
db.commit()
return _to_response(site)
@router.post("/{site_id}/suspend", response_model=HostedSiteResponse)
def suspend_site(
site_id: int = Path(...),
db: Session = Depends(get_db),
current_admin: UserContext = Depends(get_current_admin_api),
):
"""Suspend a site."""
site = hosted_site_service.suspend(db, site_id)
db.commit()
return _to_response(site)
@router.post("/{site_id}/cancel", response_model=HostedSiteResponse)
def cancel_site(
site_id: int = Path(...),
db: Session = Depends(get_db),
current_admin: UserContext = Depends(get_current_admin_api),
):
"""Cancel a site."""
site = hosted_site_service.cancel(db, site_id)
db.commit()
return _to_response(site)

View File

@@ -0,0 +1,26 @@
# app/modules/hosting/routes/api/admin_stats.py
"""
Admin API routes for hosting dashboard statistics.
"""
import logging
from fastapi import APIRouter, Depends
from sqlalchemy.orm import Session
from app.api.deps import get_current_admin_api
from app.core.database import get_db
from app.modules.hosting.services.stats_service import stats_service
from app.modules.tenancy.schemas.auth import UserContext
router = APIRouter(prefix="/stats")
logger = logging.getLogger(__name__)
@router.get("/dashboard")
def get_dashboard_stats(
db: Session = Depends(get_db),
current_admin: UserContext = Depends(get_current_admin_api),
):
"""Get dashboard statistics for the hosting module."""
return stats_service.get_dashboard_stats(db)

View File

@@ -0,0 +1 @@
# app/modules/hosting/routes/pages/__init__.py

View File

@@ -0,0 +1,95 @@
# app/modules/hosting/routes/pages/admin.py
"""
Hosting Admin Page Routes (HTML rendering).
Admin pages for hosted site management:
- Dashboard - Overview with KPIs and charts
- Sites - Hosted site list with filters
- Site Detail - Single site view with tabs (overview, services, store link)
- Site New - Create new hosted site form
- Clients - All services overview with expiry warnings
"""
from fastapi import APIRouter, Depends, Path, Request
from fastapi.responses import HTMLResponse
from sqlalchemy.orm import Session
from app.api.deps import get_db, require_menu_access
from app.modules.core.utils.page_context import get_admin_context
from app.modules.enums import FrontendType
from app.modules.tenancy.models import User
from app.templates_config import templates
router = APIRouter()
@router.get("/hosting", response_class=HTMLResponse, include_in_schema=False)
async def admin_hosting_dashboard(
request: Request,
current_user: User = Depends(require_menu_access("hosting-dashboard", FrontendType.ADMIN)),
db: Session = Depends(get_db),
):
"""Render hosting dashboard page."""
return templates.TemplateResponse(
"hosting/admin/dashboard.html",
get_admin_context(request, db, current_user),
)
@router.get("/hosting/sites", response_class=HTMLResponse, include_in_schema=False)
async def admin_hosting_sites(
request: Request,
current_user: User = Depends(require_menu_access("hosting-sites", FrontendType.ADMIN)),
db: Session = Depends(get_db),
):
"""Render hosted sites list page."""
return templates.TemplateResponse(
"hosting/admin/sites.html",
get_admin_context(request, db, current_user),
)
@router.get("/hosting/sites/new", response_class=HTMLResponse, include_in_schema=False)
async def admin_hosting_site_new(
request: Request,
current_user: User = Depends(require_menu_access("hosting-sites", FrontendType.ADMIN)),
db: Session = Depends(get_db),
):
"""Render new hosted site form."""
return templates.TemplateResponse(
"hosting/admin/site-new.html",
get_admin_context(request, db, current_user),
)
@router.get(
"/hosting/sites/{site_id}",
response_class=HTMLResponse,
include_in_schema=False,
)
async def admin_hosting_site_detail(
request: Request,
site_id: int = Path(..., description="Hosted Site ID"),
current_user: User = Depends(require_menu_access("hosting-sites", FrontendType.ADMIN)),
db: Session = Depends(get_db),
):
"""Render hosted site detail page."""
context = get_admin_context(request, db, current_user)
context["site_id"] = site_id
return templates.TemplateResponse(
"hosting/admin/site-detail.html",
context,
)
@router.get("/hosting/clients", response_class=HTMLResponse, include_in_schema=False)
async def admin_hosting_clients(
request: Request,
current_user: User = Depends(require_menu_access("hosting-clients", FrontendType.ADMIN)),
db: Session = Depends(get_db),
):
"""Render all client services overview page."""
return templates.TemplateResponse(
"hosting/admin/clients.html",
get_admin_context(request, db, current_user),
)

View File

@@ -0,0 +1,46 @@
# app/modules/hosting/routes/pages/public.py
"""
Hosting Public Page Routes.
Public-facing routes for POC site viewing:
- POC Viewer - Shows the Store's storefront with a HostWizard preview banner
"""
from fastapi import APIRouter, Depends, Path, Request
from fastapi.responses import HTMLResponse
from sqlalchemy.orm import Session
from app.core.database import get_db
from app.templates_config import templates
router = APIRouter()
@router.get(
"/hosting/sites/{site_id}/preview",
response_class=HTMLResponse,
include_in_schema=False,
)
async def poc_site_viewer(
request: Request,
site_id: int = Path(..., description="Hosted Site ID"),
db: Session = Depends(get_db),
):
"""Render POC site viewer with HostWizard preview banner."""
from app.modules.hosting.models import HostedSite, HostedSiteStatus
site = db.query(HostedSite).filter(HostedSite.id == site_id).first()
# Only allow viewing for poc_ready or proposal_sent sites
if not site or site.status not in (HostedSiteStatus.POC_READY, HostedSiteStatus.PROPOSAL_SENT):
return HTMLResponse(content="<h1>Site not available for preview</h1>", status_code=404)
context = {
"request": request,
"site": site,
"store_url": f"/stores/{site.store.subdomain}" if site.store else "#",
}
return templates.TemplateResponse(
"hosting/public/poc-viewer.html",
context,
)

View File

@@ -0,0 +1,32 @@
# app/modules/hosting/schemas/__init__.py
from app.modules.hosting.schemas.client_service import (
ClientServiceCreate,
ClientServiceDeleteResponse,
ClientServiceResponse,
ClientServiceUpdate,
)
from app.modules.hosting.schemas.hosted_site import (
AcceptProposalRequest,
GoLiveRequest,
HostedSiteCreate,
HostedSiteDetailResponse,
HostedSiteListResponse,
HostedSiteResponse,
HostedSiteUpdate,
SendProposalRequest,
)
__all__ = [
"HostedSiteCreate",
"HostedSiteUpdate",
"HostedSiteResponse",
"HostedSiteDetailResponse",
"HostedSiteListResponse",
"SendProposalRequest",
"AcceptProposalRequest",
"GoLiveRequest",
"ClientServiceCreate",
"ClientServiceUpdate",
"ClientServiceResponse",
"ClientServiceDeleteResponse",
]

View File

@@ -0,0 +1,80 @@
# app/modules/hosting/schemas/client_service.py
"""Pydantic schemas for client service management."""
from datetime import datetime
from pydantic import BaseModel, Field
class ClientServiceCreate(BaseModel):
"""Schema for creating a client service."""
service_type: str = Field(..., pattern="^(domain|email|ssl|hosting|website_maintenance)$")
name: str = Field(..., max_length=255)
description: str | None = None
billing_period: str | None = Field(None, pattern="^(monthly|annual|one_time)$")
price_cents: int | None = None
currency: str = Field("EUR", max_length=3)
addon_product_id: int | None = None
domain_name: str | None = Field(None, max_length=255)
registrar: str | None = Field(None, max_length=100)
mailbox_count: int | None = None
expires_at: datetime | None = None
period_start: datetime | None = None
period_end: datetime | None = None
auto_renew: bool = True
notes: str | None = None
class ClientServiceUpdate(BaseModel):
"""Schema for updating a client service."""
name: str | None = Field(None, max_length=255)
description: str | None = None
status: str | None = Field(None, pattern="^(pending|active|suspended|expired|cancelled)$")
billing_period: str | None = Field(None, pattern="^(monthly|annual|one_time)$")
price_cents: int | None = None
currency: str | None = Field(None, max_length=3)
addon_product_id: int | None = None
domain_name: str | None = Field(None, max_length=255)
registrar: str | None = Field(None, max_length=100)
mailbox_count: int | None = None
expires_at: datetime | None = None
period_start: datetime | None = None
period_end: datetime | None = None
auto_renew: bool | None = None
notes: str | None = None
class ClientServiceResponse(BaseModel):
"""Schema for client service response."""
id: int
hosted_site_id: int
service_type: str
name: str
description: str | None = None
status: str
billing_period: str | None = None
price_cents: int | None = None
currency: str = "EUR"
addon_product_id: int | None = None
domain_name: str | None = None
registrar: str | None = None
mailbox_count: int | None = None
expires_at: datetime | None = None
period_start: datetime | None = None
period_end: datetime | None = None
auto_renew: bool = True
notes: str | None = None
created_at: datetime
updated_at: datetime
class Config:
from_attributes = True
class ClientServiceDeleteResponse(BaseModel):
"""Response for client service deletion."""
message: str

View File

@@ -0,0 +1,101 @@
# app/modules/hosting/schemas/hosted_site.py
"""Pydantic schemas for hosted site management."""
from datetime import datetime
from pydantic import BaseModel, Field
class HostedSiteCreate(BaseModel):
"""Schema for creating a hosted site."""
business_name: str = Field(..., max_length=255)
contact_name: str | None = Field(None, max_length=255)
contact_email: str | None = Field(None, max_length=255)
contact_phone: str | None = Field(None, max_length=50)
internal_notes: str | None = None
class HostedSiteUpdate(BaseModel):
"""Schema for updating a hosted site."""
business_name: str | None = Field(None, max_length=255)
contact_name: str | None = Field(None, max_length=255)
contact_email: str | None = Field(None, max_length=255)
contact_phone: str | None = Field(None, max_length=50)
internal_notes: str | None = None
class SendProposalRequest(BaseModel):
"""Schema for sending a proposal."""
notes: str | None = None
class AcceptProposalRequest(BaseModel):
"""Schema for accepting a proposal."""
merchant_id: int | None = None
class GoLiveRequest(BaseModel):
"""Schema for going live with a domain."""
domain: str = Field(..., max_length=255)
class HostedSiteResponse(BaseModel):
"""Schema for hosted site response."""
id: int
store_id: int
prospect_id: int | None = None
status: str
business_name: str
contact_name: str | None = None
contact_email: str | None = None
contact_phone: str | None = None
proposal_sent_at: datetime | None = None
proposal_accepted_at: datetime | None = None
went_live_at: datetime | None = None
proposal_notes: str | None = None
live_domain: str | None = None
internal_notes: str | None = None
created_at: datetime
updated_at: datetime
class Config:
from_attributes = True
class HostedSiteDetailResponse(HostedSiteResponse):
"""Full hosted site detail with services."""
client_services: list["ClientServiceResponse"] = []
class Config:
from_attributes = True
class HostedSiteListResponse(BaseModel):
"""Paginated hosted site list response."""
items: list[HostedSiteResponse]
total: int
page: int
per_page: int
pages: int
class HostedSiteDeleteResponse(BaseModel):
"""Response for hosted site deletion."""
message: str
# Forward references
from app.modules.hosting.schemas.client_service import (
ClientServiceResponse, # noqa: E402
)
HostedSiteDetailResponse.model_rebuild()

View File

@@ -0,0 +1 @@
# app/modules/hosting/services/__init__.py

View File

@@ -0,0 +1,128 @@
# app/modules/hosting/services/client_service_service.py
"""
Client service CRUD service.
Manages operational tracking for hosted site services (domains, email, SSL, etc.).
"""
import logging
from datetime import UTC, datetime, timedelta
from sqlalchemy.orm import Session
from app.modules.hosting.exceptions import ClientServiceNotFoundException
from app.modules.hosting.models import (
ClientService,
ClientServiceStatus,
ServiceType,
)
logger = logging.getLogger(__name__)
class ClientServiceService:
"""Service for client service CRUD operations."""
def get_by_id(self, db: Session, service_id: int) -> ClientService:
service = db.query(ClientService).filter(ClientService.id == service_id).first()
if not service:
raise ClientServiceNotFoundException(str(service_id))
return service
def get_for_site(self, db: Session, hosted_site_id: int) -> list[ClientService]:
return (
db.query(ClientService)
.filter(ClientService.hosted_site_id == hosted_site_id)
.order_by(ClientService.created_at.desc())
.all()
)
def create(self, db: Session, hosted_site_id: int, data: dict) -> ClientService:
service = ClientService(
hosted_site_id=hosted_site_id,
service_type=ServiceType(data["service_type"]),
name=data["name"],
description=data.get("description"),
status=ClientServiceStatus.PENDING,
billing_period=data.get("billing_period"),
price_cents=data.get("price_cents"),
currency=data.get("currency", "EUR"),
addon_product_id=data.get("addon_product_id"),
domain_name=data.get("domain_name"),
registrar=data.get("registrar"),
mailbox_count=data.get("mailbox_count"),
expires_at=data.get("expires_at"),
period_start=data.get("period_start"),
period_end=data.get("period_end"),
auto_renew=data.get("auto_renew", True),
notes=data.get("notes"),
)
db.add(service)
db.flush()
logger.info("Created client service: %s (site_id=%d)", service.name, hosted_site_id)
return service
def update(self, db: Session, service_id: int, data: dict) -> ClientService:
service = self.get_by_id(db, service_id)
for field in [
"name", "description", "status", "billing_period", "price_cents",
"currency", "addon_product_id", "domain_name", "registrar",
"mailbox_count", "expires_at", "period_start", "period_end",
"auto_renew", "notes",
]:
if field in data and data[field] is not None:
setattr(service, field, data[field])
db.flush()
return service
def delete(self, db: Session, service_id: int) -> bool:
service = self.get_by_id(db, service_id)
db.delete(service)
db.flush()
logger.info("Deleted client service: %d", service_id)
return True
def get_expiring_soon(self, db: Session, days: int = 30) -> list[ClientService]:
"""Get services expiring within the given number of days."""
cutoff = datetime.now(UTC) + timedelta(days=days)
return (
db.query(ClientService)
.filter(
ClientService.expires_at.isnot(None),
ClientService.expires_at <= cutoff,
ClientService.status == ClientServiceStatus.ACTIVE,
)
.order_by(ClientService.expires_at.asc())
.all()
)
def get_all(
self,
db: Session,
*,
page: int = 1,
per_page: int = 20,
service_type: str | None = None,
status: str | None = None,
) -> tuple[list[ClientService], int]:
"""Get all services with pagination and filters."""
query = db.query(ClientService)
if service_type:
query = query.filter(ClientService.service_type == service_type)
if status:
query = query.filter(ClientService.status == status)
total = query.count()
services = (
query.order_by(ClientService.created_at.desc())
.offset((page - 1) * per_page)
.limit(per_page)
.all()
)
return services, total
client_service_service = ClientServiceService()

View File

@@ -0,0 +1,325 @@
# app/modules/hosting/services/hosted_site_service.py
"""
Hosted site service — CRUD + lifecycle management.
Manages the POC → live website pipeline:
draft → poc_ready → proposal_sent → accepted → live → suspended | cancelled
"""
import logging
import re
from datetime import UTC, datetime
from sqlalchemy import or_
from sqlalchemy.orm import Session, joinedload
from app.modules.hosting.exceptions import (
DuplicateSlugException,
HostedSiteNotFoundException,
InvalidStatusTransitionException,
)
from app.modules.hosting.models import HostedSite, HostedSiteStatus
logger = logging.getLogger(__name__)
# Valid status transitions
ALLOWED_TRANSITIONS: dict[HostedSiteStatus, list[HostedSiteStatus]] = {
HostedSiteStatus.DRAFT: [HostedSiteStatus.POC_READY, HostedSiteStatus.CANCELLED],
HostedSiteStatus.POC_READY: [HostedSiteStatus.PROPOSAL_SENT, HostedSiteStatus.CANCELLED],
HostedSiteStatus.PROPOSAL_SENT: [HostedSiteStatus.ACCEPTED, HostedSiteStatus.CANCELLED],
HostedSiteStatus.ACCEPTED: [HostedSiteStatus.LIVE, HostedSiteStatus.CANCELLED],
HostedSiteStatus.LIVE: [HostedSiteStatus.SUSPENDED, HostedSiteStatus.CANCELLED],
HostedSiteStatus.SUSPENDED: [HostedSiteStatus.LIVE, HostedSiteStatus.CANCELLED],
HostedSiteStatus.CANCELLED: [],
}
def _slugify(name: str) -> str:
"""Generate a URL-safe slug from a business name."""
slug = name.lower().strip()
slug = re.sub(r"[^a-z0-9\s-]", "", slug)
slug = re.sub(r"[\s-]+", "-", slug)
return slug.strip("-")[:50]
class HostedSiteService:
"""Service for hosted site CRUD and lifecycle operations."""
def get_by_id(self, db: Session, site_id: int) -> HostedSite:
site = (
db.query(HostedSite)
.options(joinedload(HostedSite.client_services))
.filter(HostedSite.id == site_id)
.first()
)
if not site:
raise HostedSiteNotFoundException(str(site_id))
return site
def get_all(
self,
db: Session,
*,
page: int = 1,
per_page: int = 20,
search: str | None = None,
status: str | None = None,
) -> tuple[list[HostedSite], int]:
query = db.query(HostedSite).options(joinedload(HostedSite.client_services))
if search:
query = query.filter(
or_(
HostedSite.business_name.ilike(f"%{search}%"),
HostedSite.contact_email.ilike(f"%{search}%"),
HostedSite.live_domain.ilike(f"%{search}%"),
)
)
if status:
query = query.filter(HostedSite.status == status)
total = query.count()
sites = (
query.order_by(HostedSite.created_at.desc())
.offset((page - 1) * per_page)
.limit(per_page)
.all()
)
return sites, total
def create(self, db: Session, data: dict) -> HostedSite:
"""Create a hosted site with an auto-created Store on the hosting platform."""
from app.modules.tenancy.models import Platform
from app.modules.tenancy.schemas.store import StoreCreate
from app.modules.tenancy.services.admin_service import admin_service
business_name = data["business_name"]
slug = _slugify(business_name)
# Find hosting platform
platform = db.query(Platform).filter(Platform.code == "hosting").first()
if not platform:
raise ValueError("Hosting platform not found. Run init_production first.")
# Create a temporary merchant-less store requires a merchant_id.
# For POC sites we create a placeholder: the store is re-assigned on accept_proposal.
# Use the platform's own admin store or create under a system merchant.
# For now, create store via AdminService which handles defaults.
store_code = slug.upper().replace("-", "_")[:50]
subdomain = slug
# Check for duplicate subdomain
from app.modules.tenancy.models import Store
existing = db.query(Store).filter(Store.subdomain == subdomain).first()
if existing:
raise DuplicateSlugException(subdomain)
# We need a system merchant for POC sites.
# Look for one or create if needed.
from app.modules.tenancy.models import Merchant
system_merchant = db.query(Merchant).filter(Merchant.name == "HostWizard System").first()
if not system_merchant:
system_merchant = Merchant(
name="HostWizard System",
contact_email="system@hostwizard.lu",
is_active=True,
is_verified=True,
)
db.add(system_merchant)
db.flush()
store_data = StoreCreate(
merchant_id=system_merchant.id,
store_code=store_code,
subdomain=subdomain,
name=business_name,
description=f"POC website for {business_name}",
platform_ids=[platform.id],
)
store = admin_service.create_store(db, store_data)
site = HostedSite(
store_id=store.id,
prospect_id=data.get("prospect_id"),
status=HostedSiteStatus.DRAFT,
business_name=business_name,
contact_name=data.get("contact_name"),
contact_email=data.get("contact_email"),
contact_phone=data.get("contact_phone"),
internal_notes=data.get("internal_notes"),
)
db.add(site)
db.flush()
logger.info("Created hosted site: %s (store_id=%d)", site.display_name, store.id)
return site
def create_from_prospect(self, db: Session, prospect_id: int) -> HostedSite:
"""Create a hosted site pre-filled from prospect data."""
from app.modules.prospecting.models import Prospect
prospect = db.query(Prospect).filter(Prospect.id == prospect_id).first()
if not prospect:
from app.modules.prospecting.exceptions import ProspectNotFoundException
raise ProspectNotFoundException(str(prospect_id))
# Get primary contact info from prospect contacts
contacts = prospect.contacts or []
primary_email = next((c.value for c in contacts if c.contact_type == "email"), None)
primary_phone = next((c.value for c in contacts if c.contact_type == "phone"), None)
contact_name = next((c.label for c in contacts if c.label), None)
data = {
"business_name": prospect.business_name or prospect.domain_name or f"Prospect #{prospect.id}",
"contact_name": contact_name,
"contact_email": primary_email,
"contact_phone": primary_phone,
"prospect_id": prospect.id,
}
return self.create(db, data)
def update(self, db: Session, site_id: int, data: dict) -> HostedSite:
site = self.get_by_id(db, site_id)
for field in ["business_name", "contact_name", "contact_email", "contact_phone", "internal_notes"]:
if field in data and data[field] is not None:
setattr(site, field, data[field])
db.flush()
return site
def delete(self, db: Session, site_id: int) -> bool:
site = self.get_by_id(db, site_id)
db.delete(site)
db.flush()
logger.info("Deleted hosted site: %d", site_id)
return True
# ── Lifecycle transitions ──────────────────────────────────────────────
def _transition(self, db: Session, site_id: int, target: HostedSiteStatus) -> HostedSite:
"""Validate and apply a status transition."""
site = self.get_by_id(db, site_id)
allowed = ALLOWED_TRANSITIONS.get(site.status, [])
if target not in allowed:
raise InvalidStatusTransitionException(site.status.value, target.value)
site.status = target
db.flush()
return site
def mark_poc_ready(self, db: Session, site_id: int) -> HostedSite:
site = self._transition(db, site_id, HostedSiteStatus.POC_READY)
logger.info("Site %d marked POC ready", site_id)
return site
def send_proposal(self, db: Session, site_id: int, notes: str | None = None) -> HostedSite:
site = self._transition(db, site_id, HostedSiteStatus.PROPOSAL_SENT)
site.proposal_sent_at = datetime.now(UTC)
if notes:
site.proposal_notes = notes
db.flush()
logger.info("Proposal sent for site %d", site_id)
return site
def accept_proposal(
self, db: Session, site_id: int, merchant_id: int | None = None
) -> HostedSite:
"""Accept proposal: create or link merchant, create subscription, mark converted."""
site = self._transition(db, site_id, HostedSiteStatus.ACCEPTED)
site.proposal_accepted_at = datetime.now(UTC)
from app.modules.tenancy.models import Merchant, Platform
if merchant_id:
# Link to existing merchant
merchant = db.query(Merchant).filter(Merchant.id == merchant_id).first()
if not merchant:
raise ValueError(f"Merchant {merchant_id} not found")
else:
# Create new merchant from contact info
from app.modules.tenancy.schemas.merchant import MerchantCreate
from app.modules.tenancy.services.merchant_service import merchant_service
email = site.contact_email or f"contact-{site.id}@hostwizard.lu"
merchant_data = MerchantCreate(
name=site.business_name,
contact_email=email,
contact_phone=site.contact_phone,
owner_email=email,
)
merchant, _owner_user, _temp_password = merchant_service.create_merchant_with_owner(
db, merchant_data
)
logger.info("Created merchant %s for site %d", merchant.name, site_id)
# Re-assign store to the real merchant
site.store.merchant_id = merchant.id
db.flush()
# Create MerchantSubscription on hosting platform
platform = db.query(Platform).filter(Platform.code == "hosting").first()
if platform:
from app.modules.billing.services.subscription_service import (
subscription_service,
)
existing_sub = subscription_service.get_merchant_subscription(
db, merchant.id, platform.id
)
if not existing_sub:
subscription_service.create_merchant_subscription(
db,
merchant_id=merchant.id,
platform_id=platform.id,
tier_code="essential",
trial_days=0,
)
logger.info("Created subscription for merchant %d on hosting platform", merchant.id)
# Mark prospect as converted
if site.prospect_id:
from app.modules.prospecting.models import Prospect, ProspectStatus
prospect = db.query(Prospect).filter(Prospect.id == site.prospect_id).first()
if prospect and prospect.status != ProspectStatus.CONVERTED:
prospect.status = ProspectStatus.CONVERTED
db.flush()
db.flush()
logger.info("Proposal accepted for site %d (merchant=%d)", site_id, merchant.id)
return site
def go_live(self, db: Session, site_id: int, domain: str) -> HostedSite:
"""Go live: add domain to store, update site."""
site = self._transition(db, site_id, HostedSiteStatus.LIVE)
site.went_live_at = datetime.now(UTC)
site.live_domain = domain
# Add domain to store via StoreDomainService
from app.modules.tenancy.schemas.store_domain import StoreDomainCreate
from app.modules.tenancy.services.store_domain_service import (
store_domain_service,
)
domain_data = StoreDomainCreate(domain=domain, is_primary=True)
store_domain_service.add_domain(db, site.store_id, domain_data)
db.flush()
logger.info("Site %d went live on %s", site_id, domain)
return site
def suspend(self, db: Session, site_id: int) -> HostedSite:
site = self._transition(db, site_id, HostedSiteStatus.SUSPENDED)
logger.info("Site %d suspended", site_id)
return site
def cancel(self, db: Session, site_id: int) -> HostedSite:
site = self._transition(db, site_id, HostedSiteStatus.CANCELLED)
logger.info("Site %d cancelled", site_id)
return site
hosted_site_service = HostedSiteService()

View File

@@ -0,0 +1,101 @@
# app/modules/hosting/services/stats_service.py
"""
Statistics service for the hosting dashboard.
"""
import logging
from datetime import UTC, datetime, timedelta
from sqlalchemy import func
from sqlalchemy.orm import Session
from app.modules.hosting.models import (
ClientService,
ClientServiceStatus,
HostedSite,
)
logger = logging.getLogger(__name__)
class StatsService:
"""Service for dashboard statistics and reporting."""
def get_dashboard_stats(self, db: Session) -> dict:
"""Get overview statistics for the hosting dashboard."""
total = db.query(func.count(HostedSite.id)).scalar() or 0
# Sites by status
status_results = (
db.query(HostedSite.status, func.count(HostedSite.id))
.group_by(HostedSite.status)
.all()
)
sites_by_status = {
status.value if hasattr(status, "value") else str(status): count
for status, count in status_results
}
live_count = sites_by_status.get("live", 0)
poc_count = (
sites_by_status.get("draft", 0)
+ sites_by_status.get("poc_ready", 0)
+ sites_by_status.get("proposal_sent", 0)
)
# Active services
active_services = (
db.query(func.count(ClientService.id))
.filter(ClientService.status == ClientServiceStatus.ACTIVE)
.scalar()
or 0
)
# Monthly revenue (sum of price_cents for active services with monthly billing)
monthly_revenue = (
db.query(func.sum(ClientService.price_cents))
.filter(
ClientService.status == ClientServiceStatus.ACTIVE,
)
.scalar()
or 0
)
# Upcoming renewals (next 30 days)
cutoff = datetime.now(UTC) + timedelta(days=30)
upcoming_renewals = (
db.query(func.count(ClientService.id))
.filter(
ClientService.expires_at.isnot(None),
ClientService.expires_at <= cutoff,
ClientService.status == ClientServiceStatus.ACTIVE,
)
.scalar()
or 0
)
# Services by type
type_results = (
db.query(ClientService.service_type, func.count(ClientService.id))
.filter(ClientService.status == ClientServiceStatus.ACTIVE)
.group_by(ClientService.service_type)
.all()
)
services_by_type = {
stype.value if hasattr(stype, "value") else str(stype): count
for stype, count in type_results
}
return {
"total_sites": total,
"live_sites": live_count,
"poc_sites": poc_count,
"sites_by_status": sites_by_status,
"active_services": active_services,
"monthly_revenue_cents": monthly_revenue,
"upcoming_renewals": upcoming_renewals,
"services_by_type": services_by_type,
}
stats_service = StatsService()

View File

@@ -0,0 +1 @@
# app/modules/hosting/tasks/__init__.py

View File

@@ -0,0 +1,165 @@
{% extends "admin/base.html" %}
{% from 'shared/macros/headers.html' import page_header %}
{% from 'shared/macros/alerts.html' import loading_state, error_state %}
{% from 'shared/macros/pagination.html' import pagination_controls %}
{% block title %}Client Services{% endblock %}
{% block alpine_data %}hostingClientsList(){% endblock %}
{% block content %}
{{ page_header('Client Services') }}
<!-- Filters -->
<div class="mb-6 p-4 bg-white rounded-lg shadow dark:bg-gray-800">
<div class="grid gap-4 md:grid-cols-3">
<div>
<label class="text-sm text-gray-600 dark:text-gray-400">Service Type</label>
<select x-model="filterType" @change="loadServices()"
class="w-full mt-1 text-sm rounded-lg border-gray-300 dark:border-gray-600 dark:bg-gray-700 dark:text-gray-300">
<option value="">All</option>
<option value="domain">Domain</option>
<option value="email">Email</option>
<option value="ssl">SSL</option>
<option value="hosting">Hosting</option>
<option value="website_maintenance">Website Maintenance</option>
</select>
</div>
<div>
<label class="text-sm text-gray-600 dark:text-gray-400">Status</label>
<select x-model="filterStatus" @change="loadServices()"
class="w-full mt-1 text-sm rounded-lg border-gray-300 dark:border-gray-600 dark:bg-gray-700 dark:text-gray-300">
<option value="">All</option>
<option value="pending">Pending</option>
<option value="active">Active</option>
<option value="suspended">Suspended</option>
<option value="expired">Expired</option>
<option value="cancelled">Cancelled</option>
</select>
</div>
<div class="flex items-end">
<button @click="showExpiringOnly = !showExpiringOnly; loadServices()"
:class="showExpiringOnly ? 'bg-red-600 text-white' : 'bg-gray-200 text-gray-700 dark:bg-gray-700 dark:text-gray-300'"
class="px-4 py-2 text-sm font-medium rounded-lg">
<span x-html="$icon('clock', 'w-4 h-4 inline mr-1')"></span>
Expiring Soon
</button>
</div>
</div>
</div>
{{ loading_state('Loading services...') }}
{{ error_state('Error loading services') }}
<!-- Services Table -->
<div x-show="!loading && !error" class="w-full overflow-hidden rounded-lg shadow">
<div class="w-full overflow-x-auto">
<table class="w-full whitespace-nowrap">
<thead>
<tr class="text-xs font-semibold tracking-wide text-left text-gray-500 uppercase border-b dark:border-gray-700 bg-gray-50 dark:text-gray-400 dark:bg-gray-800">
<th class="px-4 py-3">Service</th>
<th class="px-4 py-3">Type</th>
<th class="px-4 py-3">Status</th>
<th class="px-4 py-3">Price</th>
<th class="px-4 py-3">Expires</th>
<th class="px-4 py-3">Site</th>
</tr>
</thead>
<tbody class="bg-white divide-y dark:divide-gray-700 dark:bg-gray-800">
<template x-for="svc in services" :key="svc.id">
<tr class="text-gray-700 dark:text-gray-400 hover:bg-gray-50 dark:hover:bg-gray-700"
:class="isExpiringSoon(svc) ? 'bg-red-50 dark:bg-red-900/20' : ''">
<td class="px-4 py-3 text-sm font-semibold" x-text="svc.name"></td>
<td class="px-4 py-3 text-sm">
<span class="px-2 py-1 text-xs rounded-full bg-gray-100 text-gray-600 dark:bg-gray-700 dark:text-gray-400"
x-text="svc.service_type.replace('_', ' ')"></span>
</td>
<td class="px-4 py-3 text-sm">
<span class="px-2 py-1 text-xs font-semibold rounded-full"
:class="svc.status === 'active' ? 'bg-green-100 text-green-700' : svc.status === 'expired' ? 'bg-red-100 text-red-700' : 'bg-gray-100 text-gray-600'"
x-text="svc.status"></span>
</td>
<td class="px-4 py-3 text-sm">
<span x-text="svc.price_cents ? '€' + (svc.price_cents / 100).toFixed(2) : '—'"></span>
</td>
<td class="px-4 py-3 text-sm">
<span x-text="svc.expires_at ? new Date(svc.expires_at).toLocaleDateString() : '—'"
:class="isExpiringSoon(svc) ? 'text-red-600 font-semibold' : ''"></span>
</td>
<td class="px-4 py-3 text-sm">
<a :href="'/admin/hosting/sites/' + svc.hosted_site_id"
class="text-teal-600 hover:text-teal-900 dark:text-teal-400">
View Site
</a>
</td>
</tr>
</template>
</tbody>
</table>
</div>
<div x-show="services.length === 0" class="p-8 text-center text-gray-400 dark:text-gray-500">
No services found
</div>
</div>
{{ pagination_controls() }}
{% endblock %}
{% block extra_scripts %}
<script>
function hostingClientsList() {
return {
loading: true,
error: false,
services: [],
total: 0,
page: 1,
perPage: 20,
pages: 0,
filterType: '',
filterStatus: '',
showExpiringOnly: false,
async init() { await this.loadServices(); },
async loadServices() {
this.loading = true;
this.error = false;
try {
// Use the sites endpoint to get all services across all sites
const params = new URLSearchParams({ page: this.page, per_page: this.perPage });
if (this.filterStatus) params.set('status', this.filterStatus);
// Get sites and flatten their services
const resp = await fetch('/api/admin/hosting/sites?' + params);
if (!resp.ok) throw new Error('Failed to load');
const data = await resp.json();
// Collect all services from all sites
let allServices = [];
for (const site of data.items) {
const svcResp = await fetch('/api/admin/hosting/sites/' + site.id + '/services');
if (svcResp.ok) {
const svcs = await svcResp.json();
allServices = allServices.concat(svcs);
}
}
// Apply client-side filters
if (this.filterType) allServices = allServices.filter(s => s.service_type === this.filterType);
if (this.filterStatus) allServices = allServices.filter(s => s.status === this.filterStatus);
if (this.showExpiringOnly) allServices = allServices.filter(s => this.isExpiringSoon(s));
this.services = allServices;
this.total = allServices.length;
} catch (e) { this.error = true; }
finally { this.loading = false; }
},
isExpiringSoon(svc) {
if (!svc.expires_at || svc.status !== 'active') return false;
const daysLeft = (new Date(svc.expires_at) - new Date()) / (1000 * 60 * 60 * 24);
return daysLeft <= 30 && daysLeft > 0;
},
goToPage(p) { this.page = p; this.loadServices(); },
};
}
</script>
{% endblock %}

View File

@@ -0,0 +1,148 @@
{% extends "admin/base.html" %}
{% from 'shared/macros/headers.html' import page_header %}
{% from 'shared/macros/alerts.html' import loading_state, error_state %}
{% block title %}Hosting Dashboard{% endblock %}
{% block alpine_data %}hostingDashboard(){% endblock %}
{% block content %}
{{ page_header('Hosting Dashboard') }}
{{ loading_state('Loading dashboard...') }}
{{ error_state('Error loading dashboard') }}
<div x-show="!loading && !error" class="space-y-6">
<!-- KPI Cards -->
<div class="grid gap-6 mb-8 md:grid-cols-2 xl:grid-cols-4">
<div class="flex items-center p-4 bg-white rounded-lg shadow dark:bg-gray-800">
<div class="p-3 mr-4 text-teal-500 bg-teal-100 rounded-full dark:text-teal-100 dark:bg-teal-500">
<span x-html="$icon('globe', 'w-5 h-5')"></span>
</div>
<div>
<p class="mb-2 text-sm font-medium text-gray-600 dark:text-gray-400">Total Sites</p>
<p class="text-lg font-semibold text-gray-700 dark:text-gray-200" x-text="stats.total_sites || 0"></p>
</div>
</div>
<div class="flex items-center p-4 bg-white rounded-lg shadow dark:bg-gray-800">
<div class="p-3 mr-4 text-green-500 bg-green-100 rounded-full dark:text-green-100 dark:bg-green-500">
<span x-html="$icon('check-circle', 'w-5 h-5')"></span>
</div>
<div>
<p class="mb-2 text-sm font-medium text-gray-600 dark:text-gray-400">Live Sites</p>
<p class="text-lg font-semibold text-gray-700 dark:text-gray-200" x-text="stats.live_sites || 0"></p>
</div>
</div>
<div class="flex items-center p-4 bg-white rounded-lg shadow dark:bg-gray-800">
<div class="p-3 mr-4 text-orange-500 bg-orange-100 rounded-full dark:text-orange-100 dark:bg-orange-500">
<span x-html="$icon('eye', 'w-5 h-5')"></span>
</div>
<div>
<p class="mb-2 text-sm font-medium text-gray-600 dark:text-gray-400">POC Sites</p>
<p class="text-lg font-semibold text-gray-700 dark:text-gray-200" x-text="stats.poc_sites || 0"></p>
</div>
</div>
<div class="flex items-center p-4 bg-white rounded-lg shadow dark:bg-gray-800">
<div class="p-3 mr-4 text-red-500 bg-red-100 rounded-full dark:text-red-100 dark:bg-red-500">
<span x-html="$icon('clock', 'w-5 h-5')"></span>
</div>
<div>
<p class="mb-2 text-sm font-medium text-gray-600 dark:text-gray-400">Upcoming Renewals</p>
<p class="text-lg font-semibold text-gray-700 dark:text-gray-200" x-text="stats.upcoming_renewals || 0"></p>
</div>
</div>
</div>
<!-- Quick Actions -->
<div class="flex flex-wrap gap-3 mb-6">
<a href="/admin/hosting/sites/new"
class="inline-flex items-center px-4 py-2 text-sm font-medium text-white bg-teal-600 rounded-lg hover:bg-teal-700">
<span x-html="$icon('plus', 'w-4 h-4 mr-2')"></span>
New Site
</a>
<a href="/admin/hosting/sites"
class="inline-flex items-center px-4 py-2 text-sm font-medium text-gray-700 bg-gray-200 rounded-lg hover:bg-gray-300 dark:text-gray-300 dark:bg-gray-700">
<span x-html="$icon('globe', 'w-4 h-4 mr-2')"></span>
All Sites
</a>
<a href="/admin/hosting/clients"
class="inline-flex items-center px-4 py-2 text-sm font-medium text-gray-700 bg-gray-200 rounded-lg hover:bg-gray-300 dark:text-gray-300 dark:bg-gray-700">
<span x-html="$icon('server', 'w-4 h-4 mr-2')"></span>
All Services
</a>
</div>
<!-- Sites by Status + Services by Type -->
<div class="grid gap-6 md:grid-cols-2">
<div class="p-4 bg-white rounded-lg shadow dark:bg-gray-800">
<h3 class="mb-4 text-lg font-semibold text-gray-700 dark:text-gray-200">Sites by Status</h3>
<div class="space-y-3">
<template x-for="status in ['draft', 'poc_ready', 'proposal_sent', 'accepted', 'live', 'suspended', 'cancelled']" :key="status">
<div class="flex items-center justify-between">
<span class="text-sm text-gray-600 dark:text-gray-400 capitalize" x-text="status.replace('_', ' ')"></span>
<span class="px-2 py-1 text-xs font-semibold rounded-full"
:class="statusBadgeClass(status)"
x-text="stats.sites_by_status?.[status] || 0"></span>
</div>
</template>
</div>
</div>
<div class="p-4 bg-white rounded-lg shadow dark:bg-gray-800">
<h3 class="mb-4 text-lg font-semibold text-gray-700 dark:text-gray-200">Active Services</h3>
<div class="space-y-3">
<div class="flex items-center justify-between">
<span class="text-sm text-gray-600 dark:text-gray-400">Total Active</span>
<span class="text-sm font-semibold text-gray-700 dark:text-gray-200" x-text="stats.active_services || 0"></span>
</div>
<template x-for="[type, count] in Object.entries(stats.services_by_type || {})" :key="type">
<div class="flex items-center justify-between">
<span class="text-sm text-gray-600 dark:text-gray-400 capitalize" x-text="type.replace('_', ' ')"></span>
<span class="text-sm font-semibold text-gray-700 dark:text-gray-200" x-text="count"></span>
</div>
</template>
</div>
<div class="mt-4 pt-4 border-t dark:border-gray-700">
<div class="flex items-center justify-between">
<span class="text-sm font-medium text-gray-600 dark:text-gray-400">Monthly Revenue</span>
<span class="text-lg font-semibold text-green-600 dark:text-green-400"
x-text="'€' + ((stats.monthly_revenue_cents || 0) / 100).toFixed(2)"></span>
</div>
</div>
</div>
</div>
</div>
{% endblock %}
{% block extra_scripts %}
<script>
function hostingDashboard() {
return {
loading: true,
error: false,
stats: {},
async init() {
try {
const resp = await fetch('/api/admin/hosting/stats/dashboard');
if (!resp.ok) throw new Error('Failed to load');
this.stats = await resp.json();
} catch (e) {
this.error = true;
} finally {
this.loading = false;
}
},
statusBadgeClass(status) {
const classes = {
draft: 'text-gray-700 bg-gray-100 dark:text-gray-300 dark:bg-gray-700',
poc_ready: 'text-blue-700 bg-blue-100 dark:text-blue-100 dark:bg-blue-700',
proposal_sent: 'text-yellow-700 bg-yellow-100 dark:text-yellow-100 dark:bg-yellow-700',
accepted: 'text-purple-700 bg-purple-100 dark:text-purple-100 dark:bg-purple-700',
live: 'text-green-700 bg-green-100 dark:text-green-100 dark:bg-green-700',
suspended: 'text-red-700 bg-red-100 dark:text-red-100 dark:bg-red-700',
cancelled: 'text-gray-500 bg-gray-200 dark:text-gray-400 dark:bg-gray-600',
};
return classes[status] || 'text-gray-700 bg-gray-100';
},
};
}
</script>
{% endblock %}

View File

@@ -0,0 +1,405 @@
{% extends "admin/base.html" %}
{% from 'shared/macros/headers.html' import page_header %}
{% from 'shared/macros/alerts.html' import loading_state, error_state %}
{% from 'shared/macros/inputs.html' import number_stepper %}
{% block title %}Site Detail{% endblock %}
{% block alpine_data %}hostingSiteDetail({{ site_id }}){% endblock %}
{% block content %}
{{ loading_state('Loading site...') }}
{{ error_state('Error loading site') }}
<div x-show="!loading && !error && site" class="space-y-6">
<!-- Header -->
<div class="flex items-center justify-between">
<div class="flex items-center space-x-4">
<a href="/admin/hosting/sites"
class="p-2 text-gray-500 rounded-lg hover:bg-gray-100 dark:hover:bg-gray-700">
<span x-html="$icon('arrow-left', 'w-5 h-5')"></span>
</a>
<div>
<h2 class="text-2xl font-semibold text-gray-700 dark:text-gray-200"
x-text="site.business_name"></h2>
<p class="text-sm text-gray-500" x-show="site.live_domain" x-text="site.live_domain"></p>
</div>
<span class="px-3 py-1 text-xs font-semibold rounded-full"
:class="statusBadgeClass(site.status)"
x-text="site.status.replace('_', ' ')"></span>
</div>
</div>
<!-- Lifecycle Actions -->
<div class="flex flex-wrap gap-3 p-4 bg-white rounded-lg shadow dark:bg-gray-800">
<button x-show="site.status === 'draft'" @click="doAction('mark-poc-ready')"
class="px-3 py-2 text-sm font-medium text-white bg-blue-600 rounded-lg hover:bg-blue-700">
Mark POC Ready
</button>
<button x-show="site.status === 'poc_ready'" @click="showProposalModal = true"
class="px-3 py-2 text-sm font-medium text-white bg-yellow-600 rounded-lg hover:bg-yellow-700">
Send Proposal
</button>
<button x-show="site.status === 'proposal_sent'" @click="showAcceptModal = true"
class="px-3 py-2 text-sm font-medium text-white bg-purple-600 rounded-lg hover:bg-purple-700">
Accept Proposal
</button>
<button x-show="site.status === 'accepted'" @click="showGoLiveModal = true"
class="px-3 py-2 text-sm font-medium text-white bg-green-600 rounded-lg hover:bg-green-700">
Go Live
</button>
<button x-show="site.status === 'live'" @click="doAction('suspend')"
class="px-3 py-2 text-sm font-medium text-white bg-red-600 rounded-lg hover:bg-red-700">
Suspend
</button>
<button x-show="site.status === 'suspended'" @click="doAction('go-live', {domain: site.live_domain})"
class="px-3 py-2 text-sm font-medium text-white bg-green-600 rounded-lg hover:bg-green-700">
Reactivate
</button>
<button x-show="site.status !== 'cancelled' && site.status !== 'live'"
@click="doAction('cancel')"
class="px-3 py-2 text-sm font-medium text-gray-700 bg-gray-200 rounded-lg hover:bg-gray-300 dark:text-gray-300 dark:bg-gray-700">
Cancel
</button>
<a x-show="['poc_ready', 'proposal_sent'].includes(site.status)"
:href="'/hosting/sites/' + site.id + '/preview'" target="_blank"
class="ml-auto px-3 py-2 text-sm font-medium text-teal-700 bg-teal-100 rounded-lg hover:bg-teal-200 dark:text-teal-300 dark:bg-teal-900">
<span x-html="$icon('eye', 'w-4 h-4 inline mr-1')"></span>
Preview POC
</a>
</div>
<!-- Tabs -->
<div class="border-b border-gray-200 dark:border-gray-700">
<nav class="flex -mb-px space-x-8">
<template x-for="tab in tabs" :key="tab.id">
<button @click="activeTab = tab.id"
:class="activeTab === tab.id ? 'border-teal-500 text-teal-600 dark:text-teal-400' : 'border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300'"
class="py-4 px-1 border-b-2 font-medium text-sm whitespace-nowrap"
x-text="tab.label"></button>
</template>
</nav>
</div>
<!-- Tab: Overview -->
<div x-show="activeTab === 'overview'" class="grid gap-6 md:grid-cols-2">
<div class="p-4 bg-white rounded-lg shadow dark:bg-gray-800">
<h3 class="mb-3 text-sm font-semibold text-gray-700 dark:text-gray-200 uppercase">Contact Info</h3>
<div class="space-y-2 text-sm">
<div class="flex justify-between">
<span class="text-gray-600 dark:text-gray-400">Name</span>
<span class="text-gray-700 dark:text-gray-300" x-text="site.contact_name || '—'"></span>
</div>
<div class="flex justify-between">
<span class="text-gray-600 dark:text-gray-400">Email</span>
<span class="text-gray-700 dark:text-gray-300" x-text="site.contact_email || '—'"></span>
</div>
<div class="flex justify-between">
<span class="text-gray-600 dark:text-gray-400">Phone</span>
<span class="text-gray-700 dark:text-gray-300" x-text="site.contact_phone || '—'"></span>
</div>
</div>
</div>
<div class="p-4 bg-white rounded-lg shadow dark:bg-gray-800">
<h3 class="mb-3 text-sm font-semibold text-gray-700 dark:text-gray-200 uppercase">Timeline</h3>
<div class="space-y-2 text-sm">
<div class="flex justify-between">
<span class="text-gray-600 dark:text-gray-400">Created</span>
<span class="text-gray-700 dark:text-gray-300" x-text="site.created_at ? new Date(site.created_at).toLocaleDateString() : '—'"></span>
</div>
<div class="flex justify-between">
<span class="text-gray-600 dark:text-gray-400">Proposal Sent</span>
<span class="text-gray-700 dark:text-gray-300" x-text="site.proposal_sent_at ? new Date(site.proposal_sent_at).toLocaleDateString() : '—'"></span>
</div>
<div class="flex justify-between">
<span class="text-gray-600 dark:text-gray-400">Accepted</span>
<span class="text-gray-700 dark:text-gray-300" x-text="site.proposal_accepted_at ? new Date(site.proposal_accepted_at).toLocaleDateString() : '—'"></span>
</div>
<div class="flex justify-between">
<span class="text-gray-600 dark:text-gray-400">Went Live</span>
<span class="text-gray-700 dark:text-gray-300" x-text="site.went_live_at ? new Date(site.went_live_at).toLocaleDateString() : '—'"></span>
</div>
</div>
</div>
<div class="p-4 bg-white rounded-lg shadow dark:bg-gray-800 md:col-span-2" x-show="site.proposal_notes || site.internal_notes">
<h3 class="mb-3 text-sm font-semibold text-gray-700 dark:text-gray-200 uppercase">Notes</h3>
<div x-show="site.proposal_notes" class="mb-3">
<p class="text-xs text-gray-500 uppercase mb-1">Proposal Notes</p>
<p class="text-sm text-gray-700 dark:text-gray-300" x-text="site.proposal_notes"></p>
</div>
<div x-show="site.internal_notes">
<p class="text-xs text-gray-500 uppercase mb-1">Internal Notes</p>
<p class="text-sm text-gray-700 dark:text-gray-300" x-text="site.internal_notes"></p>
</div>
</div>
</div>
<!-- Tab: Services -->
<div x-show="activeTab === 'services'" class="space-y-4">
<div class="flex justify-end">
<button @click="showServiceModal = true"
class="px-4 py-2 text-sm font-medium text-white bg-teal-600 rounded-lg hover:bg-teal-700">
<span x-html="$icon('plus', 'w-4 h-4 inline mr-1')"></span>
Add Service
</button>
</div>
<template x-for="svc in site.client_services || []" :key="svc.id">
<div class="p-4 bg-white rounded-lg shadow dark:bg-gray-800">
<div class="flex items-center justify-between">
<div>
<span class="text-sm font-semibold text-gray-700 dark:text-gray-200" x-text="svc.name"></span>
<span class="ml-2 px-2 py-0.5 text-xs rounded-full bg-gray-100 text-gray-600 dark:bg-gray-700 dark:text-gray-400"
x-text="svc.service_type.replace('_', ' ')"></span>
</div>
<span class="px-2 py-1 text-xs font-semibold rounded-full"
:class="svc.status === 'active' ? 'bg-green-100 text-green-700' : svc.status === 'expired' ? 'bg-red-100 text-red-700' : 'bg-gray-100 text-gray-600'"
x-text="svc.status"></span>
</div>
<div class="mt-2 flex gap-4 text-xs text-gray-500">
<span x-show="svc.price_cents" x-text="'€' + (svc.price_cents / 100).toFixed(2) + '/' + (svc.billing_period || 'month')"></span>
<span x-show="svc.domain_name" x-text="svc.domain_name"></span>
<span x-show="svc.expires_at" x-text="'Expires: ' + new Date(svc.expires_at).toLocaleDateString()"></span>
<span x-show="svc.mailbox_count" x-text="svc.mailbox_count + ' mailboxes'"></span>
</div>
</div>
</template>
<p x-show="!site.client_services?.length" class="text-sm text-gray-400 text-center py-8">No services yet</p>
</div>
<!-- Tab: Store -->
<div x-show="activeTab === 'store'" class="p-4 bg-white rounded-lg shadow dark:bg-gray-800">
<h3 class="mb-3 text-sm font-semibold text-gray-700 dark:text-gray-200 uppercase">Store Info</h3>
<div class="space-y-2 text-sm">
<div class="flex justify-between">
<span class="text-gray-600 dark:text-gray-400">Store ID</span>
<span class="text-gray-700 dark:text-gray-300" x-text="site.store_id"></span>
</div>
</div>
<div class="mt-4">
<a :href="'/admin/stores/' + site.store_id"
class="inline-flex items-center px-4 py-2 text-sm font-medium text-teal-700 bg-teal-100 rounded-lg hover:bg-teal-200 dark:text-teal-300 dark:bg-teal-900">
<span x-html="$icon('external-link', 'w-4 h-4 mr-2')"></span>
Open in Store Admin
</a>
</div>
</div>
</div>
<!-- Send Proposal Modal --> {# noqa: FE-004 #}
<div x-show="showProposalModal" x-cloak
class="fixed inset-0 z-30 flex items-end bg-black bg-opacity-50 sm:items-center sm:justify-center"
@click.self="showProposalModal = false">
<div class="w-full px-6 py-4 overflow-hidden bg-white rounded-t-lg dark:bg-gray-800 sm:rounded-lg sm:m-4 sm:max-w-xl"
@keydown.escape.window="showProposalModal = false">
<header class="flex justify-between mb-4">
<h3 class="text-lg font-semibold text-gray-700 dark:text-gray-200">Send Proposal</h3>
<button @click="showProposalModal = false" class="text-gray-400 hover:text-gray-600">
<span x-html="$icon('x', 'w-5 h-5')"></span>
</button>
</header>
<div>
<label class="text-sm text-gray-600 dark:text-gray-400">Proposal Notes</label>
<textarea x-model="proposalNotes" rows="4"
class="w-full mt-1 text-sm rounded-lg border-gray-300 dark:border-gray-600 dark:bg-gray-700 dark:text-gray-300"></textarea>
</div>
<footer class="flex justify-end mt-6 space-x-3">
<button @click="showProposalModal = false"
class="px-4 py-2 text-sm font-medium text-gray-700 bg-gray-200 rounded-lg hover:bg-gray-300 dark:text-gray-300 dark:bg-gray-700">Cancel</button>
<button @click="sendProposal()"
class="px-4 py-2 text-sm font-medium text-white bg-yellow-600 rounded-lg hover:bg-yellow-700">Send</button>
</footer>
</div>
</div>
<!-- Accept Proposal Modal --> {# noqa: FE-004 #}
<div x-show="showAcceptModal" x-cloak
class="fixed inset-0 z-30 flex items-end bg-black bg-opacity-50 sm:items-center sm:justify-center"
@click.self="showAcceptModal = false">
<div class="w-full px-6 py-4 overflow-hidden bg-white rounded-t-lg dark:bg-gray-800 sm:rounded-lg sm:m-4 sm:max-w-xl"
@keydown.escape.window="showAcceptModal = false">
<header class="flex justify-between mb-4">
<h3 class="text-lg font-semibold text-gray-700 dark:text-gray-200">Accept Proposal</h3>
<button @click="showAcceptModal = false" class="text-gray-400 hover:text-gray-600">
<span x-html="$icon('x', 'w-5 h-5')"></span>
</button>
</header>
<div>
<label class="text-sm text-gray-600 dark:text-gray-400">Existing Merchant ID (leave empty to create new)</label>
<input type="number" x-model="acceptMerchantId" placeholder="Optional"
class="w-full mt-1 text-sm rounded-lg border-gray-300 dark:border-gray-600 dark:bg-gray-700 dark:text-gray-300">
</div>
<footer class="flex justify-end mt-6 space-x-3">
<button @click="showAcceptModal = false"
class="px-4 py-2 text-sm font-medium text-gray-700 bg-gray-200 rounded-lg hover:bg-gray-300 dark:text-gray-300 dark:bg-gray-700">Cancel</button>
<button @click="acceptProposal()"
class="px-4 py-2 text-sm font-medium text-white bg-purple-600 rounded-lg hover:bg-purple-700">Accept</button>
</footer>
</div>
</div>
<!-- Go Live Modal --> {# noqa: FE-004 #}
<div x-show="showGoLiveModal" x-cloak
class="fixed inset-0 z-30 flex items-end bg-black bg-opacity-50 sm:items-center sm:justify-center"
@click.self="showGoLiveModal = false">
<div class="w-full px-6 py-4 overflow-hidden bg-white rounded-t-lg dark:bg-gray-800 sm:rounded-lg sm:m-4 sm:max-w-xl"
@keydown.escape.window="showGoLiveModal = false">
<header class="flex justify-between mb-4">
<h3 class="text-lg font-semibold text-gray-700 dark:text-gray-200">Go Live</h3>
<button @click="showGoLiveModal = false" class="text-gray-400 hover:text-gray-600">
<span x-html="$icon('x', 'w-5 h-5')"></span>
</button>
</header>
<div>
<label class="text-sm text-gray-600 dark:text-gray-400">Domain *</label>
<input type="text" x-model="goLiveDomain" placeholder="example.lu"
class="w-full mt-1 text-sm rounded-lg border-gray-300 dark:border-gray-600 dark:bg-gray-700 dark:text-gray-300">
</div>
<footer class="flex justify-end mt-6 space-x-3">
<button @click="showGoLiveModal = false"
class="px-4 py-2 text-sm font-medium text-gray-700 bg-gray-200 rounded-lg hover:bg-gray-300 dark:text-gray-300 dark:bg-gray-700">Cancel</button>
<button @click="goLive()"
class="px-4 py-2 text-sm font-medium text-white bg-green-600 rounded-lg hover:bg-green-700">Go Live</button>
</footer>
</div>
</div>
<!-- Add Service Modal --> {# noqa: FE-004 #}
<div x-show="showServiceModal" x-cloak
class="fixed inset-0 z-30 flex items-end bg-black bg-opacity-50 sm:items-center sm:justify-center"
@click.self="showServiceModal = false">
<div class="w-full px-6 py-4 overflow-hidden bg-white rounded-t-lg dark:bg-gray-800 sm:rounded-lg sm:m-4 sm:max-w-xl"
@keydown.escape.window="showServiceModal = false">
<header class="flex justify-between mb-4">
<h3 class="text-lg font-semibold text-gray-700 dark:text-gray-200">Add Service</h3>
<button @click="showServiceModal = false" class="text-gray-400 hover:text-gray-600">
<span x-html="$icon('x', 'w-5 h-5')"></span>
</button>
</header>
<div class="space-y-4">
<div>
<label class="text-sm text-gray-600 dark:text-gray-400">Type</label>
<select x-model="newService.service_type"
class="w-full mt-1 text-sm rounded-lg border-gray-300 dark:border-gray-600 dark:bg-gray-700 dark:text-gray-300">
<option value="domain">Domain</option>
<option value="email">Email</option>
<option value="ssl">SSL</option>
<option value="hosting">Hosting</option>
<option value="website_maintenance">Website Maintenance</option>
</select>
</div>
<div>
<label class="text-sm text-gray-600 dark:text-gray-400">Name</label>
<input type="text" x-model="newService.name" placeholder="e.g., acme.lu domain"
class="w-full mt-1 text-sm rounded-lg border-gray-300 dark:border-gray-600 dark:bg-gray-700 dark:text-gray-300">
</div>
<div class="grid grid-cols-2 gap-4">
<div>
<label class="text-sm text-gray-600 dark:text-gray-400">Price (cents)</label>
{{ number_stepper(model='newService.price_cents', min=0, step=100, size='sm', label='Price (cents)') }}
</div>
<div>
<label class="text-sm text-gray-600 dark:text-gray-400">Billing</label>
<select x-model="newService.billing_period"
class="w-full mt-1 text-sm rounded-lg border-gray-300 dark:border-gray-600 dark:bg-gray-700 dark:text-gray-300">
<option value="monthly">Monthly</option>
<option value="annual">Annual</option>
<option value="one_time">One-time</option>
</select>
</div>
</div>
</div>
<footer class="flex justify-end mt-6 space-x-3">
<button @click="showServiceModal = false"
class="px-4 py-2 text-sm font-medium text-gray-700 bg-gray-200 rounded-lg hover:bg-gray-300 dark:text-gray-300 dark:bg-gray-700">Cancel</button>
<button @click="addService()"
class="px-4 py-2 text-sm font-medium text-white bg-teal-600 rounded-lg hover:bg-teal-700">Add</button>
</footer>
</div>
</div>
{% endblock %}
{% block extra_scripts %}
<script>
function hostingSiteDetail(siteId) {
return {
loading: true,
error: false,
site: null,
activeTab: 'overview',
tabs: [
{ id: 'overview', label: 'Overview' },
{ id: 'services', label: 'Services' },
{ id: 'store', label: 'Store' },
],
showProposalModal: false,
showAcceptModal: false,
showGoLiveModal: false,
showServiceModal: false,
proposalNotes: '',
acceptMerchantId: '',
goLiveDomain: '',
newService: { service_type: 'domain', name: '', price_cents: null, billing_period: 'monthly' },
async init() { await this.loadSite(); },
async loadSite() {
this.loading = true;
try {
const resp = await fetch('/api/admin/hosting/sites/' + siteId);
if (!resp.ok) throw new Error('Failed to load');
this.site = await resp.json();
} catch (e) { this.error = true; }
finally { this.loading = false; }
},
async doAction(action, body = {}) {
try {
const resp = await fetch('/api/admin/hosting/sites/' + siteId + '/' + action, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
});
if (!resp.ok) { const err = await resp.json(); alert(err.detail || 'Action failed'); return; }
await this.loadSite();
} catch (e) { alert('Action failed'); }
},
async sendProposal() {
await this.doAction('send-proposal', { notes: this.proposalNotes });
this.showProposalModal = false;
},
async acceptProposal() {
const body = {};
if (this.acceptMerchantId) body.merchant_id = parseInt(this.acceptMerchantId);
await this.doAction('accept', body);
this.showAcceptModal = false;
},
async goLive() {
await this.doAction('go-live', { domain: this.goLiveDomain });
this.showGoLiveModal = false;
},
async addService() {
try {
const resp = await fetch('/api/admin/hosting/sites/' + siteId + '/services', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(this.newService),
});
if (!resp.ok) { const err = await resp.json(); alert(err.detail || 'Failed'); return; }
this.showServiceModal = false;
this.newService = { service_type: 'domain', name: '', price_cents: null, billing_period: 'monthly' };
await this.loadSite();
} catch (e) { alert('Failed to add service'); }
},
statusBadgeClass(status) {
const classes = {
draft: 'text-gray-700 bg-gray-100 dark:text-gray-300 dark:bg-gray-700',
poc_ready: 'text-blue-700 bg-blue-100 dark:text-blue-100 dark:bg-blue-700',
proposal_sent: 'text-yellow-700 bg-yellow-100 dark:text-yellow-100 dark:bg-yellow-700',
accepted: 'text-purple-700 bg-purple-100 dark:text-purple-100 dark:bg-purple-700',
live: 'text-green-700 bg-green-100 dark:text-green-100 dark:bg-green-700',
suspended: 'text-red-700 bg-red-100 dark:text-red-100 dark:bg-red-700',
cancelled: 'text-gray-500 bg-gray-200 dark:text-gray-400 dark:bg-gray-600',
};
return classes[status] || 'text-gray-700 bg-gray-100';
},
};
}
</script>
{% endblock %}

View File

@@ -0,0 +1,115 @@
{% extends "admin/base.html" %}
{% from 'shared/macros/headers.html' import page_header %}
{% block title %}New Hosted Site{% endblock %}
{% block alpine_data %}hostingSiteNew(){% endblock %}
{% block content %}
{{ page_header('New Hosted Site') }}
<div class="max-w-2xl">
<div class="p-6 bg-white rounded-lg shadow dark:bg-gray-800">
<div class="space-y-6">
<div>
<label class="text-sm font-medium text-gray-700 dark:text-gray-300">Business Name *</label>
<input type="text" x-model="form.business_name" placeholder="Acme Luxembourg SARL"
class="w-full mt-1 text-sm rounded-lg border-gray-300 dark:border-gray-600 dark:bg-gray-700 dark:text-gray-300 focus:border-teal-400 focus:ring-teal-300">
</div>
<div class="grid grid-cols-1 gap-6 md:grid-cols-2">
<div>
<label class="text-sm font-medium text-gray-700 dark:text-gray-300">Contact Name</label>
<input type="text" x-model="form.contact_name" placeholder="Jean Dupont"
class="w-full mt-1 text-sm rounded-lg border-gray-300 dark:border-gray-600 dark:bg-gray-700 dark:text-gray-300">
</div>
<div>
<label class="text-sm font-medium text-gray-700 dark:text-gray-300">Contact Email</label>
<input type="email" x-model="form.contact_email" placeholder="contact@acme.lu"
class="w-full mt-1 text-sm rounded-lg border-gray-300 dark:border-gray-600 dark:bg-gray-700 dark:text-gray-300">
</div>
</div>
<div>
<label class="text-sm font-medium text-gray-700 dark:text-gray-300">Contact Phone</label>
<input type="tel" x-model="form.contact_phone" placeholder="+352 ..."
class="w-full mt-1 text-sm rounded-lg border-gray-300 dark:border-gray-600 dark:bg-gray-700 dark:text-gray-300">
</div>
<div>
<label class="text-sm font-medium text-gray-700 dark:text-gray-300">Internal Notes</label>
<textarea x-model="form.internal_notes" rows="3" placeholder="Any internal notes..."
class="w-full mt-1 text-sm rounded-lg border-gray-300 dark:border-gray-600 dark:bg-gray-700 dark:text-gray-300"></textarea>
</div>
<!-- Prospect Selector -->
<div>
<label class="text-sm font-medium text-gray-700 dark:text-gray-300">Create from Prospect (optional)</label>
<div class="flex mt-1 space-x-2">
<input type="number" x-model="prospectId" placeholder="Prospect ID"
class="flex-1 text-sm rounded-lg border-gray-300 dark:border-gray-600 dark:bg-gray-700 dark:text-gray-300">
<button @click="createFromProspect()"
:disabled="!prospectId || creating"
class="px-4 py-2 text-sm font-medium text-white bg-teal-600 rounded-lg hover:bg-teal-700 disabled:opacity-50">
Create from Prospect
</button>
</div>
</div>
</div>
<div class="flex justify-end mt-8 space-x-3">
<a href="/admin/hosting/sites"
class="px-4 py-2 text-sm font-medium text-gray-700 bg-gray-200 rounded-lg hover:bg-gray-300 dark:text-gray-300 dark:bg-gray-700">
Cancel
</a>
<button @click="createSite()"
:disabled="!form.business_name || creating"
class="px-4 py-2 text-sm font-medium text-white bg-teal-600 rounded-lg hover:bg-teal-700 disabled:opacity-50">
<span x-show="!creating">Create Site</span>
<span x-show="creating">Creating...</span>
</button>
</div>
<div x-show="errorMsg" class="mt-4 p-3 text-sm text-red-700 bg-red-100 rounded-lg dark:bg-red-900 dark:text-red-300" x-text="errorMsg"></div>
</div>
</div>
{% endblock %}
{% block extra_scripts %}
<script>
function hostingSiteNew() {
return {
form: { business_name: '', contact_name: '', contact_email: '', contact_phone: '', internal_notes: '' },
prospectId: '',
creating: false,
errorMsg: '',
async createSite() {
this.creating = true;
this.errorMsg = '';
try {
const resp = await fetch('/api/admin/hosting/sites', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(this.form),
});
if (!resp.ok) { const err = await resp.json(); this.errorMsg = err.detail || 'Failed to create'; return; }
const site = await resp.json();
window.location.href = '/admin/hosting/sites/' + site.id;
} catch (e) { this.errorMsg = 'Failed to create site'; }
finally { this.creating = false; }
},
async createFromProspect() {
this.creating = true;
this.errorMsg = '';
try {
const resp = await fetch('/api/admin/hosting/sites/from-prospect/' + this.prospectId, { method: 'POST' });
if (!resp.ok) { const err = await resp.json(); this.errorMsg = err.detail || 'Failed to create'; return; }
const site = await resp.json();
window.location.href = '/admin/hosting/sites/' + site.id;
} catch (e) { this.errorMsg = 'Failed to create from prospect'; }
finally { this.creating = false; }
},
};
}
</script>
{% endblock %}

View File

@@ -0,0 +1,152 @@
{% extends "admin/base.html" %}
{% from 'shared/macros/headers.html' import page_header %}
{% from 'shared/macros/alerts.html' import loading_state, error_state %}
{% from 'shared/macros/tables.html' import table_header, table_empty %}
{% from 'shared/macros/pagination.html' import pagination_controls %}
{% block title %}Hosted Sites{% endblock %}
{% block alpine_data %}hostingSitesList(){% endblock %}
{% block content %}
{{ page_header('Hosted Sites', action_label='New Site', action_href='/admin/hosting/sites/new', action_icon='plus') }}
<!-- Filters -->
<div class="mb-6 p-4 bg-white rounded-lg shadow dark:bg-gray-800">
<div class="grid gap-4 md:grid-cols-3">
<div>
<label class="text-sm text-gray-600 dark:text-gray-400">Search</label>
<input type="text" x-model="search" @input.debounce.300ms="loadSites()"
placeholder="Business name, email, domain..."
class="w-full mt-1 text-sm rounded-lg border-gray-300 dark:border-gray-600 dark:bg-gray-700 dark:text-gray-300 focus:border-teal-400 focus:ring-teal-300">
</div>
<div>
<label class="text-sm text-gray-600 dark:text-gray-400">Status</label>
<select x-model="filterStatus" @change="loadSites()"
class="w-full mt-1 text-sm rounded-lg border-gray-300 dark:border-gray-600 dark:bg-gray-700 dark:text-gray-300">
<option value="">All</option>
<option value="draft">Draft</option>
<option value="poc_ready">POC Ready</option>
<option value="proposal_sent">Proposal Sent</option>
<option value="accepted">Accepted</option>
<option value="live">Live</option>
<option value="suspended">Suspended</option>
<option value="cancelled">Cancelled</option>
</select>
</div>
<div class="flex items-end">
<a href="/admin/prospecting/prospects"
class="inline-flex items-center px-4 py-2 text-sm font-medium text-teal-700 bg-teal-100 rounded-lg hover:bg-teal-200 dark:text-teal-300 dark:bg-teal-900 dark:hover:bg-teal-800">
<span x-html="$icon('cursor-click', 'w-4 h-4 mr-2')"></span>
Create from Prospect
</a>
</div>
</div>
</div>
{{ loading_state('Loading sites...') }}
{{ error_state('Error loading sites') }}
<!-- Sites Table -->
<div x-show="!loading && !error" class="w-full overflow-hidden rounded-lg shadow">
<div class="w-full overflow-x-auto">
<table class="w-full whitespace-nowrap">
<thead>
<tr class="text-xs font-semibold tracking-wide text-left text-gray-500 uppercase border-b dark:border-gray-700 bg-gray-50 dark:text-gray-400 dark:bg-gray-800">
<th class="px-4 py-3">Business</th>
<th class="px-4 py-3">Status</th>
<th class="px-4 py-3">Contact</th>
<th class="px-4 py-3">Domain</th>
<th class="px-4 py-3">Created</th>
<th class="px-4 py-3">Actions</th>
</tr>
</thead>
<tbody class="bg-white divide-y dark:divide-gray-700 dark:bg-gray-800">
<template x-for="s in sites" :key="s.id">
<tr class="text-gray-700 dark:text-gray-400 hover:bg-gray-50 dark:hover:bg-gray-700">
<td class="px-4 py-3">
<div class="flex items-center text-sm">
<div>
<p class="font-semibold" x-text="s.business_name"></p>
<p class="text-xs text-gray-600 dark:text-gray-400" x-text="s.contact_name || ''"></p>
</div>
</div>
</td>
<td class="px-4 py-3 text-sm">
<span class="px-2 py-1 text-xs font-semibold rounded-full"
:class="statusBadgeClass(s.status)"
x-text="s.status.replace('_', ' ')"></span>
</td>
<td class="px-4 py-3 text-sm">
<span x-text="s.contact_email || '—'" class="text-xs"></span>
</td>
<td class="px-4 py-3 text-sm">
<span x-text="s.live_domain || '—'" class="text-xs"></span>
</td>
<td class="px-4 py-3 text-sm">
<span x-text="new Date(s.created_at).toLocaleDateString()" class="text-xs"></span>
</td>
<td class="px-4 py-3 text-sm">
<a :href="'/admin/hosting/sites/' + s.id"
class="text-teal-600 hover:text-teal-900 dark:text-teal-400 dark:hover:text-teal-300">
View
</a>
</td>
</tr>
</template>
</tbody>
</table>
</div>
{{ table_empty('No hosted sites found') }}
</div>
{{ pagination_controls() }}
{% endblock %}
{% block extra_scripts %}
<script>
function hostingSitesList() {
return {
loading: true,
error: false,
sites: [],
total: 0,
page: 1,
perPage: 20,
pages: 0,
search: '',
filterStatus: '',
async init() { await this.loadSites(); },
async loadSites() {
this.loading = true;
this.error = false;
try {
const params = new URLSearchParams({ page: this.page, per_page: this.perPage });
if (this.search) params.set('search', this.search);
if (this.filterStatus) params.set('status', this.filterStatus);
const resp = await fetch('/api/admin/hosting/sites?' + params);
if (!resp.ok) throw new Error('Failed to load');
const data = await resp.json();
this.sites = data.items;
this.total = data.total;
this.pages = data.pages;
} catch (e) { this.error = true; }
finally { this.loading = false; }
},
goToPage(p) { this.page = p; this.loadSites(); },
statusBadgeClass(status) {
const classes = {
draft: 'text-gray-700 bg-gray-100 dark:text-gray-300 dark:bg-gray-700',
poc_ready: 'text-blue-700 bg-blue-100 dark:text-blue-100 dark:bg-blue-700',
proposal_sent: 'text-yellow-700 bg-yellow-100 dark:text-yellow-100 dark:bg-yellow-700',
accepted: 'text-purple-700 bg-purple-100 dark:text-purple-100 dark:bg-purple-700',
live: 'text-green-700 bg-green-100 dark:text-green-100 dark:bg-green-700',
suspended: 'text-red-700 bg-red-100 dark:text-red-100 dark:bg-red-700',
cancelled: 'text-gray-500 bg-gray-200 dark:text-gray-400 dark:bg-gray-600',
};
return classes[status] || 'text-gray-700 bg-gray-100';
},
};
}
</script>
{% endblock %}

View File

@@ -0,0 +1,67 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{{ site.business_name }} - Preview by HostWizard</title>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
.hw-banner {
position: fixed;
top: 0;
left: 0;
right: 0;
z-index: 9999;
background: linear-gradient(135deg, #0D9488, #14B8A6);
color: white;
padding: 10px 20px;
display: flex;
align-items: center;
justify-content: space-between;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
font-size: 14px;
box-shadow: 0 2px 8px rgba(0,0,0,0.15);
}
.hw-banner-left { display: flex; align-items: center; gap: 12px; }
.hw-banner-logo { font-weight: 700; font-size: 16px; }
.hw-banner-text { opacity: 0.9; }
.hw-banner-right { display: flex; align-items: center; gap: 12px; }
.hw-banner-link {
color: white;
text-decoration: none;
padding: 6px 16px;
border: 1px solid rgba(255,255,255,0.4);
border-radius: 6px;
font-size: 13px;
transition: background 0.2s;
}
.hw-banner-link:hover { background: rgba(255,255,255,0.15); }
.hw-iframe-container {
position: fixed;
top: 48px;
left: 0;
right: 0;
bottom: 0;
}
.hw-iframe-container iframe {
width: 100%;
height: 100%;
border: none;
}
</style>
</head>
<body>
<div class="hw-banner">
<div class="hw-banner-left">
<span class="hw-banner-logo">HostWizard</span>
<span class="hw-banner-text">Preview for {{ site.business_name }}</span>
</div>
<div class="hw-banner-right">
<a href="https://hostwizard.lu" class="hw-banner-link" target="_blank">hostwizard.lu</a>
</div>
</div>
<div class="hw-iframe-container">
<iframe src="{{ store_url }}" title="Site preview"></iframe>
</div>
</body>
</html>

View File

@@ -1,7 +1,7 @@
"""prospecting: initial tables for lead discovery and campaign management
Revision ID: prospecting_001
Revises: None
Revises: cms_002
Create Date: 2026-02-27
"""
import sqlalchemy as sa
@@ -9,8 +9,8 @@ import sqlalchemy as sa
from alembic import op
revision = "prospecting_001"
down_revision = None
branch_labels = ("prospecting",)
down_revision = "cms_002"
branch_labels = None
depends_on = None