Skip to content

Commit bf58ad0

Browse files
Merge pull request #355 from MuckRock/organization-collective
Add resource sharing for organization collectives
2 parents 3ea3730 + 5d9b0be commit bf58ad0

File tree

11 files changed

+420
-33
lines changed

11 files changed

+420
-33
lines changed

.github/workflows/lambda.yml

Lines changed: 16 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
name: Post-Deploy Lambda
22

33
on:
4-
deployment:
4+
deployment_status:
55

66
jobs:
77
deploy-lambdas:
@@ -11,14 +11,20 @@ jobs:
1111

1212
- name: Show deployment info
1313
run: |
14-
echo "Deployment environment: $DEPLOYMENT_ENVIRONMENT"
14+
echo "Deployment environment: ${{ github.event.deployment.environment }}"
1515
16-
- name: Run Lambda deploy
16+
- name: Run Lambda production deploy
17+
if: >
18+
github.event.deployment.environment == 'documentcloud-prod' &&
19+
github.event.deployment_status.state == 'success'
1720
run: |
18-
if [[ "$DEPLOYMENT_ENVIRONMENT" == "documentcloud-staging" ]]; then
19-
echo "Deploying staging lambda updates"
20-
bash config/aws/lambda/codeship_deploy_lambdas.sh staging-lambda --staging
21-
else
22-
echo "Deploying production lambda updates"
23-
bash config/aws/lambda/codeship_deploy_lambdas.sh prod-lambda
24-
fi
21+
echo "Deploying production lambda updates"
22+
bash config/aws/lambda/codeship_deploy_lambdas.sh prod-lambda
23+
24+
- name: Run Lambda staging deploy
25+
if: >
26+
github.event.deployment.environment == 'documentcloud-staging' &&
27+
github.event.deployment_status.state == 'success'
28+
run: |
29+
echo "Deploying staging lambda updates"
30+
bash config/aws/lambda/codeship_deploy_lambdas.sh staging-lambda --staging
Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
# Generated by Django 4.2.2 on 2025-12-22 21:06
2+
3+
from django.conf import settings
4+
from django.db import migrations, models
5+
import django.db.models.deletion
6+
7+
8+
class Migration(migrations.Migration):
9+
10+
dependencies = [
11+
('organizations', '0017_organization_merged'),
12+
]
13+
14+
operations = [
15+
migrations.AlterField(
16+
model_name='organization',
17+
name='merged',
18+
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),
19+
),
20+
]
Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
# Generated by Django 4.2.2 on 2025-12-22 21:32
2+
3+
from django.conf import settings
4+
from django.db import migrations, models
5+
import django.db.models.deletion
6+
7+
8+
class Migration(migrations.Migration):
9+
10+
dependencies = [
11+
('organizations', '0018_alter_organization_merged'),
12+
]
13+
14+
operations = [
15+
migrations.AddField(
16+
model_name='organization',
17+
name='members',
18+
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),
19+
),
20+
migrations.AddField(
21+
model_name='organization',
22+
name='parent',
23+
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'),
24+
),
25+
migrations.AddField(
26+
model_name='organization',
27+
name='share_resources',
28+
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'),
29+
),
30+
]

documentcloud/organizations/models.py

Lines changed: 115 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -130,6 +130,19 @@ def merge(self, uuid):
130130
self.addons.update(organization=other)
131131
self.visual_addons.update(organization=other)
132132

133+
# transfer children to the other organization
134+
self.children.update(parent=other)
135+
136+
# transfer group memberships
137+
groups = self.groups.all()
138+
other.groups.add(*groups)
139+
self.groups.clear()
140+
141+
# transfer members
142+
members = self.members.all()
143+
other.members.add(*members)
144+
self.members.clear()
145+
133146
self.merged = other
134147

135148
def calc_ai_credits_per_month(self, users):
@@ -143,24 +156,80 @@ def calc_ai_credits_per_month(self, users):
143156

