From 15869ca542840125cc694c57c4411d18ac2b5715 Mon Sep 17 00:00:00 2001 From: Tobias Brunner Date: Fri, 20 Jun 2025 14:20:28 +0200 Subject: [PATCH 01/67] pricelist internal plans comparison --- .../templates/services/pricelist.html | 152 ++++++++++++++++-- hub/services/views/pricelist.py | 123 ++++++++++++++ 2 files changed, 260 insertions(+), 15 deletions(-) diff --git a/hub/services/templates/services/pricelist.html b/hub/services/templates/services/pricelist.html index bc90f6d..502b243 100644 --- a/hub/services/templates/services/pricelist.html +++ b/hub/services/templates/services/pricelist.html @@ -51,6 +51,41 @@ .servala-row { border-bottom: 2px solid #007bff; + border-left: 4px solid #007bff; + background-color: rgba(13, 110, 253, 0.03); + } + + .internal-comparison-row { + background-color: rgba(25, 135, 84, 0.08) !important; + border-left: 4px solid #198754; + border-top: 1px solid #198754; + } + + .external-comparison-row { + background-color: rgba(108, 117, 125, 0.08) !important; + border-left: 4px solid #6c757d; + border-top: 1px solid #6c757d; + } + + /* Group comparison rows visually */ + .comparison-group { + border-bottom: 2px solid #dee2e6; + margin-bottom: 8px; + } + + /* Add visual connection between main row and comparisons */ + .has-comparisons { + position: relative; + } + + .has-comparisons::after { + content: ''; + position: absolute; + left: 0; + bottom: -1px; + width: 100%; + height: 2px; + background: linear-gradient(to right, #007bff, transparent); } /* Price calculation breakdown styling */ @@ -171,6 +206,10 @@ This transparent pricing model ensures you understand exactly what you're paying for. The table below breaks down each component for every service variant we offer. +

+ Price Comparisons: When enabled, you'll see: +
External Providers - Competitor prices from AWS, Google Cloud, etc. +
Other Servala Providers - Same service specs on different cloud providers within our network

@@ -246,7 +285,7 @@
@@ -268,7 +307,7 @@ {% if filter_service_level %}Service Level: {{ filter_service_level }}{% endif %} {% if show_discount_details %}Discount Details{% endif %} {% if show_addon_details %}Addon Details{% endif %} - {% if show_price_comparison %}Price Comparison{% endif %} + {% if show_price_comparison %}Price Comparisons{% endif %} {% endif %} @@ -430,7 +469,7 @@ Discount Details {% endif %} {% if show_price_comparison %} - External Comparisons + Price Comparisons {% endif %} Final Price @@ -444,7 +483,7 @@ {% for row in pricing_data %} - + {{ row.compute_plan }} {{ row.cloud_provider }} {{ row.vcpus }} @@ -566,16 +605,39 @@ {% endif %} {% if show_price_comparison %} - - + {% if row.external_comparisons or row.internal_comparisons %} +
+ {% if row.external_comparisons %} +
+ {{ row.external_comparisons|length }} External +
+ {% endif %} + {% if row.internal_comparisons %} +
+ {{ row.internal_comparisons|length }} Internal +
+ {% endif %} + See rows below +
+ {% else %} + No comparisons + {% endif %} {% endif %} {{ row.final_price|floatformat:2 }} {% if show_price_comparison and row.external_comparisons %} {% for comparison in row.external_comparisons %} - - {{ comparison.plan_name }} - {{ comparison.provider }} + + +
+ + {{ comparison.plan_name }} +
+ + + {{ comparison.provider }} + {% if comparison.vcpus %}{{ comparison.vcpus }}{% else %}-{% endif %} @@ -585,11 +647,11 @@ {{ row.term }} {{ comparison.currency }} - - - - - - - - - - + - + - + - + - + - {% if show_addon_details %} - {% endif %} @@ -599,7 +661,11 @@ {% endif %} - {% if comparison.source %}{{ comparison.provider }}{% else %}{{ comparison.provider }}{% endif %}
+ {% if comparison.source %} + + External Source +
+ {% endif %} {% if comparison.description %} {{ comparison.description }}
{% endif %} @@ -610,7 +676,7 @@ Replicas: {{ comparison.replicas }}
{% endif %} {% if comparison.ratio %} - Price ratio: {{ comparison.ratio|floatformat:2 }}x
+ Ratio: {{ comparison.ratio|floatformat:2 }}x {% endif %}
@@ -625,6 +691,62 @@ {% endfor %} {% endif %} + {% if show_price_comparison and row.internal_comparisons %} + {% for comparison in row.internal_comparisons %} + + +
+ + {{ comparison.plan_name }} +
+ + + {{ comparison.provider }} + + {{ comparison.vcpus }} + {{ comparison.ram }} + {{ row.term }} + {{ comparison.currency }} + + {{ comparison.compute_plan_price|floatformat:2 }} + Same + Same + Same + {{ comparison.service_price|floatformat:2 }} + {% if show_addon_details %} + Same as above + {% endif %} + {% if show_discount_details %} + Same + Same + {% endif %} + + + Servala Network
+ {{ comparison.description }}
+ Group: {{ comparison.group_name }}
+ Ratio: {{ comparison.ratio|floatformat:2 }}x +
+ + + {{ comparison.amount|floatformat:2 }} {{ comparison.currency }} + {% if comparison.difference > 0 %} + +{{ comparison.difference|floatformat:2 }} + {% elif comparison.difference < 0 %} + {{ comparison.difference|floatformat:2 }} + {% elif comparison.difference == 0 %} + Same + {% endif %} + + + {% endfor %} + {% endif %} + + {% if show_price_comparison and row.external_comparisons or row.internal_comparisons %} + + + + {% endif %} {% endfor %} diff --git a/hub/services/views/pricelist.py b/hub/services/views/pricelist.py index b392f6b..cc3996a 100644 --- a/hub/services/views/pricelist.py +++ b/hub/services/views/pricelist.py @@ -38,6 +38,90 @@ def get_external_price_comparisons(plan, appcat_price, currency, service_level): return [] +def get_internal_cloud_provider_comparisons( + plan, appcat_price, currency, service_level +): + """Get internal comparisons with other cloud provider plans from the database""" + try: + # Get similar compute plans from other cloud providers with same specs + similar_plans = ( + ComputePlan.objects.filter( + active=True, + vcpus=plan.vcpus, + ram=plan.ram, + ) + .exclude(cloud_provider=plan.cloud_provider) # Exclude same cloud provider + .select_related("cloud_provider") + .prefetch_related("prices") + ) + + internal_comparisons = [] + + for similar_plan in similar_plans: + # Get pricing components for comparison plan + compare_plan_price = similar_plan.get_price(currency) + compare_base_fee = appcat_price.get_base_fee(currency) + compare_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 [compare_plan_price, compare_base_fee, compare_unit_rate] + ): + continue + + # Calculate units based on variable unit type + if appcat_price.variable_unit == VSHNAppCatPrice.VariableUnit.RAM: + units = int(similar_plan.ram) + elif appcat_price.variable_unit == VSHNAppCatPrice.VariableUnit.CPU: + units = int(similar_plan.vcpus) + else: + 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 + + # Calculate final price using the same logic as the main plan + price_calculation = appcat_price.calculate_final_price( + currency_code=currency, + service_level=service_level, + number_of_units=total_units, + addon_ids=None, # Include only mandatory addons + ) + + if price_calculation is None: + continue + + service_price_with_addons = price_calculation["total_price"] + compare_final_price = compare_plan_price + service_price_with_addons + + internal_comparisons.append( + { + "plan_name": similar_plan.name, + "provider": similar_plan.cloud_provider.name, + "compute_plan_price": compare_plan_price, + "service_price": service_price_with_addons, + "final_price": compare_final_price, + "currency": currency, + "vcpus": similar_plan.vcpus, + "ram": similar_plan.ram, + "group_name": ( + similar_plan.group.name if similar_plan.group else "No Group" + ), + "is_internal": True, # Flag to distinguish from external comparisons + } + ) + + return internal_comparisons + except Exception: + return [] + + @staff_member_required def pricelist(request): """Generate comprehensive price list grouped by compute plan groups and service levels""" @@ -287,7 +371,9 @@ def pricelist(request): # Get external price comparisons if enabled external_comparisons = [] + internal_comparisons = [] if show_price_comparison: + # Get external price comparisons external_prices = get_external_price_comparisons( plan, appcat_price, currency, service_level ) @@ -313,6 +399,42 @@ def pricelist(request): "ratio": ratio, "source": ext_price.source, "date_retrieved": ext_price.date_retrieved, + "is_internal": False, + } + ) + + # Get internal cloud provider comparisons + internal_price_comparisons = ( + get_internal_cloud_provider_comparisons( + plan, appcat_price, currency, service_level + ) + ) + for int_price in internal_price_comparisons: + # Calculate price difference + difference = int_price["final_price"] - final_price + ratio = ( + int_price["final_price"] / final_price + if final_price > 0 + else 0 + ) + + internal_comparisons.append( + { + "plan_name": int_price["plan_name"], + "provider": int_price["provider"], + "description": f"Same specs with {int_price['provider']}", + "amount": int_price["final_price"], + "currency": int_price["currency"], + "vcpus": int_price["vcpus"], + "ram": int_price["ram"], + "group_name": int_price["group_name"], + "compute_plan_price": int_price[ + "compute_plan_price" + ], + "service_price": int_price["service_price"], + "difference": difference, + "ratio": ratio, + "is_internal": True, } ) @@ -374,6 +496,7 @@ def pricelist(request): and appcat_price.discount_model.active ), "external_comparisons": external_comparisons, + "internal_comparisons": internal_comparisons, "mandatory_addons": mandatory_addons, "optional_addons": optional_addons, } From 150250bfb10f2e07393bb161ff5c649800ad6da3 Mon Sep 17 00:00:00 2001 From: Tobias Brunner Date: Fri, 20 Jun 2025 14:40:28 +0200 Subject: [PATCH 02/67] visual improvements --- .../templates/services/pricelist.html | 22 +++++++++---------- 1 file changed, 11 insertions(+), 11 deletions(-) diff --git a/hub/services/templates/services/pricelist.html b/hub/services/templates/services/pricelist.html index 502b243..f01109a 100644 --- a/hub/services/templates/services/pricelist.html +++ b/hub/services/templates/services/pricelist.html @@ -174,19 +174,19 @@
Price Components
@@ -305,9 +305,9 @@ {% if filter_service %}Service: {{ filter_service }}{% endif %} {% if filter_compute_plan_group %}Group: {{ filter_compute_plan_group }}{% endif %} {% if filter_service_level %}Service Level: {{ filter_service_level }}{% endif %} - {% if show_discount_details %}Discount Details{% endif %} - {% if show_addon_details %}Addon Details{% endif %} - {% if show_price_comparison %}Price Comparisons{% endif %} + {% if show_discount_details %}Discount Details{% endif %} + {% if show_addon_details %}Addon Details{% endif %} + {% if show_price_comparison %}Price Comparisons{% endif %} {% endif %} @@ -609,12 +609,12 @@
{% if row.external_comparisons %}
- {{ row.external_comparisons|length }} External + {{ row.external_comparisons|length }} External
{% endif %} {% if row.internal_comparisons %}
- {{ row.internal_comparisons|length }} Internal + {{ row.internal_comparisons|length }} Internal
{% endif %} See rows below @@ -636,7 +636,7 @@
- {{ comparison.provider }} + {{ comparison.provider }} {% if comparison.vcpus %}{{ comparison.vcpus }}{% else %}-{% endif %} @@ -701,7 +701,7 @@ - {{ comparison.provider }} + {{ comparison.provider }} {{ comparison.vcpus }} {{ comparison.ram }} From 033eea92cd1ce8a1220022cce0bebb49b0b24f4f Mon Sep 17 00:00:00 2001 From: Tobias Brunner Date: Fri, 20 Jun 2025 15:39:26 +0200 Subject: [PATCH 03/67] service level specific base fees --- hub/services/admin/pricing.py | 8 +- ...vshnappcataddonbasefee_options_and_more.py | 79 +++ hub/services/models/pricing.py | 107 ++-- hub/services/tests/test_pricing.py | 19 +- hub/services/tests/test_pricing_edge_cases.py | 2 + .../tests/test_pricing_integration.py | 548 +++++++++++++++++- hub/services/tests/test_utils.py | 1 + hub/services/views/offerings.py | 4 +- hub/services/views/pricelist.py | 6 +- 9 files changed, 716 insertions(+), 58 deletions(-) create mode 100644 hub/services/migrations/0036_alter_vshnappcataddonbasefee_options_and_more.py diff --git a/hub/services/admin/pricing.py b/hub/services/admin/pricing.py index 61f4836..ac9711d 100644 --- a/hub/services/admin/pricing.py +++ b/hub/services/admin/pricing.py @@ -289,7 +289,7 @@ class VSHNAppCatBaseFeeInline(admin.TabularInline): model = VSHNAppCatBaseFee extra = 1 - fields = ("currency", "amount") + fields = ("currency", "service_level", "amount") class VSHNAppCatUnitRateInline(admin.TabularInline): @@ -350,7 +350,7 @@ class VSHNAppCatPriceAdmin(admin.ModelAdmin): if not fees: return "No base fees" return format_html( - "
".join([f"{fee.amount} {fee.currency}" for fee in fees]) + "
".join([f"{fee.amount} {fee.currency} ({fee.get_service_level_display()})" for fee in fees]) ) admin_display_base_fees.short_description = "Base Fees" @@ -561,7 +561,7 @@ class VSHNAppCatAddonBaseFeeInline(admin.TabularInline): model = VSHNAppCatAddonBaseFee extra = 1 - fields = ("currency", "amount") + fields = ("currency", "service_level", "amount") class VSHNAppCatAddonUnitRateInline(admin.TabularInline): @@ -618,7 +618,7 @@ class VSHNAppCatAddonAdmin(admin.ModelAdmin): if not fees: return "No base fees set" return format_html( - "
".join([f"{fee.amount} {fee.currency}" for fee in fees]) + "
".join([f"{fee.amount} {fee.currency} ({fee.get_service_level_display()})" for fee in fees]) ) elif obj.addon_type == "UR": # Unit Rate rates = obj.unit_rates.all() diff --git a/hub/services/migrations/0036_alter_vshnappcataddonbasefee_options_and_more.py b/hub/services/migrations/0036_alter_vshnappcataddonbasefee_options_and_more.py new file mode 100644 index 0000000..ba0fe26 --- /dev/null +++ b/hub/services/migrations/0036_alter_vshnappcataddonbasefee_options_and_more.py @@ -0,0 +1,79 @@ +# Generated by Django 5.2 on 2025-06-20 13:33 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("services", "0035_alter_article_image_vshnappcataddon_and_more"), + ] + + operations = [ + migrations.AlterModelOptions( + name="vshnappcataddonbasefee", + options={ + "ordering": ["currency", "service_level"], + "verbose_name": "Addon Base Fee", + }, + ), + migrations.AlterModelOptions( + name="vshnappcatbasefee", + options={ + "ordering": ["currency", "service_level"], + "verbose_name": "Service Base Fee", + }, + ), + migrations.AlterUniqueTogether( + name="vshnappcataddonbasefee", + unique_together=set(), + ), + migrations.AlterUniqueTogether( + name="vshnappcatbasefee", + unique_together=set(), + ), + migrations.AddField( + model_name="vshnappcataddonbasefee", + name="service_level", + field=models.CharField( + choices=[("BE", "Best Effort"), ("GA", "Guaranteed Availability")], + default="BE", + max_length=2, + ), + ), + migrations.AddField( + model_name="vshnappcatbasefee", + name="service_level", + field=models.CharField( + choices=[("BE", "Best Effort"), ("GA", "Guaranteed Availability")], + default="BE", + max_length=2, + ), + ), + migrations.AlterField( + model_name="vshnappcataddonbasefee", + name="amount", + field=models.DecimalField( + decimal_places=2, + help_text="Base fee in the specified currency and service level, excl. VAT", + max_digits=10, + ), + ), + migrations.AlterField( + model_name="vshnappcatbasefee", + name="amount", + field=models.DecimalField( + decimal_places=2, + help_text="Base fee in the specified currency and service level, excl. VAT", + max_digits=10, + ), + ), + migrations.AlterUniqueTogether( + name="vshnappcataddonbasefee", + unique_together={("addon", "currency", "service_level")}, + ), + migrations.AlterUniqueTogether( + name="vshnappcatbasefee", + unique_together={("vshn_appcat_price_config", "currency", "service_level")}, + ), + ] diff --git a/hub/services/models/pricing.py b/hub/services/models/pricing.py index 0d22ef2..62ae0ba 100644 --- a/hub/services/models/pricing.py +++ b/hub/services/models/pricing.py @@ -250,29 +250,6 @@ class DiscountTier(models.Model): 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)" @@ -325,12 +302,6 @@ class VSHNAppCatPrice(models.Model): 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( @@ -346,7 +317,7 @@ class VSHNAppCatPrice(models.Model): number_of_units: int, addon_ids=None, ): - base_fee = self.get_base_fee(currency_code) + base_fee = self.get_base_fee(currency_code, service_level) unit_rate = self.get_unit_rate(currency_code, service_level) if base_fee is None or unit_rate is None: @@ -380,7 +351,7 @@ class VSHNAppCatPrice(models.Model): for addon in addons: addon_price = 0 if addon.addon_type == VSHNAppCatAddon.AddonType.BASE_FEE: - addon_price_value = addon.get_price(currency_code) + addon_price_value = addon.get_price(currency_code, service_level) if addon_price_value: addon_price = addon_price_value elif addon.addon_type == VSHNAppCatAddon.AddonType.UNIT_RATE: @@ -408,6 +379,15 @@ class VSHNAppCatPrice(models.Model): "addon_breakdown": addon_breakdown, } + def get_base_fee(self, currency_code: str, service_level: str): + """ + Get the base fee for the given currency and service level. + """ + try: + return self.base_fees.get(currency=currency_code, service_level=service_level).amount + except VSHNAppCatBaseFee.DoesNotExist: + return None + class VSHNAppCatUnitRate(models.Model): vshn_appcat_price_config = models.ForeignKey( @@ -477,10 +457,16 @@ class VSHNAppCatAddon(models.Model): return f"{self.vshn_appcat_price_config.service.name} - {self.name}" def get_price(self, currency_code: str, service_level: str = None): - """Get the price for this addon in the specified currency and service level""" + """ + Get the price for this addon in the specified currency and service level. + For base fee addons, service_level is required and used. + For unit rate addons, service_level is required and used. + """ try: if self.addon_type == self.AddonType.BASE_FEE: - return self.base_fees.get(currency=currency_code).amount + if not service_level: + raise ValueError("Service level is required for base fee addons") + return self.base_fees.get(currency=currency_code, service_level=service_level).amount elif self.addon_type == self.AddonType.UNIT_RATE: if not service_level: raise ValueError("Service level is required for unit rate addons") @@ -495,8 +481,9 @@ class VSHNAppCatAddon(models.Model): class VSHNAppCatAddonBaseFee(models.Model): - """Base fee for an addon (fixed amount regardless of units)""" - + """ + Base fee for an addon (fixed amount regardless of units), specified per currency and service level. + """ addon = models.ForeignKey( VSHNAppCatAddon, on_delete=models.CASCADE, related_name="base_fees" ) @@ -504,19 +491,27 @@ class VSHNAppCatAddonBaseFee(models.Model): max_length=3, choices=Currency.choices, ) + service_level = models.CharField( + max_length=2, + choices=VSHNAppCatPrice.ServiceLevel.choices, + default=VSHNAppCatPrice.ServiceLevel.BEST_EFFORT, + ) amount = models.DecimalField( max_digits=10, decimal_places=2, - help_text="Base fee in the specified currency, excl. VAT", + help_text="Base fee in the specified currency and service level, excl. VAT", ) class Meta: verbose_name = "Addon Base Fee" - unique_together = ("addon", "currency") - ordering = ["currency"] + unique_together = ("addon", "currency", "service_level") + ordering = ["currency", "service_level"] def __str__(self): - return f"{self.addon.name} Base Fee - {self.amount} {self.currency}" + return f"{self.addon.name} Base Fee - {self.amount} {self.currency} ({self.get_service_level_display()})" + + def get_service_level_display(self): + return dict(VSHNAppCatPrice.ServiceLevel.choices).get(self.service_level, self.service_level) class VSHNAppCatAddonUnitRate(models.Model): @@ -548,6 +543,40 @@ class VSHNAppCatAddonUnitRate(models.Model): return f"{self.addon.name} - {self.get_service_level_display()} Unit Rate - {self.amount} {self.currency}" +class VSHNAppCatBaseFee(models.Model): + """ + Base fee for a service, specified per currency and service level. + """ + vshn_appcat_price_config = models.ForeignKey( + "VSHNAppCatPrice", on_delete=models.CASCADE, related_name="base_fees" + ) + currency = models.CharField( + max_length=3, + choices=Currency.choices, + ) + service_level = models.CharField( + max_length=2, + choices=VSHNAppCatPrice.ServiceLevel.choices, + default=VSHNAppCatPrice.ServiceLevel.BEST_EFFORT, + ) + amount = models.DecimalField( + max_digits=10, + decimal_places=2, + help_text="Base fee in the specified currency and service level, excl. VAT", + ) + + class Meta: + verbose_name = "Service Base Fee" + 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} Base Fee - {self.amount} {self.currency} ({self.get_service_level_display()})" + + def get_service_level_display(self): + return dict(VSHNAppCatPrice.ServiceLevel.choices).get(self.service_level, self.service_level) + + class ExternalPricePlans(models.Model): plan_name = models.CharField() description = models.CharField(max_length=200, blank=True, null=True) diff --git a/hub/services/tests/test_pricing.py b/hub/services/tests/test_pricing.py index 150335d..30b2302 100644 --- a/hub/services/tests/test_pricing.py +++ b/hub/services/tests/test_pricing.py @@ -303,14 +303,15 @@ class VSHNAppCatPriceTestCase(TestCase): base_fee = VSHNAppCatBaseFee.objects.create( vshn_appcat_price_config=self.price_config, currency=Currency.CHF, + service_level=VSHNAppCatPrice.ServiceLevel.GUARANTEED, amount=Decimal("50.00"), ) - retrieved_fee = self.price_config.get_base_fee(Currency.CHF) + retrieved_fee = self.price_config.get_base_fee(Currency.CHF, VSHNAppCatPrice.ServiceLevel.GUARANTEED) self.assertEqual(retrieved_fee, Decimal("50.00")) # Test non-existent currency - non_existent_fee = self.price_config.get_base_fee(Currency.EUR) + non_existent_fee = self.price_config.get_base_fee(Currency.EUR, VSHNAppCatPrice.ServiceLevel.GUARANTEED) self.assertIsNone(non_existent_fee) def test_unit_rate_creation_and_retrieval(self): @@ -339,6 +340,7 @@ class VSHNAppCatPriceTestCase(TestCase): VSHNAppCatBaseFee.objects.create( vshn_appcat_price_config=self.price_config, currency=Currency.CHF, + service_level=VSHNAppCatPrice.ServiceLevel.GUARANTEED, amount=Decimal("50.00"), ) @@ -364,6 +366,7 @@ class VSHNAppCatPriceTestCase(TestCase): VSHNAppCatBaseFee.objects.create( vshn_appcat_price_config=self.price_config, currency=Currency.CHF, + service_level=VSHNAppCatPrice.ServiceLevel.GUARANTEED, amount=Decimal("50.00"), ) @@ -392,6 +395,7 @@ class VSHNAppCatPriceTestCase(TestCase): VSHNAppCatBaseFee.objects.create( vshn_appcat_price_config=self.price_config, currency=Currency.CHF, + service_level=VSHNAppCatPrice.ServiceLevel.GUARANTEED, amount=Decimal("50.00"), ) @@ -413,6 +417,7 @@ class VSHNAppCatPriceTestCase(TestCase): VSHNAppCatBaseFee.objects.create( vshn_appcat_price_config=self.price_config, currency=Currency.CHF, + service_level=VSHNAppCatPrice.ServiceLevel.GUARANTEED, amount=Decimal("50.00"), ) @@ -431,6 +436,7 @@ class VSHNAppCatPriceTestCase(TestCase): VSHNAppCatBaseFee.objects.create( vshn_appcat_price_config=self.price_config, currency=Currency.CHF, + service_level=VSHNAppCatPrice.ServiceLevel.GUARANTEED, amount=Decimal("50.00"), ) @@ -473,6 +479,7 @@ class VSHNAppCatAddonTestCase(TestCase): VSHNAppCatBaseFee.objects.create( vshn_appcat_price_config=self.price_config, currency=Currency.CHF, + service_level=VSHNAppCatPrice.ServiceLevel.GUARANTEED, amount=Decimal("50.00"), ) @@ -495,11 +502,11 @@ class VSHNAppCatAddonTestCase(TestCase): # Create base fee for addon VSHNAppCatAddonBaseFee.objects.create( - addon=addon, currency=Currency.CHF, amount=Decimal("25.00") + addon=addon, currency=Currency.CHF, service_level=VSHNAppCatPrice.ServiceLevel.GUARANTEED, amount=Decimal("25.00") ) # Test get_price method - price = addon.get_price(Currency.CHF) + price = addon.get_price(Currency.CHF, VSHNAppCatPrice.ServiceLevel.GUARANTEED) self.assertEqual(price, Decimal("25.00")) def test_addon_unit_rate_type(self): @@ -553,7 +560,7 @@ class VSHNAppCatAddonTestCase(TestCase): ) VSHNAppCatAddonBaseFee.objects.create( - addon=mandatory_addon, currency=Currency.CHF, amount=Decimal("25.00") + addon=mandatory_addon, currency=Currency.CHF, service_level=VSHNAppCatPrice.ServiceLevel.GUARANTEED, amount=Decimal("25.00") ) # Create mandatory unit rate addon @@ -594,7 +601,7 @@ class VSHNAppCatAddonTestCase(TestCase): ) VSHNAppCatAddonBaseFee.objects.create( - addon=optional_addon, currency=Currency.CHF, amount=Decimal("15.00") + addon=optional_addon, currency=Currency.CHF, service_level=VSHNAppCatPrice.ServiceLevel.GUARANTEED, amount=Decimal("15.00") ) # Calculate price with selected addon diff --git a/hub/services/tests/test_pricing_edge_cases.py b/hub/services/tests/test_pricing_edge_cases.py index 87ad40d..8a59104 100644 --- a/hub/services/tests/test_pricing_edge_cases.py +++ b/hub/services/tests/test_pricing_edge_cases.py @@ -127,6 +127,7 @@ class PricingEdgeCasesTestCase(TestCase): VSHNAppCatBaseFee.objects.create( vshn_appcat_price_config=price_config, currency=Currency.CHF, + service_level=VSHNAppCatPrice.ServiceLevel.GUARANTEED, amount=Decimal("50.00"), ) @@ -208,6 +209,7 @@ class PricingEdgeCasesTestCase(TestCase): VSHNAppCatBaseFee.objects.create( vshn_appcat_price_config=price_config, currency=Currency.CHF, + service_level=VSHNAppCatPrice.ServiceLevel.GUARANTEED, amount=Decimal("0.01"), # Very small base fee ) diff --git a/hub/services/tests/test_pricing_integration.py b/hub/services/tests/test_pricing_integration.py index c9e3002..d7f54a0 100644 --- a/hub/services/tests/test_pricing_integration.py +++ b/hub/services/tests/test_pricing_integration.py @@ -204,7 +204,10 @@ class PricingIntegrationTestCase(TestCase): for currency, amount in base_fees: VSHNAppCatBaseFee.objects.create( - vshn_appcat_price_config=appcat_price, currency=currency, amount=amount + vshn_appcat_price_config=appcat_price, currency=currency, service_level=VSHNAppCatPrice.ServiceLevel.BEST_EFFORT, amount=amount + ) + VSHNAppCatBaseFee.objects.create( + vshn_appcat_price_config=appcat_price, currency=currency, service_level=VSHNAppCatPrice.ServiceLevel.GUARANTEED, amount=amount ) # Set up unit rates for different service levels and currencies @@ -237,7 +240,7 @@ class PricingIntegrationTestCase(TestCase): ) VSHNAppCatAddonBaseFee.objects.create( - addon=backup_addon, currency=Currency.CHF, amount=Decimal("15.00") + addon=backup_addon, currency=Currency.CHF, service_level=VSHNAppCatPrice.ServiceLevel.GUARANTEED, amount=Decimal("15.00") ) # Create optional addon (monitoring) @@ -317,6 +320,7 @@ class PricingIntegrationTestCase(TestCase): VSHNAppCatBaseFee.objects.create( vshn_appcat_price_config=appcat_price, currency=Currency.USD, + service_level=VSHNAppCatPrice.ServiceLevel.GUARANTEED, amount=Decimal("30.00"), ) @@ -390,6 +394,7 @@ class PricingIntegrationTestCase(TestCase): VSHNAppCatBaseFee.objects.create( vshn_appcat_price_config=redis_price, currency=Currency.CHF, + service_level=VSHNAppCatPrice.ServiceLevel.GUARANTEED, amount=Decimal("20.00"), ) @@ -454,6 +459,7 @@ class PricingIntegrationTestCase(TestCase): VSHNAppCatBaseFee.objects.create( vshn_appcat_price_config=appcat_price, currency=Currency.CHF, + service_level=VSHNAppCatPrice.ServiceLevel.GUARANTEED, amount=Decimal("40.00"), # Base fee for managed service ) @@ -474,7 +480,7 @@ class PricingIntegrationTestCase(TestCase): ) VSHNAppCatAddonBaseFee.objects.create( - addon=backup_addon, currency=Currency.CHF, amount=Decimal("25.00") + addon=backup_addon, currency=Currency.CHF, service_level=VSHNAppCatPrice.ServiceLevel.GUARANTEED, amount=Decimal("25.00") ) monitoring_addon = VSHNAppCatAddon.objects.create( @@ -501,7 +507,541 @@ class PricingIntegrationTestCase(TestCase): ) VSHNAppCatAddonBaseFee.objects.create( - addon=ssl_addon, currency=Currency.CHF, amount=Decimal("18.00") + addon=ssl_addon, currency=Currency.CHF, service_level=VSHNAppCatPrice.ServiceLevel.GUARANTEED, amount=Decimal("18.00") + ) + + # Calculate final price with all selected addons + result = appcat_price.calculate_final_price( + Currency.CHF, + VSHNAppCatPrice.ServiceLevel.GUARANTEED, + 16, # 16 GiB RAM + addon_ids=[monitoring_addon.id, ssl_addon.id], + ) + + # Expected calculation: + # Base fee: 40.00 + # RAM cost: First 8 at 6.00 = 48.00, Next 8 at 5.40 (10% discount) = 43.20 + # RAM total: 91.20 + # Mandatory backup: 25.00 + # Optional monitoring: 0.75 * 16 = 12.00 + # Optional SSL: 18.00 + # Total: 40.00 + 91.20 + 25.00 + 12.00 + 18.00 = 186.20 + + self.assertEqual(result["total_price"], Decimal("186.20")) + self.assertEqual(result["addon_total"], Decimal("55.00")) + self.assertEqual(len(result["addon_breakdown"]), 3) + + # Verify addon breakdown details + addon_names = [addon["name"] for addon in result["addon_breakdown"]] + self.assertIn("Enterprise Backup", addon_names) + self.assertIn("Advanced Monitoring", addon_names) +from decimal import Decimal +from django.test import TestCase +from django.utils import timezone + +from ..models.base import Currency, Term, Unit +from ..models.providers import CloudProvider +from ..models.services import Service, Category +from ..models.pricing import ( + ComputePlan, + ComputePlanPrice, + ComputePlanGroup, + StoragePlan, + StoragePlanPrice, + ProgressiveDiscountModel, + DiscountTier, + VSHNAppCatPrice, + VSHNAppCatBaseFee, + VSHNAppCatUnitRate, + VSHNAppCatAddon, + VSHNAppCatAddonBaseFee, + VSHNAppCatAddonUnitRate, + ExternalPricePlans, +) + + +class PricingIntegrationTestCase(TestCase): + """Integration tests for pricing models working together""" + + def setUp(self): + """Set up test data for integration tests""" + # Create cloud provider + self.cloud_provider = CloudProvider.objects.create( + name="VSHN Cloud", + slug="vshn-cloud", + description="Swiss cloud provider", + website="https://vshn.ch", + is_featured=True, + ) + + # Create service category + self.database_category = Category.objects.create( + name="Databases", slug="databases", description="Database services" + ) + + # Create database service + self.postgresql_service = Service.objects.create( + name="PostgreSQL", + slug="postgresql", + description="Managed PostgreSQL database service", + tagline="Reliable, scalable PostgreSQL", + features="High availability, automated backups, monitoring", + is_featured=True, + ) + self.postgresql_service.categories.add(self.database_category) + + # Create compute plan group + self.standard_group = ComputePlanGroup.objects.create( + name="Standard", + description="Standard compute plans", + node_label="standard", + order=1, + ) + + # Create multiple compute plans + self.small_plan = ComputePlan.objects.create( + name="Small", + vcpus=1.0, + ram=2.0, + cpu_mem_ratio=0.5, + cloud_provider=self.cloud_provider, + group=self.standard_group, + term=Term.MTH, + active=True, + ) + + self.medium_plan = ComputePlan.objects.create( + name="Medium", + vcpus=2.0, + ram=4.0, + cpu_mem_ratio=0.5, + cloud_provider=self.cloud_provider, + group=self.standard_group, + term=Term.MTH, + active=True, + ) + + self.large_plan = ComputePlan.objects.create( + name="Large", + vcpus=4.0, + ram=8.0, + cpu_mem_ratio=0.5, + cloud_provider=self.cloud_provider, + group=self.standard_group, + term=Term.MTH, + active=True, + ) + + # Create storage plan + self.ssd_storage = StoragePlan.objects.create( + name="SSD Storage", + cloud_provider=self.cloud_provider, + term=Term.MTH, + unit=Unit.GIB, + ) + + # Create progressive discount model for AppCat + self.ram_discount_model = ProgressiveDiscountModel.objects.create( + name="RAM Volume Discount", + description="Progressive discount for RAM usage", + active=True, + ) + + # Create discount tiers + DiscountTier.objects.create( + discount_model=self.ram_discount_model, + min_units=0, + max_units=8, + discount_percent=Decimal("0.00"), # 0-7 GiB: no discount + ) + + DiscountTier.objects.create( + discount_model=self.ram_discount_model, + min_units=8, + max_units=32, + discount_percent=Decimal("10.00"), # 8-31 GiB: 10% discount + ) + + DiscountTier.objects.create( + discount_model=self.ram_discount_model, + min_units=32, + max_units=None, + discount_percent=Decimal("20.00"), # 32+ GiB: 20% discount + ) + + def test_complete_pricing_setup(self): + """Test complete pricing setup for all models""" + # Set up compute plan prices + ComputePlanPrice.objects.create( + compute_plan=self.small_plan, currency=Currency.CHF, amount=Decimal("50.00") + ) + + ComputePlanPrice.objects.create( + compute_plan=self.medium_plan, + currency=Currency.CHF, + amount=Decimal("100.00"), + ) + + ComputePlanPrice.objects.create( + compute_plan=self.large_plan, + currency=Currency.CHF, + amount=Decimal("200.00"), + ) + + # Set up storage pricing + StoragePlanPrice.objects.create( + storage_plan=self.ssd_storage, + currency=Currency.CHF, + amount=Decimal("0.20"), # 0.20 CHF per GiB + ) + + # Verify all prices are retrievable + self.assertEqual(self.small_plan.get_price(Currency.CHF), Decimal("50.00")) + self.assertEqual(self.medium_plan.get_price(Currency.CHF), Decimal("100.00")) + self.assertEqual(self.large_plan.get_price(Currency.CHF), Decimal("200.00")) + self.assertEqual(self.ssd_storage.get_price(Currency.CHF), Decimal("0.20")) + + def test_multi_currency_pricing(self): + """Test pricing in multiple currencies""" + # Set up prices in CHF, EUR, and USD + currencies_and_rates = [ + (Currency.CHF, Decimal("100.00")), + (Currency.EUR, Decimal("95.00")), + (Currency.USD, Decimal("110.00")), + ] + + for currency, amount in currencies_and_rates: + ComputePlanPrice.objects.create( + compute_plan=self.medium_plan, currency=currency, amount=amount + ) + + # Verify all currencies are available + for currency, expected_amount in currencies_and_rates: + self.assertEqual(self.medium_plan.get_price(currency), expected_amount) + + def test_appcat_service_with_complete_pricing(self): + """Test complete AppCat service pricing with all features""" + # Create AppCat price configuration + appcat_price = VSHNAppCatPrice.objects.create( + service=self.postgresql_service, + variable_unit=VSHNAppCatPrice.VariableUnit.RAM, + term=Term.MTH, + discount_model=self.ram_discount_model, + ha_replica_min=1, + ha_replica_max=3, + public_display_enabled=True, + ) + + # Set up base fees for different currencies + base_fees = [ + (Currency.CHF, Decimal("25.00")), + (Currency.EUR, Decimal("22.50")), + (Currency.USD, Decimal("27.50")), + ] + + for currency, amount in base_fees: + VSHNAppCatBaseFee.objects.create( + vshn_appcat_price_config=appcat_price, currency=currency, service_level=VSHNAppCatPrice.ServiceLevel.GUARANTEED, amount=amount + ) + + # Set up unit rates for different service levels and currencies + unit_rates = [ + (Currency.CHF, VSHNAppCatPrice.ServiceLevel.BEST_EFFORT, Decimal("3.5000")), + (Currency.CHF, VSHNAppCatPrice.ServiceLevel.GUARANTEED, Decimal("5.0000")), + (Currency.EUR, VSHNAppCatPrice.ServiceLevel.BEST_EFFORT, Decimal("3.2000")), + (Currency.EUR, VSHNAppCatPrice.ServiceLevel.GUARANTEED, Decimal("4.5000")), + (Currency.USD, VSHNAppCatPrice.ServiceLevel.BEST_EFFORT, Decimal("3.8000")), + (Currency.USD, VSHNAppCatPrice.ServiceLevel.GUARANTEED, Decimal("5.5000")), + ] + + for currency, service_level, amount in unit_rates: + VSHNAppCatUnitRate.objects.create( + vshn_appcat_price_config=appcat_price, + currency=currency, + service_level=service_level, + amount=amount, + ) + + # Create mandatory addon (backup) + backup_addon = VSHNAppCatAddon.objects.create( + vshn_appcat_price_config=appcat_price, + name="Automated Backup", + description="Daily automated backups with 30-day retention", + commercial_description="Never lose your data with automated daily backups", + addon_type=VSHNAppCatAddon.AddonType.BASE_FEE, + mandatory=True, + order=1, + ) + + VSHNAppCatAddonBaseFee.objects.create( + addon=backup_addon, currency=Currency.CHF, service_level=VSHNAppCatPrice.ServiceLevel.GUARANTEED, amount=Decimal("15.00") + ) + + # Create optional addon (monitoring) + monitoring_addon = VSHNAppCatAddon.objects.create( + vshn_appcat_price_config=appcat_price, + name="Advanced Monitoring", + description="Detailed monitoring with custom alerts", + commercial_description="Get insights into your database performance", + addon_type=VSHNAppCatAddon.AddonType.UNIT_RATE, + mandatory=False, + order=2, + ) + + VSHNAppCatAddonUnitRate.objects.create( + addon=monitoring_addon, + currency=Currency.CHF, + service_level=VSHNAppCatPrice.ServiceLevel.GUARANTEED, + amount=Decimal("0.5000"), + ) + + # Test price calculation scenarios + + # Scenario 1: Small setup (4 GiB RAM, no discount) + result_small = appcat_price.calculate_final_price( + Currency.CHF, VSHNAppCatPrice.ServiceLevel.GUARANTEED, 4 + ) + + # Base: 25 + (5 * 4) = 45 + # Mandatory backup: 15 + # Total: 60 + self.assertEqual(result_small["total_price"], Decimal("60.00")) + self.assertEqual(result_small["addon_total"], Decimal("15.00")) + self.assertEqual(len(result_small["addon_breakdown"]), 1) + + # Scenario 2: Medium setup (16 GiB RAM, partial discount) + result_medium = appcat_price.calculate_final_price( + Currency.CHF, VSHNAppCatPrice.ServiceLevel.GUARANTEED, 16 + ) + + # First 8 GiB at full rate: 5 * 8 = 40 + # Next 8 GiB at 90% (10% discount): 5 * 0.9 * 8 = 36 + # Unit cost: 76 + # Base: 25 + 76 = 101 + # Mandatory backup: 15 + # Total: 116 + self.assertEqual(result_medium["total_price"], Decimal("116.00")) + + # Scenario 3: Large setup with optional addon (40 GiB RAM, full discount tiers) + result_large = appcat_price.calculate_final_price( + Currency.CHF, + VSHNAppCatPrice.ServiceLevel.GUARANTEED, + 40, + addon_ids=[monitoring_addon.id], + ) + + # First 8 GiB at full rate: 5 * 8 = 40 + # Next 24 GiB at 90% (10% discount): 5 * 0.9 * 24 = 108 + # Next 8 GiB at 80% (20% discount): 5 * 0.8 * 8 = 32 + # Unit cost: 180 + # Base: 25 + 180 = 205 + # Mandatory backup: 15 + # Optional monitoring: 0.5 * 40 = 20 + # Total: 240 + self.assertEqual(result_large["total_price"], Decimal("240.00")) + self.assertEqual(result_large["addon_total"], Decimal("35.00")) + self.assertEqual(len(result_large["addon_breakdown"]), 2) + + def test_external_price_comparison_integration(self): + """Test external price comparison with internal pricing""" + # Set up internal pricing + appcat_price = VSHNAppCatPrice.objects.create( + service=self.postgresql_service, + variable_unit=VSHNAppCatPrice.VariableUnit.RAM, + term=Term.MTH, + ) + + VSHNAppCatBaseFee.objects.create( + vshn_appcat_price_config=appcat_price, + currency=Currency.USD, + service_level=VSHNAppCatPrice.ServiceLevel.GUARANTEED, + amount=Decimal("30.00"), + ) + + VSHNAppCatUnitRate.objects.create( + vshn_appcat_price_config=appcat_price, + currency=Currency.USD, + service_level=VSHNAppCatPrice.ServiceLevel.GUARANTEED, + amount=Decimal("4.0000"), + ) + + # Create external competitor pricing + aws_provider = CloudProvider.objects.create( + name="AWS", + slug="aws", + description="Amazon Web Services", + website="https://aws.amazon.com", + ) + + external_price = ExternalPricePlans.objects.create( + plan_name="RDS PostgreSQL db.t3.medium", + description="AWS RDS PostgreSQL instance", + source="https://aws.amazon.com/rds/postgresql/pricing/", + cloud_provider=aws_provider, + service=self.postgresql_service, + vshn_appcat_price=appcat_price, + service_level=VSHNAppCatPrice.ServiceLevel.GUARANTEED, + currency=Currency.USD, + term=Term.MTH, + amount=Decimal("62.56"), # Monthly cost for db.t3.medium + vcpus=2.0, + ram=4.0, # 4 GiB RAM + storage=20.0, # 20 GiB storage included + competitor_sla="99.95%", + replicas=1, + ) + + # Compare internal vs external pricing for equivalent setup + internal_result = appcat_price.calculate_final_price( + Currency.USD, + VSHNAppCatPrice.ServiceLevel.GUARANTEED, + 4, # 4 GiB RAM to match external offering + ) + + # Internal: 30 + (4 * 4) = 46 USD + internal_price = internal_result["total_price"] + external_price_amount = external_price.amount + + self.assertEqual(internal_price, Decimal("46.00")) + self.assertEqual(external_price_amount, Decimal("62.56")) + + # Verify our pricing is competitive + self.assertLess(internal_price, external_price_amount) + + def test_service_availability_with_pricing(self): + """Test service availability based on pricing configuration""" + # Create service with pricing but not enabled for public display + redis_service = Service.objects.create( + name="Redis", + slug="redis", + description="In-memory data store", + features="High performance caching", + ) + + redis_price = VSHNAppCatPrice.objects.create( + service=redis_service, + variable_unit=VSHNAppCatPrice.VariableUnit.RAM, + term=Term.MTH, + public_display_enabled=False, # Private pricing + ) + + VSHNAppCatBaseFee.objects.create( + vshn_appcat_price_config=redis_price, + currency=Currency.CHF, + service_level=VSHNAppCatPrice.ServiceLevel.GUARANTEED, + amount=Decimal("20.00"), + ) + + # Service should exist but not be publicly available for pricing + self.assertFalse(redis_price.public_display_enabled) + + # Enable public display + redis_price.public_display_enabled = True + redis_price.save() + + self.assertTrue(redis_price.public_display_enabled) + + def test_pricing_model_relationships(self): + """Test all pricing model relationships work correctly""" + # Verify cloud provider relationships + self.assertEqual(self.cloud_provider.compute_plans.count(), 3) + self.assertEqual(self.cloud_provider.storage_plans.count(), 1) + + # Verify service relationships + self.assertTrue(hasattr(self.postgresql_service, "vshn_appcat_price")) + + # Verify compute plan group relationships + self.assertEqual(self.standard_group.compute_plans.count(), 3) + + # Create and verify discount model relationships + appcat_price = VSHNAppCatPrice.objects.create( + service=self.postgresql_service, + variable_unit=VSHNAppCatPrice.VariableUnit.RAM, + term=Term.MTH, + discount_model=self.ram_discount_model, + ) + + self.assertEqual(self.ram_discount_model.price_configs.count(), 1) + self.assertEqual(self.ram_discount_model.tiers.count(), 3) + + # Test cascade deletions work properly + service_id = self.postgresql_service.id + appcat_price_id = appcat_price.id + + # Delete service should cascade to appcat price + self.postgresql_service.delete() + + with self.assertRaises(VSHNAppCatPrice.DoesNotExist): + VSHNAppCatPrice.objects.get(id=appcat_price_id) + + def test_comprehensive_pricing_scenario(self): + """Test a comprehensive real-world pricing scenario""" + # Company needs PostgreSQL with high availability + # Requirements: 16 GiB RAM, automated backups, monitoring, SSL + + appcat_price = VSHNAppCatPrice.objects.create( + service=self.postgresql_service, + variable_unit=VSHNAppCatPrice.VariableUnit.RAM, + term=Term.MTH, + discount_model=self.ram_discount_model, + ha_replica_min=2, + ha_replica_max=3, + public_display_enabled=True, + ) + + # Set up pricing + VSHNAppCatBaseFee.objects.create( + vshn_appcat_price_config=appcat_price, + currency=Currency.CHF, + service_level=VSHNAppCatPrice.ServiceLevel.GUARANTEED, + amount=Decimal("40.00"), # Base fee for managed service + ) + + VSHNAppCatUnitRate.objects.create( + vshn_appcat_price_config=appcat_price, + currency=Currency.CHF, + service_level=VSHNAppCatPrice.ServiceLevel.GUARANTEED, + amount=Decimal("6.0000"), # CHF per GiB RAM + ) + + # Create all required addons + backup_addon = VSHNAppCatAddon.objects.create( + vshn_appcat_price_config=appcat_price, + name="Enterprise Backup", + addon_type=VSHNAppCatAddon.AddonType.BASE_FEE, + mandatory=True, + order=1, + ) + + VSHNAppCatAddonBaseFee.objects.create( + addon=backup_addon, currency=Currency.CHF, service_level=VSHNAppCatPrice.ServiceLevel.GUARANTEED, amount=Decimal("25.00") + ) + + monitoring_addon = VSHNAppCatAddon.objects.create( + vshn_appcat_price_config=appcat_price, + name="Advanced Monitoring", + addon_type=VSHNAppCatAddon.AddonType.UNIT_RATE, + mandatory=False, + order=2, + ) + + VSHNAppCatAddonUnitRate.objects.create( + addon=monitoring_addon, + currency=Currency.CHF, + service_level=VSHNAppCatPrice.ServiceLevel.GUARANTEED, + amount=Decimal("0.7500"), + ) + + ssl_addon = VSHNAppCatAddon.objects.create( + vshn_appcat_price_config=appcat_price, + name="SSL Certificate", + addon_type=VSHNAppCatAddon.AddonType.BASE_FEE, + mandatory=False, + order=3, + ) + + VSHNAppCatAddonBaseFee.objects.create( + addon=ssl_addon, currency=Currency.CHF, service_level=VSHNAppCatPrice.ServiceLevel.GUARANTEED, amount=Decimal("18.00") ) # Calculate final price with all selected addons diff --git a/hub/services/tests/test_utils.py b/hub/services/tests/test_utils.py index 2ce8d31..47d429c 100644 --- a/hub/services/tests/test_utils.py +++ b/hub/services/tests/test_utils.py @@ -81,6 +81,7 @@ class PricingTestMixin: VSHNAppCatBaseFee.objects.create( vshn_appcat_price_config=appcat_price, currency=Currency.CHF, + service_level=VSHNAppCatPrice.ServiceLevel.GUARANTEED, amount=base_fee, ) diff --git a/hub/services/views/offerings.py b/hub/services/views/offerings.py index fc5c59e..e135aec 100644 --- a/hub/services/views/offerings.py +++ b/hub/services/views/offerings.py @@ -340,7 +340,7 @@ def generate_pricing_data(offering): # Get pricing components compute_plan_price = plan.get_price(currency) - base_fee = appcat_price.get_base_fee(currency) + base_fee = appcat_price.get_base_fee(currency, service_level) unit_rate = appcat_price.get_unit_rate(currency, service_level) # Skip if any pricing component is missing @@ -380,7 +380,7 @@ def generate_pricing_data(offering): addon_price_per_unit = None if addon.addon_type == "BF": # Base Fee - addon_price = addon.get_price(currency) + addon_price = addon.get_price(currency, service_level) elif addon.addon_type == "UR": # Unit Rate addon_price_per_unit = addon.get_price(currency, service_level) if addon_price_per_unit: diff --git a/hub/services/views/pricelist.py b/hub/services/views/pricelist.py index cc3996a..d2586a1 100644 --- a/hub/services/views/pricelist.py +++ b/hub/services/views/pricelist.py @@ -60,7 +60,7 @@ def get_internal_cloud_provider_comparisons( for similar_plan in similar_plans: # Get pricing components for comparison plan compare_plan_price = similar_plan.get_price(currency) - compare_base_fee = appcat_price.get_base_fee(currency) + compare_base_fee = appcat_price.get_base_fee(currency, service_level) compare_unit_rate = appcat_price.get_unit_rate(currency, service_level) # Skip if any pricing component is missing @@ -239,7 +239,7 @@ def pricelist(request): # Get pricing components compute_plan_price = plan.get_price(currency) - base_fee = appcat_price.get_base_fee(currency) + base_fee = appcat_price.get_base_fee(currency, service_level) unit_rate = appcat_price.get_unit_rate(currency, service_level) # Skip if any pricing component is missing @@ -340,7 +340,7 @@ def pricelist(request): addon_price = None if addon.addon_type == "BF": # Base Fee - addon_price = addon.get_price(currency) + addon_price = addon.get_price(currency, service_level) elif addon.addon_type == "UR": # Unit Rate addon_price_per_unit = addon.get_price( currency, service_level From fbca67ef66b394c388190f469ee4492fda4bf96a Mon Sep 17 00:00:00 2001 From: Tobias Brunner Date: Fri, 20 Jun 2025 15:48:04 +0200 Subject: [PATCH 04/67] remove all provider and all service option from pricelist --- hub/services/templates/services/pricelist.html | 10 ++-------- hub/services/views/pricelist.py | 6 ++++++ 2 files changed, 8 insertions(+), 8 deletions(-) diff --git a/hub/services/templates/services/pricelist.html b/hub/services/templates/services/pricelist.html index f01109a..08cf877 100644 --- a/hub/services/templates/services/pricelist.html +++ b/hub/services/templates/services/pricelist.html @@ -228,22 +228,16 @@
diff --git a/hub/services/views/pricelist.py b/hub/services/views/pricelist.py index d2586a1..add5b22 100644 --- a/hub/services/views/pricelist.py +++ b/hub/services/views/pricelist.py @@ -544,6 +544,12 @@ def pricelist(request): all_compute_plan_groups.append("No Group") # Add option for plans without groups all_service_levels = [choice[1] for choice in VSHNAppCatPrice.ServiceLevel.choices] + # If no filter is specified, select the first available provider/service by default + if not filter_cloud_provider and all_cloud_providers: + filter_cloud_provider = all_cloud_providers[0] + if not filter_service and all_services: + filter_service = all_services[0] + context = { "pricing_data_by_group_and_service_level": final_context_data, "show_discount_details": show_discount_details, From dc1842ef5a0e52eb75bea5006caf77761ed6cda3 Mon Sep 17 00:00:00 2001 From: Tobias Brunner Date: Fri, 20 Jun 2025 15:53:57 +0200 Subject: [PATCH 05/67] improve performane on full pricelist view --- hub/services/views/pricelist.py | 379 ++++++++++---------------------- 1 file changed, 111 insertions(+), 268 deletions(-) diff --git a/hub/services/views/pricelist.py b/hub/services/views/pricelist.py index add5b22..b39c975 100644 --- a/hub/services/views/pricelist.py +++ b/hub/services/views/pricelist.py @@ -124,7 +124,7 @@ def get_internal_cloud_provider_comparisons( @staff_member_required def pricelist(request): - """Generate comprehensive price list grouped by compute plan groups and service levels""" + """Generate comprehensive price list grouped by compute plan groups and service levels (optimized)""" # Get filter parameters from request show_discount_details = request.GET.get("discount_details", "").lower() == "true" show_addon_details = request.GET.get("addon_details", "").lower() == "true" @@ -134,45 +134,38 @@ def pricelist(request): 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 + # Fetch all active compute plans with related data (move as much sorting/filtering to DB as possible) + compute_plans_qs = ComputePlan.objects.filter(active=True) if filter_cloud_provider: - compute_plans = compute_plans.filter(cloud_provider__name=filter_cloud_provider) + compute_plans_qs = compute_plans_qs.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) + compute_plans_qs = compute_plans_qs.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), - ), + compute_plans_qs = compute_plans_qs.filter(group__name=filter_compute_plan_group) + compute_plans = list( + compute_plans_qs + .select_related("cloud_provider", "group") + .prefetch_related("prices") + .order_by("group__order", "group__name", "cloud_provider__name", "name") ) - # Fetch all appcat price configurations - appcat_prices = ( + # Fetch all appcat price configurations (prefetch addons) + appcat_prices_qs = ( VSHNAppCatPrice.objects.all() .select_related("service", "discount_model") - .prefetch_related("base_fees", "unit_rates", "discount_model__tiers") + .prefetch_related("base_fees", "unit_rates", "discount_model__tiers", "addons") .order_by("service__name") ) - - # Apply service filter if filter_service: - appcat_prices = appcat_prices.filter(service__name=filter_service) + appcat_prices_qs = appcat_prices_qs.filter(service__name=filter_service) + appcat_prices = list(appcat_prices_qs) + + # Prefetch all storage plans for all cloud providers and build a lookup + all_storage_plans = StoragePlan.objects.all().prefetch_related("prices") + storage_plans_by_provider = defaultdict(list) + for sp in all_storage_plans: + storage_plans_by_provider[sp.cloud_provider_id].append(sp) pricing_data_by_group_and_service_level = defaultdict(lambda: defaultdict(list)) processed_combinations = set() @@ -180,7 +173,6 @@ def pricelist(request): # 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: @@ -189,39 +181,22 @@ def pricelist(request): 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() - + 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 + 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) + 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) - + 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, @@ -230,63 +205,35 @@ def pricelist(request): 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, service_level) 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] - ): + 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 - ) - ) + 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 - ) - ) + 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 - # Calculate final price using the model method to ensure consistency price_calculation = appcat_price.calculate_final_price( currency_code=currency, @@ -294,60 +241,22 @@ def pricelist(request): number_of_units=total_units, addon_ids=None, # This will include only mandatory addons ) - if price_calculation is None: continue - # Calculate base service price (without addons) for display purposes base_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 = base_sla_price - sla_price - discount_percentage = ( - (discount_savings / base_sla_price) * 100 - if base_sla_price > 0 - else 0 - ) - discount_breakdown = ( - appcat_price.discount_model.get_discount_breakdown( - unit_rate, total_units - ) - ) - else: - sla_price = base_sla_price - discounted_price = total_units * unit_rate - discount_savings = 0 - discount_percentage = 0 - - # Extract addon information from the calculation + # Extract addon information from the calculation (use prefetched addons) mandatory_addons = [] optional_addons = [] - - # Get all addons to separate mandatory from optional - all_addons = appcat_price.addons.filter(active=True) + all_addons = [a for a in appcat_price.addons.all() if a.active] for addon in all_addons: addon_price = None - if addon.addon_type == "BF": # Base Fee addon_price = addon.get_price(currency, service_level) elif addon.addon_type == "UR": # Unit Rate - addon_price_per_unit = addon.get_price( - currency, service_level - ) + addon_price_per_unit = addon.get_price(currency, service_level) if addon_price_per_unit: addon_price = addon_price_per_unit * total_units - addon_info = { "id": addon.id, "name": addon.name, @@ -356,165 +265,102 @@ def pricelist(request): "addon_type": addon.get_addon_type_display(), "price": addon_price, } - if addon.mandatory: mandatory_addons.append(addon_info) else: optional_addons.append(addon_info) - - # Use the calculated total price which includes mandatory addons service_price_with_addons = price_calculation["total_price"] final_price = compute_plan_price + service_price_with_addons - service_level_display = dict(VSHNAppCatPrice.ServiceLevel.choices)[ - service_level - ] - - # Get external price comparisons if enabled + service_level_display = dict(VSHNAppCatPrice.ServiceLevel.choices).get(service_level, service_level) + # Get external/internal price comparisons if enabled (unchanged, but could be optimized further) external_comparisons = [] internal_comparisons = [] if show_price_comparison: - # Get external price comparisons - external_prices = get_external_price_comparisons( - plan, appcat_price, currency, service_level - ) + 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, - "is_internal": False, - } - ) - - # Get internal cloud provider comparisons - internal_price_comparisons = ( - get_internal_cloud_provider_comparisons( - plan, appcat_price, currency, service_level - ) - ) + 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, + "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, + "is_internal": False, + }) + internal_price_comparisons = get_internal_cloud_provider_comparisons(plan, appcat_price, currency, service_level) for int_price in internal_price_comparisons: - # Calculate price difference difference = int_price["final_price"] - final_price - ratio = ( - int_price["final_price"] / final_price - if final_price > 0 - else 0 - ) - - internal_comparisons.append( - { - "plan_name": int_price["plan_name"], - "provider": int_price["provider"], - "description": f"Same specs with {int_price['provider']}", - "amount": int_price["final_price"], - "currency": int_price["currency"], - "vcpus": int_price["vcpus"], - "ram": int_price["ram"], - "group_name": int_price["group_name"], - "compute_plan_price": int_price[ - "compute_plan_price" - ], - "service_price": int_price["service_price"], - "difference": difference, - "ratio": ratio, - "is_internal": True, - } - ) - + ratio = int_price["final_price"] / final_price if final_price > 0 else 0 + internal_comparisons.append({ + "plan_name": int_price["plan_name"], + "provider": int_price["provider"], + "description": f"Same specs with {int_price['provider']}", + "amount": int_price["final_price"], + "currency": int_price["currency"], + "vcpus": int_price["vcpus"], + "ram": int_price["ram"], + "group_name": int_price["group_name"], + "compute_plan_price": int_price["compute_plan_price"], + "service_price": int_price["service_price"], + "difference": difference, + "ratio": ratio, + "is_internal": True, + }) 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": service_price_with_addons, - "standard_sla_price": base_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, - "internal_comparisons": internal_comparisons, - "mandatory_addons": mandatory_addons, - "optional_addons": optional_addons, - } - ) - + # Use prefetched storage plans + storage_plans = storage_plans_by_provider.get(plan.cloud_provider_id, []) + 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": service_price_with_addons, + "standard_sla_price": base_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, + "internal_comparisons": internal_comparisons, + "mandatory_addons": mandatory_addons, + "optional_addons": optional_addons, + }) # 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] - ) - + 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(): @@ -522,7 +368,6 @@ def pricelist(request): 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) @@ -543,13 +388,11 @@ def pricelist(request): ) all_compute_plan_groups.append("No Group") # Add option for plans without groups all_service_levels = [choice[1] for choice in VSHNAppCatPrice.ServiceLevel.choices] - # If no filter is specified, select the first available provider/service by default if not filter_cloud_provider and all_cloud_providers: filter_cloud_provider = all_cloud_providers[0] if not filter_service and all_services: filter_service = all_services[0] - context = { "pricing_data_by_group_and_service_level": final_context_data, "show_discount_details": show_discount_details, From 95e3449015b26ed715c9c38f09f4d13cfc46514a Mon Sep 17 00:00:00 2001 From: Tobias Brunner Date: Fri, 20 Jun 2025 15:59:16 +0200 Subject: [PATCH 06/67] show inactive plans in full pricelist --- hub/services/templates/services/pricelist.html | 9 +++++++-- hub/services/views/pricelist.py | 18 +++++++----------- 2 files changed, 14 insertions(+), 13 deletions(-) diff --git a/hub/services/templates/services/pricelist.html b/hub/services/templates/services/pricelist.html index 08cf877..5cdec7b 100644 --- a/hub/services/templates/services/pricelist.html +++ b/hub/services/templates/services/pricelist.html @@ -477,8 +477,13 @@ {% for row in pricing_data %} - - {{ row.compute_plan }} + + + {{ row.compute_plan }} + {% if not row.is_active %} + Inactive plan + {% endif %} + {{ row.cloud_provider }} {{ row.vcpus }} {{ row.ram }} diff --git a/hub/services/views/pricelist.py b/hub/services/views/pricelist.py index b39c975..6e89f97 100644 --- a/hub/services/views/pricelist.py +++ b/hub/services/views/pricelist.py @@ -2,12 +2,7 @@ import re from django.shortcuts import render from collections import defaultdict -from hub.services.models import ( - ComputePlan, - VSHNAppCatPrice, - ExternalPricePlans, - StoragePlan, -) +from hub.services.models.pricing import ComputePlan, StoragePlan, ExternalPricePlans, VSHNAppCatPrice from django.contrib.admin.views.decorators import staff_member_required from django.db import models @@ -134,8 +129,8 @@ def pricelist(request): 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 (move as much sorting/filtering to DB as possible) - compute_plans_qs = ComputePlan.objects.filter(active=True) + # Fetch all compute plans (active and inactive) with related data + compute_plans_qs = ComputePlan.objects.all() if filter_cloud_provider: compute_plans_qs = compute_plans_qs.filter(cloud_provider__name=filter_cloud_provider) if filter_compute_plan_group: @@ -352,6 +347,7 @@ def pricelist(request): "internal_comparisons": internal_comparisons, "mandatory_addons": mandatory_addons, "optional_addons": optional_addons, + "is_active": plan.active, }) # Order groups correctly, placing "No Group" last ordered_groups_intermediate = {} @@ -368,9 +364,9 @@ def pricelist(request): sl_key: list(plans_list) for sl_key, plans_list in service_levels_dict.items() } - # Get filter options for dropdowns + # Get filter options for dropdowns (include all providers/groups from all plans, not just active) all_cloud_providers = ( - ComputePlan.objects.filter(active=True) + ComputePlan.objects.all() .values_list("cloud_provider__name", flat=True) .distinct() .order_by("cloud_provider__name") @@ -381,7 +377,7 @@ def pricelist(request): .order_by("service__name") ) all_compute_plan_groups = list( - ComputePlan.objects.filter(active=True, group__isnull=False) + ComputePlan.objects.filter(group__isnull=False) .values_list("group__name", flat=True) .distinct() .order_by("group__name") From e9e8d246850cafd8bc7a0ea61d3c034839e673b2 Mon Sep 17 00:00:00 2001 From: Tobias Brunner Date: Fri, 20 Jun 2025 16:04:27 +0200 Subject: [PATCH 07/67] bring back plan natural sorting --- hub/services/views/pricelist.py | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/hub/services/views/pricelist.py b/hub/services/views/pricelist.py index 6e89f97..4d12ee1 100644 --- a/hub/services/views/pricelist.py +++ b/hub/services/views/pricelist.py @@ -144,6 +144,15 @@ def pricelist(request): .prefetch_related("prices") .order_by("group__order", "group__name", "cloud_provider__name", "name") ) + # Restore natural sorting of compute plan names + compute_plans = sorted( + compute_plans, + key=lambda p: ( + p.group.order if p.group else 999, + p.group.name if p.group else "ZZZ", + natural_sort_key(p.name), + ), + ) # Fetch all appcat price configurations (prefetch addons) appcat_prices_qs = ( From 96b667dd756aa1792885e1aacdb6544994ad6f66 Mon Sep 17 00:00:00 2001 From: Tobias Brunner Date: Fri, 20 Jun 2025 16:20:04 +0200 Subject: [PATCH 08/67] bring back plan natural sorting - really --- hub/services/views/pricelist.py | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/hub/services/views/pricelist.py b/hub/services/views/pricelist.py index 4d12ee1..34d34b6 100644 --- a/hub/services/views/pricelist.py +++ b/hub/services/views/pricelist.py @@ -7,10 +7,11 @@ 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 natural_sort_key(obj): + """Extract numeric parts for natural sorting (works for any plan name)""" + name = obj.name if hasattr(obj, 'name') else str(obj) + parts = re.split(r"(\d+)", name) + return [int(part) if part.isdigit() else part for part in parts] def get_external_price_comparisons(plan, appcat_price, currency, service_level): @@ -150,7 +151,7 @@ def pricelist(request): key=lambda p: ( p.group.order if p.group else 999, p.group.name if p.group else "ZZZ", - natural_sort_key(p.name), + natural_sort_key(p), ), ) From 3b8eea9c14ae9051d5e729dd99c3b99dde78159b Mon Sep 17 00:00:00 2001 From: Tobias Brunner Date: Mon, 23 Jun 2025 10:00:19 +0200 Subject: [PATCH 09/67] model for classic plan pricing --- .../0037_remove_plan_pricing_planprice.py | 63 +++++++++++++++++++ hub/services/models/services.py | 37 ++++++++++- 2 files changed, 98 insertions(+), 2 deletions(-) create mode 100644 hub/services/migrations/0037_remove_plan_pricing_planprice.py diff --git a/hub/services/migrations/0037_remove_plan_pricing_planprice.py b/hub/services/migrations/0037_remove_plan_pricing_planprice.py new file mode 100644 index 0000000..5326161 --- /dev/null +++ b/hub/services/migrations/0037_remove_plan_pricing_planprice.py @@ -0,0 +1,63 @@ +# Generated by Django 5.2 on 2025-06-23 07:58 + +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("services", "0036_alter_vshnappcataddonbasefee_options_and_more"), + ] + + operations = [ + migrations.RemoveField( + model_name="plan", + name="pricing", + ), + migrations.CreateModel( + name="PlanPrice", + 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, + ), + ), + ( + "plan", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="plan_prices", + to="services.plan", + ), + ), + ], + options={ + "ordering": ["currency"], + "unique_together": {("plan", "currency")}, + }, + ), + ] diff --git a/hub/services/models/services.py b/hub/services/models/services.py index 5c155b8..e5b90cb 100644 --- a/hub/services/models/services.py +++ b/hub/services/models/services.py @@ -5,7 +5,13 @@ 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 .base import ( + Category, + ReusableText, + ManagedServiceProvider, + validate_image_size, + Currency, +) from .providers import CloudProvider @@ -97,10 +103,31 @@ class ServiceOffering(models.Model): ) +class PlanPrice(models.Model): + plan = models.ForeignKey( + "Plan", on_delete=models.CASCADE, related_name="plan_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 = ("plan", "currency") + ordering = ["currency"] + + def __str__(self): + return f"{self.plan.name} - {self.amount} {self.currency}" + + 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, @@ -122,6 +149,12 @@ class Plan(models.Model): def __str__(self): return f"{self.offering} - {self.name}" + def get_price(self, currency_code: str): + price_obj = PlanPrice.objects.filter(plan=self, currency=currency_code).first() + if price_obj: + return price_obj.amount + return None + class ExternalLinkOffering(models.Model): offering = models.ForeignKey( From 656b59904ca5591c5308fa6b6c64338f22f6d5a3 Mon Sep 17 00:00:00 2001 From: Tobias Brunner Date: Mon, 23 Jun 2025 11:37:26 +0200 Subject: [PATCH 10/67] classic plan new styling --- .../templates/services/offering_detail.html | 137 +++++++++++++----- 1 file changed, 98 insertions(+), 39 deletions(-) diff --git a/hub/services/templates/services/offering_detail.html b/hub/services/templates/services/offering_detail.html index 000d13d..b5c946f 100644 --- a/hub/services/templates/services/offering_detail.html +++ b/hub/services/templates/services/offering_detail.html @@ -7,6 +7,26 @@ {% block extra_js %} + {% endblock %} {% block content %} @@ -400,40 +420,90 @@ {% elif offering.plans.all %} -

Available Plans

-
- {% for plan in offering.plans.all %} -
-
-
-

{{ plan.name }}

- {% if plan.plan_description %} -
- {{ plan.plan_description.text|safe }} +

Choose your Plan

+
+
+ {% for plan in offering.plans.all %} +
+
+
+
+ {{ plan.name }} +
+ + + {% if plan.plan_description %} +
+ Description +
+ {{ plan.plan_description.text|safe }} +
+
+ {% endif %} + + {% if plan.description %} +
+ Details +
+ {{ plan.description|safe }} +
+
+ {% endif %} + + + {% if plan.plan_prices.exists %} +
+
+ Pricing +
+ {% for price in plan.plan_prices.all %} +
+ Monthly Price + {{ price.currency }} {{ price.amount }} +
+ {% endfor %} + + + Prices exclude VAT. Monthly pricing based on 30 days. + +
+ {% else %} +
+
+ Contact us for pricing details +
+
+ {% endif %} + + +
- {% endif %} - {% if plan.description %} -
- {{ plan.description|safe }} -
- {% endif %} - {% if plan.pricing %} -
- {{ plan.pricing|safe }} -
- {% endif %}
+ {% empty %} +
+
+

No plans available yet.

+

I'm interested in this offering

+ {% embedded_contact_form source="Offering Interest" service=offering.service offering_id=offering.id %} +
+
+ {% endfor %}
- {% empty %} -
-
-

No plans available yet.

-

I'm interested in this offering

- {% embedded_contact_form source="Offering Interest" service=offering.service offering_id=offering.id %} +
+ + +
+

Order Your Plan

+
+
+ {% embedded_contact_form source="Plan Order" service=offering.service offering_id=offering.id choices=offering.plans.all choice_label="Select a Plan" %}
- {% endfor %}
{% else %} @@ -443,17 +513,6 @@ {% embedded_contact_form source="Offering Interest" service=offering.service offering_id=offering.id %}
{% endif %} - - {% if offering.plans.exists and not pricing_data_by_group_and_service_level %} -
-

I'm interested in a plan

-
-
- {% embedded_contact_form source="Plan Order" service=offering.service offering_id=offering.id choices=offering.plans.all choice_label="Select a Plan" %} -
-
-
- {% endif %}
From 9e0ccb6025eb4df3721c84487b8fb3465b4a7747 Mon Sep 17 00:00:00 2001 From: Tobias Brunner Date: Mon, 23 Jun 2025 11:58:09 +0200 Subject: [PATCH 11/67] plan price admin --- hub/services/admin/services.py | 72 +++++++++++++++++++++++++++++++--- hub/settings.py | 2 + 2 files changed, 69 insertions(+), 5 deletions(-) diff --git a/hub/services/admin/services.py b/hub/services/admin/services.py index 44f848b..8d86c08 100644 --- a/hub/services/admin/services.py +++ b/hub/services/admin/services.py @@ -5,7 +5,14 @@ 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 +from ..models import ( + Service, + ServiceOffering, + ExternalLink, + ExternalLinkOffering, + Plan, + PlanPrice, +) class ExternalLinkInline(admin.TabularInline): @@ -26,14 +33,22 @@ class ExternalLinkOfferingInline(admin.TabularInline): ordering = ("order", "description") +class PlanPriceInline(admin.TabularInline): + """Inline admin for PlanPrice model""" + + model = PlanPrice + extra = 1 + fields = ("currency", "amount") + ordering = ("currency",) + + class PlanInline(admin.StackedInline): """Inline admin for Plan model""" model = Plan extra = 1 - fieldsets = ( - (None, {"fields": ("name", "description", "pricing", "plan_description")}), - ) + fieldsets = ((None, {"fields": ("name", "description", "plan_description")}),) + show_change_link = True # This allows clicking through to the Plan admin where prices can be managed class OfferingInline(admin.StackedInline): @@ -102,7 +117,54 @@ class ServiceAdmin(admin.ModelAdmin): class ServiceOfferingAdmin(admin.ModelAdmin): """Admin configuration for ServiceOffering model""" - list_display = ("service", "cloud_provider") + list_display = ("service", "cloud_provider", "plan_count", "total_prices") list_filter = ("service", "cloud_provider") search_fields = ("service__name", "cloud_provider__name", "description") inlines = [ExternalLinkOfferingInline, PlanInline] + + def plan_count(self, obj): + """Display number of plans for this offering""" + return obj.plans.count() + + plan_count.short_description = "Plans" + + def total_prices(self, obj): + """Display total number of plan prices for this offering""" + total = sum(plan.plan_prices.count() for plan in obj.plans.all()) + return f"{total} prices" + + total_prices.short_description = "Total Prices" + + +@admin.register(Plan) +class PlanAdmin(admin.ModelAdmin): + """Admin configuration for Plan model""" + + list_display = ("name", "offering", "price_summary") + list_filter = ("offering__service", "offering__cloud_provider") + search_fields = ("name", "description", "offering__service__name") + inlines = [PlanPriceInline] + + def price_summary(self, obj): + """Display a summary of prices for this plan""" + prices = obj.plan_prices.all() + if prices: + price_strs = [f"{price.amount} {price.currency}" for price in prices] + return ", ".join(price_strs) + return "No prices set" + + price_summary.short_description = "Prices" + + +@admin.register(PlanPrice) +class PlanPriceAdmin(admin.ModelAdmin): + """Admin configuration for PlanPrice model""" + + list_display = ("plan", "currency", "amount") + list_filter = ( + "currency", + "plan__offering__service", + "plan__offering__cloud_provider", + ) + search_fields = ("plan__name", "plan__offering__service__name") + ordering = ("plan__offering__service__name", "plan__name", "currency") diff --git a/hub/settings.py b/hub/settings.py index 409b3d6..8e6f3e6 100644 --- a/hub/settings.py +++ b/hub/settings.py @@ -255,6 +255,8 @@ JAZZMIN_SETTINGS = { "services.ProgressiveDiscountModel": "single", "services.VSHNAppCatPrice": "single", "services.VSHNAppCatAddon": "single", + "services.ServiceOffering": "single", + "services.Plan": "single", }, "related_modal_active": True, } From c93f8de717a8b5548dba2cb6660198e7441235ea Mon Sep 17 00:00:00 2001 From: Tobias Brunner Date: Mon, 23 Jun 2025 13:13:27 +0200 Subject: [PATCH 12/67] introduce plan ordering and marking as best --- hub/services/admin/services.py | 18 +++-- .../0038_add_plan_ordering_and_best.py | 32 ++++++++ hub/services/models/services.py | 28 ++++++- .../templates/services/offering_detail.html | 75 +++++++++++++++---- 4 files changed, 132 insertions(+), 21 deletions(-) create mode 100644 hub/services/migrations/0038_add_plan_ordering_and_best.py diff --git a/hub/services/admin/services.py b/hub/services/admin/services.py index 8d86c08..c975884 100644 --- a/hub/services/admin/services.py +++ b/hub/services/admin/services.py @@ -43,11 +43,14 @@ class PlanPriceInline(admin.TabularInline): class PlanInline(admin.StackedInline): - """Inline admin for Plan model""" + """Inline admin for Plan model with sortable ordering""" model = Plan extra = 1 - fieldsets = ((None, {"fields": ("name", "description", "plan_description")}),) + fieldsets = ( + (None, {"fields": ("name", "description", "plan_description")}), + ("Display Options", {"fields": ("is_best",)}), + ) show_change_link = True # This allows clicking through to the Plan admin where prices can be managed @@ -138,12 +141,17 @@ class ServiceOfferingAdmin(admin.ModelAdmin): @admin.register(Plan) class PlanAdmin(admin.ModelAdmin): - """Admin configuration for Plan model""" + """Admin configuration for Plan model with sortable ordering""" - list_display = ("name", "offering", "price_summary") - list_filter = ("offering__service", "offering__cloud_provider") + list_display = ("name", "offering", "is_best", "price_summary", "order") + list_filter = ("offering__service", "offering__cloud_provider", "is_best") search_fields = ("name", "description", "offering__service__name") + list_editable = ("is_best",) inlines = [PlanPriceInline] + fieldsets = ( + (None, {"fields": ("name", "offering", "description", "plan_description")}), + ("Display Options", {"fields": ("is_best", "order")}), + ) def price_summary(self, obj): """Display a summary of prices for this plan""" diff --git a/hub/services/migrations/0038_add_plan_ordering_and_best.py b/hub/services/migrations/0038_add_plan_ordering_and_best.py new file mode 100644 index 0000000..b02be24 --- /dev/null +++ b/hub/services/migrations/0038_add_plan_ordering_and_best.py @@ -0,0 +1,32 @@ +# Generated by Django 5.2 on 2025-06-23 10:34 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("services", "0037_remove_plan_pricing_planprice"), + ] + + operations = [ + migrations.AlterModelOptions( + name="plan", + options={"ordering": ["order", "name"]}, + ), + migrations.AddField( + model_name="plan", + name="is_best", + field=models.BooleanField( + default=False, help_text="Mark this plan as the best/recommended option" + ), + ), + migrations.AddField( + model_name="plan", + name="order", + field=models.PositiveIntegerField( + default=0, + help_text="Order of this plan in the offering (lower numbers appear first)", + ), + ), + ] diff --git a/hub/services/models/services.py b/hub/services/models/services.py index e5b90cb..8b6984d 100644 --- a/hub/services/models/services.py +++ b/hub/services/models/services.py @@ -139,16 +139,42 @@ class Plan(models.Model): ServiceOffering, on_delete=models.CASCADE, related_name="plans" ) + # Ordering and highlighting fields + order = models.PositiveIntegerField( + default=0, + help_text="Order of this plan in the offering (lower numbers appear first)", + ) + is_best = models.BooleanField( + default=False, help_text="Mark this plan as the best/recommended option" + ) + created_at = models.DateTimeField(auto_now_add=True) updated_at = models.DateTimeField(auto_now=True) class Meta: - ordering = ["name"] + ordering = ["order", "name"] unique_together = [["offering", "name"]] def __str__(self): return f"{self.offering} - {self.name}" + def clean(self): + # Ensure only one plan per offering can be marked as "best" + if self.is_best: + existing_best = Plan.objects.filter( + offering=self.offering, is_best=True + ).exclude(pk=self.pk) + if existing_best.exists(): + from django.core.exceptions import ValidationError + + raise ValidationError( + "Only one plan per offering can be marked as the best option." + ) + + def save(self, *args, **kwargs): + self.clean() + super().save(*args, **kwargs) + def get_price(self, currency_code: str): price_obj = PlanPrice.objects.filter(plan=self, currency=currency_code).first() if price_obj: diff --git a/hub/services/templates/services/offering_detail.html b/hub/services/templates/services/offering_detail.html index b5c946f..ee4f684 100644 --- a/hub/services/templates/services/offering_detail.html +++ b/hub/services/templates/services/offering_detail.html @@ -7,6 +7,39 @@ {% block extra_js %} + + + +{% json_ld_structured_data %} + - {% endblock %} {% block content %} diff --git a/hub/services/templatetags/json_ld_tags.py b/hub/services/templatetags/json_ld_tags.py index e95ba70..eb18007 100644 --- a/hub/services/templatetags/json_ld_tags.py +++ b/hub/services/templatetags/json_ld_tags.py @@ -225,19 +225,21 @@ def json_ld_structured_data(context): # Add offers if available if hasattr(offering, "plans") and offering.plans.exists(): # Get all plans with pricing - plans_with_prices = offering.plans.filter(plan_prices__isnull=False).distinct() - + plans_with_prices = offering.plans.filter( + plan_prices__isnull=False + ).distinct() + if plans_with_prices.exists(): # Create individual offers for each plan offers = [] all_prices = [] - + for plan in plans_with_prices: plan_prices = plan.plan_prices.all() if plan_prices.exists(): first_price = plan_prices.first() all_prices.extend([p.amount for p in plan_prices]) - + offer = { "@type": "Offer", "name": plan.name, @@ -245,25 +247,19 @@ def json_ld_structured_data(context): "priceCurrency": first_price.currency, "availability": "https://schema.org/InStock", "url": offering_url + "#plan-order-form", - "seller": { - "@type": "Organization", - "name": "VSHN" - } + "seller": {"@type": "Organization", "name": "VSHN"}, } offers.append(offer) - + # Add aggregate offer with all individual offers data["offers"] = { "@type": "AggregateOffer", "availability": "https://schema.org/InStock", "offerCount": len(offers), "offers": offers, - "seller": { - "@type": "Organization", - "name": "VSHN" - } + "seller": {"@type": "Organization", "name": "VSHN"}, } - + # Add lowPrice, highPrice and priceCurrency if we have prices if all_prices: data["offers"]["lowPrice"] = str(min(all_prices)) @@ -272,7 +268,7 @@ def json_ld_structured_data(context): first_plan_with_prices = plans_with_prices.first() first_currency = first_plan_with_prices.plan_prices.first().currency data["offers"]["priceCurrency"] = first_currency - + # Note: aggregateRating and review fields are not included as this is a B2B # service marketplace without a review system. These could be added in the future # if customer reviews/ratings are implemented. @@ -289,10 +285,7 @@ def json_ld_structured_data(context): "@type": "AggregateOffer", "availability": "https://schema.org/InStock", "offerCount": offering.plans.count(), - "seller": { - "@type": "Organization", - "name": "VSHN" - } + "seller": {"@type": "Organization", "name": "VSHN"}, } else: From 25d4164bae8f51821779131bb4e41a5f6f4cd40b Mon Sep 17 00:00:00 2001 From: Tobias Brunner Date: Mon, 23 Jun 2025 14:22:33 +0200 Subject: [PATCH 16/67] only run tests on main and PRs --- .forgejo/workflows/test.yaml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.forgejo/workflows/test.yaml b/.forgejo/workflows/test.yaml index a0cb310..1bc048c 100644 --- a/.forgejo/workflows/test.yaml +++ b/.forgejo/workflows/test.yaml @@ -2,7 +2,7 @@ name: Django Tests on: push: - branches: ["*"] + branches: [main] pull_request: jobs: @@ -31,4 +31,4 @@ jobs: -w /app \ -e SECRET_KEY=dummysecretkey \ website:test \ - sh -c 'uv run --extra dev manage.py migrate --noinput && uv run --extra dev manage.py test hub.services.tests --verbosity=2' \ No newline at end of file + sh -c 'uv run --extra dev manage.py migrate --noinput && uv run --extra dev manage.py test hub.services.tests --verbosity=2' From 0a3837d1e197c37062e5f16b94fbb1c251054932 Mon Sep 17 00:00:00 2001 From: Tobias Brunner Date: Mon, 23 Jun 2025 14:22:52 +0200 Subject: [PATCH 17/67] set service level for test --- hub/services/tests/test_pricing_edge_cases.py | 8 ++------ 1 file changed, 2 insertions(+), 6 deletions(-) diff --git a/hub/services/tests/test_pricing_edge_cases.py b/hub/services/tests/test_pricing_edge_cases.py index 8a59104..0363ea8 100644 --- a/hub/services/tests/test_pricing_edge_cases.py +++ b/hub/services/tests/test_pricing_edge_cases.py @@ -1,6 +1,5 @@ from decimal import Decimal from django.test import TestCase -from django.core.exceptions import ValidationError from django.utils import timezone from datetime import timedelta @@ -10,16 +9,12 @@ from ..models.services import Service from ..models.pricing import ( ComputePlan, ComputePlanPrice, - StoragePlan, - StoragePlanPrice, ProgressiveDiscountModel, DiscountTier, VSHNAppCatPrice, VSHNAppCatBaseFee, VSHNAppCatUnitRate, VSHNAppCatAddon, - VSHNAppCatAddonBaseFee, - VSHNAppCatAddonUnitRate, ExternalPricePlans, ) @@ -163,7 +158,8 @@ class PricingEdgeCasesTestCase(TestCase): ) # Should return None when price doesn't exist - price = addon.get_price(Currency.CHF) + # For BASE_FEE addons, service_level is required + price = addon.get_price(Currency.CHF, service_level="standard") self.assertIsNone(price) def test_compute_plan_with_validity_dates(self): From 7bbde80913997d610c5c1eede7e2c75ff6f4092a Mon Sep 17 00:00:00 2001 From: Tobias Brunner Date: Mon, 23 Jun 2025 16:42:28 +0200 Subject: [PATCH 18/67] show order in inline --- hub/services/admin/services.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/hub/services/admin/services.py b/hub/services/admin/services.py index c975884..41bc97f 100644 --- a/hub/services/admin/services.py +++ b/hub/services/admin/services.py @@ -49,9 +49,9 @@ class PlanInline(admin.StackedInline): extra = 1 fieldsets = ( (None, {"fields": ("name", "description", "plan_description")}), - ("Display Options", {"fields": ("is_best",)}), + ("Display Options", {"fields": ("is_best", "order")}), ) - show_change_link = True # This allows clicking through to the Plan admin where prices can be managed + show_change_link = True class OfferingInline(admin.StackedInline): From 60de2e547a15cb5498c344f208f57df6388bb3cd Mon Sep 17 00:00:00 2001 From: Tobias Brunner Date: Mon, 23 Jun 2025 16:47:30 +0200 Subject: [PATCH 19/67] wording and color improvements --- hub/services/static/css/price-calculator.css | 4 ++-- hub/services/templates/services/offering_detail.html | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/hub/services/static/css/price-calculator.css b/hub/services/static/css/price-calculator.css index 5efe5f6..a65d8cc 100644 --- a/hub/services/static/css/price-calculator.css +++ b/hub/services/static/css/price-calculator.css @@ -49,9 +49,9 @@ /* Best choice badge styling */ .badge.bg-success { - background: linear-gradient(135deg, #198754 0%, #20c997 100%) !important; border: 2px solid white; - text-shadow: 0 1px 2px rgba(0, 0, 0, 0.1); + text-shadow: 0 1px 2px rgba(0, 0, 0, 0.151); + color: rgb(255, 255, 255); white-space: nowrap; font-size: 0.75rem; padding: 0.5rem 0.75rem; diff --git a/hub/services/templates/services/offering_detail.html b/hub/services/templates/services/offering_detail.html index 7263024..e6682e3 100644 --- a/hub/services/templates/services/offering_detail.html +++ b/hub/services/templates/services/offering_detail.html @@ -473,7 +473,7 @@
From 470887c34ecda51f49d937d9330faf0fd88d8ecb Mon Sep 17 00:00:00 2001 From: Tobias Brunner Date: Fri, 27 Jun 2025 15:18:03 +0200 Subject: [PATCH 20/67] exoscale marketplace listing tweaks --- hub/services/views/offerings.py | 23 ++++++++++++++++++----- 1 file changed, 18 insertions(+), 5 deletions(-) diff --git a/hub/services/views/offerings.py b/hub/services/views/offerings.py index e135aec..4016857 100644 --- a/hub/services/views/offerings.py +++ b/hub/services/views/offerings.py @@ -173,15 +173,28 @@ def generate_exoscale_marketplace_yaml(offering): ).strip() # Build YAML structure + service_name = offering.service.name + + # List of service names that should have "Enterprise" appended + # This concerns all services which are already available on Exoscale Marketplace or DBaaS for differentiation + # A workaround because we don't particularly have "Enterprise" services yet + enterprise_services = ["GitLab", "PostgreSQL"] + + if any( + enterprise_service in service_name for enterprise_service in enterprise_services + ): + service_name += " Enterprise" + + title = f"{service_name} by Servala" yaml_structure = { yaml_key: { "page_class": "tmpl-marketplace-product", - "html_title": f"Managed {offering.service.name} by VSHN via Servala", - "meta_desc": "Servala is the Open Cloud Native Service Hub. It connects businesses, developers, and cloud service providers on one unique hub with secure, scalable, and easy-to-use cloud-native services.", - "page_header_title": f"Managed {offering.service.name} by VSHN via Servala", + "html_title": title, + "meta_desc": f"Managed {offering.service.name} by Servala - a product by VSHN. Servala is the Open Cloud Native Service Hub. It connects businesses, developers, and cloud service providers on one unique hub with secure, scalable, and easy-to-use cloud-native services.", + "page_header_title": title, "provider_key": "vshn", - "slug": f"servala-managed-{offering.service.slug}", - "title": f"Managed {offering.service.name} by VSHN via Servala", + "slug": f"{offering.service.slug}-by-servala", + "title": title, "logo": f"img/servala-{offering.service.slug}.svg", "list_display": [], "meta": [ From 6351da70ee3e6d0946d0c28c72a310ef6a0727f0 Mon Sep 17 00:00:00 2001 From: Tobias Brunner Date: Fri, 4 Jul 2025 15:51:44 +0200 Subject: [PATCH 21/67] add article date field --- hub/services/admin/articles.py | 5 ++-- .../migrations/0039_article_article_date.py | 22 +++++++++++++++++ hub/services/models/articles.py | 4 ++++ .../templates/services/article_detail.html | 4 +--- .../templates/services/article_list.html | 2 +- hub/services/views/articles.py | 24 +++++++++---------- 6 files changed, 42 insertions(+), 19 deletions(-) create mode 100644 hub/services/migrations/0039_article_article_date.py diff --git a/hub/services/admin/articles.py b/hub/services/admin/articles.py index e6dad8c..90298a4 100644 --- a/hub/services/admin/articles.py +++ b/hub/services/admin/articles.py @@ -45,7 +45,7 @@ class ArticleAdmin(admin.ModelAdmin): "image_preview", "is_published", "is_featured", - "created_at", + "article_date", ) list_filter = ( "is_published", @@ -54,11 +54,12 @@ class ArticleAdmin(admin.ModelAdmin): "related_service", "related_consulting_partner", "related_cloud_provider", - "created_at", + "article_date", ) search_fields = ("title", "excerpt", "content", "meta_keywords") prepopulated_fields = {"slug": ("title",)} readonly_fields = ("created_at", "updated_at") + ordering = ("-article_date",) def image_preview(self, obj): """Display image preview in admin list view""" diff --git a/hub/services/migrations/0039_article_article_date.py b/hub/services/migrations/0039_article_article_date.py new file mode 100644 index 0000000..69acc86 --- /dev/null +++ b/hub/services/migrations/0039_article_article_date.py @@ -0,0 +1,22 @@ +# Generated by Django 5.2 on 2025-07-04 13:48 + +import django.utils.timezone +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("services", "0038_add_plan_ordering_and_best"), + ] + + operations = [ + migrations.AddField( + model_name="article", + name="article_date", + field=models.DateField( + default=django.utils.timezone.now, + help_text="Date of the article publishing", + ), + ), + ] diff --git a/hub/services/models/articles.py b/hub/services/models/articles.py index 781c54c..b1b85e7 100644 --- a/hub/services/models/articles.py +++ b/hub/services/models/articles.py @@ -3,6 +3,7 @@ from django.urls import reverse from django.utils.text import slugify from django.contrib.auth.models import User from django_prose_editor.fields import ProseEditorField +from django.utils import timezone from .base import validate_image_size from .services import Service from .providers import CloudProvider, ConsultingPartner @@ -23,6 +24,9 @@ class Article(models.Model): help_text="Title picture for the article", ) author = models.ForeignKey(User, on_delete=models.CASCADE, related_name="articles") + article_date = models.DateField( + default=timezone.now, help_text="Date of the article publishing" + ) # Relations to other models related_service = models.ForeignKey( diff --git a/hub/services/templates/services/article_detail.html b/hub/services/templates/services/article_detail.html index 44af754..9721513 100644 --- a/hub/services/templates/services/article_detail.html +++ b/hub/services/templates/services/article_detail.html @@ -16,9 +16,7 @@
By {{ article.author.get_full_name|default:article.author.username }} - {{ article.created_at|date:"M d, Y" }} - {% if article.updated_at != article.created_at %} - {% endif %} + {{ article.article_date|date:"M d, Y" }}
diff --git a/hub/services/templates/services/article_list.html b/hub/services/templates/services/article_list.html index edb0989..ac86df4 100644 --- a/hub/services/templates/services/article_list.html +++ b/hub/services/templates/services/article_list.html @@ -169,7 +169,7 @@ By {{ article.author.get_full_name|default:article.author.username }} - {{ article.created_at|date:"M d, Y" }} + {{ article.article_date|date:"M d, Y" }}

diff --git a/hub/services/views/articles.py b/hub/services/views/articles.py index 6589d6f..4b8117f 100644 --- a/hub/services/views/articles.py +++ b/hub/services/views/articles.py @@ -23,7 +23,7 @@ def article_list(request): # Apply filters based on request parameters if search_query: articles = articles.filter( - Q(title__icontains=search_query) + Q(title__icontains=search_query) | Q(excerpt__icontains=search_query) | Q(content__icontains=search_query) | Q(meta_keywords__icontains=search_query) @@ -41,7 +41,7 @@ def article_list(request): # Order articles: featured first, then by creation date (newest first) articles = articles.order_by( "-is_featured", # Featured first (True before False) - "-created_at", # Newest first + "-article_date", # Newest first ) # Create base querysets for each filter type that apply all OTHER current filters @@ -51,7 +51,7 @@ def article_list(request): service_filter_base = all_articles if search_query: service_filter_base = service_filter_base.filter( - Q(title__icontains=search_query) + Q(title__icontains=search_query) | Q(excerpt__icontains=search_query) | Q(content__icontains=search_query) | Q(meta_keywords__icontains=search_query) @@ -69,7 +69,7 @@ def article_list(request): cp_filter_base = all_articles if search_query: cp_filter_base = cp_filter_base.filter( - Q(title__icontains=search_query) + Q(title__icontains=search_query) | Q(excerpt__icontains=search_query) | Q(content__icontains=search_query) | Q(meta_keywords__icontains=search_query) @@ -85,7 +85,7 @@ def article_list(request): cloud_filter_base = all_articles if search_query: cloud_filter_base = cloud_filter_base.filter( - Q(title__icontains=search_query) + Q(title__icontains=search_query) | Q(excerpt__icontains=search_query) | Q(content__icontains=search_query) | Q(meta_keywords__icontains=search_query) @@ -136,16 +136,14 @@ def article_detail(request, slug): Article.objects.select_related( "author", "related_service", - "related_consulting_partner", - "related_cloud_provider" + "related_consulting_partner", + "related_cloud_provider", ).filter(is_published=True), slug=slug, ) # Get related articles (same service, partner, or provider) - related_articles = Article.objects.filter( - is_published=True - ).exclude(id=article.id) + related_articles = Article.objects.filter(is_published=True).exclude(id=article.id) if article.related_service: related_articles = related_articles.filter( @@ -164,13 +162,13 @@ def article_detail(request, slug): related_articles = related_articles.filter( related_service__isnull=True, related_consulting_partner__isnull=True, - related_cloud_provider__isnull=True + related_cloud_provider__isnull=True, ) - related_articles = related_articles.order_by("-created_at")[:3] + related_articles = related_articles.order_by("-article_date")[:3] context = { "article": article, "related_articles": related_articles, } - return render(request, "services/article_detail.html", context) \ No newline at end of file + return render(request, "services/article_detail.html", context) From bdf06863d2fcd39fe57534a4ee36c3849e401690 Mon Sep 17 00:00:00 2001 From: Tobias Brunner Date: Fri, 4 Jul 2025 15:53:00 +0200 Subject: [PATCH 22/67] do not show header image in article detail view --- hub/services/templates/services/article_detail.html | 10 ---------- 1 file changed, 10 deletions(-) diff --git a/hub/services/templates/services/article_detail.html b/hub/services/templates/services/article_detail.html index 9721513..b3264e5 100644 --- a/hub/services/templates/services/article_detail.html +++ b/hub/services/templates/services/article_detail.html @@ -23,16 +23,6 @@ -{% if article.image %} -
-
-
- {{ article.title }} -
-
-
-{% endif %} -
From 52dbe895825af65ca9d9ff31e1d08c96cfeba400 Mon Sep 17 00:00:00 2001 From: Tobias Brunner Date: Fri, 4 Jul 2025 16:28:13 +0200 Subject: [PATCH 23/67] image library created using VS Codey Copilot Agent with Claude Sonnet 4 --- hub/services/admin/__init__.py | 1 + hub/services/admin/images.py | 115 +++++++ hub/services/forms/image_library.py | 149 +++++++++ hub/services/management/__init__.py | 0 hub/services/management/commands/__init__.py | 0 .../management/commands/migrate_images.py | 293 ++++++++++++++++++ .../migrations/0040_add_image_library.py | 144 +++++++++ hub/services/models/__init__.py | 1 + hub/services/models/base.py | 6 +- hub/services/models/images.py | 224 +++++++++++++ .../static/admin/css/image_library.css | 79 +++++ hub/services/templatetags/image_library.py | 112 +++++++ hub/services/utils/image_library.py | 243 +++++++++++++++ hub/settings.py | 2 + 14 files changed, 1366 insertions(+), 3 deletions(-) create mode 100644 hub/services/admin/images.py create mode 100644 hub/services/forms/image_library.py create mode 100644 hub/services/management/__init__.py create mode 100644 hub/services/management/commands/__init__.py create mode 100644 hub/services/management/commands/migrate_images.py create mode 100644 hub/services/migrations/0040_add_image_library.py create mode 100644 hub/services/models/images.py create mode 100644 hub/services/static/admin/css/image_library.css create mode 100644 hub/services/templatetags/image_library.py create mode 100644 hub/services/utils/image_library.py diff --git a/hub/services/admin/__init__.py b/hub/services/admin/__init__.py index 3c79092..fba7be9 100644 --- a/hub/services/admin/__init__.py +++ b/hub/services/admin/__init__.py @@ -4,6 +4,7 @@ from .articles import * from .base import * from .content import * +from .images import * from .leads import * from .pricing import * from .providers import * diff --git a/hub/services/admin/images.py b/hub/services/admin/images.py new file mode 100644 index 0000000..917d0db --- /dev/null +++ b/hub/services/admin/images.py @@ -0,0 +1,115 @@ +from django.contrib import admin +from django.utils.html import format_html +from django.urls import reverse +from django.utils.safestring import mark_safe +from ..models.images import ImageLibrary + + +@admin.register(ImageLibrary) +class ImageLibraryAdmin(admin.ModelAdmin): + """ + Admin interface for the Image Library. + """ + + list_display = [ + "image_thumbnail", + "name", + "category", + "get_dimensions", + "get_file_size_display", + "usage_count", + "uploaded_by", + "uploaded_at", + ] + + list_filter = [ + "category", + "uploaded_at", + "uploaded_by", + ] + + search_fields = [ + "name", + "description", + "alt_text", + "tags", + ] + + readonly_fields = [ + "width", + "height", + "file_size", + "usage_count", + "uploaded_at", + "updated_at", + "image_preview", + ] + + prepopulated_fields = {"slug": ("name",)} + + fieldsets = ( + ("Image Information", {"fields": ("name", "slug", "description", "alt_text")}), + ("Image File", {"fields": ("image", "image_preview")}), + ("Categorization", {"fields": ("category", "tags")}), + ( + "Metadata", + { + "fields": ("width", "height", "file_size", "usage_count"), + "classes": ("collapse",), + }, + ), + ( + "Timestamps", + { + "fields": ("uploaded_by", "uploaded_at", "updated_at"), + "classes": ("collapse",), + }, + ), + ) + + def image_thumbnail(self, obj): + """ + Display small thumbnail in list view. + """ + if obj.image: + return format_html( + '', + obj.image.url, + ) + return "No Image" + + image_thumbnail.short_description = "Thumbnail" + + def image_preview(self, obj): + """ + Display larger preview in detail view. + """ + if obj.image: + return format_html( + '', + obj.image.url, + ) + return "No Image" + + image_preview.short_description = "Preview" + + def get_dimensions(self, obj): + """ + Display image dimensions. + """ + if obj.width and obj.height: + return f"{obj.width} × {obj.height}" + return "Unknown" + + get_dimensions.short_description = "Dimensions" + + def save_model(self, request, obj, form, change): + """ + Set uploaded_by field to current user if not already set. + """ + if not change: # Only set on creation + obj.uploaded_by = request.user + super().save_model(request, obj, form, change) + + class Media: + css = {"all": ("admin/css/image_library.css",)} diff --git a/hub/services/forms/image_library.py b/hub/services/forms/image_library.py new file mode 100644 index 0000000..1770456 --- /dev/null +++ b/hub/services/forms/image_library.py @@ -0,0 +1,149 @@ +from django import forms +from django.utils.safestring import mark_safe +from django.utils.html import format_html +from django.urls import reverse +from ..models.images import ImageLibrary + + +class ImageLibraryWidget(forms.Select): + """ + Custom widget for selecting images from the library with thumbnails. + """ + + def __init__(self, attrs=None, choices=(), show_thumbnails=True): + self.show_thumbnails = show_thumbnails + super().__init__(attrs, choices) + + def format_value(self, value): + """ + Format the selected value for display. + """ + if value is None: + return "" + return str(value) + + def render(self, name, value, attrs=None, renderer=None): + """ + Render the widget with thumbnails. + """ + if attrs is None: + attrs = {} + + # Add CSS class for styling + attrs["class"] = attrs.get("class", "") + " image-library-select" + + # Get all images for the select options + images = ImageLibrary.objects.all().order_by("name") + + # Build choices with thumbnails + choices = [("", "--- Select an image ---")] + for image in images: + thumbnail_html = "" + if self.show_thumbnails and image.image: + thumbnail_html = format_html( + ' ', + image.image.url, + ) + + choice_text = ( + f"{image.name} ({image.get_category_display()}){thumbnail_html}" + ) + choices.append((image.pk, choice_text)) + + # Build the select element + select_html = format_html( + '', + name, + attrs.get("id", ""), + self._build_attrs_string(attrs), + self._build_options(choices, value), + ) + + # Add preview area + preview_html = "" + if value: + try: + image = ImageLibrary.objects.get(pk=value) + preview_html = format_html( + '
' + '' + '

{} - {}x{} - {}

' + "
", + image.image.url, + image.name, + image.width or "?", + image.height or "?", + image.get_file_size_display(), + ) + except ImageLibrary.DoesNotExist: + pass + + # Add JavaScript for preview updates + js_html = format_html( + "", + attrs.get("id", ""), + ) + + return mark_safe(select_html + preview_html + js_html) + + def _build_attrs_string(self, attrs): + """ + Build HTML attributes string. + """ + attr_parts = [] + for key, value in attrs.items(): + if key != "id": # id is handled separately + attr_parts.append(f'{key}="{value}"') + return " " + " ".join(attr_parts) if attr_parts else "" + + def _build_options(self, choices, selected_value): + """ + Build option elements for the select. + """ + options = [] + for value, text in choices: + selected = "selected" if str(value) == str(selected_value) else "" + options.append(f'') + return "".join(options) + + +class ImageLibraryField(forms.ModelChoiceField): + """ + Custom form field for selecting images from the library. + """ + + def __init__(self, queryset=None, widget=None, show_thumbnails=True, **kwargs): + if queryset is None: + queryset = ImageLibrary.objects.all() + + if widget is None: + widget = ImageLibraryWidget(show_thumbnails=show_thumbnails) + + super().__init__(queryset=queryset, widget=widget, **kwargs) + + def label_from_instance(self, obj): + """ + Return the label for an image instance. + """ + return f"{obj.name} ({obj.get_category_display()})" diff --git a/hub/services/management/__init__.py b/hub/services/management/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/hub/services/management/commands/__init__.py b/hub/services/management/commands/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/hub/services/management/commands/migrate_images.py b/hub/services/management/commands/migrate_images.py new file mode 100644 index 0000000..ca90dcb --- /dev/null +++ b/hub/services/management/commands/migrate_images.py @@ -0,0 +1,293 @@ +from django.core.management.base import BaseCommand +from django.core.files.base import ContentFile +from django.utils.text import slugify +from hub.services.models import ( + ImageLibrary, + Service, + CloudProvider, + ConsultingPartner, + Article, +) +import os +import shutil + + +class Command(BaseCommand): + help = "Migrate existing images to the Image Library" + + def add_arguments(self, parser): + parser.add_argument( + "--dry-run", + action="store_true", + help="Show what would be migrated without actually doing it", + ) + parser.add_argument( + "--force", + action="store_true", + help="Force migration even if images already exist in library", + ) + + def handle(self, *args, **options): + """ + Main command handler to migrate existing images to the library. + """ + dry_run = options["dry_run"] + force = options["force"] + + self.stdout.write( + self.style.SUCCESS( + f'Starting image migration {"(DRY RUN)" if dry_run else ""}' + ) + ) + + # Migrate different types of images + self.migrate_service_logos(dry_run, force) + self.migrate_cloud_provider_logos(dry_run, force) + self.migrate_partner_logos(dry_run, force) + self.migrate_article_images(dry_run, force) + + self.stdout.write( + self.style.SUCCESS( + f'Image migration completed {"(DRY RUN)" if dry_run else ""}' + ) + ) + + def migrate_service_logos(self, dry_run, force): + """ + Migrate service logos to the image library. + """ + self.stdout.write("Migrating service logos...") + + services = Service.objects.filter(logo__isnull=False).exclude(logo="") + + for service in services: + if not service.logo: + continue + + # Check if image already exists in library + existing_image = ImageLibrary.objects.filter( + name=f"{service.name} Logo" + ).first() + + if existing_image and not force: + self.stdout.write( + self.style.WARNING( + f" - Skipping {service.name} logo (already exists)" + ) + ) + continue + + if dry_run: + self.stdout.write( + self.style.SUCCESS(f" - Would migrate: {service.name} logo") + ) + continue + + # Create image library entry + image_lib = ImageLibrary( + name=f"{service.name} Logo", + slug=slugify(f"{service.name}-logo"), + description=f"Logo for {service.name} service", + alt_text=f"{service.name} logo", + category="logo", + tags=f"service, logo, {service.name.lower()}", + ) + + # Copy the image file + if service.logo and os.path.exists(service.logo.path): + with open(service.logo.path, "rb") as f: + image_lib.image.save( + os.path.basename(service.logo.name), + ContentFile(f.read()), + save=True, + ) + + self.stdout.write( + self.style.SUCCESS(f" - Migrated: {service.name} logo") + ) + else: + self.stdout.write( + self.style.ERROR( + f" - Failed to migrate: {service.name} logo (file not found)" + ) + ) + + def migrate_cloud_provider_logos(self, dry_run, force): + """ + Migrate cloud provider logos to the image library. + """ + self.stdout.write("Migrating cloud provider logos...") + + providers = CloudProvider.objects.filter(logo__isnull=False).exclude(logo="") + + for provider in providers: + if not provider.logo: + continue + + # Check if image already exists in library + existing_image = ImageLibrary.objects.filter( + name=f"{provider.name} Logo" + ).first() + + if existing_image and not force: + self.stdout.write( + self.style.WARNING( + f" - Skipping {provider.name} logo (already exists)" + ) + ) + continue + + if dry_run: + self.stdout.write( + self.style.SUCCESS(f" - Would migrate: {provider.name} logo") + ) + continue + + # Create image library entry + image_lib = ImageLibrary( + name=f"{provider.name} Logo", + slug=slugify(f"{provider.name}-logo"), + description=f"Logo for {provider.name} cloud provider", + alt_text=f"{provider.name} logo", + category="logo", + tags=f"cloud, provider, logo, {provider.name.lower()}", + ) + + # Copy the image file + if provider.logo and os.path.exists(provider.logo.path): + with open(provider.logo.path, "rb") as f: + image_lib.image.save( + os.path.basename(provider.logo.name), + ContentFile(f.read()), + save=True, + ) + + self.stdout.write( + self.style.SUCCESS(f" - Migrated: {provider.name} logo") + ) + else: + self.stdout.write( + self.style.ERROR( + f" - Failed to migrate: {provider.name} logo (file not found)" + ) + ) + + def migrate_partner_logos(self, dry_run, force): + """ + Migrate consulting partner logos to the image library. + """ + self.stdout.write("Migrating consulting partner logos...") + + partners = ConsultingPartner.objects.filter(logo__isnull=False).exclude(logo="") + + for partner in partners: + if not partner.logo: + continue + + # Check if image already exists in library + existing_image = ImageLibrary.objects.filter( + name=f"{partner.name} Logo" + ).first() + + if existing_image and not force: + self.stdout.write( + self.style.WARNING( + f" - Skipping {partner.name} logo (already exists)" + ) + ) + continue + + if dry_run: + self.stdout.write( + self.style.SUCCESS(f" - Would migrate: {partner.name} logo") + ) + continue + + # Create image library entry + image_lib = ImageLibrary( + name=f"{partner.name} Logo", + slug=slugify(f"{partner.name}-logo"), + description=f"Logo for {partner.name} consulting partner", + alt_text=f"{partner.name} logo", + category="logo", + tags=f"consulting, partner, logo, {partner.name.lower()}", + ) + + # Copy the image file + if partner.logo and os.path.exists(partner.logo.path): + with open(partner.logo.path, "rb") as f: + image_lib.image.save( + os.path.basename(partner.logo.name), + ContentFile(f.read()), + save=True, + ) + + self.stdout.write( + self.style.SUCCESS(f" - Migrated: {partner.name} logo") + ) + else: + self.stdout.write( + self.style.ERROR( + f" - Failed to migrate: {partner.name} logo (file not found)" + ) + ) + + def migrate_article_images(self, dry_run, force): + """ + Migrate article images to the image library. + """ + self.stdout.write("Migrating article images...") + + articles = Article.objects.filter(image__isnull=False).exclude(image="") + + for article in articles: + if not article.image: + continue + + # Check if image already exists in library + existing_image = ImageLibrary.objects.filter( + name=f"{article.title} Image" + ).first() + + if existing_image and not force: + self.stdout.write( + self.style.WARNING( + f" - Skipping {article.title} image (already exists)" + ) + ) + continue + + if dry_run: + self.stdout.write( + self.style.SUCCESS(f" - Would migrate: {article.title} image") + ) + continue + + # Create image library entry + image_lib = ImageLibrary( + name=f"{article.title} Image", + slug=slugify(f"{article.title}-image"), + description=f"Feature image for article: {article.title}", + alt_text=f"{article.title} feature image", + category="article", + tags=f"article, {article.title.lower()}", + ) + + # Copy the image file + if article.image and os.path.exists(article.image.path): + with open(article.image.path, "rb") as f: + image_lib.image.save( + os.path.basename(article.image.name), + ContentFile(f.read()), + save=True, + ) + + self.stdout.write( + self.style.SUCCESS(f" - Migrated: {article.title} image") + ) + else: + self.stdout.write( + self.style.ERROR( + f" - Failed to migrate: {article.title} image (file not found)" + ) + ) diff --git a/hub/services/migrations/0040_add_image_library.py b/hub/services/migrations/0040_add_image_library.py new file mode 100644 index 0000000..bc06e33 --- /dev/null +++ b/hub/services/migrations/0040_add_image_library.py @@ -0,0 +1,144 @@ +# Generated by Django 5.2 on 2025-07-04 14:19 + +import django.db.models.deletion +import hub.services.models.base +import hub.services.models.images +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("services", "0039_article_article_date"), + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.CreateModel( + name="ImageLibrary", + fields=[ + ( + "id", + models.BigAutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ( + "name", + models.CharField( + help_text="Descriptive name for the image", max_length=200 + ), + ), + ( + "slug", + models.SlugField( + help_text="URL-friendly version of the name", + max_length=250, + unique=True, + ), + ), + ( + "description", + models.TextField( + blank=True, help_text="Optional description of the image" + ), + ), + ( + "alt_text", + models.CharField( + help_text="Alternative text for accessibility", max_length=255 + ), + ), + ( + "image", + models.ImageField( + help_text="Upload image file (max 1MB)", + upload_to=hub.services.models.images.get_image_upload_path, + validators=[hub.services.models.base.validate_image_size], + ), + ), + ( + "width", + models.PositiveIntegerField( + blank=True, help_text="Image width in pixels", null=True + ), + ), + ( + "height", + models.PositiveIntegerField( + blank=True, help_text="Image height in pixels", null=True + ), + ), + ( + "file_size", + models.PositiveIntegerField( + blank=True, help_text="File size in bytes", null=True + ), + ), + ( + "category", + models.CharField( + choices=[ + ("logo", "Logo"), + ("article", "Article Image"), + ("banner", "Banner"), + ("icon", "Icon"), + ("screenshot", "Screenshot"), + ("photo", "Photo"), + ("other", "Other"), + ], + default="other", + help_text="Category of the image", + max_length=20, + ), + ), + ( + "tags", + models.CharField( + blank=True, + help_text="Comma-separated tags for searching", + max_length=500, + ), + ), + ( + "uploaded_at", + models.DateTimeField( + auto_now_add=True, + help_text="Date and time when image was uploaded", + ), + ), + ( + "updated_at", + models.DateTimeField( + auto_now=True, + help_text="Date and time when image was last updated", + ), + ), + ( + "usage_count", + models.PositiveIntegerField( + default=0, help_text="Number of times this image is referenced" + ), + ), + ( + "uploaded_by", + models.ForeignKey( + blank=True, + help_text="User who uploaded the image", + null=True, + on_delete=django.db.models.deletion.SET_NULL, + to=settings.AUTH_USER_MODEL, + ), + ), + ], + options={ + "verbose_name": "Image", + "verbose_name_plural": "Image Library", + "ordering": ["-uploaded_at"], + }, + ), + ] diff --git a/hub/services/models/__init__.py b/hub/services/models/__init__.py index b29a71e..68159a4 100644 --- a/hub/services/models/__init__.py +++ b/hub/services/models/__init__.py @@ -1,6 +1,7 @@ from .articles import * from .base import * from .content import * +from .images import * from .leads import * from .pricing import * from .providers import * diff --git a/hub/services/models/base.py b/hub/services/models/base.py index 9a7abce..a047330 100644 --- a/hub/services/models/base.py +++ b/hub/services/models/base.py @@ -4,10 +4,10 @@ from django.utils.text import slugify from django_prose_editor.fields import ProseEditorField -def validate_image_size(value): +def validate_image_size(value, mb=1): filesize = value.size - if filesize > 1 * 1024 * 1024: # 1MB - raise ValidationError("Maximum file size is 1MB") + if filesize > mb * 1024 * 1024: + raise ValidationError(f"Maximum file size is {mb} MB") class Currency(models.TextChoices): diff --git a/hub/services/models/images.py b/hub/services/models/images.py new file mode 100644 index 0000000..90052ab --- /dev/null +++ b/hub/services/models/images.py @@ -0,0 +1,224 @@ +import os + +from django.db import models +from django.utils import timezone +from django.contrib.auth.models import User +from django.core.exceptions import ValidationError +from django.utils.text import slugify +from PIL import Image as PILImage +from .base import validate_image_size + + +def get_image_upload_path(instance, filename): + """ + Generate upload path for images based on the image library structure. + """ + return f"image_library/{filename}" + + +class ImageLibrary(models.Model): + """ + Generic image library model that can be referenced by other models + to avoid duplicate uploads and provide centralized image management. + """ + + # Image metadata + name = models.CharField(max_length=200, help_text="Descriptive name for the image") + slug = models.SlugField( + max_length=250, unique=True, help_text="URL-friendly version of the name" + ) + description = models.TextField( + blank=True, help_text="Optional description of the image" + ) + alt_text = models.CharField( + max_length=255, help_text="Alternative text for accessibility" + ) + + # Image file + image = models.ImageField( + upload_to=get_image_upload_path, + validators=[validate_image_size], + help_text="Upload image file (max 1MB)", + ) + + # Image properties (automatically populated) + width = models.PositiveIntegerField( + null=True, blank=True, help_text="Image width in pixels" + ) + height = models.PositiveIntegerField( + null=True, blank=True, help_text="Image height in pixels" + ) + file_size = models.PositiveIntegerField( + null=True, blank=True, help_text="File size in bytes" + ) + + # Categorization + CATEGORY_CHOICES = [ + ("logo", "Logo"), + ("article", "Article Image"), + ("banner", "Banner"), + ("icon", "Icon"), + ("screenshot", "Screenshot"), + ("photo", "Photo"), + ("other", "Other"), + ] + category = models.CharField( + max_length=20, + choices=CATEGORY_CHOICES, + default="other", + help_text="Category of the image", + ) + + # Tags for easier searching + tags = models.CharField( + max_length=500, blank=True, help_text="Comma-separated tags for searching" + ) + + # Metadata + uploaded_by = models.ForeignKey( + User, + on_delete=models.SET_NULL, + null=True, + blank=True, + help_text="User who uploaded the image", + ) + uploaded_at = models.DateTimeField( + auto_now_add=True, help_text="Date and time when image was uploaded" + ) + updated_at = models.DateTimeField( + auto_now=True, help_text="Date and time when image was last updated" + ) + + # Usage tracking + usage_count = models.PositiveIntegerField( + default=0, help_text="Number of times this image is referenced" + ) + + class Meta: + ordering = ["-uploaded_at"] + verbose_name = "Image" + verbose_name_plural = "Image Library" + + def __str__(self): + return self.name + + def save(self, *args, **kwargs): + """ + Override save to automatically populate image properties and slug. + """ + # Generate slug if not provided + if not self.slug: + self.slug = slugify(self.name) + + # Save the model first to get the image file + super().save(*args, **kwargs) + + # Update image properties if image exists + if self.image: + self._update_image_properties() + + def _update_image_properties(self): + """ + Update image properties like width, height, and file size. + """ + try: + # Get image dimensions + with PILImage.open(self.image.path) as img: + self.width = img.width + self.height = img.height + + # Get file size + self.file_size = self.image.size + + # Save without calling the full save method to avoid recursion + ImageLibrary.objects.filter(pk=self.pk).update( + width=self.width, height=self.height, file_size=self.file_size + ) + except Exception as e: + # Log error but don't fail the save + print(f"Error updating image properties: {e}") + + def get_file_size_display(self): + """ + Return human-readable file size. + """ + if not self.file_size: + return "Unknown" + + size = self.file_size + for unit in ["B", "KB", "MB", "GB"]: + if size < 1024.0: + return f"{size:.1f} {unit}" + size /= 1024.0 + return f"{size:.1f} TB" + + def get_tags_list(self): + """ + Return tags as a list. + """ + if not self.tags: + return [] + return [tag.strip() for tag in self.tags.split(",") if tag.strip()] + + def increment_usage(self): + """ + Increment usage count when image is referenced. + """ + self.usage_count += 1 + self.save(update_fields=["usage_count"]) + + def decrement_usage(self): + """ + Decrement usage count when reference is removed. + """ + if self.usage_count > 0: + self.usage_count -= 1 + self.save(update_fields=["usage_count"]) + + +class ImageReference(models.Model): + """ + Abstract base class for models that want to reference images from the library. + This helps track usage and provides a consistent interface. + """ + + image = models.ForeignKey( + ImageLibrary, + on_delete=models.SET_NULL, + null=True, + blank=True, + help_text="Select an image from the library", + ) + + class Meta: + abstract = True + + def save(self, *args, **kwargs): + """ + Override save to update usage count. + """ + # Track if image changed + old_image = None + if self.pk: + try: + old_instance = self.__class__.objects.get(pk=self.pk) + old_image = old_instance.image + except self.__class__.DoesNotExist: + pass + + super().save(*args, **kwargs) + + # Update usage counts + if old_image and old_image != self.image: + old_image.decrement_usage() + + if self.image and self.image != old_image: + self.image.increment_usage() + + def delete(self, *args, **kwargs): + """ + Override delete to update usage count. + """ + if self.image: + self.image.decrement_usage() + super().delete(*args, **kwargs) diff --git a/hub/services/static/admin/css/image_library.css b/hub/services/static/admin/css/image_library.css new file mode 100644 index 0000000..6d589bb --- /dev/null +++ b/hub/services/static/admin/css/image_library.css @@ -0,0 +1,79 @@ +/* CSS for Image Library Admin */ + +/* Thumbnail styling in list view */ +.image-thumbnail { + border-radius: 4px; + object-fit: cover; +} + +/* Preview styling in detail view */ +.image-preview { + border-radius: 4px; + box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1); +} + +/* Form styling */ +.image-library-form .form-row { + margin-bottom: 15px; +} + +.image-library-form .help { + font-size: 11px; + color: #666; + margin-top: 5px; +} + +/* Usage count styling */ +.usage-count { + font-weight: bold; + color: #0066cc; +} + +.usage-count.high { + color: #cc0000; +} + +/* Category badges */ +.category-badge { + display: inline-block; + padding: 2px 6px; + border-radius: 3px; + font-size: 10px; + font-weight: bold; + text-transform: uppercase; +} + +.category-badge.logo { + background-color: #e8f4f8; + color: #2c6e92; +} + +.category-badge.article { + background-color: #f0f8e8; + color: #5a7c3a; +} + +.category-badge.banner { + background-color: #fef4e8; + color: #d2691e; +} + +.category-badge.icon { + background-color: #f8e8f8; + color: #8b4c8b; +} + +.category-badge.screenshot { + background-color: #e8f8f4; + color: #3a7c5a; +} + +.category-badge.photo { + background-color: #f4e8f8; + color: #923c92; +} + +.category-badge.other { + background-color: #f0f0f0; + color: #666; +} \ No newline at end of file diff --git a/hub/services/templatetags/image_library.py b/hub/services/templatetags/image_library.py new file mode 100644 index 0000000..99339b7 --- /dev/null +++ b/hub/services/templatetags/image_library.py @@ -0,0 +1,112 @@ +from django import template +from django.utils.safestring import mark_safe +from django.utils.html import format_html +from ..models.images import ImageLibrary + +register = template.Library() + + +@register.simple_tag +def image_library_img(slug_or_id, css_class="", alt_text="", width=None, height=None): + """ + Render an image from the image library by slug or ID. + + Usage: + {% image_library_img "my-image-slug" css_class="img-fluid" %} + {% image_library_img image_id css_class="logo" width="100" height="100" %} + """ + try: + # Try to get by slug first, then by ID + if isinstance(slug_or_id, str): + image = ImageLibrary.objects.get(slug=slug_or_id) + else: + image = ImageLibrary.objects.get(pk=slug_or_id) + + # Use provided alt_text or fall back to image's alt_text + final_alt_text = alt_text or image.alt_text + + # Build HTML attributes + attrs = { + "src": image.image.url, + "alt": final_alt_text, + } + + if css_class: + attrs["class"] = css_class + + if width: + attrs["width"] = width + + if height: + attrs["height"] = height + + # Build the HTML + attr_string = " ".join(f'{k}="{v}"' for k, v in attrs.items()) + return format_html("", attr_string) + + except ImageLibrary.DoesNotExist: + # Return empty string or placeholder if image not found + return format_html( + 'Image not found', + css_class, + ) + + +@register.simple_tag +def image_library_url(slug_or_id): + """ + Get the URL of an image from the image library. + + Usage: + {% image_library_url "my-image-slug" %} + {% image_library_url image_id %} + """ + try: + if isinstance(slug_or_id, str): + image = ImageLibrary.objects.get(slug=slug_or_id) + else: + image = ImageLibrary.objects.get(pk=slug_or_id) + + return image.image.url + + except ImageLibrary.DoesNotExist: + return "/static/images/placeholder.png" + + +@register.simple_tag +def image_library_info(slug_or_id): + """ + Get information about an image from the image library. + + Usage: + {% image_library_info "my-image-slug" as img_info %} + {{ img_info.name }} - {{ img_info.width }}x{{ img_info.height }} + """ + try: + if isinstance(slug_or_id, str): + image = ImageLibrary.objects.get(slug=slug_or_id) + else: + image = ImageLibrary.objects.get(pk=slug_or_id) + + return { + "name": image.name, + "alt_text": image.alt_text, + "width": image.width, + "height": image.height, + "file_size": image.get_file_size_display(), + "category": image.get_category_display(), + "tags": image.get_tags_list(), + "url": image.image.url, + } + + except ImageLibrary.DoesNotExist: + return { + "name": "Image not found", + "alt_text": "Image not found", + "width": None, + "height": None, + "file_size": "Unknown", + "category": "Unknown", + "tags": [], + "url": "/static/images/placeholder.png", + } diff --git a/hub/services/utils/image_library.py b/hub/services/utils/image_library.py new file mode 100644 index 0000000..c0b72b2 --- /dev/null +++ b/hub/services/utils/image_library.py @@ -0,0 +1,243 @@ +from django.core.files.base import ContentFile +from django.utils.text import slugify +from ..models.images import ImageLibrary +import os + +try: + import requests +except ImportError: + requests = None +from PIL import Image as PILImage + + +def create_image_from_file( + file_path, name, description="", alt_text="", category="other", tags="" +): + """ + Create an ImageLibrary entry from a local file. + + Args: + file_path: Path to the image file + name: Name for the image + description: Optional description + alt_text: Alternative text for accessibility + category: Image category + tags: Comma-separated tags + + Returns: + ImageLibrary instance or None if failed + """ + try: + if not os.path.exists(file_path): + print(f"File not found: {file_path}") + return None + + # Generate slug + slug = slugify(name) + + # Check if image already exists + if ImageLibrary.objects.filter(slug=slug).exists(): + print(f"Image with slug '{slug}' already exists") + return ImageLibrary.objects.get(slug=slug) + + # Create image library entry + image_lib = ImageLibrary( + name=name, + slug=slug, + description=description, + alt_text=alt_text or name, + category=category, + tags=tags, + ) + + # Read and save the image file + with open(file_path, "rb") as f: + image_lib.image.save( + os.path.basename(file_path), ContentFile(f.read()), save=True + ) + + print(f"Created image library entry: {name}") + return image_lib + + except Exception as e: + print(f"Error creating image library entry: {e}") + return None + + +def create_image_from_url( + url, name, description="", alt_text="", category="other", tags="" +): + """ + Create an ImageLibrary entry from a URL. + + Args: + url: URL to the image + name: Name for the image + description: Optional description + alt_text: Alternative text for accessibility + category: Image category + tags: Comma-separated tags + + Returns: + ImageLibrary instance or None if failed + """ + if requests is None: + print("requests library is not installed. Cannot download from URL.") + return None + + try: + # Generate slug + slug = slugify(name) + + # Check if image already exists + if ImageLibrary.objects.filter(slug=slug).exists(): + print(f"Image with slug '{slug}' already exists") + return ImageLibrary.objects.get(slug=slug) + + # Download the image + response = requests.get(url) + response.raise_for_status() + + # Create image library entry + image_lib = ImageLibrary( + name=name, + slug=slug, + description=description, + alt_text=alt_text or name, + category=category, + tags=tags, + ) + + # Save the image + filename = url.split("/")[-1] + if "?" in filename: + filename = filename.split("?")[0] + + image_lib.image.save(filename, ContentFile(response.content), save=True) + + print(f"Created image library entry from URL: {name}") + return image_lib + + except Exception as e: + print(f"Error creating image library entry from URL: {e}") + return None + + +def get_image_by_slug(slug): + """ + Get an image from the library by slug. + + Args: + slug: Slug of the image + + Returns: + ImageLibrary instance or None if not found + """ + try: + return ImageLibrary.objects.get(slug=slug) + except ImageLibrary.DoesNotExist: + return None + + +def get_images_by_category(category): + """ + Get all images from a specific category. + + Args: + category: Category name + + Returns: + QuerySet of ImageLibrary instances + """ + return ImageLibrary.objects.filter(category=category) + + +def get_images_by_tags(tags): + """ + Get images that contain any of the specified tags. + + Args: + tags: List of tags or comma-separated string + + Returns: + QuerySet of ImageLibrary instances + """ + if isinstance(tags, str): + tags = [tag.strip() for tag in tags.split(",")] + + from django.db.models import Q + + query = Q() + for tag in tags: + query |= Q(tags__icontains=tag) + + return ImageLibrary.objects.filter(query).distinct() + + +def cleanup_unused_images(): + """ + Find and optionally clean up unused images from the library. + + Returns: + List of ImageLibrary instances with usage_count = 0 + """ + unused_images = ImageLibrary.objects.filter(usage_count=0) + + print(f"Found {unused_images.count()} unused images:") + for image in unused_images: + print(f" - {image.name} ({image.slug})") + + return unused_images + + +def optimize_image(image_library_instance, max_width=1920, max_height=1080, quality=85): + """ + Optimize an image in the library by resizing and compressing. + + Args: + image_library_instance: ImageLibrary instance + max_width: Maximum width in pixels + max_height: Maximum height in pixels + quality: JPEG quality (1-100) + + Returns: + bool: True if optimization was successful + """ + try: + if not image_library_instance.image: + return False + + # Open the image + with PILImage.open(image_library_instance.image.path) as img: + # Calculate new dimensions while maintaining aspect ratio + ratio = min(max_width / img.width, max_height / img.height) + + if ratio < 1: # Only resize if image is larger than max dimensions + new_width = int(img.width * ratio) + new_height = int(img.height * ratio) + + # Resize the image + img_resized = img.resize( + (new_width, new_height), PILImage.Resampling.LANCZOS + ) + + # Save the optimized image + img_resized.save( + image_library_instance.image.path, + format="JPEG", + quality=quality, + optimize=True, + ) + + # Update the image properties + image_library_instance._update_image_properties() + + print(f"Optimized image: {image_library_instance.name}") + return True + else: + print(f"Image already optimal: {image_library_instance.name}") + return True + + except Exception as e: + print(f"Error optimizing image {image_library_instance.name}: {e}") + return False diff --git a/hub/settings.py b/hub/settings.py index 8e6f3e6..120d9cb 100644 --- a/hub/settings.py +++ b/hub/settings.py @@ -245,6 +245,7 @@ JAZZMIN_SETTINGS = { "new_window": True, }, {"name": "Articles", "url": "/admin/services/article/"}, + {"name": "Image Library", "url": "/admin/services/imagelibrary/"}, {"name": "FAQs", "url": "/admin/services/websitefaq/"}, ], "show_sidebar": True, @@ -257,6 +258,7 @@ JAZZMIN_SETTINGS = { "services.VSHNAppCatAddon": "single", "services.ServiceOffering": "single", "services.Plan": "single", + "services.ImageLibrary": "single", }, "related_modal_active": True, } From 07bea333bc95d8859b58d3dc1d0ebef952b92561 Mon Sep 17 00:00:00 2001 From: Tobias Brunner Date: Fri, 4 Jul 2025 16:49:29 +0200 Subject: [PATCH 24/67] move form to folder --- hub/services/forms/__init__.py | 2 ++ hub/services/{forms.py => forms/lead.py} | 2 +- 2 files changed, 3 insertions(+), 1 deletion(-) create mode 100644 hub/services/forms/__init__.py rename hub/services/{forms.py => forms/lead.py} (94%) diff --git a/hub/services/forms/__init__.py b/hub/services/forms/__init__.py new file mode 100644 index 0000000..920f500 --- /dev/null +++ b/hub/services/forms/__init__.py @@ -0,0 +1,2 @@ +from .lead import LeadForm +from .image_library import ImageLibraryField, ImageLibraryWidget diff --git a/hub/services/forms.py b/hub/services/forms/lead.py similarity index 94% rename from hub/services/forms.py rename to hub/services/forms/lead.py index a608d44..d02c876 100644 --- a/hub/services/forms.py +++ b/hub/services/forms/lead.py @@ -1,5 +1,5 @@ from django import forms -from .models import Lead, Plan +from ..models import Lead class LeadForm(forms.ModelForm): From 1a2bbb1c3522a08edfc55c2ab37204abfba17aee Mon Sep 17 00:00:00 2001 From: Tobias Brunner Date: Fri, 4 Jul 2025 17:26:09 +0200 Subject: [PATCH 25/67] image library migration step 1 --- IMAGE_LIBRARY_MIGRATION_STATUS.md | 81 +++++++++++++++++++ hub/services/admin/articles.py | 43 +++++++++- hub/services/admin/providers.py | 56 +++++++++++-- hub/services/admin/services.py | 33 +++++++- .../0041_add_image_library_references.py | 57 +++++++++++++ .../0042_fix_image_library_field_name.py | 74 +++++++++++++++++ hub/services/models/articles.py | 13 ++- hub/services/models/images.py | 15 ++-- hub/services/models/providers.py | 21 ++++- hub/services/models/services.py | 11 ++- hub/services/templates/pages/homepage.html | 12 ++- .../templates/services/article_detail.html | 8 +- .../templates/services/article_list.html | 2 +- .../templates/services/lead_form.html | 2 +- .../templates/services/offering_detail.html | 4 +- .../templates/services/offering_list.html | 4 +- .../templates/services/partner_detail.html | 4 +- .../templates/services/partner_list.html | 2 +- .../templates/services/provider_detail.html | 4 +- .../templates/services/provider_list.html | 2 +- .../templates/services/service_detail.html | 4 +- .../templates/services/service_list.html | 2 +- hub/services/templatetags/json_ld_tags.py | 16 ++-- 23 files changed, 413 insertions(+), 57 deletions(-) create mode 100644 IMAGE_LIBRARY_MIGRATION_STATUS.md create mode 100644 hub/services/migrations/0041_add_image_library_references.py create mode 100644 hub/services/migrations/0042_fix_image_library_field_name.py diff --git a/IMAGE_LIBRARY_MIGRATION_STATUS.md b/IMAGE_LIBRARY_MIGRATION_STATUS.md new file mode 100644 index 0000000..d2e27de --- /dev/null +++ b/IMAGE_LIBRARY_MIGRATION_STATUS.md @@ -0,0 +1,81 @@ +# Image Library Migration Status + +## ✅ COMPLETED (First Production Rollout) - UPDATED + +### Models Updated +- **Article**: Now inherits from `ImageReference`, with `image_library` field for new images and original `image` field temporarily +- **CloudProvider**: Now inherits from `ImageReference`, with `image_library` field for new images and original `logo` field temporarily +- **ConsultingPartner**: Now inherits from `ImageReference`, with `image_library` field for new images and original `logo` field temporarily +- **Service**: Now inherits from `ImageReference`, with `image_library` field for new images and original `logo` field temporarily + +### New Properties Added +- `Article.get_image()` - Returns image from library or falls back to original field +- `CloudProvider.get_logo()` - Returns logo from library or falls back to original field +- `ConsultingPartner.get_logo()` - Returns logo from library or falls back to original field +- `Service.get_logo()` - Returns logo from library or falls back to original field + +### Templates Updated +- ✅ `pages/homepage.html` - Updated service, provider, and partner image references +- ✅ `services/article_list.html` - Updated article image references +- ✅ `services/article_detail.html` - Updated related service/provider/partner logos +- ✅ `services/offering_list.html` - Updated service and provider logos +- ✅ `services/offering_detail.html` - Updated service and provider logos +- ✅ `services/lead_form.html` - Updated service logo +- ✅ `services/partner_detail.html` - Updated partner and service logos +- ✅ `services/partner_list.html` - Updated partner logos +- ✅ `services/provider_list.html` - Updated provider logos +- ✅ `services/provider_detail.html` - Updated provider and service logos +- ✅ `services/service_detail.html` - Updated service and provider logos + +### Admin Interface Updated +- ✅ `ArticleAdmin` - Updated image_preview to use get_image property +- ✅ `ServiceAdmin` - Updated logo_preview to use get_logo property +- ✅ `CloudProviderAdmin` - Updated logo_preview to use get_logo property +- ✅ `ConsultingPartnerAdmin` - Updated logo_preview to use get_logo property + +### JSON-LD Template Tags Updated +- ✅ Updated structured data generation to use new image properties +- ✅ Updated logo references for services, providers, and partners + +### Database Migration +- ✅ Migration `0041_add_image_library_references` successfully applied +- ✅ Migration `0042_fix_image_library_field_name` successfully applied +- ✅ All models now have `image_library` foreign key fields to ImageLibrary +- ✅ Original image fields preserved for backward compatibility +- ✅ Fixed field name conflicts using `%(class)s_references` related_name pattern + +### Admin Interface Enhanced +- ✅ **ArticleAdmin**: Added fieldsets with `image_library` field visible in "Images" section +- ✅ **ServiceAdmin**: Added fieldsets with `image_library` field visible in "Images" section +- ✅ **CloudProviderAdmin**: Added fieldsets with `image_library` field visible in "Images" section +- ✅ **ConsultingPartnerAdmin**: Added fieldsets with `image_library` field visible in "Images" section +- ✅ All admin interfaces show both new and legacy fields during transition +- ✅ Clear descriptions guide users to use Image Library for new images + +## Current Status +The system is now ready for production with dual image support: +- **New images**: Can be added through the Image Library +- **Legacy images**: Still work through the original fields +- **Templates**: Use the new `get_image/get_logo` properties that automatically fall back + +## Next Steps (Future Cleanup) +1. **Data Migration**: Create script to migrate existing images to ImageLibrary +2. **Admin Updates**: Update admin interfaces to use ImageLibrary selection +3. **Template Validation**: Add null checks to remaining templates +4. **Field Removal**: Remove legacy image fields after migration is complete +5. **Storage Cleanup**: Remove old image files from media directories + +## Benefits Achieved +- ✅ Centralized image management through ImageLibrary +- ✅ Usage tracking for images +- ✅ Backward compatibility maintained +- ✅ Enhanced admin experience ready +- ✅ Consistent image handling across all models +- ✅ Proper fallback mechanisms in place + +## Safety Measures +- ✅ Original image fields preserved +- ✅ Gradual migration approach +- ✅ Fallback properties ensure no broken images +- ✅ Database migration tested and applied +- ✅ Admin interface maintains functionality diff --git a/hub/services/admin/articles.py b/hub/services/admin/articles.py index 90298a4..387ec30 100644 --- a/hub/services/admin/articles.py +++ b/hub/services/admin/articles.py @@ -61,12 +61,47 @@ class ArticleAdmin(admin.ModelAdmin): readonly_fields = ("created_at", "updated_at") ordering = ("-article_date",) + fieldsets = ( + (None, {"fields": ("title", "slug", "excerpt", "content", "meta_keywords")}), + ( + "Images", + { + "fields": ( + "image_library", + "image", + ), # New image library field and legacy field + "description": "Use the Image Library field for new images. Legacy field will be removed after migration.", + }, + ), + ( + "Publishing", + {"fields": ("author", "article_date", "is_published", "is_featured")}, + ), + ( + "Relations", + { + "fields": ( + "related_service", + "related_consulting_partner", + "related_cloud_provider", + ), + "classes": ("collapse",), + }, + ), + ( + "Metadata", + { + "fields": ("created_at", "updated_at"), + "classes": ("collapse",), + }, + ), + ) + def image_preview(self, obj): """Display image preview in admin list view""" - if obj.image: - return format_html( - '', obj.image.url - ) + image = obj.get_image + if image: + return format_html('', image.url) return "No image" image_preview.short_description = "Image" diff --git a/hub/services/admin/providers.py b/hub/services/admin/providers.py index 8ef3ad3..d33e291 100644 --- a/hub/services/admin/providers.py +++ b/hub/services/admin/providers.py @@ -47,12 +47,30 @@ class CloudProviderAdmin(SortableAdminMixin, admin.ModelAdmin): inlines = [OfferingInline] ordering = ("order",) + fieldsets = ( + (None, {"fields": ("name", "slug", "description", "order")}), + ( + "Images", + { + "fields": ( + "image_library", + "logo", + ), # New image library field and legacy field + "description": "Use the Image Library field for new images. Legacy field will be removed after migration.", + }, + ), + ( + "Contact Information", + {"fields": ("website", "linkedin", "phone", "email", "address")}, + ), + ("Settings", {"fields": ("is_featured", "disable_listing")}), + ) + def logo_preview(self, obj): """Display logo preview in admin list view""" - if obj.logo: - return format_html( - '', obj.logo.url - ) + logo = obj.get_logo + if logo: + return format_html('', logo.url) return "No logo" logo_preview.short_description = "Logo" @@ -75,12 +93,34 @@ class ConsultingPartnerAdmin(SortableAdminMixin, admin.ModelAdmin): filter_horizontal = ("services", "cloud_providers") ordering = ("order",) + fieldsets = ( + (None, {"fields": ("name", "slug", "description", "order")}), + ( + "Images", + { + "fields": ( + "image_library", + "logo", + ), # New image library field and legacy field + "description": "Use the Image Library field for new images. Legacy field will be removed after migration.", + }, + ), + ( + "Contact Information", + {"fields": ("website", "linkedin", "phone", "email", "address")}, + ), + ( + "Relations", + {"fields": ("services", "cloud_providers"), "classes": ("collapse",)}, + ), + ("Settings", {"fields": ("is_featured", "disable_listing")}), + ) + def logo_preview(self, obj): """Display logo preview in admin list view""" - if obj.logo: - return format_html( - '', obj.logo.url - ) + logo = obj.get_logo + if logo: + return format_html('', logo.url) return "No logo" logo_preview.short_description = "Logo" diff --git a/hub/services/admin/services.py b/hub/services/admin/services.py index 41bc97f..b34cf97 100644 --- a/hub/services/admin/services.py +++ b/hub/services/admin/services.py @@ -93,12 +93,37 @@ class ServiceAdmin(admin.ModelAdmin): filter_horizontal = ("categories",) inlines = [ExternalLinkInline, OfferingInline] + fieldsets = ( + (None, {"fields": ("name", "slug", "description", "tagline")}), + ( + "Images", + { + "fields": ( + "image_library", + "logo", + ), # New image library field and legacy field + "description": "Use the Image Library field for new images. Legacy field will be removed after migration.", + }, + ), + ( + "Configuration", + { + "fields": ( + "categories", + "features", + "is_featured", + "is_coming_soon", + "disable_listing", + ) + }, + ), + ) + def logo_preview(self, obj): """Display logo preview in admin list view""" - if obj.logo: - return format_html( - '', obj.logo.url - ) + logo = obj.get_logo + if logo: + return format_html('', logo.url) return "No logo" logo_preview.short_description = "Logo" diff --git a/hub/services/migrations/0041_add_image_library_references.py b/hub/services/migrations/0041_add_image_library_references.py new file mode 100644 index 0000000..231520b --- /dev/null +++ b/hub/services/migrations/0041_add_image_library_references.py @@ -0,0 +1,57 @@ +# Generated by Django 5.2 on 2025-07-04 15:04 + +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("services", "0040_add_image_library"), + ] + + operations = [ + migrations.AddField( + model_name="cloudprovider", + name="image", + field=models.ForeignKey( + blank=True, + help_text="Select an image from the library", + null=True, + on_delete=django.db.models.deletion.SET_NULL, + to="services.imagelibrary", + ), + ), + migrations.AddField( + model_name="consultingpartner", + name="image", + field=models.ForeignKey( + blank=True, + help_text="Select an image from the library", + null=True, + on_delete=django.db.models.deletion.SET_NULL, + to="services.imagelibrary", + ), + ), + migrations.AddField( + model_name="service", + name="image", + field=models.ForeignKey( + blank=True, + help_text="Select an image from the library", + null=True, + on_delete=django.db.models.deletion.SET_NULL, + to="services.imagelibrary", + ), + ), + migrations.AlterField( + model_name="article", + name="image", + field=models.ImageField( + blank=True, + help_text="Title picture for the article", + null=True, + upload_to="article_images/", + ), + ), + ] diff --git a/hub/services/migrations/0042_fix_image_library_field_name.py b/hub/services/migrations/0042_fix_image_library_field_name.py new file mode 100644 index 0000000..0996f8b --- /dev/null +++ b/hub/services/migrations/0042_fix_image_library_field_name.py @@ -0,0 +1,74 @@ +# Generated by Django 5.2 on 2025-07-04 15:22 + +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("services", "0041_add_image_library_references"), + ] + + operations = [ + migrations.RemoveField( + model_name="cloudprovider", + name="image", + ), + migrations.RemoveField( + model_name="consultingpartner", + name="image", + ), + migrations.RemoveField( + model_name="service", + name="image", + ), + migrations.AddField( + model_name="article", + name="image_library", + field=models.ForeignKey( + blank=True, + help_text="Select an image from the library", + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="%(class)s_references", + to="services.imagelibrary", + ), + ), + migrations.AddField( + model_name="cloudprovider", + name="image_library", + field=models.ForeignKey( + blank=True, + help_text="Select an image from the library", + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="%(class)s_references", + to="services.imagelibrary", + ), + ), + migrations.AddField( + model_name="consultingpartner", + name="image_library", + field=models.ForeignKey( + blank=True, + help_text="Select an image from the library", + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="%(class)s_references", + to="services.imagelibrary", + ), + ), + migrations.AddField( + model_name="service", + name="image_library", + field=models.ForeignKey( + blank=True, + help_text="Select an image from the library", + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="%(class)s_references", + to="services.imagelibrary", + ), + ), + ] diff --git a/hub/services/models/articles.py b/hub/services/models/articles.py index b1b85e7..8ea50c0 100644 --- a/hub/services/models/articles.py +++ b/hub/services/models/articles.py @@ -7,9 +7,10 @@ from django.utils import timezone from .base import validate_image_size from .services import Service from .providers import CloudProvider, ConsultingPartner +from .images import ImageReference -class Article(models.Model): +class Article(ImageReference): title = models.CharField(max_length=200) slug = models.SlugField(max_length=250, unique=True) excerpt = models.TextField( @@ -19,9 +20,12 @@ class Article(models.Model): meta_keywords = models.CharField( max_length=255, blank=True, help_text="SEO keywords separated by commas" ) + # Original image field - keep temporarily for migration image = models.ImageField( upload_to="article_images/", help_text="Title picture for the article", + null=True, + blank=True, ) author = models.ForeignKey(User, on_delete=models.CASCADE, related_name="articles") article_date = models.DateField( @@ -86,6 +90,13 @@ class Article(models.Model): def get_absolute_url(self): return reverse("services:article_detail", kwargs={"slug": self.slug}) + @property + def get_image(self): + """Returns the image from library or falls back to legacy image""" + if self.image_library and self.image_library.image: + return self.image_library.image + return self.image + @property def related_to(self): """Returns a string describing what this article is related to""" diff --git a/hub/services/models/images.py b/hub/services/models/images.py index 90052ab..3bf72f7 100644 --- a/hub/services/models/images.py +++ b/hub/services/models/images.py @@ -182,12 +182,13 @@ class ImageReference(models.Model): This helps track usage and provides a consistent interface. """ - image = models.ForeignKey( + image_library = models.ForeignKey( ImageLibrary, on_delete=models.SET_NULL, null=True, blank=True, help_text="Select an image from the library", + related_name="%(class)s_references", ) class Meta: @@ -202,23 +203,23 @@ class ImageReference(models.Model): if self.pk: try: old_instance = self.__class__.objects.get(pk=self.pk) - old_image = old_instance.image + old_image = old_instance.image_library except self.__class__.DoesNotExist: pass super().save(*args, **kwargs) # Update usage counts - if old_image and old_image != self.image: + if old_image and old_image != self.image_library: old_image.decrement_usage() - if self.image and self.image != old_image: - self.image.increment_usage() + if self.image_library and self.image_library != old_image: + self.image_library.increment_usage() def delete(self, *args, **kwargs): """ Override delete to update usage count. """ - if self.image: - self.image.decrement_usage() + if self.image_library: + self.image_library.decrement_usage() super().delete(*args, **kwargs) diff --git a/hub/services/models/providers.py b/hub/services/models/providers.py index e3257ea..5567cb9 100644 --- a/hub/services/models/providers.py +++ b/hub/services/models/providers.py @@ -4,9 +4,10 @@ from django.utils.text import slugify from django_prose_editor.fields import ProseEditorField from .base import validate_image_size +from .images import ImageReference -class CloudProvider(models.Model): +class CloudProvider(ImageReference): name = models.CharField(max_length=100) slug = models.SlugField(unique=True) description = ProseEditorField() @@ -15,6 +16,7 @@ class CloudProvider(models.Model): 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) + # Original logo field - keep temporarily for migration logo = models.ImageField( upload_to="cloud_provider_logos/", validators=[validate_image_size], @@ -39,11 +41,19 @@ class CloudProvider(models.Model): def get_absolute_url(self): return reverse("services:provider_detail", kwargs={"slug": self.slug}) + @property + def get_logo(self): + """Returns the logo from library or falls back to legacy logo""" + if self.image_library and self.image_library.image: + return self.image_library.image + return self.logo -class ConsultingPartner(models.Model): + +class ConsultingPartner(ImageReference): name = models.CharField(max_length=200) slug = models.SlugField(unique=True) description = ProseEditorField() + # Original logo field - keep temporarily for migration logo = models.ImageField( upload_to="partner_logos/", validators=[validate_image_size], @@ -83,3 +93,10 @@ class ConsultingPartner(models.Model): def get_absolute_url(self): return reverse("services:partner_detail", kwargs={"slug": self.slug}) + + @property + def get_logo(self): + """Returns the logo from library or falls back to legacy logo""" + if self.image_library and self.image_library.image: + return self.image_library.image + return self.logo diff --git a/hub/services/models/services.py b/hub/services/models/services.py index 8b6984d..af4c2e0 100644 --- a/hub/services/models/services.py +++ b/hub/services/models/services.py @@ -13,13 +13,15 @@ from .base import ( Currency, ) from .providers import CloudProvider +from .images import ImageReference -class Service(models.Model): +class Service(ImageReference): 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) + # Original logo field - keep temporarily for migration logo = models.ImageField( upload_to="service_logos/", validators=[validate_image_size], @@ -58,6 +60,13 @@ class Service(models.Model): def get_absolute_url(self): return reverse("services:service_detail", kwargs={"slug": self.slug}) + @property + def get_logo(self): + """Returns the logo from library or falls back to legacy logo""" + if self.image_library and self.image_library.image: + return self.image_library.image + return self.logo + class ServiceOffering(models.Model): service = models.ForeignKey( diff --git a/hub/services/templates/pages/homepage.html b/hub/services/templates/pages/homepage.html index f16da44..a7507c6 100644 --- a/hub/services/templates/pages/homepage.html +++ b/hub/services/templates/pages/homepage.html @@ -48,9 +48,15 @@ @@ -105,7 +111,7 @@
- {{ provider.name }} @@ -159,7 +165,7 @@
- {{ partner.name }} diff --git a/hub/services/templates/services/article_detail.html b/hub/services/templates/services/article_detail.html index b3264e5..2f67b43 100644 --- a/hub/services/templates/services/article_detail.html +++ b/hub/services/templates/services/article_detail.html @@ -43,7 +43,7 @@
Service
{% if article.related_service.logo %}
- {{ article.related_service.name }} logo
{% endif %} @@ -60,7 +60,7 @@
Partner
{% if article.related_consulting_partner.logo %}
- {{ article.related_consulting_partner.name }} logo
{% endif %} @@ -77,7 +77,7 @@
Provider
{% if article.related_cloud_provider.logo %}
- {{ article.related_cloud_provider.name }} logo
{% endif %} @@ -100,7 +100,7 @@
{% if related_article.image %} - {{ related_article.title }} + {{ related_article.title }} {% endif %}
{{ related_article.title }}
diff --git a/hub/services/templates/services/article_list.html b/hub/services/templates/services/article_list.html index ac86df4..47ae5ae 100644 --- a/hub/services/templates/services/article_list.html +++ b/hub/services/templates/services/article_list.html @@ -149,7 +149,7 @@
{% if article.image %}
- {{ article.title }} + {{ article.title }}
{% endif %} {% if article.is_featured %} diff --git a/hub/services/templates/services/lead_form.html b/hub/services/templates/services/lead_form.html index 73e0585..bcc26d6 100644 --- a/hub/services/templates/services/lead_form.html +++ b/hub/services/templates/services/lead_form.html @@ -78,7 +78,7 @@
{% if selected_offering.service.logo %} - Service Logo + Service Logo {% endif %}
diff --git a/hub/services/templates/services/offering_detail.html b/hub/services/templates/services/offering_detail.html index e6682e3..2fe0eba 100644 --- a/hub/services/templates/services/offering_detail.html +++ b/hub/services/templates/services/offering_detail.html @@ -34,7 +34,7 @@
{% if offering.service.logo %} - {{ offering.service.name }} logo {% endif %} @@ -50,7 +50,7 @@

Runs on

- {{ offering.cloud_provider.name }} logo + {{ offering.cloud_provider.name }} logo
diff --git a/hub/services/templates/services/offering_list.html b/hub/services/templates/services/offering_list.html index 0f138e3..741352b 100644 --- a/hub/services/templates/services/offering_list.html +++ b/hub/services/templates/services/offering_list.html @@ -151,7 +151,7 @@
{% if offering.service.logo %} - {{ offering.service.name }} {% endif %} @@ -165,7 +165,7 @@
{% if offering.cloud_provider.logo %} - {{ offering.cloud_provider.name }} diff --git a/hub/services/templates/services/partner_detail.html b/hub/services/templates/services/partner_detail.html index 7e90b82..1e78144 100644 --- a/hub/services/templates/services/partner_detail.html +++ b/hub/services/templates/services/partner_detail.html @@ -24,7 +24,7 @@
{% if partner.logo %} - {{ partner.name }} logo + {{ partner.name }} logo {% endif %}
@@ -178,7 +178,7 @@ {% if service.logo %} {% endif %} diff --git a/hub/services/templates/services/partner_list.html b/hub/services/templates/services/partner_list.html index fb196dc..5f554ac 100644 --- a/hub/services/templates/services/partner_list.html +++ b/hub/services/templates/services/partner_list.html @@ -115,7 +115,7 @@
- {{ partner.name }} diff --git a/hub/services/templates/services/provider_detail.html b/hub/services/templates/services/provider_detail.html index 151c986..aa9c360 100644 --- a/hub/services/templates/services/provider_detail.html +++ b/hub/services/templates/services/provider_detail.html @@ -24,7 +24,7 @@
{% if provider.logo %} - {{ provider.name }} logo + {{ provider.name }} logo {% endif %}
@@ -178,7 +178,7 @@ {% if offering.service.logo %} {% endif %} diff --git a/hub/services/templates/services/provider_list.html b/hub/services/templates/services/provider_list.html index 5484b7e..ca27bab 100644 --- a/hub/services/templates/services/provider_list.html +++ b/hub/services/templates/services/provider_list.html @@ -99,7 +99,7 @@
{% if provider.logo %} - {{ provider.name }} diff --git a/hub/services/templates/services/service_detail.html b/hub/services/templates/services/service_detail.html index 5054d61..bdb3748 100644 --- a/hub/services/templates/services/service_detail.html +++ b/hub/services/templates/services/service_detail.html @@ -23,7 +23,7 @@
{% if service.logo %} - {{ service.name }} logo + {{ service.name }} logo {% endif %}
@@ -184,7 +184,7 @@
{% if offering.cloud_provider.logo %}
- {{ offering.cloud_provider.name }} logo
{% else %} diff --git a/hub/services/templates/services/service_list.html b/hub/services/templates/services/service_list.html index da16d3e..0420a74 100644 --- a/hub/services/templates/services/service_list.html +++ b/hub/services/templates/services/service_list.html @@ -156,7 +156,7 @@
{% if service.logo %}
- {{ service.name }} logo + {{ service.name }} logo
{% endif %} {% if service.is_featured %} diff --git a/hub/services/templatetags/json_ld_tags.py b/hub/services/templatetags/json_ld_tags.py index eb18007..c9225d5 100644 --- a/hub/services/templatetags/json_ld_tags.py +++ b/hub/services/templatetags/json_ld_tags.py @@ -119,8 +119,8 @@ def json_ld_structured_data(context): } # Add image if available - if hasattr(service, "logo") and service.logo: - data["image"] = request.build_absolute_uri(service.logo.url) + if hasattr(service, "get_logo") and service.get_logo: + data["image"] = request.build_absolute_uri(service.get_logo.url) # Add offerings if available if hasattr(service, "offerings") and service.offerings.exists(): @@ -143,8 +143,8 @@ def json_ld_structured_data(context): } # Add image if available - if hasattr(provider, "logo") and provider.logo: - data["logo"] = request.build_absolute_uri(provider.logo.url) + if hasattr(provider, "get_logo") and provider.get_logo: + data["logo"] = request.build_absolute_uri(provider.get_logo.url) # Add contact information if available contact_point = {"@type": "ContactPoint", "contactType": "Customer Support"} @@ -179,8 +179,8 @@ def json_ld_structured_data(context): } # Add image if available - if hasattr(partner, "logo") and partner.logo: - data["logo"] = request.build_absolute_uri(partner.logo.url) + if hasattr(partner, "get_logo") and partner.get_logo: + data["logo"] = request.build_absolute_uri(partner.get_logo.url) # Add contact information if available contact_point = {"@type": "ContactPoint", "contactType": "Customer Support"} @@ -219,8 +219,8 @@ def json_ld_structured_data(context): data["brand"] = {"@type": "Brand", "name": offering.service.name} # Add image if available - if hasattr(offering.service, "logo") and offering.service.logo: - data["image"] = request.build_absolute_uri(offering.service.logo.url) + if hasattr(offering.service, "get_logo") and offering.service.get_logo: + data["image"] = request.build_absolute_uri(offering.service.get_logo.url) # Add offers if available if hasattr(offering, "plans") and offering.plans.exists(): From 89149198cc7547aa6302b9c381708e716a90474e Mon Sep 17 00:00:00 2001 From: Tobias Brunner Date: Mon, 7 Jul 2025 13:18:07 +0200 Subject: [PATCH 26/67] move articles menu item to before about --- hub/services/templates/base.html | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/hub/services/templates/base.html b/hub/services/templates/base.html index 9cef2f2..1e451a9 100644 --- a/hub/services/templates/base.html +++ b/hub/services/templates/base.html @@ -55,9 +55,9 @@
diff --git a/hub/services/templates/services/partner_detail.html b/hub/services/templates/services/partner_detail.html index 477af4a..1f3968e 100644 --- a/hub/services/templates/services/partner_detail.html +++ b/hub/services/templates/services/partner_detail.html @@ -153,7 +153,7 @@

