Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 4 additions & 1 deletion docker-app/qfieldcloud/core/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -860,7 +860,10 @@ def clean(self) -> None:
if self.organization.organization_owner == self.member:
raise ValidationError(_("Cannot add the organization owner as a member."))

max_organization_members = self.organization.useraccount.current_subscription.plan.max_organization_members
max_organization_members = (
self.organization.useraccount.current_subscription.max_organization_members
)

if (
max_organization_members > -1
and self.organization.members.count() >= max_organization_members
Expand Down
23 changes: 23 additions & 0 deletions docker-app/qfieldcloud/core/permissions_utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -911,3 +911,26 @@ def check_can_upload_file(project, client_type, file_size_bytes: int) -> bool:
)
else:
return True


def can_change_seats(user: QfcUser, subscription: Subscription) -> bool:
"""
Return True if `user` may adjust the seat count on this `subscription`.
Only organization owners can do so, the plan must have a finite member limit,
and the subscription must still be active (no pending cancellation).
"""

if (
not subscription.account.user.is_organization
or subscription.plan.max_organization_members == -1
):
return False

if subscription.active_until:
return False

return user_has_organization_role_origins(
user,
subscription.account.organization,
[OrganizationQueryset.RoleOrigins.ORGANIZATIONOWNER],
)
7 changes: 6 additions & 1 deletion docker-app/qfieldcloud/core/tests/utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -74,8 +74,13 @@ def set_subscription(
)
subscription: Subscription = user.useraccount.current_subscription
subscription.plan = plan
subscription.max_organization_members = (
subscription.plan.max_organization_members
)
subscription.active_since = timezone.now() - timedelta(days=1)
subscription.save(update_fields=["plan", "active_since"])
subscription.save(
update_fields=["plan", "max_organization_members", "active_since"]
)

return subscription

Expand Down
7 changes: 7 additions & 0 deletions docker-app/qfieldcloud/subscription/admin.py
Original file line number Diff line number Diff line change
Expand Up @@ -141,6 +141,8 @@ class SubscriptionAdmin(QFieldCloudModelAdmin):
"account__link",
"account__user__email",
"plan__link",
"plan__is_seat_flexible",
"max_organization_members",
"active_since",
"active_until",
"is_active",
Expand All @@ -159,6 +161,7 @@ class SubscriptionAdmin(QFieldCloudModelAdmin):
"created_at",
"updated_at",
"requested_cancel_at",
"plan__is_seat_flexible",
)

autocomplete_fields = ("account",)
Expand Down Expand Up @@ -212,6 +215,10 @@ def is_active(self, instance):
def plan__link(self, instance):
return model_admin_url(instance.plan, str(instance.plan))

@admin.display(description="Seat-flexible plan")
def plan__is_seat_flexible(self, instance):
return instance.plan.is_seat_flexible

@admin.display(description=_("Promotion"))
def promotion__link(self, instance):
return model_admin_url(instance.promotion, str(instance.promotion))
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
# Generated by Django 4.2.19 on 2025-08-21 06:58

from django.db import migrations, models


class Migration(migrations.Migration):
dependencies = [
("subscription", "0006_auto_20230426_2222"),
]

operations = [
migrations.RunSQL(
sql=migrations.RunSQL.noop,
reverse_sql="""
DROP VIEW IF EXISTS current_subscriptions_vw;
CREATE VIEW current_subscriptions_vw AS
SELECT *
FROM subscription_subscription
WHERE active_since < now()
AND (active_until IS NULL OR active_until > now());
""",
),
migrations.AddField(
model_name="plan",
name="is_seat_flexible",
field=models.BooleanField(default=False),
),
migrations.AddField(
model_name="subscription",
name="max_organization_members",
field=models.IntegerField(
default=-1,
help_text="Maximum organization members allowed for this subscription. Used for enforcing seat limits on a per-subscription basis for specific plans.",
),
),
]
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
from django.db import migrations


class Migration(migrations.Migration):
dependencies = [
("subscription", "0007_plan_is_seat_flexible_and_more"),
]

operations = []
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
from django.db import migrations


class Migration(migrations.Migration):
dependencies = [
("subscription", "0008_empty_migration"),
]

operations = [
migrations.RemoveField(
model_name="plan",
name="is_metered",
),
migrations.RunSQL(
sql="""
DROP VIEW IF EXISTS current_subscriptions_vw;
CREATE VIEW current_subscriptions_vw AS
SELECT
*
FROM subscription_subscription
WHERE active_since < now()
AND (active_until IS NULL OR active_until > now());
""",
reverse_sql="""
DROP VIEW IF EXISTS current_subscriptions_vw;
""",
),
]
72 changes: 62 additions & 10 deletions docker-app/qfieldcloud/subscription/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,13 @@
from django.utils.translation import gettext as _
from model_utils.managers import InheritanceManagerMixin