144157
@transaction.atomic
145158
def use_ai_credits(self, amount, user_id, note):
146-
"""Try to deduct AI credits from the organization's balance"""
159+
"""Try to deduct AI credits from the organization's balance
160+
161+
Consumes AI credits in priority order:
162+
1. Own monthly AI credits
163+
2. Own regular (purchased) AI credits
164+
3. Parent monthly AI credits (if parent.share_resources=True)
165+
4. Parent regular AI credits (if parent.share_resources=True)
166+
5. Group monthly AI credits (for each group where group.share_resources=True)
167+
6. Group regular AI credits (for each group where group.share_resources=True)
168+
169+
Args:
170+
amount: Number of AI credits to consume
171+
user_id: ID of the user consuming the credits
172+
note: Description of what the credits are being used for
173+
174+
Returns:
175+
dict: {"monthly": count, "regular": count} - breakdown of consumed credits
176+
177+
Raises:
178+
InsufficientAICreditsError: If not enough AI credits available across
179+
all sources
180+
"""
147181
initial_amount = amount
148182
ai_credit_count = {"monthly": 0, "regular": 0}
149-
organization = Organization.objects.select_for_update().get(pk=self.pk)
150-
151-
ai_credit_count["monthly"] = min(amount, organization.monthly_ai_credits)
152-
amount -= ai_credit_count["monthly"]
153183

154-
ai_credit_count["regular"] = min(amount, organization.number_ai_credits)
155-
amount -= ai_credit_count["regular"]
184+
# Lock this organization and related organizations for update to prevent
185+
# race conditions
186+
organization = Organization.objects.select_for_update().get(pk=self.pk)
187+
if organization.parent and organization.parent.share_resources:
188+
parent = Organization.objects.select_for_update().get(
189+
pk=organization.parent_id
190+
)
191+
else:
192+
parent = None
193+
groups = organization.groups.filter(share_resources=True).select_for_update()
194+
195+
def deduct_credits(amount, organization, field):
196+
"""Helper to deduct AI credits from a specific field on an organization"""
197+
# Calculate how much to deduct: take up to the amount requested,
198+
# but no more than what's available in this field
199+
deduct_amount = min(amount, getattr(organization, field))
200+
amount -= deduct_amount
201+
setattr(organization, field, getattr(organization, field) - deduct_amount)
202+
# Return remaining amount needed and how much we deducted
203+
return amount, deduct_amount
204+
205+
# Build list of organizations to consume from in priority order
206+
organizations = [organization]
207+
if parent:
208+
organizations.append(parent)
209+
organizations.extend(groups)
210+
211+
# Consume AI credits from each organization in priority order
212+
for current_organization in organizations:
213+
# For each organization, consume monthly credits first, then regular
214+
for field, count in [
215+
("monthly_ai_credits", "monthly"),
216+
("number_ai_credits", "regular"),
217+
]:
218+
amount, deduct_amount = deduct_credits(
219+
amount, current_organization, field
220+
)
221+
ai_credit_count[count] += deduct_amount
222+
if amount == 0:
223+
break
224+
current_organization.save()
225+
if amount == 0:
226+
break
156227

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