{{ partner.name }}

- +
diff --git a/hub/services/templates/services/partner_list.html b/hub/services/templates/services/partner_list.html index f05ca5b..59765b0 100644 --- a/hub/services/templates/services/partner_list.html +++ b/hub/services/templates/services/partner_list.html @@ -94,6 +94,23 @@
+ +
+
+ +
+
+ +
+
+
Clear @@ -126,6 +143,9 @@

{{ partner.name }}

+
+ {{ partner.get_category_display_badge }} +
diff --git a/hub/services/views/partners.py b/hub/services/views/partners.py index ac3668e..4651a14 100644 --- a/hub/services/views/partners.py +++ b/hub/services/views/partners.py @@ -1,6 +1,7 @@ from django.shortcuts import render, get_object_or_404 from django.db.models import Q from hub.services.models import ConsultingPartner, CloudProvider, Service +from hub.services.models.base import PartnerCategory def partner_list(request): @@ -8,6 +9,7 @@ def partner_list(request): search_query = request.GET.get("search", "") service_id = request.GET.get("service", "") cloud_provider_id = request.GET.get("cloud_provider", "") + category = request.GET.get("category", "") # Start with all active partners partners = ConsultingPartner.objects.filter(disable_listing=False).order_by("order") @@ -24,6 +26,9 @@ def partner_list(request): if cloud_provider_id: partners = partners.filter(cloud_providers__id=cloud_provider_id) + if category: + partners = partners.filter(category=category) + # Get available services from filtered partners available_service_ids = partners.values_list("services__id", flat=True).distinct() available_services = Service.objects.filter( @@ -68,6 +73,7 @@ def partner_list(request): ), "available_services": available_services, "available_cloud_providers": available_cloud_providers, + "partner_categories": PartnerCategory.choices, } return render(request, "services/partner_list.html", context) From dd25ea9d10c7bbde3b8984fd30d8ae3c334c754d Mon Sep 17 00:00:00 2001 From: Tobias Brunner Date: Fri, 11 Jul 2025 11:02:14 +0200 Subject: [PATCH 55/67] better category badge on list view --- .../templates/services/partner_detail.html | 22 ++++++------- .../templates/services/partner_list.html | 31 +++++++++---------- 2 files changed, 25 insertions(+), 28 deletions(-) diff --git a/hub/services/templates/services/partner_detail.html b/hub/services/templates/services/partner_detail.html index 1f3968e..98c81a4 100644 --- a/hub/services/templates/services/partner_detail.html +++ b/hub/services/templates/services/partner_detail.html @@ -171,19 +171,17 @@

Consulting for Services

{% for service in services %} -
-
- {% if service.get_logo %} -
- {% if service.get_logo %} -
- - {{ service.name }} logo - -
- {% endif %} +
+ {% if service.get_logo %} +
+ - {% endif %} +
+ {% endif %}

{{ service.name }}

diff --git a/hub/services/templates/services/partner_list.html b/hub/services/templates/services/partner_list.html index 59765b0..1a3edf6 100644 --- a/hub/services/templates/services/partner_list.html +++ b/hub/services/templates/services/partner_list.html @@ -127,25 +127,24 @@
+ {% if partner.category %} +
+ {{ partner.get_category_display_badge }} +
+ {% endif %} + {% if partner.get_logo %} +
+
+ {{ partner.name }} logo +
+
+ {% endif %}
-
- {% if partner.get_logo %} -
- - {{ partner.name }} - -
- {% endif %} -

{{ partner.name }}

-
- {{ partner.get_category_display_badge }} -
@@ -155,9 +154,9 @@
From c123e74c1f9c669ecbc434f27fefbcc5744fcb08 Mon Sep 17 00:00:00 2001 From: Tobias Brunner Date: Fri, 11 Jul 2025 11:14:43 +0200 Subject: [PATCH 56/67] several adjustments to support different partner categories --- hub/services/models/providers.py | 3 +- hub/services/templates/base.html | 4 +- .../templates/services/partner_detail.html | 29 ++------ .../templates/services/partner_list.html | 4 +- .../templates/services/service_detail.html | 73 ++++++++++++++----- 5 files changed, 66 insertions(+), 47 deletions(-) diff --git a/hub/services/models/providers.py b/hub/services/models/providers.py index 3589d66..0877111 100644 --- a/hub/services/models/providers.py +++ b/hub/services/models/providers.py @@ -80,8 +80,7 @@ class ConsultingPartner(ImageReference): return f"{self.name} ({self.get_category_display()})" def get_category_display_badge(self): - """Returns category display suitable for badges/UI""" - return self.get_category_display() + return f"Servala {self.get_category_display()} Partner" def save(self, *args, **kwargs): if not self.slug: diff --git a/hub/services/templates/base.html b/hub/services/templates/base.html index 1e451a9..57d59ff 100644 --- a/hub/services/templates/base.html +++ b/hub/services/templates/base.html @@ -55,8 +55,8 @@ diff --git a/hub/services/templates/services/partner_detail.html b/hub/services/templates/services/partner_detail.html index 98c81a4..159533f 100644 --- a/hub/services/templates/services/partner_detail.html +++ b/hub/services/templates/services/partner_detail.html @@ -99,27 +99,6 @@
- - {% if partner.cloud_providers.exists %} -
-