from qfieldcloud.core.models import Organization, Person, User, UserAccount
from qfieldcloud.core.models import (
Organization,
OrganizationMember,
Person,
User,
UserAccount,
)

from .exceptions import NotPremiumPlanException

Expand Down Expand Up @@ -152,15 +158,15 @@ def get_plans_for_user(cls, user: User, user_type: User.Type) -> QuerySet["Plan"
# the plan is set as trial
is_trial = models.BooleanField(default=False)

# the plan is metered or licensed. If it metered, it is automatically post-paid.
is_metered = models.BooleanField(default=False)

# the plan is cancellable. If it True, the plan cannot be cancelled.
is_cancellable = models.BooleanField(default=True)

# the plan is cancellable. If it True, the plan cannot be cancelled.
is_storage_modifiable = models.BooleanField(default=True)

# has seats flexibility
is_seat_flexible = models.BooleanField(default=False)

# The maximum number of organizations members that are allowed to be added per organization
# This constraint is useful for public administrations with limited resources who want to cap
# the maximum amount of money that they are going to pay.
Expand Down Expand Up @@ -450,6 +456,11 @@ class Meta:

objects = SubscriptionManager()

###
# ID is implicitly set by Django.
###
id: int

Status = SubscriptionStatus

class UpdateSubscriptionKwargs(TypedDict):
Expand All @@ -466,6 +477,16 @@ class UpdateSubscriptionKwargs(TypedDict):
related_name="+",
)

# The maximum number of organization members (seats) allowed under this specific subscription.
# This value is set at subscription creation time, typically based on the quantity selected during checkout (for per-seat pricing).
max_organization_members = models.IntegerField(
default=-1,
help_text=_(
"Maximum organization members allowed for this subscription. "
"Used for enforcing seat limits on a per-subscription basis for specific plans."
),
)

is_frontend_user_editable = False

account = models.ForeignKey(
Expand Down Expand Up @@ -523,12 +544,12 @@ class UpdateSubscriptionKwargs(TypedDict):

@property
@deprecated("Use `AbstractSubscription.active_storage_total_bytes` instead")
def active_storage_total_mb(self) -> int:
return self.plan.storage_mb + self.active_storage_package_mb
def active_storage_total_mb(self) -> float:
return float(self.active_storage_total_bytes / 1000 / 1000)

@property
def active_storage_total_bytes(self) -> int:
return self.plan.storage_bytes + self.active_storage_package_bytes
return self.included_storage_bytes + self.active_storage_package_bytes

@property
def active_storage_package(self) -> Package:
Expand Down Expand Up @@ -627,6 +648,33 @@ def active_users_count(self) -> int:

return self.active_users.count()

@property
def organization_members_count(self) -> int:
# if non-organization account, then it is always 1 user
if not self.account.user.is_organization:
return 1

return (
OrganizationMember.objects.filter(organization_id=self.account.user.pk)
.exclude(member_id=self.account.user.organization_owner_id)
.count()
# +1 for the organization owner
+ 1
)

@property
def included_storage_bytes(self) -> int:
"""
How much storage (in bytes) this subscription comes with by default.
- For the `flat` plan (1GB per seat) is calculated as `Subscription.max_organization_members * Plan.storage_bytes`
- For any other plan, just use `Plan.storage_bytes`
"""
if self.plan.is_seat_flexible:
return self.max_organization_members * self.plan.storage_bytes

# whatever the plan ships by default
return self.plan.storage_bytes

def get_active_package(self, package_type: PackageType) -> Package:
storage_package_qs = self.packages.active().filter(type=package_type) # type: ignore

Expand Down Expand Up @@ -944,9 +992,13 @@ def save(self, *args, **kwargs):
super().save(*args, **kwargs)

def __str__(self):
active_storage_total_mb = (
self.active_storage_package_mb if hasattr(self, "packages") else 0
)
if hasattr(self, "packages"):
active_storage_total_mb = int(
self.active_storage_package_bytes / 1000 / 1000
)
else:
active_storage_total_mb = 0

return f"{self.__class__.__name__} #{self.id} user:{self.account.user.username} plan:{self.plan.code} total:{active_storage_total_mb}MB"


Expand Down
Loading