website/hub/services/admin/pricing.py

545 lines
17 KiB
Python
Raw Normal View History

2025-05-26 11:33:04 +02:00
"""
Admin classes for pricing models including compute plans, storage plans, and VSHN AppCat pricing
"""
import re
2025-06-04 15:50:19 +02:00
from django.contrib import admin, messages
from django.contrib.admin import helpers
2025-01-27 15:14:58 +01:00
from django.utils.html import format_html
2025-06-04 15:50:19 +02:00
from django import forms
from django.shortcuts import render
from django.http import HttpResponseRedirect
2025-03-07 14:58:51 +01:00
from adminsortable2.admin import SortableAdminMixin
2025-05-20 14:26:31 +02:00
from import_export.admin import ImportExportModelAdmin
from import_export import resources
from import_export.fields import Field
from import_export.widgets import ForeignKeyWidget
2025-03-07 14:58:51 +01:00
2025-05-26 11:33:04 +02:00
from ..models import (
2025-05-20 11:26:52 +02:00
ComputePlan,
2025-05-23 17:09:02 +02:00
ComputePlanGroup,
2025-05-20 15:27:45 +02:00
ComputePlanPrice,
2025-05-26 11:33:04 +02:00
CloudProvider,
2025-05-22 16:34:15 +02:00
StoragePlan,
StoragePlanPrice,
2025-05-20 15:27:45 +02:00
VSHNAppCatBaseFee,
VSHNAppCatPrice,
VSHNAppCatUnitRate,
2025-05-26 11:33:04 +02:00
ProgressiveDiscountModel,
DiscountTier,
2025-05-27 17:07:55 +02:00
ExternalPricePlans,
2025-05-30 10:23:34 +02:00
Service,
2025-01-28 08:53:00 +01:00
)
2025-01-27 15:23:50 +01:00
2025-06-04 15:50:19 +02:00
from ..models.base import Term
def natural_sort_key(obj):
"""Extract numeric parts for natural sorting"""
parts = re.split(r"(\d+)", obj.name)
return [int(part) if part.isdigit() else part for part in parts]
2025-05-26 11:33:04 +02:00
class ComputePlanPriceInline(admin.TabularInline):
"""Inline admin for ComputePlanPrice model"""
2025-05-26 11:33:04 +02:00
model = ComputePlanPrice
extra = 1
2025-05-26 11:33:04 +02:00
fields = ("currency", "amount")
2025-01-27 14:58:23 +01:00
2025-05-20 15:27:45 +02:00
class ComputePlanItemInline(admin.TabularInline):
2025-05-26 11:33:04 +02:00
"""Inline admin for ComputePlan model"""
2025-05-20 11:26:52 +02:00
model = ComputePlan
extra = 1
2025-05-20 15:27:45 +02:00
fields = ("name", "vcpus", "ram", "active", "valid_from", "valid_to")
2025-05-20 11:26:52 +02:00
2025-05-23 17:09:02 +02:00
@admin.register(ComputePlanGroup)
class ComputePlanGroupAdmin(SortableAdminMixin, admin.ModelAdmin):
2025-05-26 11:33:04 +02:00
"""Admin configuration for ComputePlanGroup model"""
2025-05-23 17:09:02 +02:00
list_display = ("name", "node_label", "compute_plans_count")
search_fields = ("name", "description", "node_label")
ordering = ("order",)
def compute_plans_count(self, obj):
2025-05-26 11:33:04 +02:00
"""Display count of compute plans in this group"""
2025-05-23 17:09:02 +02:00
return obj.compute_plans.count()
compute_plans_count.short_description = "Compute Plans"
2025-05-20 14:26:31 +02:00
class ComputePlanResource(resources.ModelResource):
2025-05-26 11:33:04 +02:00
"""Import/Export resource for ComputePlan model"""
2025-05-20 14:26:31 +02:00
cloud_provider = Field(
column_name="cloud_provider",
attribute="cloud_provider",
widget=ForeignKeyWidget(CloudProvider, "name"),
)
2025-05-23 17:09:02 +02:00
group = Field(
column_name="group",
attribute="group",
widget=ForeignKeyWidget(ComputePlanGroup, "name"),
)
2025-05-22 16:34:15 +02:00
prices = Field(column_name="prices", attribute=None)
2025-05-20 14:26:31 +02:00
class Meta:
model = ComputePlan
skip_unchanged = True
report_skipped = False
import_id_fields = ["name"]
fields = (
"name",
"vcpus",
"ram",
"cpu_mem_ratio",
"cloud_provider",
2025-05-23 17:09:02 +02:00
"group",
2025-05-20 15:27:45 +02:00
"active",
2025-05-22 16:34:15 +02:00
"term",
2025-05-20 15:27:45 +02:00
"valid_from",
"valid_to",
2025-05-22 16:34:15 +02:00
"prices",
2025-05-20 14:26:31 +02:00
)
2025-05-22 16:34:15 +02:00
def dehydrate_prices(self, compute_plan):
2025-05-26 11:33:04 +02:00
"""Export prices in a custom format"""
2025-05-22 16:34:15 +02:00
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):
2025-05-26 11:33:04 +02:00
"""Handle many-to-many relationships during import"""
2025-05-22 16:34:15 +02:00
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
)
2025-05-20 14:26:31 +02:00
2025-06-04 15:50:19 +02:00
class MassUpdateComputePlanForm(forms.Form):
"""Form for mass updating ComputePlan fields"""
active = forms.ChoiceField(
choices=[("", "-- No change --"), ("True", "Active"), ("False", "Inactive")],
required=False,
help_text="Set active status for selected compute plans",
)
term = forms.ChoiceField(
choices=[("", "-- No change --")] + Term.choices,
required=False,
help_text="Set billing term for selected compute plans",
)
group = forms.ModelChoiceField(
queryset=ComputePlanGroup.objects.all(),
required=False,
empty_label="-- No change --",
help_text="Set group for selected compute plans",
)
valid_from = forms.DateField(
widget=forms.DateInput(attrs={"type": "date"}),
required=False,
help_text="Set valid from date for selected compute plans",
)
valid_to = forms.DateField(
widget=forms.DateInput(attrs={"type": "date"}),
required=False,
help_text="Set valid to date for selected compute plans",
)
2025-05-20 11:26:52 +02:00
@admin.register(ComputePlan)
class ComputePlanAdmin(ImportExportModelAdmin):
2025-05-26 11:33:04 +02:00
"""Admin configuration for ComputePlan model with import/export functionality"""
2025-05-20 14:26:31 +02:00
resource_class = ComputePlanResource
2025-05-20 15:27:45 +02:00
list_display = (
"name",
"cloud_provider",
2025-05-23 17:09:02 +02:00
"group",
2025-05-20 15:27:45 +02:00
"vcpus",
"ram",
2025-05-22 16:34:15 +02:00
"term",
2025-05-20 15:27:45 +02:00
"display_prices",
"active",
)
2025-05-23 17:09:02 +02:00
search_fields = ("name", "cloud_provider__name", "group__name")
list_filter = ("active", "cloud_provider", "group")
2025-05-20 15:27:45 +02:00
inlines = [ComputePlanPriceInline]
2025-06-04 15:50:19 +02:00
actions = ["mass_update_compute_plans"]
2025-05-20 15:27:45 +02:00
def changelist_view(self, request, extra_context=None):
"""Override changelist view to apply natural sorting"""
# Get the response from parent
response = super().changelist_view(request, extra_context)
# If it's a TemplateResponse, we can modify the context
if hasattr(response, "context_data") and "cl" in response.context_data:
cl = response.context_data["cl"]
if hasattr(cl, "result_list"):
cl.result_list = sorted(cl.result_list, key=natural_sort_key)
return response
2025-05-20 15:27:45 +02:00
def display_prices(self, obj):
2025-05-26 11:33:04 +02:00
"""Display formatted prices for the list view"""
2025-05-20 15:27:45 +02:00
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)"
2025-06-04 15:50:19 +02:00
def mass_update_compute_plans(self, request, queryset):
"""Admin action to mass update compute plan fields"""
if request.POST.get("post"):
# Process the form submission
form = MassUpdateComputePlanForm(request.POST)
if form.is_valid():
updated_count = 0
updated_fields = []
# Prepare update data
update_data = {}
# Handle active field
if form.cleaned_data["active"]:
update_data["active"] = form.cleaned_data["active"] == "True"
updated_fields.append("active")
# Handle term field
if form.cleaned_data["term"]:
update_data["term"] = form.cleaned_data["term"]
updated_fields.append("term")
# Handle group field
if form.cleaned_data["group"]:
update_data["group"] = form.cleaned_data["group"]
updated_fields.append("group")
# Handle valid_from field
if form.cleaned_data["valid_from"]:
update_data["valid_from"] = form.cleaned_data["valid_from"]
updated_fields.append("valid_from")
# Handle valid_to field
if form.cleaned_data["valid_to"]:
update_data["valid_to"] = form.cleaned_data["valid_to"]
updated_fields.append("valid_to")
# Perform the bulk update
if update_data:
updated_count = queryset.update(**update_data)
# Create success message
field_list = ", ".join(updated_fields)
self.message_user(
request,
f"Successfully updated {updated_count} compute plan(s). "
f"Updated fields: {field_list}",
messages.SUCCESS,
)
else:
self.message_user(
request, "No fields were selected for update.", messages.WARNING
)
return HttpResponseRedirect(request.get_full_path())
else:
# Show the form
form = MassUpdateComputePlanForm()
# Render the mass update template
return render(
request,
"admin/mass_update_compute_plans.html",
{
"form": form,
"queryset": queryset,
"action_checkbox_name": helpers.ACTION_CHECKBOX_NAME,
"opts": self.model._meta,
"title": f"Mass Update {queryset.count()} Compute Plans",
},
)
mass_update_compute_plans.short_description = "Mass update selected compute plans"
2025-05-20 15:27:45 +02:00
class VSHNAppCatBaseFeeInline(admin.TabularInline):
2025-05-26 11:33:04 +02:00
"""Inline admin for VSHNAppCatBaseFee model"""
2025-05-20 15:27:45 +02:00
model = VSHNAppCatBaseFee
extra = 1
fields = ("currency", "amount")
class VSHNAppCatUnitRateInline(admin.TabularInline):
2025-05-26 11:33:04 +02:00
"""Inline admin for VSHNAppCatUnitRate model"""
2025-05-20 15:27:45 +02:00
model = VSHNAppCatUnitRate
extra = 1
fields = ("currency", "service_level", "amount")
2025-05-22 16:52:34 +02:00
class DiscountTierInline(admin.TabularInline):
2025-05-26 11:33:04 +02:00
"""Inline admin for DiscountTier model"""
2025-05-22 16:52:34 +02:00
model = DiscountTier
extra = 1
2025-05-23 16:37:03 +02:00
fields = ("min_units", "max_units", "discount_percent")
ordering = ("min_units",)
2025-05-22 16:52:34 +02:00
@admin.register(ProgressiveDiscountModel)
class ProgressiveDiscountModelAdmin(admin.ModelAdmin):
2025-05-26 11:33:04 +02:00
"""Admin configuration for ProgressiveDiscountModel"""
2025-05-22 16:52:34 +02:00
list_display = ("name", "description", "active")
search_fields = ("name", "description")
inlines = [DiscountTierInline]
2025-05-20 15:27:45 +02:00
@admin.register(VSHNAppCatPrice)
class VSHNAppCatPriceAdmin(admin.ModelAdmin):
2025-05-26 11:33:04 +02:00
"""Admin configuration for VSHNAppCatPrice model"""
2025-05-20 15:27:45 +02:00
list_display = (
"service",
"variable_unit",
2025-05-22 16:34:15 +02:00
"term",
2025-05-22 16:52:34 +02:00
"discount_model",
2025-05-20 15:27:45 +02:00
"admin_display_base_fees",
"admin_display_unit_rates",
"public_display_enabled",
2025-05-20 15:27:45 +02:00
)
2025-05-22 16:52:34 +02:00
list_filter = ("variable_unit", "service", "discount_model")
2025-05-20 15:27:45 +02:00
search_fields = ("service__name",)
inlines = [VSHNAppCatBaseFeeInline, VSHNAppCatUnitRateInline]
def admin_display_base_fees(self, obj):
2025-05-26 11:33:04 +02:00
"""Display base fees in admin list view"""
2025-05-20 15:27:45 +02:00
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):
2025-05-26 11:33:04 +02:00
"""Display unit rates in admin list view"""
2025-05-20 15:27:45 +02:00
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"
2025-05-22 16:34:15 +02:00
class StoragePlanPriceInline(admin.TabularInline):
2025-05-26 11:33:04 +02:00
"""Inline admin for StoragePlanPrice model"""
2025-05-22 16:34:15 +02:00
model = StoragePlanPrice
extra = 1
fields = ("currency", "amount")
class StoragePlanResource(resources.ModelResource):
2025-05-26 11:33:04 +02:00
"""Import/Export resource for StoragePlan model"""
2025-05-22 16:34:15 +02:00
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):
2025-05-26 11:33:04 +02:00
"""Export prices in a custom format"""
2025-05-22 16:34:15 +02:00
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):
2025-05-26 11:33:04 +02:00
"""Handle many-to-many relationships during import"""
2025-05-22 16:34:15 +02:00
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):
2025-05-26 11:33:04 +02:00
"""Admin configuration for StoragePlan model with import/export functionality"""
2025-05-22 16:34:15 +02:00
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):
2025-05-26 11:33:04 +02:00
"""Display formatted prices for the list view"""
2025-05-22 16:34:15 +02:00
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)"
2025-05-27 17:07:55 +02:00
2025-05-30 10:23:34 +02:00
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
2025-05-27 17:07:55 +02:00
@admin.register(ExternalPricePlans)
2025-05-30 10:23:34 +02:00
class ExternalPricePlansAdmin(ImportExportModelAdmin):
"""Admin configuration for ExternalPricePlans model with import/export functionality"""
2025-05-27 17:07:55 +02:00
2025-05-30 10:23:34 +02:00
resource_class = ExternalPricePlansResource
2025-05-27 17:07:55 +02:00
list_display = (
"plan_name",
"cloud_provider",
"service",
"amount",
"display_compare_to_count",
2025-05-30 13:36:09 +02:00
"replicas",
2025-05-27 17:07:55 +02:00
)
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"