Subman

Subman uses a fairly complex state schema to manage subscription states internally. Its behavior can be configured using the SubscriptionManager, which is also the main interface of the library.

Subscription state schema

This graphic was created using Draw.io. To edit it, upload the SubscriptionState.png file there.

Subman uses a total of seven distinct states, which allow a consistent and useful subscription management, even if the condition for list membership change. Of these, four states are so-called core states, without which the software can not function properly, while the other states are optional.

Every transition between these states is modeled by a corresponding SubscriptionAction, which are shown as arrows in the graph. While most subscription actions act on multiple states, there is always a unique target state associated with each action.

Subman includes a distinction between action which require additional privileges (possibly depending on the list in question). These privileged users are referred to as “moderators”. Actions restricted to moderators are referred to as “managing actions”.

In addition to the manual actions which can be performed, it is required to regularly perform cleanup actions to react to changes in implicators. This actually does not make use of the SubscriptionAction enum, but makes use of its own internal state transitioning logic given in SubscriptionManager._apply_cleanup().

In subman, we differentiate between subscribing (shown in green in the graph) and non-subscribing states (shown in red), where users listed in subscribing states are considered to be subscribed in the usual sense. For subscribers, there is no visible distinction between the different subscribing states intended.

List of states

Subscribing states are:

Explicitly Subscribed (Core)

Users, which have been actively subscribed to a list, either by themselves or by a moderator, are saved as explicitly subscribed. If these users have no more means to access a list, for example because they lost club membership, or because they no longer attend an event, they are removed from the list. Lists without special membership implicators only have explicit subscribers.

Subscription Override (Optional)

Subscription Overrides are a special kind of explicit subscriptions, which are kept even if the user should not be able to access a list. However, if a list allows unsubscribing in general, they do not prevent a user from unsubscribing themselves. The list of Subscription Overrides should be accessible for moderators.

Implicitly Subscribed (Core)

Users, which are subscribed to a list, because they meet some condition, are listed as implicit subscribers. Typical examples are lists having all members or all attendees of an event or assembly as implicit subscribers. If users lose the automatic implicator that subscribes them to the list, they are removed even if they would still be able to access it.

It is optional to store implicit subscribers explicitly. Otherwise, they have to be calculated at runtime.

Non-subscribing states are:

Implicitly Unsubscribed (Core)

This is the standard state for users having no relationship to a list whatsoever, because they never were listed on it or lost access to it.

It is optional to store this state explicitly.

Explicitly Unsubscribed (Core)

Users, who have stated to not want to receive information from a specific list anymore. This decision is permanent, until manually reverted by them or a moderator. Even if they lose access to a list, this information is kept. Thus, if they regain access later on, these users will not be receiving information from it. However, if they are explicitly subscribed again, they do not receive special treatment.

Due to this fact, users tend to get “stuck” in this case, since it is not cleaned up automatically. For example, every user who has been manually removed from a list by a moderator, will stay here forever without further intervention. While the state transitions are designed with this in mind, making no difference between manual actions on explicitly and implicitly unsubscribed users, it is still possible for moderators to cleanup explicit unsubscriptions to implicit subscriptions.

To not obstruct the design of the state schema, this should only be used to cleanup test cases or to prepare for the use of tools which might be obstructed by explicit unsubscriptions.

Unsubscription Override (Optional)

Unsubscription Overrides are a special kind of explicit unsubscriptions, which can not be removed by the affected user. Except for mandatory lists, they can be used to block a specific user from any kind of subscription or subscription request. The list of Unsubscription Overrides should be accessible for moderators.

Request Pending (Optional)

This is a special case only existing for mailinglists with moderated opt-in subscription policy for a group of users. Users with pending subscription requests are displayed on a specific list to moderators, so they can decide if they want to approve or deny their request. It is also possible to block further requests by this user.

When changing the SubscriptionPolicy associated to a certain list, subman can handle updating its subscribers automatically. However, if the list is set to allow_unsub = False, all users with explicit unsubscriptions, including Unsubscription Overrides, need to be cleaned up during the transition. If subman detects this has not been done, it will raise an error.

