-
Notifications
You must be signed in to change notification settings - Fork 14
Support arbitrary, temporary Entitlement grants #680
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: master
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| 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), | ||
| ] |
| 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 | ||
|
|
@@ -11,6 +13,7 @@ | |
| # Third Party | ||
| import stripe | ||
| from autoslug import AutoSlugField | ||
| from dateutil.relativedelta import relativedelta | ||
| from memoize import mproperty | ||
|
|
||
| # Squarelet | ||
|
|
@@ -19,6 +22,7 @@ | |
| from squarelet.organizations.payments.factory import get_payment_provider | ||
| from squarelet.organizations.querysets import ( | ||
| ChargeQuerySet, | ||
| EntitlementGrantQuerySet, | ||
| EntitlementQuerySet, | ||
| PlanQuerySet, | ||
| SubscriptionQuerySet, | ||
|
|
@@ -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: | ||
| 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()): | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
|
||
| 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: | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Checking my understanding: If you check If you check 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() | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. if there is only |
||
|
|
||
|
|
||
| class ReceiptEmail(models.Model): | ||
| """An email address to send receipts to""" | ||
|
|
||
|
|
||
There was a problem hiding this comment.
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