Sentry Issue: FLAGSMITH-API-5QK
AttributeError: 'NoneType' object has no attribute 'id'
(2 additional frame(s) were not displayed)
...
File "organisations/tasks.py", line 110, in finish_subscription_cancellation
subscription.organisation.cancel_users()
File "organisations/models.py", line 203, in cancel_users
id=remaining_seat_holder.id # type: ignore[union-attr]
Failed to execute task 'tasks.finish_subscription_cancellation', with id 269. Exception: 'NoneType' object has no attribute 'id'
Summary
Organisation.cancel_users() raises AttributeError: 'NoneType' object has no attribute 'id' when an organisation undergoing subscription cancellation has no UserOrganisation with role=ADMIN.
Root cause
api/organisations/models.py (cancel_users):
remaining_seat_holder = (
UserOrganisation.objects.filter(
organisation=self,
role=OrganisationRole.ADMIN,
)
.order_by("date_joined")
.first() # returns None when the org has no admin
)
UserOrganisation.objects.filter(
organisation=self,
).exclude(
id=<remaining_seat_holder.id> # <None.id> -> AttributeError
# type: ignore[union-attr] # the Optional access was suppressed, not handled
).delete()
When the org has no admin membership, .first() returns None and <remaining_seat_holder.id> throws. The pre-existing # type: ignore[union-attr] shows mypy already flagged this access as unsafe.
Reproduction
An organisation reaches its cancellation_date while having either:
- only non-admin (
USER role) members, or
- no memberships at all.
The recurring task finish_subscription_cancellation (api/organisations/tasks.py, runs every 12h) then calls cancel_users() on it and crashes.
Impact
finish_subscription_cancellation iterates over the elapsed subscriptions in a bare loop and calls cancel_users() for each. An unhandled exception aborts the entire task run, so organisations later in the same batch are not downgraded to the free plan in that cycle.
Suggested fix
Only apply the exclude when a seat holder exists, and remove the type: ignore:
def cancel_users(self) -> None:
remaining_seat_holder = (
UserOrganisation.objects.filter(
organisation=self,
role=OrganisationRole.ADMIN,
)
.order_by("date_joined")
.first()
)
user_organisations = UserOrganisation.objects.filter(organisation=self)
if remaining_seat_holder is not None:
user_organisations = user_organisations.exclude(id=<remaining_seat_holder.id>)
user_organisations.delete()
Open product decision: when there's no admin, should we delete all memberships (above) or keep the oldest member of any role as the single retained seat?
Test gap
Existing tests (api/tests/unit/organisations/test_unit_organisations_tasks.py) only create orgs with role=ADMIN members, so the no-admin path is uncovered. A regression test should add an org with no admin to finish_subscription_cancellation's batch and assert it downgrades cleanly.
Sentry Issue: FLAGSMITH-API-5QK
Summary
Organisation.cancel_users()raisesAttributeError: 'NoneType' object has no attribute 'id'when an organisation undergoing subscription cancellation has noUserOrganisationwithrole=ADMIN.Root cause
api/organisations/models.py(cancel_users):When the org has no admin membership,
.first()returnsNoneand<remaining_seat_holder.id>throws. The pre-existing# type: ignore[union-attr]shows mypy already flagged this access as unsafe.Reproduction
An organisation reaches its
cancellation_datewhile having either:USERrole) members, orThe recurring task
finish_subscription_cancellation(api/organisations/tasks.py, runs every 12h) then callscancel_users()on it and crashes.Impact
finish_subscription_cancellationiterates over the elapsed subscriptions in a bare loop and callscancel_users()for each. An unhandled exception aborts the entire task run, so organisations later in the same batch are not downgraded to the free plan in that cycle.Suggested fix
Only apply the
excludewhen a seat holder exists, and remove thetype: ignore:Open product decision: when there's no admin, should we delete all memberships (above) or keep the oldest member of any role as the single retained seat?
Test gap
Existing tests (
api/tests/unit/organisations/test_unit_organisations_tasks.py) only create orgs withrole=ADMINmembers, so the no-admin path is uncovered. A regression test should add an org with no admin tofinish_subscription_cancellation's batch and assert it downgrades cleanly.