Usage example

For a comprehensive example of the use of subman, we recommend taking a look at the ml realm of the CdEDBv2. There, subman is used to support complex mailinglist management by subscribers and moderators. The subscription state data is saved into a SQL database, except for SubscriptionState.none, and includes a logging of each SubscriptionAction.

However, a minimal example class can be realized as follows. This is basically a really streamlined version of the CdEDBv2 MlBackend.

#!/usr/bin/env python3

from typing import Collection, Dict, NewType, Tuple

import subman
from subman.machine import SubscriptionAction, SubscriptionPolicy, SubscriptionState

# Some object representing a potential list subscriber
Persona = NewType("Persona", object)
# Some object representing a list of subscribers, a subscription object.
# Is expected to have an `allow_unsub` attribute.
ML = NewType("ML", object)


class PrivilegeError(RuntimeError):
    """Exception for signalling missing privileges."""


class ListManager:
    def __init__(self) -> None:
        self.subman = subman.SubscriptionManager()

    def may_manage(self, ml: ML) -> bool: ...

    def get_subscription_policy(self, persona: Persona, ml: ML) -> SubscriptionPolicy:
        ...

    def get_implicit_subscribers(self, ml: ML) -> Collection[Persona]: ...

    def get_subscription_states(self, ml: ML, states: Collection[SubscriptionState],
                                ) -> Dict[Persona, SubscriptionState]: ...

    def get_subscription(self, persona: Persona, ml: ML) -> SubscriptionState: ...

    def _set_subscriptions(self, data: Collection[Tuple[Persona, ML, SubscriptionState]]
                           ) -> None: ...

    def do_subscription_action(self, action: SubscriptionAction, persona: Persona,
                               ml: ML) -> None:
        """Provide a single entry point for all subscription actions."""
        # Managing actions can only be done by moderators. Other options always
        # change your own subscription state.
        if action.is_managing():
            if not self.may_manage(ml):
                raise PrivilegeError()

        old_state = self.get_subscription(persona, ml)
        new_state = self.subman.apply_action(
            action=action,
            policy=self.get_subscription_policy(persona, ml),
            allow_unsub=ml.allow_unsub,  # type: ignore
            old_state=old_state)

        # Write the transition
        self._set_subscriptions([(persona, ml, new_state)])

    def write_subscription_states(self, ml: ML) -> None:
        """This takes care of writing implicit subscriptions to the db.

        This also checks the integrity of existing subscriptions.
        """
        # States we may not touch.
        protected_states = (self.subman.written_states
                            & self.subman.cleanup_protected_states)
        # States we may touch: non-special subscriptions.
        old_subscriber_states = (self.subman.written_states
                                 - self.subman.cleanup_protected_states)

        if not self.may_manage(ml):
            raise PrivilegeError()

        old_subscribers = self.get_subscription_states(ml, states=old_subscriber_states)
        new_implicits = self.get_implicit_subscribers(ml)

        # Check whether current subscribers may stay subscribed.
        # This is the case if they are still implicit subscribers of
        # the list or if `get_subscription_policy` says so.
        delete = []
        for persona in old_subscribers:
            policy = self.get_subscription_policy(persona, ml)
            state = old_subscribers[persona]
            if self.subman.is_obsolete(policy=policy, old_state=state,
                                       is_implied=persona in new_implicits):
                datum = persona, ml, SubscriptionState.none
                delete.append(datum)

        # Remove those who may not stay subscribed.
        if delete:
            self._set_subscriptions(delete)

        # Check whether any implicit subscribers need to be written.
        # This is the case if they are not already old subscribers and
        # they don't have a protected subscription.
        protected = self.get_subscription_states(ml, states=protected_states)
        write = set(new_implicits) - set(old_subscribers) - set(protected)

        # Set implicit subscriptions.
        data = [(persona, ml, SubscriptionState.implicit) for persona in write]
        if data:
            self._set_subscriptions(data)

Internationalization

Like the CdEDBv2, subman is internationalized using GNU gettext. By adding translations for the respective strings, users of the library can customize error messages to their heart’s content.