feat(hosting): add HostWizard platform module and fix migration chain
Some checks failed
Some checks failed
- 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:
@@ -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
|
||||
|
||||
|
||||
36
app/modules/hosting/__init__.py
Normal file
36
app/modules/hosting/__init__.py
Normal 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"]
|
||||
31
app/modules/hosting/config.py
Normal file
31
app/modules/hosting/config.py
Normal 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()
|
||||
132
app/modules/hosting/definition.py
Normal file
132
app/modules/hosting/definition.py
Normal 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"]
|
||||
57
app/modules/hosting/exceptions.py
Normal file
57
app/modules/hosting/exceptions.py
Normal 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",
|
||||
]
|
||||
22
app/modules/hosting/locales/de.json
Normal file
22
app/modules/hosting/locales/de.json
Normal 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"
|
||||
}
|
||||
}
|
||||
22
app/modules/hosting/locales/en.json
Normal file
22
app/modules/hosting/locales/en.json
Normal 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"
|
||||
}
|
||||
}
|
||||
22
app/modules/hosting/locales/fr.json
Normal file
22
app/modules/hosting/locales/fr.json
Normal 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"
|
||||
}
|
||||
}
|
||||
22
app/modules/hosting/locales/lb.json
Normal file
22
app/modules/hosting/locales/lb.json
Normal 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"
|
||||
}
|
||||
}
|
||||
0
app/modules/hosting/migrations/__init__.py
Normal file
0
app/modules/hosting/migrations/__init__.py
Normal file
0
app/modules/hosting/migrations/versions/__init__.py
Normal file
0
app/modules/hosting/migrations/versions/__init__.py
Normal file
125
app/modules/hosting/migrations/versions/hosting_001_initial.py
Normal file
125
app/modules/hosting/migrations/versions/hosting_001_initial.py
Normal 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")
|
||||
20
app/modules/hosting/models/__init__.py
Normal file
20
app/modules/hosting/models/__init__.py
Normal 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",
|
||||
]
|
||||
97
app/modules/hosting/models/client_service.py
Normal file
97
app/modules/hosting/models/client_service.py
Normal 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")
|
||||
81
app/modules/hosting/models/hosted_site.py
Normal file
81
app/modules/hosting/models/hosted_site.py
Normal 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}"
|
||||
1
app/modules/hosting/routes/__init__.py
Normal file
1
app/modules/hosting/routes/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
# app/modules/hosting/routes/__init__.py
|
||||
1
app/modules/hosting/routes/api/__init__.py
Normal file
1
app/modules/hosting/routes/api/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
# app/modules/hosting/routes/api/__init__.py
|
||||
21
app/modules/hosting/routes/api/admin.py
Normal file
21
app/modules/hosting/routes/api/admin.py
Normal 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"])
|
||||
@@ -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")
|
||||
|
||||
210
app/modules/hosting/routes/api/admin_sites.py
Normal file
210
app/modules/hosting/routes/api/admin_sites.py
Normal 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)
|
||||
26
app/modules/hosting/routes/api/admin_stats.py
Normal file
26
app/modules/hosting/routes/api/admin_stats.py
Normal 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)
|
||||
1
app/modules/hosting/routes/pages/__init__.py
Normal file
1
app/modules/hosting/routes/pages/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
# app/modules/hosting/routes/pages/__init__.py
|
||||
95
app/modules/hosting/routes/pages/admin.py
Normal file
95
app/modules/hosting/routes/pages/admin.py
Normal 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),
|
||||
)
|
||||
46
app/modules/hosting/routes/pages/public.py
Normal file
46
app/modules/hosting/routes/pages/public.py
Normal 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,
|
||||
)
|
||||
32
app/modules/hosting/schemas/__init__.py
Normal file
32
app/modules/hosting/schemas/__init__.py
Normal 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",
|
||||
]
|
||||
80
app/modules/hosting/schemas/client_service.py
Normal file
80
app/modules/hosting/schemas/client_service.py
Normal 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
|
||||
101
app/modules/hosting/schemas/hosted_site.py
Normal file
101
app/modules/hosting/schemas/hosted_site.py
Normal 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()
|
||||
1
app/modules/hosting/services/__init__.py
Normal file
1
app/modules/hosting/services/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
# app/modules/hosting/services/__init__.py
|
||||
128
app/modules/hosting/services/client_service_service.py
Normal file
128
app/modules/hosting/services/client_service_service.py
Normal 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()
|
||||
325
app/modules/hosting/services/hosted_site_service.py
Normal file
325
app/modules/hosting/services/hosted_site_service.py
Normal 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()
|
||||
101
app/modules/hosting/services/stats_service.py
Normal file
101
app/modules/hosting/services/stats_service.py
Normal 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()
|
||||
0
app/modules/hosting/static/admin/js/.gitkeep
Normal file
0
app/modules/hosting/static/admin/js/.gitkeep
Normal file
1
app/modules/hosting/tasks/__init__.py
Normal file
1
app/modules/hosting/tasks/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
# app/modules/hosting/tasks/__init__.py
|
||||
165
app/modules/hosting/templates/hosting/admin/clients.html
Normal file
165
app/modules/hosting/templates/hosting/admin/clients.html
Normal 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 %}
|
||||
148
app/modules/hosting/templates/hosting/admin/dashboard.html
Normal file
148
app/modules/hosting/templates/hosting/admin/dashboard.html
Normal 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 %}
|
||||
405
app/modules/hosting/templates/hosting/admin/site-detail.html
Normal file
405
app/modules/hosting/templates/hosting/admin/site-detail.html
Normal 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 %}
|
||||
115
app/modules/hosting/templates/hosting/admin/site-new.html
Normal file
115
app/modules/hosting/templates/hosting/admin/site-new.html
Normal 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 %}
|
||||
152
app/modules/hosting/templates/hosting/admin/sites.html
Normal file
152
app/modules/hosting/templates/hosting/admin/sites.html
Normal 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 %}
|
||||
67
app/modules/hosting/templates/hosting/public/poc-viewer.html
Normal file
67
app/modules/hosting/templates/hosting/public/poc-viewer.html
Normal 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>
|
||||
@@ -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
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user