160-
organization.monthly_ai_credits -= ai_credit_count["monthly"]
161-
organization.number_ai_credits -= ai_credit_count["regular"]
162-
organization.save()
163-
164233
organization.ai_credit_logs.create(
165234
user_id=user_id,
166235
organization=organization,
@@ -170,6 +239,41 @@ def use_ai_credits(self, amount, user_id, note):
170239

171240
return ai_credit_count
172241

242+
def get_total_number_ai_credits(self):
243+
"""Get total number AI credits including parent and groups"""
244+
number_ai_credits = self.number_ai_credits
245+
if self.parent and self.parent.share_resources:
246+
number_ai_credits += self.parent.number_ai_credits
247+
for group in self.groups.filter(share_resources=True):
248+
number_ai_credits += group.number_ai_credits
249+
return number_ai_credits
250+
251+
def get_total_monthly_ai_credits(self):
252+
"""Get total monthly AI credits remaining including parent and groups"""
253+
monthly_ai_credits = self.monthly_ai_credits
254+
if self.parent and self.parent.share_resources:
255+
monthly_ai_credits += self.parent.monthly_ai_credits
256+
for group in self.groups.filter(share_resources=True):
257+
monthly_ai_credits += group.monthly_ai_credits
258+
return monthly_ai_credits
259+
260+
def get_total_monthly_ai_credits_allowance(self):
261+
"""
262+
Get the total monthly AI credits allowance, including parent and shared groups.
263+
This is the amount that monthly_credits will reset to each month.
264+
"""
265+
total = self.ai_credits_per_month
266+
267+
# Include parent if it shares resources
268+
if self.parent and self.parent.share_resources:
269+
total += self.parent.ai_credits_per_month
270+
271+
# Include groups that share resources
272+
for group in self.groups.filter(share_resources=True):
273+
total += group.ai_credits_per_month
274+
275+
return total
276+
173277

174278
class AICreditLog(models.Model):
175279
"""Log usage of AI Credits"""

documentcloud/organizations/serializers.py

Lines changed: 23 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -19,21 +19,23 @@ class OrganizationSerializer(serializers.ModelSerializer):
1919
"Only viewable by organization members."
2020
),
2121
)
22-
monthly_credits = serializers.IntegerField(
23-
source="monthly_ai_credits",
22+
monthly_credits = serializers.SerializerMethodField(
23+
label=_("Monthly Credits"),
2424
read_only=True,
2525
help_text=(
2626
"Number of monthly premium credits this organization has left. "
2727
"This will reset to monthly_credit_allowance on credit_reset_date. "
28+
"This includes shared credits from parents and groups. "
2829
"Only viewable be organization members."
2930
),
3031
)
31-
purchased_credits = serializers.IntegerField(
32-
source="number_ai_credits",
32+
purchased_credits = serializers.SerializerMethodField(
33+
label=_("Purchased Credits"),
3334
read_only=True,
3435
help_text=(
3536
"Number of purchased premium credits. "
3637
"These do not reset or expire. "
38+
"This includes shared credits from parents and groups. "
3739
"Only viewable by organization members."
3840
),
3941
)
@@ -45,8 +47,7 @@ class OrganizationSerializer(serializers.ModelSerializer):
4547
"Only viewable by organization members."
4648
),
4749
)
48-
monthly_credit_allowance = serializers.IntegerField(
49-
source="ai_credits_per_month",
50+
monthly_credit_allowance = serializers.SerializerMethodField(
5051
read_only=True,
5152
help_text=(
5253
"The amount of credits that monthly_credits will reset to. "
@@ -81,10 +82,16 @@ def to_representation(self, instance):
8182
if "monthly_credits" in self.fields:
8283
# skip checks if we have already removed the fields
8384
request = self.context and self.context.get("request")
85+
view = self.context and self.context.get("view")
86+
action = view.action if view else None
8487
user = request and request.user
8588
is_org = isinstance(instance, Organization)
8689
if not (
87-
is_org and user and user.is_authenticated and instance.has_member(user)
90+
is_org
91+
and user
92+
and user.is_authenticated
93+
and instance.has_member(user)
94+
and action == "retrieve"
8895
):
8996
# only members may see AI credit information
9097
self.fields.pop("monthly_credits")
@@ -102,6 +109,15 @@ def get_plan(self, obj):
102109
else:
103110
return "Free"
104111

112+
def get_monthly_credits(self, obj):
113+
return obj.get_total_monthly_ai_credits()
114+
115+
def get_purchased_credits(self, obj):
116+
return obj.get_total_number_ai_credits()
117+
118+
def get_monthly_credit_allowance(self, obj):
119+
return obj.get_total_monthly_ai_credits_allowance()
120+
105121

106122
class AICreditSerializer(serializers.Serializer):
107123
"""Serializer for the AI credit endpoint"""

0 commit comments

Comments
 (0)