from django.db import models from django.core.exceptions import ValidationError from django.core.validators import URLValidator from django.urls import reverse from django.utils.text import slugify from django_prose_editor.fields import ProseEditorField from .base import ( Category, ReusableText, ManagedServiceProvider, validate_image_size, Currency, ) from .providers import CloudProvider from .images import ImageReference 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], 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}) @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( Service, on_delete=models.CASCADE, related_name="offerings" ) msp = models.CharField( "Managed Service Provider", max_length=2, default=ManagedServiceProvider.VS, choices=ManagedServiceProvider.choices, ) cloud_provider = models.ForeignKey( CloudProvider, on_delete=models.CASCADE, related_name="offerings" ) description = ProseEditorField(blank=True, null=True) offer_description = models.ForeignKey( ReusableText, on_delete=models.PROTECT, related_name="offer_descriptions", blank=True, null=True, ) disable_listing = models.BooleanField(default=False) created_at = models.DateTimeField(auto_now_add=True) updated_at = models.DateTimeField(auto_now=True) class Meta: unique_together = ["service", "cloud_provider"] ordering = ["service__name", "cloud_provider__name"] def __str__(self): return f"{self.service.name} on {self.cloud_provider.name}" def get_absolute_url(self): return reverse( "services:offering_detail", kwargs={ "provider_slug": self.cloud_provider.slug, "service_slug": self.service.slug, }, ) class 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) 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" ) # 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 = ["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: return price_obj.amount return None 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."})