Compare commits
26 commits
1347f9c72a
...
86df11505f
Author | SHA1 | Date | |
---|---|---|---|
86df11505f | |||
7b93830df2 | |||
d987f62471 | |||
e06105942b | |||
4cffe5a9e3 | |||
06b4cba4bc | |||
7989d4e553 | |||
57fd001246 | |||
a64163cad0 | |||
60e083b5bb | |||
99f4edc209 | |||
a3cf1cc590 | |||
d9a04655ed | |||
5b4392f838 | |||
19b36b9a2c | |||
3896636f9b | |||
f5f4ec8ac9 | |||
c3d20fda7b | |||
d39ff91a74 | |||
b0a76b88b4 | |||
836187f2aa | |||
a6a15150ea | |||
6f41c8c344 | |||
3a0cc248a7 | |||
f14cc0e39e | |||
cc5307a723 |
40 changed files with 3796 additions and 577 deletions
15
.github/copilot-instructions.md
vendored
Normal file
15
.github/copilot-instructions.md
vendored
Normal file
|
@ -0,0 +1,15 @@
|
|||
This is a Django project which uses SQLite as the database.
|
||||
Follow the Django conventions and best practices, use the Django ORM and define the fields with appropriate types
|
||||
Use function-based views and follow the Django conventions for naming and structuring views.
|
||||
Templates use the Django template language and Bootstrap 5 CSS and JavaScript for styling.
|
||||
The main Django app is in hub/services, ignore the app hub/broker.
|
||||
Docker specific code is in the folder docker/.
|
||||
Kubernetes deployment specific files in deployment/.
|
||||
GitLab CI is used as the main CI/CD system.
|
||||
|
||||
The project uses Astral uv to manage the Pythong project, dependencies and the venv.
|
||||
Execute Django with `uv run --extra dev manage.py`.
|
||||
|
||||
Always add comments to the code to describe what's happening.
|
||||
|
||||
Answers should be short and concise, and should not include any unnecessary comments or explanations, but be clear on which file a code block should be placed in.
|
|
@ -1,181 +0,0 @@
|
|||
from django.contrib import admin
|
||||
from django.utils.html import format_html
|
||||
from adminsortable2.admin import SortableAdminMixin
|
||||
|
||||
from .models import (
|
||||
Category,
|
||||
CloudProvider,
|
||||
ConsultingPartner,
|
||||
ExternalLink,
|
||||
ExternalLinkOffering,
|
||||
Lead,
|
||||
Plan,
|
||||
ReusableText,
|
||||
Service,
|
||||
ServiceOffering,
|
||||
WebsiteFaq,
|
||||
)
|
||||
|
||||
|
||||
class PlanInline(admin.StackedInline):
|
||||
model = Plan
|
||||
extra = 1
|
||||
fieldsets = (
|
||||
(None, {"fields": ("name", "description", "pricing", "plan_description")}),
|
||||
)
|
||||
|
||||
|
||||
class ExternalLinkOfferingInline(admin.TabularInline):
|
||||
model = ExternalLinkOffering
|
||||
extra = 1
|
||||
fields = ("description", "url", "order")
|
||||
ordering = ("order", "description")
|
||||
|
||||
|
||||
class OfferingInline(admin.StackedInline):
|
||||
model = ServiceOffering
|
||||
extra = 1
|
||||
fieldsets = (
|
||||
(
|
||||
None,
|
||||
{
|
||||
"fields": (
|
||||
"description",
|
||||
"service",
|
||||
"cloud_provider",
|
||||
"offer_description",
|
||||
)
|
||||
},
|
||||
),
|
||||
)
|
||||
show_change_link = True
|
||||
|
||||
|
||||
@admin.register(ReusableText)
|
||||
class ReusableTextAdmin(admin.ModelAdmin):
|
||||
list_display = ("name",)
|
||||
search_fields = ("name", "text")
|
||||
ordering = ("name",)
|
||||
|
||||
|
||||
@admin.register(Category)
|
||||
class CategoryAdmin(admin.ModelAdmin):
|
||||
list_display = ("name", "slug", "parent", "order")
|
||||
list_filter = ("parent",)
|
||||
search_fields = ("name", "description")
|
||||
prepopulated_fields = {"slug": ("name",)}
|
||||
ordering = ("order", "name")
|
||||
|
||||
|
||||
@admin.register(CloudProvider)
|
||||
class CloudProviderAdmin(SortableAdminMixin, admin.ModelAdmin):
|
||||
list_display = (
|
||||
"name",
|
||||
"slug",
|
||||
"logo_preview",
|
||||
"disable_listing",
|
||||
"is_featured",
|
||||
"order",
|
||||
)
|
||||
search_fields = ("name", "description")
|
||||
prepopulated_fields = {"slug": ("name",)}
|
||||
inlines = [OfferingInline]
|
||||
ordering = ("order",)
|
||||
|
||||
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",
|
||||
"is_featured",
|
||||
"is_coming_soon",
|
||||
"disable_listing",
|
||||
)
|
||||
list_filter = ("categories",)
|
||||
search_fields = ("name", "description", "slug")
|
||||
prepopulated_fields = {"slug": ("name",)}
|
||||
filter_horizontal = ("categories",)
|
||||
inlines = [ExternalLinkInline, OfferingInline]
|
||||
|
||||
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(ServiceOffering)
|
||||
class ServiceOfferingAdmin(admin.ModelAdmin):
|
||||
list_display = ("service", "cloud_provider")
|
||||
list_filter = ("service", "cloud_provider")
|
||||
search_fields = ("service__name", "cloud_provider__name", "description")
|
||||
inlines = [ExternalLinkOfferingInline, PlanInline]
|
||||
|
||||
|
||||
@admin.register(ConsultingPartner)
|
||||
class ConsultingPartnerAdmin(SortableAdminMixin, admin.ModelAdmin):
|
||||
list_display = (
|
||||
"name",
|
||||
"website",
|
||||
"logo_preview",
|
||||
"disable_listing",
|
||||
"is_featured",
|
||||
"order",
|
||||
)
|
||||
search_fields = ("name", "description")
|
||||
prepopulated_fields = {"slug": ("name",)}
|
||||
filter_horizontal = ("services", "cloud_providers")
|
||||
ordering = ("order",)
|
||||
|
||||
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"
|
||||
|
||||
|
||||
@admin.register(Lead)
|
||||
class LeadAdmin(admin.ModelAdmin):
|
||||
list_display = ("name", "company", "created_at", "odoo_lead_id")
|
||||
search_fields = ("name", "company")
|
||||
|
||||
|
||||
@admin.register(WebsiteFaq)
|
||||
class WebsiteFaqAdmin(SortableAdminMixin, admin.ModelAdmin):
|
||||
list_display = ("question", "order")
|
||||
search_fields = ("question", "answer")
|
||||
ordering = ("order",)
|
9
hub/services/admin/__init__.py
Normal file
9
hub/services/admin/__init__.py
Normal file
|
@ -0,0 +1,9 @@
|
|||
# Admin module initialization
|
||||
# Import all admin classes to register them with Django admin
|
||||
|
||||
from .base import *
|
||||
from .content import *
|
||||
from .leads import *
|
||||
from .pricing import *
|
||||
from .providers import *
|
||||
from .services import *
|
38
hub/services/admin/base.py
Normal file
38
hub/services/admin/base.py
Normal file
|
@ -0,0 +1,38 @@
|
|||
"""
|
||||
Base admin classes and common functionality
|
||||
"""
|
||||
|
||||
from django.contrib import admin
|
||||
from django.utils.html import format_html
|
||||
from adminsortable2.admin import SortableAdminMixin
|
||||
|
||||
from ..models import ReusableText, Category, WebsiteFaq
|
||||
|
||||
|
||||
@admin.register(ReusableText)
|
||||
class ReusableTextAdmin(admin.ModelAdmin):
|
||||
"""Admin configuration for ReusableText model"""
|
||||
|
||||
list_display = ("name",)
|
||||
search_fields = ("name", "text")
|
||||
ordering = ("name",)
|
||||
|
||||
|
||||
@admin.register(Category)
|
||||
class CategoryAdmin(admin.ModelAdmin):
|
||||
"""Admin configuration for Category model"""
|
||||
|
||||
list_display = ("name", "slug", "parent", "order")
|
||||
list_filter = ("parent",)
|
||||
search_fields = ("name", "description")
|
||||
prepopulated_fields = {"slug": ("name",)}
|
||||
ordering = ("order", "name")
|
||||
|
||||
|
||||
@admin.register(WebsiteFaq)
|
||||
class WebsiteFaqAdmin(SortableAdminMixin, admin.ModelAdmin):
|
||||
"""Admin configuration for WebsiteFaq model"""
|
||||
|
||||
list_display = ("question", "order")
|
||||
search_fields = ("question", "answer")
|
||||
ordering = ("order",)
|
35
hub/services/admin/content.py
Normal file
35
hub/services/admin/content.py
Normal file
|
@ -0,0 +1,35 @@
|
|||
"""
|
||||
Admin classes for content-related models like external links and plans
|
||||
"""
|
||||
|
||||
from django.contrib import admin
|
||||
|
||||
from ..models import ExternalLink, ExternalLinkOffering, Plan
|
||||
|
||||
|
||||
class PlanInline(admin.StackedInline):
|
||||
"""Inline admin for Plan model"""
|
||||
|
||||
model = Plan
|
||||
extra = 1
|
||||
fieldsets = (
|
||||
(None, {"fields": ("name", "description", "pricing", "plan_description")}),
|
||||
)
|
||||
|
||||
|
||||
class ExternalLinkOfferingInline(admin.TabularInline):
|
||||
"""Inline admin for ExternalLinkOffering model"""
|
||||
|
||||
model = ExternalLinkOffering
|
||||
extra = 1
|
||||
fields = ("description", "url", "order")
|
||||
ordering = ("order", "description")
|
||||
|
||||
|
||||
class ExternalLinkInline(admin.TabularInline):
|
||||
"""Inline admin for ExternalLink model"""
|
||||
|
||||
model = ExternalLink
|
||||
extra = 1
|
||||
fields = ("description", "url", "order")
|
||||
ordering = ("order", "description")
|
15
hub/services/admin/leads.py
Normal file
15
hub/services/admin/leads.py
Normal file
|
@ -0,0 +1,15 @@
|
|||
"""
|
||||
Admin classes for lead management
|
||||
"""
|
||||
|
||||
from django.contrib import admin
|
||||
|
||||
from ..models import Lead
|
||||
|
||||
|
||||
@admin.register(Lead)
|
||||
class LeadAdmin(admin.ModelAdmin):
|
||||
"""Admin configuration for Lead model"""
|
||||
|
||||
list_display = ("name", "company", "created_at", "odoo_lead_id")
|
||||
search_fields = ("name", "company")
|
408
hub/services/admin/pricing.py
Normal file
408
hub/services/admin/pricing.py
Normal file
|
@ -0,0 +1,408 @@
|
|||
"""
|
||||
Admin classes for pricing models including compute plans, storage plans, and VSHN AppCat pricing
|
||||
"""
|
||||
|
||||
from django.contrib import admin
|
||||
from django.utils.html import format_html
|
||||
from adminsortable2.admin import SortableAdminMixin
|
||||
from import_export.admin import ImportExportModelAdmin
|
||||
from import_export import resources
|
||||
from import_export.fields import Field
|
||||
from import_export.widgets import ForeignKeyWidget
|
||||
|
||||
from ..models import (
|
||||
ComputePlan,
|
||||
ComputePlanGroup,
|
||||
ComputePlanPrice,
|
||||
CloudProvider,
|
||||
StoragePlan,
|
||||
StoragePlanPrice,
|
||||
VSHNAppCatBaseFee,
|
||||
VSHNAppCatPrice,
|
||||
VSHNAppCatUnitRate,
|
||||
ProgressiveDiscountModel,
|
||||
DiscountTier,
|
||||
ExternalPricePlans,
|
||||
Service,
|
||||
)
|
||||
|
||||
|
||||
class ComputePlanPriceInline(admin.TabularInline):
|
||||
"""Inline admin for ComputePlanPrice model"""
|
||||
|
||||
model = ComputePlanPrice
|
||||
extra = 1
|
||||
fields = ("currency", "amount")
|
||||
|
||||
|
||||
class ComputePlanItemInline(admin.TabularInline):
|
||||
"""Inline admin for ComputePlan model"""
|
||||
|
||||
model = ComputePlan
|
||||
extra = 1
|
||||
fields = ("name", "vcpus", "ram", "active", "valid_from", "valid_to")
|
||||
|
||||
|
||||
@admin.register(ComputePlanGroup)
|
||||
class ComputePlanGroupAdmin(SortableAdminMixin, admin.ModelAdmin):
|
||||
"""Admin configuration for ComputePlanGroup model"""
|
||||
|
||||
list_display = ("name", "node_label", "compute_plans_count")
|
||||
search_fields = ("name", "description", "node_label")
|
||||
ordering = ("order",)
|
||||
|
||||
def compute_plans_count(self, obj):
|
||||
"""Display count of compute plans in this group"""
|
||||
return obj.compute_plans.count()
|
||||
|
||||
compute_plans_count.short_description = "Compute Plans"
|
||||
|
||||
|
||||
class ComputePlanResource(resources.ModelResource):
|
||||
"""Import/Export resource for ComputePlan model"""
|
||||
|
||||
cloud_provider = Field(
|
||||
column_name="cloud_provider",
|
||||
attribute="cloud_provider",
|
||||
widget=ForeignKeyWidget(CloudProvider, "name"),
|
||||
)
|
||||
group = Field(
|
||||
column_name="group",
|
||||
attribute="group",
|
||||
widget=ForeignKeyWidget(ComputePlanGroup, "name"),
|
||||
)
|
||||
prices = Field(column_name="prices", attribute=None)
|
||||
|
||||
class Meta:
|
||||
model = ComputePlan
|
||||
skip_unchanged = True
|
||||
report_skipped = False
|
||||
import_id_fields = ["name"]
|
||||
fields = (
|
||||
"name",
|
||||
"vcpus",
|
||||
"ram",
|
||||
"cpu_mem_ratio",
|
||||
"cloud_provider",
|
||||
"group",
|
||||
"active",
|
||||
"term",
|
||||
"valid_from",
|
||||
"valid_to",
|
||||
"prices",
|
||||
)
|
||||
|
||||
def dehydrate_prices(self, compute_plan):
|
||||
"""Export prices in a custom format"""
|
||||
prices = compute_plan.prices.all()
|
||||
if not prices:
|
||||
return ""
|
||||
return "|".join([f"{p.currency} {p.amount}" for p in prices])
|
||||
|
||||
def save_m2m(self, instance, row, *args, **kwargs):
|
||||
"""Handle many-to-many relationships during import"""
|
||||
super().save_m2m(instance, row, *args, **kwargs)
|
||||
|
||||
if "prices" in row and row["prices"]:
|
||||
# Clear existing prices first
|
||||
instance.prices.all().delete()
|
||||
|
||||
price_entries = row["prices"].split("|")
|
||||
for entry in price_entries:
|
||||
if " " in entry:
|
||||
currency, amount = entry.split(" ")
|
||||
ComputePlanPrice.objects.create(
|
||||
compute_plan=instance, currency=currency, amount=amount
|
||||
)
|
||||
|
||||
|
||||
@admin.register(ComputePlan)
|
||||
class ComputePlansAdmin(ImportExportModelAdmin):
|
||||
"""Admin configuration for ComputePlan model with import/export functionality"""
|
||||
|
||||
resource_class = ComputePlanResource
|
||||
list_display = (
|
||||
"name",
|
||||
"cloud_provider",
|
||||
"group",
|
||||
"vcpus",
|
||||
"ram",
|
||||
"term",
|
||||
"display_prices",
|
||||
"active",
|
||||
)
|
||||
search_fields = ("name", "cloud_provider__name", "group__name")
|
||||
list_filter = ("active", "cloud_provider", "group")
|
||||
ordering = ("name",)
|
||||
inlines = [ComputePlanPriceInline]
|
||||
|
||||
def display_prices(self, obj):
|
||||
"""Display formatted prices for the list view"""
|
||||
prices = obj.prices.all()
|
||||
if not prices:
|
||||
return "No prices set"
|
||||
return format_html("<br>".join([f"{p.amount} {p.currency}" for p in prices]))
|
||||
|
||||
display_prices.short_description = "Prices (Amount Currency)"
|
||||
|
||||
|
||||
class VSHNAppCatBaseFeeInline(admin.TabularInline):
|
||||
"""Inline admin for VSHNAppCatBaseFee model"""
|
||||
|
||||
model = VSHNAppCatBaseFee
|
||||
extra = 1
|
||||
fields = ("currency", "amount")
|
||||
|
||||
|
||||
class VSHNAppCatUnitRateInline(admin.TabularInline):
|
||||
"""Inline admin for VSHNAppCatUnitRate model"""
|
||||
|
||||
model = VSHNAppCatUnitRate
|
||||
extra = 1
|
||||
fields = ("currency", "service_level", "amount")
|
||||
|
||||
|
||||
class DiscountTierInline(admin.TabularInline):
|
||||
"""Inline admin for DiscountTier model"""
|
||||
|
||||
model = DiscountTier
|
||||
extra = 1
|
||||
fields = ("min_units", "max_units", "discount_percent")
|
||||
ordering = ("min_units",)
|
||||
|
||||
|
||||
@admin.register(ProgressiveDiscountModel)
|
||||
class ProgressiveDiscountModelAdmin(admin.ModelAdmin):
|
||||
"""Admin configuration for ProgressiveDiscountModel"""
|
||||
|
||||
list_display = ("name", "description", "active")
|
||||
search_fields = ("name", "description")
|
||||
inlines = [DiscountTierInline]
|
||||
|
||||
|
||||
@admin.register(VSHNAppCatPrice)
|
||||
class VSHNAppCatPriceAdmin(admin.ModelAdmin):
|
||||
"""Admin configuration for VSHNAppCatPrice model"""
|
||||
|
||||
list_display = (
|
||||
"service",
|
||||
"variable_unit",
|
||||
"term",
|
||||
"discount_model",
|
||||
"admin_display_base_fees",
|
||||
"admin_display_unit_rates",
|
||||
)
|
||||
list_filter = ("variable_unit", "service", "discount_model")
|
||||
search_fields = ("service__name",)
|
||||
inlines = [VSHNAppCatBaseFeeInline, VSHNAppCatUnitRateInline]
|
||||
|
||||
def admin_display_base_fees(self, obj):
|
||||
"""Display base fees in admin list view"""
|
||||
fees = obj.base_fees.all()
|
||||
if not fees:
|
||||
return "No base fees"
|
||||
return format_html(
|
||||
"<br>".join([f"{fee.amount} {fee.currency}" for fee in fees])
|
||||
)
|
||||
|
||||
admin_display_base_fees.short_description = "Base Fees"
|
||||
|
||||
def admin_display_unit_rates(self, obj):
|
||||
"""Display unit rates in admin list view"""
|
||||
rates = obj.unit_rates.all()
|
||||
if not rates:
|
||||
return "No unit rates"
|
||||
return format_html(
|
||||
"<br>".join(
|
||||
[
|
||||
f"{rate.amount} {rate.currency} ({rate.get_service_level_display()})"
|
||||
for rate in rates
|
||||
]
|
||||
)
|
||||
)
|
||||
|
||||
admin_display_unit_rates.short_description = "Unit Rates"
|
||||
|
||||
|
||||
class StoragePlanPriceInline(admin.TabularInline):
|
||||
"""Inline admin for StoragePlanPrice model"""
|
||||
|
||||
model = StoragePlanPrice
|
||||
extra = 1
|
||||
fields = ("currency", "amount")
|
||||
|
||||
|
||||
class StoragePlanResource(resources.ModelResource):
|
||||
"""Import/Export resource for StoragePlan model"""
|
||||
|
||||
cloud_provider = Field(
|
||||
column_name="cloud_provider",
|
||||
attribute="cloud_provider",
|
||||
widget=ForeignKeyWidget(CloudProvider, "name"),
|
||||
)
|
||||
prices = Field(column_name="prices", attribute=None)
|
||||
|
||||
class Meta:
|
||||
model = StoragePlan
|
||||
skip_unchanged = True
|
||||
report_skipped = False
|
||||
import_id_fields = ["name"]
|
||||
fields = (
|
||||
"name",
|
||||
"cloud_provider",
|
||||
"term",
|
||||
"unit",
|
||||
"valid_from",
|
||||
"valid_to",
|
||||
"prices",
|
||||
)
|
||||
|
||||
def dehydrate_prices(self, storage_plan):
|
||||
"""Export prices in a custom format"""
|
||||
prices = storage_plan.prices.all()
|
||||
if not prices:
|
||||
return ""
|
||||
return "|".join([f"{p.currency} {p.amount}" for p in prices])
|
||||
|
||||
def save_m2m(self, instance, row, *args, **kwargs):
|
||||
"""Handle many-to-many relationships during import"""
|
||||
super().save_m2m(instance, row, *args, **kwargs)
|
||||
|
||||
# Handle prices
|
||||
if "prices" in row and row["prices"]:
|
||||
# Clear existing prices first
|
||||
instance.prices.all().delete()
|
||||
|
||||
# Create new prices
|
||||
price_entries = row["prices"].split("|")
|
||||
for entry in price_entries:
|
||||
if " " in entry:
|
||||
currency, amount = entry.split(" ")
|
||||
StoragePlanPrice.objects.create(
|
||||
storage_plan=instance, currency=currency, amount=amount
|
||||
)
|
||||
|
||||
|
||||
@admin.register(StoragePlan)
|
||||
class StoragePlanAdmin(ImportExportModelAdmin):
|
||||
"""Admin configuration for StoragePlan model with import/export functionality"""
|
||||
|
||||
resource_class = StoragePlanResource
|
||||
list_display = (
|
||||
"name",
|
||||
"cloud_provider",
|
||||
"term",
|
||||
"unit",
|
||||
"display_prices",
|
||||
)
|
||||
search_fields = ("name", "cloud_provider__name")
|
||||
list_filter = ("cloud_provider",)
|
||||
ordering = ("name",)
|
||||
inlines = [StoragePlanPriceInline]
|
||||
|
||||
def display_prices(self, obj):
|
||||
"""Display formatted prices for the list view"""
|
||||
prices = obj.prices.all()
|
||||
if not prices:
|
||||
return "No prices set"
|
||||
return format_html("<br>".join([f"{p.amount} {p.currency}" for p in prices]))
|
||||
|
||||
display_prices.short_description = "Prices (Amount Currency)"
|
||||
|
||||
|
||||
class ExternalPricePlansResource(resources.ModelResource):
|
||||
"""Import/Export resource for ExternalPricePlans model"""
|
||||
|
||||
cloud_provider = Field(
|
||||
column_name="cloud_provider",
|
||||
attribute="cloud_provider",
|
||||
widget=ForeignKeyWidget(CloudProvider, "name"),
|
||||
)
|
||||
service = Field(
|
||||
column_name="service",
|
||||
attribute="service",
|
||||
widget=ForeignKeyWidget(Service, "name"),
|
||||
)
|
||||
compare_to = Field(column_name="compare_to", attribute=None)
|
||||
|
||||
class Meta:
|
||||
model = ExternalPricePlans
|
||||
skip_unchanged = True
|
||||
report_skipped = False
|
||||
import_id_fields = ["plan_name", "cloud_provider", "service"]
|
||||
fields = (
|
||||
"plan_name",
|
||||
"description",
|
||||
"source",
|
||||
"date_retrieved",
|
||||
"cloud_provider",
|
||||
"service",
|
||||
"currency",
|
||||
"term",
|
||||
"amount",
|
||||
"vcpus",
|
||||
"ram",
|
||||
"storage",
|
||||
"competitor_sla",
|
||||
"replicas",
|
||||
"service_level",
|
||||
"compare_to",
|
||||
)
|
||||
|
||||
def dehydrate_compare_to(self, external_price):
|
||||
"""Export compute plans this external price compares to"""
|
||||
compute_plans = external_price.compare_to.all()
|
||||
if not compute_plans:
|
||||
return ""
|
||||
return "|".join([plan.name for plan in compute_plans])
|
||||
|
||||
def save_m2m(self, instance, row, *args, **kwargs):
|
||||
"""Handle many-to-many relationships during import"""
|
||||
super().save_m2m(instance, row, *args, **kwargs)
|
||||
|
||||
# Handle compare_to relationships
|
||||
if "compare_to" in row and row["compare_to"]:
|
||||
# Clear existing relationships first
|
||||
instance.compare_to.clear()
|
||||
|
||||
# Create new relationships
|
||||
plan_names = row["compare_to"].split("|")
|
||||
for plan_name in plan_names:
|
||||
plan_name = plan_name.strip()
|
||||
if plan_name:
|
||||
try:
|
||||
compute_plan = ComputePlan.objects.get(name=plan_name)
|
||||
instance.compare_to.add(compute_plan)
|
||||
except ComputePlan.DoesNotExist:
|
||||
# Log or handle missing compute plan
|
||||
pass
|
||||
|
||||
|
||||
@admin.register(ExternalPricePlans)
|
||||
class ExternalPricePlansAdmin(ImportExportModelAdmin):
|
||||
"""Admin configuration for ExternalPricePlans model with import/export functionality"""
|
||||
|
||||
resource_class = ExternalPricePlansResource
|
||||
list_display = (
|
||||
"plan_name",
|
||||
"cloud_provider",
|
||||
"service",
|
||||
"amount",
|
||||
"display_compare_to_count",
|
||||
"replicas",
|
||||
)
|
||||
list_filter = ("cloud_provider", "service", "currency", "term")
|
||||
search_fields = ("plan_name", "cloud_provider__name", "service__name")
|
||||
ordering = ("cloud_provider", "service", "plan_name")
|
||||
|
||||
# Configure many-to-many field display
|
||||
filter_horizontal = ("compare_to",)
|
||||
|
||||
def display_compare_to_count(self, obj):
|
||||
"""Display count of compute plans this external price compares to"""
|
||||
count = obj.compare_to.count()
|
||||
if count == 0:
|
||||
return "No comparisons"
|
||||
return f"{count} plan{'s' if count != 1 else ''}"
|
||||
|
||||
display_compare_to_count.short_description = "Compare To"
|
86
hub/services/admin/providers.py
Normal file
86
hub/services/admin/providers.py
Normal file
|
@ -0,0 +1,86 @@
|
|||
"""
|
||||
Admin classes for cloud providers and consulting partners
|
||||
"""
|
||||
|
||||
from django.contrib import admin
|
||||
from django.utils.html import format_html
|
||||
from adminsortable2.admin import SortableAdminMixin
|
||||
|
||||
from ..models import CloudProvider, ConsultingPartner, ServiceOffering
|
||||
|
||||
|
||||
class OfferingInline(admin.StackedInline):
|
||||
"""Inline admin for ServiceOffering model"""
|
||||
|
||||
model = ServiceOffering
|
||||
extra = 1
|
||||
fieldsets = (
|
||||
(
|
||||
None,
|
||||
{
|
||||
"fields": (
|
||||
"description",
|
||||
"service",
|
||||
"cloud_provider",
|
||||
"offer_description",
|
||||
)
|
||||
},
|
||||
),
|
||||
)
|
||||
show_change_link = True
|
||||
|
||||
|
||||
@admin.register(CloudProvider)
|
||||
class CloudProviderAdmin(SortableAdminMixin, admin.ModelAdmin):
|
||||
"""Admin configuration for CloudProvider model"""
|
||||
|
||||
list_display = (
|
||||
"name",
|
||||
"slug",
|
||||
"logo_preview",
|
||||
"disable_listing",
|
||||
"is_featured",
|
||||
"order",
|
||||
)
|
||||
search_fields = ("name", "description")
|
||||
prepopulated_fields = {"slug": ("name",)}
|
||||
inlines = [OfferingInline]
|
||||
ordering = ("order",)
|
||||
|
||||
def logo_preview(self, obj):
|
||||
"""Display logo preview in admin list view"""
|
||||
if obj.logo:
|
||||
return format_html(
|
||||
'<img src="{}" style="max-height: 50px;"/>', obj.logo.url
|
||||
)
|
||||
return "No logo"
|
||||
|
||||
logo_preview.short_description = "Logo"
|
||||
|
||||
|
||||
@admin.register(ConsultingPartner)
|
||||
class ConsultingPartnerAdmin(SortableAdminMixin, admin.ModelAdmin):
|
||||
"""Admin configuration for ConsultingPartner model"""
|
||||
|
||||
list_display = (
|
||||
"name",
|
||||
"website",
|
||||
"logo_preview",
|
||||
"disable_listing",
|
||||
"is_featured",
|
||||
"order",
|
||||
)
|
||||
search_fields = ("name", "description")
|
||||
prepopulated_fields = {"slug": ("name",)}
|
||||
filter_horizontal = ("services", "cloud_providers")
|
||||
ordering = ("order",)
|
||||
|
||||
def logo_preview(self, obj):
|
||||
"""Display logo preview in admin list view"""
|
||||
if obj.logo:
|
||||
return format_html(
|
||||
'<img src="{}" style="max-height: 50px;"/>', obj.logo.url
|
||||
)
|
||||
return "No logo"
|
||||
|
||||
logo_preview.short_description = "Logo"
|
108
hub/services/admin/services.py
Normal file
108
hub/services/admin/services.py
Normal file
|
@ -0,0 +1,108 @@
|
|||
"""
|
||||
Admin classes for services and service offerings
|
||||
"""
|
||||
|
||||
from django.contrib import admin
|
||||
from django.utils.html import format_html
|
||||
|
||||
from ..models import Service, ServiceOffering, ExternalLink, ExternalLinkOffering, Plan
|
||||
|
||||
|
||||
class ExternalLinkInline(admin.TabularInline):
|
||||
"""Inline admin for ExternalLink model"""
|
||||
|
||||
model = ExternalLink
|
||||
extra = 1
|
||||
fields = ("description", "url", "order")
|
||||
ordering = ("order", "description")
|
||||
|
||||
|
||||
class ExternalLinkOfferingInline(admin.TabularInline):
|
||||
"""Inline admin for ExternalLinkOffering model"""
|
||||
|
||||
model = ExternalLinkOffering
|
||||
extra = 1
|
||||
fields = ("description", "url", "order")
|
||||
ordering = ("order", "description")
|
||||
|
||||
|
||||
class PlanInline(admin.StackedInline):
|
||||
"""Inline admin for Plan model"""
|
||||
|
||||
model = Plan
|
||||
extra = 1
|
||||
fieldsets = (
|
||||
(None, {"fields": ("name", "description", "pricing", "plan_description")}),
|
||||
)
|
||||
|
||||
|
||||
class OfferingInline(admin.StackedInline):
|
||||
"""Inline admin for ServiceOffering model"""
|
||||
|
||||
model = ServiceOffering
|
||||
extra = 1
|
||||
fieldsets = (
|
||||
(
|
||||
None,
|
||||
{
|
||||
"fields": (
|
||||
"description",
|
||||
"service",
|
||||
"cloud_provider",
|
||||
"offer_description",
|
||||
)
|
||||
},
|
||||
),
|
||||
)
|
||||
show_change_link = True
|
||||
|
||||
|
||||
@admin.register(Service)
|
||||
class ServiceAdmin(admin.ModelAdmin):
|
||||
"""Admin configuration for Service model"""
|
||||
|
||||
list_display = (
|
||||
"name",
|
||||
"logo_preview",
|
||||
"category_list",
|
||||
"is_featured",
|
||||
"is_coming_soon",
|
||||
"disable_listing",
|
||||
)
|
||||
list_filter = ("categories",)
|
||||
search_fields = ("name", "description", "slug")
|
||||
prepopulated_fields = {"slug": ("name",)}
|
||||
filter_horizontal = ("categories",)
|
||||
inlines = [ExternalLinkInline, OfferingInline]
|
||||
|
||||
def logo_preview(self, obj):
|
||||
"""Display logo preview in admin list view"""
|
||||
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):
|
||||
"""Display categories as comma-separated list"""
|
||||
return ", ".join([cat.name for cat in obj.categories.all()])
|
||||
|
||||
category_list.short_description = "Categories"
|
||||
|
||||
def partner_list(self, obj):
|
||||
"""Display consulting partners as comma-separated list"""
|
||||
return ", ".join([partner.name for partner in obj.consulting_partners.all()])
|
||||
|
||||
partner_list.short_description = "Consulting Partners"
|
||||
|
||||
|
||||
@admin.register(ServiceOffering)
|
||||
class ServiceOfferingAdmin(admin.ModelAdmin):
|
||||
"""Admin configuration for ServiceOffering model"""
|
||||
|
||||
list_display = ("service", "cloud_provider")
|
||||
list_filter = ("service", "cloud_provider")
|
||||
search_fields = ("service__name", "cloud_provider__name", "description")
|
||||
inlines = [ExternalLinkOfferingInline, PlanInline]
|
70
hub/services/migrations/0022_computeplan.py
Normal file
70
hub/services/migrations/0022_computeplan.py
Normal file
|
@ -0,0 +1,70 @@
|
|||
# Generated by Django 5.2 on 2025-05-20 09:08
|
||||
|
||||
import django.db.models.deletion
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("services", "0021_alter_consultingpartner_options_and_more"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name="ComputePlan",
|
||||
fields=[
|
||||
(
|
||||
"id",
|
||||
models.BigAutoField(
|
||||
auto_created=True,
|
||||
primary_key=True,
|
||||
serialize=False,
|
||||
verbose_name="ID",
|
||||
),
|
||||
),
|
||||
("name", models.CharField(max_length=200)),
|
||||
("vcpus", models.FloatField(help_text="Number of available vCPUs")),
|
||||
("ram", models.FloatField(help_text="Amount of RAM available")),
|
||||
(
|
||||
"cpu_mem_ratio",
|
||||
models.FloatField(
|
||||
help_text="vCPU to Memory ratio. How much vCPU per GiB RAM is available?"
|
||||
),
|
||||
),
|
||||
(
|
||||
"price_chf",
|
||||
models.FloatField(help_text="Plan price in CHF excl. VAT"),
|
||||
),
|
||||
(
|
||||
"price_eur",
|
||||
models.FloatField(
|
||||
blank=True, help_text="Plan price in EUR excl. VAT", null=True
|
||||
),
|
||||
),
|
||||
(
|
||||
"price_usd",
|
||||
models.FloatField(
|
||||
blank=True, help_text="Plan price in USD excl. VAT", null=True
|
||||
),
|
||||
),
|
||||
(
|
||||
"active",
|
||||
models.BooleanField(default=True, help_text="Is the plan active?"),
|
||||
),
|
||||
("valid_from", models.DateTimeField(blank=True, null=True)),
|
||||
("valid_to", models.DateTimeField(blank=True, null=True)),
|
||||
(
|
||||
"cloud_provider",
|
||||
models.ForeignKey(
|
||||
on_delete=django.db.models.deletion.CASCADE,
|
||||
related_name="compute_plans",
|
||||
to="services.cloudprovider",
|
||||
),
|
||||
),
|
||||
],
|
||||
options={
|
||||
"ordering": ["price_chf"],
|
||||
},
|
||||
),
|
||||
]
|
|
@ -0,0 +1,223 @@
|
|||
# Generated by Django 5.2 on 2025-05-20 13:25
|
||||
|
||||
import django.db.models.deletion
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("services", "0022_computeplan"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterModelOptions(
|
||||
name="computeplan",
|
||||
options={"ordering": ["name"]},
|
||||
),
|
||||
migrations.RemoveField(
|
||||
model_name="computeplan",
|
||||
name="price_chf",
|
||||
),
|
||||
migrations.RemoveField(
|
||||
model_name="computeplan",
|
||||
name="price_eur",
|
||||
),
|
||||
migrations.RemoveField(
|
||||
model_name="computeplan",
|
||||
name="price_usd",
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name="VSHNAppCatPrice",
|
||||
fields=[
|
||||
(
|
||||
"id",
|
||||
models.BigAutoField(
|
||||
auto_created=True,
|
||||
primary_key=True,
|
||||
serialize=False,
|
||||
verbose_name="ID",
|
||||
),
|
||||
),
|
||||
(
|
||||
"variable_unit",
|
||||
models.CharField(
|
||||
choices=[
|
||||
("RAM", "Memory (RAM)"),
|
||||
("CPU", "CPU (vCPU)"),
|
||||
("USR", "Users"),
|
||||
],
|
||||
default="RAM",
|
||||
max_length=3,
|
||||
),
|
||||
),
|
||||
(
|
||||
"ha_replica_min",
|
||||
models.IntegerField(
|
||||
default=1, help_text="Minimum of replicas for HA"
|
||||
),
|
||||
),
|
||||
(
|
||||
"ha_replica_max",
|
||||
models.IntegerField(
|
||||
default=1, help_text="Maximum supported replicas"
|
||||
),
|
||||
),
|
||||
(
|
||||
"service",
|
||||
models.ForeignKey(
|
||||
on_delete=django.db.models.deletion.CASCADE,
|
||||
related_name="vshn_appcat_price",
|
||||
to="services.service",
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name="ComputePlanPrice",
|
||||
fields=[
|
||||
(
|
||||
"id",
|
||||
models.BigAutoField(
|
||||
auto_created=True,
|
||||
primary_key=True,
|
||||
serialize=False,
|
||||
verbose_name="ID",
|
||||
),
|
||||
),
|
||||
(
|
||||
"currency",
|
||||
models.CharField(
|
||||
choices=[
|
||||
("CHF", "Swiss Franc"),
|
||||
("EUR", "Euro"),
|
||||
("USD", "US Dollar"),
|
||||
],
|
||||
max_length=3,
|
||||
),
|
||||
),
|
||||
(
|
||||
"amount",
|
||||
models.DecimalField(
|
||||
decimal_places=2,
|
||||
help_text="Price in the specified currency, excl. VAT",
|
||||
max_digits=10,
|
||||
),
|
||||
),
|
||||
(
|
||||
"compute_plan",
|
||||
models.ForeignKey(
|
||||
on_delete=django.db.models.deletion.CASCADE,
|
||||
related_name="prices",
|
||||
to="services.computeplan",
|
||||
),
|
||||
),
|
||||
],
|
||||
options={
|
||||
"ordering": ["currency"],
|
||||
"unique_together": {("compute_plan", "currency")},
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name="VSHNAppCatBaseFee",
|
||||
fields=[
|
||||
(
|
||||
"id",
|
||||
models.BigAutoField(
|
||||
auto_created=True,
|
||||
primary_key=True,
|
||||
serialize=False,
|
||||
verbose_name="ID",
|
||||
),
|
||||
),
|
||||
(
|
||||
"currency",
|
||||
models.CharField(
|
||||
choices=[
|
||||
("CHF", "Swiss Franc"),
|
||||
("EUR", "Euro"),
|
||||
("USD", "US Dollar"),
|
||||
],
|
||||
max_length=3,
|
||||
),
|
||||
),
|
||||
(
|
||||
"amount",
|
||||
models.DecimalField(
|
||||
decimal_places=2,
|
||||
help_text="Base fee in the specified currency, excl. VAT",
|
||||
max_digits=10,
|
||||
),
|
||||
),
|
||||
(
|
||||
"vshn_appcat_price_config",
|
||||
models.ForeignKey(
|
||||
on_delete=django.db.models.deletion.CASCADE,
|
||||
related_name="base_fees",
|
||||
to="services.vshnappcatprice",
|
||||
),
|
||||
),
|
||||
],
|
||||
options={
|
||||
"ordering": ["currency"],
|
||||
"unique_together": {("vshn_appcat_price_config", "currency")},
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name="VSHNAppCatUnitRate",
|
||||
fields=[
|
||||
(
|
||||
"id",
|
||||
models.BigAutoField(
|
||||
auto_created=True,
|
||||
primary_key=True,
|
||||
serialize=False,
|
||||
verbose_name="ID",
|
||||
),
|
||||
),
|
||||
(
|
||||
"currency",
|
||||
models.CharField(
|
||||
choices=[
|
||||
("CHF", "Swiss Franc"),
|
||||
("EUR", "Euro"),
|
||||
("USD", "US Dollar"),
|
||||
],
|
||||
max_length=3,
|
||||
),
|
||||
),
|
||||
(
|
||||
"service_level",
|
||||
models.CharField(
|
||||
choices=[
|
||||
("BE", "Best Effort"),
|
||||
("GA", "Guaranteed Availability"),
|
||||
],
|
||||
max_length=2,
|
||||
),
|
||||
),
|
||||
(
|
||||
"amount",
|
||||
models.DecimalField(
|
||||
decimal_places=4,
|
||||
help_text="Price per unit in the specified currency and service level, excl. VAT",
|
||||
max_digits=10,
|
||||
),
|
||||
),
|
||||
(
|
||||
"vshn_appcat_price_config",
|
||||
models.ForeignKey(
|
||||
on_delete=django.db.models.deletion.CASCADE,
|
||||
related_name="unit_rates",
|
||||
to="services.vshnappcatprice",
|
||||
),
|
||||
),
|
||||
],
|
||||
options={
|
||||
"ordering": ["currency", "service_level"],
|
||||
"unique_together": {
|
||||
("vshn_appcat_price_config", "currency", "service_level")
|
||||
},
|
||||
},
|
||||
),
|
||||
]
|
87
hub/services/migrations/0024_storageplan_storageplanprice.py
Normal file
87
hub/services/migrations/0024_storageplan_storageplanprice.py
Normal file
|
@ -0,0 +1,87 @@
|
|||
# Generated by Django 5.2 on 2025-05-22 14:13
|
||||
|
||||
import django.db.models.deletion
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("services", "0023_alter_computeplan_options_and_more"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name="StoragePlan",
|
||||
fields=[
|
||||
(
|
||||
"id",
|
||||
models.BigAutoField(
|
||||
auto_created=True,
|
||||
primary_key=True,
|
||||
serialize=False,
|
||||
verbose_name="ID",
|
||||
),
|
||||
),
|
||||
("name", models.CharField(max_length=200)),
|
||||
("valid_from", models.DateTimeField(blank=True, null=True)),
|
||||
("valid_to", models.DateTimeField(blank=True, null=True)),
|
||||
(
|
||||
"cloud_provider",
|
||||
models.ForeignKey(
|
||||
on_delete=django.db.models.deletion.CASCADE,
|
||||
related_name="storage_plans",
|
||||
to="services.cloudprovider",
|
||||
),
|
||||
),
|
||||
],
|
||||
options={
|
||||
"ordering": ["name"],
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name="StoragePlanPrice",
|
||||
fields=[
|
||||
(
|
||||
"id",
|
||||
models.BigAutoField(
|
||||
auto_created=True,
|
||||
primary_key=True,
|
||||
serialize=False,
|
||||
verbose_name="ID",
|
||||
),
|
||||
),
|
||||
(
|
||||
"currency",
|
||||
models.CharField(
|
||||
choices=[
|
||||
("CHF", "Swiss Franc"),
|
||||
("EUR", "Euro"),
|
||||
("USD", "US Dollar"),
|
||||
],
|
||||
max_length=3,
|
||||
),
|
||||
),
|
||||
(
|
||||
"amount",
|
||||
models.DecimalField(
|
||||
decimal_places=2,
|
||||
help_text="Price in the specified currency, excl. VAT",
|
||||
max_digits=10,
|
||||
),
|
||||
),
|
||||
(
|
||||
"storage_plan",
|
||||
models.ForeignKey(
|
||||
on_delete=django.db.models.deletion.CASCADE,
|
||||
related_name="prices",
|
||||
to="services.storageplan",
|
||||
),
|
||||
),
|
||||
],
|
||||
options={
|
||||
"ordering": ["currency"],
|
||||
"unique_together": {("storage_plan", "currency")},
|
||||
},
|
||||
),
|
||||
]
|
|
@ -0,0 +1,70 @@
|
|||
# Generated by Django 5.2 on 2025-05-22 14:27
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("services", "0024_storageplan_storageplanprice"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name="computeplan",
|
||||
name="term",
|
||||
field=models.CharField(
|
||||
choices=[
|
||||
("MTH", "per Month (30d)"),
|
||||
("DAY", "per Day"),
|
||||
("HR", "per Hour"),
|
||||
("MIN", "per Minute"),
|
||||
],
|
||||
default="MTH",
|
||||
max_length=3,
|
||||
),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="storageplan",
|
||||
name="term",
|
||||
field=models.CharField(
|
||||
choices=[
|
||||
("MTH", "per Month (30d)"),
|
||||
("DAY", "per Day"),
|
||||
("HR", "per Hour"),
|
||||
("MIN", "per Minute"),
|
||||
],
|
||||
default="MTH",
|
||||
max_length=3,
|
||||
),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="storageplan",
|
||||
name="unit",
|
||||
field=models.CharField(
|
||||
choices=[("GIB", "GiB"), ("MIB", "MiB"), ("CPU", "vCPU")],
|
||||
default="GIB",
|
||||
max_length=3,
|
||||
),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="vshnappcatprice",
|
||||
name="term",
|
||||
field=models.CharField(
|
||||
choices=[
|
||||
("MTH", "per Month (30d)"),
|
||||
("DAY", "per Day"),
|
||||
("HR", "per Hour"),
|
||||
("MIN", "per Minute"),
|
||||
],
|
||||
default="MTH",
|
||||
max_length=3,
|
||||
),
|
||||
),
|
||||
migrations.AlterUniqueTogether(
|
||||
name="storageplan",
|
||||
unique_together={
|
||||
("cloud_provider", "term", "unit", "valid_from", "valid_to")
|
||||
},
|
||||
),
|
||||
]
|
|
@ -0,0 +1,95 @@
|
|||
# Generated by Django 5.2 on 2025-05-22 14:50
|
||||
|
||||
import django.db.models.deletion
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
(
|
||||
"services",
|
||||
"0025_computeplan_term_storageplan_term_storageplan_unit_and_more",
|
||||
),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name="ProgressiveDiscountModel",
|
||||
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)),
|
||||
("active", models.BooleanField(default=True)),
|
||||
],
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="vshnappcatprice",
|
||||
name="valid_from",
|
||||
field=models.DateTimeField(blank=True, null=True),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="vshnappcatprice",
|
||||
name="valid_to",
|
||||
field=models.DateTimeField(blank=True, null=True),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="vshnappcatprice",
|
||||
name="discount_model",
|
||||
field=models.ForeignKey(
|
||||
blank=True,
|
||||
null=True,
|
||||
on_delete=django.db.models.deletion.SET_NULL,
|
||||
related_name="price_configs",
|
||||
to="services.progressivediscountmodel",
|
||||
),
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name="DiscountTier",
|
||||
fields=[
|
||||
(
|
||||
"id",
|
||||
models.BigAutoField(
|
||||
auto_created=True,
|
||||
primary_key=True,
|
||||
serialize=False,
|
||||
verbose_name="ID",
|
||||
),
|
||||
),
|
||||
(
|
||||
"threshold",
|
||||
models.PositiveIntegerField(
|
||||
help_text="Starting unit count for this tier"
|
||||
),
|
||||
),
|
||||
(
|
||||
"discount_percent",
|
||||
models.DecimalField(
|
||||
decimal_places=2,
|
||||
help_text="Percentage discount applied (0-100)",
|
||||
max_digits=5,
|
||||
),
|
||||
),
|
||||
(
|
||||
"discount_model",
|
||||
models.ForeignKey(
|
||||
on_delete=django.db.models.deletion.CASCADE,
|
||||
related_name="tiers",
|
||||
to="services.progressivediscountmodel",
|
||||
),
|
||||
),
|
||||
],
|
||||
options={
|
||||
"ordering": ["threshold"],
|
||||
"unique_together": {("discount_model", "threshold")},
|
||||
},
|
||||
),
|
||||
]
|
|
@ -0,0 +1,77 @@
|
|||
# Generated by Django 5.2 on 2025-05-23 14:32
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
def delete_all_discount_tiers(apps, schema_editor):
|
||||
DiscountTier = apps.get_model("services", "DiscountTier")
|
||||
DiscountTier.objects.all().delete()
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
(
|
||||
"services",
|
||||
"0026_progressivediscountmodel_vshnappcatprice_valid_from_and_more",
|
||||
),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.RunPython(delete_all_discount_tiers),
|
||||
migrations.AlterModelOptions(
|
||||
name="category",
|
||||
options={
|
||||
"ordering": ["order", "name"],
|
||||
"verbose_name": "Service Category",
|
||||
"verbose_name_plural": "Service Categories",
|
||||
},
|
||||
),
|
||||
migrations.AlterModelOptions(
|
||||
name="discounttier",
|
||||
options={"ordering": ["min_units"]},
|
||||
),
|
||||
migrations.AlterModelOptions(
|
||||
name="progressivediscountmodel",
|
||||
options={"verbose_name": "Discount Model"},
|
||||
),
|
||||
migrations.AlterModelOptions(
|
||||
name="vshnappcatbasefee",
|
||||
options={"ordering": ["currency"], "verbose_name": "Service Base Fee"},
|
||||
),
|
||||
migrations.AlterModelOptions(
|
||||
name="vshnappcatprice",
|
||||
options={"verbose_name": "AppCat Price"},
|
||||
),
|
||||
migrations.AlterModelOptions(
|
||||
name="vshnappcatunitrate",
|
||||
options={
|
||||
"ordering": ["currency", "service_level"],
|
||||
"verbose_name": "Service Unit Rate",
|
||||
},
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="discounttier",
|
||||
name="min_units",
|
||||
field=models.PositiveIntegerField(
|
||||
default=0, help_text="Minimum unit count for this tier (inclusive)"
|
||||
),
|
||||
),
|
||||
migrations.AlterUniqueTogether(
|
||||
name="discounttier",
|
||||
unique_together={("discount_model", "min_units")},
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="discounttier",
|
||||
name="max_units",
|
||||
field=models.PositiveIntegerField(
|
||||
blank=True,
|
||||
help_text="Maximum unit count for this tier (exclusive). Leave blank for unlimited.",
|
||||
null=True,
|
||||
),
|
||||
),
|
||||
migrations.RemoveField(
|
||||
model_name="discounttier",
|
||||
name="threshold",
|
||||
),
|
||||
]
|
|
@ -0,0 +1,52 @@
|
|||
# Generated by Django 5.2 on 2025-05-23 14:45
|
||||
|
||||
import django.db.models.deletion
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("services", "0027_alter_category_options_alter_discounttier_options_and_more"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name="ComputePlanGroup",
|
||||
fields=[
|
||||
(
|
||||
"id",
|
||||
models.BigAutoField(
|
||||
auto_created=True,
|
||||
primary_key=True,
|
||||
serialize=False,
|
||||
verbose_name="ID",
|
||||
),
|
||||
),
|
||||
("name", models.CharField(max_length=200)),
|
||||
("description", models.TextField(blank=True)),
|
||||
(
|
||||
"node_label",
|
||||
models.CharField(
|
||||
help_text="Kubernetes node label for this group", max_length=100
|
||||
),
|
||||
),
|
||||
("order", models.IntegerField(default=0)),
|
||||
],
|
||||
options={
|
||||
"ordering": ["name"],
|
||||
},
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="computeplan",
|
||||
name="group",
|
||||
field=models.ForeignKey(
|
||||
blank=True,
|
||||
help_text="Group this compute plan belongs to",
|
||||
null=True,
|
||||
on_delete=django.db.models.deletion.SET_NULL,
|
||||
related_name="compute_plans",
|
||||
to="services.computeplangroup",
|
||||
),
|
||||
),
|
||||
]
|
|
@ -0,0 +1,17 @@
|
|||
# Generated by Django 5.2 on 2025-05-23 14:52
|
||||
|
||||
from django.db import migrations
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("services", "0028_computeplangroup_computeplan_group"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterModelOptions(
|
||||
name="computeplangroup",
|
||||
options={"ordering": ["order", "name"]},
|
||||
),
|
||||
]
|
23
hub/services/migrations/0030_serviceoffering_msp.py
Normal file
23
hub/services/migrations/0030_serviceoffering_msp.py
Normal file
|
@ -0,0 +1,23 @@
|
|||
# Generated by Django 5.2 on 2025-05-23 15:23
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("services", "0029_alter_computeplangroup_options"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name="serviceoffering",
|
||||
name="msp",
|
||||
field=models.CharField(
|
||||
choices=[("VS", "VSHN")],
|
||||
default="VS",
|
||||
max_length=2,
|
||||
verbose_name="Managed Service Provider",
|
||||
),
|
||||
),
|
||||
]
|
127
hub/services/migrations/0031_externalpriceplans.py
Normal file
127
hub/services/migrations/0031_externalpriceplans.py
Normal file
|
@ -0,0 +1,127 @@
|
|||
# Generated by Django 5.2 on 2025-05-27 14:52
|
||||
|
||||
import django.db.models.deletion
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("services", "0030_serviceoffering_msp"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name="ExternalPricePlans",
|
||||
fields=[
|
||||
(
|
||||
"id",
|
||||
models.BigAutoField(
|
||||
auto_created=True,
|
||||
primary_key=True,
|
||||
serialize=False,
|
||||
verbose_name="ID",
|
||||
),
|
||||
),
|
||||
("plan_name", models.CharField()),
|
||||
(
|
||||
"description",
|
||||
models.CharField(blank=True, max_length=200, null=True),
|
||||
),
|
||||
("source", models.URLField(blank=True, null=True)),
|
||||
("date_retrieved", models.DateField(blank=True, null=True)),
|
||||
(
|
||||
"currency",
|
||||
models.CharField(
|
||||
choices=[
|
||||
("CHF", "Swiss Franc"),
|
||||
("EUR", "Euro"),
|
||||
("USD", "US Dollar"),
|
||||
],
|
||||
default="CHF",
|
||||
max_length=3,
|
||||
),
|
||||
),
|
||||
(
|
||||
"term",
|
||||
models.CharField(
|
||||
choices=[
|
||||
("MTH", "per Month (30d)"),
|
||||
("DAY", "per Day"),
|
||||
("HR", "per Hour"),
|
||||
("MIN", "per Minute"),
|
||||
],
|
||||
default="MTH",
|
||||
max_length=3,
|
||||
),
|
||||
),
|
||||
(
|
||||
"amount",
|
||||
models.DecimalField(
|
||||
decimal_places=4,
|
||||
help_text="Price per unit in the specified currency, excl. VAT",
|
||||
max_digits=10,
|
||||
),
|
||||
),
|
||||
(
|
||||
"vcpus",
|
||||
models.FloatField(
|
||||
blank=True, help_text="Number of included vCPUs", null=True
|
||||
),
|
||||
),
|
||||
(
|
||||
"ram",
|
||||
models.FloatField(
|
||||
blank=True, help_text="Amount of GiB RAM included", null=True
|
||||
),
|
||||
),
|
||||
(
|
||||
"storage",
|
||||
models.FloatField(
|
||||
blank=True, help_text="Amount of GiB included", null=True
|
||||
),
|
||||
),
|
||||
("competitor_sla", models.CharField(blank=True, null=True)),
|
||||
("replicas", models.IntegerField(blank=True, null=True)),
|
||||
(
|
||||
"cloud_provider",
|
||||
models.ForeignKey(
|
||||
on_delete=django.db.models.deletion.CASCADE,
|
||||
related_name="external_price",
|
||||
to="services.cloudprovider",
|
||||
),
|
||||
),
|
||||
(
|
||||
"compare_to",
|
||||
models.ManyToManyField(
|
||||
blank=True,
|
||||
null=True,
|
||||
related_name="external_prices",
|
||||
to="services.computeplan",
|
||||
),
|
||||
),
|
||||
(
|
||||
"service",
|
||||
models.ForeignKey(
|
||||
on_delete=django.db.models.deletion.CASCADE,
|
||||
related_name="external_price",
|
||||
to="services.service",
|
||||
),
|
||||
),
|
||||
(
|
||||
"vshn_appcat_price",
|
||||
models.ForeignKey(
|
||||
blank=True,
|
||||
help_text="Specific VSHN AppCat price configuration to compare against",
|
||||
null=True,
|
||||
on_delete=django.db.models.deletion.CASCADE,
|
||||
related_name="external_comparisons",
|
||||
to="services.vshnappcatprice",
|
||||
),
|
||||
),
|
||||
],
|
||||
options={
|
||||
"verbose_name": "External Price",
|
||||
},
|
||||
),
|
||||
]
|
|
@ -0,0 +1,24 @@
|
|||
# Generated by Django 5.2 on 2025-05-27 15:03
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("services", "0031_externalpriceplans"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name="externalpriceplans",
|
||||
name="service_level",
|
||||
field=models.CharField(
|
||||
blank=True,
|
||||
choices=[("BE", "Best Effort"), ("GA", "Guaranteed Availability")],
|
||||
help_text="Service level equivalent for comparison",
|
||||
max_length=2,
|
||||
null=True,
|
||||
),
|
||||
),
|
||||
]
|
|
@ -1,359 +0,0 @@
|
|||
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
|
||||
|
||||
|
||||
def validate_image_size(value):
|
||||
filesize = value.size
|
||||
if filesize > 1 * 1024 * 1024: # 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):
|
||||
name = models.CharField(max_length=100)
|
||||
slug = models.SlugField(unique=True)
|
||||
parent = models.ForeignKey(
|
||||
"self", on_delete=models.CASCADE, null=True, blank=True, related_name="children"
|
||||
)
|
||||
description = models.TextField(blank=True)
|
||||
order = models.IntegerField(default=0)
|
||||
|
||||
class Meta:
|
||||
verbose_name_plural = "Categories"
|
||||
ordering = ["order", "name"]
|
||||
|
||||
def __str__(self):
|
||||
if self.parent:
|
||||
return f"{self.parent} > {self.name}"
|
||||
return self.name
|
||||
|
||||
def save(self, *args, **kwargs):
|
||||
if not self.slug:
|
||||
self.slug = slugify(self.name)
|
||||
super().save(*args, **kwargs)
|
||||
|
||||
@property
|
||||
def full_path(self):
|
||||
path = [self.name]
|
||||
parent = self.parent
|
||||
while parent:
|
||||
path.append(parent.name)
|
||||
parent = parent.parent
|
||||
return " > ".join(reversed(path))
|
||||
|
||||
|
||||
class CloudProvider(models.Model):
|
||||
name = models.CharField(max_length=100)
|
||||
slug = models.SlugField(unique=True)
|
||||
description = ProseEditorField()
|
||||
website = models.URLField()
|
||||
linkedin = models.URLField(blank=True)
|
||||
phone = models.CharField(max_length=25, blank=True, null=True)
|
||||
email = models.EmailField(max_length=254, blank=True, null=True)
|
||||
address = models.TextField(max_length=250, blank=True, null=True)
|
||||
logo = models.ImageField(
|
||||
upload_to="cloud_provider_logos/",
|
||||
validators=[validate_image_size],
|
||||
null=True,
|
||||
blank=True,
|
||||
)
|
||||
order = models.IntegerField(default=0)
|
||||
is_featured = models.BooleanField(default=False)
|
||||
disable_listing = models.BooleanField(default=False)
|
||||
|
||||
class Meta:
|
||||
ordering = ["order"]
|
||||
|
||||
def __str__(self):
|
||||
return self.name
|
||||
|
||||
def save(self, *args, **kwargs):
|
||||
if not self.slug:
|
||||
self.slug = slugify(self.name)
|
||||
super().save(*args, **kwargs)
|
||||
|
||||
def get_absolute_url(self):
|
||||
return reverse("services:provider_detail", kwargs={"slug": self.slug})
|
||||
|
||||
|
||||
class Service(models.Model):
|
||||
name = models.CharField(max_length=200)
|
||||
slug = models.SlugField(max_length=250, unique=True)
|
||||
description = ProseEditorField()
|
||||
tagline = models.TextField(max_length=500, blank=True, null=True)
|
||||
logo = models.ImageField(
|
||||
upload_to="service_logos/",
|
||||
validators=[validate_image_size],
|
||||
null=True,
|
||||
blank=True,
|
||||
)
|
||||
categories = models.ManyToManyField(Category, related_name="services")
|
||||
features = ProseEditorField()
|
||||
is_featured = models.BooleanField(default=False)
|
||||
is_coming_soon = models.BooleanField(default=False)
|
||||
disable_listing = models.BooleanField(default=False)
|
||||
|
||||
created_at = models.DateTimeField(auto_now_add=True)
|
||||
updated_at = models.DateTimeField(auto_now=True)
|
||||
|
||||
def __str__(self):
|
||||
return self.name
|
||||
|
||||
def clean(self):
|
||||
if self.is_featured and self.is_coming_soon:
|
||||
raise ValidationError(
|
||||
"A service cannot be both featured and coming soon simultaneously."
|
||||
)
|
||||
super().clean()
|
||||
|
||||
def save(self, *args, **kwargs):
|
||||
self.clean() # Ensure validation runs on save
|
||||
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()
|
||||
logo = models.ImageField(
|
||||
upload_to="partner_logos/",
|
||||
validators=[validate_image_size],
|
||||
null=True,
|
||||
blank=True,
|
||||
)
|
||||
website = models.URLField(blank=True)
|
||||
linkedin = models.URLField(blank=True)
|
||||
phone = models.CharField(max_length=25, blank=True, null=True)
|
||||
email = models.EmailField(max_length=254, blank=True, null=True)
|
||||
address = models.TextField(max_length=250, blank=True, null=True)
|
||||
|
||||
services = models.ManyToManyField(
|
||||
Service, related_name="consulting_partners", blank=True
|
||||
)
|
||||
cloud_providers = models.ManyToManyField(
|
||||
CloudProvider, related_name="consulting_partners", blank=True
|
||||
)
|
||||
|
||||
order = models.IntegerField(default=0)
|
||||
is_featured = models.BooleanField(default=False)
|
||||
disable_listing = models.BooleanField(default=False)
|
||||
|
||||
created_at = models.DateTimeField(auto_now_add=True)
|
||||
updated_at = models.DateTimeField(auto_now=True)
|
||||
|
||||
class Meta:
|
||||
ordering = ["order"]
|
||||
|
||||
def __str__(self):
|
||||
return self.name
|
||||
|
||||
def save(self, *args, **kwargs):
|
||||
if not self.slug:
|
||||
self.slug = slugify(self.name)
|
||||
super().save(*args, **kwargs)
|
||||
|
||||
def get_absolute_url(self):
|
||||
return reverse("services:partner_detail", kwargs={"slug": self.slug})
|
||||
|
||||
|
||||
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"
|
||||
)
|
||||
description = ProseEditorField(blank=True, null=True)
|
||||
offer_description = models.ForeignKey(
|
||||
ReusableText,
|
||||
on_delete=models.PROTECT,
|
||||
related_name="offer_descriptions",
|
||||
blank=True,
|
||||
null=True,
|
||||
)
|
||||
|
||||
disable_listing = models.BooleanField(default=False)
|
||||
|
||||
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 f"{self.service.name} on {self.cloud_provider.name}"
|
||||
|
||||
def get_absolute_url(self):
|
||||
return reverse(
|
||||
"services:offering_detail",
|
||||
kwargs={
|
||||
"provider_slug": self.cloud_provider.slug,
|
||||
"service_slug": self.service.slug,
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
class Plan(models.Model):
|
||||
name = models.CharField(max_length=100)
|
||||
description = ProseEditorField(blank=True, null=True)
|
||||
pricing = ProseEditorField(blank=True, null=True)
|
||||
plan_description = models.ForeignKey(
|
||||
ReusableText,
|
||||
on_delete=models.PROTECT,
|
||||
related_name="plan_descriptions",
|
||||
blank=True,
|
||||
null=True,
|
||||
)
|
||||
offering = models.ForeignKey(
|
||||
ServiceOffering, on_delete=models.CASCADE, related_name="plans"
|
||||
)
|
||||
|
||||
created_at = models.DateTimeField(auto_now_add=True)
|
||||
updated_at = models.DateTimeField(auto_now=True)
|
||||
|
||||
class Meta:
|
||||
ordering = ["name"]
|
||||
unique_together = [["offering", "name"]]
|
||||
|
||||
def __str__(self):
|
||||
return f"{self.offering} - {self.name}"
|
||||
|
||||
|
||||
class ExternalLinkOffering(models.Model):
|
||||
offering = models.ForeignKey(
|
||||
ServiceOffering, on_delete=models.CASCADE, related_name="external_links"
|
||||
)
|
||||
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)
|
||||
|
||||
class Meta:
|
||||
ordering = ["order", "description"]
|
||||
verbose_name = "External Link"
|
||||
verbose_name_plural = "External Links"
|
||||
|
||||
def __str__(self):
|
||||
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):
|
||||
service = models.ForeignKey(
|
||||
Service, on_delete=models.CASCADE, related_name="external_links"
|
||||
)
|
||||
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)
|
||||
|
||||
class Meta:
|
||||
ordering = ["order", "description"]
|
||||
verbose_name = "External Link"
|
||||
verbose_name_plural = "External Links"
|
||||
|
||||
def __str__(self):
|
||||
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 Lead(models.Model):
|
||||
name = models.CharField(max_length=200)
|
||||
email = models.EmailField()
|
||||
company = models.CharField(max_length=200, null=True, blank=True)
|
||||
phone = models.CharField(max_length=50, null=True, blank=True)
|
||||
message = models.TextField(blank=True, null=True, max_length=1000)
|
||||
odoo_lead_id = models.IntegerField(null=True, blank=True)
|
||||
|
||||
service = models.ForeignKey(
|
||||
Service, on_delete=models.SET_NULL, null=True, blank=True
|
||||
)
|
||||
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)
|
||||
|
||||
created_at = models.DateTimeField(auto_now_add=True)
|
||||
|
||||
def __str__(self):
|
||||
return f"{self.name} - {self.company} ({self.service})"
|
||||
|
||||
|
||||
class WebsiteFaq(models.Model):
|
||||
question = models.CharField(max_length=200)
|
||||
answer = ProseEditorField()
|
||||
order = models.IntegerField(default=0)
|
||||
|
||||
class Meta:
|
||||
ordering = ["order"]
|
||||
|
||||
def __str__(self):
|
||||
return self.question
|
6
hub/services/models/__init__.py
Normal file
6
hub/services/models/__init__.py
Normal file
|
@ -0,0 +1,6 @@
|
|||
from .base import *
|
||||
from .content import *
|
||||
from .leads import *
|
||||
from .pricing import *
|
||||
from .providers import *
|
||||
from .services import *
|
106
hub/services/models/base.py
Normal file
106
hub/services/models/base.py
Normal file
|
@ -0,0 +1,106 @@
|
|||
from django.db import models
|
||||
from django.core.exceptions import ValidationError
|
||||
from django.utils.text import slugify
|
||||
from django_prose_editor.fields import ProseEditorField
|
||||
|
||||
|
||||
def validate_image_size(value):
|
||||
filesize = value.size
|
||||
if filesize > 1 * 1024 * 1024: # 1MB
|
||||
raise ValidationError("Maximum file size is 1MB")
|
||||
|
||||
|
||||
class Currency(models.TextChoices):
|
||||
CHF = "CHF", "Swiss Franc"
|
||||
EUR = "EUR", "Euro"
|
||||
USD = "USD", "US Dollar"
|
||||
|
||||
|
||||
class Term(models.TextChoices):
|
||||
MTH = "MTH", "per Month (30d)"
|
||||
DAY = "DAY", "per Day"
|
||||
HR = "HR", "per Hour"
|
||||
MIN = "MIN", "per Minute"
|
||||
|
||||
|
||||
class Unit(models.TextChoices):
|
||||
GIB = "GIB", "GiB"
|
||||
MIB = "MIB", "MiB"
|
||||
CPU = "CPU", "vCPU"
|
||||
|
||||
|
||||
# This should be a relation, but for now this is good enough :TM:
|
||||
class ManagedServiceProvider(models.TextChoices):
|
||||
VS = "VS", "VSHN"
|
||||
|
||||
|
||||
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):
|
||||
name = models.CharField(max_length=100)
|
||||
slug = models.SlugField(unique=True)
|
||||
parent = models.ForeignKey(
|
||||
"self", on_delete=models.CASCADE, null=True, blank=True, related_name="children"
|
||||
)
|
||||
description = models.TextField(blank=True)
|
||||
order = models.IntegerField(default=0)
|
||||
|
||||
class Meta:
|
||||
verbose_name = "Service Category"
|
||||
verbose_name_plural = "Service Categories"
|
||||
ordering = ["order", "name"]
|
||||
|
||||
def __str__(self):
|
||||
if self.parent:
|
||||
return f"{self.parent} > {self.name}"
|
||||
return self.name
|
||||
|
||||
def save(self, *args, **kwargs):
|
||||
if not self.slug:
|
||||
self.slug = slugify(self.name)
|
||||
super().save(*args, **kwargs)
|
||||
|
||||
@property
|
||||
def full_path(self):
|
||||
path = [self.name]
|
||||
parent = self.parent
|
||||
while parent:
|
||||
path.append(parent.name)
|
||||
parent = parent.parent
|
||||
return " > ".join(reversed(path))
|
14
hub/services/models/content.py
Normal file
14
hub/services/models/content.py
Normal file
|
@ -0,0 +1,14 @@
|
|||
from django.db import models
|
||||
from django_prose_editor.fields import ProseEditorField
|
||||
|
||||
|
||||
class WebsiteFaq(models.Model):
|
||||
question = models.CharField(max_length=200)
|
||||
answer = ProseEditorField()
|
||||
order = models.IntegerField(default=0)
|
||||
|
||||
class Meta:
|
||||
ordering = ["order"]
|
||||
|
||||
def __str__(self):
|
||||
return self.question
|
25
hub/services/models/leads.py
Normal file
25
hub/services/models/leads.py
Normal file
|
@ -0,0 +1,25 @@
|
|||
from django.db import models
|
||||
|
||||
from .services import Service, ServiceOffering, Plan
|
||||
|
||||
|
||||
class Lead(models.Model):
|
||||
name = models.CharField(max_length=200)
|
||||
email = models.EmailField()
|
||||
company = models.CharField(max_length=200, null=True, blank=True)
|
||||
phone = models.CharField(max_length=50, null=True, blank=True)
|
||||
message = models.TextField(blank=True, null=True, max_length=1000)
|
||||
odoo_lead_id = models.IntegerField(null=True, blank=True)
|
||||
|
||||
service = models.ForeignKey(
|
||||
Service, on_delete=models.SET_NULL, null=True, blank=True
|
||||
)
|
||||
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)
|
||||
|
||||
created_at = models.DateTimeField(auto_now_add=True)
|
||||
|
||||
def __str__(self):
|
||||
return f"{self.name} - {self.company} ({self.service})"
|
453
hub/services/models/pricing.py
Normal file
453
hub/services/models/pricing.py
Normal file
|
@ -0,0 +1,453 @@
|
|||
from django.db import models
|
||||
|
||||
from .base import Currency, Term, Unit
|
||||
from .providers import CloudProvider
|
||||
from .services import Service
|
||||
|
||||
|
||||
class ComputePlanGroup(models.Model):
|
||||
name = models.CharField(max_length=200)
|
||||
description = models.TextField(blank=True)
|
||||
node_label = models.CharField(
|
||||
max_length=100, help_text="Kubernetes node label for this group"
|
||||
)
|
||||
order = models.IntegerField(default=0)
|
||||
|
||||
class Meta:
|
||||
ordering = ["order", "name"]
|
||||
|
||||
def __str__(self):
|
||||
return self.name
|
||||
|
||||
|
||||
class ComputePlanPrice(models.Model):
|
||||
compute_plan = models.ForeignKey(
|
||||
"ComputePlan", on_delete=models.CASCADE, related_name="prices"
|
||||
)
|
||||
currency = models.CharField(
|
||||
max_length=3,
|
||||
choices=Currency.choices,
|
||||
)
|
||||
amount = models.DecimalField(
|
||||
max_digits=10,
|
||||
decimal_places=2,
|
||||
help_text="Price in the specified currency, excl. VAT",
|
||||
)
|
||||
|
||||
class Meta:
|
||||
unique_together = ("compute_plan", "currency")
|
||||
ordering = ["currency"]
|
||||
|
||||
def __str__(self):
|
||||
return f"{self.compute_plan.name} - {self.amount} {self.currency}"
|
||||
|
||||
|
||||
class ComputePlan(models.Model):
|
||||
name = models.CharField(max_length=200)
|
||||
vcpus = models.FloatField(help_text="Number of available vCPUs")
|
||||
ram = models.FloatField(help_text="Amount of RAM available")
|
||||
cpu_mem_ratio = models.FloatField(
|
||||
help_text="vCPU to Memory ratio. How much vCPU per GiB RAM is available?"
|
||||
)
|
||||
active = models.BooleanField(default=True, help_text="Is the plan active?")
|
||||
term = models.CharField(
|
||||
max_length=3,
|
||||
default=Term.MTH,
|
||||
choices=Term.choices,
|
||||
)
|
||||
|
||||
cloud_provider = models.ForeignKey(
|
||||
CloudProvider, on_delete=models.CASCADE, related_name="compute_plans"
|
||||
)
|
||||
|
||||
group = models.ForeignKey(
|
||||
ComputePlanGroup,
|
||||
on_delete=models.SET_NULL,
|
||||
null=True,
|
||||
blank=True,
|
||||
related_name="compute_plans",
|
||||
help_text="Group this compute plan belongs to",
|
||||
)
|
||||
|
||||
valid_from = models.DateTimeField(blank=True, null=True)
|
||||
valid_to = models.DateTimeField(blank=True, null=True)
|
||||
|
||||
class Meta:
|
||||
ordering = ["name"]
|
||||
|
||||
def __str__(self):
|
||||
return self.name
|
||||
|
||||
def get_price(self, currency_code: str):
|
||||
try:
|
||||
return self.prices.get(currency=currency_code).amount
|
||||
except ComputePlanPrice.DoesNotExist:
|
||||
return None
|
||||
|
||||
|
||||
class StoragePlanPrice(models.Model):
|
||||
storage_plan = models.ForeignKey(
|
||||
"StoragePlan", on_delete=models.CASCADE, related_name="prices"
|
||||
)
|
||||
currency = models.CharField(
|
||||
max_length=3,
|
||||
choices=Currency.choices,
|
||||
)
|
||||
amount = models.DecimalField(
|
||||
max_digits=10,
|
||||
decimal_places=2,
|
||||
help_text="Price in the specified currency, excl. VAT",
|
||||
)
|
||||
|
||||
class Meta:
|
||||
unique_together = ("storage_plan", "currency")
|
||||
ordering = ["currency"]
|
||||
|
||||
def __str__(self):
|
||||
return f"{self.storage_plan.name} - {self.amount} {self.currency}"
|
||||
|
||||
|
||||
class StoragePlan(models.Model):
|
||||
name = models.CharField(max_length=200)
|
||||
cloud_provider = models.ForeignKey(
|
||||
CloudProvider, on_delete=models.CASCADE, related_name="storage_plans"
|
||||
)
|
||||
term = models.CharField(
|
||||
max_length=3,
|
||||
default=Term.MTH,
|
||||
choices=Term.choices,
|
||||
)
|
||||
unit = models.CharField(
|
||||
max_length=3,
|
||||
default=Unit.GIB,
|
||||
choices=Unit.choices,
|
||||
)
|
||||
|
||||
valid_from = models.DateTimeField(blank=True, null=True)
|
||||
valid_to = models.DateTimeField(blank=True, null=True)
|
||||
|
||||
class Meta:
|
||||
unique_together = ("cloud_provider", "term", "unit", "valid_from", "valid_to")
|
||||
ordering = ["name"]
|
||||
|
||||
def __str__(self):
|
||||
return self.name
|
||||
|
||||
def get_price(self, currency_code: str):
|
||||
try:
|
||||
return self.prices.get(currency=currency_code).amount
|
||||
except StoragePlanPrice.DoesNotExist:
|
||||
return None
|
||||
|
||||
|
||||
class ProgressiveDiscountModel(models.Model):
|
||||
name = models.CharField(max_length=100)
|
||||
description = models.TextField(blank=True)
|
||||
active = models.BooleanField(default=True)
|
||||
|
||||
class Meta:
|
||||
verbose_name = "Discount Model"
|
||||
|
||||
def __str__(self):
|
||||
return self.name
|
||||
|
||||
def calculate_discount(self, base_rate, units):
|
||||
"""Calculate price using progressive percentage discounts."""
|
||||
final_price = 0
|
||||
remaining_units = units
|
||||
|
||||
discount_tiers = self.tiers.all().order_by("min_units")
|
||||
|
||||
for tier in discount_tiers:
|
||||
if remaining_units <= 0:
|
||||
break
|
||||
|
||||
# Determine how many units fall into this tier
|
||||
tier_min = tier.min_units
|
||||
tier_max = tier.max_units if tier.max_units else float("inf")
|
||||
|
||||
# Skip if we haven't reached this tier yet
|
||||
if units < tier_min:
|
||||
continue
|
||||
|
||||
# Calculate units in this tier
|
||||
units_start = max(0, units - remaining_units)
|
||||
units_end = min(units, tier_max if tier.max_units else units)
|
||||
tier_units = max(0, units_end - max(units_start, tier_min - 1))
|
||||
|
||||
if tier_units > 0:
|
||||
discounted_rate = base_rate * (1 - tier.discount_percent / 100)
|
||||
final_price += discounted_rate * tier_units
|
||||
remaining_units -= tier_units
|
||||
|
||||
return final_price
|
||||
|
||||
def get_discount_breakdown(self, base_rate, units):
|
||||
"""Get detailed breakdown of discount calculation."""
|
||||
breakdown = []
|
||||
remaining_units = units
|
||||
|
||||
discount_tiers = self.tiers.all().order_by("min_units")
|
||||
|
||||
for tier in discount_tiers:
|
||||
if remaining_units <= 0:
|
||||
break
|
||||
|
||||
tier_min = tier.min_units
|
||||
tier_max = tier.max_units if tier.max_units else float("inf")
|
||||
|
||||
if units < tier_min:
|
||||
continue
|
||||
|
||||
units_start = max(0, units - remaining_units)
|
||||
units_end = min(units, tier_max if tier.max_units else units)
|
||||
tier_units = max(0, units_end - max(units_start, tier_min - 1))
|
||||
|
||||
if tier_units > 0:
|
||||
discounted_rate = base_rate * (1 - tier.discount_percent / 100)
|
||||
tier_total = discounted_rate * tier_units
|
||||
|
||||
breakdown.append(
|
||||
{
|
||||
"tier_range": f"{tier_min}-{tier_max-1 if tier.max_units else '∞'}",
|
||||
"units": tier_units,
|
||||
"discount_percent": tier.discount_percent,
|
||||
"rate": discounted_rate,
|
||||
"subtotal": tier_total,
|
||||
}
|
||||
)
|
||||
|
||||
remaining_units -= tier_units
|
||||
|
||||
return breakdown
|
||||
|
||||
|
||||
class DiscountTier(models.Model):
|
||||
discount_model = models.ForeignKey(
|
||||
ProgressiveDiscountModel, on_delete=models.CASCADE, related_name="tiers"
|
||||
)
|
||||
min_units = models.PositiveIntegerField(
|
||||
help_text="Minimum unit count for this tier (inclusive)", default=0
|
||||
)
|
||||
max_units = models.PositiveIntegerField(
|
||||
null=True,
|
||||
blank=True,
|
||||
help_text="Maximum unit count for this tier (exclusive). Leave blank for unlimited.",
|
||||
)
|
||||
discount_percent = models.DecimalField(
|
||||
max_digits=5, decimal_places=2, help_text="Percentage discount applied (0-100)"
|
||||
)
|
||||
|
||||
class Meta:
|
||||
ordering = ["min_units"]
|
||||
unique_together = ["discount_model", "min_units"]
|
||||
|
||||
def __str__(self):
|
||||
if self.max_units:
|
||||
return f"{self.discount_model.name}: {self.min_units}-{self.max_units-1} units → {self.discount_percent}% discount"
|
||||
else:
|
||||
return f"{self.discount_model.name}: {self.min_units}+ units → {self.discount_percent}% discount"
|
||||
|
||||
|
||||
class VSHNAppCatBaseFee(models.Model):
|
||||
vshn_appcat_price_config = models.ForeignKey(
|
||||
"VSHNAppCatPrice", on_delete=models.CASCADE, related_name="base_fees"
|
||||
)
|
||||
currency = models.CharField(
|
||||
max_length=3,
|
||||
choices=Currency.choices,
|
||||
)
|
||||
amount = models.DecimalField(
|
||||
max_digits=10,
|
||||
decimal_places=2,
|
||||
help_text="Base fee in the specified currency, excl. VAT",
|
||||
)
|
||||
|
||||
class Meta:
|
||||
verbose_name = "Service Base Fee"
|
||||
unique_together = ("vshn_appcat_price_config", "currency")
|
||||
ordering = ["currency"]
|
||||
|
||||
def __str__(self):
|
||||
return f"{self.vshn_appcat_price_config.service.name} Base Fee - {self.amount} {self.currency}"
|
||||
|
||||
|
||||
class VSHNAppCatPrice(models.Model):
|
||||
class VariableUnit(models.TextChoices):
|
||||
RAM = "RAM", "Memory (RAM)"
|
||||
CPU = "CPU", "CPU (vCPU)"
|
||||
USER = "USR", "Users"
|
||||
|
||||
class ServiceLevel(models.TextChoices):
|
||||
BEST_EFFORT = "BE", "Best Effort"
|
||||
GUARANTEED = "GA", "Guaranteed Availability"
|
||||
|
||||
service = models.ForeignKey(
|
||||
Service, on_delete=models.CASCADE, related_name="vshn_appcat_price"
|
||||
)
|
||||
variable_unit = models.CharField(
|
||||
max_length=3,
|
||||
choices=VariableUnit.choices,
|
||||
default=VariableUnit.RAM,
|
||||
)
|
||||
term = models.CharField(
|
||||
max_length=3,
|
||||
default=Term.MTH,
|
||||
choices=Term.choices,
|
||||
)
|
||||
discount_model = models.ForeignKey(
|
||||
ProgressiveDiscountModel,
|
||||
on_delete=models.SET_NULL,
|
||||
null=True,
|
||||
blank=True,
|
||||
related_name="price_configs",
|
||||
)
|
||||
|
||||
ha_replica_min = models.IntegerField(
|
||||
default=1, help_text="Minimum of replicas for HA"
|
||||
)
|
||||
ha_replica_max = models.IntegerField(
|
||||
default=1, help_text="Maximum supported replicas"
|
||||
)
|
||||
|
||||
valid_from = models.DateTimeField(blank=True, null=True)
|
||||
valid_to = models.DateTimeField(blank=True, null=True)
|
||||
|
||||
class Meta:
|
||||
verbose_name = "AppCat Price"
|
||||
|
||||
def __str__(self):
|
||||
return f"{self.service.name} - {self.get_variable_unit_display()} based pricing"
|
||||
|
||||
def get_base_fee(self, currency_code: str):
|
||||
try:
|
||||
return self.base_fees.get(currency=currency_code).amount
|
||||
except VSHNAppCatBaseFee.DoesNotExist:
|
||||
return None
|
||||
|
||||
def get_unit_rate(self, currency_code: str, service_level: str):
|
||||
try:
|
||||
return self.unit_rates.get(
|
||||
currency=currency_code, service_level=service_level
|
||||
).amount
|
||||
except VSHNAppCatUnitRate.DoesNotExist:
|
||||
return None
|
||||
|
||||
def calculate_final_price(
|
||||
self, currency_code: str, service_level: str, number_of_units: int
|
||||
):
|
||||
base_fee = self.get_base_fee(currency_code)
|
||||
unit_rate = self.get_unit_rate(currency_code, service_level)
|
||||
|
||||
if base_fee is None or unit_rate is None:
|
||||
return None
|
||||
|
||||
if number_of_units < 0:
|
||||
raise ValueError("Number of units cannot be negative")
|
||||
|
||||
# Apply discount model if available
|
||||
if self.discount_model and self.discount_model.active:
|
||||
discounted_price = self.discount_model.calculate_discount(
|
||||
unit_rate, number_of_units
|
||||
)
|
||||
total_price = base_fee + discounted_price
|
||||
else:
|
||||
total_price = base_fee + (unit_rate * number_of_units)
|
||||
|
||||
return total_price
|
||||
|
||||
|
||||
class VSHNAppCatUnitRate(models.Model):
|
||||
vshn_appcat_price_config = models.ForeignKey(
|
||||
VSHNAppCatPrice, on_delete=models.CASCADE, related_name="unit_rates"
|
||||
)
|
||||
currency = models.CharField(
|
||||
max_length=3,
|
||||
choices=Currency.choices,
|
||||
)
|
||||
service_level = models.CharField(
|
||||
max_length=2,
|
||||
choices=VSHNAppCatPrice.ServiceLevel.choices,
|
||||
)
|
||||
amount = models.DecimalField(
|
||||
max_digits=10,
|
||||
decimal_places=4,
|
||||
help_text="Price per unit in the specified currency and service level, excl. VAT",
|
||||
)
|
||||
|
||||
class Meta:
|
||||
verbose_name = "Service Unit Rate"
|
||||
unique_together = ("vshn_appcat_price_config", "currency", "service_level")
|
||||
ordering = ["currency", "service_level"]
|
||||
|
||||
def __str__(self):
|
||||
return f"{self.vshn_appcat_price_config.service.name} - {self.get_service_level_display()} Unit Rate - {self.amount} {self.currency}"
|
||||
|
||||
|
||||
class ExternalPricePlans(models.Model):
|
||||
plan_name = models.CharField()
|
||||
description = models.CharField(max_length=200, blank=True, null=True)
|
||||
source = models.URLField(blank=True, null=True)
|
||||
date_retrieved = models.DateField(blank=True, null=True)
|
||||
|
||||
## Relations
|
||||
cloud_provider = models.ForeignKey(
|
||||
CloudProvider, on_delete=models.CASCADE, related_name="external_price"
|
||||
)
|
||||
service = models.ForeignKey(
|
||||
Service, on_delete=models.CASCADE, related_name="external_price"
|
||||
)
|
||||
compare_to = models.ManyToManyField(
|
||||
ComputePlan, related_name="external_prices", blank=True
|
||||
)
|
||||
vshn_appcat_price = models.ForeignKey(
|
||||
VSHNAppCatPrice,
|
||||
on_delete=models.CASCADE,
|
||||
related_name="external_comparisons",
|
||||
blank=True,
|
||||
null=True,
|
||||
help_text="Specific VSHN AppCat price configuration to compare against",
|
||||
)
|
||||
service_level = models.CharField(
|
||||
max_length=2,
|
||||
choices=VSHNAppCatPrice.ServiceLevel.choices,
|
||||
blank=True,
|
||||
null=True,
|
||||
help_text="Service level equivalent for comparison",
|
||||
)
|
||||
|
||||
## Money
|
||||
currency = models.CharField(
|
||||
max_length=3,
|
||||
default=Currency.CHF,
|
||||
choices=Currency.choices,
|
||||
)
|
||||
term = models.CharField(
|
||||
max_length=3,
|
||||
default=Term.MTH,
|
||||
choices=Term.choices,
|
||||
)
|
||||
amount = models.DecimalField(
|
||||
max_digits=10,
|
||||
decimal_places=4,
|
||||
help_text="Price per unit in the specified currency, excl. VAT",
|
||||
)
|
||||
|
||||
## Offering
|
||||
vcpus = models.FloatField(
|
||||
help_text="Number of included vCPUs", blank=True, null=True
|
||||
)
|
||||
ram = models.FloatField(
|
||||
help_text="Amount of GiB RAM included", blank=True, null=True
|
||||
)
|
||||
storage = models.FloatField(
|
||||
help_text="Amount of GiB included", blank=True, null=True
|
||||
)
|
||||
competitor_sla = models.CharField(blank=True, null=True)
|
||||
replicas = models.IntegerField(blank=True, null=True)
|
||||
|
||||
class Meta:
|
||||
verbose_name = "External Price"
|
||||
|
||||
def __str__(self):
|
||||
return f"{self.cloud_provider.name} - {self.service.name} - {self.plan_name}"
|
85
hub/services/models/providers.py
Normal file
85
hub/services/models/providers.py
Normal file
|
@ -0,0 +1,85 @@
|
|||
from django.db import models
|
||||
from django.urls import reverse
|
||||
from django.utils.text import slugify
|
||||
from django_prose_editor.fields import ProseEditorField
|
||||
|
||||
from .base import validate_image_size
|
||||
|
||||
|
||||
class CloudProvider(models.Model):
|
||||
name = models.CharField(max_length=100)
|
||||
slug = models.SlugField(unique=True)
|
||||
description = ProseEditorField()
|
||||
website = models.URLField()
|
||||
linkedin = models.URLField(blank=True)
|
||||
phone = models.CharField(max_length=25, blank=True, null=True)
|
||||
email = models.EmailField(max_length=254, blank=True, null=True)
|
||||
address = models.TextField(max_length=250, blank=True, null=True)
|
||||
logo = models.ImageField(
|
||||
upload_to="cloud_provider_logos/",
|
||||
validators=[validate_image_size],
|
||||
null=True,
|
||||
blank=True,
|
||||
)
|
||||
order = models.IntegerField(default=0)
|
||||
is_featured = models.BooleanField(default=False)
|
||||
disable_listing = models.BooleanField(default=False)
|
||||
|
||||
class Meta:
|
||||
ordering = ["order"]
|
||||
|
||||
def __str__(self):
|
||||
return self.name
|
||||
|
||||
def save(self, *args, **kwargs):
|
||||
if not self.slug:
|
||||
self.slug = slugify(self.name)
|
||||
super().save(*args, **kwargs)
|
||||
|
||||
def get_absolute_url(self):
|
||||
return reverse("services:provider_detail", kwargs={"slug": self.slug})
|
||||
|
||||
|
||||
class ConsultingPartner(models.Model):
|
||||
name = models.CharField(max_length=200)
|
||||
slug = models.SlugField(unique=True)
|
||||
description = ProseEditorField()
|
||||
logo = models.ImageField(
|
||||
upload_to="partner_logos/",
|
||||
validators=[validate_image_size],
|
||||
null=True,
|
||||
blank=True,
|
||||
)
|
||||
website = models.URLField(blank=True)
|
||||
linkedin = models.URLField(blank=True)
|
||||
phone = models.CharField(max_length=25, blank=True, null=True)
|
||||
email = models.EmailField(max_length=254, blank=True, null=True)
|
||||
address = models.TextField(max_length=250, blank=True, null=True)
|
||||
|
||||
services = models.ManyToManyField(
|
||||
"services.Service", related_name="consulting_partners", blank=True
|
||||
)
|
||||
cloud_providers = models.ManyToManyField(
|
||||
CloudProvider, related_name="consulting_partners", blank=True
|
||||
)
|
||||
|
||||
order = models.IntegerField(default=0)
|
||||
is_featured = models.BooleanField(default=False)
|
||||
disable_listing = models.BooleanField(default=False)
|
||||
|
||||
created_at = models.DateTimeField(auto_now_add=True)
|
||||
updated_at = models.DateTimeField(auto_now=True)
|
||||
|
||||
class Meta:
|
||||
ordering = ["order"]
|
||||
|
||||
def __str__(self):
|
||||
return self.name
|
||||
|
||||
def save(self, *args, **kwargs):
|
||||
if not self.slug:
|
||||
self.slug = slugify(self.name)
|
||||
super().save(*args, **kwargs)
|
||||
|
||||
def get_absolute_url(self):
|
||||
return reverse("services:partner_detail", kwargs={"slug": self.slug})
|
175
hub/services/models/services.py
Normal file
175
hub/services/models/services.py
Normal file
|
@ -0,0 +1,175 @@
|
|||
from django.db import models
|
||||
from django.core.exceptions import ValidationError
|
||||
from django.core.validators import URLValidator
|
||||
from django.urls import reverse
|
||||
from django.utils.text import slugify
|
||||
from django_prose_editor.fields import ProseEditorField
|
||||
|
||||
from .base import Category, ReusableText, ManagedServiceProvider, validate_image_size
|
||||
from .providers import CloudProvider
|
||||
|
||||
|
||||
class Service(models.Model):
|
||||
name = models.CharField(max_length=200)
|
||||
slug = models.SlugField(max_length=250, unique=True)
|
||||
description = ProseEditorField()
|
||||
tagline = models.TextField(max_length=500, blank=True, null=True)
|
||||
logo = models.ImageField(
|
||||
upload_to="service_logos/",
|
||||
validators=[validate_image_size],
|
||||
null=True,
|
||||
blank=True,
|
||||
)
|
||||
categories = models.ManyToManyField(Category, related_name="services")
|
||||
features = ProseEditorField()
|
||||
is_featured = models.BooleanField(default=False)
|
||||
is_coming_soon = models.BooleanField(default=False)
|
||||
disable_listing = models.BooleanField(default=False)
|
||||
|
||||
created_at = models.DateTimeField(auto_now_add=True)
|
||||
updated_at = models.DateTimeField(auto_now=True)
|
||||
|
||||
def __str__(self):
|
||||
return self.name
|
||||
|
||||
def clean(self):
|
||||
if self.is_featured and self.is_coming_soon:
|
||||
raise ValidationError(
|
||||
"A service cannot be both featured and coming soon simultaneously."
|
||||
)
|
||||
super().clean()
|
||||
|
||||
def save(self, *args, **kwargs):
|
||||
self.clean() # Ensure validation runs on save
|
||||
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 ServiceOffering(models.Model):
|
||||
service = models.ForeignKey(
|
||||
Service, on_delete=models.CASCADE, related_name="offerings"
|
||||
)
|
||||
msp = models.CharField(
|
||||
"Managed Service Provider",
|
||||
max_length=2,
|
||||
default=ManagedServiceProvider.VS,
|
||||
choices=ManagedServiceProvider.choices,
|
||||
)
|
||||
cloud_provider = models.ForeignKey(
|
||||
CloudProvider, on_delete=models.CASCADE, related_name="offerings"
|
||||
)
|
||||
description = ProseEditorField(blank=True, null=True)
|
||||
offer_description = models.ForeignKey(
|
||||
ReusableText,
|
||||
on_delete=models.PROTECT,
|
||||
related_name="offer_descriptions",
|
||||
blank=True,
|
||||
null=True,
|
||||
)
|
||||
|
||||
disable_listing = models.BooleanField(default=False)
|
||||
|
||||
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 f"{self.service.name} on {self.cloud_provider.name}"
|
||||
|
||||
def get_absolute_url(self):
|
||||
return reverse(
|
||||
"services:offering_detail",
|
||||
kwargs={
|
||||
"provider_slug": self.cloud_provider.slug,
|
||||
"service_slug": self.service.slug,
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
class Plan(models.Model):
|
||||
name = models.CharField(max_length=100)
|
||||
description = ProseEditorField(blank=True, null=True)
|
||||
pricing = ProseEditorField(blank=True, null=True)
|
||||
plan_description = models.ForeignKey(
|
||||
ReusableText,
|
||||
on_delete=models.PROTECT,
|
||||
related_name="plan_descriptions",
|
||||
blank=True,
|
||||
null=True,
|
||||
)
|
||||
offering = models.ForeignKey(
|
||||
ServiceOffering, on_delete=models.CASCADE, related_name="plans"
|
||||
)
|
||||
|
||||
created_at = models.DateTimeField(auto_now_add=True)
|
||||
updated_at = models.DateTimeField(auto_now=True)
|
||||
|
||||
class Meta:
|
||||
ordering = ["name"]
|
||||
unique_together = [["offering", "name"]]
|
||||
|
||||
def __str__(self):
|
||||
return f"{self.offering} - {self.name}"
|
||||
|
||||
|
||||
class ExternalLinkOffering(models.Model):
|
||||
offering = models.ForeignKey(
|
||||
ServiceOffering, on_delete=models.CASCADE, related_name="external_links"
|
||||
)
|
||||
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)
|
||||
|
||||
class Meta:
|
||||
ordering = ["order", "description"]
|
||||
verbose_name = "External Link"
|
||||
verbose_name_plural = "External Links"
|
||||
|
||||
def __str__(self):
|
||||
return f"{self.description} ({self.url})"
|
||||
|
||||
def clean(self):
|
||||
validate = URLValidator()
|
||||
try:
|
||||
validate(self.url)
|
||||
except ValidationError:
|
||||
raise ValidationError({"url": "Enter a valid URL."})
|
||||
|
||||
|
||||
class ExternalLink(models.Model):
|
||||
service = models.ForeignKey(
|
||||
Service, on_delete=models.CASCADE, related_name="external_links"
|
||||
)
|
||||
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)
|
||||
|
||||
class Meta:
|
||||
ordering = ["order", "description"]
|
||||
verbose_name = "External Link"
|
||||
verbose_name_plural = "External Links"
|
||||
|
||||
def __str__(self):
|
||||
return f"{self.description} ({self.url})"
|
||||
|
||||
def clean(self):
|
||||
validate = URLValidator()
|
||||
try:
|
||||
validate(self.url)
|
||||
except ValidationError:
|
||||
raise ValidationError({"url": "Enter a valid URL."})
|
|
@ -12339,3 +12339,194 @@ a.btn:focus {
|
|||
position: relative;
|
||||
z-index: 2;
|
||||
}
|
||||
|
||||
/* Accordion styles */
|
||||
.accordion {
|
||||
--bs-accordion-color: var(--bs-body-color);
|
||||
--bs-accordion-bg: var(--bs-body-bg);
|
||||
--bs-accordion-transition: color 0.15s ease-in-out, background-color 0.15s ease-in-out, border-color 0.15s ease-in-out, box-shadow 0.15s ease-in-out, border-radius 0.15s ease;
|
||||
--bs-accordion-border-color: var(--bs-border-color);
|
||||
--bs-accordion-border-width: var(--bs-border-width);
|
||||
--bs-accordion-border-radius: var(--bs-border-radius);
|
||||
--bs-accordion-inner-border-radius: calc(var(--bs-border-radius) - var(--bs-border-width));
|
||||
--bs-accordion-btn-padding-x: 1.25rem;
|
||||
--bs-accordion-btn-padding-y: 1rem;
|
||||
--bs-accordion-btn-color: var(--bs-body-color);
|
||||
--bs-accordion-btn-bg: var(--bs-accordion-bg);
|
||||
--bs-accordion-btn-icon: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16' fill='%23212529'%3e%3cpath fill-rule='evenodd' d='M1.646 4.646a.5.5 0 0 1 .708 0L8 10.293l5.646-5.647a.5.5 0 0 1 .708.708l-6 6a.5.5 0 0 1-.708 0l-6-6a.5.5 0 0 1 0-.708z'/%3e%3c/svg%3e");
|
||||
--bs-accordion-btn-icon-width: 1.25rem;
|
||||
--bs-accordion-btn-icon-transform: rotate(-180deg);
|
||||
--bs-accordion-btn-icon-transition: transform 0.2s ease-in-out;
|
||||
--bs-accordion-btn-active-icon: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16' fill='%23052c65'%3e%3cpath fill-rule='evenodd' d='M1.646 4.646a.5.5 0 0 1 .708 0L8 10.293l5.646-5.647a.5.5 0 0 1 .708.708l-6 6a.5.5 0 0 1-.708 0l-6-6a.5.5 0 0 1 0-.708z'/%3e%3c/svg%3e");
|
||||
--bs-accordion-btn-focus-border-color: #86b7fe;
|
||||
--bs-accordion-btn-focus-box-shadow: 0 0 0 0.25rem rgba(13, 110, 253, 0.25);
|
||||
--bs-accordion-body-padding-x: 1.25rem;
|
||||
--bs-accordion-body-padding-y: 1rem;
|
||||
--bs-accordion-active-color: var(--bs-primary-text-emphasis);
|
||||
--bs-accordion-active-bg: var(--bs-primary-bg-subtle);
|
||||
}
|
||||
|
||||
.accordion-button {
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
width: 100%;
|
||||
padding: var(--bs-accordion-btn-padding-y) var(--bs-accordion-btn-padding-x);
|
||||
font-size: 1rem;
|
||||
color: var(--bs-accordion-btn-color);
|
||||
text-align: left;
|
||||
background-color: var(--bs-accordion-btn-bg);
|
||||
border: 0;
|
||||
border-radius: 0;
|
||||
overflow-anchor: none;
|
||||
transition: var(--bs-accordion-transition);
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.accordion-button {
|
||||
transition: none;
|
||||
}
|
||||
}
|
||||
|
||||
.accordion-button:not(.collapsed) {
|
||||
color: var(--bs-accordion-active-color);
|
||||
background-color: var(--bs-accordion-active-bg);
|
||||
box-shadow: inset 0 calc(-1 * var(--bs-accordion-border-width)) 0 var(--bs-accordion-border-color);
|
||||
}
|
||||
|
||||
.accordion-button:not(.collapsed):after {
|
||||
background-image: var(--bs-accordion-btn-active-icon);
|
||||
transform: var(--bs-accordion-btn-icon-transform);
|
||||
}
|
||||
|
||||
.accordion-button:after {
|
||||
flex-shrink: 0;
|
||||
width: var(--bs-accordion-btn-icon-width);
|
||||
height: var(--bs-accordion-btn-icon-width);
|
||||
margin-left: auto;
|
||||
content: "";
|
||||
background-image: var(--bs-accordion-btn-icon);
|
||||
background-repeat: no-repeat;
|
||||
background-size: var(--bs-accordion-btn-icon-width);
|
||||
transition: var(--bs-accordion-btn-icon-transition);
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.accordion-button:after {
|
||||
transition: none;
|
||||
}
|
||||
}
|
||||
|
||||
.accordion-button:hover {
|
||||
z-index: 2;
|
||||
}
|
||||
|
||||
.accordion-button:focus {
|
||||
z-index: 3;
|
||||
border-color: var(--bs-accordion-btn-focus-border-color);
|
||||
outline: 0;
|
||||
box-shadow: var(--bs-accordion-btn-focus-box-shadow);
|
||||
}
|
||||
|
||||
.accordion-header {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.accordion-item {
|
||||
color: var(--bs-accordion-color);
|
||||
background-color: var(--bs-accordion-bg);
|
||||
border: var(--bs-accordion-border-width) solid var(--bs-accordion-border-color);
|
||||
}
|
||||
|
||||
.accordion-item:first-of-type {
|
||||
border-top-left-radius: var(--bs-accordion-border-radius);
|
||||
border-top-right-radius: var(--bs-accordion-border-radius);
|
||||
}
|
||||
|
||||
.accordion-item:first-of-type .accordion-button {
|
||||
border-top-left-radius: var(--bs-accordion-inner-border-radius);
|
||||
border-top-right-radius: var(--bs-accordion-inner-border-radius);
|
||||
}
|
||||
|
||||
.accordion-item:not(:first-of-type) {
|
||||
border-top: 0;
|
||||
}
|
||||
|
||||
.accordion-item:last-of-type {
|
||||
border-bottom-right-radius: var(--bs-accordion-border-radius);
|
||||
border-bottom-left-radius: var(--bs-accordion-border-radius);
|
||||
}
|
||||
|
||||
.accordion-item:last-of-type .accordion-button.collapsed {
|
||||
border-bottom-right-radius: var(--bs-accordion-inner-border-radius);
|
||||
border-bottom-left-radius: var(--bs-accordion-inner-border-radius);
|
||||
}
|
||||
|
||||
.accordion-item:last-of-type .accordion-collapse {
|
||||
border-bottom-right-radius: var(--bs-accordion-border-radius);
|
||||
border-bottom-left-radius: var(--bs-accordion-border-radius);
|
||||
}
|
||||
|
||||
.accordion-body {
|
||||
padding: var(--bs-accordion-body-padding-y) var(--bs-accordion-body-padding-x);
|
||||
}
|
||||
|
||||
.accordion-flush .accordion-collapse {
|
||||
border-width: 0;
|
||||
}
|
||||
|
||||
.accordion-flush .accordion-item {
|
||||
border-right: 0;
|
||||
border-left: 0;
|
||||
border-radius: 0;
|
||||
}
|
||||
|
||||
.accordion-flush .accordion-item:first-child {
|
||||
border-top: 0;
|
||||
}
|
||||
|
||||
.accordion-flush .accordion-item:last-child {
|
||||
border-bottom: 0;
|
||||
}
|
||||
|
||||
.accordion-flush .accordion-item .accordion-button,
|
||||
.accordion-flush .accordion-item .accordion-button.collapsed {
|
||||
border-radius: 0;
|
||||
}
|
||||
|
||||
[data-bs-theme=dark] .accordion-button:not(.collapsed) {
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
[data-bs-theme=dark] .accordion-button::after {
|
||||
--bs-accordion-btn-icon: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16' fill='%236ea8fe'%3e%3cpath fill-rule='evenodd' d='M1.646 4.646a.5.5 0 0 1 .708 0L8 10.293l5.646-5.647a.5.5 0 0 1 .708.708l-6 6a.5.5 0 0 1-.708 0l-6-6a.5.5 0 0 1 0-.708z'/%3e%3c/svg%3e");
|
||||
--bs-accordion-btn-active-icon: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16' fill='%236ea8fe'%3e%3cpath fill-rule='evenodd' d='M1.646 4.646a.5.5 0 0 1 .708 0L8 10.293l5.646-5.647a.5.5 0 0 1 .708.708l-6 6a.5.5 0 0 1-.708 0l-6-6a.5.5 0 0 1 0-.708z'/%3e%3c/svg%3e");
|
||||
}
|
||||
|
||||
.accordion-button:not(.collapsed) {
|
||||
color: white !important;
|
||||
}
|
||||
|
||||
.accordion-button:not(.collapsed)::after {
|
||||
background-image: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16' fill='%23ffffff'%3e%3cpath fill-rule='evenodd' d='M1.646 4.646a.5.5 0 0 1 .708 0L8 10.293l5.646-5.647a.5.5 0 0 1 .708.708l-6 6a.5.5 0 0 1-.708 0l-6-6a.5.5 0 0 1 0-.708z'/%3e%3c/svg%3e");
|
||||
}
|
||||
|
||||
.pricing-table thead.table-dark {
|
||||
background-color: #160037 !important;
|
||||
}
|
||||
|
||||
.pricing-table .final-price-header {
|
||||
background-color: #9A63EC !important;
|
||||
color: #fff !important;
|
||||
}
|
||||
|
||||
.pricing-table .final-price-cell {
|
||||
background-color: #f9fafb !important;
|
||||
color: #160037 !important;
|
||||
}
|
||||
|
||||
.price-chart {
|
||||
background-color: #f8f9fa;
|
||||
padding: 20px;
|
||||
border-radius: 8px;
|
||||
}
|
20
hub/services/static/js/chart.js
Normal file
20
hub/services/static/js/chart.js
Normal file
File diff suppressed because one or more lines are too long
|
@ -19,7 +19,9 @@
|
|||
<link rel="stylesheet" type="text/css" href='{% static "css/servala-main.css" %}'>
|
||||
{% block extra_css %}{% endblock %}
|
||||
|
||||
{% if not debug %}
|
||||
<script defer data-api="/api/event" data-domain="servala.com" src="/js/script.js"></script>
|
||||
{% endif %}
|
||||
<script defer src="{% static "js/htmx204.min.js" %}"></script>
|
||||
<script defer src="{% static "js/alpine-collapse.min.js" %}"></script>
|
||||
<script defer src="{% static "js/servala-main.js" %}"></script>
|
||||
|
|
|
@ -152,50 +152,121 @@
|
|||
</div>
|
||||
{% endif %}
|
||||
|
||||
<!-- Plans -->
|
||||
{% if offering.plans.all %}
|
||||
<!-- Plans or Service Plans -->
|
||||
<div class="pt-24" id="plans" style="scroll-margin-top: 30px;">
|
||||
<h3 class="fs-24 fw-semibold lh-1 mb-12">Available Plans</h3>
|
||||
<div class="row">
|
||||
{% 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="bg-purple-50 rounded-16 border-all p-24">
|
||||
<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>
|
||||
{% if plan.plan_description %}
|
||||
<div class="text-black mb-20">
|
||||
{{ plan.plan_description.text|safe }}
|
||||
{% if offering.msp == "VS" and pricing_data_by_group_and_service_level %}
|
||||
<!-- Service Plans with Pricing Data -->
|
||||
<h3 class="fs-24 fw-semibold lh-1 mb-12">Service Plans</h3>
|
||||
<div class="accordion" id="servicePlansAccordion">
|
||||
{% for group_name, service_levels in pricing_data_by_group_and_service_level.items %}
|
||||
<div class="accordion-item">
|
||||
<h2 class="accordion-header" id="heading{{ forloop.counter }}">
|
||||
<button class="accordion-button{% if not forloop.first %} collapsed{% endif %}" type="button" data-bs-toggle="collapse" data-bs-target="#collapse{{ forloop.counter }}" aria-expanded="{% if forloop.first %}true{% else %}false{% endif %}" aria-controls="collapse{{ forloop.counter }}">
|
||||
<strong>{{ group_name }}</strong>
|
||||
</button>
|
||||
</h2>
|
||||
<div id="collapse{{ forloop.counter }}" class="accordion-collapse collapse{% if forloop.first %} show{% endif %}" aria-labelledby="heading{{ forloop.counter }}" data-bs-parent="#servicePlansAccordion">
|
||||
<div class="accordion-body">
|
||||
{% comment %} Display group description from first available plan {% endcomment %}
|
||||
{% for service_level, pricing_data in service_levels.items %}
|
||||
{% if pricing_data and forloop.first %}
|
||||
{% with pricing_data.0 as representative_plan %}
|
||||
{% if representative_plan.compute_plan_group_description %}
|
||||
<p class="text-muted mb-3">{{ representative_plan.compute_plan_group_description }}</p>
|
||||
{% endif %}
|
||||
{% endwith %}
|
||||
{% endif %}
|
||||
{% if forloop.first %}
|
||||
{% comment %} Only show description for first service level {% endcomment %}
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
|
||||
{% for service_level, pricing_data in service_levels.items %}
|
||||
<div class="mb-4">
|
||||
<h4 class="mb-3 text-primary">{{ service_level }}</h4>
|
||||
{% if pricing_data %}
|
||||
<div class="table-responsive">
|
||||
<table class="table table-striped table-sm">
|
||||
<thead class="table-dark">
|
||||
<tr>
|
||||
<th>Compute Plan</th>
|
||||
<th>vCPUs</th>
|
||||
<th>RAM (GB)</th>
|
||||
<th>Currency</th>
|
||||
<th>Compute Price</th>
|
||||
<th>Service Price</th>
|
||||
<th class="table-warning">Total Price</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for row in pricing_data %}
|
||||
<tr>
|
||||
<td>{{ row.compute_plan }}</td>
|
||||
<td>{{ row.vcpus }}</td>
|
||||
<td>{{ row.ram }}</td>
|
||||
<td>{{ row.currency }}</td>
|
||||
<td>{{ row.compute_plan_price|floatformat:2 }}</td>
|
||||
<td>{{ row.sla_price|floatformat:2 }}</td>
|
||||
<td class="table-warning fw-bold">{{ row.final_price|floatformat:2 }}</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{% else %}
|
||||
<p class="text-muted">No pricing data available for {{ service_level }}.</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% if plan.description %}
|
||||
<div class="text-black mb-20">
|
||||
{{ plan.description|safe }}
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% elif offering.plans.all %}
|
||||
<!-- Traditional Plans -->
|
||||
<h3 class="fs-24 fw-semibold lh-1 mb-12">Available Plans</h3>
|
||||
<div class="row">
|
||||
{% 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="bg-purple-50 rounded-16 border-all p-24">
|
||||
<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>
|
||||
{% if plan.plan_description %}
|
||||
<div class="text-black mb-20">
|
||||
{{ plan.plan_description.text|safe }}
|
||||
</div>
|
||||
{% endif %}
|
||||
{% if plan.description %}
|
||||
<div class="text-black mb-20">
|
||||
{{ plan.description|safe }}
|
||||
</div>
|
||||
{% endif %}
|
||||
{% if plan.pricing %}
|
||||
<div class="text-black mb-20">
|
||||
{{ plan.pricing|safe }}
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endif %}
|
||||
{% if plan.pricing %}
|
||||
<div class="text-black mb-20">
|
||||
{{ plan.pricing|safe }}
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% empty %}
|
||||
<div class="col-12" id="interest" style="scroll-margin-top: 30px;">
|
||||
<div class="alert alert-info">
|
||||
<p>No plans available yet.</p>
|
||||
<h4 class="mb-3">I'm interested in this offering</h4>
|
||||
{% embedded_contact_form source="Offering Interest" service=offering.service offering_id=offering.id %}
|
||||
{% empty %}
|
||||
<div class="col-12" id="interest" style="scroll-margin-top: 30px;">
|
||||
<div class="alert alert-info">
|
||||
<p>No plans available yet.</p>
|
||||
<h4 class="mb-3">I'm interested in this offering</h4>
|
||||
{% embedded_contact_form source="Offering Interest" service=offering.service offering_id=offering.id %}
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="col-12" id="interest" style="scroll-margin-top: 30px;">
|
||||
<h4 class="mb-3">I'm interested in this offering</h4>
|
||||
{% load contact_tags %}
|
||||
{% embedded_contact_form source="Offering Interest" service=offering.service offering_id=offering.id %}
|
||||
</div>
|
||||
<!-- No Plans Available -->
|
||||
<div class="col-12" id="interest" style="scroll-margin-top: 30px;">
|
||||
<h4 class="mb-3">I'm interested in this offering</h4>
|
||||
{% load contact_tags %}
|
||||
{% embedded_contact_form source="Offering Interest" service=offering.service offering_id=offering.id %}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% if offering.plans.exists %}
|
||||
|
|
440
hub/services/templates/services/pricelist.html
Normal file
440
hub/services/templates/services/pricelist.html
Normal file
|
@ -0,0 +1,440 @@
|
|||
{% extends 'base.html' %}
|
||||
{% load static %}
|
||||
|
||||
{% block title %}Complete Price List{% endblock %}
|
||||
|
||||
{% block extra_js %}
|
||||
<script src="{% static "js/chart.js" %}"></script>
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="container-fluid mt-4">
|
||||
<div class="row">
|
||||
<div class="col-12">
|
||||
<h1 class="mb-4">Complete Price List - All Service Variants</h1>
|
||||
|
||||
<!-- Filter Form -->
|
||||
<div class="card mb-4">
|
||||
<div class="card-header">
|
||||
<h5 class="mb-0">Filters</h5>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<form method="get" class="row g-3" id="filter-form">
|
||||
<div class="col-md-3">
|
||||
<label for="cloud_provider" class="form-label">Cloud Provider</label>
|
||||
<select name="cloud_provider" id="cloud_provider" class="form-select filter-select">
|
||||
<option value="">All Providers</option>
|
||||
{% for provider in all_cloud_providers %}
|
||||
<option value="{{ provider }}" {% if provider == filter_cloud_provider %}selected{% endif %}>
|
||||
{{ provider }}
|
||||
</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
<label for="service" class="form-label">Service</label>
|
||||
<select name="service" id="service" class="form-select filter-select">
|
||||
<option value="">All Services</option>
|
||||
{% for service in all_services %}
|
||||
<option value="{{ service }}" {% if service == filter_service %}selected{% endif %}>
|
||||
{{ service }}
|
||||
</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
<label for="compute_plan_group" class="form-label">Compute Plan Group</label>
|
||||
<select name="compute_plan_group" id="compute_plan_group" class="form-select filter-select">
|
||||
<option value="">All Groups</option>
|
||||
{% for group in all_compute_plan_groups %}
|
||||
<option value="{{ group }}" {% if group == filter_compute_plan_group %}selected{% endif %}>
|
||||
{{ group }}
|
||||
</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
<label for="service_level" class="form-label">Service Level</label>
|
||||
<select name="service_level" id="service_level" class="form-select filter-select">
|
||||
<option value="">All Service Levels</option>
|
||||
{% for level in all_service_levels %}
|
||||
<option value="{{ level }}" {% if level == filter_service_level %}selected{% endif %}>
|
||||
{{ level }}
|
||||
</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-12">
|
||||
<div class="form-check">
|
||||
<input class="form-check-input" type="checkbox" name="discount_details" value="true" id="discount_details" {% if show_discount_details %}checked{% endif %}>
|
||||
<label class="form-check-label" for="discount_details">
|
||||
Show discount details
|
||||
</label>
|
||||
</div>
|
||||
<div class="form-check">
|
||||
<input class="form-check-input" type="checkbox" name="price_comparison" value="true" id="price_comparison" {% if show_price_comparison %}checked{% endif %}>
|
||||
<label class="form-check-label" for="price_comparison">
|
||||
Show external price comparisons
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-12">
|
||||
<button type="submit" class="btn btn-primary">Apply Filters</button>
|
||||
<a href="{% url 'services:pricelist' %}" class="btn btn-secondary">Clear Filters</a>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Active Filters Display -->
|
||||
{% if filter_cloud_provider or filter_service or filter_compute_plan_group or filter_service_level %}
|
||||
<div class="alert alert-info">
|
||||
<strong>Active Filters:</strong>
|
||||
{% if filter_cloud_provider %}<span class="badge me-1">Cloud Provider: {{ filter_cloud_provider }}</span>{% endif %}
|
||||
{% if filter_service %}<span class="badge me-1">Service: {{ filter_service }}</span>{% endif %}
|
||||
{% if filter_compute_plan_group %}<span class="badge me-1">Group: {{ filter_compute_plan_group }}</span>{% endif %}
|
||||
{% if filter_service_level %}<span class="badge me-1">Service Level: {{ filter_service_level }}</span>{% endif %}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% if pricing_data_by_group_and_service_level %}
|
||||
{% for group_name, service_levels in pricing_data_by_group_and_service_level.items %}
|
||||
<div class="mb-5 border rounded p-3">
|
||||
<h2 class="mb-3 text-primary">{{ group_name }}</h2>
|
||||
|
||||
{# Display group description and node_label from first available plan #}
|
||||
{% for service_level, pricing_data in service_levels.items %}
|
||||
{% if pricing_data and forloop.first %}
|
||||
{% with pricing_data.0 as representative_plan %}
|
||||
{% if representative_plan.compute_plan_group_description %}
|
||||
<p class="text-muted mb-2"><strong>Description:</strong> {{ representative_plan.compute_plan_group_description }}</p>
|
||||
{% endif %}
|
||||
{% if representative_plan.compute_plan_group_node_label %}
|
||||
<p class="text-muted mb-3"><strong>Node Label:</strong> <code>{{ representative_plan.compute_plan_group_node_label }}</code></p>
|
||||
{% endif %}
|
||||
|
||||
{# Display storage pricing for this cloud provider #}
|
||||
{% if representative_plan.storage_plans %}
|
||||
<div class="mb-3">
|
||||
<p class="text-muted mb-2"><strong>Storage Options:</strong></p>
|
||||
<div class="table-responsive">
|
||||
<table class="table table-sm table-bordered">
|
||||
<thead class="table-secondary">
|
||||
<tr>
|
||||
<th>Storage Plan</th>
|
||||
<th>Term</th>
|
||||
<th>Unit</th>
|
||||
<th>Prices</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for storage_plan in representative_plan.storage_plans %}
|
||||
<tr>
|
||||
<td>{{ storage_plan.name }}</td>
|
||||
<td>{{ storage_plan.get_term_display }}</td>
|
||||
<td>{{ storage_plan.get_unit_display }}</td>
|
||||
<td>
|
||||
{% for price in storage_plan.prices.all %}
|
||||
<span class="badge bg-light text-dark me-1">{{ price.amount }} {{ price.currency }}</span>
|
||||
{% empty %}
|
||||
<span class="text-muted">No prices</span>
|
||||
{% endfor %}
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% endwith %}
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
|
||||
{% for service_level, pricing_data in service_levels.items %}
|
||||
<div class="mb-4">
|
||||
<h3 class="mb-3">SLA: {{ service_level }}</h3>
|
||||
{% if pricing_data %}
|
||||
{# Display common values for this service level #}
|
||||
{% with pricing_data.0 as first_row %}
|
||||
<div class="row mb-3">
|
||||
<div class="col-md-2">
|
||||
<strong>Cloud Provider:</strong> {{ first_row.cloud_provider }}
|
||||
</div>
|
||||
<div class="col-md-2">
|
||||
<strong>Service:</strong> {{ first_row.service }}
|
||||
</div>
|
||||
<div class="col-md-2">
|
||||
<strong>CPU/Memory Ratio:</strong> {{ first_row.cpu_mem_ratio }}
|
||||
</div>
|
||||
<div class="col-md-2">
|
||||
<strong>Variable Unit:</strong> {{ first_row.variable_unit }}
|
||||
</div>
|
||||
<div class="col-md-2">
|
||||
<strong>Replica Enforce:</strong> {{ first_row.replica_enforce }}
|
||||
</div>
|
||||
</div>
|
||||
{% endwith %}
|
||||
|
||||
<div class="table-responsive">
|
||||
<table class="table table-striped table-bordered table-sm pricing-table">
|
||||
<thead class="table-dark">
|
||||
<tr>
|
||||
<th>Compute Plan</th>
|
||||
<th>Cloud Provider</th>
|
||||
<th>vCPUs</th>
|
||||
<th>RAM (GB)</th>
|
||||
<th>Term</th>
|
||||
<th>Currency</th>
|
||||
<th>Compute Plan Price</th>
|
||||
<th>Units</th>
|
||||
<th>SLA Base</th>
|
||||
<th>SLA Per Unit</th>
|
||||
<th>SLA Price</th>
|
||||
{% if show_discount_details %}
|
||||
<th>Discount Model</th>
|
||||
<th>Discount Details</th>
|
||||
{% endif %}
|
||||
{% if show_price_comparison %}
|
||||
<th>External Comparisons</th>
|
||||
{% endif %}
|
||||
<th class="final-price-header">Final Price</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for row in pricing_data %}
|
||||
<tr class="servala-row">
|
||||
<td>{{ row.compute_plan }}</td>
|
||||
<td>{{ row.cloud_provider }}</td>
|
||||
<td>{{ row.vcpus }}</td>
|
||||
<td>{{ row.ram }}</td>
|
||||
<td>{{ row.term }}</td>
|
||||
<td>{{ row.currency }}</td>
|
||||
<td>{{ row.compute_plan_price|floatformat:2 }}</td>
|
||||
<td>{{ row.units }}</td>
|
||||
<td>{{ row.sla_base|floatformat:2 }}</td>
|
||||
<td>{{ row.sla_per_unit|floatformat:4 }}</td>
|
||||
<td>{{ row.sla_price|floatformat:2 }}</td>
|
||||
{% if show_discount_details %}
|
||||
<td>
|
||||
{% if row.has_discount %}
|
||||
{{ row.discount_model }}
|
||||
{% else %}
|
||||
None
|
||||
{% endif %}
|
||||
</td>
|
||||
<td>
|
||||
{% if row.has_discount %}
|
||||
<small class="text-muted">
|
||||
<strong>Total Units:</strong> {{ row.total_units }}<br>
|
||||
<strong>Standard Price:</strong> {{ row.standard_sla_price|floatformat:2 }}<br>
|
||||
<strong>Discounted Price:</strong> {{ row.discounted_sla_price|floatformat:2 }}<br>
|
||||
<strong>Savings:</strong> {{ row.discount_savings|floatformat:2 }} ({{ row.discount_percentage|floatformat:1 }}%)<br>
|
||||
{% if row.discount_breakdown %}
|
||||
<strong>Breakdown:</strong><br>
|
||||
{% for tier in row.discount_breakdown %}
|
||||
{{ tier.tier_range }} units: {{ tier.units }} × {{ tier.rate|floatformat:4 }} = {{ tier.subtotal|floatformat:2 }}<br>
|
||||
{% endfor %}
|
||||
{% endif %}
|
||||
</small>
|
||||
{% else %}
|
||||
<small class="text-muted">No discount applied</small>
|
||||
{% endif %}
|
||||
</td>
|
||||
{% endif %}
|
||||
{% if show_price_comparison %}
|
||||
<td>
|
||||
<span class="badge">-</span>
|
||||
</td>
|
||||
{% endif %}
|
||||
<td class="final-price-cell fw-bold">{{ row.final_price|floatformat:2 }}</td>
|
||||
</tr>
|
||||
{% if show_price_comparison and row.external_comparisons %}
|
||||
{% for comparison in row.external_comparisons %}
|
||||
<tr class="table-light comparison-row">
|
||||
<td class="text-muted">{{ comparison.plan_name }}</td>
|
||||
<td class="text-muted">{{ comparison.provider }}</td>
|
||||
<td class="text-muted">
|
||||
{% if comparison.vcpus %}{{ comparison.vcpus }}{% else %}-{% endif %}
|
||||
</td>
|
||||
<td class="text-muted">
|
||||
{% if comparison.ram %}{{ comparison.ram }}{% else %}-{% endif %}
|
||||
</td>
|
||||
<td class="text-muted">{{ row.term }}</td>
|
||||
<td class="text-muted">{{ comparison.currency }}</td>
|
||||
<td class="text-muted">-</td>
|
||||
<td class="text-muted">-</td>
|
||||
<td class="text-muted">-</td>
|
||||
<td class="text-muted">-</td>
|
||||
<td class="text-muted">-</td>
|
||||
{% if show_discount_details %}
|
||||
<td class="text-muted">-</td>
|
||||
<td class="text-muted">-</td>
|
||||
{% endif %}
|
||||
<td>
|
||||
<small>
|
||||
<span class="badge bg-secondary">{% if comparison.source %}<span class="text-muted"><a href="{{ comparison.source }}" target="_blank">{{ comparison.provider }}</a></span>{% else %}{{ comparison.provider }}{% endif %}</span><br>
|
||||
{% if comparison.description %}
|
||||
<span class="text-muted">{{ comparison.description }}</span><br>
|
||||
{% endif %}
|
||||
{% if comparison.storage %}
|
||||
<span class="text-muted">Storage: {{ comparison.storage }} GB</span><br>
|
||||
{% endif %}
|
||||
{% if comparison.replicas %}
|
||||
<span class="text-muted">Replicas: {{ comparison.replicas }}</span><br>
|
||||
{% endif %}
|
||||
{% if comparison.ratio %}
|
||||
<span class="text-muted">Price ratio: {{ comparison.ratio|floatformat:2 }}x</span><br>
|
||||
{% endif %}
|
||||
</small>
|
||||
</td>
|
||||
<td class="fw-bold">
|
||||
{{ comparison.amount|floatformat:2 }} {{ comparison.currency }}
|
||||
{% if comparison.difference > 0 %}
|
||||
<span class="badge bg-danger ms-1">+{{ comparison.difference|floatformat:2 }}</span>
|
||||
{% elif comparison.difference < 0 %}
|
||||
<span class="badge bg-success ms-1">{{ comparison.difference|floatformat:2 }}</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{# Price Chart #}
|
||||
<div class="price-chart mt-3">
|
||||
<h5 class="text-muted">Price Chart - Units vs Final Price</h5>
|
||||
<div style="height: 400px;">
|
||||
<canvas id="chart-{{ group_name|slugify }}-{{ service_level|slugify }}" width="400" height="200"></canvas>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p class="text-muted"><strong>{{ pricing_data|length }}</strong> variants for {{ service_level }} in {{ group_name }}</p>
|
||||
{% else %}
|
||||
<p class="text-muted">No pricing variants available for {{ service_level }} in {{ group_name }}.</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% empty %}
|
||||
<p class="text-muted">No service levels with pricing data found for group: {{ group_name }}.</p>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% endfor %}
|
||||
{% else %}
|
||||
<div class="alert alert-info">
|
||||
<h4>No pricing data available</h4>
|
||||
<p>{% if filter_cloud_provider or filter_service or filter_compute_plan_group or filter_service_level %}No data matches the selected filters. Try adjusting your filter criteria.{% else %}Please ensure you have active compute plans with prices and VSHNAppCat price configurations.{% endif %}</p>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
// Auto-submit form when filter dropdowns change
|
||||
const filterForm = document.getElementById('filter-form');
|
||||
const filterSelects = document.querySelectorAll('.filter-select');
|
||||
const discountCheckbox = document.getElementById('discount_details');
|
||||
|
||||
// Add change event listeners to all filter dropdowns
|
||||
filterSelects.forEach(function(select) {
|
||||
select.addEventListener('change', function() {
|
||||
filterForm.submit();
|
||||
});
|
||||
});
|
||||
|
||||
// Add change event listener to discount details checkbox
|
||||
discountCheckbox.addEventListener('change', function() {
|
||||
filterForm.submit();
|
||||
});
|
||||
|
||||
// Add change event listener to price comparison checkbox
|
||||
const priceComparisonCheckbox = document.getElementById('price_comparison');
|
||||
priceComparisonCheckbox.addEventListener('change', function() {
|
||||
filterForm.submit();
|
||||
});
|
||||
|
||||
// Chart data for each service level
|
||||
{% for group_name, service_levels in pricing_data_by_group_and_service_level.items %}
|
||||
{% for service_level, pricing_data in service_levels.items %}
|
||||
{% if pricing_data %}
|
||||
// Prepare data for {{ group_name }} - {{ service_level }}
|
||||
const chartData{{ forloop.parentloop.counter }}{{ forloop.counter }} = {
|
||||
labels: [{% for row in pricing_data %}{{ row.units }}{% if not forloop.last %}, {% endif %}{% endfor %}],
|
||||
datasets: [
|
||||
{
|
||||
label: 'Final Price',
|
||||
data: [{% for row in pricing_data %}{{ row.final_price|floatformat:2 }}{% if not forloop.last %}, {% endif %}{% endfor %}],
|
||||
borderColor: 'rgb(75, 192, 192)',
|
||||
backgroundColor: 'rgba(75, 192, 192, 0.2)',
|
||||
tension: 0.1,
|
||||
fill: false
|
||||
},
|
||||
{
|
||||
label: 'SLA Price',
|
||||
data: [{% for row in pricing_data %}{{ row.sla_price|floatformat:2 }}{% if not forloop.last %}, {% endif %}{% endfor %}],
|
||||
borderColor: 'rgb(255, 99, 132)',
|
||||
backgroundColor: 'rgba(255, 99, 132, 0.2)',
|
||||
tension: 0.1,
|
||||
fill: false
|
||||
},
|
||||
{
|
||||
label: 'Compute Plan Price',
|
||||
data: [{% for row in pricing_data %}{{ row.compute_plan_price|floatformat:2 }}{% if not forloop.last %}, {% endif %}{% endfor %}],
|
||||
borderColor: 'rgb(54, 162, 235)',
|
||||
backgroundColor: 'rgba(54, 162, 235, 0.2)',
|
||||
tension: 0.1,
|
||||
fill: false
|
||||
}
|
||||
]
|
||||
};
|
||||
|
||||
// Create chart for {{ group_name }} - {{ service_level }}
|
||||
const ctx{{ forloop.parentloop.counter }}{{ forloop.counter }} = document.getElementById('chart-{{ group_name|slugify }}-{{ service_level|slugify }}').getContext('2d');
|
||||
new Chart(ctx{{ forloop.parentloop.counter }}{{ forloop.counter }}, {
|
||||
type: 'line',
|
||||
data: chartData{{ forloop.parentloop.counter }}{{ forloop.counter }},
|
||||
options: {
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
scales: {
|
||||
x: {
|
||||
title: {
|
||||
display: true,
|
||||
text: 'Units'
|
||||
}
|
||||
},
|
||||
y: {
|
||||
title: {
|
||||
display: true,
|
||||
text: 'Price ({{ pricing_data.0.currency|default:"CHF" }})'
|
||||
},
|
||||
beginAtZero: true
|
||||
}
|
||||
},
|
||||
plugins: {
|
||||
title: {
|
||||
display: true,
|
||||
text: '{{ group_name }} - {{ service_level }} Pricing'
|
||||
},
|
||||
legend: {
|
||||
display: true
|
||||
}
|
||||
},
|
||||
elements: {
|
||||
point: {
|
||||
radius: 4,
|
||||
hoverRadius: 6
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
{% endfor %}
|
||||
});
|
||||
</script>
|
||||
{% endblock %}
|
|
@ -22,4 +22,9 @@ urlpatterns = [
|
|||
path("contact/thank-you/", views.thank_you, name="thank_you"),
|
||||
path("contact-form/", views.contact_form, name="contact_form"),
|
||||
path("subscribe/", views.subscribe, name="subscribe"),
|
||||
path(
|
||||
"pricelist/",
|
||||
views.pricelist,
|
||||
name="pricelist",
|
||||
),
|
||||
]
|
||||
|
|
|
@ -5,3 +5,4 @@ from .providers import *
|
|||
from .services import *
|
||||
from .pages import *
|
||||
from .subscriptions import *
|
||||
from .pricelist import *
|
||||
|
|
|
@ -1,6 +1,21 @@
|
|||
from django.shortcuts import render, get_object_or_404
|
||||
from django.db.models import Q
|
||||
from hub.services.models import ServiceOffering, CloudProvider, Category, Service
|
||||
from hub.services.models import (
|
||||
ServiceOffering,
|
||||
CloudProvider,
|
||||
Category,
|
||||
Service,
|
||||
ComputePlan,
|
||||
VSHNAppCatPrice,
|
||||
)
|
||||
import re
|
||||
from collections import defaultdict
|
||||
|
||||
|
||||
def natural_sort_key(name):
|
||||
"""Extract numeric part from compute plan name for natural sorting"""
|
||||
match = re.search(r"compute-std-(\d+)", name)
|
||||
return int(match.group(1)) if match else 0
|
||||
|
||||
|
||||
def offering_list(request):
|
||||
|
@ -64,7 +79,174 @@ def offering_detail(request, provider_slug, service_slug):
|
|||
service__slug=service_slug,
|
||||
)
|
||||
|
||||
pricing_data_by_group_and_service_level = None
|
||||
|
||||
# Generate pricing data for VSHN offerings
|
||||
if offering.msp == "VS":
|
||||
pricing_data_by_group_and_service_level = generate_pricing_data(offering)
|
||||
|
||||
context = {
|
||||
"offering": offering,
|
||||
"pricing_data_by_group_and_service_level": pricing_data_by_group_and_service_level,
|
||||
}
|
||||
return render(request, "services/offering_detail.html", context)
|
||||
|
||||
|
||||
def generate_pricing_data(offering):
|
||||
"""Generate pricing data for a specific offering and cloud provider"""
|
||||
# Fetch compute plans for this cloud provider
|
||||
compute_plans = (
|
||||
ComputePlan.objects.filter(active=True, cloud_provider=offering.cloud_provider)
|
||||
.select_related("cloud_provider", "group")
|
||||
.prefetch_related("prices")
|
||||
.order_by("group__order", "group__name")
|
||||
)
|
||||
|
||||
# Apply natural sorting for compute plan names
|
||||
compute_plans = sorted(
|
||||
compute_plans,
|
||||
key=lambda x: (
|
||||
x.group.order if x.group else 999,
|
||||
x.group.name if x.group else "ZZZ",
|
||||
natural_sort_key(x.name),
|
||||
),
|
||||
)
|
||||
|
||||
# Fetch pricing for this specific service
|
||||
try:
|
||||
appcat_price = (
|
||||
VSHNAppCatPrice.objects.select_related("service", "discount_model")
|
||||
.prefetch_related("base_fees", "unit_rates", "discount_model__tiers")
|
||||
.get(service=offering.service)
|
||||
)
|
||||
except VSHNAppCatPrice.DoesNotExist:
|
||||
return None
|
||||
|
||||
pricing_data_by_group_and_service_level = defaultdict(lambda: defaultdict(list))
|
||||
processed_combinations = set()
|
||||
|
||||
# Generate pricing combinations for each compute plan
|
||||
for plan in compute_plans:
|
||||
plan_currencies = set(plan.prices.values_list("currency", flat=True))
|
||||
|
||||
# Determine units based on variable unit type
|
||||
if appcat_price.variable_unit == VSHNAppCatPrice.VariableUnit.RAM:
|
||||
units = int(plan.ram)
|
||||
elif appcat_price.variable_unit == VSHNAppCatPrice.VariableUnit.CPU:
|
||||
units = int(plan.vcpus)
|
||||
else:
|
||||
continue
|
||||
|
||||
base_fee_currencies = set(
|
||||
appcat_price.base_fees.values_list("currency", flat=True)
|
||||
)
|
||||
|
||||
service_levels = appcat_price.unit_rates.values_list(
|
||||
"service_level", flat=True
|
||||
).distinct()
|
||||
|
||||
for service_level in service_levels:
|
||||
unit_rate_currencies = set(
|
||||
appcat_price.unit_rates.filter(service_level=service_level).values_list(
|
||||
"currency", flat=True
|
||||
)
|
||||
)
|
||||
|
||||
# Find currencies that exist across all pricing components
|
||||
matching_currencies = plan_currencies.intersection(
|
||||
base_fee_currencies
|
||||
).intersection(unit_rate_currencies)
|
||||
|
||||
if not matching_currencies:
|
||||
continue
|
||||
|
||||
for currency in matching_currencies:
|
||||
combination_key = (
|
||||
plan.name,
|
||||
service_level,
|
||||
currency,
|
||||
)
|
||||
|
||||
# Skip if combination already processed
|
||||
if combination_key in processed_combinations:
|
||||
continue
|
||||
|
||||
processed_combinations.add(combination_key)
|
||||
|
||||
# Get pricing components
|
||||
compute_plan_price = plan.get_price(currency)
|
||||
base_fee = appcat_price.get_base_fee(currency)
|
||||
unit_rate = appcat_price.get_unit_rate(currency, service_level)
|
||||
|
||||
# Skip if any pricing component is missing
|
||||
if any(
|
||||
price is None for price in [compute_plan_price, base_fee, unit_rate]
|
||||
):
|
||||
continue
|
||||
|
||||
# Calculate replica enforcement based on service level
|
||||
if service_level == VSHNAppCatPrice.ServiceLevel.GUARANTEED:
|
||||
replica_enforce = appcat_price.ha_replica_min
|
||||
else:
|
||||
replica_enforce = 1
|
||||
|
||||
total_units = units * replica_enforce
|
||||
standard_sla_price = base_fee + (total_units * unit_rate)
|
||||
|
||||
# Apply discount if available
|
||||
if appcat_price.discount_model and appcat_price.discount_model.active:
|
||||
discounted_price = appcat_price.discount_model.calculate_discount(
|
||||
unit_rate, total_units
|
||||
)
|
||||
sla_price = base_fee + discounted_price
|
||||
else:
|
||||
sla_price = standard_sla_price
|
||||
|
||||
final_price = compute_plan_price + sla_price
|
||||
service_level_display = dict(VSHNAppCatPrice.ServiceLevel.choices)[
|
||||
service_level
|
||||
]
|
||||
|
||||
group_name = plan.group.name if plan.group else "No Group"
|
||||
|
||||
# Add pricing data to the grouped structure
|
||||
pricing_data_by_group_and_service_level[group_name][
|
||||
service_level_display
|
||||
].append(
|
||||
{
|
||||
"compute_plan": plan.name,
|
||||
"compute_plan_group": group_name,
|
||||
"compute_plan_group_description": (
|
||||
plan.group.description if plan.group else ""
|
||||
),
|
||||
"vcpus": plan.vcpus,
|
||||
"ram": plan.ram,
|
||||
"currency": currency,
|
||||
"compute_plan_price": compute_plan_price,
|
||||
"sla_price": sla_price,
|
||||
"final_price": final_price,
|
||||
}
|
||||
)
|
||||
|
||||
# Order groups correctly, placing "No Group" last
|
||||
ordered_groups = {}
|
||||
all_group_names = list(pricing_data_by_group_and_service_level.keys())
|
||||
|
||||
if "No Group" in all_group_names:
|
||||
all_group_names.remove("No Group")
|
||||
all_group_names.append("No Group")
|
||||
|
||||
for group_name_key in all_group_names:
|
||||
ordered_groups[group_name_key] = pricing_data_by_group_and_service_level[
|
||||
group_name_key
|
||||
]
|
||||
|
||||
# Convert defaultdicts to regular dicts for the template
|
||||
final_context_data = {}
|
||||
for group_key, service_levels_dict in ordered_groups.items():
|
||||
final_context_data[group_key] = {
|
||||
sl_key: list(plans_list)
|
||||
for sl_key, plans_list in service_levels_dict.items()
|
||||
}
|
||||
|
||||
return final_context_data
|
||||
|
|
357
hub/services/views/pricelist.py
Normal file
357
hub/services/views/pricelist.py
Normal file
|
@ -0,0 +1,357 @@
|
|||
import re
|
||||
|
||||
from django.shortcuts import render
|
||||
from collections import defaultdict
|
||||
from hub.services.models import (
|
||||
ComputePlan,
|
||||
VSHNAppCatPrice,
|
||||
ExternalPricePlans,
|
||||
StoragePlan,
|
||||
)
|
||||
from django.contrib.admin.views.decorators import staff_member_required
|
||||
from django.db import models
|
||||
|
||||
|
||||
def natural_sort_key(name):
|
||||
"""Extract numeric part from compute plan name for natural sorting"""
|
||||
match = re.search(r"compute-std-(\d+)", name)
|
||||
return int(match.group(1)) if match else 0
|
||||
|
||||
|
||||
def get_external_price_comparisons(plan, appcat_price, currency, service_level):
|
||||
"""Get external price comparisons for a specific compute plan and service"""
|
||||
try:
|
||||
# Filter by service level if external price has one set, ignore currency for comparison
|
||||
external_prices = ExternalPricePlans.objects.filter(
|
||||
compare_to=plan, service=appcat_price.service
|
||||
).select_related("cloud_provider")
|
||||
|
||||
# Filter by service level if the external price has it configured
|
||||
if service_level:
|
||||
external_prices = external_prices.filter(
|
||||
models.Q(service_level=service_level)
|
||||
| models.Q(service_level__isnull=True)
|
||||
)
|
||||
|
||||
return external_prices
|
||||
except Exception:
|
||||
return []
|
||||
|
||||
|
||||
@staff_member_required
|
||||
def pricelist(request):
|
||||
"""Generate comprehensive price list grouped by compute plan groups and service levels"""
|
||||
# Get filter parameters from request
|
||||
show_discount_details = request.GET.get("discount_details", "").lower() == "true"
|
||||
show_price_comparison = request.GET.get("price_comparison", "").lower() == "true"
|
||||
filter_cloud_provider = request.GET.get("cloud_provider", "")
|
||||
filter_service = request.GET.get("service", "")
|
||||
filter_compute_plan_group = request.GET.get("compute_plan_group", "")
|
||||
filter_service_level = request.GET.get("service_level", "")
|
||||
|
||||
# Fetch all active compute plans with related data
|
||||
compute_plans = (
|
||||
ComputePlan.objects.filter(active=True)
|
||||
.select_related("cloud_provider", "group")
|
||||
.prefetch_related("prices")
|
||||
.order_by("group__order", "group__name", "cloud_provider__name")
|
||||
)
|
||||
|
||||
# Apply compute plan filters
|
||||
if filter_cloud_provider:
|
||||
compute_plans = compute_plans.filter(cloud_provider__name=filter_cloud_provider)
|
||||
if filter_compute_plan_group:
|
||||
if filter_compute_plan_group == "No Group":
|
||||
compute_plans = compute_plans.filter(group__isnull=True)
|
||||
else:
|
||||
compute_plans = compute_plans.filter(group__name=filter_compute_plan_group)
|
||||
|
||||
# Apply natural sorting for compute plan names
|
||||
compute_plans = sorted(
|
||||
compute_plans,
|
||||
key=lambda x: (
|
||||
x.group.order if x.group else 999, # No group plans at the end
|
||||
x.group.name if x.group else "ZZZ",
|
||||
x.cloud_provider.name,
|
||||
natural_sort_key(x.name),
|
||||
),
|
||||
)
|
||||
|
||||
# Fetch all appcat price configurations
|
||||
appcat_prices = (
|
||||
VSHNAppCatPrice.objects.all()
|
||||
.select_related("service", "discount_model")
|
||||
.prefetch_related("base_fees", "unit_rates", "discount_model__tiers")
|
||||
.order_by("service__name")
|
||||
)
|
||||
|
||||
# Apply service filter
|
||||
if filter_service:
|
||||
appcat_prices = appcat_prices.filter(service__name=filter_service)
|
||||
|
||||
pricing_data_by_group_and_service_level = defaultdict(lambda: defaultdict(list))
|
||||
processed_combinations = set()
|
||||
|
||||
# Generate pricing combinations for each compute plan and service
|
||||
for plan in compute_plans:
|
||||
plan_currencies = set(plan.prices.values_list("currency", flat=True))
|
||||
|
||||
for appcat_price in appcat_prices:
|
||||
# Determine units based on variable unit type
|
||||
if appcat_price.variable_unit == VSHNAppCatPrice.VariableUnit.RAM:
|
||||
units = int(plan.ram)
|
||||
elif appcat_price.variable_unit == VSHNAppCatPrice.VariableUnit.CPU:
|
||||
units = int(plan.vcpus)
|
||||
else:
|
||||
continue
|
||||
|
||||
base_fee_currencies = set(
|
||||
appcat_price.base_fees.values_list("currency", flat=True)
|
||||
)
|
||||
|
||||
service_levels = appcat_price.unit_rates.values_list(
|
||||
"service_level", flat=True
|
||||
).distinct()
|
||||
|
||||
# Apply service level filter
|
||||
if filter_service_level:
|
||||
service_levels = [
|
||||
sl
|
||||
for sl in service_levels
|
||||
if dict(VSHNAppCatPrice.ServiceLevel.choices)[sl]
|
||||
== filter_service_level
|
||||
]
|
||||
|
||||
for service_level in service_levels:
|
||||
unit_rate_currencies = set(
|
||||
appcat_price.unit_rates.filter(
|
||||
service_level=service_level
|
||||
).values_list("currency", flat=True)
|
||||
)
|
||||
|
||||
# Find currencies that exist across all pricing components
|
||||
matching_currencies = plan_currencies.intersection(
|
||||
base_fee_currencies
|
||||
).intersection(unit_rate_currencies)
|
||||
|
||||
if not matching_currencies:
|
||||
continue
|
||||
|
||||
for currency in matching_currencies:
|
||||
combination_key = (
|
||||
plan.cloud_provider.name,
|
||||
plan.name,
|
||||
appcat_price.service.name,
|
||||
service_level,
|
||||
currency,
|
||||
)
|
||||
|
||||
# Skip if combination already processed
|
||||
if combination_key in processed_combinations:
|
||||
continue
|
||||
|
||||
processed_combinations.add(combination_key)
|
||||
|
||||
# Get pricing components
|
||||
compute_plan_price = plan.get_price(currency)
|
||||
base_fee = appcat_price.get_base_fee(currency)
|
||||
unit_rate = appcat_price.get_unit_rate(currency, service_level)
|
||||
|
||||
# Skip if any pricing component is missing
|
||||
if any(
|
||||
price is None
|
||||
for price in [compute_plan_price, base_fee, unit_rate]
|
||||
):
|
||||
continue
|
||||
|
||||
# Calculate replica enforcement based on service level
|
||||
if service_level == VSHNAppCatPrice.ServiceLevel.GUARANTEED:
|
||||
replica_enforce = appcat_price.ha_replica_min
|
||||
else:
|
||||
replica_enforce = 1
|
||||
|
||||
total_units = units * replica_enforce
|
||||
standard_sla_price = base_fee + (total_units * unit_rate)
|
||||
|
||||
# Apply discount if available
|
||||
discount_breakdown = None
|
||||
if (
|
||||
appcat_price.discount_model
|
||||
and appcat_price.discount_model.active
|
||||
):
|
||||
discounted_price = (
|
||||
appcat_price.discount_model.calculate_discount(
|
||||
unit_rate, total_units
|
||||
)
|
||||
)
|
||||
sla_price = base_fee + discounted_price
|
||||
discount_savings = standard_sla_price - sla_price
|
||||
discount_percentage = (
|
||||
(discount_savings / standard_sla_price) * 100
|
||||
if standard_sla_price > 0
|
||||
else 0
|
||||
)
|
||||
discount_breakdown = (
|
||||
appcat_price.discount_model.get_discount_breakdown(
|
||||
unit_rate, total_units
|
||||
)
|
||||
)
|
||||
else:
|
||||
sla_price = standard_sla_price
|
||||
discounted_price = total_units * unit_rate
|
||||
discount_savings = 0
|
||||
discount_percentage = 0
|
||||
|
||||
final_price = compute_plan_price + sla_price
|
||||
service_level_display = dict(VSHNAppCatPrice.ServiceLevel.choices)[
|
||||
service_level
|
||||
]
|
||||
|
||||
# Get external price comparisons if enabled
|
||||
external_comparisons = []
|
||||
if show_price_comparison:
|
||||
external_prices = get_external_price_comparisons(
|
||||
plan, appcat_price, currency, service_level
|
||||
)
|
||||
for ext_price in external_prices:
|
||||
# Calculate price difference using external price currency
|
||||
difference = ext_price.amount - final_price
|
||||
ratio = (
|
||||
ext_price.amount / final_price if final_price > 0 else 0
|
||||
)
|
||||
|
||||
external_comparisons.append(
|
||||
{
|
||||
"plan_name": ext_price.plan_name,
|
||||
"provider": ext_price.cloud_provider.name,
|
||||
"description": ext_price.description,
|
||||
"amount": ext_price.amount,
|
||||
"currency": ext_price.currency, # Use external price currency
|
||||
"vcpus": ext_price.vcpus,
|
||||
"ram": ext_price.ram,
|
||||
"storage": ext_price.storage,
|
||||
"replicas": ext_price.replicas,
|
||||
"difference": difference,
|
||||
"ratio": ratio,
|
||||
"source": ext_price.source,
|
||||
"date_retrieved": ext_price.date_retrieved,
|
||||
}
|
||||
)
|
||||
|
||||
group_name = plan.group.name if plan.group else "No Group"
|
||||
|
||||
# Get storage plans for this cloud provider
|
||||
storage_plans = StoragePlan.objects.filter(
|
||||
cloud_provider=plan.cloud_provider
|
||||
).prefetch_related("prices")
|
||||
|
||||
# Add pricing data to the grouped structure
|
||||
pricing_data_by_group_and_service_level[group_name][
|
||||
service_level_display
|
||||
].append(
|
||||
{
|
||||
"cloud_provider": plan.cloud_provider.name,
|
||||
"service": appcat_price.service.name,
|
||||
"compute_plan": plan.name,
|
||||
"compute_plan_group": group_name,
|
||||
"compute_plan_group_description": (
|
||||
plan.group.description if plan.group else ""
|
||||
),
|
||||
"compute_plan_group_node_label": (
|
||||
plan.group.node_label if plan.group else ""
|
||||
),
|
||||
"storage_plans": storage_plans,
|
||||
"vcpus": plan.vcpus,
|
||||
"ram": plan.ram,
|
||||
"cpu_mem_ratio": plan.cpu_mem_ratio,
|
||||
"term": plan.get_term_display(),
|
||||
"currency": currency,
|
||||
"compute_plan_price": compute_plan_price,
|
||||
"variable_unit": appcat_price.get_variable_unit_display(),
|
||||
"units": units,
|
||||
"replica_enforce": replica_enforce,
|
||||
"total_units": total_units,
|
||||
"service_level": service_level_display,
|
||||
"sla_base": base_fee,
|
||||
"sla_per_unit": unit_rate,
|
||||
"sla_price": sla_price,
|
||||
"standard_sla_price": standard_sla_price,
|
||||
"discounted_sla_price": (
|
||||
base_fee + discounted_price
|
||||
if appcat_price.discount_model
|
||||
and appcat_price.discount_model.active
|
||||
else None
|
||||
),
|
||||
"discount_savings": discount_savings,
|
||||
"discount_percentage": discount_percentage,
|
||||
"discount_breakdown": discount_breakdown,
|
||||
"final_price": final_price,
|
||||
"discount_model": (
|
||||
appcat_price.discount_model.name
|
||||
if appcat_price.discount_model
|
||||
else None
|
||||
),
|
||||
"has_discount": bool(
|
||||
appcat_price.discount_model
|
||||
and appcat_price.discount_model.active
|
||||
),
|
||||
"external_comparisons": external_comparisons,
|
||||
}
|
||||
)
|
||||
|
||||
# Order groups correctly, placing "No Group" last
|
||||
ordered_groups_intermediate = {}
|
||||
all_group_names = list(pricing_data_by_group_and_service_level.keys())
|
||||
|
||||
if "No Group" in all_group_names:
|
||||
all_group_names.remove("No Group")
|
||||
all_group_names.append("No Group")
|
||||
|
||||
for group_name_key in all_group_names:
|
||||
ordered_groups_intermediate[group_name_key] = (
|
||||
pricing_data_by_group_and_service_level[group_name_key]
|
||||
)
|
||||
|
||||
# Convert defaultdicts to regular dicts for the template
|
||||
final_context_data = {}
|
||||
for group_key, service_levels_dict in ordered_groups_intermediate.items():
|
||||
final_context_data[group_key] = {
|
||||
sl_key: list(plans_list)
|
||||
for sl_key, plans_list in service_levels_dict.items()
|
||||
}
|
||||
|
||||
# Get filter options for dropdowns
|
||||
all_cloud_providers = (
|
||||
ComputePlan.objects.filter(active=True)
|
||||
.values_list("cloud_provider__name", flat=True)
|
||||
.distinct()
|
||||
.order_by("cloud_provider__name")
|
||||
)
|
||||
all_services = (
|
||||
VSHNAppCatPrice.objects.values_list("service__name", flat=True)
|
||||
.distinct()
|
||||
.order_by("service__name")
|
||||
)
|
||||
all_compute_plan_groups = list(
|
||||
ComputePlan.objects.filter(active=True, group__isnull=False)
|
||||
.values_list("group__name", flat=True)
|
||||
.distinct()
|
||||
.order_by("group__name")
|
||||
)
|
||||
all_compute_plan_groups.append("No Group") # Add option for plans without groups
|
||||
all_service_levels = [choice[1] for choice in VSHNAppCatPrice.ServiceLevel.choices]
|
||||
|
||||
context = {
|
||||
"pricing_data_by_group_and_service_level": final_context_data,
|
||||
"show_discount_details": show_discount_details,
|
||||
"show_price_comparison": show_price_comparison,
|
||||
"filter_cloud_provider": filter_cloud_provider,
|
||||
"filter_service": filter_service,
|
||||
"filter_compute_plan_group": filter_compute_plan_group,
|
||||
"filter_service_level": filter_service_level,
|
||||
"all_cloud_providers": all_cloud_providers,
|
||||
"all_services": all_services,
|
||||
"all_compute_plan_groups": all_compute_plan_groups,
|
||||
"all_service_levels": all_service_levels,
|
||||
}
|
||||
return render(request, "services/pricelist.html", context)
|
|
@ -1,5 +1,6 @@
|
|||
from pathlib import Path
|
||||
from environs import Env
|
||||
from import_export.formats.base_formats import CSV
|
||||
|
||||
env = Env()
|
||||
env.read_env()
|
||||
|
@ -40,6 +41,7 @@ SECRET_KEY = env.str("SECRET_KEY")
|
|||
|
||||
# SECURITY WARNING: don't run with debug turned on in production!
|
||||
DEBUG = env.bool("DEBUG", default=False)
|
||||
INTERNAL_IPS = ["127.0.0.1"]
|
||||
|
||||
# Get all allowed hosts
|
||||
original_hosts = env.list("ALLOWED_HOSTS", default=[])
|
||||
|
@ -79,6 +81,7 @@ INSTALLED_APPS = [
|
|||
"schema_viewer",
|
||||
"nested_admin",
|
||||
"adminsortable2",
|
||||
"import_export",
|
||||
# local
|
||||
"hub.services",
|
||||
"hub.broker",
|
||||
|
@ -244,4 +247,13 @@ JAZZMIN_SETTINGS = {
|
|||
],
|
||||
"show_sidebar": True,
|
||||
"navigation_expanded": True,
|
||||
"hide_apps": ["broker"],
|
||||
"order_with_respect_to": ["services", "auth"],
|
||||
"changeform_format_overrides": {
|
||||
"services.ProgressiveDiscountModel": "single",
|
||||
"services.VSHNAppCatPrice": "single",
|
||||
},
|
||||
"related_modal_active": True,
|
||||
}
|
||||
|
||||
IMPORT_EXPORT_FORMATS = [CSV]
|
||||
|
|
|
@ -7,6 +7,7 @@ requires-python = ">=3.13"
|
|||
dependencies = [
|
||||
"django>=5.2",
|
||||
"django-admin-sortable2>=2.2.4",
|
||||
"django-import-export>=4.3.7",
|
||||
"django-jazzmin>=3.0.1",
|
||||
"django-nested-admin>=4.1.1",
|
||||
"django-prose-editor[sanitize]>=0.10.3",
|
||||
|
|
34
uv.lock
generated
34
uv.lock
generated
|
@ -11,6 +11,15 @@ wheels = [
|
|||
{ url = "https://files.pythonhosted.org/packages/39/e3/893e8757be2612e6c266d9bb58ad2e3651524b5b40cf56761e985a28b13e/asgiref-3.8.1-py3-none-any.whl", hash = "sha256:3e1e3ecc849832fe52ccf2cb6686b7a55f82bb1d6aee72a58826471390335e47", size = 23828, upload-time = "2024-03-22T14:39:34.521Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "diff-match-patch"
|
||||
version = "20241021"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/0e/ad/32e1777dd57d8e85fa31e3a243af66c538245b8d64b7265bec9a61f2ca33/diff_match_patch-20241021.tar.gz", hash = "sha256:beae57a99fa48084532935ee2968b8661db861862ec82c6f21f4acdd6d835073", size = 39962, upload-time = "2024-10-21T19:41:21.094Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/f7/bb/2aa9b46a01197398b901e458974c20ed107935c26e44e37ad5b0e5511e44/diff_match_patch-20241021-py3-none-any.whl", hash = "sha256:93cea333fb8b2bc0d181b0de5e16df50dd344ce64828226bda07728818936782", size = 43252, upload-time = "2024-10-21T19:41:19.914Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "dj-database-url"
|
||||
version = "2.3.0"
|
||||
|
@ -81,6 +90,20 @@ wheels = [
|
|||
{ url = "https://files.pythonhosted.org/packages/48/90/01755e4a42558b763f7021e9369aa6aa94c2ede7313deed56cb7483834ab/django_cache_url-3.4.5-py2.py3-none-any.whl", hash = "sha256:5f350759978483ab85dc0e3e17b3d53eed3394a28148f6bf0f53d11d0feb5b3c", size = 4760, upload-time = "2023-12-04T17:19:44.355Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "django-import-export"
|
||||
version = "4.3.7"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "diff-match-patch" },
|
||||
{ name = "django" },
|
||||
{ name = "tablib" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/57/ae/52275e8a49a963468f9f807c24df17416fad0220169a8e5d7bfd4778f17f/django_import_export-4.3.7.tar.gz", hash = "sha256:bd3fe0aa15a2bce9de4be1a2f882e2c4539fdbfdfa16f2052c98dd7aec0f085c", size = 2222150, upload-time = "2025-02-25T12:38:47.076Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/9d/ad/b1f4aef18fd4ab86ce68f3f0fff0e9c14e5fd2c866c47f0b1cfb9ccd85c8/django_import_export-4.3.7-py3-none-any.whl", hash = "sha256:5514d09636e84e823a42cd5e79292f70f20d6d2feed117a145f5b64a5b44f168", size = 142815, upload-time = "2025-02-25T12:38:43.654Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "django-jazzmin"
|
||||
version = "3.0.1"
|
||||
|
@ -292,6 +315,7 @@ source = { virtual = "." }
|
|||
dependencies = [
|
||||
{ name = "django" },
|
||||
{ name = "django-admin-sortable2" },
|
||||
{ name = "django-import-export" },
|
||||
{ name = "django-jazzmin" },
|
||||
{ name = "django-nested-admin" },
|
||||
{ name = "django-prose-editor", extra = ["sanitize"] },
|
||||
|
@ -312,6 +336,7 @@ requires-dist = [
|
|||
{ name = "django", specifier = ">=5.2" },
|
||||
{ name = "django-admin-sortable2", specifier = ">=2.2.4" },
|
||||
{ name = "django-browser-reload", marker = "extra == 'dev'", specifier = "~=1.13" },
|
||||
{ name = "django-import-export", specifier = ">=4.3.7" },
|
||||
{ name = "django-jazzmin", specifier = ">=3.0.1" },
|
||||
{ name = "django-nested-admin", specifier = ">=4.1.1" },
|
||||
{ name = "django-prose-editor", extras = ["sanitize"], specifier = ">=0.10.3" },
|
||||
|
@ -332,6 +357,15 @@ wheels = [
|
|||
{ url = "https://files.pythonhosted.org/packages/a9/5c/bfd6bd0bf979426d405cc6e71eceb8701b148b16c21d2dc3c261efc61c7b/sqlparse-0.5.3-py3-none-any.whl", hash = "sha256:cf2196ed3418f3ba5de6af7e82c694a9fbdbfecccdfc72e281548517081f16ca", size = 44415, upload-time = "2024-12-10T12:05:27.824Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tablib"
|
||||
version = "3.8.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/09/cc/fe19d9c2ac1088794a51fc72f49b7226f88a0361f924fb3d17a9ec80e657/tablib-3.8.0.tar.gz", hash = "sha256:94d8bcdc65a715a0024a6d5b701a5f31e45bd159269e62c73731de79f048db2b", size = 122247, upload-time = "2025-01-22T15:29:27.276Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/5c/95/6542f54ebd90539b12ed6189cb54a6550a28407b1c503c2e55190c29a4c9/tablib-3.8.0-py3-none-any.whl", hash = "sha256:35bdb9d4ec7052232f8803908f9c7a9c3c65807188b70618fa7a7d8ccd560b4d", size = 47935, upload-time = "2025-01-22T15:28:44.499Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "typing-extensions"
|
||||
version = "4.12.2"
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue