diff --git a/hub/services/models.py b/hub/services/models.py deleted file mode 100644 index 89831d3..0000000 --- a/hub/services/models.py +++ /dev/null @@ -1,687 +0,0 @@ -from django.db import models -from django.core.exceptions import ValidationError -from django.urls import reverse -from django.utils.text import slugify -from django_prose_editor.fields import ProseEditorField -from django.core.validators import URLValidator - - -def validate_image_size(value): - filesize = value.size - if filesize > 1 * 1024 * 1024: # 1MB - raise ValidationError("Maximum file size is 1MB") - - -class ReusableText(models.Model): - name = models.CharField(max_length=100) - textsnippet = models.ForeignKey( - "self", - on_delete=models.SET_NULL, - null=True, - blank=True, - related_name="children", - ) - text = ProseEditorField() - - class Meta: - ordering = ["name"] - - def __str__(self): - return self.name - - def get_full_text(self): - """Returns the text with all nested textsnippet content recursively included in reverse order""" - text_parts = [] - - # Recursively collect all text parts - def collect_text(snippet): - if snippet is None: - return - collect_text(snippet.textsnippet) # Collect deepest snippets first - text_parts.append(snippet.text) - - # Start collection with the deepest snippets - collect_text(self.textsnippet) - - # Add the main text at the end - text_parts.append(self.text) - - # Join all text parts - return "".join(text_parts) - - -class Category(models.Model): - name = models.CharField(max_length=100) - slug = models.SlugField(unique=True) - parent = models.ForeignKey( - "self", on_delete=models.CASCADE, null=True, blank=True, related_name="children" - ) - description = models.TextField(blank=True) - order = models.IntegerField(default=0) - - class Meta: - verbose_name = "Service Category" - verbose_name_plural = "Service Categories" - ordering = ["order", "name"] - - def __str__(self): - if self.parent: - return f"{self.parent} > {self.name}" - return self.name - - def save(self, *args, **kwargs): - if not self.slug: - self.slug = slugify(self.name) - super().save(*args, **kwargs) - - @property - def full_path(self): - path = [self.name] - parent = self.parent - while parent: - path.append(parent.name) - parent = parent.parent - return " > ".join(reversed(path)) - - -class CloudProvider(models.Model): - name = models.CharField(max_length=100) - slug = models.SlugField(unique=True) - description = ProseEditorField() - website = models.URLField() - linkedin = models.URLField(blank=True) - phone = models.CharField(max_length=25, blank=True, null=True) - email = models.EmailField(max_length=254, blank=True, null=True) - address = models.TextField(max_length=250, blank=True, null=True) - logo = models.ImageField( - upload_to="cloud_provider_logos/", - validators=[validate_image_size], - null=True, - blank=True, - ) - order = models.IntegerField(default=0) - is_featured = models.BooleanField(default=False) - disable_listing = models.BooleanField(default=False) - - class Meta: - ordering = ["order"] - - def __str__(self): - return self.name - - def save(self, *args, **kwargs): - if not self.slug: - self.slug = slugify(self.name) - super().save(*args, **kwargs) - - def get_absolute_url(self): - return reverse("services:provider_detail", kwargs={"slug": self.slug}) - - -class Service(models.Model): - name = models.CharField(max_length=200) - slug = models.SlugField(max_length=250, unique=True) - description = ProseEditorField() - tagline = models.TextField(max_length=500, blank=True, null=True) - logo = models.ImageField( - upload_to="service_logos/", - validators=[validate_image_size], - null=True, - blank=True, - ) - categories = models.ManyToManyField(Category, related_name="services") - features = ProseEditorField() - is_featured = models.BooleanField(default=False) - is_coming_soon = models.BooleanField(default=False) - disable_listing = models.BooleanField(default=False) - - created_at = models.DateTimeField(auto_now_add=True) - updated_at = models.DateTimeField(auto_now=True) - - def __str__(self): - return self.name - - def clean(self): - if self.is_featured and self.is_coming_soon: - raise ValidationError( - "A service cannot be both featured and coming soon simultaneously." - ) - super().clean() - - def save(self, *args, **kwargs): - self.clean() # Ensure validation runs on save - if not self.slug: - self.slug = slugify(self.name) - counter = 1 - while Service.objects.filter(slug=self.slug).exists(): - self.slug = f"{slugify(self.name)}-{counter}" - counter += 1 - super().save(*args, **kwargs) - - def get_absolute_url(self): - return reverse("services:service_detail", kwargs={"slug": self.slug}) - - -class ConsultingPartner(models.Model): - name = models.CharField(max_length=200) - slug = models.SlugField(unique=True) - description = ProseEditorField() - logo = models.ImageField( - upload_to="partner_logos/", - validators=[validate_image_size], - null=True, - blank=True, - ) - website = models.URLField(blank=True) - linkedin = models.URLField(blank=True) - phone = models.CharField(max_length=25, blank=True, null=True) - email = models.EmailField(max_length=254, blank=True, null=True) - address = models.TextField(max_length=250, blank=True, null=True) - - services = models.ManyToManyField( - Service, related_name="consulting_partners", blank=True - ) - cloud_providers = models.ManyToManyField( - CloudProvider, related_name="consulting_partners", blank=True - ) - - order = models.IntegerField(default=0) - is_featured = models.BooleanField(default=False) - disable_listing = models.BooleanField(default=False) - - created_at = models.DateTimeField(auto_now_add=True) - updated_at = models.DateTimeField(auto_now=True) - - class Meta: - ordering = ["order"] - - def __str__(self): - return self.name - - def save(self, *args, **kwargs): - if not self.slug: - self.slug = slugify(self.name) - super().save(*args, **kwargs) - - def get_absolute_url(self): - return reverse("services:partner_detail", kwargs={"slug": self.slug}) - - -class ServiceOffering(models.Model): - service = models.ForeignKey( - Service, on_delete=models.CASCADE, related_name="offerings" - ) - cloud_provider = models.ForeignKey( - CloudProvider, on_delete=models.CASCADE, related_name="offerings" - ) - description = ProseEditorField(blank=True, null=True) - offer_description = models.ForeignKey( - ReusableText, - on_delete=models.PROTECT, - related_name="offer_descriptions", - blank=True, - null=True, - ) - - disable_listing = models.BooleanField(default=False) - - created_at = models.DateTimeField(auto_now_add=True) - updated_at = models.DateTimeField(auto_now=True) - - class Meta: - unique_together = ["service", "cloud_provider"] - ordering = ["service__name", "cloud_provider__name"] - - def __str__(self): - return f"{self.service.name} on {self.cloud_provider.name}" - - def get_absolute_url(self): - return reverse( - "services:offering_detail", - kwargs={ - "provider_slug": self.cloud_provider.slug, - "service_slug": self.service.slug, - }, - ) - - -class Plan(models.Model): - name = models.CharField(max_length=100) - description = ProseEditorField(blank=True, null=True) - pricing = ProseEditorField(blank=True, null=True) - plan_description = models.ForeignKey( - ReusableText, - on_delete=models.PROTECT, - related_name="plan_descriptions", - blank=True, - null=True, - ) - offering = models.ForeignKey( - ServiceOffering, on_delete=models.CASCADE, related_name="plans" - ) - - created_at = models.DateTimeField(auto_now_add=True) - updated_at = models.DateTimeField(auto_now=True) - - class Meta: - ordering = ["name"] - unique_together = [["offering", "name"]] - - def __str__(self): - return f"{self.offering} - {self.name}" - - -class ExternalLinkOffering(models.Model): - offering = models.ForeignKey( - ServiceOffering, on_delete=models.CASCADE, related_name="external_links" - ) - url = models.URLField() - description = models.CharField(max_length=200) - order = models.IntegerField(default=0) - created_at = models.DateTimeField(auto_now_add=True) - updated_at = models.DateTimeField(auto_now=True) - - class Meta: - ordering = ["order", "description"] - verbose_name = "External Link" - verbose_name_plural = "External Links" - - def __str__(self): - return f"{self.description} ({self.url})" - - def clean(self): - validate = URLValidator() - try: - validate(self.url) - except ValidationError: - raise ValidationError({"url": "Enter a valid URL."}) - - -class ExternalLink(models.Model): - service = models.ForeignKey( - Service, on_delete=models.CASCADE, related_name="external_links" - ) - url = models.URLField() - description = models.CharField(max_length=200) - order = models.IntegerField(default=0) - created_at = models.DateTimeField(auto_now_add=True) - updated_at = models.DateTimeField(auto_now=True) - - class Meta: - ordering = ["order", "description"] - verbose_name = "External Link" - verbose_name_plural = "External Links" - - def __str__(self): - return f"{self.description} ({self.url})" - - def clean(self): - validate = URLValidator() - try: - validate(self.url) - except ValidationError: - raise ValidationError({"url": "Enter a valid URL."}) - - -class Lead(models.Model): - name = models.CharField(max_length=200) - email = models.EmailField() - company = models.CharField(max_length=200, null=True, blank=True) - phone = models.CharField(max_length=50, null=True, blank=True) - message = models.TextField(blank=True, null=True, max_length=1000) - odoo_lead_id = models.IntegerField(null=True, blank=True) - - service = models.ForeignKey( - Service, on_delete=models.SET_NULL, null=True, blank=True - ) - offering = models.ForeignKey( - ServiceOffering, on_delete=models.SET_NULL, null=True, blank=True - ) - plan = models.ForeignKey(Plan, on_delete=models.SET_NULL, null=True, blank=True) - - created_at = models.DateTimeField(auto_now_add=True) - - def __str__(self): - return f"{self.name} - {self.company} ({self.service})" - - -class WebsiteFaq(models.Model): - question = models.CharField(max_length=200) - answer = ProseEditorField() - order = models.IntegerField(default=0) - - class Meta: - ordering = ["order"] - - def __str__(self): - return self.question - - -class Currency(models.TextChoices): - CHF = "CHF", "Swiss Franc" - EUR = "EUR", "Euro" - USD = "USD", "US Dollar" - - -class Term(models.TextChoices): - MTH = "MTH", "per Month (30d)" - DAY = "DAY", "per Day" - HR = "HR", "per Hour" - MIN = "MIN", "per Minute" - - -class Unit(models.TextChoices): - GIB = "GIB", "GiB" - MIB = "MIB", "MiB" - CPU = "CPU", "vCPU" - - -class ComputePlanPrice(models.Model): - compute_plan = models.ForeignKey( - "ComputePlan", on_delete=models.CASCADE, related_name="prices" - ) - currency = models.CharField( - max_length=3, - choices=Currency.choices, - ) - amount = models.DecimalField( - max_digits=10, - decimal_places=2, - help_text="Price in the specified currency, excl. VAT", - ) - - class Meta: - unique_together = ("compute_plan", "currency") - ordering = ["currency"] - - def __str__(self): - return f"{self.compute_plan.name} - {self.amount} {self.currency}" - - -class ComputePlan(models.Model): - name = models.CharField(max_length=200) - vcpus = models.FloatField(help_text="Number of available vCPUs") - ram = models.FloatField(help_text="Amount of RAM available") - cpu_mem_ratio = models.FloatField( - help_text="vCPU to Memory ratio. How much vCPU per GiB RAM is available?" - ) - active = models.BooleanField(default=True, help_text="Is the plan active?") - term = models.CharField( - max_length=3, - default=Term.MTH, - choices=Term.choices, - ) - - cloud_provider = models.ForeignKey( - CloudProvider, on_delete=models.CASCADE, related_name="compute_plans" - ) - - valid_from = models.DateTimeField(blank=True, null=True) - valid_to = models.DateTimeField(blank=True, null=True) - - class Meta: - ordering = ["name"] - - def __str__(self): - return self.name - - def get_price(self, currency_code: str): - try: - return self.prices.get(currency=currency_code).amount - except ComputePlanPrice.DoesNotExist: - return None - - -class StoragePlanPrice(models.Model): - storage_plan = models.ForeignKey( - "StoragePlan", on_delete=models.CASCADE, related_name="prices" - ) - currency = models.CharField( - max_length=3, - choices=Currency.choices, - ) - amount = models.DecimalField( - max_digits=10, - decimal_places=2, - help_text="Price in the specified currency, excl. VAT", - ) - - class Meta: - unique_together = ("storage_plan", "currency") - ordering = ["currency"] - - def __str__(self): - return f"{self.storage_plan.name} - {self.amount} {self.currency}" - - -class StoragePlan(models.Model): - name = models.CharField(max_length=200) - cloud_provider = models.ForeignKey( - CloudProvider, on_delete=models.CASCADE, related_name="storage_plans" - ) - term = models.CharField( - max_length=3, - default=Term.MTH, - choices=Term.choices, - ) - unit = models.CharField( - max_length=3, - default=Unit.GIB, - choices=Unit.choices, - ) - - valid_from = models.DateTimeField(blank=True, null=True) - valid_to = models.DateTimeField(blank=True, null=True) - - class Meta: - unique_together = ("cloud_provider", "term", "unit", "valid_from", "valid_to") - ordering = ["name"] - - def __str__(self): - return self.name - - def get_price(self, currency_code: str): - try: - return self.prices.get(currency=currency_code).amount - except StoragePlanPrice.DoesNotExist: - return None - - -class 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 ProgressiveDiscountModel(models.Model): - name = models.CharField(max_length=100) - description = models.TextField(blank=True) - active = models.BooleanField(default=True) - - class Meta: - verbose_name = "Discount Model" - - def __str__(self): - return self.name - - def calculate_discount(self, base_rate, units): - """Calculate price using progressive percentage discounts.""" - final_price = 0 - remaining_units = units - processed_units = 0 - - # Get all tiers sorted by threshold - discount_tiers = self.tiers.all().order_by("threshold") - - for i, tier in enumerate(discount_tiers): - # Calculate how many units fall into this tier - if i < discount_tiers.count() - 1: - next_threshold = discount_tiers[i + 1].threshold - tier_units = min(remaining_units, next_threshold - processed_units) - else: - # Last tier handles all remaining units - tier_units = remaining_units - - # Calculate discounted rate for this tier - discounted_rate = base_rate * (1 - tier.discount_percent / 100) - - # Add the price for units in this tier - final_price += discounted_rate * tier_units - - # Update tracking variables - remaining_units -= tier_units - processed_units += tier_units - - # Exit if all units have been processed - if remaining_units <= 0: - break - - return final_price - - -class DiscountTier(models.Model): - discount_model = models.ForeignKey( - ProgressiveDiscountModel, on_delete=models.CASCADE, related_name="tiers" - ) - threshold = models.PositiveIntegerField( - help_text="Starting unit count for this tier" - ) - discount_percent = models.DecimalField( - max_digits=5, decimal_places=2, help_text="Percentage discount applied (0-100)" - ) - - class Meta: - ordering = ["threshold"] - unique_together = ["discount_model", "threshold"] - - def __str__(self): - return f"{self.discount_model.name}: {self.threshold}+ units → {self.discount_percent}% discount" - - -class VSHNAppCatPrice(models.Model): - class VariableUnit(models.TextChoices): - RAM = "RAM", "Memory (RAM)" - CPU = "CPU", "CPU (vCPU)" - USER = "USR", "Users" - - class ServiceLevel(models.TextChoices): - BEST_EFFORT = "BE", "Best Effort" - GUARANTEED = "GA", "Guaranteed Availability" - - service = models.ForeignKey( - Service, on_delete=models.CASCADE, related_name="vshn_appcat_price" - ) - variable_unit = models.CharField( - max_length=3, - choices=VariableUnit.choices, - default=VariableUnit.RAM, - ) - term = models.CharField( - max_length=3, - default=Term.MTH, - choices=Term.choices, - ) - discount_model = models.ForeignKey( - ProgressiveDiscountModel, - on_delete=models.SET_NULL, - null=True, - blank=True, - related_name="price_configs", - ) - - ha_replica_min = models.IntegerField( - default=1, help_text="Minimum of replicas for HA" - ) - ha_replica_max = models.IntegerField( - default=1, help_text="Maximum supported replicas" - ) - - valid_from = models.DateTimeField(blank=True, null=True) - valid_to = models.DateTimeField(blank=True, null=True) - - class Meta: - verbose_name = "AppCat Price" - - def __str__(self): - return f"{self.service.name} - {self.get_variable_unit_display()} based pricing" - - def get_base_fee(self, currency_code: str): - try: - return self.base_fees.get(currency=currency_code).amount - except VSHNAppCatBaseFee.DoesNotExist: - return None - - def get_unit_rate(self, currency_code: str, service_level: str): - try: - return self.unit_rates.get( - currency=currency_code, service_level=service_level - ).amount - except VSHNAppCatUnitRate.DoesNotExist: - return None - - def calculate_final_price( - self, currency_code: str, service_level: str, number_of_units: int - ): - base_fee = self.get_base_fee(currency_code) - unit_rate = self.get_unit_rate(currency_code, service_level) - - if base_fee is None or unit_rate is None: - return None - - if number_of_units < 0: - raise ValueError("Number of units cannot be negative") - - # Apply discount model if available - if self.discount_model and self.discount_model.active: - discounted_price = self.discount_model.calculate_discount( - unit_rate, number_of_units - ) - total_price = base_fee + discounted_price - else: - total_price = base_fee + (unit_rate * number_of_units) - - return total_price - - -class VSHNAppCatUnitRate(models.Model): - vshn_appcat_price_config = models.ForeignKey( - VSHNAppCatPrice, on_delete=models.CASCADE, related_name="unit_rates" - ) - currency = models.CharField( - max_length=3, - choices=Currency.choices, - ) - service_level = models.CharField( - max_length=2, - choices=VSHNAppCatPrice.ServiceLevel.choices, - ) - amount = models.DecimalField( - max_digits=10, - decimal_places=4, - help_text="Price per unit in the specified currency and service level, excl. VAT", - ) - - class Meta: - verbose_name = "Service Unit Rate" - unique_together = ("vshn_appcat_price_config", "currency", "service_level") - ordering = ["currency", "service_level"] - - def __str__(self): - return f"{self.vshn_appcat_price_config.service.name} - {self.get_service_level_display()} Unit Rate - {self.amount} {self.currency}" diff --git a/hub/services/models/__init__.py b/hub/services/models/__init__.py new file mode 100644 index 0000000..f5c3107 --- /dev/null +++ b/hub/services/models/__init__.py @@ -0,0 +1,6 @@ +from .base import * +from .content import * +from .leads import * +from .pricing import * +from .providers import * +from .services import * diff --git a/hub/services/models/base.py b/hub/services/models/base.py new file mode 100644 index 0000000..04d9b26 --- /dev/null +++ b/hub/services/models/base.py @@ -0,0 +1,101 @@ +from django.db import models +from django.core.exceptions import ValidationError +from django.utils.text import slugify +from django_prose_editor.fields import ProseEditorField + + +def validate_image_size(value): + filesize = value.size + if filesize > 1 * 1024 * 1024: # 1MB + raise ValidationError("Maximum file size is 1MB") + + +class Currency(models.TextChoices): + CHF = "CHF", "Swiss Franc" + EUR = "EUR", "Euro" + USD = "USD", "US Dollar" + + +class Term(models.TextChoices): + MTH = "MTH", "per Month (30d)" + DAY = "DAY", "per Day" + HR = "HR", "per Hour" + MIN = "MIN", "per Minute" + + +class Unit(models.TextChoices): + GIB = "GIB", "GiB" + MIB = "MIB", "MiB" + CPU = "CPU", "vCPU" + + +class ReusableText(models.Model): + name = models.CharField(max_length=100) + textsnippet = models.ForeignKey( + "self", + on_delete=models.SET_NULL, + null=True, + blank=True, + related_name="children", + ) + text = ProseEditorField() + + class Meta: + ordering = ["name"] + + def __str__(self): + return self.name + + def get_full_text(self): + """Returns the text with all nested textsnippet content recursively included in reverse order""" + text_parts = [] + + # Recursively collect all text parts + def collect_text(snippet): + if snippet is None: + return + collect_text(snippet.textsnippet) # Collect deepest snippets first + text_parts.append(snippet.text) + + # Start collection with the deepest snippets + collect_text(self.textsnippet) + + # Add the main text at the end + text_parts.append(self.text) + + # Join all text parts + return "".join(text_parts) + + +class Category(models.Model): + name = models.CharField(max_length=100) + slug = models.SlugField(unique=True) + parent = models.ForeignKey( + "self", on_delete=models.CASCADE, null=True, blank=True, related_name="children" + ) + description = models.TextField(blank=True) + order = models.IntegerField(default=0) + + class Meta: + verbose_name = "Service Category" + verbose_name_plural = "Service Categories" + ordering = ["order", "name"] + + def __str__(self): + if self.parent: + return f"{self.parent} > {self.name}" + return self.name + + def save(self, *args, **kwargs): + if not self.slug: + self.slug = slugify(self.name) + super().save(*args, **kwargs) + + @property + def full_path(self): + path = [self.name] + parent = self.parent + while parent: + path.append(parent.name) + parent = parent.parent + return " > ".join(reversed(path)) diff --git a/hub/services/models/content.py b/hub/services/models/content.py new file mode 100644 index 0000000..6f4eba1 --- /dev/null +++ b/hub/services/models/content.py @@ -0,0 +1,14 @@ +from django.db import models +from django_prose_editor.fields import ProseEditorField + + +class WebsiteFaq(models.Model): + question = models.CharField(max_length=200) + answer = ProseEditorField() + order = models.IntegerField(default=0) + + class Meta: + ordering = ["order"] + + def __str__(self): + return self.question diff --git a/hub/services/models/leads.py b/hub/services/models/leads.py new file mode 100644 index 0000000..ef69036 --- /dev/null +++ b/hub/services/models/leads.py @@ -0,0 +1,25 @@ +from django.db import models + +from .services import Service, ServiceOffering, Plan + + +class Lead(models.Model): + name = models.CharField(max_length=200) + email = models.EmailField() + company = models.CharField(max_length=200, null=True, blank=True) + phone = models.CharField(max_length=50, null=True, blank=True) + message = models.TextField(blank=True, null=True, max_length=1000) + odoo_lead_id = models.IntegerField(null=True, blank=True) + + service = models.ForeignKey( + Service, on_delete=models.SET_NULL, null=True, blank=True + ) + offering = models.ForeignKey( + ServiceOffering, on_delete=models.SET_NULL, null=True, blank=True + ) + plan = models.ForeignKey(Plan, on_delete=models.SET_NULL, null=True, blank=True) + + created_at = models.DateTimeField(auto_now_add=True) + + def __str__(self): + return f"{self.name} - {self.company} ({self.service})" diff --git a/hub/services/models/pricing.py b/hub/services/models/pricing.py new file mode 100644 index 0000000..929ab43 --- /dev/null +++ b/hub/services/models/pricing.py @@ -0,0 +1,316 @@ +from django.db import models + +from .base import Currency, Term, Unit +from .providers import CloudProvider +from .services import Service + + +class ComputePlanPrice(models.Model): + compute_plan = models.ForeignKey( + "ComputePlan", on_delete=models.CASCADE, related_name="prices" + ) + currency = models.CharField( + max_length=3, + choices=Currency.choices, + ) + amount = models.DecimalField( + max_digits=10, + decimal_places=2, + help_text="Price in the specified currency, excl. VAT", + ) + + class Meta: + unique_together = ("compute_plan", "currency") + ordering = ["currency"] + + def __str__(self): + return f"{self.compute_plan.name} - {self.amount} {self.currency}" + + +class ComputePlan(models.Model): + name = models.CharField(max_length=200) + vcpus = models.FloatField(help_text="Number of available vCPUs") + ram = models.FloatField(help_text="Amount of RAM available") + cpu_mem_ratio = models.FloatField( + help_text="vCPU to Memory ratio. How much vCPU per GiB RAM is available?" + ) + active = models.BooleanField(default=True, help_text="Is the plan active?") + term = models.CharField( + max_length=3, + default=Term.MTH, + choices=Term.choices, + ) + + cloud_provider = models.ForeignKey( + CloudProvider, on_delete=models.CASCADE, related_name="compute_plans" + ) + + valid_from = models.DateTimeField(blank=True, null=True) + valid_to = models.DateTimeField(blank=True, null=True) + + class Meta: + ordering = ["name"] + + def __str__(self): + return self.name + + def get_price(self, currency_code: str): + try: + return self.prices.get(currency=currency_code).amount + except ComputePlanPrice.DoesNotExist: + return None + + +class StoragePlanPrice(models.Model): + storage_plan = models.ForeignKey( + "StoragePlan", on_delete=models.CASCADE, related_name="prices" + ) + currency = models.CharField( + max_length=3, + choices=Currency.choices, + ) + amount = models.DecimalField( + max_digits=10, + decimal_places=2, + help_text="Price in the specified currency, excl. VAT", + ) + + class Meta: + unique_together = ("storage_plan", "currency") + ordering = ["currency"] + + def __str__(self): + return f"{self.storage_plan.name} - {self.amount} {self.currency}" + + +class StoragePlan(models.Model): + name = models.CharField(max_length=200) + cloud_provider = models.ForeignKey( + CloudProvider, on_delete=models.CASCADE, related_name="storage_plans" + ) + term = models.CharField( + max_length=3, + default=Term.MTH, + choices=Term.choices, + ) + unit = models.CharField( + max_length=3, + default=Unit.GIB, + choices=Unit.choices, + ) + + valid_from = models.DateTimeField(blank=True, null=True) + valid_to = models.DateTimeField(blank=True, null=True) + + class Meta: + unique_together = ("cloud_provider", "term", "unit", "valid_from", "valid_to") + ordering = ["name"] + + def __str__(self): + return self.name + + def get_price(self, currency_code: str): + try: + return self.prices.get(currency=currency_code).amount + except StoragePlanPrice.DoesNotExist: + return None + + +class ProgressiveDiscountModel(models.Model): + name = models.CharField(max_length=100) + description = models.TextField(blank=True) + active = models.BooleanField(default=True) + + class Meta: + verbose_name = "Discount Model" + + def __str__(self): + return self.name + + def calculate_discount(self, base_rate, units): + """Calculate price using progressive percentage discounts.""" + final_price = 0 + remaining_units = units + processed_units = 0 + + # Get all tiers sorted by threshold + discount_tiers = self.tiers.all().order_by("threshold") + + for i, tier in enumerate(discount_tiers): + # Calculate how many units fall into this tier + if i < discount_tiers.count() - 1: + next_threshold = discount_tiers[i + 1].threshold + tier_units = min(remaining_units, next_threshold - processed_units) + else: + # Last tier handles all remaining units + tier_units = remaining_units + + # Calculate discounted rate for this tier + discounted_rate = base_rate * (1 - tier.discount_percent / 100) + + # Add the price for units in this tier + final_price += discounted_rate * tier_units + + # Update tracking variables + remaining_units -= tier_units + processed_units += tier_units + + # Exit if all units have been processed + if remaining_units <= 0: + break + + return final_price + + +class DiscountTier(models.Model): + discount_model = models.ForeignKey( + ProgressiveDiscountModel, on_delete=models.CASCADE, related_name="tiers" + ) + threshold = models.PositiveIntegerField( + help_text="Starting unit count for this tier" + ) + discount_percent = models.DecimalField( + max_digits=5, decimal_places=2, help_text="Percentage discount applied (0-100)" + ) + + class Meta: + ordering = ["threshold"] + unique_together = ["discount_model", "threshold"] + + def __str__(self): + return f"{self.discount_model.name}: {self.threshold}+ units → {self.discount_percent}% discount" + + +class VSHNAppCatBaseFee(models.Model): + vshn_appcat_price_config = models.ForeignKey( + "VSHNAppCatPrice", on_delete=models.CASCADE, related_name="base_fees" + ) + currency = models.CharField( + max_length=3, + choices=Currency.choices, + ) + amount = models.DecimalField( + max_digits=10, + decimal_places=2, + help_text="Base fee in the specified currency, excl. VAT", + ) + + class Meta: + verbose_name = "Service Base Fee" + unique_together = ("vshn_appcat_price_config", "currency") + ordering = ["currency"] + + def __str__(self): + return f"{self.vshn_appcat_price_config.service.name} Base Fee - {self.amount} {self.currency}" + + +class VSHNAppCatPrice(models.Model): + class VariableUnit(models.TextChoices): + RAM = "RAM", "Memory (RAM)" + CPU = "CPU", "CPU (vCPU)" + USER = "USR", "Users" + + class ServiceLevel(models.TextChoices): + BEST_EFFORT = "BE", "Best Effort" + GUARANTEED = "GA", "Guaranteed Availability" + + service = models.ForeignKey( + Service, on_delete=models.CASCADE, related_name="vshn_appcat_price" + ) + variable_unit = models.CharField( + max_length=3, + choices=VariableUnit.choices, + default=VariableUnit.RAM, + ) + term = models.CharField( + max_length=3, + default=Term.MTH, + choices=Term.choices, + ) + discount_model = models.ForeignKey( + ProgressiveDiscountModel, + on_delete=models.SET_NULL, + null=True, + blank=True, + related_name="price_configs", + ) + + ha_replica_min = models.IntegerField( + default=1, help_text="Minimum of replicas for HA" + ) + ha_replica_max = models.IntegerField( + default=1, help_text="Maximum supported replicas" + ) + + valid_from = models.DateTimeField(blank=True, null=True) + valid_to = models.DateTimeField(blank=True, null=True) + + class Meta: + verbose_name = "AppCat Price" + + def __str__(self): + return f"{self.service.name} - {self.get_variable_unit_display()} based pricing" + + def get_base_fee(self, currency_code: str): + try: + return self.base_fees.get(currency=currency_code).amount + except VSHNAppCatBaseFee.DoesNotExist: + return None + + def get_unit_rate(self, currency_code: str, service_level: str): + try: + return self.unit_rates.get( + currency=currency_code, service_level=service_level + ).amount + except VSHNAppCatUnitRate.DoesNotExist: + return None + + def calculate_final_price( + self, currency_code: str, service_level: str, number_of_units: int + ): + base_fee = self.get_base_fee(currency_code) + unit_rate = self.get_unit_rate(currency_code, service_level) + + if base_fee is None or unit_rate is None: + return None + + if number_of_units < 0: + raise ValueError("Number of units cannot be negative") + + # Apply discount model if available + if self.discount_model and self.discount_model.active: + discounted_price = self.discount_model.calculate_discount( + unit_rate, number_of_units + ) + total_price = base_fee + discounted_price + else: + total_price = base_fee + (unit_rate * number_of_units) + + return total_price + + +class VSHNAppCatUnitRate(models.Model): + vshn_appcat_price_config = models.ForeignKey( + VSHNAppCatPrice, on_delete=models.CASCADE, related_name="unit_rates" + ) + currency = models.CharField( + max_length=3, + choices=Currency.choices, + ) + service_level = models.CharField( + max_length=2, + choices=VSHNAppCatPrice.ServiceLevel.choices, + ) + amount = models.DecimalField( + max_digits=10, + decimal_places=4, + help_text="Price per unit in the specified currency and service level, excl. VAT", + ) + + class Meta: + verbose_name = "Service Unit Rate" + unique_together = ("vshn_appcat_price_config", "currency", "service_level") + ordering = ["currency", "service_level"] + + def __str__(self): + return f"{self.vshn_appcat_price_config.service.name} - {self.get_service_level_display()} Unit Rate - {self.amount} {self.currency}" diff --git a/hub/services/models/providers.py b/hub/services/models/providers.py new file mode 100644 index 0000000..e3257ea --- /dev/null +++ b/hub/services/models/providers.py @@ -0,0 +1,85 @@ +from django.db import models +from django.urls import reverse +from django.utils.text import slugify +from django_prose_editor.fields import ProseEditorField + +from .base import validate_image_size + + +class CloudProvider(models.Model): + name = models.CharField(max_length=100) + slug = models.SlugField(unique=True) + description = ProseEditorField() + website = models.URLField() + linkedin = models.URLField(blank=True) + phone = models.CharField(max_length=25, blank=True, null=True) + email = models.EmailField(max_length=254, blank=True, null=True) + address = models.TextField(max_length=250, blank=True, null=True) + logo = models.ImageField( + upload_to="cloud_provider_logos/", + validators=[validate_image_size], + null=True, + blank=True, + ) + order = models.IntegerField(default=0) + is_featured = models.BooleanField(default=False) + disable_listing = models.BooleanField(default=False) + + class Meta: + ordering = ["order"] + + def __str__(self): + return self.name + + def save(self, *args, **kwargs): + if not self.slug: + self.slug = slugify(self.name) + super().save(*args, **kwargs) + + def get_absolute_url(self): + return reverse("services:provider_detail", kwargs={"slug": self.slug}) + + +class ConsultingPartner(models.Model): + name = models.CharField(max_length=200) + slug = models.SlugField(unique=True) + description = ProseEditorField() + logo = models.ImageField( + upload_to="partner_logos/", + validators=[validate_image_size], + null=True, + blank=True, + ) + website = models.URLField(blank=True) + linkedin = models.URLField(blank=True) + phone = models.CharField(max_length=25, blank=True, null=True) + email = models.EmailField(max_length=254, blank=True, null=True) + address = models.TextField(max_length=250, blank=True, null=True) + + services = models.ManyToManyField( + "services.Service", related_name="consulting_partners", blank=True + ) + cloud_providers = models.ManyToManyField( + CloudProvider, related_name="consulting_partners", blank=True + ) + + order = models.IntegerField(default=0) + is_featured = models.BooleanField(default=False) + disable_listing = models.BooleanField(default=False) + + created_at = models.DateTimeField(auto_now_add=True) + updated_at = models.DateTimeField(auto_now=True) + + class Meta: + ordering = ["order"] + + def __str__(self): + return self.name + + def save(self, *args, **kwargs): + if not self.slug: + self.slug = slugify(self.name) + super().save(*args, **kwargs) + + def get_absolute_url(self): + return reverse("services:partner_detail", kwargs={"slug": self.slug}) diff --git a/hub/services/models/services.py b/hub/services/models/services.py new file mode 100644 index 0000000..4f4b873 --- /dev/null +++ b/hub/services/models/services.py @@ -0,0 +1,169 @@ +from django.db import models +from django.core.exceptions import ValidationError +from django.core.validators import URLValidator +from django.urls import reverse +from django.utils.text import slugify +from django_prose_editor.fields import ProseEditorField + +from .base import Category, ReusableText, validate_image_size +from .providers import CloudProvider + + +class Service(models.Model): + name = models.CharField(max_length=200) + slug = models.SlugField(max_length=250, unique=True) + description = ProseEditorField() + tagline = models.TextField(max_length=500, blank=True, null=True) + logo = models.ImageField( + upload_to="service_logos/", + validators=[validate_image_size], + null=True, + blank=True, + ) + categories = models.ManyToManyField(Category, related_name="services") + features = ProseEditorField() + is_featured = models.BooleanField(default=False) + is_coming_soon = models.BooleanField(default=False) + disable_listing = models.BooleanField(default=False) + + created_at = models.DateTimeField(auto_now_add=True) + updated_at = models.DateTimeField(auto_now=True) + + def __str__(self): + return self.name + + def clean(self): + if self.is_featured and self.is_coming_soon: + raise ValidationError( + "A service cannot be both featured and coming soon simultaneously." + ) + super().clean() + + def save(self, *args, **kwargs): + self.clean() # Ensure validation runs on save + if not self.slug: + self.slug = slugify(self.name) + counter = 1 + while Service.objects.filter(slug=self.slug).exists(): + self.slug = f"{slugify(self.name)}-{counter}" + counter += 1 + super().save(*args, **kwargs) + + def get_absolute_url(self): + return reverse("services:service_detail", kwargs={"slug": self.slug}) + + +class ServiceOffering(models.Model): + service = models.ForeignKey( + Service, on_delete=models.CASCADE, related_name="offerings" + ) + cloud_provider = models.ForeignKey( + CloudProvider, on_delete=models.CASCADE, related_name="offerings" + ) + description = ProseEditorField(blank=True, null=True) + offer_description = models.ForeignKey( + ReusableText, + on_delete=models.PROTECT, + related_name="offer_descriptions", + blank=True, + null=True, + ) + + disable_listing = models.BooleanField(default=False) + + created_at = models.DateTimeField(auto_now_add=True) + updated_at = models.DateTimeField(auto_now=True) + + class Meta: + unique_together = ["service", "cloud_provider"] + ordering = ["service__name", "cloud_provider__name"] + + def __str__(self): + return f"{self.service.name} on {self.cloud_provider.name}" + + def get_absolute_url(self): + return reverse( + "services:offering_detail", + kwargs={ + "provider_slug": self.cloud_provider.slug, + "service_slug": self.service.slug, + }, + ) + + +class Plan(models.Model): + name = models.CharField(max_length=100) + description = ProseEditorField(blank=True, null=True) + pricing = ProseEditorField(blank=True, null=True) + plan_description = models.ForeignKey( + ReusableText, + on_delete=models.PROTECT, + related_name="plan_descriptions", + blank=True, + null=True, + ) + offering = models.ForeignKey( + ServiceOffering, on_delete=models.CASCADE, related_name="plans" + ) + + created_at = models.DateTimeField(auto_now_add=True) + updated_at = models.DateTimeField(auto_now=True) + + class Meta: + ordering = ["name"] + unique_together = [["offering", "name"]] + + def __str__(self): + return f"{self.offering} - {self.name}" + + +class ExternalLinkOffering(models.Model): + offering = models.ForeignKey( + ServiceOffering, on_delete=models.CASCADE, related_name="external_links" + ) + url = models.URLField() + description = models.CharField(max_length=200) + order = models.IntegerField(default=0) + created_at = models.DateTimeField(auto_now_add=True) + updated_at = models.DateTimeField(auto_now=True) + + class Meta: + ordering = ["order", "description"] + verbose_name = "External Link" + verbose_name_plural = "External Links" + + def __str__(self): + return f"{self.description} ({self.url})" + + def clean(self): + validate = URLValidator() + try: + validate(self.url) + except ValidationError: + raise ValidationError({"url": "Enter a valid URL."}) + + +class ExternalLink(models.Model): + service = models.ForeignKey( + Service, on_delete=models.CASCADE, related_name="external_links" + ) + url = models.URLField() + description = models.CharField(max_length=200) + order = models.IntegerField(default=0) + created_at = models.DateTimeField(auto_now_add=True) + updated_at = models.DateTimeField(auto_now=True) + + class Meta: + ordering = ["order", "description"] + verbose_name = "External Link" + verbose_name_plural = "External Links" + + def __str__(self): + return f"{self.description} ({self.url})" + + def clean(self): + validate = URLValidator() + try: + validate(self.url) + except ValidationError: + raise ValidationError({"url": "Enter a valid URL."})