Skip to content
Open
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
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
# Generated by Django 4.2.2 on 2025-12-22 21:06

from django.conf import settings
from django.db import migrations, models
import django.db.models.deletion


class Migration(migrations.Migration):

dependencies = [
('organizations', '0017_organization_merged'),
]

operations = [
migrations.AlterField(
model_name='organization',
name='merged',
field=models.ForeignKey(blank=True, help_text='The organization this organization was merged in to', null=True, on_delete=django.db.models.deletion.PROTECT, related_name='+', to=settings.SQUARELET_ORGANIZATION_MODEL),
),
]
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
# Generated by Django 4.2.2 on 2025-12-22 21:32

from django.conf import settings
from django.db import migrations, models
import django.db.models.deletion


class Migration(migrations.Migration):

dependencies = [
('organizations', '0018_alter_organization_merged'),
]

operations = [
migrations.AddField(
model_name='organization',
name='members',
field=models.ManyToManyField(blank=True, help_text='Organizations which are members of this organization (useful for trade associations or other member groups)', related_name='groups', to=settings.SQUARELET_ORGANIZATION_MODEL),
),
migrations.AddField(
model_name='organization',
name='parent',
field=models.ForeignKey(blank=True, help_text='The parent organization', null=True, on_delete=django.db.models.deletion.PROTECT, related_name='children', to=settings.SQUARELET_ORGANIZATION_MODEL, verbose_name='parent'),
),
migrations.AddField(
model_name='organization',
name='share_resources',
field=models.BooleanField(default=True, help_text='Share resources (subscriptions, credits) with all children and member organizations. Global toggle that applies to all relationships.', verbose_name='share resources'),
),
]
109 changes: 98 additions & 11 deletions documentcloud/organizations/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -130,6 +130,19 @@ def merge(self, uuid):
self.addons.update(organization=other)
self.visual_addons.update(organization=other)

# transfer children to the other organization
self.children.update(parent=other)

# transfer group memberships
groups = self.groups.all()
other.groups.add(*groups)
self.groups.clear()

# transfer members
members = self.members.all()
other.members.add(*members)
self.members.clear()

self.merged = other

def calc_ai_credits_per_month(self, users):
Expand All @@ -143,24 +156,80 @@ def calc_ai_credits_per_month(self, users):

@transaction.atomic
def use_ai_credits(self, amount, user_id, note):
"""Try to deduct AI credits from the organization's balance"""
"""Try to deduct AI credits from the organization's balance

Consumes AI credits in priority order:
1. Own monthly AI credits
2. Own regular (purchased) AI credits
3. Parent monthly AI credits (if parent.share_resources=True)
4. Parent regular AI credits (if parent.share_resources=True)
5. Group monthly AI credits (for each group where group.share_resources=True)
6. Group regular AI credits (for each group where group.share_resources=True)

Args:
amount: Number of AI credits to consume
user_id: ID of the user consuming the credits
note: Description of what the credits are being used for

Returns:
dict: {"monthly": count, "regular": count} - breakdown of consumed credits

Raises:
InsufficientAICreditsError: If not enough AI credits available across
all sources
"""
initial_amount = amount
ai_credit_count = {"monthly": 0, "regular": 0}
organization = Organization.objects.select_for_update().get(pk=self.pk)

ai_credit_count["monthly"] = min(amount, organization.monthly_ai_credits)
amount -= ai_credit_count["monthly"]

ai_credit_count["regular"] = min(amount, organization.number_ai_credits)
amount -= ai_credit_count["regular"]
# Lock this organization and related organizations for update to prevent
# race conditions
organization = Organization.objects.select_for_update().get(pk=self.pk)
if organization.parent and organization.parent.share_resources:
parent = Organization.objects.select_for_update().get(
pk=organization.parent_id
)
else:
parent = None
groups = organization.groups.filter(share_resources=True).select_for_update()

def deduct_credits(amount, organization, field):
"""Helper to deduct AI credits from a specific field on an organization"""
# Calculate how much to deduct: take up to the amount requested,
# but no more than what's available in this field
deduct_amount = min(amount, getattr(organization, field))
amount -= deduct_amount
setattr(organization, field, getattr(organization, field) - deduct_amount)
# Return remaining amount needed and how much we deducted
return amount, deduct_amount

# Build list of organizations to consume from in priority order
organizations = [organization]
if parent:
organizations.append(parent)
organizations.extend(groups)

# Consume AI credits from each organization in priority order
for current_organization in organizations:
# For each organization, consume monthly credits first, then regular
for field, count in [
("monthly_ai_credits", "monthly"),
("number_ai_credits", "regular"),
]:
amount, deduct_amount = deduct_credits(
amount, current_organization, field
)
ai_credit_count[count] += deduct_amount
if amount == 0:
break
current_organization.save()
if amount == 0:
break

if amount > 0:
# Raising an error here will cancel the current atomic transaction
# No changes to the organizations will be committed to the database
raise InsufficientAICreditsError(amount)

organization.monthly_ai_credits -= ai_credit_count["monthly"]
organization.number_ai_credits -= ai_credit_count["regular"]
organization.save()

organization.ai_credit_logs.create(
user_id=user_id,
organization=organization,
Expand All @@ -170,6 +239,24 @@ def use_ai_credits(self, amount, user_id, note):

return ai_credit_count

def get_total_number_ai_credits(self):
"""Get total number AI credits including parent and groups"""
number_ai_credits = self.number_ai_credits
if self.parent and self.parent.share_resources:
number_ai_credits += self.parent.number_ai_credits
for group in self.groups.filter(share_resources=True):
number_ai_credits += group.number_ai_credits
return number_ai_credits

def get_total_monthly_ai_credits(self):
"""Get total monthly AI credits including parent and groups"""
monthly_ai_credits = self.monthly_ai_credits
if self.parent and self.parent.share_resources:
monthly_ai_credits += self.parent.monthly_ai_credits
for group in self.groups.filter(share_resources=True):
monthly_ai_credits += group.monthly_ai_credits
return monthly_ai_credits


class AICreditLog(models.Model):
"""Log usage of AI Credits"""
Expand Down
Loading