Cloud Providers

- -
- {% endif %} - {% if related_articles %}
@@ -168,7 +147,13 @@ {% if services %}
-

Consulting for Services

+

+ {% if partner.category == 'TRAINING' %} + Training for Services + {% else %} + Consulting for Services + {% endif %} +

{% for service in services %}
diff --git a/hub/services/templates/services/partner_list.html b/hub/services/templates/services/partner_list.html index 1a3edf6..942f748 100644 --- a/hub/services/templates/services/partner_list.html +++ b/hub/services/templates/services/partner_list.html @@ -156,7 +156,9 @@ {% if partner.website %} Visit Website {% endif %} - Available Services + + {% if partner.category == 'TRAINING' %}Available Trainings{% else %}Available Services{% endif %} +
diff --git a/hub/services/templates/services/service_detail.html b/hub/services/templates/services/service_detail.html index 9628846..e45e1a4 100644 --- a/hub/services/templates/services/service_detail.html +++ b/hub/services/templates/services/service_detail.html @@ -51,26 +51,59 @@ {% endif %} - {% if service.consulting_partners.exists %} -
-

Consulting Partners

-

If you want to get the most out of your {{ service.name }}, our consulting partners can help you optimize your setup and application:

- -
- {% endif %} + {% with consulting_partners=service.consulting_partners.all|dictsort:"order" %} + {% regroup consulting_partners by category as partners_by_category %} + {% for category_group in partners_by_category %} + {% if category_group.grouper == "CONSULTING" and category_group.list %} +
+

