This commit is contained in:
parent
9a3734192e
commit
cce071397c
4 changed files with 269 additions and 281 deletions
|
|
@ -9,8 +9,11 @@ from servala.core.forms import ControlPlaneAdminForm, ServiceDefinitionAdminForm
|
||||||
from servala.core.models import (
|
from servala.core.models import (
|
||||||
BillingEntity,
|
BillingEntity,
|
||||||
CloudProvider,
|
CloudProvider,
|
||||||
|
ComputePlan,
|
||||||
|
ComputePlanAssignment,
|
||||||
ControlPlane,
|
ControlPlane,
|
||||||
ControlPlaneCRD,
|
ControlPlaneCRD,
|
||||||
|
OdooObjectCache,
|
||||||
Organization,
|
Organization,
|
||||||
OrganizationInvitation,
|
OrganizationInvitation,
|
||||||
OrganizationMembership,
|
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):
|
def get_exclude(self, request, obj=None):
|
||||||
|
|
@ -363,15 +379,21 @@ class ControlPlaneCRDAdmin(admin.ModelAdmin):
|
||||||
|
|
||||||
@admin.register(ServiceInstance)
|
@admin.register(ServiceInstance)
|
||||||
class ServiceInstanceAdmin(admin.ModelAdmin):
|
class ServiceInstanceAdmin(admin.ModelAdmin):
|
||||||
list_display = ("name", "organization", "context", "created_by")
|
list_display = (
|
||||||
list_filter = ("organization", "context")
|
"name",
|
||||||
|
"organization",
|
||||||
|
"context",
|
||||||
|
"compute_plan_assignment",
|
||||||
|
"created_by",
|
||||||
|
)
|
||||||
|
list_filter = ("organization", "context", "compute_plan_assignment")
|
||||||
search_fields = (
|
search_fields = (
|
||||||
"name",
|
"name",
|
||||||
"organization__name",
|
"organization__name",
|
||||||
"context__service_offering__service__name",
|
"context__service_offering__service__name",
|
||||||
)
|
)
|
||||||
readonly_fields = ("name", "organization", "context")
|
readonly_fields = ("name", "organization", "context")
|
||||||
autocomplete_fields = ("organization", "context")
|
autocomplete_fields = ("organization", "context", "compute_plan_assignment")
|
||||||
|
|
||||||
def get_readonly_fields(self, request, obj=None):
|
def get_readonly_fields(self, request, obj=None):
|
||||||
if obj: # If this is an edit (not a new instance)
|
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
|
schema=external_links_schema
|
||||||
)
|
)
|
||||||
return form
|
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")
|
||||||
|
|
|
||||||
|
|
@ -8,10 +8,8 @@ from .organization import (
|
||||||
OrganizationRole,
|
OrganizationRole,
|
||||||
)
|
)
|
||||||
from .plan import (
|
from .plan import (
|
||||||
ResourcePlan,
|
ComputePlan,
|
||||||
ResourcePlanAssignment,
|
ComputePlanAssignment,
|
||||||
StoragePlan,
|
|
||||||
StoragePlanAssignment,
|
|
||||||
)
|
)
|
||||||
from .service import (
|
from .service import (
|
||||||
CloudProvider,
|
CloudProvider,
|
||||||
|
|
@ -28,6 +26,8 @@ from .user import User
|
||||||
__all__ = [
|
__all__ = [
|
||||||
"BillingEntity",
|
"BillingEntity",
|
||||||
"CloudProvider",
|
"CloudProvider",
|
||||||
|
"ComputePlan",
|
||||||
|
"ComputePlanAssignment",
|
||||||
"ControlPlane",
|
"ControlPlane",
|
||||||
"ControlPlaneCRD",
|
"ControlPlaneCRD",
|
||||||
"OdooObjectCache",
|
"OdooObjectCache",
|
||||||
|
|
@ -36,14 +36,10 @@ __all__ = [
|
||||||
"OrganizationMembership",
|
"OrganizationMembership",
|
||||||
"OrganizationOrigin",
|
"OrganizationOrigin",
|
||||||
"OrganizationRole",
|
"OrganizationRole",
|
||||||
"ResourcePlan",
|
|
||||||
"ResourcePlanAssignment",
|
|
||||||
"Service",
|
"Service",
|
||||||
"ServiceCategory",
|
"ServiceCategory",
|
||||||
"ServiceDefinition",
|
"ServiceDefinition",
|
||||||
"ServiceInstance",
|
"ServiceInstance",
|
||||||
"ServiceOffering",
|
"ServiceOffering",
|
||||||
"StoragePlan",
|
|
||||||
"StoragePlanAssignment",
|
|
||||||
"User",
|
"User",
|
||||||
]
|
]
|
||||||
|
|
|
||||||
|
|
@ -1,23 +1,20 @@
|
||||||
|
from decimal import Decimal
|
||||||
|
|
||||||
from django.core.validators import MinValueValidator
|
from django.core.validators import MinValueValidator
|
||||||
from django.db import models
|
from django.db import models
|
||||||
from django.utils.functional import cached_property
|
|
||||||
from django.utils.translation import gettext_lazy as _
|
from django.utils.translation import gettext_lazy as _
|
||||||
|
|
||||||
from servala.core.models.mixins import ServalaModelMixin
|
from servala.core.models.mixins import ServalaModelMixin
|
||||||
|
|
||||||
|
|
||||||
class BasePlan(ServalaModelMixin):
|
class ComputePlan(ServalaModelMixin):
|
||||||
"""
|
"""
|
||||||
Abstract base class for all plan types (resource and storage plans).
|
Compute resource plans for service instances.
|
||||||
|
|
||||||
Plans define service configurations and are linked to Odoo products for billing.
|
Defines CPU and memory allocations. Pricing and service level are configured
|
||||||
|
per assignment to a ControlPlaneCRD.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
ODOO_PRODUCT_TYPE_CHOICES = [
|
|
||||||
("product.product", _("Product Variant")),
|
|
||||||
("product.template", _("Product Template")),
|
|
||||||
]
|
|
||||||
|
|
||||||
name = models.CharField(
|
name = models.CharField(
|
||||||
max_length=100,
|
max_length=100,
|
||||||
verbose_name=_("Name"),
|
verbose_name=_("Name"),
|
||||||
|
|
@ -32,165 +29,6 @@ class BasePlan(ServalaModelMixin):
|
||||||
help_text=_("Whether this plan is available for selection"),
|
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")
|
# Kubernetes resource specifications (use Kubernetes format: "2Gi", "500m")
|
||||||
memory_requests = models.CharField(
|
memory_requests = models.CharField(
|
||||||
max_length=20,
|
max_length=20,
|
||||||
|
|
@ -213,26 +51,14 @@ class ResourcePlan(BasePlan):
|
||||||
help_text=_("e.g., '2000m', '2', '4'"),
|
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:
|
class Meta:
|
||||||
verbose_name = _("Resource Plan")
|
verbose_name = _("Compute Plan")
|
||||||
verbose_name_plural = _("Resource Plans")
|
verbose_name_plural = _("Compute Plans")
|
||||||
ordering = ["name"]
|
ordering = ["name"]
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
return self.name
|
||||||
|
|
||||||
def get_resource_summary(self):
|
def get_resource_summary(self):
|
||||||
"""
|
"""
|
||||||
Get a human-readable summary of resources.
|
Get a human-readable summary of resources.
|
||||||
|
|
@ -243,41 +69,71 @@ class ResourcePlan(BasePlan):
|
||||||
return f"{self.cpu_limits} vCPU, {self.memory_limits} RAM"
|
return f"{self.cpu_limits} vCPU, {self.memory_limits} RAM"
|
||||||
|
|
||||||
|
|
||||||
class StoragePlan(BasePlan):
|
class ComputePlanAssignment(ServalaModelMixin):
|
||||||
"""
|
"""
|
||||||
Storage plans for service instances.
|
Links compute plans to control plane CRDs with pricing and service level.
|
||||||
|
|
||||||
Currently inherits all fields from BasePlan. Future fields could include
|
A product in Odoo represents a service with a specific compute plan, control plane,
|
||||||
storage class, performance tier, IOPS limits, etc.
|
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.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
class Meta:
|
SLA_CHOICES = [
|
||||||
verbose_name = _("Storage Plan")
|
("besteffort", _("Best Effort")),
|
||||||
verbose_name_plural = _("Storage Plans")
|
("guaranteed", _("Guaranteed Availability")),
|
||||||
ordering = ["name"]
|
]
|
||||||
|
|
||||||
|
compute_plan = models.ForeignKey(
|
||||||
class ResourcePlanAssignment(ServalaModelMixin):
|
ComputePlan,
|
||||||
"""
|
|
||||||
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,
|
on_delete=models.CASCADE,
|
||||||
related_name="assignments",
|
related_name="assignments",
|
||||||
verbose_name=_("Resource plan"),
|
verbose_name=_("Compute plan"),
|
||||||
)
|
)
|
||||||
control_plane_crd = models.ForeignKey(
|
control_plane_crd = models.ForeignKey(
|
||||||
"ControlPlaneCRD",
|
"ControlPlaneCRD",
|
||||||
on_delete=models.CASCADE,
|
on_delete=models.CASCADE,
|
||||||
related_name="resource_plan_assignments",
|
related_name="compute_plan_assignments",
|
||||||
verbose_name=_("Control plane CRD"),
|
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
|
# Display ordering in UI
|
||||||
sort_order = models.PositiveIntegerField(
|
sort_order = models.PositiveIntegerField(
|
||||||
default=0,
|
default=0,
|
||||||
|
|
@ -292,60 +148,25 @@ class ResourcePlanAssignment(ServalaModelMixin):
|
||||||
help_text=_("Whether this plan is available for this CRD"),
|
help_text=_("Whether this plan is available for this CRD"),
|
||||||
)
|
)
|
||||||
|
|
||||||
# Future: organization limits, pricing overrides, etc.
|
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
verbose_name = _("Resource Plan Assignment")
|
verbose_name = _("Compute Plan Assignment")
|
||||||
verbose_name_plural = _("Resource Plan Assignments")
|
verbose_name_plural = _("Compute Plan Assignments")
|
||||||
unique_together = [["resource_plan", "control_plane_crd"]]
|
unique_together = [["compute_plan", "control_plane_crd", "sla"]]
|
||||||
ordering = ["sort_order", "resource_plan__name"]
|
ordering = ["sort_order", "compute_plan__name", "sla"]
|
||||||
|
|
||||||
def __str__(self):
|
def __str__(self):
|
||||||
return f"{self.resource_plan.name} → {self.control_plane_crd}"
|
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.
|
||||||
|
|
||||||
class StoragePlanAssignment(ServalaModelMixin):
|
In the future, this will query Odoo based on invoicing policy.
|
||||||
"""
|
For now, returns the product ID directly.
|
||||||
Links storage plans to control plane CRDs.
|
|
||||||
|
|
||||||
Allows the same plan to be reused across multiple CRDs with per-assignment
|
Returns:
|
||||||
configuration (sorting, activation, future: pricing overrides, limits, etc.)
|
The Odoo product ID to use for billing
|
||||||
"""
|
"""
|
||||||
|
# TODO: Implement Odoo cache lookup when OdooObjectCache is integrated
|
||||||
storage_plan = models.ForeignKey(
|
# For now, just return the product ID
|
||||||
StoragePlan,
|
return self.odoo_product_id
|
||||||
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}"
|
|
||||||
|
|
|
||||||
|
|
@ -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:
|
class Meta:
|
||||||
verbose_name = _("Control plane")
|
verbose_name = _("Control plane")
|
||||||
verbose_name_plural = _("Control planes")
|
verbose_name_plural = _("Control planes")
|
||||||
|
|
@ -613,23 +635,14 @@ class ServiceInstance(ServalaModelMixin, models.Model):
|
||||||
related_name="service_instances",
|
related_name="service_instances",
|
||||||
on_delete=models.PROTECT,
|
on_delete=models.PROTECT,
|
||||||
)
|
)
|
||||||
resource_plan = models.ForeignKey(
|
compute_plan_assignment = models.ForeignKey(
|
||||||
to="core.ResourcePlan",
|
to="core.ComputePlanAssignment",
|
||||||
on_delete=models.SET_NULL,
|
on_delete=models.SET_NULL,
|
||||||
null=True,
|
null=True,
|
||||||
blank=True,
|
blank=True,
|
||||||
related_name="instances",
|
related_name="instances",
|
||||||
verbose_name=_("Resource plan"),
|
verbose_name=_("Compute plan assignment"),
|
||||||
help_text=_("Compute resource plan for this instance"),
|
help_text=_("Compute plan with SLA 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:
|
class Meta:
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue