diff --git a/src/servala/core/admin.py b/src/servala/core/admin.py index 60fe147..f7cf161 100644 --- a/src/servala/core/admin.py +++ b/src/servala/core/admin.py @@ -9,8 +9,11 @@ from servala.core.forms import ControlPlaneAdminForm, ServiceDefinitionAdminForm from servala.core.models import ( BillingEntity, CloudProvider, + ComputePlan, + ComputePlanAssignment, ControlPlane, ControlPlaneCRD, + OdooObjectCache, Organization, OrganizationInvitation, OrganizationMembership, @@ -269,6 +272,19 @@ class ControlPlaneAdmin(admin.ModelAdmin): ), }, ), + ( + _("Storage Plan"), + { + "fields": ( + "storage_plan_odoo_product_id", + "storage_plan_odoo_unit_id", + "storage_plan_price_per_gib", + ), + "description": _( + "Storage plan configuration for this control plane (hardcoded per control plane)." + ), + }, + ), ) def get_exclude(self, request, obj=None): @@ -363,15 +379,21 @@ class ControlPlaneCRDAdmin(admin.ModelAdmin): @admin.register(ServiceInstance) class ServiceInstanceAdmin(admin.ModelAdmin): - list_display = ("name", "organization", "context", "created_by") - list_filter = ("organization", "context") + list_display = ( + "name", + "organization", + "context", + "compute_plan_assignment", + "created_by", + ) + list_filter = ("organization", "context", "compute_plan_assignment") search_fields = ( "name", "organization__name", "context__service_offering__service__name", ) readonly_fields = ("name", "organization", "context") - autocomplete_fields = ("organization", "context") + autocomplete_fields = ("organization", "context", "compute_plan_assignment") def get_readonly_fields(self, request, obj=None): if obj: # If this is an edit (not a new instance) @@ -390,6 +412,10 @@ class ServiceInstanceAdmin(admin.ModelAdmin): ) }, ), + ( + _("Plan"), + {"fields": ("compute_plan_assignment",)}, + ), ) @@ -420,3 +446,135 @@ class ServiceOfferingAdmin(admin.ModelAdmin): schema=external_links_schema ) return form + + +class ComputePlanAssignmentInline(admin.TabularInline): + model = ComputePlanAssignment + extra = 1 + autocomplete_fields = ("control_plane_crd",) + fields = ( + "compute_plan", + "control_plane_crd", + "sla", + "odoo_product_id", + "odoo_unit_id", + "price", + "minimum_service_size", + "sort_order", + "is_active", + ) + readonly_fields = () + + +@admin.register(ComputePlan) +class ComputePlanAdmin(admin.ModelAdmin): + list_display = ( + "name", + "is_active", + "memory_limits", + "cpu_limits", + ) + list_filter = ("is_active",) + search_fields = ("name", "description") + inlines = (ComputePlanAssignmentInline,) + fieldsets = ( + ( + None, + { + "fields": ( + "name", + "description", + "is_active", + ) + }, + ), + ( + _("Resources"), + { + "fields": ( + "memory_requests", + "memory_limits", + "cpu_requests", + "cpu_limits", + ) + }, + ), + ) + + +@admin.register(ComputePlanAssignment) +class ComputePlanAssignmentAdmin(admin.ModelAdmin): + list_display = ( + "compute_plan", + "control_plane_crd", + "sla", + "price", + "sort_order", + "is_active", + ) + list_filter = ("is_active", "sla", "control_plane_crd") + search_fields = ( + "compute_plan__name", + "control_plane_crd__service_offering__service__name", + ) + autocomplete_fields = ("compute_plan", "control_plane_crd") + fieldsets = ( + ( + None, + { + "fields": ( + "compute_plan", + "control_plane_crd", + "sla", + "is_active", + "sort_order", + ) + }, + ), + ( + _("Odoo Integration"), + { + "fields": ( + "odoo_product_id", + "odoo_unit_id", + ) + }, + ), + ( + _("Pricing & Constraints"), + { + "fields": ( + "price", + "minimum_service_size", + ) + }, + ), + ) + + +@admin.register(OdooObjectCache) +class OdooObjectCacheAdmin(admin.ModelAdmin): + list_display = ("odoo_model", "odoo_id", "updated_at", "expires_at", "is_expired") + list_filter = ("odoo_model", "updated_at", "expires_at") + search_fields = ("odoo_model", "odoo_id") + readonly_fields = ("created_at", "updated_at") + actions = ["refresh_caches"] + + def is_expired(self, obj): + return obj.is_expired() + + is_expired.boolean = True + is_expired.short_description = _("Expired") + + def refresh_caches(self, request, queryset): + """Admin action to refresh selected Odoo caches.""" + refreshed_count = 0 + for cache_obj in queryset: + cache_obj.fetch_and_update() + refreshed_count += 1 + messages.success( + request, + _(f"Successfully refreshed {refreshed_count} cache(s)."), + ) + + refresh_caches.short_description = _("Refresh caches") diff --git a/src/servala/core/models/__init__.py b/src/servala/core/models/__init__.py index 4c23f18..4cb8fd7 100644 --- a/src/servala/core/models/__init__.py +++ b/src/servala/core/models/__init__.py @@ -1,3 +1,4 @@ +from .odoo_cache import OdooObjectCache from .organization import ( BillingEntity, Organization, @@ -6,6 +7,10 @@ from .organization import ( OrganizationOrigin, OrganizationRole, ) +from .plan import ( + ComputePlan, + ComputePlanAssignment, +) from .service import ( CloudProvider, ControlPlane, @@ -21,8 +26,11 @@ from .user import User __all__ = [ "BillingEntity", "CloudProvider", + "ComputePlan", + "ComputePlanAssignment", "ControlPlane", "ControlPlaneCRD", + "OdooObjectCache", "Organization", "OrganizationInvitation", "OrganizationMembership", @@ -30,8 +38,8 @@ __all__ = [ "OrganizationRole", "Service", "ServiceCategory", - "ServiceInstance", "ServiceDefinition", + "ServiceInstance", "ServiceOffering", "User", ] diff --git a/src/servala/core/models/odoo_cache.py b/src/servala/core/models/odoo_cache.py new file mode 100644 index 0000000..8f0bd3f --- /dev/null +++ b/src/servala/core/models/odoo_cache.py @@ -0,0 +1,124 @@ +from django.db import models +from django.utils.translation import gettext_lazy as _ + +from servala.core.models.mixins import ServalaModelMixin + + +class OdooObjectCache(ServalaModelMixin): + """ + Generic cache for Odoo API responses. + + Caches data from various Odoo models (product.product, product.template, uom.uom, etc.) + to reduce API calls and improve performance. + """ + + odoo_model = models.CharField( + max_length=100, + verbose_name=_("Odoo model"), + help_text=_( + "Odoo model name: 'product.product', 'product.template', 'uom.uom', etc." + ), + ) + odoo_id = models.PositiveIntegerField( + verbose_name=_("Odoo ID"), + help_text=_("ID in the Odoo model"), + ) + data = models.JSONField( + verbose_name=_("Cached data"), + help_text=_("Cached Odoo data including price, reporting_product_id, etc."), + ) + expires_at = models.DateTimeField( + null=True, + blank=True, + verbose_name=_("Expires at"), + help_text=_("When cache should be refreshed (null = never expires)"), + ) + + class Meta: + verbose_name = _("Odoo Object Cache") + verbose_name_plural = _("Odoo Object Caches") + unique_together = [["odoo_model", "odoo_id"]] + indexes = [ + models.Index(fields=["odoo_model", "odoo_id"]), + models.Index(fields=["expires_at"]), + ] + + def __str__(self): + return f"{self.odoo_model}({self.odoo_id})" + + def is_expired(self): + """Check if cache needs refresh.""" + if self.expires_at is None: + return False + from django.utils import timezone + + return timezone.now() > self.expires_at + + @classmethod + def get_or_fetch(cls, odoo_model, odoo_id, ttl_hours=24): + """ + Get cached data or fetch from Odoo if expired/missing. + + Args: + odoo_model: Odoo model name (e.g., 'product.product') + odoo_id: ID in the Odoo model + ttl_hours: Time-to-live in hours for the cache + + Returns: + OdooObjectCache instance with fresh data + """ + from datetime import timedelta + + from django.utils import timezone + + try: + cache_obj = cls.objects.get(odoo_model=odoo_model, odoo_id=odoo_id) + if not cache_obj.is_expired(): + return cache_obj + # Cache exists but expired, refresh it + cache_obj.fetch_and_update(ttl_hours=ttl_hours) + return cache_obj + except cls.DoesNotExist: + # Create new cache entry + cache_obj = cls.objects.create( + odoo_model=odoo_model, + odoo_id=odoo_id, + data={}, + expires_at=( + timezone.now() + timedelta(hours=ttl_hours) if ttl_hours else None + ), + ) + cache_obj.fetch_and_update(ttl_hours=ttl_hours) + return cache_obj + + def fetch_and_update(self, ttl_hours=24): + """ + Fetch latest data from Odoo and update cache. + + Args: + ttl_hours: Time-to-live in hours for the cache + """ + from datetime import timedelta + + from django.utils import timezone + + from servala.core.odoo import CLIENT + + # Fetch data from Odoo + results = CLIENT.search_read( + self.odoo_model, + [[("id", "=", self.odoo_id)]], + fields=None, # Fetch all fields + ) + + if results: + self.data = results[0] + self.expires_at = ( + timezone.now() + timedelta(hours=ttl_hours) if ttl_hours else None + ) + self.save(update_fields=["data", "expires_at", "updated_at"]) + else: + # Object not found in Odoo, mark as expired immediately + self.data = {} + self.expires_at = timezone.now() + self.save(update_fields=["data", "expires_at", "updated_at"]) diff --git a/src/servala/core/models/plan.py b/src/servala/core/models/plan.py new file mode 100644 index 0000000..6fc50e4 --- /dev/null +++ b/src/servala/core/models/plan.py @@ -0,0 +1,172 @@ +from decimal import Decimal + +from django.core.validators import MinValueValidator +from django.db import models +from django.utils.translation import gettext_lazy as _ + +from servala.core.models.mixins import ServalaModelMixin + + +class ComputePlan(ServalaModelMixin): + """ + Compute resource plans for service instances. + + Defines CPU and memory allocations. Pricing and service level are configured + per assignment to a ControlPlaneCRD. + """ + + name = models.CharField( + max_length=100, + verbose_name=_("Name"), + ) + description = models.TextField( + blank=True, + verbose_name=_("Description"), + ) + is_active = models.BooleanField( + default=True, + verbose_name=_("Is active"), + help_text=_("Whether this plan is available for selection"), + ) + + # Kubernetes resource specifications (use Kubernetes format: "2Gi", "500m") + memory_requests = models.CharField( + max_length=20, + verbose_name=_("Memory requests"), + help_text=_("e.g., '2Gi', '512Mi'"), + ) + memory_limits = models.CharField( + max_length=20, + verbose_name=_("Memory limits"), + help_text=_("e.g., '4Gi', '1Gi'"), + ) + cpu_requests = models.CharField( + max_length=20, + verbose_name=_("CPU requests"), + help_text=_("e.g., '500m', '1', '2'"), + ) + cpu_limits = models.CharField( + max_length=20, + verbose_name=_("CPU limits"), + help_text=_("e.g., '2000m', '2', '4'"), + ) + + class Meta: + verbose_name = _("Compute Plan") + verbose_name_plural = _("Compute Plans") + ordering = ["name"] + + def __str__(self): + return self.name + + def get_resource_summary(self): + """ + Get a human-readable summary of resources. + + Returns: + String like "2 vCPU, 4Gi RAM" + """ + return f"{self.cpu_limits} vCPU, {self.memory_limits} RAM" + + +class ComputePlanAssignment(ServalaModelMixin): + """ + Links compute plans to control plane CRDs with pricing and service level. + + A product in Odoo represents a service with a specific compute plan, control plane, + and SLA. This model stores that correlation. The same compute plan can be assigned + multiple times to the same CRD with different SLAs and pricing. + """ + + SLA_CHOICES = [ + ("besteffort", _("Best Effort")), + ("guaranteed", _("Guaranteed Availability")), + ] + + compute_plan = models.ForeignKey( + ComputePlan, + on_delete=models.CASCADE, + related_name="assignments", + verbose_name=_("Compute plan"), + ) + control_plane_crd = models.ForeignKey( + "ControlPlaneCRD", + on_delete=models.CASCADE, + related_name="compute_plan_assignments", + verbose_name=_("Control plane CRD"), + ) + + # Service Level Agreement + sla = models.CharField( + max_length=20, + choices=SLA_CHOICES, + verbose_name=_("SLA"), + help_text=_("Service Level Agreement"), + ) + + # Odoo product reference + odoo_product_id = models.IntegerField( + verbose_name=_("Odoo product ID"), + help_text=_("ID of the product in Odoo (product.product or product.template)"), + ) + odoo_unit_id = models.IntegerField( + verbose_name=_("Odoo unit ID"), + help_text=_("ID of the unit of measure in Odoo (uom.uom)"), + ) + + # Pricing + price = models.DecimalField( + max_digits=10, + decimal_places=2, + validators=[MinValueValidator(Decimal("0.00"))], + verbose_name=_("Price"), + help_text=_("Price per unit"), + ) + + # Service constraints + minimum_service_size = models.PositiveIntegerField( + default=1, + validators=[MinValueValidator(1)], + verbose_name=_("Minimum service size"), + help_text=_( + "Minimum value for spec.parameters.instances " + "(Guaranteed Availability may require multiple instances)" + ), + ) + + # Display ordering in UI + sort_order = models.PositiveIntegerField( + default=0, + verbose_name=_("Sort order"), + help_text=_("Order in which plans are displayed to users"), + ) + + # Allow per-assignment activation + is_active = models.BooleanField( + default=True, + verbose_name=_("Is active"), + help_text=_("Whether this plan is available for this CRD"), + ) + + class Meta: + verbose_name = _("Compute Plan Assignment") + verbose_name_plural = _("Compute Plan Assignments") + unique_together = [["compute_plan", "control_plane_crd", "sla"]] + ordering = ["sort_order", "compute_plan__name", "sla"] + + def __str__(self): + return f"{self.compute_plan.name} ({self.get_sla_display()}) → {self.control_plane_crd}" + + def get_odoo_reporting_product_id(self): + """ + Get the reporting product ID for this plan. + + In the future, this will query Odoo based on invoicing policy. + For now, returns the product ID directly. + + Returns: + The Odoo product ID to use for billing + """ + # TODO: Implement Odoo cache lookup when OdooObjectCache is integrated + # For now, just return the product ID + return self.odoo_product_id diff --git a/src/servala/core/models/service.py b/src/servala/core/models/service.py index ab7b76f..d4612c7 100644 --- a/src/servala/core/models/service.py +++ b/src/servala/core/models/service.py @@ -170,6 +170,28 @@ class ControlPlane(ServalaModelMixin, models.Model): ), ) + # Storage plan configuration (hardcoded per control plane) + storage_plan_odoo_product_id = models.IntegerField( + null=True, + blank=True, + verbose_name=_("Storage plan Odoo product ID"), + help_text=_("ID of the storage product in Odoo"), + ) + storage_plan_odoo_unit_id = models.IntegerField( + null=True, + blank=True, + verbose_name=_("Storage plan Odoo unit ID"), + help_text=_("ID of the unit of measure in Odoo (uom.uom)"), + ) + storage_plan_price_per_gib = models.DecimalField( + max_digits=10, + decimal_places=2, + null=True, + blank=True, + verbose_name=_("Storage plan price per GiB"), + help_text=_("Price per GiB of storage"), + ) + class Meta: verbose_name = _("Control plane") verbose_name_plural = _("Control planes") @@ -613,6 +635,15 @@ class ServiceInstance(ServalaModelMixin, models.Model): related_name="service_instances", on_delete=models.PROTECT, ) + compute_plan_assignment = models.ForeignKey( + to="core.ComputePlanAssignment", + on_delete=models.SET_NULL, + null=True, + blank=True, + related_name="instances", + verbose_name=_("Compute plan assignment"), + help_text=_("Compute plan with SLA for this instance"), + ) class Meta: verbose_name = _("Service instance")