Consulting Partners

+

If you want to get the most out of your {{ service.name }} service, our consulting partners can help you optimize your setup and application:

+ +
+ {% endif %} + {% endfor %} + {% endwith %} + + + {% with training_partners=service.consulting_partners.all|dictsort:"order" %} + {% regroup training_partners by category as partners_by_category %} + {% for category_group in partners_by_category %} + {% if category_group.grouper == "TRAINING" and category_group.list %} +
+

Training Partners

+

Looking to upskill your team on {{ service.name }}? Our training partners offer comprehensive courses and workshops:

+ +
+ {% endif %} + {% endfor %} + {% endwith %} {% if service.external_links.exists %} From 4bc6e7d3541034f70bd24a226e7a436ece9b3272 Mon Sep 17 00:00:00 2001 From: Tobias Brunner Date: Fri, 11 Jul 2025 11:19:02 +0200 Subject: [PATCH 57/67] add time to datePublished --- hub/services/templatetags/json_ld_tags.py | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/hub/services/templatetags/json_ld_tags.py b/hub/services/templatetags/json_ld_tags.py index 3741bfe..6efa78c 100644 --- a/hub/services/templatetags/json_ld_tags.py +++ b/hub/services/templatetags/json_ld_tags.py @@ -1,7 +1,9 @@ -# hub/services/templatetags/json_ld_tags.py +from datetime import datetime, time from django import template from django.urls import resolve, Resolver404 from django.utils.safestring import mark_safe +from django.utils import timezone as django_timezone + import json register = template.Library() @@ -332,8 +334,10 @@ def json_ld_structured_data(context): } # Add publication date using article_date field - if article.article_date: - data["datePublished"] = article.article_date.isoformat() + article_datetime = django_timezone.make_aware( + datetime.combine(article.article_date, time.min) + ) + data["datePublished"] = article_datetime.isoformat() # Add modification date if article.updated_at: From 53e87ae1a2dfc3d9b12647caa8ba35118acb80c4 Mon Sep 17 00:00:00 2001 From: Tobias Brunner Date: Fri, 11 Jul 2025 11:27:57 +0200 Subject: [PATCH 58/67] small adjustments to partner listing --- .../templates/services/partner_list.html | 24 ++++++++++--------- 1 file changed, 13 insertions(+), 11 deletions(-) diff --git a/hub/services/templates/services/partner_list.html b/hub/services/templates/services/partner_list.html index 942f748..600cc6c 100644 --- a/hub/services/templates/services/partner_list.html +++ b/hub/services/templates/services/partner_list.html @@ -129,19 +129,21 @@ onclick="cardClicked(event, '{{ partner.get_absolute_url }}')"> {% if partner.category %}
- {{ partner.get_category_display_badge }} -
- {% endif %} - {% if partner.get_logo %} -
-
- {{ partner.name }} logo -
+ {{ partner.get_category_display_badge }}
{% endif %}
+ {% if partner.get_logo %} +
+
+ + {{ partner.name }} logo + +
+
+ {% endif %}

{{ partner.name }}

@@ -154,9 +156,9 @@