refactor all the things

This commit is contained in:
Tobias Brunner 2025-01-28 13:55:43 +01:00
parent 8ed39690f1
commit bb5cb708bd
No known key found for this signature in database
36 changed files with 1563 additions and 931 deletions

4
.gitignore vendored
View file

@ -11,5 +11,5 @@ wheels/
# Project specifics
.env
hub/db.sqlite3
hub/media/
*.sqlite3
hub/media/

View file

@ -57,6 +57,7 @@ INSTALLED_APPS = [
# 3rd party
"django_prose_editor",
"rest_framework",
"schema_viewer",
# local
"services",
"servicebroker",
@ -174,3 +175,12 @@ REST_FRAMEWORK = {
],
"UNAUTHENTICATED_USER": None,
}
SCHEMA_VIEWER = {
"apps": [
"services",
],
"exclude": {
"auth": ["User"],
},
}

View file

@ -11,5 +11,6 @@ urlpatterns = [
if settings.DEBUG:
urlpatterns += [
path("__reload__/", include("django_browser_reload.urls")),
path("schema-viewer/", include("schema_viewer.urls")),
]
urlpatterns += static(settings.MEDIA_URL, document_root=settings.MEDIA_ROOT)

View file

@ -1,46 +1,19 @@
from django.contrib import admin
from django.utils.html import format_html
from .models import (
Category,
CloudProvider,
ConsultingPartner,
Service,
Category,
Currency,
ExternalLink,
Plan,
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)
class CategoryAdmin(admin.ModelAdmin):
list_display = ("name", "slug", "parent", "order")
@ -50,73 +23,6 @@ class CategoryAdmin(admin.ModelAdmin):
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)
class CurrencyAdmin(admin.ModelAdmin):
list_display = ("code", "name", "symbol")
@ -124,10 +30,139 @@ class CurrencyAdmin(admin.ModelAdmin):
ordering = ("code",)
@admin.register(Plan)
class PlanAdmin(admin.ModelAdmin):
list_display = ("name", "service", "is_default", "order")
list_filter = ("service", "is_default")
@admin.register(Term)
class TermAdmin(admin.ModelAdmin):
list_display = ("name", "order")
ordering = ("order", "name")
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"

View file

@ -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 services.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):
initial = True
@ -24,11 +58,22 @@ class Migration(migrations.Migration):
),
),
("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(
name="Country",
name="Currency",
fields=[
(
"id",
@ -39,15 +84,17 @@ class Migration(migrations.Migration):
verbose_name="ID",
),
),
("name", models.CharField(max_length=100)),
("code", models.CharField(max_length=2)),
("code", models.CharField(max_length=3, unique=True)),
("name", models.CharField(max_length=50)),
("symbol", models.CharField(max_length=5)),
],
options={
"verbose_name_plural": "Countries",
"verbose_name_plural": "Currencies",
"ordering": ["code"],
},
),
migrations.CreateModel(
name="ServiceLevel",
name="Plan",
fields=[
(
"id",
@ -60,8 +107,67 @@ class Migration(migrations.Migration):
),
("name", models.CharField(max_length=100)),
("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(
name="Service",
@ -76,26 +182,235 @@ class Migration(migrations.Migration):
),
),
("name", models.CharField(max_length=200)),
("slug", models.SlugField(max_length=250, unique=True)),
("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()),
("created_at", models.DateTimeField(auto_now_add=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",
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
related_name="offerings",
to="services.cloudprovider",
),
),
("countries", models.ManyToManyField(to="services.country")),
(
"service_level",
"service",
models.ForeignKey(
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),
]

View file

@ -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],
),
),
]

View file

@ -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
from django.db import migrations, models
@ -7,18 +7,18 @@ from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("services", "0010_remove_service_price"),
("services", "0001_initial"),
]
operations = [
migrations.AddField(
model_name="lead",
name="plan",
name="offering",
field=models.ForeignKey(
blank=True,
null=True,
on_delete=django.db.models.deletion.SET_NULL,
to="services.plan",
to="services.serviceoffering",
),
),
]

View file

@ -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"
),
),
]

View file

@ -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,
),
]

View file

@ -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",
),
),
],
),
]

View file

@ -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),
),
]

View file

