complete rework of offerings
This commit is contained in:
parent
84e25c82d1
commit
20f27bd6b5
16 changed files with 313 additions and 294 deletions
|
@ -30,9 +30,6 @@ class ServiceBrokerViewSet(viewsets.ViewSet):
|
||||||
broker_user = get_object_or_404(ServiceBrokerUser, user=request.user)
|
broker_user = get_object_or_404(ServiceBrokerUser, user=request.user)
|
||||||
offerings = broker_user.allowed_offerings.prefetch_related(
|
offerings = broker_user.allowed_offerings.prefetch_related(
|
||||||
"plans",
|
"plans",
|
||||||
"plans__prices",
|
|
||||||
"plans__prices__currency",
|
|
||||||
"plans__prices__term",
|
|
||||||
"service",
|
"service",
|
||||||
"cloud_provider",
|
"cloud_provider",
|
||||||
).all()
|
).all()
|
||||||
|
|
|
@ -4,17 +4,23 @@ from .models import (
|
||||||
Category,
|
Category,
|
||||||
CloudProvider,
|
CloudProvider,
|
||||||
ConsultingPartner,
|
ConsultingPartner,
|
||||||
Currency,
|
|
||||||
ExternalLink,
|
ExternalLink,
|
||||||
|
ExternalLinkOffering,
|
||||||
|
Lead,
|
||||||
Plan,
|
Plan,
|
||||||
PlanPrice,
|
ReusableText,
|
||||||
Service,
|
Service,
|
||||||
ServiceOffering,
|
ServiceOffering,
|
||||||
Term,
|
|
||||||
Lead,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@admin.register(ReusableText)
|
||||||
|
class ReusableTextAdmin(admin.ModelAdmin):
|
||||||
|
list_display = ("name",)
|
||||||
|
search_fields = ("name", "text")
|
||||||
|
ordering = ("name",)
|
||||||
|
|
||||||
|
|
||||||
@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")
|
||||||
|
@ -24,20 +30,6 @@ class CategoryAdmin(admin.ModelAdmin):
|
||||||
ordering = ("order", "name")
|
ordering = ("order", "name")
|
||||||
|
|
||||||
|
|
||||||
@admin.register(Currency)
|
|
||||||
class CurrencyAdmin(admin.ModelAdmin):
|
|
||||||
list_display = ("code", "name", "symbol")
|
|
||||||
search_fields = ("code", "name")
|
|
||||||
ordering = ("code",)
|
|
||||||
|
|
||||||
|
|
||||||
@admin.register(Term)
|
|
||||||
class TermAdmin(admin.ModelAdmin):
|
|
||||||
list_display = ("name", "order")
|
|
||||||
ordering = ("order", "name")
|
|
||||||
search_fields = ("name", "description")
|
|
||||||
|
|
||||||
|
|
||||||
@admin.register(CloudProvider)
|
@admin.register(CloudProvider)
|
||||||
class CloudProviderAdmin(admin.ModelAdmin):
|
class CloudProviderAdmin(admin.ModelAdmin):
|
||||||
list_display = ("name", "slug", "logo_preview")
|
list_display = ("name", "slug", "logo_preview")
|
||||||
|
@ -90,66 +82,25 @@ class ServiceAdmin(admin.ModelAdmin):
|
||||||
partner_list.short_description = "Consulting Partners"
|
partner_list.short_description = "Consulting Partners"
|
||||||
|
|
||||||
|
|
||||||
@admin.register(PlanPrice)
|
|
||||||
class PlanPriceAdmin(admin.ModelAdmin):
|
|
||||||
model = PlanPrice
|
|
||||||
|
|
||||||
|
|
||||||
class PlanInline(admin.StackedInline):
|
class PlanInline(admin.StackedInline):
|
||||||
model = Plan
|
model = Plan
|
||||||
extra = 1
|
extra = 1
|
||||||
fieldsets = (
|
fieldsets = ((None, {"fields": ("name", "description", "plan_description")}),)
|
||||||
(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">']
|
class ExternalLinkOfferingInline(admin.TabularInline):
|
||||||
html.append(
|
model = ExternalLinkOffering
|
||||||
"<tr><th>Currency</th><th>Term</th><th>Price</th><th>Actions</th></tr>"
|
extra = 1
|
||||||
)
|
fields = ("description", "url", "order")
|
||||||
|
ordering = ("order", "description")
|
||||||
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)
|
@admin.register(ServiceOffering)
|
||||||
class ServiceOfferingAdmin(admin.ModelAdmin):
|
class ServiceOfferingAdmin(admin.ModelAdmin):
|
||||||
list_display = ("service", "cloud_provider", "status")
|
list_display = ("service", "slug", "cloud_provider")
|
||||||
list_filter = ("service", "cloud_provider")
|
list_filter = ("service", "cloud_provider")
|
||||||
search_fields = ("service__name", "cloud_provider__name", "description")
|
search_fields = ("service__name", "cloud_provider__name", "description")
|
||||||
inlines = [PlanInline]
|
inlines = [ExternalLinkOfferingInline, PlanInline]
|
||||||
|
|
||||||
class Media:
|
|
||||||
css = {"all": ("admin/css/service_offering.css",)}
|
|
||||||
|
|
||||||
|
|
||||||
@admin.register(ConsultingPartner)
|
@admin.register(ConsultingPartner)
|
||||||
|
|
|
@ -1,5 +1,5 @@
|
||||||
from django import forms
|
from django import forms
|
||||||
from .models import Lead, Plan, PlanPrice
|
from .models import Lead, Plan
|
||||||
|
|
||||||
|
|
||||||
class LeadForm(forms.ModelForm):
|
class LeadForm(forms.ModelForm):
|
||||||
|
@ -18,33 +18,8 @@ class LeadForm(forms.ModelForm):
|
||||||
class PlanForm(forms.ModelForm):
|
class PlanForm(forms.ModelForm):
|
||||||
class Meta:
|
class Meta:
|
||||||
model = Plan
|
model = Plan
|
||||||
fields = ("name", "description", "is_default", "features", "order")
|
fields = ("name", "description")
|
||||||
widgets = {
|
widgets = {
|
||||||
"description": forms.Textarea(attrs={"rows": 3}),
|
"description": forms.Textarea(attrs={"rows": 3}),
|
||||||
"features": forms.Textarea(attrs={"rows": 4}),
|
"features": forms.Textarea(attrs={"rows": 4}),
|
||||||
}
|
}
|
||||||
|
|
||||||
def clean(self):
|
|
||||||
cleaned_data = super().clean()
|
|
||||||
# If this is set as default, ensure no other plan for this service is default
|
|
||||||
if cleaned_data.get("is_default"):
|
|
||||||
service = self.instance.service
|
|
||||||
if service:
|
|
||||||
Plan.objects.filter(service=service).exclude(
|
|
||||||
pk=self.instance.pk
|
|
||||||
).update(is_default=False)
|
|
||||||
return cleaned_data
|
|
||||||
|
|
||||||
|
|
||||||
class PlanPriceForm(forms.ModelForm):
|
|
||||||
class Meta:
|
|
||||||
model = PlanPrice
|
|
||||||
fields = ("currency", "price")
|
|
||||||
|
|
||||||
def clean(self):
|
|
||||||
cleaned_data = super().clean()
|
|
||||||
currency = cleaned_data.get("currency")
|
|
||||||
price = cleaned_data.get("price")
|
|
||||||
if price and price < 0:
|
|
||||||
raise forms.ValidationError("Price cannot be negative")
|
|
||||||
return cleaned_data
|
|
||||||
|
|
|
@ -0,0 +1,142 @@
|
||||||
|
# Generated by Django 5.1.5 on 2025-02-28 10:46
|
||||||
|
|
||||||
|
import django.db.models.deletion
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
("services", "0009_consultingpartner_address_consultingpartner_email_and_more"),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
# 1. First create new models
|
||||||
|
migrations.CreateModel(
|
||||||
|
name="ReusableText",
|
||||||
|
fields=[
|
||||||
|
(
|
||||||
|
"id",
|
||||||
|
models.BigAutoField(
|
||||||
|
auto_created=True,
|
||||||
|
primary_key=True,
|
||||||
|
serialize=False,
|
||||||
|
verbose_name="ID",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
("name", models.CharField(max_length=100)),
|
||||||
|
("text", models.TextField()),
|
||||||
|
],
|
||||||
|
options={
|
||||||
|
"ordering": ["name"],
|
||||||
|
},
|
||||||
|
),
|
||||||
|
# 2. Modify models that will be kept
|
||||||
|
migrations.AlterModelOptions(
|
||||||
|
name="plan",
|
||||||
|
options={"ordering": ["name"]},
|
||||||
|
),
|
||||||
|
migrations.RemoveField(
|
||||||
|
model_name="plan",
|
||||||
|
name="features",
|
||||||
|
),
|
||||||
|
migrations.RemoveField(
|
||||||
|
model_name="plan",
|
||||||
|
name="is_default",
|
||||||
|
),
|
||||||
|
migrations.RemoveField(
|
||||||
|
model_name="plan",
|
||||||
|
name="order",
|
||||||
|
),
|
||||||
|
migrations.RemoveField(
|
||||||
|
model_name="serviceoffering",
|
||||||
|
name="status",
|
||||||
|
),
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name="serviceoffering",
|
||||||
|
name="description",
|
||||||
|
field=models.TextField(blank=True, null=True),
|
||||||
|
),
|
||||||
|
# 3. Add new models and relationships
|
||||||
|
migrations.CreateModel(
|
||||||
|
name="ExternalLinkOffering",
|
||||||
|
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)),
|
||||||
|
(
|
||||||
|
"offering",
|
||||||
|
models.ForeignKey(
|
||||||
|
on_delete=django.db.models.deletion.CASCADE,
|
||||||
|
related_name="external_links",
|
||||||
|
to="services.serviceoffering",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
options={
|
||||||
|
"verbose_name": "External Link",
|
||||||
|
"verbose_name_plural": "External Links",
|
||||||
|
"ordering": ["order", "description"],
|
||||||
|
},
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name="plan",
|
||||||
|
name="plan_description",
|
||||||
|
field=models.ForeignKey(
|
||||||
|
blank=True,
|
||||||
|
null=True,
|
||||||
|
on_delete=django.db.models.deletion.PROTECT,
|
||||||
|
related_name="plan_descriptions",
|
||||||
|
to="services.reusabletext",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name="serviceoffering",
|
||||||
|
name="offer_description",
|
||||||
|
field=models.ForeignKey(
|
||||||
|
blank=True,
|
||||||
|
null=True,
|
||||||
|
on_delete=django.db.models.deletion.PROTECT,
|
||||||
|
related_name="offer_descriptions",
|
||||||
|
to="services.reusabletext",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
# 4. Handle PlanPrice modifications and deletion
|
||||||
|
migrations.AlterUniqueTogether(
|
||||||
|
name="planprice",
|
||||||
|
unique_together=None,
|
||||||
|
),
|
||||||
|
migrations.RemoveField(
|
||||||
|
model_name="planprice",
|
||||||
|
name="plan",
|
||||||
|
),
|
||||||
|
migrations.RemoveField(
|
||||||
|
model_name="planprice",
|
||||||
|
name="term",
|
||||||
|
),
|
||||||
|
migrations.RemoveField(
|
||||||
|
model_name="planprice",
|
||||||
|
name="currency",
|
||||||
|
),
|
||||||
|
# 5. Delete models in the right order (dependent models first)
|
||||||
|
migrations.DeleteModel(
|
||||||
|
name="PlanPrice",
|
||||||
|
),
|
||||||
|
migrations.DeleteModel(
|
||||||
|
name="Currency",
|
||||||
|
),
|
||||||
|
migrations.DeleteModel(
|
||||||
|
name="Term",
|
||||||
|
),
|
||||||
|
]
|
25
hub/services/migrations/0011_reusabletext_textsnippet.py
Normal file
25
hub/services/migrations/0011_reusabletext_textsnippet.py
Normal file
|
@ -0,0 +1,25 @@
|
||||||
|
# Generated by Django 5.1.5 on 2025-02-28 13:07
|
||||||
|
|
||||||
|
import django.db.models.deletion
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
("services", "0010_reusabletext_remove_planprice_currency_and_more"),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AddField(
|
||||||
|
model_name="reusabletext",
|
||||||
|
name="textsnippet",
|
||||||
|
field=models.ForeignKey(
|
||||||
|
blank=True,
|
||||||
|
null=True,
|
||||||
|
on_delete=django.db.models.deletion.SET_NULL,
|
||||||
|
related_name="children",
|
||||||
|
to="services.reusabletext",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
]
|
|
@ -11,6 +11,44 @@ def validate_image_size(value):
|
||||||
raise ValidationError("Maximum file size is 1MB")
|
raise ValidationError("Maximum file size is 1MB")
|
||||||
|
|
||||||
|
|
||||||
|
class ReusableText(models.Model):
|
||||||
|
name = models.CharField(max_length=100)
|
||||||
|
textsnippet = models.ForeignKey(
|
||||||
|
"self",
|
||||||
|
on_delete=models.SET_NULL,
|
||||||
|
null=True,
|
||||||
|
blank=True,
|
||||||
|
related_name="children",
|
||||||
|
)
|
||||||
|
text = ProseEditorField()
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
ordering = ["name"]
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
return self.name
|
||||||
|
|
||||||
|
def get_full_text(self):
|
||||||
|
"""Returns the text with all nested textsnippet content recursively included in reverse order"""
|
||||||
|
text_parts = []
|
||||||
|
|
||||||
|
# Recursively collect all text parts
|
||||||
|
def collect_text(snippet):
|
||||||
|
if snippet is None:
|
||||||
|
return
|
||||||
|
collect_text(snippet.textsnippet) # Collect deepest snippets first
|
||||||
|
text_parts.append(snippet.text)
|
||||||
|
|
||||||
|
# Start collection with the deepest snippets
|
||||||
|
collect_text(self.textsnippet)
|
||||||
|
|
||||||
|
# Add the main text at the end
|
||||||
|
text_parts.append(self.text)
|
||||||
|
|
||||||
|
# Join all text parts
|
||||||
|
return "".join(text_parts)
|
||||||
|
|
||||||
|
|
||||||
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)
|
||||||
|
@ -69,31 +107,6 @@ 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):
|
class Service(models.Model):
|
||||||
name = models.CharField(max_length=200)
|
name = models.CharField(max_length=200)
|
||||||
slug = models.SlugField(max_length=250, unique=True)
|
slug = models.SlugField(max_length=250, unique=True)
|
||||||
|
@ -182,19 +195,18 @@ class ServiceOffering(models.Model):
|
||||||
CloudProvider, on_delete=models.CASCADE, related_name="offerings"
|
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(help_text="Provider-specific details and features")
|
description = ProseEditorField(blank=True, null=True)
|
||||||
|
offer_description = models.ForeignKey(
|
||||||
|
ReusableText,
|
||||||
|
on_delete=models.PROTECT,
|
||||||
|
related_name="offer_descriptions",
|
||||||
|
blank=True,
|
||||||
|
null=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)
|
||||||
|
|
||||||
STATUS_CHOICES = [
|
|
||||||
("available", "Available"),
|
|
||||||
("planned", "Planned"),
|
|
||||||
("on_request", "On Request"),
|
|
||||||
]
|
|
||||||
status = models.CharField(
|
|
||||||
max_length=20, choices=STATUS_CHOICES, default="available"
|
|
||||||
)
|
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
unique_together = ["service", "cloud_provider"]
|
unique_together = ["service", "cloud_provider"]
|
||||||
ordering = ["service__name", "cloud_provider__name"]
|
ordering = ["service__name", "cloud_provider__name"]
|
||||||
|
@ -219,40 +231,54 @@ class ServiceOffering(models.Model):
|
||||||
class Plan(models.Model):
|
class Plan(models.Model):
|
||||||
name = models.CharField(max_length=100)
|
name = models.CharField(max_length=100)
|
||||||
description = ProseEditorField()
|
description = ProseEditorField()
|
||||||
|
plan_description = models.ForeignKey(
|
||||||
|
ReusableText,
|
||||||
|
on_delete=models.PROTECT,
|
||||||
|
related_name="plan_descriptions",
|
||||||
|
blank=True,
|
||||||
|
null=True,
|
||||||
|
)
|
||||||
offering = models.ForeignKey(
|
offering = models.ForeignKey(
|
||||||
ServiceOffering, on_delete=models.CASCADE, related_name="plans"
|
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)
|
created_at = models.DateTimeField(auto_now_add=True)
|
||||||
updated_at = models.DateTimeField(auto_now=True)
|
updated_at = models.DateTimeField(auto_now=True)
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
ordering = ["order", "name"]
|
ordering = ["name"]
|
||||||
unique_together = [["offering", "name"]]
|
unique_together = [["offering", "name"]]
|
||||||
|
|
||||||
def __str__(self):
|
def __str__(self):
|
||||||
return f"{self.offering} - {self.name}"
|
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 ExternalLinkOffering(models.Model):
|
||||||
class PlanPrice(models.Model):
|
offering = models.ForeignKey(
|
||||||
plan = models.ForeignKey(Plan, on_delete=models.CASCADE, related_name="prices")
|
ServiceOffering, on_delete=models.CASCADE, related_name="external_links"
|
||||||
currency = models.ForeignKey(Currency, on_delete=models.PROTECT)
|
)
|
||||||
term = models.ForeignKey(Term, on_delete=models.PROTECT)
|
url = models.URLField()
|
||||||
price = models.DecimalField(max_digits=10, decimal_places=2)
|
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)
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
unique_together = [["plan", "currency", "term"]]
|
ordering = ["order", "description"]
|
||||||
|
verbose_name = "External Link"
|
||||||
|
verbose_name_plural = "External Links"
|
||||||
|
|
||||||
def __str__(self):
|
def __str__(self):
|
||||||
return f"{self.plan.name} - {self.currency.code} {self.price}"
|
return f"{self.description} ({self.url})"
|
||||||
|
|
||||||
|
def clean(self):
|
||||||
|
from django.core.validators import URLValidator
|
||||||
|
|
||||||
|
validate = URLValidator()
|
||||||
|
try:
|
||||||
|
validate(self.url)
|
||||||
|
except ValidationError:
|
||||||
|
raise ValidationError({"url": "Enter a valid URL."})
|
||||||
|
|
||||||
|
|
||||||
class ExternalLink(models.Model):
|
class ExternalLink(models.Model):
|
||||||
|
|
|
@ -1,29 +0,0 @@
|
||||||
.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;
|
|
||||||
}
|
|
|
@ -33,7 +33,6 @@
|
||||||
<ul class="navbar__menu menu mr-lg-27">
|
<ul class="navbar__menu menu mr-lg-27">
|
||||||
<li class="menu__item"><a class="menu__item-link" href="{% url 'services:homepage' %}">Home</a></li>
|
<li class="menu__item"><a class="menu__item-link" href="{% url 'services:homepage' %}">Home</a></li>
|
||||||
<li class="menu__item"><a class="menu__item-link" href="{% url 'services:service_list' %}">Services</a></li>
|
<li class="menu__item"><a class="menu__item-link" href="{% url 'services:service_list' %}">Services</a></li>
|
||||||
<li class="menu__item"><a class="menu__item-link" href="{% url 'services:offering_list' %}">Offerings</a></li>
|
|
||||||
<li class="menu__item"><a class="menu__item-link" href="{% url 'services:provider_list' %}">Cloud Providers</a></li>
|
<li class="menu__item"><a class="menu__item-link" href="{% url 'services:provider_list' %}">Cloud Providers</a></li>
|
||||||
<li class="menu__item"><a class="menu__item-link" href="{% url 'services:partner_list' %}">Consulting Partners</a></li>
|
<li class="menu__item"><a class="menu__item-link" href="{% url 'services:partner_list' %}">Consulting Partners</a></li>
|
||||||
</ul>
|
</ul>
|
||||||
|
@ -125,7 +124,6 @@
|
||||||
<ul class="list-unstyled space-y-20 fs-15 fw-medium ps-0">
|
<ul class="list-unstyled space-y-20 fs-15 fw-medium ps-0">
|
||||||
<li><a href="{% url 'services:homepage' %}">Home</a></li>
|
<li><a href="{% url 'services:homepage' %}">Home</a></li>
|
||||||
<li><a href="{% url 'services:service_list' %}">Services</a></li>
|
<li><a href="{% url 'services:service_list' %}">Services</a></li>
|
||||||
<li><a href="{% url 'services:offering_list' %}">Offerings</a></li>
|
|
||||||
<li><a href="{% url 'services:provider_list' %}">Cloud Providers</a></li>
|
<li><a href="{% url 'services:provider_list' %}">Cloud Providers</a></li>
|
||||||
<li><a href="{% url 'services:partner_list' %}">Consulting Partners</a></li>
|
<li><a href="{% url 'services:partner_list' %}">Consulting Partners</a></li>
|
||||||
</ul>
|
</ul>
|
||||||
|
|
|
@ -91,15 +91,6 @@
|
||||||
<div class="mb-24">
|
<div class="mb-24">
|
||||||
<div class="bg-white border-all rounded-7 p-20">
|
<div class="bg-white border-all rounded-7 p-20">
|
||||||
<h3 class="text-purple fs-18 fw-semibold lh-1-7 mb-0">{{ selected_plan.name }}</h3>
|
<h3 class="text-purple fs-18 fw-semibold lh-1-7 mb-0">{{ selected_plan.name }}</h3>
|
||||||
{% if selected_plan.prices.exists %}
|
|
||||||
<div>
|
|
||||||
<ul class="list-unstyled text-gray-500 fs-14 lh-1-7 ps-0 mb-0">
|
|
||||||
{% for price in selected_plan.prices.all %}
|
|
||||||
<li>{{ price.currency.symbol }}{{ price.price }} {{ price.currency.code }} per {{ price.term.name }}</li>
|
|
||||||
{% endfor %}
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
{% endif %}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
|
@ -12,25 +12,28 @@
|
||||||
<!-- Logo -->
|
<!-- Logo -->
|
||||||
<div class="mb-40 border rounded-4 p-4 d-flex align-items-center justify-content-center" style="min-height: 160px;">
|
<div class="mb-40 border rounded-4 p-4 d-flex align-items-center justify-content-center" style="min-height: 160px;">
|
||||||
{% if offering.service.logo %}
|
{% if offering.service.logo %}
|
||||||
|
<a href="{{ offering.service.get_absolute_url }}">
|
||||||
<img class="img-fluid w-100 w-lg-auto" src="{{ offering.service.logo.url }}"
|
<img class="img-fluid w-100 w-lg-auto" src="{{ offering.service.logo.url }}"
|
||||||
alt="{{ offering.service.name }} logo" style="max-height: 120px; object-fit: contain;">
|
alt="{{ offering.service.name }} logo" style="max-height: 120px; object-fit: contain;">
|
||||||
|
</a>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Highlights -->
|
<!-- External Links for Offering -->
|
||||||
{% if offering.highlights.exists %}
|
{% if offering.external_links.exists %}
|
||||||
<div class="mb-40">
|
<div class="mb-40">
|
||||||
<h3 class="fw-semibold mb-12">Highlights</h3>
|
<h3 class="fw-semibold mb-12">External Links</h3>
|
||||||
<ul class="list-unstyled space-y-12 fs-19 ps-0">
|
<ul class="list-unstyled space-y-12 fs-19 ps-0">
|
||||||
{% for highlight in offering.highlights.all %}
|
{% for link in offering.external_links.all %}
|
||||||
<li>
|
<li>
|
||||||
<a class="d-flex align-items-center text-gray-500 h-32 lh-32" href="">
|
<a class="d-flex align-items-center text-gray-500 h-32 lh-32" href="{{ link.url }}" target="_blank">
|
||||||
<span class="pr-10">
|
<span class="pr-10">
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" class="bi bi-star-fill" viewBox="0 0 16 16">
|
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" class="bi bi-box-arrow-up-right" viewBox="0 0 16 16">
|
||||||
<path d="M3.612 15.443c-.386.198-.824-.149-.746-.592l.83-4.73L.173 6.765c-.329-.314-.158-.888.283-.95l4.898-.696L7.538.792c.197-.39.73-.39.927 0l2.184 4.327 4.898.696c.441.062.612.636.282.95l-3.522 3.356.83 4.73c.078.443-.36.79-.746.592L8 13.187l-4.389 2.256z" fill="#9A63EC"/>
|
<path fill-rule="evenodd" d="M8.636 3.5a.5.5 0 0 0-.5-.5H1.5A1.5 1.5 0 0 0 0 4.5v10A1.5 1.5 0 0 0 1.5 16h10a1.5 1.5 0 0 0 1.5-1.5V7.864a.5.5 0 0 0-1 0V14.5a.5.5 0 0 1-.5.5h-10a.5.5 0 0 1-.5-.5v-10a.5.5 0 0 1 .5-.5h6.636a.5.5 0 0 0 .5-.5" fill="#9A63EC"/>
|
||||||
|
<path fill-rule="evenodd" d="M16 .5a.5.5 0 0 0-.5-.5h-5a.5.5 0 0 0 0 1h3.793L6.146 9.146a.5.5 0 1 0 .708.708L15 1.707V5.5a.5.5 0 0 0 1 0z" fill="#9A63EC"/>
|
||||||
</svg>
|
</svg>
|
||||||
</span>
|
</span>
|
||||||
<span>{{ highlight.name }}</span>
|
<span>{{ link.description }}</span>
|
||||||
</a>
|
</a>
|
||||||
</li>
|
</li>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
|
@ -58,28 +61,6 @@
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
<!-- External Links -->
|
|
||||||
{% if offering.service.external_links.exists %}
|
|
||||||
<div class="mb-40">
|
|
||||||
<h3 class="fw-semibold mb-12">External Links</h3>
|
|
||||||
<ul class="list-unstyled space-y-12 fs-19 ps-0">
|
|
||||||
{% for link in offering.service.external_links.all %}
|
|
||||||
<li>
|
|
||||||
<a class="d-flex align-items-center text-gray-500 h-32 lh-32" href="{{ link.url }}" target="_blank">
|
|
||||||
<span class="pr-10">
|
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" class="bi bi-box-arrow-up-right" viewBox="0 0 16 16">
|
|
||||||
<path fill-rule="evenodd" d="M8.636 3.5a.5.5 0 0 0-.5-.5H1.5A1.5 1.5 0 0 0 0 4.5v10A1.5 1.5 0 0 0 1.5 16h10a1.5 1.5 0 0 0 1.5-1.5V7.864a.5.5 0 0 0-1 0V14.5a.5.5 0 0 1-.5.5h-10a.5.5 0 0 1-.5-.5v-10a.5.5 0 0 1 .5-.5h6.636a.5.5 0 0 0 .5-.5" fill="#9A63EC"/>
|
|
||||||
<path fill-rule="evenodd" d="M16 .5a.5.5 0 0 0-.5-.5h-5a.5.5 0 0 0 0 1h3.793L6.146 9.146a.5.5 0 1 0 .708.708L15 1.707V5.5a.5.5 0 0 0 1 0z" fill="#9A63EC"/>
|
|
||||||
</svg>
|
|
||||||
</span>
|
|
||||||
<span>{{ link.description }}</span>
|
|
||||||
</a>
|
|
||||||
</li>
|
|
||||||
{% endfor %}
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
{% endif %}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
@ -90,12 +71,12 @@
|
||||||
<div class="pt-60 pb-lg-60 w-lg-70">
|
<div class="pt-60 pb-lg-60 w-lg-70">
|
||||||
<header>
|
<header>
|
||||||
{% if offering.cloud_provider.logo %}
|
{% if offering.cloud_provider.logo %}
|
||||||
<p class="mb-6"><a href="{{ offering.cloud_provider.website }}" target="_blank">
|
<p class="mb-6"><a href="{{ offering.cloud_provider.get_absolute_url }}">
|
||||||
<img class="img-fluid" src="{{ offering.cloud_provider.logo.url }}"
|
<img class="img-fluid" src="{{ offering.cloud_provider.logo.url }}"
|
||||||
alt="{{ offering.cloud_provider.name }} logo" style="max-height: 40px;"></a>
|
alt="{{ offering.cloud_provider.name }} logo" style="max-height: 40px;"></a>
|
||||||
</p>
|
</p>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
<h2 class="fs-50 fw-semibold lh-1 mb-12">{{ offering.service.name }}</h2>
|
<h2 class="fs-50 fw-semibold lh-1 mb-12"><a href="{{ offering.service.get_absolute_url }}" class="text-decoration-none">{{ offering.service.name }}</a></h2>
|
||||||
</header>
|
</header>
|
||||||
<div class="fs-19 text-gray-500">
|
<div class="fs-19 text-gray-500">
|
||||||
{% for category in offering.service.categories.all %}
|
{% for category in offering.service.categories.all %}
|
||||||
|
@ -107,12 +88,29 @@
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Description -->
|
<!-- Service Description -->
|
||||||
|
{% if offering.service.description %}
|
||||||
|
<div>
|
||||||
|
<h3 class="fs-24 fw-semibold lh-1 mb-12">Service Overview</h3>
|
||||||
|
<div class="fs-19 text-gray-500">
|
||||||
|
{{ offering.service.description|safe }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<!-- Offering Description -->
|
||||||
<div class="pt-40 pt-lg-34">
|
<div class="pt-40 pt-lg-34">
|
||||||
<h3 class="fs-24 fw-semibold lh-1 mb-12">Overview</h3>
|
<h3 class="fs-24 fw-semibold lh-1 mb-12">Offering</h3>
|
||||||
|
{% if offering.description %}
|
||||||
<div class="fs-19 text-gray-500">
|
<div class="fs-19 text-gray-500">
|
||||||
{{ offering.description|safe }}
|
{{ offering.description|safe }}
|
||||||
</div>
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
{% if offering.offer_description %}
|
||||||
|
<div class="fs-19 text-gray-500">
|
||||||
|
{{ offering.offer_description.get_full_text|safe }}
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Plans -->
|
<!-- Plans -->
|
||||||
|
@ -122,18 +120,18 @@
|
||||||
{% for plan in offering.plans.all %}
|
{% for plan in offering.plans.all %}
|
||||||
<div class="col-12 col-lg-6 {% if not forloop.last %}mb-20 mb-lg-0{% endif %}">
|
<div class="col-12 col-lg-6 {% if not forloop.last %}mb-20 mb-lg-0{% endif %}">
|
||||||
<div class="bg-purple-50 rounded-16 border-all p-24">
|
<div class="bg-purple-50 rounded-16 border-all p-24">
|
||||||
<div class="text-black mb-20">
|
|
||||||
<p class="mb-0">{{ plan.description|safe }}</p>
|
|
||||||
</div>
|
|
||||||
<div class="bg-white border-all rounded-7 p-20 mb-20">
|
<div class="bg-white border-all rounded-7 p-20 mb-20">
|
||||||
<h3 class="text-purple fs-22 fw-semibold lh-1-7 mb-0">{{ plan.name }}</h3>
|
<h3 class="text-purple fs-22 fw-semibold lh-1-7 mb-0">{{ plan.name }}</h3>
|
||||||
<div>
|
{% if plan.plan_description %}
|
||||||
<ul class="list-unstyled text-gray-500 fs-19 lh-1-7 ps-0 mb-0">
|
<div class="text-black mb-20">
|
||||||
{% for price in plan.prices.all %}
|
{{ plan.plan_description.text|safe }}
|
||||||
<li>{{ price.currency.symbol }}{{ price.price }} {{ price.currency.code }} per {{ price.term.name }}</li>
|
|
||||||
{% endfor %}
|
|
||||||
</ul>
|
|
||||||
</div>
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
{% if plan.description %}
|
||||||
|
<div class="text-black mb-20">
|
||||||
|
{{ plan.description|safe }}
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<a class="btn btn-primary btn-lg w-100"
|
<a class="btn btn-primary btn-lg w-100"
|
||||||
|
|
|
@ -184,10 +184,6 @@
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="card__desc flex-grow-1 rich-text-content">
|
|
||||||
{{ offering.description|safe|truncatewords_html:30 }}
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
@ -30,54 +30,15 @@
|
||||||
</svg>
|
</svg>
|
||||||
</div>
|
</div>
|
||||||
<header class="mb-20">
|
<header class="mb-20">
|
||||||
<h2 class="fs-32 fs-lg-40 fw-semibold mb-20">Inquiry Received Successfully!</h2>
|
<h2 class="fs-32 fs-lg-40 fw-semibold mb-20">Inquiry received successfully!</h2>
|
||||||
<div class="fs-base text-gray-600 w-lg-75 mx-auto">
|
<div class="fs-base text-gray-600 w-lg-75 mx-auto">
|
||||||
<p class="mb-0">Thank you for your interest in {{ service.name }}! We have received your inquiry and our team will contact you shortly. A confirmation email will be sent to your provided email address.</p>
|
<p class="mb-0">Thank you for your interest in {{ service.name }}. We have received your inquiry and our team will contact you shortly. A confirmation email will be sent to your provided email address.</p>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
<div>
|
|
||||||
<a href="{% url 'services:service_list' %}" class="btn btn-primary btn-lg w-100 w-md-auto" role="button">Browse More Services</a>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="w-lg-34 bg-purple-50 rounded-16 p-24 d-flex flex-column justify-content-between">
|
|
||||||
<div class="d-flex align-items-center mb-24">
|
|
||||||
<div class="card__image mb-0">
|
|
||||||
{% if service.image %}
|
|
||||||
<img class="img-fluid" src="{{ service.image.url }}" alt="{{ service.name }}">
|
|
||||||
{% endif %}
|
|
||||||
</div>
|
|
||||||
<div class="card__header ps-16">
|
|
||||||
<h3 class="card__title">{{ service.name }}</h3>
|
|
||||||
<p class="card__subtitle mb-0">{{ service.category }}</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{% if service.description %}
|
|
||||||
<div class="mb-24">
|
|
||||||
<div class="text-black">
|
|
||||||
<p class="mb-0">{{ service.description|safe }}</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{% endif %}
|
|
||||||
{% if service.pricing_plans %}
|
|
||||||
<div>
|
|
||||||
{% for plan in service.pricing_plans %}
|
|
||||||
<div class="bg-white border-all rounded-7 p-20 {% if not forloop.last %}mb-12{% endif %}">
|
|
||||||
<h3 class="text-purple fs-18 fw-semibold lh-1-7 mb-0">{{ plan.name }}</h3>
|
|
||||||
<div>
|
|
||||||
<ul class="list-unstyled text-gray-500 fs-14 lh-1-7 ps-0 mb-0">
|
|
||||||
{% for price in plan.prices %}
|
|
||||||
<li>{{ price.amount }} {{ price.currency }} per {{ price.interval }}</li>
|
|
||||||
{% endfor %}
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{% endfor %}
|
|
||||||
</div>
|
|
||||||
{% endif %}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
{% endblock %}
|
{% endblock %}
|
|
@ -43,9 +43,7 @@ def create_lead(request, slug):
|
||||||
|
|
||||||
# Get the selected plan
|
# Get the selected plan
|
||||||
selected_plan = get_object_or_404(
|
selected_plan = get_object_or_404(
|
||||||
Plan.objects.prefetch_related(
|
Plan,
|
||||||
"prices", "prices__currency", "prices__term"
|
|
||||||
),
|
|
||||||
id=request.GET.get("plan"),
|
id=request.GET.get("plan"),
|
||||||
offering=selected_offering,
|
offering=selected_offering,
|
||||||
)
|
)
|
||||||
|
|
|
@ -11,9 +11,6 @@ def offering_list(request):
|
||||||
.prefetch_related(
|
.prefetch_related(
|
||||||
"service__categories",
|
"service__categories",
|
||||||
"plans",
|
"plans",
|
||||||
"plans__prices",
|
|
||||||
"plans__prices__currency",
|
|
||||||
"plans__prices__term",
|
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@ -62,9 +59,7 @@ def offering_detail(request, slug):
|
||||||
offering = get_object_or_404(
|
offering = get_object_or_404(
|
||||||
ServiceOffering.objects.select_related(
|
ServiceOffering.objects.select_related(
|
||||||
"service", "cloud_provider"
|
"service", "cloud_provider"
|
||||||
).prefetch_related(
|
).prefetch_related("plans"),
|
||||||
"plans", "plans__prices", "plans__prices__currency", "plans__prices__term"
|
|
||||||
),
|
|
||||||
slug=slug,
|
slug=slug,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
|
@ -32,7 +32,6 @@ def provider_detail(request, slug):
|
||||||
"offerings",
|
"offerings",
|
||||||
"offerings__service",
|
"offerings__service",
|
||||||
"offerings__plans",
|
"offerings__plans",
|
||||||
"offerings__plans__prices",
|
|
||||||
"consulting_partners",
|
"consulting_partners",
|
||||||
),
|
),
|
||||||
slug=slug,
|
slug=slug,
|
||||||
|
|
|
@ -17,7 +17,6 @@ def service_list(request):
|
||||||
"offerings",
|
"offerings",
|
||||||
"offerings__cloud_provider",
|
"offerings__cloud_provider",
|
||||||
"offerings__plans",
|
"offerings__plans",
|
||||||
"offerings__plans__prices",
|
|
||||||
"consulting_partners",
|
"consulting_partners",
|
||||||
"external_links",
|
"external_links",
|
||||||
)
|
)
|
||||||
|
@ -69,9 +68,6 @@ def service_detail(request, slug):
|
||||||
"offerings",
|
"offerings",
|
||||||
"offerings__cloud_provider",
|
"offerings__cloud_provider",
|
||||||
"offerings__plans",
|
"offerings__plans",
|
||||||
"offerings__plans__prices",
|
|
||||||
"offerings__plans__prices__currency",
|
|
||||||
"offerings__plans__prices__term",
|
|
||||||
"consulting_partners",
|
"consulting_partners",
|
||||||
"external_links",
|
"external_links",
|
||||||
),
|
),
|
||||||
|
|
Loading…
Add table
Add a link
Reference in a new issue