# Copyright (c) 2025 Ferrosoft GmbH. All rights reserved.
from django.db import models
from django.urls import reverse
from django.utils.translation import gettext_lazy as _
from ferrosoft.apps.ferrobase.models.base import UUIDModel
from ferrosoft.decimal import zero
[docs]
class AppFeatureManager(models.Manager):
"""Manager for ``AppFeature`` with a bulk upsert keyed on ``code``."""
[docs]
def upsert(self, objects):
return self.bulk_create(
objects,
update_conflicts=True,
update_fields=["name"],
unique_fields=["code"],
)
[docs]
class AppFeature(UUIDModel):
"""A toggleable platform feature identified by a stable string ``code``.
Features are assigned to ``SubscriptionPlan`` instances via
``FeatureAssignment``, optionally with a numeric limit.
"""
class Meta:
verbose_name = _("App Feature")
verbose_name_plural = _("App Features")
constraints = [
models.UniqueConstraint(name="unique_app_feature", fields=["code"])
]
indexes = [
models.Index(fields=["name"]),
]
objects = AppFeatureManager()
code = models.CharField(
verbose_name=_("Code Name"),
)
name = models.CharField(
verbose_name=_("Name"),
)
def __str__(self):
return self.name
[docs]
class SubscriptionPlanManager(models.Manager):
"""Manager for ``SubscriptionPlan`` with a bulk upsert keyed on ``id``."""
[docs]
def upsert(self, objects):
return self.bulk_create(
objects,
update_conflicts=True,
update_fields=["name", "max_tonnage", "enable_all_features"],
unique_fields=["id"],
)
[docs]
class SubscriptionPlan(UUIDModel):
"""Defines a subscription tier that controls which features and tonnage cap an organisation gets.
When ``enable_all_features`` is ``True``, feature-gate checks are bypassed
regardless of the ``FeatureAssignment`` entries.
"""
class Meta:
verbose_name = _("Subscription Plan")
verbose_name_plural = _("Subscription Plans")
constraints = [
models.UniqueConstraint(
fields=["name"],
name="uniq_subscription_plan_name",
)
]
objects = SubscriptionPlanManager()
name = models.CharField(
verbose_name=_("Name"),
)
max_tonnage = models.IntegerField(
blank=True,
null=True,
help_text=_(
"Set maximum allowed tonnage in metric ton or leave blank for unlimited tonnage."
),
verbose_name=_("Maximum Tonnage"),
)
enable_all_features = models.BooleanField(
default=False,
help_text=_("Allow the use of all features regardless of assigned features."),
verbose_name=_("Enable All Features"),
)
features = models.ManyToManyField(
AppFeature,
verbose_name=_("Features"),
related_name="+",
through="FeatureAssignment",
)
def __str__(self):
return self.name
[docs]
def get_absolute_url(self):
return reverse("ferrobase:subscriptionplan_update", args=[self.pk])
[docs]
def get_assigned_feature(self, feature_code: str) -> "FeatureAssignment | None":
return FeatureAssignment.objects.filter(
plan=self, feature__code=feature_code
).first()
[docs]
class FeatureAssignmentModel(models.Manager):
"""Manager for ``FeatureAssignment`` that pre-fetches the related feature and supports bulk upsert."""
[docs]
def get_queryset(self):
return super().get_queryset().select_related("feature")
[docs]
def upsert(self, objects):
return self.bulk_create(
objects,
update_conflicts=True,
update_fields=["limit"],
unique_fields=["plan", "feature"],
)
[docs]
class FeatureAssignment(models.Model):
"""Through model linking an ``AppFeature`` to a ``SubscriptionPlan`` with an optional numeric limit.
The meaning of ``limit`` is feature-specific (e.g. maximum number of
allowed users). Setting ``limit`` to ``0`` disables any limit check for
that feature.
"""
class Meta:
verbose_name = _("Feature Assignment")
verbose_name_plural = _("Feature Assignments")
constraints = [
models.UniqueConstraint(
name="unique_feature_assignment",
fields=["plan", "feature"],
)
]
objects = FeatureAssignmentModel()
plan = models.ForeignKey(
SubscriptionPlan,
related_name="assigned_features",
on_delete=models.CASCADE,
)
feature = models.ForeignKey(
AppFeature,
related_name="+",
on_delete=models.CASCADE,
)
limit = models.IntegerField(
verbose_name=_("Limit"),
help_text=_(
"Meaning of limit is up to the code enforcing it. Set to 0 to disable limit."
),
default=0,
blank=True,
)
def __str__(self):
if self.limit == zero:
return str(self.feature)
return f"{self.feature} (limit: {self.limit})"
[docs]
def get_absolute_url(self):
return reverse("ferrobase:featureassignment_update", args=[self.pk])
[docs]
def with_plan(self, plan: SubscriptionPlan) -> "FeatureAssignment":
self.plan = plan
return self