Package: django-subscriptions
django-subscriptionsA django package for managing the status and terms of a subscription.CompatibilityDjango: 2.2 (LTS versions only)Python: 3.6+Other Django or Python versionsmaywork, but that is totally cooincidental
and no effort is made to maintain compatibility with versions other than those
listed above.Installation$pipinstalldjango-subscriptionsThen add the following packages toINSTALLED_APPSin your settings:INSTALLED_APPS = [
    ...
    "django_fsm_log",
    "subscriptions.apps.SubscriptionsConfig",
    ...
]And of course, you'll need to run the migrations:$ python manage.py migrateYou'll also need to setup the triggers, which can be scheduled with celery or
run from a management task. See theTriggerssection below.DesignManages subscriptions in a single table. Pushes events (signals) so that
consumers can do the actual work required for that subscription, like billing.Subscriptions are built around a Finite State Machine model, where states and
allowed transitions between states are well defined on the Model. To update from
one state to another, the user calls methods on the Subscription instance. This
way, all side-effects and actions are contained within the state methods.Subscription State must not be modified directly.When a state change is triggered, the subscription will publish relevant signals
so that interested parties can, themselves, react to the state changes.APIThere are 3 major API components. State change methods, signals/events, and the
triggers used to begin the state changes.State MethodsMethodSource StatesTarget StateSignal Emittedcancel_autorenew()ACTIVEEXPIRINGautorenew_canceledenable_autorenew()EXPIRINGACTIVEautorenew_enabledrenew()ACTIVE,SUSPENDEDRENEWINGsubscription_duerenewed(new_end, new_ref, description=None)ACTIVE,RENEWING,ERRORACTIVEsubscription_renewedrenewal_failed(description=None)RENEWING,ERRORSUSPENDEDrenewal_failedend_subscription(description=None)ACTIVE,SUSPENDED,EXPIRING,ERRORENDEDsubscription_endedstate_unknown(description=None)RENEWINGERRORsubscription_errorExample:subscription.renew()may only be called ifsubscription.stateis eitherACTIVEorSUSPENDED,
and will causesubscription.stateto move into theRENEWINGstate.Thedescriptionargument is a string that can be used to persist the reason for a state
change in theStateLogtable (and admin inlines).TriggersThere are a bunch of triggers that are used to update subscriptions as they become
due or expire. Nothing is configured to run these triggers by default. You can
either call them as part of your own process, or usecelery beatto execute
the triggers using the tasks provided insubscriptions.tasks.Create a new subscription:Subscription.objects.add_subscription(start_date, end_date, reference) -> SubscriptionTrigger subscriptions that are due for renewal:Subscription.objects.trigger_renewals() -> int  # number of renewals sentTrigger subscriptions that are due to expire:Subscription.objects.trigger_expiring() -> int  # number of expirationsTrigger subscriptions that are suspended:Subscription.objects.trigger_suspended() -> int  # number of renewalsTrigger subscriptions that have been suspended for longer thantimeout_hoursto
end (usessubscription.enddate, notsubscription.last_updated):Subscription.objects.trigger_suspended_timeout(timeout_hours=48) -> int  # number of suspensionsTrigger subscriptions that have been stuck in renewing state for longer thantimeout_hoursto be marked as an error (usessubscription.last_updatedto determine the timeout):Subscription.objects.trigger_stuck(timeout_hours=2) -> int  # number of error subscriptionsIfsettings.SUBSCRIPTIONS_STUCK_RETRYisTrue, then subscriptions are moved back into
theSUSPENDEDstate, ready to be retried. This can be useful when you have an offline
process that can resolve stuck subscription issues, and there is no issue retrying the
subscription.TasksThe following tasks are defined but are not scheduled:subscriptions.tasks.trigger_renewals
subscriptions.tasks.trigger_expiring
subscriptions.tasks.trigger_suspended
subscriptions.tasks.trigger_suspended_timeout
subscriptions.tasks.trigger_stuckIf you'd like to schedule the tasks, do so with a celery beat configuration like this:# settings.py

CELERYBEAT_SCHEDULE = {
    "subscriptions_renewals": {
        "task": "subscriptions.tasks.trigger_renewals",
        "schedule": crontab(hour=0, minute=10),
    },
    "subscriptions_expiring": {
        "task": "subscriptions.tasks.trigger_expiring",
        "schedule": crontab(hour=0, minute=15),
    },
    "subscriptions_suspended": {
        "task": "subscriptions.tasks.trigger_suspended",
        "schedule": crontab(hour="3,6,9", minute=30),
    },
    "subscriptions_suspended_timeout": {
        "task": "subscriptions.tasks.trigger_suspended_timeout",
        "schedule": crontab(hour=0, minute=40),
        "kwargs": {"hours": 48},
    },
    "subscriptions_stuck": {
        "task": "subscriptions.tasks.trigger_stuck",
        "schedule": crontab(hour="*/2", minute=50),
        "kwargs": {"hours": 2},
    },
}ContributingWe usepre-commit <https://pre-commit.com/>to enforce our code style rules
locally before you commit them into git. Once you install the pre-commit library
(locally via pip is fine), just install the hooks::pre-commit install -f --install-hooksThe same checks are executed on the build server, so skipping the local linting
(withgit commit --no-verify) will only result in a failed test build.Current style checking tools:flake8: python lintingisort: python import sortingblack: python code formattingNote:You must have python3.6 available on your path, as it is required for some
of the hooks.Generating MigrationsAfter installing all dependencies, you can generate required migration files
like so:$poetryrunipythonmigrate.py<nameofmigration>Publishing a new versionBump the version number in pyproject.toml and src/subscriptions/init.pyCommit and push to masterFrom github,create a new releaseName the release "v<maj.minor.patch>" using the version number from step 1.Publish the releaseIf the release successfully builds, circleci will publish the new package to pypi