Initial Plan model
This commit is contained in:
parent
c0d3a83c9d
commit
9a3734192e
4 changed files with 506 additions and 1 deletions
|
|
@ -1,3 +1,4 @@
|
||||||
|
from .odoo_cache import OdooObjectCache
|
||||||
from .organization import (
|
from .organization import (
|
||||||
BillingEntity,
|
BillingEntity,
|
||||||
Organization,
|
Organization,
|
||||||
|
|
@ -6,6 +7,12 @@ from .organization import (
|
||||||
OrganizationOrigin,
|
OrganizationOrigin,
|
||||||
OrganizationRole,
|
OrganizationRole,
|
||||||
)
|
)
|
||||||
|
from .plan import (
|
||||||
|
ResourcePlan,
|
||||||
|
ResourcePlanAssignment,
|
||||||
|
StoragePlan,
|
||||||
|
StoragePlanAssignment,
|
||||||
|
)
|
||||||
from .service import (
|
from .service import (
|
||||||
CloudProvider,
|
CloudProvider,
|
||||||
ControlPlane,
|
ControlPlane,
|
||||||
|
|
@ -23,15 +30,20 @@ __all__ = [
|
||||||
"CloudProvider",
|
"CloudProvider",
|
||||||
"ControlPlane",
|
"ControlPlane",
|
||||||
"ControlPlaneCRD",
|
"ControlPlaneCRD",
|
||||||
|
"OdooObjectCache",
|
||||||
"Organization",
|
"Organization",
|
||||||
"OrganizationInvitation",
|
"OrganizationInvitation",
|
||||||
"OrganizationMembership",
|
"OrganizationMembership",
|
||||||
"OrganizationOrigin",
|
"OrganizationOrigin",
|
||||||
"OrganizationRole",
|
"OrganizationRole",
|
||||||
|
"ResourcePlan",
|
||||||
|
"ResourcePlanAssignment",
|
||||||
"Service",
|
"Service",
|
||||||
"ServiceCategory",
|
"ServiceCategory",
|
||||||
"ServiceInstance",
|
|
||||||
"ServiceDefinition",
|
"ServiceDefinition",
|
||||||
|
"ServiceInstance",
|
||||||
"ServiceOffering",
|
"ServiceOffering",
|
||||||
|
"StoragePlan",
|
||||||
|
"StoragePlanAssignment",
|
||||||
"User",
|
"User",
|
||||||
]
|
]
|
||||||
|
|
|
||||||
124
src/servala/core/models/odoo_cache.py
Normal file
124
src/servala/core/models/odoo_cache.py
Normal 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"])
|
||||||
351
src/servala/core/models/plan.py
Normal file
351
src/servala/core/models/plan.py
Normal 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}"
|
||||||
|
|
@ -613,6 +613,24 @@ 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(
|
||||||
|
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:
|
class Meta:
|
||||||
verbose_name = _("Service instance")
|
verbose_name = _("Service instance")
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue