WIP: Service Plans #308

Draft
rixx wants to merge 11 commits from 264-service-plans into main
4 changed files with 506 additions and 1 deletions
Showing only changes of commit 9a3734192e - Show all commits

View file

@ -1,3 +1,4 @@
from .odoo_cache import OdooObjectCache
from .organization import (
BillingEntity,
Organization,
@ -6,6 +7,12 @@ from .organization import (
OrganizationOrigin,
OrganizationRole,
)
from .plan import (
ResourcePlan,
ResourcePlanAssignment,
StoragePlan,
StoragePlanAssignment,
)
from .service import (
CloudProvider,
ControlPlane,
@ -23,15 +30,20 @@ __all__ = [
"CloudProvider",
"ControlPlane",
"ControlPlaneCRD",
"OdooObjectCache",
"Organization",
"OrganizationInvitation",
"OrganizationMembership",
"OrganizationOrigin",
"OrganizationRole",
"ResourcePlan",
"ResourcePlanAssignment",
"Service",
"ServiceCategory",
"ServiceInstance",
"ServiceDefinition",
"ServiceInstance",
"ServiceOffering",
"StoragePlan",
"StoragePlanAssignment",
"User",
]

View file

@ -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"])

View file

@ -0,0 +1,351 @@
from django.core.validators import MinValueValidator
from django.db import models
from django.utils.functional import cached_property
from django.utils.translation import gettext_lazy as _
from servala.core.models.mixins import ServalaModelMixin
class BasePlan(ServalaModelMixin):
"""
Abstract base class for all plan types (resource and storage plans).
Plans define service configurations and are linked to Odoo products for billing.
"""
ODOO_PRODUCT_TYPE_CHOICES = [
("product.product", _("Product Variant")),
("product.template", _("Product Template")),
]
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"),
)
# Odoo product reference
odoo_plan_id = models.IntegerField(
null=True,
blank=True,
verbose_name=_("Odoo plan ID"),
help_text=_("ID in the Odoo product model"),
)
odoo_plan_type = models.CharField(
max_length=50,
choices=ODOO_PRODUCT_TYPE_CHOICES,
null=True,
blank=True,
verbose_name=_("Odoo plan type"),
help_text=_("Type of Odoo product model"),
)
# Odoo unit reference (name is cached in OdooObjectCache)
odoo_unit_id = models.IntegerField(
null=True,
blank=True,
verbose_name=_("Odoo unit ID"),
help_text=_("ID of the unit of measure in Odoo (uom.uom)"),
)
class Meta:
abstract = True
def __str__(self):
return self.name
@cached_property
def odoo_product_cache(self):
"""
Fetch cached Odoo product data.
Returns:
OdooObjectCache instance or None if not configured
"""
if not self.odoo_plan_id or not self.odoo_plan_type:
return None
from servala.core.models.odoo_cache import OdooObjectCache
return OdooObjectCache.get_or_fetch(
odoo_model=self.odoo_plan_type,
odoo_id=self.odoo_plan_id,
ttl_hours=24,
)
@cached_property
def odoo_unit_cache(self):
"""
Fetch cached Odoo unit data.
Returns:
OdooObjectCache instance or None if not configured
"""
if not self.odoo_unit_id:
return None
from servala.core.models.odoo_cache import OdooObjectCache
return OdooObjectCache.get_or_fetch(
odoo_model="uom.uom",
odoo_id=self.odoo_unit_id,
ttl_hours=168, # 1 week - units change rarely
)
def get_cached_price(self):
"""
Get price from Odoo cache.
Returns:
Price as float or None if not available
"""
cache = self.odoo_product_cache
if cache and cache.data:
return cache.data.get("list_price") or cache.data.get("standard_price")
return None
def get_reporting_product_id(self):
"""
Get the reporting product ID based on invoicing policy.
Returns:
Reporting product ID or None
"""
cache = self.odoo_product_cache
if not cache or not cache.data:
return None
invoicing_policy = cache.data.get("invoice_policy")
if invoicing_policy == "delivery":
# Metered billing
return cache.data.get("metered_billing_id")
else:
# Order-based billing
return cache.data.get("product_instance_event_id")
def get_odoo_unit_name(self):
"""
Get the unit name from Odoo cache.
Returns:
Unit name string or None
"""
cache = self.odoo_unit_cache
if cache and cache.data:
return cache.data.get("name")
return None
def refresh_odoo_caches(self, ttl_hours=24):
"""
Force refresh of all Odoo caches for this plan.
Args:
ttl_hours: Time-to-live in hours for the cache
"""
# Clear cached_property caches
if "odoo_product_cache" in self.__dict__:
del self.__dict__["odoo_product_cache"]
if "odoo_unit_cache" in self.__dict__:
del self.__dict__["odoo_unit_cache"]
# Refresh caches
if self.odoo_plan_id and self.odoo_plan_type:
from servala.core.models.odoo_cache import OdooObjectCache
try:
cache_obj = OdooObjectCache.objects.get(
odoo_model=self.odoo_plan_type,
odoo_id=self.odoo_plan_id,
)
cache_obj.fetch_and_update(ttl_hours=ttl_hours)
except OdooObjectCache.DoesNotExist:
# Will be created on next access
pass
if self.odoo_unit_id:
from servala.core.models.odoo_cache import OdooObjectCache
try:
cache_obj = OdooObjectCache.objects.get(
odoo_model="uom.uom",
odoo_id=self.odoo_unit_id,
)
cache_obj.fetch_and_update(ttl_hours=168)
except OdooObjectCache.DoesNotExist:
# Will be created on next access
pass
class ResourcePlan(BasePlan):
"""
Compute resource plans for service instances.
Defines CPU, memory, and storage allocations along with service level.
"""
# 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'"),
)
# Storage proposal (for UI pre-fill)
proposed_storage_gib = models.PositiveIntegerField(
validators=[MinValueValidator(1)],
verbose_name=_("Proposed storage (GiB)"),
help_text=_("Suggested storage amount in GiB to propose to the user"),
)
# Service level
service_level = models.CharField(
max_length=50,
blank=True,
verbose_name=_("Service level"),
help_text=_("e.g., 'Standard', 'Premium', 'Enterprise'"),
)
class Meta:
verbose_name = _("Resource Plan")
verbose_name_plural = _("Resource Plans")
ordering = ["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 StoragePlan(BasePlan):
"""
Storage plans for service instances.
Currently inherits all fields from BasePlan. Future fields could include
storage class, performance tier, IOPS limits, etc.
"""
class Meta:
verbose_name = _("Storage Plan")
verbose_name_plural = _("Storage Plans")
ordering = ["name"]
class ResourcePlanAssignment(ServalaModelMixin):
"""
Links resource plans to control plane CRDs.
Allows the same plan to be reused across multiple CRDs with per-assignment
configuration (sorting, activation, future: pricing overrides, limits, etc.)
"""
resource_plan = models.ForeignKey(
ResourcePlan,
on_delete=models.CASCADE,
related_name="assignments",
verbose_name=_("Resource plan"),
)
control_plane_crd = models.ForeignKey(
"ControlPlaneCRD",
on_delete=models.CASCADE,
related_name="resource_plan_assignments",
verbose_name=_("Control plane CRD"),
)
# 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"),
)
# Future: organization limits, pricing overrides, etc.
class Meta:
verbose_name = _("Resource Plan Assignment")
verbose_name_plural = _("Resource Plan Assignments")
unique_together = [["resource_plan", "control_plane_crd"]]
ordering = ["sort_order", "resource_plan__name"]
def __str__(self):
return f"{self.resource_plan.name}{self.control_plane_crd}"
class StoragePlanAssignment(ServalaModelMixin):
"""
Links storage plans to control plane CRDs.
Allows the same plan to be reused across multiple CRDs with per-assignment
configuration (sorting, activation, future: pricing overrides, limits, etc.)
"""
storage_plan = models.ForeignKey(
StoragePlan,
on_delete=models.CASCADE,
related_name="assignments",
verbose_name=_("Storage plan"),
)
control_plane_crd = models.ForeignKey(
"ControlPlaneCRD",
on_delete=models.CASCADE,
related_name="storage_plan_assignments",
verbose_name=_("Control plane CRD"),
)
# 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"),
)
# Future: organization limits, pricing overrides, etc.
class Meta:
verbose_name = _("Storage Plan Assignment")
verbose_name_plural = _("Storage Plan Assignments")
unique_together = [["storage_plan", "control_plane_crd"]]
ordering = ["sort_order", "storage_plan__name"]
def __str__(self):
return f"{self.storage_plan.name}{self.control_plane_crd}"

View file

@ -613,6 +613,24 @@ class ServiceInstance(ServalaModelMixin, models.Model):
related_name="service_instances",
on_delete=models.PROTECT,
)
resource_plan = models.ForeignKey(
to="core.ResourcePlan",
on_delete=models.SET_NULL,
null=True,
blank=True,
related_name="instances",
verbose_name=_("Resource plan"),
help_text=_("Compute resource plan for this instance"),
)
storage_plan = models.ForeignKey(
to="core.StoragePlan",
on_delete=models.SET_NULL,
null=True,
blank=True,
related_name="instances",
verbose_name=_("Storage plan"),
help_text=_("Storage plan for this instance"),
)
class Meta:
verbose_name = _("Service instance")