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
46 changes: 46 additions & 0 deletions squarelet/organizations/admin.py
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@
Charge,
Customer,
Entitlement,
EntitlementGrant,
Invitation,
Invoice,
Membership,
Expand Down Expand Up @@ -516,6 +517,51 @@ class EntitlementAdmin(VersionAdmin):
autocomplete_fields = ("client",)


@admin.register(EntitlementGrant)
class EntitlementGrantAdmin(VersionAdmin):
list_display = (
"name",
"active",
"for_individuals",
"for_groups",
"require_verified",
"require_active_subscription",
"update_on",
)
list_filter = (
"active",
"for_individuals",
"for_groups",
"require_verified",
"require_active_subscription",
)
search_fields = ("name", "description")
autocomplete_fields = ("entitlements", "organizations")
filter_horizontal = ("entitlements", "organizations")
fieldsets = (
(None, {"fields": ("name", "description", "active", "entitlements")}),
(
"Eligible organization types",
{"fields": ("for_individuals", "for_groups")},
),
("Explicit grants", {"fields": ("organizations",)}),
(
"Rule-based grants",
{"fields": ("require_verified", "require_active_subscription")},
),
(
"Refresh",
{
"fields": ("update_on",),
"description": (
"Leave blank to default to one month from creation. "
"Resources tied to this grant refresh on this date."
),
},
),
)


def make_metadata_filter(field):
"""Make a dynamic filter class"""

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,110 @@
# Generated by Django 5.2.12 on 2026-05-19 00:18

import autoslug.fields
from django.db import migrations, models


class Migration(migrations.Migration):

dependencies = [
("organizations", "0061_merge_20260324"),
]

operations = [
migrations.AlterField(
model_name="plan",
name="slug",
field=autoslug.fields.AutoSlugField(
editable=True,
help_text="A unique slug to identify the plan",
populate_from="name",
unique=True,
verbose_name="slug",
),
),
migrations.CreateModel(
name="EntitlementGrant",
fields=[
(
"id",
models.AutoField(
auto_created=True,
primary_key=True,
serialize=False,
verbose_name="ID",
),
),
("name", models.CharField(max_length=255, verbose_name="name")),
(
"description",
models.TextField(
blank=True, default="", verbose_name="description"
),
),
(
"require_verified",
models.BooleanField(
default=False,
help_text="Match organizations whose verified_journalist=True",
verbose_name="require verified",
),
),
(
"require_active_subscription",
models.BooleanField(
default=False,
help_text="Match organizations with at least one active subscription",
verbose_name="require active subscription",
),
),
(
"for_individuals",
models.BooleanField(
default=True,
help_text="Apply this grant to individual organizations",
verbose_name="for individuals",
),
),
(
"for_groups",
models.BooleanField(
default=True,
help_text="Apply this grant to non-individual organizations",
verbose_name="for groups",
),
),
(
"active",
models.BooleanField(
default=True,
help_text="Inactive grants do not apply to any organization",
verbose_name="active",
),
),
("created_at", models.DateTimeField(auto_now_add=True)),
("updated_at", models.DateTimeField(auto_now=True)),
(
"entitlements",
models.ManyToManyField(
help_text="Entitlements this grant extends",
related_name="grants",
to="organizations.entitlement",
verbose_name="entitlements",
),
),
(
"organizations",
models.ManyToManyField(
blank=True,
help_text="Organizations explicitly granted these entitlements",
related_name="entitlement_grants",
to="organizations.organization",
verbose_name="organizations",
),
),
],
options={
"ordering": ("-created_at", "name"),
},
),
]
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
# Generated by Django 5.2.12 on 2026-05-19 17:51

from datetime import date

from dateutil.relativedelta import relativedelta
from django.db import migrations, models


def backfill_update_on(apps, schema_editor):
"""Populate update_on for any pre-existing grants so admins see consistent
values. NULL <= today is false in SQL, so the celery task wouldn't sweep
these without backfill, but admins inspecting the table would see blanks."""
EntitlementGrant = apps.get_model("organizations", "EntitlementGrant")
EntitlementGrant.objects.filter(update_on__isnull=True).update(
update_on=date.today() + relativedelta(months=1)
)


class Migration(migrations.Migration):

dependencies = [
("organizations", "0062_alter_plan_slug_entitlementgrant"),
]

operations = [
migrations.AddField(
model_name="entitlementgrant",
name="update_on",
field=models.DateField(
blank=True,
help_text="Date when this grant's resources next refresh",
null=True,
verbose_name="date update",
),
),
migrations.RunPython(backfill_update_on, migrations.RunPython.noop),
]
9 changes: 8 additions & 1 deletion squarelet/organizations/models/organization.py
Original file line number Diff line number Diff line change
Expand Up @@ -847,7 +847,14 @@ def merge(self, org, user):
if self.parent is None:
self.parent = org.parent