@ -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"
),
),
]

View file

@ -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",
),
]

View file

@ -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),
]

View file

@ -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",
),
]

View file

@ -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"],
},
),
]

View file

@ -2,7 +2,6 @@ from django.db import models
from django.core.exceptions import ValidationError
from django.urls import reverse
from django.utils.text import slugify
from django_prose_editor.fields import ProseEditorField
@ -12,63 +11,6 @@ def validate_image_size(value):
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):
name = models.CharField(max_length=100)
slug = models.SlugField(unique=True)
@ -105,7 +47,8 @@ class Category(models.Model):
class CloudProvider(models.Model):
name = models.CharField(max_length=100)
slug = models.SlugField(unique=True)
description = ProseEditorField(blank=True)
description = ProseEditorField()
website = models.URLField()
logo = models.ImageField(
upload_to="cloud_provider_logos/",
validators=[validate_image_size],
@ -125,10 +68,66 @@ class CloudProvider(models.Model):
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):
name = models.CharField(max_length=200)
slug = models.SlugField(unique=True)
description = ProseEditorField(blank=True)
description = ProseEditorField()
logo = models.ImageField(
upload_to="partner_logos/",
validators=[validate_image_size],
@ -136,6 +135,10 @@ class ConsultingPartner(models.Model):
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)
updated_at = models.DateTimeField(auto_now=True)
@ -151,60 +154,76 @@ class ConsultingPartner(models.Model):
return reverse("services:partner_detail", kwargs={"slug": self.slug})
class Service(models.Model):
name = models.CharField(max_length=200)
class ServiceOffering(models.Model):
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)
description = ProseEditorField()
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,
)
description = ProseEditorField(help_text="Provider-specific details and features")
created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)
class Meta:
unique_together = ["service", "cloud_provider"]
ordering = ["service__name", "cloud_provider__name"]
def __str__(self):
return self.name
return f"{self.service.name} on {self.cloud_provider.name}"
def save(self, *args, **kwargs):
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)
# If slug exists, append number
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}"
counter += 1
super().save(*args, **kwargs)
def get_absolute_url(self):
return reverse("services:service_detail", kwargs={"slug": self.slug})
def get_default_plan(self):
return self.plans.filter(is_default=True).first() or self.plans.first()
return reverse("services:offering_detail", kwargs={"slug": self.slug})
class Lead(models.Model):
service = models.ForeignKey(Service, on_delete=models.CASCADE)
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)
class Plan(models.Model):
name = models.CharField(max_length=100)
description = ProseEditorField()
offering = models.ForeignKey(
ServiceOffering, 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)
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):
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):
@ -227,11 +246,26 @@ class ExternalLink(models.Model):
def clean(self):
from django.core.validators import URLValidator
from django.core.exceptions import ValidationError
# Validate URL
validate = URLValidator()
try:
validate(self.url)
except ValidationError:
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})"

View file

