Compare commits

...

26 commits

Author SHA1 Message Date
86df11505f
no null for many to many
All checks were successful
Build and Deploy / build (push) Successful in 1m22s
Build and Deploy / deploy (push) Successful in 4s
2025-05-30 14:28:54 +02:00
7b93830df2
display storage plans 2025-05-30 13:43:02 +02:00
d987f62471
price comparison improved 2025-05-30 13:36:09 +02:00
e06105942b
import for external prices 2025-05-30 10:23:34 +02:00
4cffe5a9e3
initial work on comparison 2025-05-27 17:07:55 +02:00
06b4cba4bc
deduplicate table data 2025-05-26 15:42:06 +02:00
7989d4e553
filtering in price list 2025-05-26 15:36:20 +02:00
57fd001246
protect pricelist with authentication 2025-05-26 15:03:39 +02:00
a64163cad0
small styling updates 2025-05-26 15:02:14 +02:00
60e083b5bb
add charts to pricelist 2025-05-26 14:55:45 +02:00
99f4edc209
dont load plausible when on debug 2025-05-26 14:45:36 +02:00
a3cf1cc590
refactor admin into several files 2025-05-26 11:33:04 +02:00
d9a04655ed
pricelist on offering detail 2025-05-23 17:43:29 +02:00
5b4392f838
discount_details URL parameter for pricelist 2025-05-23 17:12:05 +02:00
19b36b9a2c
compute plan grouping 2025-05-23 17:09:02 +02:00
3896636f9b
correct discount model 2025-05-23 16:37:03 +02:00
f5f4ec8ac9
refactor models into files 2025-05-23 16:04:33 +02:00
c3d20fda7b
reworked price list view 2025-05-23 15:56:11 +02:00
d39ff91a74
small admin improvements 2025-05-23 08:57:20 +02:00
b0a76b88b4
some settings 2025-05-23 08:49:08 +02:00
836187f2aa
add discount models 2025-05-22 16:52:34 +02:00
a6a15150ea
continued work on price model 2025-05-22 16:34:15 +02:00
6f41c8c344
copilot instructions 2025-05-22 15:23:33 +02:00
3a0cc248a7
appcat price model 2025-05-20 15:27:45 +02:00
f14cc0e39e
add import and export functionality 2025-05-20 14:26:31 +02:00
cc5307a723
add model for compute plans 2025-05-20 11:26:52 +02:00
40 changed files with 3796 additions and 577 deletions

15
.github/copilot-instructions.md vendored Normal file
View 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.

View file

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

View 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 *

View 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",)

View 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")

View 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")

View 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"

View 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"

View 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]

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

View file

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

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

View file

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

View file

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

View file

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

View file

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

View file

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

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

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

View file

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

View file

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

View 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
View 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))

View 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

View 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})"

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

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

View 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."})

View file

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

File diff suppressed because one or more lines are too long

View file

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

View file

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

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

View file

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

View file

@ -5,3 +5,4 @@ from .providers import *
from .services import *
from .pages import *
from .subscriptions import *
from .pricelist import *

View file

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

View 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)

View file

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

View file

@ -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
View file

@ -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"