m2m_relations = ["private_plans", "children", "groups", "members", "subtypes"]
m2m_relations = [
"private_plans",
"children",
"groups",
"members",
"subtypes",
"entitlement_grants",
]
for m2m in m2m_relations:
getattr(self, m2m).add(*getattr(org, m2m).all())
getattr(org, m2m).clear()
Expand Down
134 changes: 134 additions & 0 deletions squarelet/organizations/models/payment.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,9 @@
# Django
from django.conf import settings
from django.db import models, transaction
from django.db.models import Q
from django.urls import reverse
from django.utils import timezone
from django.utils.translation import gettext_lazy as _

# Standard Library
Expand All @@ -11,6 +13,7 @@
# Third Party
import stripe
from autoslug import AutoSlugField
from dateutil.relativedelta import relativedelta
from memoize import mproperty

# Squarelet
Expand All @@ -19,6 +22,7 @@
from squarelet.organizations.payments.factory import get_payment_provider
from squarelet.organizations.querysets import (
ChargeQuerySet,
EntitlementGrantQuerySet,
EntitlementQuerySet,
PlanQuerySet,
SubscriptionQuerySet,
Expand Down Expand Up @@ -762,6 +766,136 @@ def public(self):
return self.plans.filter(public=True).exists()


class EntitlementGrant(models.Model):
"""Grants Entitlements to organizations, explicitly or by rule."""

name = models.CharField(_("name"), max_length=255)
description = models.TextField(_("description"), blank=True, default="")

entitlements = models.ManyToManyField(
verbose_name=_("entitlements"),
to="organizations.Entitlement",
related_name="grants",
help_text=_("Entitlements this grant extends"),
)
organizations = models.ManyToManyField(
verbose_name=_("organizations"),
to="organizations.Organization",
related_name="entitlement_grants",
blank=True,
help_text=_("Organizations explicitly granted these entitlements"),
)

require_verified = models.BooleanField(
_("require verified"),
default=False,
help_text=_("Match organizations whose verified_journalist=True"),
)
require_active_subscription = models.BooleanField(
_("require active subscription"),
default=False,
help_text=_("Match organizations with at least one active subscription"),
)

for_individuals = models.BooleanField(
_("for individuals"),
default=True,
help_text=_("Apply this grant to individual organizations"),
)
for_groups = models.BooleanField(
_("for groups"),
default=True,
help_text=_("Apply this grant to non-individual organizations"),
)

active = models.BooleanField(
_("active"),
default=True,
help_text=_("Inactive grants do not apply to any organization"),
)

update_on = models.DateField(
_("date update"),
null=True,
blank=True,
help_text=_("Date when this grant's resources next refresh"),
)

created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)

objects = EntitlementGrantQuerySet.as_manager()

class Meta:
ordering = ("-created_at", "name")

def __str__(self):
return self.name

def save(self, *args, **kwargs):
if self.update_on is None:
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If you are just trying to set a default value, you can do this more cleanly by setting the default to a callable:

https://docs.djangoproject.com/en/6.0/ref/models/fields/#django.db.models.Field.default

self.update_on = timezone.now().date() + relativedelta(months=1)
super().save(*args, **kwargs)

def matches(self, org):
if not self.active:
return False
# Org-type filter applies to both explicit and rule-based matches.
if org.individual and not self.for_individuals:
return False
if not org.individual and not self.for_groups:
return False
# Uses `.all()` so a prefetched `organizations` relation is reused.
if any(o.pk == org.pk for o in self.organizations.all()):
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

if self.organizations.filter(pk=org.pk).exists():

return True
checks = []
if self.require_verified:
checks.append(bool(org.verified_journalist))
if self.require_active_subscription:
checks.append(org.has_active_subscription())
if not checks:
return False
return all(checks)

def matching_organizations(self):
"""Return queryset of organizations this grant currently matches.

Reverse of `matches(org)`. Used by the celery refresh task and by signal
handlers to compute the set of orgs whose cache must be invalidated.
"""
# pylint: disable=import-outside-toplevel
# Squarelet
from squarelet.organizations.models.organization import Organization

if not self.active:
return Organization.objects.none()

if self.for_individuals and self.for_groups:
eligible = Organization.objects.all()
elif self.for_individuals:
eligible = Organization.objects.filter(individual=True)
elif self.for_groups:
eligible = Organization.objects.filter(individual=False)
else:
return Organization.objects.none()

explicit_q = Q(entitlement_grants=self)

rule_clauses = []
if self.require_verified:
rule_clauses.append(Q(verified_journalist=True))
if self.require_active_subscription:
# Mirrors org.has_active_subscription() = bool(subscriptions.first())
rule_clauses.append(Q(subscriptions__isnull=False))

if rule_clauses:
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Checking my understanding:

If you check require_verified then this entitlement grant applies to all Organizations which are verified (filtered by individual/group as appropriate)

If you check require_active_subscription then it applies to all Orgs with an active subscription (again filtered by individual/group)

If both are checked, then it applies to all Orgs which are both verified and have an active subscription.

rule_q = rule_clauses[0]
for clause in rule_clauses[1:]:
rule_q &= clause
return eligible.filter(explicit_q | rule_q).distinct()
return eligible.filter(explicit_q).distinct()
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

if there is only explicit_q then distinct is not necessary as you can't add the same org more than once.



class ReceiptEmail(models.Model):
"""An email address to send receipts to"""

Expand Down
Loading
Loading