refactor all the things
This commit is contained in:
parent
8ed39690f1
commit
bb5cb708bd
36 changed files with 1563 additions and 931 deletions
2
.gitignore
vendored
2
.gitignore
vendored
|
@ -11,5 +11,5 @@ wheels/
|
||||||
|
|
||||||
# Project specifics
|
# Project specifics
|
||||||
.env
|
.env
|
||||||
hub/db.sqlite3
|
*.sqlite3
|
||||||
hub/media/
|
hub/media/
|
|
@ -57,6 +57,7 @@ INSTALLED_APPS = [
|
||||||
# 3rd party
|
# 3rd party
|
||||||
"django_prose_editor",
|
"django_prose_editor",
|
||||||
"rest_framework",
|
"rest_framework",
|
||||||
|
"schema_viewer",
|
||||||
# local
|
# local
|
||||||
"services",
|
"services",
|
||||||
"servicebroker",
|
"servicebroker",
|
||||||
|
@ -174,3 +175,12 @@ REST_FRAMEWORK = {
|
||||||
],
|
],
|
||||||
"UNAUTHENTICATED_USER": None,
|
"UNAUTHENTICATED_USER": None,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
SCHEMA_VIEWER = {
|
||||||
|
"apps": [
|
||||||
|
"services",
|
||||||
|
],
|
||||||
|
"exclude": {
|
||||||
|
"auth": ["User"],
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
|
@ -11,5 +11,6 @@ urlpatterns = [
|
||||||
if settings.DEBUG:
|
if settings.DEBUG:
|
||||||
urlpatterns += [
|
urlpatterns += [
|
||||||
path("__reload__/", include("django_browser_reload.urls")),
|
path("__reload__/", include("django_browser_reload.urls")),
|
||||||
|
path("schema-viewer/", include("schema_viewer.urls")),
|
||||||
]
|
]
|
||||||
urlpatterns += static(settings.MEDIA_URL, document_root=settings.MEDIA_ROOT)
|
urlpatterns += static(settings.MEDIA_URL, document_root=settings.MEDIA_ROOT)
|
||||||
|
|
|
@ -1,46 +1,19 @@
|
||||||
from django.contrib import admin
|
from django.contrib import admin
|
||||||
from django.utils.html import format_html
|
from django.utils.html import format_html
|
||||||
from .models import (
|
from .models import (
|
||||||
|
Category,
|
||||||
CloudProvider,
|
CloudProvider,
|
||||||
ConsultingPartner,
|
ConsultingPartner,
|
||||||
Service,
|
|
||||||
Category,
|
|
||||||
Currency,
|
Currency,
|
||||||
|
ExternalLink,
|
||||||
Plan,
|
Plan,
|
||||||
PlanPrice,
|
PlanPrice,
|
||||||
ExternalLink,
|
Service,
|
||||||
|
ServiceOffering,
|
||||||
|
Term,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
class PlanPriceInline(admin.TabularInline):
|
|
||||||
model = PlanPrice
|
|
||||||
extra = 1
|
|
||||||
min_num = 1
|
|
||||||
verbose_name = "Price"
|
|
||||||
verbose_name_plural = "Prices"
|
|
||||||
|
|
||||||
|
|
||||||
class PlanInline(admin.StackedInline):
|
|
||||||
model = Plan
|
|
||||||
extra = 1
|
|
||||||
show_change_link = True
|
|
||||||
fields = ("name", "description", "is_default", "features", "order")
|
|
||||||
classes = ("collapse",)
|
|
||||||
inlines = [PlanPriceInline]
|
|
||||||
|
|
||||||
def get_formset(self, request, obj=None, **kwargs):
|
|
||||||
formset = super().get_formset(request, obj, **kwargs)
|
|
||||||
formset.request = request
|
|
||||||
return formset
|
|
||||||
|
|
||||||
|
|
||||||
class ExternalLinkInline(admin.TabularInline):
|
|
||||||
model = ExternalLink
|
|
||||||
extra = 1
|
|
||||||
fields = ("description", "url", "order")
|
|
||||||
ordering = ("order", "description")
|
|
||||||
|
|
||||||
|
|
||||||
@admin.register(Category)
|
@admin.register(Category)
|
||||||
class CategoryAdmin(admin.ModelAdmin):
|
class CategoryAdmin(admin.ModelAdmin):
|
||||||
list_display = ("name", "slug", "parent", "order")
|
list_display = ("name", "slug", "parent", "order")
|
||||||
|
@ -50,73 +23,6 @@ class CategoryAdmin(admin.ModelAdmin):
|
||||||
ordering = ("order", "name")
|
ordering = ("order", "name")
|
||||||
|
|
||||||
|
|
||||||
@admin.register(CloudProvider)
|
|
||||||
class CloudProviderAdmin(admin.ModelAdmin):
|
|
||||||
list_display = ("name", "slug", "logo_preview")
|
|
||||||
search_fields = ("name",)
|
|
||||||
prepopulated_fields = {"slug": ("name",)}
|
|
||||||
|
|
||||||
def logo_preview(self, obj):
|
|
||||||
if obj.logo:
|
|
||||||
return format_html(
|
|
||||||
'<img src="{}" style="max-height: 50px;"/>', obj.logo.url
|
|
||||||
)
|
|
||||||
return "No logo"
|
|
||||||
|
|
||||||
|
|
||||||
@admin.register(Service)
|
|
||||||
class ServiceAdmin(admin.ModelAdmin):
|
|
||||||
list_display = (
|
|
||||||
"name",
|
|
||||||
"cloud_provider",
|
|
||||||
"logo_preview",
|
|
||||||
"category_list",
|
|
||||||
"partner_list",
|
|
||||||
)
|
|
||||||
list_filter = (
|
|
||||||
"cloud_provider",
|
|
||||||
"categories",
|
|
||||||
"consulting_partners",
|
|
||||||
)
|
|
||||||
filter_horizontal = ("categories", "consulting_partners")
|
|
||||||
search_fields = ("name", "description", "slug")
|
|
||||||
prepopulated_fields = {"slug": ("name",)}
|
|
||||||
inlines = [PlanInline, ExternalLinkInline]
|
|
||||||
|
|
||||||
def logo_preview(self, obj):
|
|
||||||
if obj.logo:
|
|
||||||
return format_html(
|
|
||||||
'<img src="{}" style="max-height: 50px;"/>', obj.logo.url
|
|
||||||
)
|
|
||||||
return "No logo"
|
|
||||||
|
|
||||||
def category_list(self, obj):
|
|
||||||
return ", ".join([cat.name for cat in obj.categories.all()])
|
|
||||||
|
|
||||||
def partner_list(self, obj):
|
|
||||||
return ", ".join([partner.name for partner in obj.consulting_partners.all()])
|
|
||||||
|
|
||||||
partner_list.short_description = "Consulting Partners"
|
|
||||||
category_list.short_description = "Categories"
|
|
||||||
|
|
||||||
class Media:
|
|
||||||
css = {"all": ("admin/css/hide_inline_header.css",)}
|
|
||||||
|
|
||||||
|
|
||||||
@admin.register(ConsultingPartner)
|
|
||||||
class ConsultingPartnerAdmin(admin.ModelAdmin):
|
|
||||||
list_display = ("name", "website", "logo_preview")
|
|
||||||
search_fields = ("name", "description")
|
|
||||||
prepopulated_fields = {"slug": ("name",)}
|
|
||||||
|
|
||||||
def logo_preview(self, obj):
|
|
||||||
if obj.logo:
|
|
||||||
return format_html(
|
|
||||||
'<img src="{}" style="max-height: 50px;"/>', obj.logo.url
|
|
||||||
)
|
|
||||||
return "No logo"
|
|
||||||
|
|
||||||
|
|
||||||
@admin.register(Currency)
|
@admin.register(Currency)
|
||||||
class CurrencyAdmin(admin.ModelAdmin):
|
class CurrencyAdmin(admin.ModelAdmin):
|
||||||
list_display = ("code", "name", "symbol")
|
list_display = ("code", "name", "symbol")
|
||||||
|
@ -124,10 +30,139 @@ class CurrencyAdmin(admin.ModelAdmin):
|
||||||
ordering = ("code",)
|
ordering = ("code",)
|
||||||
|
|
||||||
|
|
||||||
@admin.register(Plan)
|
@admin.register(Term)
|
||||||
class PlanAdmin(admin.ModelAdmin):
|
class TermAdmin(admin.ModelAdmin):
|
||||||
list_display = ("name", "service", "is_default", "order")
|
list_display = ("name", "order")
|
||||||
list_filter = ("service", "is_default")
|
ordering = ("order", "name")
|
||||||
search_fields = ("name", "description")
|
search_fields = ("name", "description")
|
||||||
ordering = ("service", "order", "name")
|
|
||||||
inlines = [PlanPriceInline]
|
|
||||||
|
@admin.register(CloudProvider)
|
||||||
|
class CloudProviderAdmin(admin.ModelAdmin):
|
||||||
|
list_display = ("name", "slug", "logo_preview")
|
||||||
|
search_fields = ("name", "description")
|
||||||
|
prepopulated_fields = {"slug": ("name",)}
|
||||||
|
|
||||||
|
def logo_preview(self, obj):
|
||||||
|
if obj.logo:
|
||||||
|
return format_html(
|
||||||
|
'<img src="{}" style="max-height: 50px;"/>', obj.logo.url
|
||||||
|
)
|
||||||
|
return "No logo"
|
||||||
|
|
||||||
|
logo_preview.short_description = "Logo"
|
||||||
|
|
||||||
|
|
||||||
|
class ExternalLinkInline(admin.TabularInline):
|
||||||
|
model = ExternalLink
|
||||||
|
extra = 1
|
||||||
|
fields = ("description", "url", "order")
|
||||||
|
ordering = ("order", "description")
|
||||||
|
|
||||||
|
|
||||||
|
@admin.register(Service)
|
||||||
|
class ServiceAdmin(admin.ModelAdmin):
|
||||||
|
list_display = ("name", "logo_preview", "category_list", "partner_list")
|
||||||
|
list_filter = ("categories",)
|
||||||
|
search_fields = ("name", "description", "slug")
|
||||||
|
prepopulated_fields = {"slug": ("name",)}
|
||||||
|
filter_horizontal = ("categories",)
|
||||||
|
inlines = [ExternalLinkInline]
|
||||||
|
|
||||||
|
def logo_preview(self, obj):
|
||||||
|
if obj.logo:
|
||||||
|
return format_html(
|
||||||
|
'<img src="{}" style="max-height: 50px;"/>', obj.logo.url
|
||||||
|
)
|
||||||
|
return "No logo"
|
||||||
|
|
||||||
|
logo_preview.short_description = "Logo"
|
||||||
|
|
||||||
|
def category_list(self, obj):
|
||||||
|
return ", ".join([cat.name for cat in obj.categories.all()])
|
||||||
|
|
||||||
|
category_list.short_description = "Categories"
|
||||||
|
|
||||||
|
def partner_list(self, obj):
|
||||||
|
return ", ".join([partner.name for partner in obj.consulting_partners.all()])
|
||||||
|
|
||||||
|
partner_list.short_description = "Consulting Partners"
|
||||||
|
|
||||||
|
|
||||||
|
@admin.register(PlanPrice)
|
||||||
|
class PlanPriceAdmin(admin.ModelAdmin):
|
||||||
|
model = PlanPrice
|
||||||
|
|
||||||
|
|
||||||
|
class PlanInline(admin.StackedInline):
|
||||||
|
model = Plan
|
||||||
|
extra = 1
|
||||||
|
fieldsets = (
|
||||||
|
(None, {"fields": ("name", "description", "is_default", "features", "order")}),
|
||||||
|
(
|
||||||
|
"Pricing",
|
||||||
|
{
|
||||||
|
"fields": ("get_prices",),
|
||||||
|
},
|
||||||
|
),
|
||||||
|
)
|
||||||
|
readonly_fields = ("get_prices",)
|
||||||
|
|
||||||
|
def get_prices(self, obj):
|
||||||
|
if not obj:
|
||||||
|
return "Save the plan first to add prices"
|
||||||
|
|
||||||
|
html = ['<table class="price-table">']
|
||||||
|
html.append(
|
||||||
|
"<tr><th>Currency</th><th>Term</th><th>Price</th><th>Actions</th></tr>"
|
||||||
|
)
|
||||||
|
|
||||||
|
for price in obj.prices.all():
|
||||||
|
html.append(
|
||||||
|
f"""
|
||||||
|
<tr>
|
||||||
|
<td>{price.currency}</td>
|
||||||
|
<td>{price.term}</td>
|
||||||
|
<td>{price.price}</td>
|
||||||
|
<td>
|
||||||
|
<a href="/admin/services/planprice/{price.id}/change/" class="button" target="_blank">Edit</a>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
|
||||||
|
html.append("</table>")
|
||||||
|
html.append(
|
||||||
|
f'<a href="/admin/services/planprice/add/?plan={obj.id}" class="button" target="_blank">Add Price</a>'
|
||||||
|
)
|
||||||
|
return format_html("".join(html))
|
||||||
|
|
||||||
|
get_prices.short_description = "Plan Prices"
|
||||||
|
|
||||||
|
|
||||||
|
@admin.register(ServiceOffering)
|
||||||
|
class ServiceOfferingAdmin(admin.ModelAdmin):
|
||||||
|
list_display = ("service", "cloud_provider")
|
||||||
|
list_filter = ("service", "cloud_provider")
|
||||||
|
search_fields = ("service__name", "cloud_provider__name", "description")
|
||||||
|
inlines = [PlanInline]
|
||||||
|
|
||||||
|
class Media:
|
||||||
|
css = {"all": ("admin/css/service_offering.css",)}
|
||||||
|
|
||||||
|
|
||||||
|
@admin.register(ConsultingPartner)
|
||||||
|
class ConsultingPartnerAdmin(admin.ModelAdmin):
|
||||||
|
list_display = ("name", "website", "logo_preview")
|
||||||
|
search_fields = ("name", "description")
|
||||||
|
prepopulated_fields = {"slug": ("name",)}
|
||||||
|
filter_horizontal = ("services", "cloud_providers")
|
||||||
|
|
||||||
|
def logo_preview(self, obj):
|
||||||
|
if obj.logo:
|
||||||
|
return format_html(
|
||||||
|
'<img src="{}" style="max-height: 50px;"/>', obj.logo.url
|
||||||
|
)
|
||||||
|
return "No logo"
|
||||||
|
|
||||||
|
logo_preview.short_description = "Logo"
|
||||||
|
|
|
@ -1,9 +1,43 @@
|
||||||
# Generated by Django 5.1.5 on 2025-01-27 12:25
|
# Generated by Django 5.1.5 on 2025-01-29 08:34
|
||||||
|
|
||||||
import django.db.models.deletion
|
import django.db.models.deletion
|
||||||
|
import services.models
|
||||||
from django.db import migrations, models
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
def create_initial_currencies(apps, schema_editor):
|
||||||
|
Currency = apps.get_model("services", "Currency")
|
||||||
|
currencies = [
|
||||||
|
{"code": "USD", "name": "US Dollar", "symbol": "$"},
|
||||||
|
{"code": "CHF", "name": "Swiss Franc", "symbol": "CHF"},
|
||||||
|
{"code": "EUR", "name": "Euro", "symbol": "€"},
|
||||||
|
]
|
||||||
|
for currency_data in currencies:
|
||||||
|
Currency.objects.create(**currency_data)
|
||||||
|
|
||||||
|
|
||||||
|
def create_initial_terms(apps, schema_editor):
|
||||||
|
Term = apps.get_model("services", "Term")
|
||||||
|
terms = [
|
||||||
|
{"name": "Monthly (30d)", "order": 1},
|
||||||
|
{"name": "Yearly", "order": 2},
|
||||||
|
{"name": "One-time", "order": 3},
|
||||||
|
]
|
||||||
|
for term_data in terms:
|
||||||
|
Term.objects.create(**term_data)
|
||||||
|
|
||||||
|
|
||||||
|
def create_initial_categories(apps, schema_editor):
|
||||||
|
Category = apps.get_model("services", "Category")
|
||||||
|
categories = [
|
||||||
|
{"name": "Database", "slug": "database", "order": 1},
|
||||||
|
{"name": "Cache", "slug": "cache", "order": 2},
|
||||||
|
{"name": "SaaS", "slug": "saas", "order": 3},
|
||||||
|
]
|
||||||
|
for category_data in categories:
|
||||||
|
Category.objects.create(**category_data)
|
||||||
|
|
||||||
|
|
||||||
class Migration(migrations.Migration):
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
initial = True
|
initial = True
|
||||||
|
@ -24,11 +58,22 @@ class Migration(migrations.Migration):
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
("name", models.CharField(max_length=100)),
|
("name", models.CharField(max_length=100)),
|
||||||
("description", models.TextField(blank=True)),
|
("slug", models.SlugField(unique=True)),
|
||||||
|
("description", models.TextField()),
|
||||||
|
("website", models.URLField()),
|
||||||
|
(
|
||||||
|
"logo",
|
||||||
|
models.ImageField(
|
||||||
|
blank=True,
|
||||||
|
null=True,
|
||||||
|
upload_to="cloud_provider_logos/",
|
||||||
|
validators=[services.models.validate_image_size],
|
||||||
|
),
|
||||||
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
migrations.CreateModel(
|
migrations.CreateModel(
|
||||||
name="Country",
|
name="Currency",
|
||||||
fields=[
|
fields=[
|
||||||
(
|
(
|
||||||
"id",
|
"id",
|
||||||
|
@ -39,15 +84,17 @@ class Migration(migrations.Migration):
|
||||||
verbose_name="ID",
|
verbose_name="ID",
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
("name", models.CharField(max_length=100)),
|
("code", models.CharField(max_length=3, unique=True)),
|
||||||
("code", models.CharField(max_length=2)),
|
("name", models.CharField(max_length=50)),
|
||||||
|
("symbol", models.CharField(max_length=5)),
|
||||||
],
|
],
|
||||||
options={
|
options={
|
||||||
"verbose_name_plural": "Countries",
|
"verbose_name_plural": "Currencies",
|
||||||
|
"ordering": ["code"],
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
migrations.CreateModel(
|
migrations.CreateModel(
|
||||||
name="ServiceLevel",
|
name="Plan",
|
||||||
fields=[
|
fields=[
|
||||||
(
|
(
|
||||||
"id",
|
"id",
|
||||||
|
@ -60,8 +107,67 @@ class Migration(migrations.Migration):
|
||||||
),
|
),
|
||||||
("name", models.CharField(max_length=100)),
|
("name", models.CharField(max_length=100)),
|
||||||
("description", models.TextField()),
|
("description", models.TextField()),
|
||||||
("response_time", models.CharField(max_length=50)),
|
("is_default", models.BooleanField(default=False)),
|
||||||
|
("features", models.TextField(blank=True)),
|
||||||
|
("order", models.IntegerField(default=0)),
|
||||||
|
("created_at", models.DateTimeField(auto_now_add=True)),
|
||||||
|
("updated_at", models.DateTimeField(auto_now=True)),
|
||||||
],
|
],
|
||||||
|
options={
|
||||||
|
"ordering": ["order", "name"],
|
||||||
|
},
|
||||||
|
),
|
||||||
|
migrations.CreateModel(
|
||||||
|
name="Term",
|
||||||
|
fields=[
|
||||||
|
(
|
||||||
|
"id",
|
||||||
|
models.BigAutoField(
|
||||||
|
auto_created=True,
|
||||||
|
primary_key=True,
|
||||||
|
serialize=False,
|
||||||
|
verbose_name="ID",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
("name", models.CharField(max_length=100)),
|
||||||
|
("description", models.TextField(blank=True)),
|
||||||
|
("order", models.IntegerField(default=0)),
|
||||||
|
],
|
||||||
|
options={
|
||||||
|
"ordering": ["order", "name"],
|
||||||
|
},
|
||||||
|
),
|
||||||
|
migrations.CreateModel(
|
||||||
|
name="Category",
|
||||||
|
fields=[
|
||||||
|
(
|
||||||
|
"id",
|
||||||
|
models.BigAutoField(
|
||||||
|
auto_created=True,
|
||||||
|
primary_key=True,
|
||||||
|
serialize=False,
|
||||||
|
verbose_name="ID",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
("name", models.CharField(max_length=100)),
|
||||||
|
("slug", models.SlugField(unique=True)),
|
||||||
|
("description", models.TextField(blank=True)),
|
||||||
|
("order", models.IntegerField(default=0)),
|
||||||
|
(
|
||||||
|
"parent",
|
||||||
|
models.ForeignKey(
|
||||||
|
blank=True,
|
||||||
|
null=True,
|
||||||
|
on_delete=django.db.models.deletion.CASCADE,
|
||||||
|
related_name="children",
|
||||||
|
to="services.category",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
options={
|
||||||
|
"verbose_name_plural": "Categories",
|
||||||
|
"ordering": ["order", "name"],
|
||||||
|
},
|
||||||
),
|
),
|
||||||
migrations.CreateModel(
|
migrations.CreateModel(
|
||||||
name="Service",
|
name="Service",
|
||||||
|
@ -76,26 +182,235 @@ class Migration(migrations.Migration):
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
("name", models.CharField(max_length=200)),
|
("name", models.CharField(max_length=200)),
|
||||||
|
("slug", models.SlugField(max_length=250, unique=True)),
|
||||||
("description", models.TextField()),
|
("description", models.TextField()),
|
||||||
("price", models.DecimalField(decimal_places=2, max_digits=10)),
|
(
|
||||||
|
"logo",
|
||||||
|
models.ImageField(
|
||||||
|
blank=True,
|
||||||
|
null=True,
|
||||||
|
upload_to="service_logos/",
|
||||||
|
validators=[services.models.validate_image_size],
|
||||||
|
),
|
||||||
|
),
|
||||||
("features", models.TextField()),
|
("features", models.TextField()),
|
||||||
("created_at", models.DateTimeField(auto_now_add=True)),
|
("created_at", models.DateTimeField(auto_now_add=True)),
|
||||||
("updated_at", models.DateTimeField(auto_now=True)),
|
("updated_at", models.DateTimeField(auto_now=True)),
|
||||||
|
(
|
||||||
|
"categories",
|
||||||
|
models.ManyToManyField(
|
||||||
|
related_name="services", to="services.category"
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
migrations.CreateModel(
|
||||||
|
name="Lead",
|
||||||
|
fields=[
|
||||||
|
(
|
||||||
|
"id",
|
||||||
|
models.BigAutoField(
|
||||||
|
auto_created=True,
|
||||||
|
primary_key=True,
|
||||||
|
serialize=False,
|
||||||
|
verbose_name="ID",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
("name", models.CharField(max_length=200)),
|
||||||
|
("company", models.CharField(max_length=200)),
|
||||||
|
("email", models.EmailField(max_length=254)),
|
||||||
|
("phone", models.CharField(max_length=50)),
|
||||||
|
("created_at", models.DateTimeField(auto_now_add=True)),
|
||||||
|
("odoo_lead_id", models.IntegerField(blank=True, null=True)),
|
||||||
|
(
|
||||||
|
"plan",
|
||||||
|
models.ForeignKey(
|
||||||
|
blank=True,
|
||||||
|
null=True,
|
||||||
|
on_delete=django.db.models.deletion.SET_NULL,
|
||||||
|
to="services.plan",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"service",
|
||||||
|
models.ForeignKey(
|
||||||
|
on_delete=django.db.models.deletion.CASCADE,
|
||||||
|
to="services.service",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
migrations.CreateModel(
|
||||||
|
name="ExternalLink",
|
||||||
|
fields=[
|
||||||
|
(
|
||||||
|
"id",
|
||||||
|
models.BigAutoField(
|
||||||
|
auto_created=True,
|
||||||
|
primary_key=True,
|
||||||
|
serialize=False,
|
||||||
|
verbose_name="ID",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
("url", models.URLField()),
|
||||||
|
("description", models.CharField(max_length=200)),
|
||||||
|
("order", models.IntegerField(default=0)),
|
||||||
|
("created_at", models.DateTimeField(auto_now_add=True)),
|
||||||
|
("updated_at", models.DateTimeField(auto_now=True)),
|
||||||
|
(
|
||||||
|
"service",
|
||||||
|
models.ForeignKey(
|
||||||
|
on_delete=django.db.models.deletion.CASCADE,
|
||||||
|
related_name="external_links",
|
||||||
|
to="services.service",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
options={
|
||||||
|
"verbose_name": "External Link",
|
||||||
|
"verbose_name_plural": "External Links",
|
||||||
|
"ordering": ["order", "description"],
|
||||||
|
},
|
||||||
|
),
|
||||||
|
migrations.CreateModel(
|
||||||
|
name="ConsultingPartner",
|
||||||
|
fields=[
|
||||||
|
(
|
||||||
|
"id",
|
||||||
|
models.BigAutoField(
|
||||||
|
auto_created=True,
|
||||||
|
primary_key=True,
|
||||||
|
serialize=False,
|
||||||
|
verbose_name="ID",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
("name", models.CharField(max_length=200)),
|
||||||
|
("slug", models.SlugField(unique=True)),
|
||||||
|
("description", models.TextField()),
|
||||||
|
(
|
||||||
|
"logo",
|
||||||
|
models.ImageField(
|
||||||
|
blank=True,
|
||||||
|
null=True,
|
||||||
|
upload_to="partner_logos/",
|
||||||
|
validators=[services.models.validate_image_size],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
("website", models.URLField(blank=True)),
|
||||||
|
("created_at", models.DateTimeField(auto_now_add=True)),
|
||||||
|
("updated_at", models.DateTimeField(auto_now=True)),
|
||||||
|
(
|
||||||
|
"cloud_providers",
|
||||||
|
models.ManyToManyField(
|
||||||
|
blank=True,
|
||||||
|
related_name="consulting_partners",
|
||||||
|
to="services.cloudprovider",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"services",
|
||||||
|
models.ManyToManyField(
|
||||||
|
related_name="consulting_partners", to="services.service"
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
migrations.CreateModel(
|
||||||
|
name="ServiceOffering",
|
||||||
|
fields=[
|
||||||
|
(
|
||||||
|
"id",
|
||||||
|
models.BigAutoField(
|
||||||
|
auto_created=True,
|
||||||
|
primary_key=True,
|
||||||
|
serialize=False,
|
||||||
|
verbose_name="ID",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
("slug", models.SlugField(max_length=250, unique=True)),
|
||||||
|
(
|
||||||
|
"description",
|
||||||
|
models.TextField(
|
||||||
|
help_text="Provider-specific details and features"
|
||||||
|
),
|
||||||
|
),
|
||||||
|
("created_at", models.DateTimeField(auto_now_add=True)),
|
||||||
|
("updated_at", models.DateTimeField(auto_now=True)),
|
||||||
(
|
(
|
||||||
"cloud_provider",
|
"cloud_provider",
|
||||||
models.ForeignKey(
|
models.ForeignKey(
|
||||||
on_delete=django.db.models.deletion.CASCADE,
|
on_delete=django.db.models.deletion.CASCADE,
|
||||||
|
related_name="offerings",
|
||||||
to="services.cloudprovider",
|
to="services.cloudprovider",
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
("countries", models.ManyToManyField(to="services.country")),
|
|
||||||
(
|
(
|
||||||
"service_level",
|
"service",
|
||||||
models.ForeignKey(
|
models.ForeignKey(
|
||||||
on_delete=django.db.models.deletion.CASCADE,
|
on_delete=django.db.models.deletion.CASCADE,
|
||||||
to="services.servicelevel",
|
related_name="offerings",
|
||||||
|
to="services.service",
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
|
options={
|
||||||
|
"ordering": ["service__name", "cloud_provider__name"],
|
||||||
|
"unique_together": {("service", "cloud_provider")},
|
||||||
|
},
|
||||||
),
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name="plan",
|
||||||
|
name="offering",
|
||||||
|
field=models.ForeignKey(
|
||||||
|
on_delete=django.db.models.deletion.CASCADE,
|
||||||
|
related_name="plans",
|
||||||
|
to="services.serviceoffering",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
migrations.AlterUniqueTogether(
|
||||||
|
name="plan",
|
||||||
|
unique_together={("offering", "name")},
|
||||||
|
),
|
||||||
|
migrations.CreateModel(
|
||||||
|
name="PlanPrice",
|
||||||
|
fields=[
|
||||||
|
(
|
||||||
|
"id",
|
||||||
|
models.BigAutoField(
|
||||||
|
auto_created=True,
|
||||||
|
primary_key=True,
|
||||||
|
serialize=False,
|
||||||
|
verbose_name="ID",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
("price", models.DecimalField(decimal_places=2, max_digits=10)),
|
||||||
|
(
|
||||||
|
"currency",
|
||||||
|
models.ForeignKey(
|
||||||
|
on_delete=django.db.models.deletion.PROTECT,
|
||||||
|
to="services.currency",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"plan",
|
||||||
|
models.ForeignKey(
|
||||||
|
on_delete=django.db.models.deletion.CASCADE,
|
||||||
|
related_name="prices",
|
||||||
|
to="services.plan",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"term",
|
||||||
|
models.ForeignKey(
|
||||||
|
on_delete=django.db.models.deletion.PROTECT, to="services.term"
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
options={
|
||||||
|
"unique_together": {("plan", "currency", "term")},
|
||||||
|
},
|
||||||
|
),
|
||||||
|
migrations.RunPython(create_initial_currencies),
|
||||||
|
migrations.RunPython(create_initial_terms),
|
||||||
|
migrations.RunPython(create_initial_categories),
|
||||||
]
|
]
|
||||||
|
|
|
@ -1,34 +0,0 @@
|
||||||
# Generated by Django 5.1.5 on 2025-01-27 14:10
|
|
||||||
|
|
||||||
import services.models
|
|
||||||
from django.db import migrations, models
|
|
||||||
|
|
||||||
|
|
||||||
class Migration(migrations.Migration):
|
|
||||||
|
|
||||||
dependencies = [
|
|
||||||
("services", "0001_initial"),
|
|
||||||
]
|
|
||||||
|
|
||||||
operations = [
|
|
||||||
migrations.AddField(
|
|
||||||
model_name="cloudprovider",
|
|
||||||
name="logo",
|
|
||||||
field=models.ImageField(
|
|
||||||
blank=True,
|
|
||||||
null=True,
|
|
||||||
upload_to="cloud_provider_logos/",
|
|
||||||
validators=[services.models.validate_image_size],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
migrations.AddField(
|
|
||||||
model_name="service",
|
|
||||||
name="logo",
|
|
||||||
field=models.ImageField(
|
|
||||||
blank=True,
|
|
||||||
null=True,
|
|
||||||
upload_to="service_logos/",
|
|
||||||
validators=[services.models.validate_image_size],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
]
|
|
|
@ -1,4 +1,4 @@
|
||||||
# Generated by Django 5.1.5 on 2025-01-28 09:48
|
# Generated by Django 5.1.5 on 2025-01-29 16:11
|
||||||
|
|
||||||
import django.db.models.deletion
|
import django.db.models.deletion
|
||||||
from django.db import migrations, models
|
from django.db import migrations, models
|
||||||
|
@ -7,18 +7,18 @@ from django.db import migrations, models
|
||||||
class Migration(migrations.Migration):
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
dependencies = [
|
dependencies = [
|
||||||
("services", "0010_remove_service_price"),
|
("services", "0001_initial"),
|
||||||
]
|
]
|
||||||
|
|
||||||
operations = [
|
operations = [
|
||||||
migrations.AddField(
|
migrations.AddField(
|
||||||
model_name="lead",
|
model_name="lead",
|
||||||
name="plan",
|
name="offering",
|
||||||
field=models.ForeignKey(
|
field=models.ForeignKey(
|
||||||
blank=True,
|
blank=True,
|
||||||
null=True,
|
null=True,
|
||||||
on_delete=django.db.models.deletion.SET_NULL,
|
on_delete=django.db.models.deletion.SET_NULL,
|
||||||
to="services.plan",
|
to="services.serviceoffering",
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
]
|
]
|
|
@ -1,53 +0,0 @@
|
||||||
# Generated by Django 5.1.5 on 2025-01-27 14:19
|
|
||||||
|
|
||||||
import django.db.models.deletion
|
|
||||||
from django.db import migrations, models
|
|
||||||
|
|
||||||
|
|
||||||
class Migration(migrations.Migration):
|
|
||||||
|
|
||||||
dependencies = [
|
|
||||||
("services", "0002_cloudprovider_logo_service_logo"),
|
|
||||||
]
|
|
||||||
|
|
||||||
operations = [
|
|
||||||
migrations.CreateModel(
|
|
||||||
name="Category",
|
|
||||||
fields=[
|
|
||||||
(
|
|
||||||
"id",
|
|
||||||
models.BigAutoField(
|
|
||||||
auto_created=True,
|
|
||||||
primary_key=True,
|
|
||||||
serialize=False,
|
|
||||||
verbose_name="ID",
|
|
||||||
),
|
|
||||||
),
|
|
||||||
("name", models.CharField(max_length=100)),
|
|
||||||
("slug", models.SlugField(unique=True)),
|
|
||||||
("description", models.TextField(blank=True)),
|
|
||||||
("order", models.IntegerField(default=0)),
|
|
||||||
(
|
|
||||||
"parent",
|
|
||||||
models.ForeignKey(
|
|
||||||
blank=True,
|
|
||||||
null=True,
|
|
||||||
on_delete=django.db.models.deletion.CASCADE,
|
|
||||||
related_name="children",
|
|
||||||
to="services.category",
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
options={
|
|
||||||
"verbose_name_plural": "Categories",
|
|
||||||
"ordering": ["order", "name"],
|
|
||||||
},
|
|
||||||
),
|
|
||||||
migrations.AddField(
|
|
||||||
model_name="service",
|
|
||||||
name="categories",
|
|
||||||
field=models.ManyToManyField(
|
|
||||||
related_name="services", to="services.category"
|
|
||||||
),
|
|
||||||
),
|
|
||||||
]
|
|
|
@ -1,40 +0,0 @@
|
||||||
# Generated by Django 5.1.5 on 2025-01-27 14:49
|
|
||||||
|
|
||||||
from django.db import migrations, models
|
|
||||||
from django.utils.text import slugify
|
|
||||||
|
|
||||||
|
|
||||||
def generate_provider_slugs(apps, schema_editor):
|
|
||||||
CloudProvider = apps.get_model("services", "CloudProvider")
|
|
||||||
for provider in CloudProvider.objects.all():
|
|
||||||
provider.slug = slugify(provider.name)
|
|
||||||
counter = 1
|
|
||||||
while CloudProvider.objects.filter(slug=provider.slug).exists():
|
|
||||||
provider.slug = f"{slugify(provider.name)}-{counter}"
|
|
||||||
counter += 1
|
|
||||||
provider.save()
|
|
||||||
|
|
||||||
|
|
||||||
class Migration(migrations.Migration):
|
|
||||||
|
|
||||||
dependencies = [
|
|
||||||
("services", "0003_category_service_categories"),
|
|
||||||
]
|
|
||||||
|
|
||||||
operations = [
|
|
||||||
migrations.AddField(
|
|
||||||
model_name="cloudprovider",
|
|
||||||
name="slug",
|
|
||||||
field=models.SlugField(unique=True, null=True),
|
|
||||||
preserve_default=False,
|
|
||||||
),
|
|
||||||
migrations.RunPython(
|
|
||||||
generate_provider_slugs, reverse_code=migrations.RunPython.noop
|
|
||||||
),
|
|
||||||
migrations.AlterField(
|
|
||||||
model_name="cloudprovider",
|
|
||||||
name="slug",
|
|
||||||
field=models.SlugField(unique=True),
|
|
||||||
preserve_default=False,
|
|
||||||
),
|
|
||||||
]
|
|
|
@ -1,41 +0,0 @@
|
||||||
# Generated by Django 5.1.5 on 2025-01-27 15:21
|
|
||||||
|
|
||||||
import django.db.models.deletion
|
|
||||||
from django.db import migrations, models
|
|
||||||
|
|
||||||
|
|
||||||
class Migration(migrations.Migration):
|
|
||||||
|
|
||||||
dependencies = [
|
|
||||||
("services", "0004_cloudprovider_slug"),
|
|
||||||
]
|
|
||||||
|
|
||||||
operations = [
|
|
||||||
migrations.CreateModel(
|
|
||||||
name="Lead",
|
|
||||||
fields=[
|
|
||||||
(
|
|
||||||
"id",
|
|
||||||
models.BigAutoField(
|
|
||||||
auto_created=True,
|
|
||||||
primary_key=True,
|
|
||||||
serialize=False,
|
|
||||||
verbose_name="ID",
|
|
||||||
),
|
|
||||||
),
|
|
||||||
("name", models.CharField(max_length=200)),
|
|
||||||
("company", models.CharField(max_length=200)),
|
|
||||||
("email", models.EmailField(max_length=254)),
|
|
||||||
("phone", models.CharField(max_length=50)),
|
|
||||||
("created_at", models.DateTimeField(auto_now_add=True)),
|
|
||||||
("odoo_lead_id", models.IntegerField(blank=True, null=True)),
|
|
||||||
(
|
|
||||||
"service",
|
|
||||||
models.ForeignKey(
|
|
||||||
on_delete=django.db.models.deletion.CASCADE,
|
|
||||||
to="services.service",
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
]
|
|
|
@ -1,44 +0,0 @@
|
||||||
# Generated by Django 5.1.5 on 2025-01-27 15:57
|
|
||||||
|
|
||||||
from django.db import migrations, models
|
|
||||||
from django.utils.text import slugify
|
|
||||||
|
|
||||||
|
|
||||||
def generate_service_slugs(apps, schema_editor):
|
|
||||||
Service = apps.get_model("services", "Service")
|
|
||||||
|
|
||||||
for service in Service.objects.all():
|
|
||||||
base_slug = f"{service.name}-{service.cloud_provider.name}"
|
|
||||||
slug = slugify(base_slug)
|
|
||||||
|
|
||||||
counter = 1
|
|
||||||
while Service.objects.filter(slug=slug).exists():
|
|
||||||
slug = f"{slugify(base_slug)}-{counter}"
|
|
||||||
counter += 1
|
|
||||||
|
|
||||||
service.slug = slug
|
|
||||||
service.save()
|
|
||||||
|
|
||||||
|
|
||||||
class Migration(migrations.Migration):
|
|
||||||
|
|
||||||
dependencies = [
|
|
||||||
("services", "0005_lead"),
|
|
||||||
]
|
|
||||||
|
|
||||||
operations = [
|
|
||||||
migrations.AddField(
|
|
||||||
model_name="service",
|
|
||||||
name="slug",
|
|
||||||
field=models.SlugField(max_length=250, blank=True),
|
|
||||||
preserve_default=False,
|
|
||||||
),
|
|
||||||
migrations.RunPython(
|
|
||||||
generate_service_slugs, reverse_code=migrations.RunPython.noop
|
|
||||||
),
|
|
||||||
migrations.AlterField(
|
|
||||||
model_name="service",
|
|
||||||
name="slug",
|
|
||||||
field=models.SlugField(max_length=250, unique=True),
|
|
||||||
),
|
|
||||||
]
|
|
|
@ -1,50 +0,0 @@
|
||||||
# Generated by Django 5.1.5 on 2025-01-28 07:49
|
|
||||||
|
|
||||||
import services.models
|
|
||||||
from django.db import migrations, models
|
|
||||||
|
|
||||||
|
|
||||||
class Migration(migrations.Migration):
|
|
||||||
|
|
||||||
dependencies = [
|
|
||||||
("services", "0006_service_slug"),
|
|
||||||
]
|
|
||||||
|
|
||||||
operations = [
|
|
||||||
migrations.CreateModel(
|
|
||||||
name="ConsultingPartner",
|
|
||||||
fields=[
|
|
||||||
(
|
|
||||||
"id",
|
|
||||||
models.BigAutoField(
|
|
||||||
auto_created=True,
|
|
||||||
primary_key=True,
|
|
||||||
serialize=False,
|
|
||||||
verbose_name="ID",
|
|
||||||
),
|
|
||||||
),
|
|
||||||
("name", models.CharField(max_length=200)),
|
|
||||||
("slug", models.SlugField(unique=True)),
|
|
||||||
("description", models.TextField(blank=True)),
|
|
||||||
(
|
|
||||||
"logo",
|
|
||||||
models.ImageField(
|
|
||||||
blank=True,
|
|
||||||
null=True,
|
|
||||||
upload_to="partner_logos/",
|
|
||||||
validators=[services.models.validate_image_size],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
("website", models.URLField(blank=True)),
|
|
||||||
("created_at", models.DateTimeField(auto_now_add=True)),
|
|
||||||
("updated_at", models.DateTimeField(auto_now=True)),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
migrations.AddField(
|
|
||||||
model_name="service",
|
|
||||||
name="consulting_partners",
|
|
||||||
field=models.ManyToManyField(
|
|
||||||
blank=True, related_name="services", to="services.consultingpartner"
|
|
||||||
),
|
|
||||||
),
|
|
||||||
]
|
|
|
@ -1,27 +0,0 @@
|
||||||
# Generated by Django 5.1.5 on 2025-01-28 09:03
|
|
||||||
|
|
||||||
from django.db import migrations
|
|
||||||
|
|
||||||
|
|
||||||
class Migration(migrations.Migration):
|
|
||||||
|
|
||||||
dependencies = [
|
|
||||||
("services", "0007_consultingpartner_service_consulting_partners"),
|
|
||||||
]
|
|
||||||
|
|
||||||
operations = [
|
|
||||||
migrations.RemoveField(
|
|
||||||
model_name="service",
|
|
||||||
name="countries",
|
|
||||||
),
|
|
||||||
migrations.RemoveField(
|
|
||||||
model_name="service",
|
|
||||||
name="service_level",
|
|
||||||
),
|
|
||||||
migrations.DeleteModel(
|
|
||||||
name="Country",
|
|
||||||
),
|
|
||||||
migrations.DeleteModel(
|
|
||||||
name="ServiceLevel",
|
|
||||||
),
|
|
||||||
]
|
|
|
@ -1,118 +0,0 @@
|
||||||
# Generated by Django 5.1.5 on 2025-01-28 09:19
|
|
||||||
|
|
||||||
import django.db.models.deletion
|
|
||||||
from django.db import migrations, models
|
|
||||||
|
|
||||||
|
|
||||||
def create_initial_currencies(apps, schema_editor):
|
|
||||||
Currency = apps.get_model("services", "Currency")
|
|
||||||
currencies = [
|
|
||||||
{"code": "USD", "name": "US Dollar", "symbol": "$"},
|
|
||||||
{"code": "CHF", "name": "Swiss Franc", "symbol": "CHF"},
|
|
||||||
{"code": "EUR", "name": "Euro", "symbol": "€"},
|
|
||||||
]
|
|
||||||
for currency_data in currencies:
|
|
||||||
Currency.objects.create(**currency_data)
|
|
||||||
|
|
||||||
|
|
||||||
def remove_initial_currencies(apps, schema_editor):
|
|
||||||
Currency = apps.get_model("services", "Currency")
|
|
||||||
Currency.objects.all().delete()
|
|
||||||
|
|
||||||
|
|
||||||
class Migration(migrations.Migration):
|
|
||||||
|
|
||||||
dependencies = [
|
|
||||||
("services", "0008_remove_service_countries_and_more"),
|
|
||||||
]
|
|
||||||
|
|
||||||
operations = [
|
|
||||||
migrations.CreateModel(
|
|
||||||
name="Currency",
|
|
||||||
fields=[
|
|
||||||
(
|
|
||||||
"id",
|
|
||||||
models.BigAutoField(
|
|
||||||
auto_created=True,
|
|
||||||
primary_key=True,
|
|
||||||
serialize=False,
|
|
||||||
verbose_name="ID",
|
|
||||||
),
|
|
||||||
),
|
|
||||||
("code", models.CharField(max_length=3, unique=True)),
|
|
||||||
("name", models.CharField(max_length=50)),
|
|
||||||
("symbol", models.CharField(max_length=5)),
|
|
||||||
],
|
|
||||||
options={
|
|
||||||
"verbose_name_plural": "Currencies",
|
|
||||||
"ordering": ["code"],
|
|
||||||
},
|
|
||||||
),
|
|
||||||
migrations.CreateModel(
|
|
||||||
name="Plan",
|
|
||||||
fields=[
|
|
||||||
(
|
|
||||||
"id",
|
|
||||||
models.BigAutoField(
|
|
||||||
auto_created=True,
|
|
||||||
primary_key=True,
|
|
||||||
serialize=False,
|
|
||||||
verbose_name="ID",
|
|
||||||
),
|
|
||||||
),
|
|
||||||
("name", models.CharField(max_length=100)),
|
|
||||||
("description", models.TextField()),
|
|
||||||
("is_default", models.BooleanField(default=False)),
|
|
||||||
("features", models.TextField(blank=True)),
|
|
||||||
("order", models.IntegerField(default=0)),
|
|
||||||
("created_at", models.DateTimeField(auto_now_add=True)),
|
|
||||||
("updated_at", models.DateTimeField(auto_now=True)),
|
|
||||||
(
|
|
||||||
"service",
|
|
||||||
models.ForeignKey(
|
|
||||||
on_delete=django.db.models.deletion.CASCADE,
|
|
||||||
related_name="plans",
|
|
||||||
to="services.service",
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
options={
|
|
||||||
"ordering": ["order", "name"],
|
|
||||||
"unique_together": {("service", "name")},
|
|
||||||
},
|
|
||||||
),
|
|
||||||
migrations.CreateModel(
|
|
||||||
name="PlanPrice",
|
|
||||||
fields=[
|
|
||||||
(
|
|
||||||
"id",
|
|
||||||
models.BigAutoField(
|
|
||||||
auto_created=True,
|
|
||||||
primary_key=True,
|
|
||||||
serialize=False,
|
|
||||||
verbose_name="ID",
|
|
||||||
),
|
|
||||||
),
|
|
||||||
("price", models.DecimalField(decimal_places=2, max_digits=10)),
|
|
||||||
(
|
|
||||||
"currency",
|
|
||||||
models.ForeignKey(
|
|
||||||
on_delete=django.db.models.deletion.PROTECT,
|
|
||||||
to="services.currency",
|
|
||||||
),
|
|
||||||
),
|
|
||||||
(
|
|
||||||
"plan",
|
|
||||||
models.ForeignKey(
|
|
||||||
on_delete=django.db.models.deletion.CASCADE,
|
|
||||||
related_name="prices",
|
|
||||||
to="services.plan",
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
options={
|
|
||||||
"unique_together": {("plan", "currency")},
|
|
||||||
},
|
|
||||||
),
|
|
||||||
migrations.RunPython(create_initial_currencies, remove_initial_currencies),
|
|
||||||
]
|
|
|
@ -1,17 +0,0 @@
|
||||||
# Generated by Django 5.1.5 on 2025-01-28 09:28
|
|
||||||
|
|
||||||
from django.db import migrations
|
|
||||||
|
|
||||||
|
|
||||||
class Migration(migrations.Migration):
|
|
||||||
|
|
||||||
dependencies = [
|
|
||||||
("services", "0009_currency_plan_planprice"),
|
|
||||||
]
|
|
||||||
|
|
||||||
operations = [
|
|
||||||
migrations.RemoveField(
|
|
||||||
model_name="service",
|
|
||||||
name="price",
|
|
||||||
),
|
|
||||||
]
|
|
|
@ -1,46 +0,0 @@
|
||||||
# Generated by Django 5.1.5 on 2025-01-28 09:58
|
|
||||||
|
|
||||||
import django.db.models.deletion
|
|
||||||
from django.db import migrations, models
|
|
||||||
|
|
||||||
|
|
||||||
class Migration(migrations.Migration):
|
|
||||||
|
|
||||||
dependencies = [
|
|
||||||
("services", "0011_lead_plan"),
|
|
||||||
]
|
|
||||||
|
|
||||||
operations = [
|
|
||||||
migrations.CreateModel(
|
|
||||||
name="ExternalLink",
|
|
||||||
fields=[
|
|
||||||
(
|
|
||||||
"id",
|
|
||||||
models.BigAutoField(
|
|
||||||
auto_created=True,
|
|
||||||
primary_key=True,
|
|
||||||
serialize=False,
|
|
||||||
verbose_name="ID",
|
|
||||||
),
|
|
||||||
),
|
|
||||||
("url", models.URLField()),
|
|
||||||
("description", models.CharField(max_length=200)),
|
|
||||||
("order", models.IntegerField(default=0)),
|
|
||||||
("created_at", models.DateTimeField(auto_now_add=True)),
|
|
||||||
("updated_at", models.DateTimeField(auto_now=True)),
|
|
||||||
(
|
|
||||||
"service",
|
|
||||||
models.ForeignKey(
|
|
||||||
on_delete=django.db.models.deletion.CASCADE,
|
|
||||||
related_name="external_links",
|
|
||||||
to="services.service",
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
options={
|
|
||||||
"verbose_name": "External Link",
|
|
||||||
"verbose_name_plural": "External Links",
|
|
||||||
"ordering": ["order", "description"],
|
|
||||||
},
|
|
||||||
),
|
|
||||||
]
|
|
|
@ -2,7 +2,6 @@ from django.db import models
|
||||||
from django.core.exceptions import ValidationError
|
from django.core.exceptions import ValidationError
|
||||||
from django.urls import reverse
|
from django.urls import reverse
|
||||||
from django.utils.text import slugify
|
from django.utils.text import slugify
|
||||||
|
|
||||||
from django_prose_editor.fields import ProseEditorField
|
from django_prose_editor.fields import ProseEditorField
|
||||||
|
|
||||||
|
|
||||||
|
@ -12,63 +11,6 @@ def validate_image_size(value):
|
||||||
raise ValidationError("Maximum file size is 1MB")
|
raise ValidationError("Maximum file size is 1MB")
|
||||||
|
|
||||||
|
|
||||||
class Currency(models.Model):
|
|
||||||
code = models.CharField(max_length=3, unique=True) # ISO 4217 currency code
|
|
||||||
name = models.CharField(max_length=50)
|
|
||||||
symbol = models.CharField(max_length=5)
|
|
||||||
|
|
||||||
class Meta:
|
|
||||||
verbose_name_plural = "Currencies"
|
|
||||||
ordering = ["code"]
|
|
||||||
|
|
||||||
def __str__(self):
|
|
||||||
return f"{self.code} ({self.name})"
|
|
||||||
|
|
||||||
|
|
||||||
class Plan(models.Model):
|
|
||||||
name = models.CharField(max_length=100)
|
|
||||||
description = ProseEditorField()
|
|
||||||
service = models.ForeignKey(
|
|
||||||
"Service", on_delete=models.CASCADE, related_name="plans"
|
|
||||||
)
|
|
||||||
is_default = models.BooleanField(default=False)
|
|
||||||
features = ProseEditorField(blank=True)
|
|
||||||
order = models.IntegerField(default=0)
|
|
||||||
created_at = models.DateTimeField(auto_now_add=True)
|
|
||||||
updated_at = models.DateTimeField(auto_now=True)
|
|
||||||
|
|
||||||
class Meta:
|
|
||||||
ordering = ["order", "name"]
|
|
||||||
unique_together = [["service", "name"]]
|
|
||||||
|
|
||||||
def __str__(self):
|
|
||||||
return f"{self.service.name} - {self.name}"
|
|
||||||
|
|
||||||
def save(self, *args, **kwargs):
|
|
||||||
if self.is_default:
|
|
||||||
# Ensure only one default plan per service
|
|
||||||
Plan.objects.filter(service=self.service).update(is_default=False)
|
|
||||||
super().save(*args, **kwargs)
|
|
||||||
|
|
||||||
def clean(self):
|
|
||||||
super().clean()
|
|
||||||
# If this is the only plan, make it default
|
|
||||||
if self.pk and self.service and self.service.plans.count() == 1:
|
|
||||||
self.is_default = True
|
|
||||||
|
|
||||||
|
|
||||||
class PlanPrice(models.Model):
|
|
||||||
plan = models.ForeignKey(Plan, on_delete=models.CASCADE, related_name="prices")
|
|
||||||
currency = models.ForeignKey(Currency, on_delete=models.PROTECT)
|
|
||||||
price = models.DecimalField(max_digits=10, decimal_places=2)
|
|
||||||
|
|
||||||
class Meta:
|
|
||||||
unique_together = [["plan", "currency"]]
|
|
||||||
|
|
||||||
def __str__(self):
|
|
||||||
return f"{self.plan.name} - {self.currency.code} {self.price}"
|
|
||||||
|
|
||||||
|
|
||||||
class Category(models.Model):
|
class Category(models.Model):
|
||||||
name = models.CharField(max_length=100)
|
name = models.CharField(max_length=100)
|
||||||
slug = models.SlugField(unique=True)
|
slug = models.SlugField(unique=True)
|
||||||
|
@ -105,7 +47,8 @@ class Category(models.Model):
|
||||||
class CloudProvider(models.Model):
|
class CloudProvider(models.Model):
|
||||||
name = models.CharField(max_length=100)
|
name = models.CharField(max_length=100)
|
||||||
slug = models.SlugField(unique=True)
|
slug = models.SlugField(unique=True)
|
||||||
description = ProseEditorField(blank=True)
|
description = ProseEditorField()
|
||||||
|
website = models.URLField()
|
||||||
logo = models.ImageField(
|
logo = models.ImageField(
|
||||||
upload_to="cloud_provider_logos/",
|
upload_to="cloud_provider_logos/",
|
||||||
validators=[validate_image_size],
|
validators=[validate_image_size],
|
||||||
|
@ -125,10 +68,66 @@ class CloudProvider(models.Model):
|
||||||
return reverse("services:provider_detail", kwargs={"slug": self.slug})
|
return reverse("services:provider_detail", kwargs={"slug": self.slug})
|
||||||
|
|
||||||
|
|
||||||
|
class Currency(models.Model):
|
||||||
|
code = models.CharField(max_length=3, unique=True) # ISO 4217 currency code
|
||||||
|
name = models.CharField(max_length=50)
|
||||||
|
symbol = models.CharField(max_length=5)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
verbose_name_plural = "Currencies"
|
||||||
|
ordering = ["code"]
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
return f"{self.code} ({self.name})"
|
||||||
|
|
||||||
|
|
||||||
|
class Term(models.Model):
|
||||||
|
name = models.CharField(max_length=100)
|
||||||
|
description = models.TextField(blank=True)
|
||||||
|
order = models.IntegerField(default=0)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
ordering = ["order", "name"]
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
return self.name
|
||||||
|
|
||||||
|
|
||||||
|
class Service(models.Model):
|
||||||
|
name = models.CharField(max_length=200)
|
||||||
|
slug = models.SlugField(max_length=250, unique=True)
|
||||||
|
description = ProseEditorField()
|
||||||
|
logo = models.ImageField(
|
||||||
|
upload_to="service_logos/",
|
||||||
|
validators=[validate_image_size],
|
||||||
|
null=True,
|
||||||
|
blank=True,
|
||||||
|
)
|
||||||
|
categories = models.ManyToManyField(Category, related_name="services")
|
||||||
|
features = ProseEditorField()
|
||||||
|
created_at = models.DateTimeField(auto_now_add=True)
|
||||||
|
updated_at = models.DateTimeField(auto_now=True)
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
return self.name
|
||||||
|
|
||||||
|
def save(self, *args, **kwargs):
|
||||||
|
if not self.slug:
|
||||||
|
self.slug = slugify(self.name)
|
||||||
|
counter = 1
|
||||||
|
while Service.objects.filter(slug=self.slug).exists():
|
||||||
|
self.slug = f"{slugify(self.name)}-{counter}"
|
||||||
|
counter += 1
|
||||||
|
super().save(*args, **kwargs)
|
||||||
|
|
||||||
|
def get_absolute_url(self):
|
||||||
|
return reverse("services:service_detail", kwargs={"slug": self.slug})
|
||||||
|
|
||||||
|
|
||||||
class ConsultingPartner(models.Model):
|
class ConsultingPartner(models.Model):
|
||||||
name = models.CharField(max_length=200)
|
name = models.CharField(max_length=200)
|
||||||
slug = models.SlugField(unique=True)
|
slug = models.SlugField(unique=True)
|
||||||
description = ProseEditorField(blank=True)
|
description = ProseEditorField()
|
||||||
logo = models.ImageField(
|
logo = models.ImageField(
|
||||||
upload_to="partner_logos/",
|
upload_to="partner_logos/",
|
||||||
validators=[validate_image_size],
|
validators=[validate_image_size],
|
||||||
|
@ -136,6 +135,10 @@ class ConsultingPartner(models.Model):
|
||||||
blank=True,
|
blank=True,
|
||||||
)
|
)
|
||||||
website = models.URLField(blank=True)
|
website = models.URLField(blank=True)
|
||||||
|
services = models.ManyToManyField(Service, related_name="consulting_partners")
|
||||||
|
cloud_providers = models.ManyToManyField(
|
||||||
|
CloudProvider, related_name="consulting_partners", blank=True
|
||||||
|
)
|
||||||
created_at = models.DateTimeField(auto_now_add=True)
|
created_at = models.DateTimeField(auto_now_add=True)
|
||||||
updated_at = models.DateTimeField(auto_now=True)
|
updated_at = models.DateTimeField(auto_now=True)
|
||||||
|
|
||||||
|
@ -151,60 +154,76 @@ class ConsultingPartner(models.Model):
|
||||||
return reverse("services:partner_detail", kwargs={"slug": self.slug})
|
return reverse("services:partner_detail", kwargs={"slug": self.slug})
|
||||||
|
|
||||||
|
|
||||||
class Service(models.Model):
|
class ServiceOffering(models.Model):
|
||||||
name = models.CharField(max_length=200)
|
service = models.ForeignKey(
|
||||||
|
Service, on_delete=models.CASCADE, related_name="offerings"
|
||||||
|
)
|
||||||
|
cloud_provider = models.ForeignKey(
|
||||||
|
CloudProvider, on_delete=models.CASCADE, related_name="offerings"
|
||||||
|
)
|
||||||
slug = models.SlugField(max_length=250, unique=True)
|
slug = models.SlugField(max_length=250, unique=True)
|
||||||
description = ProseEditorField()
|
description = ProseEditorField(help_text="Provider-specific details and features")
|
||||||
cloud_provider = models.ForeignKey(CloudProvider, on_delete=models.CASCADE)
|
|
||||||
consulting_partners = models.ManyToManyField(
|
|
||||||
ConsultingPartner, related_name="services", blank=True
|
|
||||||
)
|
|
||||||
categories = models.ManyToManyField(Category, related_name="services")
|
|
||||||
features = ProseEditorField()
|
|
||||||
logo = models.ImageField(
|
|
||||||
upload_to="service_logos/",
|
|
||||||
validators=[validate_image_size],
|
|
||||||
null=True,
|
|
||||||
blank=True,
|
|
||||||
)
|
|
||||||
created_at = models.DateTimeField(auto_now_add=True)
|
created_at = models.DateTimeField(auto_now_add=True)
|
||||||
updated_at = models.DateTimeField(auto_now=True)
|
updated_at = models.DateTimeField(auto_now=True)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
unique_together = ["service", "cloud_provider"]
|
||||||
|
ordering = ["service__name", "cloud_provider__name"]
|
||||||
|
|
||||||
def __str__(self):
|
def __str__(self):
|
||||||
return self.name
|
return f"{self.service.name} on {self.cloud_provider.name}"
|
||||||
|
|
||||||
def save(self, *args, **kwargs):
|
def save(self, *args, **kwargs):
|
||||||
if not self.slug:
|
if not self.slug:
|
||||||
base_slug = f"{self.name}-{self.cloud_provider.name}"
|
base_slug = f"{self.service.name}-{self.cloud_provider.name}"
|
||||||
self.slug = slugify(base_slug)
|
self.slug = slugify(base_slug)
|
||||||
|
|
||||||
# If slug exists, append number
|
|
||||||
counter = 1
|
counter = 1
|
||||||
while Service.objects.filter(slug=self.slug).exists():
|
while ServiceOffering.objects.filter(slug=self.slug).exists():
|
||||||
self.slug = f"{slugify(base_slug)}-{counter}"
|
self.slug = f"{slugify(base_slug)}-{counter}"
|
||||||
counter += 1
|
counter += 1
|
||||||
|
|
||||||
super().save(*args, **kwargs)
|
super().save(*args, **kwargs)
|
||||||
|
|
||||||
def get_absolute_url(self):
|
def get_absolute_url(self):
|
||||||
return reverse("services:service_detail", kwargs={"slug": self.slug})
|
return reverse("services:offering_detail", kwargs={"slug": self.slug})
|
||||||
|
|
||||||
def get_default_plan(self):
|
|
||||||
return self.plans.filter(is_default=True).first() or self.plans.first()
|
|
||||||
|
|
||||||
|
|
||||||
class Lead(models.Model):
|
class Plan(models.Model):
|
||||||
service = models.ForeignKey(Service, on_delete=models.CASCADE)
|
name = models.CharField(max_length=100)
|
||||||
plan = models.ForeignKey(Plan, on_delete=models.SET_NULL, null=True, blank=True)
|
description = ProseEditorField()
|
||||||
name = models.CharField(max_length=200)
|
offering = models.ForeignKey(
|
||||||
company = models.CharField(max_length=200)
|
ServiceOffering, on_delete=models.CASCADE, related_name="plans"
|
||||||
email = models.EmailField()
|
)
|
||||||
phone = models.CharField(max_length=50)
|
is_default = models.BooleanField(default=False)
|
||||||
|
features = ProseEditorField(blank=True)
|
||||||
|
order = models.IntegerField(default=0)
|
||||||
created_at = models.DateTimeField(auto_now_add=True)
|
created_at = models.DateTimeField(auto_now_add=True)
|
||||||
odoo_lead_id = models.IntegerField(null=True, blank=True)
|
updated_at = models.DateTimeField(auto_now=True)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
ordering = ["order", "name"]
|
||||||
|
unique_together = [["offering", "name"]]
|
||||||
|
|
||||||
def __str__(self):
|
def __str__(self):
|
||||||
return f"{self.name} - {self.company} ({self.service})"
|
return f"{self.offering} - {self.name}"
|
||||||
|
|
||||||
|
def save(self, *args, **kwargs):
|
||||||
|
if self.is_default:
|
||||||
|
# Ensure only one default plan per offering
|
||||||
|
Plan.objects.filter(offering=self.offering).update(is_default=False)
|
||||||
|
super().save(*args, **kwargs)
|
||||||
|
|
||||||
|
|
||||||
|
class PlanPrice(models.Model):
|
||||||
|
plan = models.ForeignKey(Plan, on_delete=models.CASCADE, related_name="prices")
|
||||||
|
currency = models.ForeignKey(Currency, on_delete=models.PROTECT)
|
||||||
|
term = models.ForeignKey(Term, on_delete=models.PROTECT)
|
||||||
|
price = models.DecimalField(max_digits=10, decimal_places=2)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
unique_together = [["plan", "currency", "term"]]
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
return f"{self.plan.name} - {self.currency.code} {self.price}"
|
||||||
|
|
||||||
|
|
||||||
class ExternalLink(models.Model):
|
class ExternalLink(models.Model):
|
||||||
|
@ -227,11 +246,26 @@ class ExternalLink(models.Model):
|
||||||
|
|
||||||
def clean(self):
|
def clean(self):
|
||||||
from django.core.validators import URLValidator
|
from django.core.validators import URLValidator
|
||||||
from django.core.exceptions import ValidationError
|
|
||||||
|
|
||||||
# Validate URL
|
|
||||||
validate = URLValidator()
|
validate = URLValidator()
|
||||||
try:
|
try:
|
||||||
validate(self.url)
|
validate(self.url)
|
||||||
except ValidationError:
|
except ValidationError:
|
||||||
raise ValidationError({"url": "Enter a valid URL."})
|
raise ValidationError({"url": "Enter a valid URL."})
|
||||||
|
|
||||||
|
|
||||||
|
class Lead(models.Model):
|
||||||
|
service = models.ForeignKey(Service, on_delete=models.CASCADE)
|
||||||
|
offering = models.ForeignKey(
|
||||||
|
ServiceOffering, on_delete=models.SET_NULL, null=True, blank=True
|
||||||
|
)
|
||||||
|
plan = models.ForeignKey(Plan, on_delete=models.SET_NULL, null=True, blank=True)
|
||||||
|
name = models.CharField(max_length=200)
|
||||||
|
company = models.CharField(max_length=200)
|
||||||
|
email = models.EmailField()
|
||||||
|
phone = models.CharField(max_length=50)
|
||||||
|
created_at = models.DateTimeField(auto_now_add=True)
|
||||||
|
odoo_lead_id = models.IntegerField(null=True, blank=True)
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
return f"{self.name} - {self.company} ({self.service})"
|
||||||
|
|
|
@ -18,7 +18,7 @@ class OdooAPI:
|
||||||
# Parse URL to get host
|
# Parse URL to get host
|
||||||
url = settings.ODOO_CONFIG["url"]
|
url = settings.ODOO_CONFIG["url"]
|
||||||
parsed_url = urlparse(url)
|
parsed_url = urlparse(url)
|
||||||
host = parsed_url.netloc or parsed_url.path # If no netloc, use path
|
host = parsed_url.netloc or parsed_url.path
|
||||||
|
|
||||||
# Log connection attempt
|
# Log connection attempt
|
||||||
logger.info(f"Attempting to connect to Odoo at {host}")
|
logger.info(f"Attempting to connect to Odoo at {host}")
|
||||||
|
@ -27,10 +27,8 @@ class OdooAPI:
|
||||||
f"USER={settings.ODOO_CONFIG['username']}"
|
f"USER={settings.ODOO_CONFIG['username']}"
|
||||||
)
|
)
|
||||||
|
|
||||||
# Try to establish connection
|
|
||||||
self.odoo = odoorpc.ODOO(host, port=443, protocol="jsonrpc+ssl")
|
self.odoo = odoorpc.ODOO(host, port=443, protocol="jsonrpc+ssl")
|
||||||
|
|
||||||
# Try to login
|
|
||||||
logger.info("Connection established, attempting login...")
|
logger.info("Connection established, attempting login...")
|
||||||
self.odoo.login(
|
self.odoo.login(
|
||||||
settings.ODOO_CONFIG["db"],
|
settings.ODOO_CONFIG["db"],
|
||||||
|
@ -39,7 +37,6 @@ class OdooAPI:
|
||||||
)
|
)
|
||||||
logger.info("Successfully logged into Odoo")
|
logger.info("Successfully logged into Odoo")
|
||||||
|
|
||||||
# Test the connection by making a simple API call
|
|
||||||
version_info = self.odoo.version
|
version_info = self.odoo.version
|
||||||
logger.info(f"Connected to Odoo version: {version_info}")
|
logger.info(f"Connected to Odoo version: {version_info}")
|
||||||
|
|
||||||
|
@ -63,6 +60,25 @@ class OdooAPI:
|
||||||
f"Attempting to create lead for {lead.name} from {lead.company}"
|
f"Attempting to create lead for {lead.name} from {lead.company}"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# Get provider name from offering if it exists
|
||||||
|
provider_name = ""
|
||||||
|
if hasattr(lead, "offering") and lead.offering:
|
||||||
|
provider_name = lead.offering.cloud_provider.name
|
||||||
|
|
||||||
|
# Prepare service description
|
||||||
|
service_details = []
|
||||||
|
if lead.service:
|
||||||
|
service_details.append(f"Service: {lead.service.name}")
|
||||||
|
if provider_name:
|
||||||
|
service_details.append(f"Provider: {provider_name}")
|
||||||
|
if lead.service.categories.exists():
|
||||||
|
categories = ", ".join(
|
||||||
|
cat.name for cat in lead.service.categories.all()
|
||||||
|
)
|
||||||
|
service_details.append(f"Categories: {categories}")
|
||||||
|
if lead.plan:
|
||||||
|
service_details.append(f"Plan: {lead.plan.name}")
|
||||||
|
|
||||||
# Prepare lead data
|
# Prepare lead data
|
||||||
lead_data = {
|
lead_data = {
|
||||||
"name": f"Interest in {lead.service.name}",
|
"name": f"Interest in {lead.service.name}",
|
||||||
|
@ -70,11 +86,7 @@ class OdooAPI:
|
||||||
"partner_name": lead.company,
|
"partner_name": lead.company,
|
||||||
"email_from": lead.email,
|
"email_from": lead.email,
|
||||||
"phone": lead.phone,
|
"phone": lead.phone,
|
||||||
"description": f"""
|
"description": "\n".join(service_details),
|
||||||
Service: {lead.service.name}
|
|
||||||
Provider: {lead.service.cloud_provider.name}
|
|
||||||
Categories: {', '.join(cat.name for cat in lead.service.categories.all())}
|
|
||||||
""",
|
|
||||||
"type": "lead",
|
"type": "lead",
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -85,20 +97,18 @@ class OdooAPI:
|
||||||
logger.warning("No active Odoo connection, attempting to reconnect")
|
logger.warning("No active Odoo connection, attempting to reconnect")
|
||||||
self._connect()
|
self._connect()
|
||||||
|
|
||||||
# Get the CRM lead model
|
|
||||||
Lead = self.odoo.env["crm.lead"]
|
|
||||||
logger.debug("Successfully got CRM lead model")
|
|
||||||
|
|
||||||
# Create the lead
|
# Create the lead
|
||||||
|
Lead = self.odoo.env["crm.lead"]
|
||||||
odoo_lead_id = Lead.create(lead_data)
|
odoo_lead_id = Lead.create(lead_data)
|
||||||
logger.info(f"Successfully created lead in Odoo with ID: {odoo_lead_id}")
|
logger.info(f"Successfully created lead in Odoo with ID: {odoo_lead_id}")
|
||||||
|
|
||||||
# Post message in chatter
|
# Post message in chatter
|
||||||
message_data = {
|
message_data = {
|
||||||
"body": f"""
|
"body": f"""
|
||||||
<p>Thank you for your interest in the service <strong>{lead.service.name}</strong>. We recorded the following information and will get back to you soon.</p>
|
<p>Thank you for your interest in the service <strong>{lead.service.name}</strong>.
|
||||||
|
We recorded the following information and will get back to you soon.</p>
|
||||||
|
|
||||||
<h3>Contact Details:</h3>
|
<p>Contact Details:</p>
|
||||||
<ul>
|
<ul>
|
||||||
<li><strong>Name:</strong> {lead.name}</li>
|
<li><strong>Name:</strong> {lead.name}</li>
|
||||||
<li><strong>Company:</strong> {lead.company}</li>
|
<li><strong>Company:</strong> {lead.company}</li>
|
||||||
|
@ -106,11 +116,11 @@ class OdooAPI:
|
||||||
<li><strong>Phone:</strong> {lead.phone}</li>
|
<li><strong>Phone:</strong> {lead.phone}</li>
|
||||||
</ul>
|
</ul>
|
||||||
|
|
||||||
<h3>Service Details:</h3>
|
<p>Service Details:<p>
|
||||||
<ul>
|
<ul>
|
||||||
<li><strong>Service:</strong> {lead.service.name}</li>
|
<li><strong>Service:</strong> {lead.service.name}</li>
|
||||||
<li><strong>Provider:</strong> {lead.service.cloud_provider.name}</li>
|
{f'<li><strong>Provider:</strong> {provider_name}</li>' if provider_name else ''}
|
||||||
<li><strong>Plan:</strong> {lead.plan.name if lead.plan else 'Not specified'}</li>
|
{f'<li><strong>Plan:</strong> {lead.plan.name}</li>' if lead.plan else ''}
|
||||||
<li><strong>Categories:</strong> {', '.join(cat.name for cat in lead.service.categories.all())}</li>
|
<li><strong>Categories:</strong> {', '.join(cat.name for cat in lead.service.categories.all())}</li>
|
||||||
</ul>
|
</ul>
|
||||||
""",
|
""",
|
||||||
|
@ -118,16 +128,7 @@ class OdooAPI:
|
||||||
"subtype_xmlid": "mail.mt_comment",
|
"subtype_xmlid": "mail.mt_comment",
|
||||||
}
|
}
|
||||||
|
|
||||||
# Get context for message posting
|
# Post the message
|
||||||
context = {
|
|
||||||
"mail_post_autofollow": True,
|
|
||||||
"lang": "en_US",
|
|
||||||
"tz": "UTC",
|
|
||||||
"uid": self.odoo.env.uid,
|
|
||||||
"allowed_company_ids": [1], # Default company ID
|
|
||||||
}
|
|
||||||
|
|
||||||
# Post the message using the message_post method
|
|
||||||
self.odoo.env["crm.lead"].browse(odoo_lead_id).message_post(
|
self.odoo.env["crm.lead"].browse(odoo_lead_id).message_post(
|
||||||
body=message_data["body"],
|
body=message_data["body"],
|
||||||
message_type=message_data["message_type"],
|
message_type=message_data["message_type"],
|
||||||
|
@ -135,7 +136,6 @@ class OdooAPI:
|
||||||
)
|
)
|
||||||
|
|
||||||
logger.info(f"Successfully posted message to lead {odoo_lead_id}")
|
logger.info(f"Successfully posted message to lead {odoo_lead_id}")
|
||||||
|
|
||||||
return odoo_lead_id
|
return odoo_lead_id
|
||||||
|
|
||||||
except odoorpc.error.RPCError as e:
|
except odoorpc.error.RPCError as e:
|
||||||
|
|
|
@ -1,7 +0,0 @@
|
||||||
.inline-group .tabular .has_original td:first-child {
|
|
||||||
display: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.inline-group .tabular tr:not(.has_original) td:first-child {
|
|
||||||
display: none;
|
|
||||||
}
|
|
29
hub/services/static/admin/css/service_offering.css
Normal file
29
hub/services/static/admin/css/service_offering.css
Normal file
|
@ -0,0 +1,29 @@
|
||||||
|
.price-table {
|
||||||
|
width: 100%;
|
||||||
|
border-collapse: collapse;
|
||||||
|
margin: 1em 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.price-table th,
|
||||||
|
.price-table td {
|
||||||
|
padding: 8px;
|
||||||
|
border: 1px solid #ddd;
|
||||||
|
}
|
||||||
|
|
||||||
|
.price-table th {
|
||||||
|
background-color: #f5f5f5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.button {
|
||||||
|
display: inline-block;
|
||||||
|
padding: 4px 8px;
|
||||||
|
background: #79aec8;
|
||||||
|
color: white;
|
||||||
|
text-decoration: none;
|
||||||
|
border-radius: 4px;
|
||||||
|
margin: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.button:hover {
|
||||||
|
background: #609ab6;
|
||||||
|
}
|
5
hub/services/static/css/bootstrap-icons.min.css
vendored
Normal file
5
hub/services/static/css/bootstrap-icons.min.css
vendored
Normal file
File diff suppressed because one or more lines are too long
7
hub/services/static/js/bootstrap.bundle.min.js
vendored
Normal file
7
hub/services/static/js/bootstrap.bundle.min.js
vendored
Normal file
File diff suppressed because one or more lines are too long
|
@ -1,23 +0,0 @@
|
||||||
{% extends "admin/change_form.html" %}
|
|
||||||
{% load static %}
|
|
||||||
|
|
||||||
{% block extrahead %}
|
|
||||||
{{ block.super }}
|
|
||||||
<link rel="stylesheet" type="text/css" href="{% static 'admin/css/hide_inline_header.css' %}" />
|
|
||||||
{% endblock %}
|
|
||||||
|
|
||||||
{% block admin_change_form_document_ready %}
|
|
||||||
{{ block.super }}
|
|
||||||
<script>
|
|
||||||
django.jQuery(document).ready(function($) {
|
|
||||||
// Initialize collapse state for plan inlines
|
|
||||||
$('.plan-inline').addClass('collapsed');
|
|
||||||
|
|
||||||
// Add custom styling to price inline tables
|
|
||||||
$('.price-inline').find('table').addClass('price-table');
|
|
||||||
|
|
||||||
// Add helper text for default plan selection
|
|
||||||
$('.field-is_default').append('<p class="help">Only one plan can be default per service.</p>');
|
|
||||||
});
|
|
||||||
</script>
|
|
||||||
{% endblock %}
|
|
|
@ -5,7 +5,7 @@
|
||||||
<meta charset="UTF-8">
|
<meta charset="UTF-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
<title>Servala - The Cloud Native Services Hub</title>
|
<title>Servala - The Cloud Native Services Hub</title>
|
||||||
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.7.2/font/bootstrap-icons.css">
|
<link rel="stylesheet" href='{% static "css/bootstrap-icons.min.css" %}'>
|
||||||
<link rel="stylesheet" type="text/css" href='{% static "css/bootstrap.min.css" %}'>
|
<link rel="stylesheet" type="text/css" href='{% static "css/bootstrap.min.css" %}'>
|
||||||
<style>
|
<style>
|
||||||
.rich-text-content {
|
.rich-text-content {
|
||||||
|
@ -26,7 +26,30 @@
|
||||||
<body>
|
<body>
|
||||||
<nav class="navbar navbar-expand-lg navbar-dark bg-dark">
|
<nav class="navbar navbar-expand-lg navbar-dark bg-dark">
|
||||||
<div class="container">
|
<div class="container">
|
||||||
<a class="navbar-brand" href="{% url 'services:service_list' %}">Servala - The Cloud Native Services Hub</a>
|
<a class="navbar-brand" href="{% url 'services:service_list' %}">Servala</a>
|
||||||
|
<button class="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target="#navbarNav">
|
||||||
|
<span class="navbar-toggler-icon"></span>
|
||||||
|
</button>
|
||||||
|
<div class="collapse navbar-collapse" id="navbarNav">
|
||||||
|
<ul class="navbar-nav">
|
||||||
|
<li class="nav-item">
|
||||||
|
<a class="nav-link {% if request.resolver_match.view_name == 'services:service_list' %}active{% endif %}"
|
||||||
|
href="{% url 'services:service_list' %}">Services</a>
|
||||||
|
</li>
|
||||||
|
<li class="nav-item">
|
||||||
|
<a class="nav-link {% if request.resolver_match.view_name == 'services:offering_list' %}active{% endif %}"
|
||||||
|
href="{% url 'services:offering_list' %}">Service Offerings</a>
|
||||||
|
</li>
|
||||||
|
<li class="nav-item">
|
||||||
|
<a class="nav-link {% if request.resolver_match.view_name == 'services:provider_list' %}active{% endif %}"
|
||||||
|
href="{% url 'services:provider_list' %}">Cloud Providers</a>
|
||||||
|
</li>
|
||||||
|
<li class="nav-item">
|
||||||
|
<a class="nav-link {% if request.resolver_match.view_name == 'services:partner_list' %}active{% endif %}"
|
||||||
|
href="{% url 'services:partner_list' %}">Consulting Partners</a>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
|
@ -35,6 +58,6 @@
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/js/bootstrap.bundle.min.js"></script>
|
<script src="{% static "js/bootstrap.bundle.min.js" %}"></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
|
@ -9,16 +9,28 @@
|
||||||
|
|
||||||
<div class="mb-4">
|
<div class="mb-4">
|
||||||
<h5>Service Details</h5>
|
<h5>Service Details</h5>
|
||||||
<p><strong>Provider:</strong> {{ service.cloud_provider.name }}</p>
|
{% if selected_offering %}
|
||||||
<p><strong>Plan:</strong> {{ selected_plan.name }}</p>
|
<p><strong>Provider:</strong> {{ selected_offering.cloud_provider.name }}</p>
|
||||||
|
{% endif %}
|
||||||
|
{% if selected_plan %}
|
||||||
|
<p><strong>Plan:</strong> {{ selected_plan.name }}</p>
|
||||||
|
{% if selected_plan.prices.exists %}
|
||||||
|
<p><strong>Pricing:</strong></p>
|
||||||
|
<ul class="list-unstyled">
|
||||||
|
{% for price in selected_plan.prices.all %}
|
||||||
|
<li>{{ price.currency.symbol }}{{ price.price }} {{ price.currency.code }} per {{ price.term.name }}</li>
|
||||||
|
{% endfor %}
|
||||||
|
</ul>
|
||||||
|
{% endif %}
|
||||||
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{% if messages %}
|
{% if messages %}
|
||||||
{% for message in messages %}
|
{% for message in messages %}
|
||||||
<div class="alert alert-{{ message.tags }}">
|
<div class="alert alert-{{ message.tags }}">
|
||||||
{{ message }}
|
{{ message }}
|
||||||
</div>
|
</div>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
<form method="post">
|
<form method="post">
|
||||||
|
@ -28,9 +40,9 @@
|
||||||
<label for="{{ form.name.id_for_label }}" class="form-label">Name</label>
|
<label for="{{ form.name.id_for_label }}" class="form-label">Name</label>
|
||||||
{{ form.name }}
|
{{ form.name }}
|
||||||
{% if form.name.errors %}
|
{% if form.name.errors %}
|
||||||
<div class="invalid-feedback d-block">
|
<div class="invalid-feedback d-block">
|
||||||
{{ form.name.errors }}
|
{{ form.name.errors }}
|
||||||
</div>
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
@ -38,9 +50,9 @@
|
||||||
<label for="{{ form.company.id_for_label }}" class="form-label">Company</label>
|
<label for="{{ form.company.id_for_label }}" class="form-label">Company</label>
|
||||||
{{ form.company }}
|
{{ form.company }}
|
||||||
{% if form.company.errors %}
|
{% if form.company.errors %}
|
||||||
<div class="invalid-feedback d-block">
|
<div class="invalid-feedback d-block">
|
||||||
{{ form.company.errors }}
|
{{ form.company.errors }}
|
||||||
</div>
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
@ -48,9 +60,9 @@
|
||||||
<label for="{{ form.email.id_for_label }}" class="form-label">Email</label>
|
<label for="{{ form.email.id_for_label }}" class="form-label">Email</label>
|
||||||
{{ form.email }}
|
{{ form.email }}
|
||||||
{% if form.email.errors %}
|
{% if form.email.errors %}
|
||||||
<div class="invalid-feedback d-block">
|
<div class="invalid-feedback d-block">
|
||||||
{{ form.email.errors }}
|
{{ form.email.errors }}
|
||||||
</div>
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
@ -58,9 +70,9 @@
|
||||||
<label for="{{ form.phone.id_for_label }}" class="form-label">Phone</label>
|
<label for="{{ form.phone.id_for_label }}" class="form-label">Phone</label>
|
||||||
{{ form.phone }}
|
{{ form.phone }}
|
||||||
{% if form.phone.errors %}
|
{% if form.phone.errors %}
|
||||||
<div class="invalid-feedback d-block">
|
<div class="invalid-feedback d-block">
|
||||||
{{ form.phone.errors }}
|
{{ form.phone.errors }}
|
||||||
</div>
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
87
hub/services/templates/services/offering_detail.html
Normal file
87
hub/services/templates/services/offering_detail.html
Normal file
|
@ -0,0 +1,87 @@
|
||||||
|
{% extends 'services/base.html' %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<div class="card mb-4">
|
||||||
|
<div class="card-body">
|
||||||
|
<div class="d-flex justify-content-between align-items-start mb-4">
|
||||||
|
<div class="d-flex align-items-start">
|
||||||
|
{% if offering.service.logo %}
|
||||||
|
<img src="{{ offering.service.logo.url }}" alt="{{ offering.service.name }} logo" class="me-4"
|
||||||
|
style="max-height: 120px; max-width: 240px; object-fit: contain;">
|
||||||
|
{% endif %}
|
||||||
|
<div>
|
||||||
|
<h2 class="card-title mb-2">{{ offering.service.name }}</h2>
|
||||||
|
<h4 class="text-muted">
|
||||||
|
on
|
||||||
|
<a href="{{ offering.cloud_provider.get_absolute_url }}" class="text-decoration-none">
|
||||||
|
{{ offering.cloud_provider.name }}
|
||||||
|
</a>
|
||||||
|
</h4>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% if offering.cloud_provider.logo %}
|
||||||
|
<img src="{{ offering.cloud_provider.logo.url }}"
|
||||||
|
alt="{{ offering.cloud_provider.name }} logo"
|
||||||
|
style="max-height: 80px; max-width: 160px; object-fit: contain;">
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="rich-text-content mb-4">
|
||||||
|
{{ offering.description|safe }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Plans -->
|
||||||
|
<div class="card mb-4">
|
||||||
|
<div class="card-body">
|
||||||
|
<h3 class="mb-4">Available Plans</h3>
|
||||||
|
<div class="row row-cols-1 row-cols-md-3 g-4">
|
||||||
|
{% for plan in offering.plans.all %}
|
||||||
|
<div class="col">
|
||||||
|
<div class="card h-100 {% if plan.is_default %}border-primary{% endif %}">
|
||||||
|
{% if plan.is_default %}
|
||||||
|
<div class="card-header text-primary">
|
||||||
|
Recommended Plan
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
<div class="card-body">
|
||||||
|
<h4 class="card-title mb-3">{{ plan.name }}</h4>
|
||||||
|
|
||||||
|
<div class="rich-text-content mb-3">
|
||||||
|
{{ plan.description|safe }}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% if plan.features %}
|
||||||
|
<div class="rich-text-content mb-3">
|
||||||
|
{{ plan.features|safe }}
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<div class="mb-3">
|
||||||
|
{% for price in plan.prices.all %}
|
||||||
|
<div class="mb-2">
|
||||||
|
<strong>{{ price.currency.symbol }}{{ price.price }}</strong>
|
||||||
|
{{ price.currency.code }} per {{ price.term.name }}
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<a href="{% url 'services:create_lead' offering.service.slug %}?offering={{ offering.id }}&plan={{ plan.id }}"
|
||||||
|
class="btn btn-success">Select This Plan</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% empty %}
|
||||||
|
<div class="col-12">
|
||||||
|
<div class="alert alert-info">
|
||||||
|
No plans available yet.
|
||||||
|
<a href="{% url 'services:create_lead' offering.service.slug %}?offering={{ offering.id }}"
|
||||||
|
class="btn btn-success ms-3">Show Interest</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
151
hub/services/templates/services/offering_list.html
Normal file
151
hub/services/templates/services/offering_list.html
Normal file
|
@ -0,0 +1,151 @@
|
||||||
|
{% extends 'services/base.html' %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<div class="row">
|
||||||
|
<div class="col-md-3">
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-body">
|
||||||
|
<h5 class="card-title">Filters</h5>
|
||||||
|
<form method="get">
|
||||||
|
<div class="mb-3">
|
||||||
|
<label for="search" class="form-label">Search</label>
|
||||||
|
<input type="text" class="form-control" id="search" name="search" value="{{ request.GET.search }}">
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mb-3">
|
||||||
|
<label for="cloud_provider" class="form-label">Cloud Provider</label>
|
||||||
|
<select class="form-select" id="cloud_provider" name="cloud_provider">
|
||||||
|
<option value="">All Providers</option>
|
||||||
|
{% for provider in cloud_providers %}
|
||||||
|
<option value="{{ provider.id }}" {% if request.GET.cloud_provider == provider.id|stringformat:'i' %}selected{% endif %}>
|
||||||
|
{{ provider.name }}
|
||||||
|
</option>
|
||||||
|
{% endfor %}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mb-3">
|
||||||
|
<label for="category" class="form-label">Category</label>
|
||||||
|
<select class="form-select" id="category" name="category">
|
||||||
|
<option value="">All Categories</option>
|
||||||
|
{% for category in categories %}
|
||||||
|
<option value="{{ category.id }}" {% if request.GET.category == category.id|stringformat:'i' %}selected{% endif %}>
|
||||||
|
{{ category.name }}
|
||||||
|
</option>
|
||||||
|
{% for subcategory in category.children.all %}
|
||||||
|
<option value="{{ subcategory.id }}" {% if request.GET.category == subcategory.id|stringformat:'i' %}selected{% endif %}>
|
||||||
|
{{ subcategory.name }}
|
||||||
|
</option>
|
||||||
|
{% endfor %}
|
||||||
|
{% endfor %}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button type="submit" class="btn btn-primary">Apply Filters</button>
|
||||||
|
<a href="{% url 'services:offering_list' %}" class="btn btn-secondary">Clear</a>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="col-md-9">
|
||||||
|
<div class="row row-cols-1 row-cols-md-2 g-4">
|
||||||
|
{% for offering in offerings %}
|
||||||
|
<div class="col">
|
||||||
|
<div class="card h-100">
|
||||||
|
<div class="card-body">
|
||||||
|
<div class="d-flex align-items-start mb-3">
|
||||||
|
<div class="me-3">
|
||||||
|
{% if offering.service.logo %}
|
||||||
|
<img src="{{ offering.service.logo.url }}"
|
||||||
|
alt="{{ offering.service.name }}"
|
||||||
|
style="max-height: 50px; max-width: 100px; object-fit: contain;">
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h5 class="card-title mb-1">
|
||||||
|
<a href="{{ offering.service.get_absolute_url }}" class="text-decoration-none">
|
||||||
|
{{ offering.service.name }}
|
||||||
|
</a>
|
||||||
|
</h5>
|
||||||
|
<div class="d-flex align-items-center">
|
||||||
|
{% if offering.cloud_provider.logo %}
|
||||||
|
<a href="{{ offering.cloud_provider.get_absolute_url }}" class="me-2">
|
||||||
|
<img src="{{ offering.cloud_provider.logo.url }}"
|
||||||
|
alt="{{ offering.cloud_provider.name }}"
|
||||||
|
style="max-height: 25px; max-width: 50px; object-fit: contain;">
|
||||||
|
</a>
|
||||||
|
{% endif %}
|
||||||
|
<small class="text-muted">
|
||||||
|
{{ offering.cloud_provider.name }}
|
||||||
|
</small>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mb-2">
|
||||||
|
{% for category in offering.service.categories.all %}
|
||||||
|
<span class="badge bg-secondary me-1">{{ category.full_path }}</span>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="rich-text-content mb-3">
|
||||||
|
{{ offering.description|safe|truncatewords_html:30 }}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% if offering.plans.exists %}
|
||||||
|
<div class="mb-3">
|
||||||
|
<strong>Available Plans:</strong>
|
||||||
|
<ul class="list-unstyled small">
|
||||||
|
{% for plan in offering.plans.all %}
|
||||||
|
<li>• {{ plan.name }}</li>
|
||||||
|
{% endfor %}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<div class="mt-auto d-flex gap-2">
|
||||||
|
<a href="{{ offering.get_absolute_url }}" class="btn btn-primary">View Details</a>
|
||||||
|
{% if offering.plans.exists %}
|
||||||
|
{% if offering.plans.count == 1 %}
|
||||||
|
{% with plan=offering.plans.first %}
|
||||||
|
<a href="{% url 'services:create_lead' offering.service.slug %}?offering={{ offering.id }}&plan={{ plan.id }}"
|
||||||
|
class="btn btn-success">Show Interest</a>
|
||||||
|
{% endwith %}
|
||||||
|
{% else %}
|
||||||
|
<div class="dropdown">
|
||||||
|
<button class="btn btn-success dropdown-toggle" type="button" data-bs-toggle="dropdown">
|
||||||
|
Show Interest
|
||||||
|
</button>
|
||||||
|
<ul class="dropdown-menu">
|
||||||
|
{% for plan in offering.plans.all %}
|
||||||
|
<li>
|
||||||
|
<a class="dropdown-item"
|
||||||
|
href="{% url 'services:create_lead' offering.service.slug %}?offering={{ offering.id }}&plan={{ plan.id }}">
|
||||||
|
{{ plan.name }}
|
||||||
|
{% if plan.is_default %}(Recommended){% endif %}
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
{% endfor %}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
{% else %}
|
||||||
|
<a href="{% url 'services:create_lead' offering.service.slug %}?offering={{ offering.id }}"
|
||||||
|
class="btn btn-success">Show Interest</a>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% empty %}
|
||||||
|
<div class="col">
|
||||||
|
<div class="alert alert-info">
|
||||||
|
No service offerings found matching your criteria.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
93
hub/services/templates/services/partner_list.html
Normal file
93
hub/services/templates/services/partner_list.html
Normal file
|
@ -0,0 +1,93 @@
|
||||||
|
{% extends 'services/base.html' %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<div class="row">
|
||||||
|
<div class="col-md-3">
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-body">
|
||||||
|
<h5 class="card-title">Filters</h5>
|
||||||
|
<form method="get">
|
||||||
|
<div class="mb-3">
|
||||||
|
<label for="search" class="form-label">Search</label>
|
||||||
|
<input type="text" class="form-control" id="search" name="search" value="{{ request.GET.search }}">
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mb-3">
|
||||||
|
<label for="cloud_provider" class="form-label">Cloud Provider</label>
|
||||||
|
<select class="form-select" id="cloud_provider" name="cloud_provider">
|
||||||
|
<option value="">All Providers</option>
|
||||||
|
{% for provider in cloud_providers %}
|
||||||
|
<option value="{{ provider.id }}" {% if request.GET.cloud_provider == provider.id|stringformat:'i' %}selected{% endif %}>
|
||||||
|
{{ provider.name }}
|
||||||
|
</option>
|
||||||
|
{% endfor %}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button type="submit" class="btn btn-primary">Apply Filters</button>
|
||||||
|
<a href="{% url 'services:partner_list' %}" class="btn btn-secondary">Clear</a>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="col-md-9">
|
||||||
|
<div class="row row-cols-1 row-cols-md-2 g-4">
|
||||||
|
{% for partner in partners %}
|
||||||
|
<div class="col">
|
||||||
|
<div class="card h-100">
|
||||||
|
<div class="card-body">
|
||||||
|
<div class="d-flex align-items-center mb-3">
|
||||||
|
{% if partner.logo %}
|
||||||
|
<img src="{{ partner.logo.url }}"
|
||||||
|
alt="{{ partner.name }}"
|
||||||
|
class="me-3" style="max-height: 60px; max-width: 120px; object-fit: contain;">
|
||||||
|
{% endif %}
|
||||||
|
<h5 class="card-title mb-0">{{ partner.name }}</h5>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="rich-text-content mb-3">
|
||||||
|
{{ partner.description|safe|truncatewords_html:50 }}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mb-3">
|
||||||
|
<strong>Cloud Providers:</strong>
|
||||||
|
<div class="mt-2">
|
||||||
|
{% for provider in partner.cloud_providers.all %}
|
||||||
|
<div class="d-inline-block me-2 mb-2">
|
||||||
|
{% if provider.logo %}
|
||||||
|
<img src="{{ provider.logo.url }}"
|
||||||
|
alt="{{ provider.name }}"
|
||||||
|
style="max-height: 25px; max-width: 50px; object-fit: contain;">
|
||||||
|
{% else %}
|
||||||
|
<span class="badge bg-secondary">{{ provider.name }}</span>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mb-3">
|
||||||
|
<strong>Services Available:</strong> {{ partner.services.count }}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mt-auto">
|
||||||
|
<a href="{{ partner.get_absolute_url }}" class="btn btn-primary">View Details</a>
|
||||||
|
{% if partner.website %}
|
||||||
|
<a href="{{ partner.website }}" class="btn btn-outline-secondary" target="_blank">Visit Website</a>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% empty %}
|
||||||
|
<div class="col">
|
||||||
|
<div class="alert alert-info">
|
||||||
|
No consulting partners found matching your criteria.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
68
hub/services/templates/services/provider_list.html
Normal file
68
hub/services/templates/services/provider_list.html
Normal file
|
@ -0,0 +1,68 @@
|
||||||
|
{% extends 'services/base.html' %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<div class="row">
|
||||||
|
<div class="col-md-3">
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-body">
|
||||||
|
<h5 class="card-title">Filters</h5>
|
||||||
|
<form method="get">
|
||||||
|
<div class="mb-3">
|
||||||
|
<label for="search" class="form-label">Search</label>
|
||||||
|
<input type="text" class="form-control" id="search" name="search" value="{{ request.GET.search }}">
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button type="submit" class="btn btn-primary">Apply Filters</button>
|
||||||
|
<a href="{% url 'services:provider_list' %}" class="btn btn-secondary">Clear</a>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="col-md-9">
|
||||||
|
<div class="row row-cols-1 row-cols-md-2 g-4">
|
||||||
|
{% for provider in providers %}
|
||||||
|
<div class="col">
|
||||||
|
<div class="card h-100">
|
||||||
|
<div class="card-body">
|
||||||
|
<div class="d-flex align-items-center mb-3">
|
||||||
|
{% if provider.logo %}
|
||||||
|
<img src="{{ provider.logo.url }}"
|
||||||
|
alt="{{ provider.name }}"
|
||||||
|
class="me-3" style="max-height: 60px; max-width: 120px; object-fit: contain;">
|
||||||
|
{% endif %}
|
||||||
|
<h5 class="card-title mb-0">{{ provider.name }}</h5>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="rich-text-content mb-3">
|
||||||
|
{{ provider.description|safe|truncatewords_html:50 }}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mb-3">
|
||||||
|
<strong>Services Available:</strong> {{ provider.offerings.count }}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mb-3">
|
||||||
|
<strong>Partners:</strong> {{ provider.consulting_partners.count }}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mt-auto">
|
||||||
|
<a href="{{ provider.get_absolute_url }}" class="btn btn-primary">View Details</a>
|
||||||
|
{% if provider.website %}
|
||||||
|
<a href="{{ provider.website }}" class="btn btn-outline-secondary" target="_blank">Visit Website</a>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% empty %}
|
||||||
|
<div class="col">
|
||||||
|
<div class="alert alert-info">
|
||||||
|
No cloud providers found matching your criteria.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
|
@ -1,120 +1,132 @@
|
||||||
{% extends 'services/base.html' %}
|
{% extends 'services/base.html' %}
|
||||||
|
|
||||||
{% block content %}
|
{% block content %}
|
||||||
<div class="card">
|
<div class="card mb-4">
|
||||||
<div class="card-body">
|
<div class="card-body">
|
||||||
<div class="d-flex align-items-start mb-4">
|
<div class="d-flex align-items-start mb-4">
|
||||||
{% if service.logo %}
|
{% if service.logo %}
|
||||||
<img src="{{ service.logo.url }}" alt="{{ service.name }} logo" class="me-4" style="max-height: 100px; max-width: 200px; object-fit: contain;">
|
<img src="{{ service.logo.url }}" alt="{{ service.name }} logo" class="me-4"
|
||||||
|
style="max-height: 120px; max-width: 240px; object-fit: contain;">
|
||||||
{% endif %}
|
{% endif %}
|
||||||
<div>
|
<div>
|
||||||
<h2 class="card-title">{{ service.name }}</h2>
|
<h2 class="card-title mb-3">{{ service.name }}</h2>
|
||||||
<div class="d-flex align-items-center">
|
<div class="mb-3">
|
||||||
{% if service.cloud_provider.logo %}
|
{% for category in service.categories.all %}
|
||||||
<a href="{% url 'services:provider_detail' service.cloud_provider.slug %}">
|
<span class="badge bg-secondary me-1">{{ category.full_path }}</span>
|
||||||
<img src="{{ service.cloud_provider.logo.url }}" alt="{{ service.cloud_provider.name }} logo"
|
|
||||||
class="me-2" style="max-height: 25px; max-width: 200px; object-fit: contain;">
|
|
||||||
</a>
|
|
||||||
{% else %}
|
|
||||||
<h6 class="card-subtitle text-muted mb-0">
|
|
||||||
<a href="{% url 'services:provider_detail' service.cloud_provider.pk %}"
|
|
||||||
class="text-decoration-none text-muted">{{ service.cloud_provider.name }}</a>
|
|
||||||
</h6>
|
|
||||||
{% endif %}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="row mb-4">
|
|
||||||
<div class="col-md-8">
|
|
||||||
<h5>Description</h5>
|
|
||||||
<p>{{ service.description|safe }}</p>
|
|
||||||
|
|
||||||
<h5>Features</h5>
|
|
||||||
<p>{{ service.features|safe }}</p>
|
|
||||||
|
|
||||||
{% if service.external_links.exists %}
|
|
||||||
<h5>External Resources</h5>
|
|
||||||
{% for link in service.external_links.all %}
|
|
||||||
<a href="{{ link.url }}" target="_blank" rel="noopener noreferrer"
|
|
||||||
class="list-group-item list-group-item-action d-flex justify-content-between align-items-center">
|
|
||||||
{{ link.description }}
|
|
||||||
<i class="bi bi-box-arrow-up-right"></i>
|
|
||||||
</a>
|
|
||||||
{% endfor %}
|
|
||||||
{% endif %}
|
|
||||||
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="row mb-4">
|
|
||||||
<div class="col-12">
|
|
||||||
<h3 class="mb-4">Plans</h3>
|
|
||||||
<div class="row row-cols-1 row-cols-md-3 g-4">
|
|
||||||
{% for plan in service.plans.all %}
|
|
||||||
<div class="col">
|
|
||||||
<div class="card h-100 {% if plan.is_default %}border-primary{% endif %}">
|
|
||||||
{% if plan.is_default %}
|
|
||||||
<div class="card-header text-primary">
|
|
||||||
Recommended
|
|
||||||
</div>
|
|
||||||
{% endif %}
|
|
||||||
<div class="card-body">
|
|
||||||
<h5 class="card-title">{{ plan.name }}</h5>
|
|
||||||
<div class="card-text mb-3">{{ plan.description|safe }}</div>
|
|
||||||
|
|
||||||
<div class="mb-3">
|
|
||||||
{% for price in plan.prices.all %}
|
|
||||||
<div class="mb-1">
|
|
||||||
<strong>{{ price.currency.symbol }}</strong> {{ price.price }}
|
|
||||||
<small class="text-muted">{{ price.currency.code }}</small>
|
|
||||||
</div>
|
|
||||||
{% endfor %}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{% if plan.features %}
|
|
||||||
<div class="rich-text-content">
|
|
||||||
{{ plan.features|safe }}
|
|
||||||
</div>
|
|
||||||
{% endif %}
|
|
||||||
|
|
||||||
<a href="{% url 'services:create_lead' service.slug %}?plan={{ plan.id }}"
|
|
||||||
class="btn btn-primary mt-3">Order</a>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<a href="{% url 'services:create_lead' service.slug %}" class="btn btn-success">Order</a>
|
<div class="rich-text-content mb-4">
|
||||||
<a href="{% url 'services:service_list' %}" class="btn btn-secondary">Back to Services</a>
|
{{ service.description|safe }}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% if service.features %}
|
||||||
|
<h3>Features</h3>
|
||||||
|
<div class="rich-text-content mb-4">
|
||||||
|
{{ service.features|safe }}
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{% if service.consulting_partners.exists %}
|
<!-- Cloud Provider Offerings -->
|
||||||
<div class="card mb-3">
|
<div class="card mb-4">
|
||||||
<div class="card-body">
|
<div class="card-body">
|
||||||
<h5 class="card-title">Consulting Partners</h5>
|
<h3 class="mb-4">Available Offerings</h3>
|
||||||
<div class="row row-cols-1 row-cols-md-2 g-4">
|
<div class="row row-cols-1 row-cols-md-2 g-4">
|
||||||
|
{% for offering in service.offerings.all %}
|
||||||
|
<div class="col">
|
||||||
|
<div class="card h-100">
|
||||||
|
<div class="card-body">
|
||||||
|
<div class="d-flex align-items-center mb-3">
|
||||||
|
{% if offering.cloud_provider.logo %}
|
||||||
|
<img src="{{ offering.cloud_provider.logo.url }}"
|
||||||
|
alt="{{ offering.cloud_provider.name }} logo"
|
||||||
|
class="me-3" style="max-height: 50px; max-width: 100px; object-fit: contain;">
|
||||||
|
{% endif %}
|
||||||
|
<div>
|
||||||
|
<h5 class="card-title mb-0">
|
||||||
|
<a href="{{ offering.cloud_provider.get_absolute_url }}" class="text-decoration-none">
|
||||||
|
{{ offering.cloud_provider.name }}
|
||||||
|
</a>
|
||||||
|
</h5>
|
||||||
|
{% if offering.cloud_provider.website %}
|
||||||
|
<a href="{{ offering.cloud_provider.website }}" class="text-muted small" target="_blank">
|
||||||
|
Visit Provider Website
|
||||||
|
</a>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="rich-text-content mb-3">
|
||||||
|
{{ offering.description|safe|truncatewords_html:50 }}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% if offering.plans.exists %}
|
||||||
|
<div class="mb-3">
|
||||||
|
<h6>Plans:</h6>
|
||||||
|
{% for plan in offering.plans.all %}
|
||||||
|
<div class="card mb-2">
|
||||||
|
<div class="card-body">
|
||||||
|
<h6 class="card-title">{{ plan.name }}</h6>
|
||||||
|
{% for price in plan.prices.all %}
|
||||||
|
<div class="small text-muted">
|
||||||
|
{{ price.currency.symbol }}{{ price.price }} {{ price.currency.code }} per {{ price.term.name }}
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<a href="{% url 'services:create_lead' service.slug %}?offering={{ offering.id }}"
|
||||||
|
class="btn btn-success">Show Interest</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% empty %}
|
||||||
|
<div class="col-12">
|
||||||
|
<div class="alert alert-info">
|
||||||
|
No offerings available yet.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Consulting Partners -->
|
||||||
|
{% if service.consulting_partners.exists %}
|
||||||
|
<div class="card mb-4">
|
||||||
|
<div class="card-body">
|
||||||
|
<h3 class="mb-4">Consulting Partners</h3>
|
||||||
|
<div class="row row-cols-1 row-cols-md-3 g-4">
|
||||||
{% for partner in service.consulting_partners.all %}
|
{% for partner in service.consulting_partners.all %}
|
||||||
<div class="col">
|
<div class="col">
|
||||||
<div class="d-flex align-items-center">
|
<div class="card h-100">
|
||||||
{% if partner.logo %}
|
<div class="card-body">
|
||||||
<img src="{{ partner.logo.url }}" alt="{{ partner.name }} logo"
|
<div class="d-flex align-items-center mb-3">
|
||||||
class="me-2" style="max-height: 40px; max-width: 80px; object-fit: contain;">
|
{% if partner.logo %}
|
||||||
{% endif %}
|
<img src="{{ partner.logo.url }}"
|
||||||
<div>
|
alt="{{ partner.name }} logo"
|
||||||
<a href="{{ partner.get_absolute_url }}" class="text-decoration-none">
|
class="me-3" style="max-height: 50px; max-width: 100px; object-fit: contain;">
|
||||||
{{ partner.name }}
|
{% endif %}
|
||||||
</a>
|
<div>
|
||||||
{% if partner.website %}
|
<h5 class="card-title mb-0">
|
||||||
<br>
|
<a href="{{ partner.get_absolute_url }}" class="text-decoration-none">
|
||||||
<a href="{{ partner.website }}" class="small text-muted" target="_blank">
|
{{ partner.name }}
|
||||||
Visit Website
|
</a>
|
||||||
</a>
|
</h5>
|
||||||
{% endif %}
|
{% if partner.website %}
|
||||||
|
<a href="{{ partner.website }}" class="text-muted small" target="_blank">
|
||||||
|
Visit Website
|
||||||
|
</a>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
@ -123,4 +135,22 @@
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
|
<!-- External Links -->
|
||||||
|
{% if service.external_links.exists %}
|
||||||
|
<div class="card mb-4">
|
||||||
|
<div class="card-body">
|
||||||
|
<h3>Additional Information</h3>
|
||||||
|
<div class="list-group">
|
||||||
|
{% for link in service.external_links.all %}
|
||||||
|
<a href="{{ link.url }}" target="_blank" rel="noopener noreferrer"
|
||||||
|
class="list-group-item list-group-item-action d-flex justify-content-between align-items-center">
|
||||||
|
{{ link.description }}
|
||||||
|
<i class="bi bi-box-arrow-up-right"></i>
|
||||||
|
</a>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
{% endblock %}
|
{% endblock %}
|
|
@ -72,18 +72,23 @@
|
||||||
{% endif %}
|
{% endif %}
|
||||||
<div>
|
<div>
|
||||||
<h5 class="card-title mb-0">{{ service.name }}</h5>
|
<h5 class="card-title mb-0">{{ service.name }}</h5>
|
||||||
<div class="d-flex align-items-center mt-2">
|
<div class="mt-2">
|
||||||
{% if service.cloud_provider.logo %}
|
{% for offering in service.offerings.all %}
|
||||||
<a href="{% url 'services:provider_detail' service.cloud_provider.slug %}">
|
<div class="d-inline-block me-2">
|
||||||
<img src="{{ service.cloud_provider.logo.url }}" alt="{{ service.cloud_provider.name }} logo"
|
{% if offering.cloud_provider.logo %}
|
||||||
class="me-2" style="max-height: 25px; max-width: 200px; object-fit: contain;">
|
<a href="{% url 'services:provider_detail' offering.cloud_provider.slug %}" title="{{ offering.cloud_provider.name }}">
|
||||||
</a>
|
<img src="{{ offering.cloud_provider.logo.url }}"
|
||||||
{% else %}
|
alt="{{ offering.cloud_provider.name }}"
|
||||||
<h6 class="card-subtitle text-muted mb-0">
|
style="max-height: 25px; max-width: 50px; object-fit: contain;">
|
||||||
<a href="{% url 'services:provider_detail' service.cloud_provider.pk %}"
|
</a>
|
||||||
class="text-decoration-none text-muted">{{ service.cloud_provider.name }}</a>
|
{% else %}
|
||||||
</h6>
|
<a href="{% url 'services:provider_detail' offering.cloud_provider.slug %}"
|
||||||
{% endif %}
|
class="badge bg-secondary text-decoration-none">
|
||||||
|
{{ offering.cloud_provider.name }}
|
||||||
|
</a>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
@ -94,8 +99,25 @@
|
||||||
</div>
|
</div>
|
||||||
<p class="card-text">{{ service.description|safe|truncatewords:30 }}</p>
|
<p class="card-text">{{ service.description|safe|truncatewords:30 }}</p>
|
||||||
|
|
||||||
<a href="{{ service.get_absolute_url }}" class="btn btn-primary">View Details</a>
|
<div class="mt-3">
|
||||||
<a href="{% url 'services:create_lead' service.slug %}" class="btn btn-success">Show Interest</a>
|
<a href="{{ service.get_absolute_url }}" class="btn btn-primary">View Details</a>
|
||||||
|
{% if service.offerings.exists %}
|
||||||
|
<div class="dropdown d-inline-block">
|
||||||
|
<button class="btn btn-success dropdown-toggle" type="button" data-bs-toggle="dropdown">
|
||||||
|
Show Interest
|
||||||
|
</button>
|
||||||
|
<ul class="dropdown-menu">
|
||||||
|
{% for offering in service.offerings.all %}
|
||||||
|
<li>
|
||||||
|
<a class="dropdown-item" href="{% url 'services:create_lead' service.slug %}?offering={{ offering.id }}">
|
||||||
|
via {{ offering.cloud_provider.name }}
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
{% endfor %}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
@ -5,9 +5,13 @@ app_name = "services"
|
||||||
|
|
||||||
urlpatterns = [
|
urlpatterns = [
|
||||||
path("", views.service_list, name="service_list"),
|
path("", views.service_list, name="service_list"),
|
||||||
|
path("offerings/", views.offering_list, name="offering_list"),
|
||||||
|
path("providers/", views.provider_list, name="provider_list"),
|
||||||
|
path("partners/", views.partner_list, name="partner_list"),
|
||||||
path("service/<slug:slug>/", views.service_detail, name="service_detail"),
|
path("service/<slug:slug>/", views.service_detail, name="service_detail"),
|
||||||
|
path("offering/<slug:slug>/", views.offering_detail, name="offering_detail"),
|
||||||
|
path("provider/<slug:slug>/", views.cloud_provider_detail, name="provider_detail"),
|
||||||
|
path("partner/<slug:slug>/", views.partner_detail, name="partner_detail"),
|
||||||
path("service/<slug:slug>/interest/", views.create_lead, name="create_lead"),
|
path("service/<slug:slug>/interest/", views.create_lead, name="create_lead"),
|
||||||
path("service/<slug:slug>/thank-you/", views.thank_you, name="thank_you"),
|
path("service/<slug:slug>/thank-you/", views.thank_you, name="thank_you"),
|
||||||
path("provider/<slug:slug>/", views.provider_detail, name="provider_detail"),
|
|
||||||
path("partner/<slug:slug>/", views.partner_detail, name="partner_detail"),
|
|
||||||
]
|
]
|
||||||
|
|
|
@ -1,17 +1,17 @@
|
||||||
import logging
|
import logging
|
||||||
|
|
||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
from django.shortcuts import render, get_object_or_404, redirect
|
from django.shortcuts import render, get_object_or_404, redirect
|
||||||
from django.contrib import messages
|
from django.contrib import messages
|
||||||
from django.db.models import Q
|
from django.db.models import Q, Prefetch
|
||||||
from .models import (
|
from .models import (
|
||||||
Service,
|
Service,
|
||||||
CloudProvider,
|
ServiceOffering,
|
||||||
ConsultingPartner,
|
|
||||||
Category,
|
|
||||||
Plan,
|
Plan,
|
||||||
|
ConsultingPartner,
|
||||||
|
CloudProvider,
|
||||||
|
Category,
|
||||||
|
PlanPrice,
|
||||||
)
|
)
|
||||||
|
|
||||||
from .forms import LeadForm
|
from .forms import LeadForm
|
||||||
from .odoo import OdooAPI
|
from .odoo import OdooAPI
|
||||||
|
|
||||||
|
@ -19,50 +19,177 @@ logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
def service_list(request):
|
def service_list(request):
|
||||||
services = Service.objects.all()
|
services = Service.objects.all().prefetch_related(
|
||||||
|
"categories",
|
||||||
|
"offerings",
|
||||||
|
"offerings__cloud_provider",
|
||||||
|
"offerings__plans",
|
||||||
|
"offerings__plans__prices",
|
||||||
|
"consulting_partners",
|
||||||
|
"external_links",
|
||||||
|
)
|
||||||
cloud_providers = CloudProvider.objects.all()
|
cloud_providers = CloudProvider.objects.all()
|
||||||
categories = Category.objects.filter(parent=None)
|
categories = Category.objects.filter(parent=None).prefetch_related("children")
|
||||||
consulting_partners = ConsultingPartner.objects.all()
|
|
||||||
|
|
||||||
if request.GET.get("cloud_provider"):
|
|
||||||
services = services.filter(cloud_provider_id=request.GET.get("cloud_provider"))
|
|
||||||
|
|
||||||
|
# Handle category filter
|
||||||
if request.GET.get("category"):
|
if request.GET.get("category"):
|
||||||
category_id = request.GET.get("category")
|
category_id = request.GET.get("category")
|
||||||
category = Category.objects.get(id=category_id)
|
category = get_object_or_404(Category, id=category_id)
|
||||||
subcategories = Category.objects.filter(parent=category)
|
subcategories = Category.objects.filter(parent=category)
|
||||||
services = services.filter(
|
services = services.filter(
|
||||||
Q(categories=category) | Q(categories__in=subcategories)
|
Q(categories=category) | Q(categories__in=subcategories)
|
||||||
).distinct()
|
).distinct()
|
||||||
|
|
||||||
if request.GET.get("consulting_partner"):
|
# Handle cloud provider filter
|
||||||
services = services.filter(
|
if request.GET.get("cloud_provider"):
|
||||||
consulting_partners__id=request.GET.get("consulting_partner")
|
provider_id = request.GET.get("cloud_provider")
|
||||||
)
|
services = services.filter(offerings__cloud_provider_id=provider_id).distinct()
|
||||||
|
|
||||||
|
# Handle consulting partner filter
|
||||||
|
if request.GET.get("consulting_partner"):
|
||||||
|
partner_id = request.GET.get("consulting_partner")
|
||||||
|
services = services.filter(consulting_partners__id=partner_id).distinct()
|
||||||
|
|
||||||
|
# Handle search
|
||||||
if request.GET.get("search"):
|
if request.GET.get("search"):
|
||||||
query = request.GET.get("search")
|
query = request.GET.get("search")
|
||||||
services = services.filter(
|
services = services.filter(
|
||||||
Q(name__icontains=query) | Q(description__icontains=query)
|
Q(name__icontains=query)
|
||||||
)
|
| Q(description__icontains=query)
|
||||||
|
| Q(offerings__description__icontains=query)
|
||||||
|
).distinct()
|
||||||
|
|
||||||
context = {
|
context = {
|
||||||
"services": services,
|
"services": services,
|
||||||
"cloud_providers": cloud_providers,
|
"cloud_providers": cloud_providers,
|
||||||
"categories": categories,
|
"categories": categories,
|
||||||
"consulting_partners": consulting_partners,
|
"consulting_partners": ConsultingPartner.objects.all(),
|
||||||
}
|
}
|
||||||
return render(request, "services/service_list.html", context)
|
return render(request, "services/service_list.html", context)
|
||||||
|
|
||||||
|
|
||||||
def service_detail(request, slug):
|
def service_detail(request, slug):
|
||||||
service = get_object_or_404(Service, slug=slug)
|
service = get_object_or_404(
|
||||||
return render(request, "services/service_detail.html", {"service": service})
|
Service.objects.prefetch_related(
|
||||||
|
"categories",
|
||||||
|
"offerings",
|
||||||
|
"offerings__cloud_provider",
|
||||||
|
"offerings__plans",
|
||||||
|
"offerings__plans__prices",
|
||||||
|
"offerings__plans__prices__currency",
|
||||||
|
"offerings__plans__prices__term",
|
||||||
|
"consulting_partners",
|
||||||
|
"external_links",
|
||||||
|
),
|
||||||
|
slug=slug,
|
||||||
|
)
|
||||||
|
|
||||||
|
context = {
|
||||||
|
"service": service,
|
||||||
|
}
|
||||||
|
return render(request, "services/service_detail.html", context)
|
||||||
|
|
||||||
|
|
||||||
def provider_detail(request, slug):
|
def offering_list(request):
|
||||||
provider = get_object_or_404(CloudProvider, slug=slug)
|
offerings = (
|
||||||
services = Service.objects.filter(cloud_provider=provider)
|
ServiceOffering.objects.all()
|
||||||
|
.select_related("service", "cloud_provider")
|
||||||
|
.prefetch_related(
|
||||||
|
"service__categories",
|
||||||
|
"plans",
|
||||||
|
"plans__prices",
|
||||||
|
"plans__prices__currency",
|
||||||
|
"plans__prices__term",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
cloud_providers = CloudProvider.objects.all()
|
||||||
|
categories = Category.objects.filter(parent=None).prefetch_related("children")
|
||||||
|
|
||||||
|
# Handle cloud provider filter
|
||||||
|
if request.GET.get("cloud_provider"):
|
||||||
|
provider_id = request.GET.get("cloud_provider")
|
||||||
|
offerings = offerings.filter(cloud_provider_id=provider_id)
|
||||||
|
|
||||||
|
# Handle category filter
|
||||||
|
if request.GET.get("category"):
|
||||||
|
category_id = request.GET.get("category")
|
||||||
|
category = get_object_or_404(Category, id=category_id)
|
||||||
|
subcategories = Category.objects.filter(parent=category)
|
||||||
|
offerings = offerings.filter(
|
||||||
|
Q(service__categories=category) | Q(service__categories__in=subcategories)
|
||||||
|
).distinct()
|
||||||
|
|
||||||
|
# Handle search
|
||||||
|
if request.GET.get("search"):
|
||||||
|
query = request.GET.get("search")
|
||||||
|
offerings = offerings.filter(
|
||||||
|
Q(service__name__icontains=query)
|
||||||
|
| Q(description__icontains=query)
|
||||||
|
| Q(cloud_provider__name__icontains=query)
|
||||||
|
).distinct()
|
||||||
|
|
||||||
|
context = {
|
||||||
|
"offerings": offerings,
|
||||||
|
"cloud_providers": cloud_providers,
|
||||||
|
"categories": categories,
|
||||||
|
}
|
||||||
|
return render(request, "services/offering_list.html", context)
|
||||||
|
|
||||||
|
|
||||||
|
def offering_detail(request, slug):
|
||||||
|
offering = get_object_or_404(
|
||||||
|
ServiceOffering.objects.select_related(
|
||||||
|
"service", "cloud_provider"
|
||||||
|
).prefetch_related(
|
||||||
|
"plans", "plans__prices", "plans__prices__currency", "plans__prices__term"
|
||||||
|
),
|
||||||
|
slug=slug,
|
||||||
|
)
|
||||||
|
|
||||||
|
context = {
|
||||||
|
"offering": offering,
|
||||||
|
}
|
||||||
|
return render(request, "services/offering_detail.html", context)
|
||||||
|
|
||||||
|
|
||||||
|
def provider_list(request):
|
||||||
|
providers = CloudProvider.objects.all().prefetch_related(
|
||||||
|
"offerings", "consulting_partners"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Handle search
|
||||||
|
if request.GET.get("search"):
|
||||||
|
query = request.GET.get("search")
|
||||||
|
providers = providers.filter(
|
||||||
|
Q(name__icontains=query) | Q(description__icontains=query)
|
||||||
|
)
|
||||||
|
|
||||||
|
context = {
|
||||||
|
"providers": providers,
|
||||||
|
}
|
||||||
|
return render(request, "services/provider_list.html", context)
|
||||||
|
|
||||||
|
|
||||||
|
def cloud_provider_detail(request, slug):
|
||||||
|
provider = get_object_or_404(
|
||||||
|
CloudProvider.objects.prefetch_related(
|
||||||
|
"offerings",
|
||||||
|
"offerings__service",
|
||||||
|
"offerings__plans",
|
||||||
|
"offerings__plans__prices",
|
||||||
|
"consulting_partners",
|
||||||
|
),
|
||||||
|
slug=slug,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Get all services offered by this provider through offerings
|
||||||
|
services = (
|
||||||
|
Service.objects.filter(offerings__cloud_provider=provider)
|
||||||
|
.distinct()
|
||||||
|
.prefetch_related("categories")
|
||||||
|
)
|
||||||
|
|
||||||
context = {
|
context = {
|
||||||
"provider": provider,
|
"provider": provider,
|
||||||
"services": services,
|
"services": services,
|
||||||
|
@ -70,37 +197,94 @@ def provider_detail(request, slug):
|
||||||
return render(request, "services/provider_detail.html", context)
|
return render(request, "services/provider_detail.html", context)
|
||||||
|
|
||||||
|
|
||||||
def partner_detail(request, slug):
|
def partner_list(request):
|
||||||
partner = get_object_or_404(ConsultingPartner, slug=slug)
|
partners = ConsultingPartner.objects.all().prefetch_related(
|
||||||
services = Service.objects.filter(consulting_partners=partner)
|
"services", "cloud_providers"
|
||||||
return render(
|
|
||||||
request,
|
|
||||||
"services/partner_detail.html",
|
|
||||||
{"partner": partner, "services": services},
|
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# Handle cloud provider filter
|
||||||
|
if request.GET.get("cloud_provider"):
|
||||||
|
provider_id = request.GET.get("cloud_provider")
|
||||||
|
partners = partners.filter(cloud_providers__id=provider_id)
|
||||||
|
|
||||||
def thank_you(request, slug):
|
# Handle search
|
||||||
service = get_object_or_404(Service, slug=slug)
|
if request.GET.get("search"):
|
||||||
return render(request, "services/thank_you.html", {"service": service})
|
query = request.GET.get("search")
|
||||||
|
partners = partners.filter(
|
||||||
|
Q(name__icontains=query) | Q(description__icontains=query)
|
||||||
|
)
|
||||||
|
|
||||||
|
context = {
|
||||||
|
"partners": partners,
|
||||||
|
"cloud_providers": CloudProvider.objects.all(),
|
||||||
|
}
|
||||||
|
return render(request, "services/partner_list.html", context)
|
||||||
|
|
||||||
|
|
||||||
|
def partner_detail(request, slug):
|
||||||
|
partner = get_object_or_404(
|
||||||
|
ConsultingPartner.objects.prefetch_related(
|
||||||
|
"services",
|
||||||
|
"services__categories",
|
||||||
|
"services__offerings",
|
||||||
|
"services__offerings__cloud_provider",
|
||||||
|
"cloud_providers",
|
||||||
|
),
|
||||||
|
slug=slug,
|
||||||
|
)
|
||||||
|
|
||||||
|
context = {
|
||||||
|
"partner": partner,
|
||||||
|
"services": partner.services.all(),
|
||||||
|
}
|
||||||
|
return render(request, "services/partner_detail.html", context)
|
||||||
|
|
||||||
|
|
||||||
def create_lead(request, slug):
|
def create_lead(request, slug):
|
||||||
service = get_object_or_404(Service, slug=slug)
|
service = get_object_or_404(
|
||||||
|
Service.objects.prefetch_related("categories"), slug=slug
|
||||||
|
)
|
||||||
|
selected_offering = None
|
||||||
selected_plan = None
|
selected_plan = None
|
||||||
|
|
||||||
if request.GET.get("plan"):
|
# Get the offering if specified
|
||||||
selected_plan = get_object_or_404(
|
if request.GET.get("offering"):
|
||||||
Plan, id=request.GET.get("plan"), service=service
|
selected_offering = get_object_or_404(
|
||||||
|
ServiceOffering.objects.select_related("cloud_provider").prefetch_related(
|
||||||
|
"plans"
|
||||||
|
),
|
||||||
|
id=request.GET.get("offering"),
|
||||||
|
service=service,
|
||||||
)
|
)
|
||||||
else:
|
|
||||||
selected_plan = service.get_default_plan()
|
if selected_offering.plans.exists():
|
||||||
|
if not request.GET.get("plan"):
|
||||||
|
# If there's only one plan, automatically select it
|
||||||
|
if selected_offering.plans.count() == 1:
|
||||||
|
return redirect(
|
||||||
|
"services:create_lead",
|
||||||
|
slug=service.slug,
|
||||||
|
offering=selected_offering.id,
|
||||||
|
plan=selected_offering.plans.first().id,
|
||||||
|
)
|
||||||
|
# If there are multiple plans, redirect to offering detail
|
||||||
|
return redirect("services:offering_detail", slug=selected_offering.slug)
|
||||||
|
|
||||||
|
# Get the selected plan
|
||||||
|
selected_plan = get_object_or_404(
|
||||||
|
Plan.objects.prefetch_related(
|
||||||
|
"prices", "prices__currency", "prices__term"
|
||||||
|
),
|
||||||
|
id=request.GET.get("plan"),
|
||||||
|
offering=selected_offering,
|
||||||
|
)
|
||||||
|
|
||||||
if request.method == "POST":
|
if request.method == "POST":
|
||||||
form = LeadForm(request.POST)
|
form = LeadForm(request.POST)
|
||||||
if form.is_valid():
|
if form.is_valid():
|
||||||
lead = form.save(commit=False)
|
lead = form.save(commit=False)
|
||||||
lead.service = service
|
lead.service = service
|
||||||
|
lead.offering = selected_offering
|
||||||
lead.plan = selected_plan
|
lead.plan = selected_plan
|
||||||
|
|
||||||
try:
|
try:
|
||||||
|
@ -124,8 +308,15 @@ def create_lead(request, slug):
|
||||||
else:
|
else:
|
||||||
form = LeadForm()
|
form = LeadForm()
|
||||||
|
|
||||||
return render(
|
context = {
|
||||||
request,
|
"form": form,
|
||||||
"services/lead_form.html",
|
"service": service,
|
||||||
{"form": form, "service": service, "selected_plan": selected_plan},
|
"selected_offering": selected_offering,
|
||||||
)
|
"selected_plan": selected_plan,
|
||||||
|
}
|
||||||
|
return render(request, "services/lead_form.html", context)
|
||||||
|
|
||||||
|
|
||||||
|
def thank_you(request, slug):
|
||||||
|
service = get_object_or_404(Service, slug=slug)
|
||||||
|
return render(request, "services/thank_you.html", {"service": service})
|
||||||
|
|
|
@ -7,6 +7,7 @@ requires-python = ">=3.13"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"django>=5.1.5",
|
"django>=5.1.5",
|
||||||
"django-prose-editor[sanitize]>=0.10.3",
|
"django-prose-editor[sanitize]>=0.10.3",
|
||||||
|
"django-schema-viewer>=0.5.2",
|
||||||
"djangorestframework>=3.15.2",
|
"djangorestframework>=3.15.2",
|
||||||
"environs[django]~=14.0",
|
"environs[django]~=14.0",
|
||||||
"odoorpc>=0.10.1",
|
"odoorpc>=0.10.1",
|
||||||
|
|
14
uv.lock
generated
14
uv.lock
generated
|
@ -98,6 +98,18 @@ sanitize = [
|
||||||
{ name = "nh3" },
|
{ name = "nh3" },
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "django-schema-viewer"
|
||||||
|
version = "0.5.2"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
dependencies = [
|
||||||
|
{ name = "django" },
|
||||||
|
]
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/a2/11/ab9c6b0105d55b9a8197b9795d77fec72d1ec065c2cb479410c6ca1f7daf/django_schema_viewer-0.5.2.tar.gz", hash = "sha256:4c9316a3831e6015270b432404f1589b6b711b13ebc6d797b1fce82d1fa7281e", size = 486063 }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/aa/1a/f2dc4b54b18da7fd8dec7149c86f484581addd010f65f7a40bf16addbf39/django_schema_viewer-0.5.2-py3-none-any.whl", hash = "sha256:d799f52194cb906add990a1f178d92b0acfcb0a2e54c022843784e06fb12d75b", size = 492672 },
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "djangorestframework"
|
name = "djangorestframework"
|
||||||
version = "3.15.2"
|
version = "3.15.2"
|
||||||
|
@ -234,6 +246,7 @@ source = { virtual = "." }
|
||||||
dependencies = [
|
dependencies = [
|
||||||
{ name = "django" },
|
{ name = "django" },
|
||||||
{ name = "django-prose-editor", extra = ["sanitize"] },
|
{ name = "django-prose-editor", extra = ["sanitize"] },
|
||||||
|
{ name = "django-schema-viewer" },
|
||||||
{ name = "djangorestframework" },
|
{ name = "djangorestframework" },
|
||||||
{ name = "environs", extra = ["django"] },
|
{ name = "environs", extra = ["django"] },
|
||||||
{ name = "odoorpc" },
|
{ name = "odoorpc" },
|
||||||
|
@ -250,6 +263,7 @@ requires-dist = [
|
||||||
{ name = "django", specifier = ">=5.1.5" },
|
{ name = "django", specifier = ">=5.1.5" },
|
||||||
{ name = "django-browser-reload", marker = "extra == 'dev'", specifier = "~=1.13" },
|
{ name = "django-browser-reload", marker = "extra == 'dev'", specifier = "~=1.13" },
|
||||||
{ name = "django-prose-editor", extras = ["sanitize"], specifier = ">=0.10.3" },
|
{ name = "django-prose-editor", extras = ["sanitize"], specifier = ">=0.10.3" },
|
||||||
|
{ name = "django-schema-viewer", specifier = ">=0.5.2" },
|
||||||
{ name = "djangorestframework", specifier = ">=3.15.2" },
|
{ name = "djangorestframework", specifier = ">=3.15.2" },
|
||||||
{ name = "environs", extras = ["django"], specifier = "~=14.0" },
|
{ name = "environs", extras = ["django"], specifier = "~=14.0" },
|
||||||
{ name = "odoorpc", specifier = ">=0.10.1" },
|
{ name = "odoorpc", specifier = ">=0.10.1" },
|
||||||
|
|
Loading…
Add table
Add a link
Reference in a new issue