@ -18,7 +18,7 @@ class OdooAPI:
# Parse URL to get host
url = settings.ODOO_CONFIG["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
logger.info(f"Attempting to connect to Odoo at {host}")
@ -27,10 +27,8 @@ class OdooAPI:
f"USER={settings.ODOO_CONFIG['username']}"
)
# Try to establish connection
self.odoo = odoorpc.ODOO(host, port=443, protocol="jsonrpc+ssl")
# Try to login
logger.info("Connection established, attempting login...")
self.odoo.login(
settings.ODOO_CONFIG["db"],
@ -39,7 +37,6 @@ class OdooAPI:
)
logger.info("Successfully logged into Odoo")
# Test the connection by making a simple API call
version_info = self.odoo.version
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}"
)
# 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
lead_data = {
"name": f"Interest in {lead.service.name}",
@ -70,11 +86,7 @@ class OdooAPI:
"partner_name": lead.company,
"email_from": lead.email,
"phone": lead.phone,
"description": f"""
Service: {lead.service.name}
Provider: {lead.service.cloud_provider.name}
Categories: {', '.join(cat.name for cat in lead.service.categories.all())}
""",
"description": "\n".join(service_details),
"type": "lead",
}
@ -85,20 +97,18 @@ class OdooAPI:
logger.warning("No active Odoo connection, attempting to reconnect")
self._connect()
# Get the CRM lead model
Lead = self.odoo.env["crm.lead"]
logger.debug("Successfully got CRM lead model")
# Create the lead
Lead = self.odoo.env["crm.lead"]
odoo_lead_id = Lead.create(lead_data)
logger.info(f"Successfully created lead in Odoo with ID: {odoo_lead_id}")
# Post message in chatter
message_data = {
"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>
<li><strong>Name:</strong> {lead.name}</li>
<li><strong>Company:</strong> {lead.company}</li>
@ -106,11 +116,11 @@ class OdooAPI:
<li><strong>Phone:</strong> {lead.phone}</li>
</ul>
<h3>Service Details:</h3>
<p>Service Details:<p>
<ul>
<li><strong>Service:</strong> {lead.service.name}</li>
<li><strong>Provider:</strong> {lead.service.cloud_provider.name}</li>
<li><strong>Plan:</strong> {lead.plan.name if lead.plan else 'Not specified'}</li>
{f'<li><strong>Provider:</strong> {provider_name}</li>' if provider_name else ''}
{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>
</ul>
""",
@ -118,16 +128,7 @@ class OdooAPI:
"subtype_xmlid": "mail.mt_comment",
}
# Get context for message posting
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
# Post the message
self.odoo.env["crm.lead"].browse(odoo_lead_id).message_post(
body=message_data["body"],
message_type=message_data["message_type"],
@ -135,7 +136,6 @@ class OdooAPI:
)
logger.info(f"Successfully posted message to lead {odoo_lead_id}")
return odoo_lead_id
except odoorpc.error.RPCError as e:

View file

@ -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;
}

View 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;
}

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View file

@ -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 %}

View file

@ -5,7 +5,7 @@
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<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" %}'>
<style>
.rich-text-content {
@ -26,7 +26,30 @@
<body>
<nav class="navbar navbar-expand-lg navbar-dark bg-dark">
<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>
</nav>
@ -35,6 +58,6 @@
{% endblock %}
</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>
</html>

View file

@ -9,16 +9,28 @@
<div class="mb-4">
<h5>Service Details</h5>
<p><strong>Provider:</strong> {{ service.cloud_provider.name }}</p>
<p><strong>Plan:</strong> {{ selected_plan.name }}</p>
{% if selected_offering %}
<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>
{% if messages %}
{% for message in messages %}
<div class="alert alert-{{ message.tags }}">
{{ message }}
</div>
{% endfor %}
{% for message in messages %}
<div class="alert alert-{{ message.tags }}">
{{ message }}
</div>
{% endfor %}
{% endif %}
<form method="post">
@ -28,9 +40,9 @@
<label for="{{ form.name.id_for_label }}" class="form-label">Name</label>
{{ form.name }}
{% if form.name.errors %}
<div class="invalid-feedback d-block">
{{ form.name.errors }}
</div>
<div class="invalid-feedback d-block">
{{ form.name.errors }}
</div>
{% endif %}
</div>
@ -38,9 +50,9 @@
<label for="{{ form.company.id_for_label }}" class="form-label">Company</label>
{{ form.company }}
{% if form.company.errors %}
<div class="invalid-feedback d-block">
{{ form.company.errors }}
</div>
<div class="invalid-feedback d-block">
{{ form.company.errors }}
</div>
{% endif %}
</div>
@ -48,9 +60,9 @@
<label for="{{ form.email.id_for_label }}" class="form-label">Email</label>
{{ form.email }}
{% if form.email.errors %}
<div class="invalid-feedback d-block">
{{ form.email.errors }}
</div>
<div class="invalid-feedback d-block">
{{ form.email.errors }}
</div>
{% endif %}
</div>
@ -58,9 +70,9 @@
<label for="{{ form.phone.id_for_label }}" class="form-label">Phone</label>
{{ form.phone }}
{% if form.phone.errors %}
<div class="invalid-feedback d-block">
{{ form.phone.errors }}
</div>
<div class="invalid-feedback d-block">
{{ form.phone.errors }}
</div>
{% endif %}
</div>

View 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 %}

View 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 %}>
&nbsp;&nbsp;&nbsp;{{ 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 %}

View 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 %}

View 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 %}

View file

@ -1,120 +1,132 @@
{% extends 'services/base.html' %}
{% block content %}
<div class="card">
<div class="card mb-4">
<div class="card-body">
<div class="d-flex align-items-start mb-4">
{% 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 %}
<div>
<h2 class="card-title">{{ service.name }}</h2>
<div class="d-flex align-items-center">
{% if service.cloud_provider.logo %}
<a href="{% url 'services:provider_detail' service.cloud_provider.slug %}">
<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>
<h2 class="card-title mb-3">{{ service.name }}</h2>
<div class="mb-3">
{% for category in service.categories.all %}
<span class="badge bg-secondary me-1">{{ category.full_path }}</span>
{% endfor %}
</div>
</div>
</div>
<a href="{% url 'services:create_lead' service.slug %}" class="btn btn-success">Order</a>
<a href="{% url 'services:service_list' %}" class="btn btn-secondary">Back to Services</a>
<div class="rich-text-content mb-4">
{{ service.description|safe }}
</div>
{% if service.features %}
<h3>Features</h3>
<div class="rich-text-content mb-4">
{{ service.features|safe }}
</div>
{% endif %}
</div>
</div>
{% if service.consulting_partners.exists %}
<div class="card mb-3">
<!-- Cloud Provider Offerings -->
<div class="card mb-4">
<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">
{% 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 %}
<div class="col">
<div class="d-flex align-items-center">
{% if partner.logo %}
<img src="{{ partner.logo.url }}" alt="{{ partner.name }} logo"
class="me-2" style="max-height: 40px; max-width: 80px; object-fit: contain;">
{% endif %}
<div>
<a href="{{ partner.get_absolute_url }}" class="text-decoration-none">
{{ partner.name }}
</a>
{% if partner.website %}
<br>
<a href="{{ partner.website }}" class="small text-muted" target="_blank">
Visit Website
</a>
{% endif %}
<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 }} logo"
class="me-3" style="max-height: 50px; max-width: 100px; object-fit: contain;">
{% endif %}
<div>
<h5 class="card-title mb-0">
<a href="{{ partner.get_absolute_url }}" class="text-decoration-none">
{{ partner.name }}
</a>
</h5>
{% if partner.website %}
<a href="{{ partner.website }}" class="text-muted small" target="_blank">
Visit Website
</a>
{% endif %}
</div>
</div>
</div>
</div>
</div>
@ -123,4 +135,22 @@
</div>
</div>
{% endif %}
{% endblock %}
<!-- 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 %}

View file

@ -72,18 +72,23 @@
{% endif %}
<div>
<h5 class="card-title mb-0">{{ service.name }}</h5>
<div class="d-flex align-items-center mt-2">
{% if service.cloud_provider.logo %}
<a href="{% url 'services:provider_detail' service.cloud_provider.slug %}">
<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 class="mt-2">
{% for offering in service.offerings.all %}
<div class="d-inline-block me-2">
{% if offering.cloud_provider.logo %}
<a href="{% url 'services:provider_detail' offering.cloud_provider.slug %}" title="{{ offering.cloud_provider.name }}">
<img src="{{ offering.cloud_provider.logo.url }}"
alt="{{ offering.cloud_provider.name }}"
style="max-height: 25px; max-width: 50px; object-fit: contain;">
</a>
{% else %}
<a href="{% url 'services:provider_detail' offering.cloud_provider.slug %}"
class="badge bg-secondary text-decoration-none">
{{ offering.cloud_provider.name }}
</a>
{% endif %}
</div>
{% endfor %}
</div>
</div>
</div>
@ -94,8 +99,25 @@
</div>
<p class="card-text">{{ service.description|safe|truncatewords:30 }}</p>
<a href="{{ service.get_absolute_url }}" class="btn btn-primary">View Details</a>
<a href="{% url 'services:create_lead' service.slug %}" class="btn btn-success">Show Interest</a>
<div class="mt-3">
<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>
@ -109,4 +131,4 @@
</div>
</div>
</div>
{% endblock %}
{% endblock %}

View file

@ -5,9 +5,13 @@ app_name = "services"
urlpatterns = [
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("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>/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"),
]

View file

@ -1,17 +1,17 @@
import logging
from django.conf import settings
from django.shortcuts import render, get_object_or_404, redirect
from django.contrib import messages
from django.db.models import Q
from django.db.models import Q, Prefetch
from .models import (
Service,
CloudProvider,
ConsultingPartner,
Category,
ServiceOffering,
Plan,
ConsultingPartner,
CloudProvider,
Category,
PlanPrice,
)
from .forms import LeadForm
from .odoo import OdooAPI
@ -19,50 +19,177 @@ logger = logging.getLogger(__name__)
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()
categories = Category.objects.filter(parent=None)
consulting_partners = ConsultingPartner.objects.all()
if request.GET.get("cloud_provider"):
services = services.filter(cloud_provider_id=request.GET.get("cloud_provider"))
categories = Category.objects.filter(parent=None).prefetch_related("children")
# Handle category filter
if 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)
services = services.filter(
Q(categories=category) | Q(categories__in=subcategories)
).distinct()
if request.GET.get("consulting_partner"):
services = services.filter(
consulting_partners__id=request.GET.get("consulting_partner")
)
# Handle cloud provider filter
if request.GET.get("cloud_provider"):
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"):
query = request.GET.get("search")
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 = {
"services": services,
"cloud_providers": cloud_providers,
"categories": categories,
"consulting_partners": consulting_partners,
"consulting_partners": ConsultingPartner.objects.all(),
}
return render(request, "services/service_list.html", context)
def service_detail(request, slug):
service = get_object_or_404(Service, slug=slug)
return render(request, "services/service_detail.html", {"service": service})
service = get_object_or_404(
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):
provider = get_object_or_404(CloudProvider, slug=slug)
services = Service.objects.filter(cloud_provider=provider)
def offering_list(request):
offerings = (
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 = {
"provider": provider,
"services": services,
@ -70,37 +197,94 @@ def provider_detail(request, slug):
return render(request, "services/provider_detail.html", context)
def partner_detail(request, slug):
partner = get_object_or_404(ConsultingPartner, slug=slug)
services = Service.objects.filter(consulting_partners=partner)
return render(
request,
"services/partner_detail.html",
{"partner": partner, "services": services},
def partner_list(request):
partners = ConsultingPartner.objects.all().prefetch_related(
"services", "cloud_providers"
)
# 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):
service = get_object_or_404(Service, slug=slug)
return render(request, "services/thank_you.html", {"service": service})
# Handle search
if request.GET.get("search"):
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):
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
if request.GET.get("plan"):
selected_plan = get_object_or_404(
Plan, id=request.GET.get("plan"), service=service
# Get the offering if specified
if request.GET.get("offering"):
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":
form = LeadForm(request.POST)
if form.is_valid():
lead = form.save(commit=False)
lead.service = service
lead.offering = selected_offering
lead.plan = selected_plan
try:
@ -124,8 +308,15 @@ def create_lead(request, slug):
else:
form = LeadForm()
return render(
request,
"services/lead_form.html",
{"form": form, "service": service, "selected_plan": selected_plan},
)
context = {
"form": form,
"service": service,
"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})

View file

View file

@ -7,6 +7,7 @@ requires-python = ">=3.13"
dependencies = [
"django>=5.1.5",
"django-prose-editor[sanitize]>=0.10.3",
"django-schema-viewer>=0.5.2",
"djangorestframework>=3.15.2",
"environs[django]~=14.0",
"odoorpc>=0.10.1",

14
uv.lock generated
View file

@ -98,6 +98,18 @@ sanitize = [
{ 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]]
name = "djangorestframework"
version = "3.15.2"
@ -234,6 +246,7 @@ source = { virtual = "." }
dependencies = [
{ name = "django" },
{ name = "django-prose-editor", extra = ["sanitize"] },
{ name = "django-schema-viewer" },
{ name = "djangorestframework" },
{ name = "environs", extra = ["django"] },
{ name = "odoorpc" },
@ -250,6 +263,7 @@ requires-dist = [
{ name = "django", specifier = ">=5.1.5" },
{ name = "django-browser-reload", marker = "extra == 'dev'", specifier = "~=1.13" },
{ 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 = "environs", extras = ["django"], specifier = "~=14.0" },
{ name = "odoorpc", specifier = ">=0.10.1" },