33 |
39 |
40 |
41 |
42 | {% trans "Subscription" %}
43 | {{ subscription.subscription.plan }}
44 |
45 |
46 | {% trans "Payment details" %}
47 | {{ subscription.subscription.cost|currency }}
48 | {{ subscription.subscription.display_billing_frequency_text }}
49 |
50 |
51 | {% trans "Last billing date" %}
52 | {% if subscription.date_billing_last %}
53 | {{ subscription.date_billing_last|date:"Y-m-d H:i" }}
54 | {% else %}
55 | N/A
56 | {% endif %}
57 |
58 |
59 | {% trans "Next billing date" %}
60 | {% if subscription.date_billing_next %}
61 | {{ subscription.date_billing_next|date:"Y-m-d H:i" }}
62 | {% else %}
63 | N/A
64 | {% endif %}
65 |
66 |
67 |
68 |
72 |
73 | {% endblock %}
74 |
--------------------------------------------------------------------------------
/subscriptions/templates/subscriptions/subscribe_user_list.html:
--------------------------------------------------------------------------------
1 | {% extends template_extends %}
2 |
3 | {% load i18n %}
4 | {% load currency_filters %}
5 |
6 | {% block subscriptions_styles %}
7 |
26 | {% endblock %}
27 |
28 | {% block content %}
29 |
30 | Manage subscriptions
31 |
32 |
33 |
40 |
41 | {% for subscription in subscriptions %}
42 |
43 |
44 | {% trans "Subscription" %}
45 | {{ subscription.subscription.plan }}
46 |
47 |
48 | {% trans "Payment details" %}
49 | {{ subscription.subscription.cost|currency }}
50 | {{ subscription.subscription.display_billing_frequency_text }}
51 |
52 |
53 | {% trans "Next billing date" %}
54 | {% if subscription.date_billing_next %}
55 | {{ subscription.date_billing_next|date:"Y-m-d H:i" }}
56 | {% else %}
57 | --
58 | {% endif %}
59 |
60 |
61 | {% trans "Subscription ends" %}
62 | {% if subscription.cancelled %}
63 | {{ subscription.date_billing_end|date:"Y-m-d H:i" }}
64 | {% else %}
65 | --
66 | {% endif %}
67 |
68 |
69 | {% if not subscription.cancelled %}
70 |
71 | Cancel
72 |
73 | {% endif %}
74 |
75 |
76 | {% endfor %}
77 |
78 |
79 | {% endblock %}
80 |
--------------------------------------------------------------------------------
/tests/subscriptions/test_abstract.py:
--------------------------------------------------------------------------------
1 | """Tests for the abstract module."""
2 | from unittest.mock import patch
3 |
4 | from subscriptions import abstract
5 |
6 |
7 | def test_template_view_get_context_data():
8 | """Tests that context is properly extended."""
9 | view = abstract.TemplateView()
10 | context = view.get_context_data()
11 |
12 | assert 'template_extends' in context
13 | assert context['template_extends'] == 'subscriptions/base.html'
14 |
15 |
16 | def test_list_view_get_context_data():
17 | """Tests that context is properly extended."""
18 | view = abstract.ListView()
19 | view.object = None
20 | view.object_list = None
21 | context = view.get_context_data()
22 |
23 | assert 'template_extends' in context
24 | assert context['template_extends'] == 'subscriptions/base.html'
25 |
26 |
27 | def test_detail_view_get_context_data():
28 | """Tests that context is properly extended."""
29 | view = abstract.DetailView()
30 | view.object = None
31 | context = view.get_context_data()
32 |
33 | assert 'template_extends' in context
34 | assert context['template_extends'] == 'subscriptions/base.html'
35 |
36 |
37 | @patch('subscriptions.abstract.CreateView.get_queryset', lambda x: True)
38 | @patch('subscriptions.abstract.CreateView.get_form_class', lambda x: True)
39 | @patch('subscriptions.abstract.CreateView.get_form_kwargs', lambda x: True)
40 | @patch('subscriptions.abstract.CreateView.get_form', lambda x: True)
41 | def test_create_view_get_context_data():
42 | """Tests that context is properly extended."""
43 | view = abstract.CreateView()
44 | view.object = None
45 | context = view.get_context_data()
46 |
47 | assert 'template_extends' in context
48 | assert context['template_extends'] == 'subscriptions/base.html'
49 |
50 |
51 | @patch('subscriptions.abstract.UpdateView.get_queryset', lambda x: True)
52 | @patch('subscriptions.abstract.UpdateView.get_form_class', lambda x: True)
53 | @patch('subscriptions.abstract.UpdateView.get_form_kwargs', lambda x: True)
54 | @patch('subscriptions.abstract.UpdateView.get_form', lambda x: True)
55 | def test_update_view_get_context_data():
56 | """Tests that context is properly extended."""
57 | view = abstract.UpdateView()
58 | view.object = None
59 | context = view.get_context_data()
60 |
61 | assert 'template_extends' in context
62 | assert context['template_extends'] == 'subscriptions/base.html'
63 |
64 |
65 | def test_delete_view_get_context_data():
66 | """Tests that context is properly extended."""
67 | view = abstract.DeleteView()
68 | view.object = None
69 | context = view.get_context_data()
70 |
71 | assert 'template_extends' in context
72 | assert context['template_extends'] == 'subscriptions/base.html'
73 |
--------------------------------------------------------------------------------
/subscriptions/templates/subscriptions/transaction_list.html:
--------------------------------------------------------------------------------
1 | {% extends 'subscriptions/base_developer.html' %}
2 |
3 | {% load i18n %}
4 | {% load currency_filters %}
5 |
6 | {% block title %}DFS Dashboard | Transactions{% endblock %}
7 |
8 | {% block subscriptions_styles %}
9 |
28 | {% endblock %}
29 |
30 | {% block main %}
31 |
35 |
36 |
Transactions
37 |
38 | {% include 'subscriptions/snippets/messages.html' %}
39 |
40 | {% if transactions %}
41 |
42 |
48 |
49 | {% for transaction in transactions %}
50 |
51 |
52 | {% trans "User" %}
53 | {% if transaction.user %}{{ transaction.user }}{% else %}No user{% endif %}
54 |
55 |
56 | {% trans "Plan" %}
57 | {% if transaction.subscription %}{{ transaction.subscription.plan }}{% else %}No subscription plan{% endif %}
58 |
59 |
60 | {% trans "Transaction date" %}
61 | {{ transaction.date_transaction }}
62 |
63 |
64 | {% trans "Amount" %}
65 | {% if transaction.amount %}{{ transaction.amount|currency }}{% else %}{{ 0|currency }}{% endif %}
66 |
67 |
70 |
71 | {% endfor %}
72 |
73 | {% else %}
74 |
{% trans "No subscription payment transactions have occurred have been added yet." %}
75 | {% endif %}
76 |
77 | {% include 'subscriptions/snippets/pagination.html' with page_obj=page_obj %}
78 | {% endblock %}
79 |
--------------------------------------------------------------------------------
/subscriptions/templates/subscriptions/subscribe_confirmation.html:
--------------------------------------------------------------------------------
1 | {% extends template_extends %}
2 |
3 | {% load i18n %}
4 | {% load currency_filters %}
5 |
6 | {% block title %}Subscribe | Confirmation{% endblock %}
7 |
8 | {% block content %}
9 |
10 | Confirmation
11 |
12 |
13 | Below are the details of your subscription for you to review and confirm.
14 | Your credit card has not been charged yet.
15 |
16 |
17 | Subscription Details
18 |
19 |
20 | -
21 | Plan
22 | {{ plan }}
23 |
24 | -
25 | Cost
26 |
27 | {{ plan_cost.cost|currency }}
28 | {{ plan_cost.display_billing_frequency_text }}
29 |
30 |
31 |
32 |
33 | Payment Details
34 |
35 |
36 | -
37 | Cardholder Name
38 | {{ payment_form.cardholder_name.value }}
39 |
40 | -
41 | Card Number
42 |
43 | {{ payment_form.card_number.value|make_list|slice:':4'|join:'' }}********{{ payment_form.card_number.value|make_list|slice:'12:'|join:'' }}
44 |
45 |
46 | -
47 | Card Expiry
48 |
49 | {{ payment_form.card_expiry_month.value }}/{{ payment_form.card_expiry_year.value }}
50 |
51 |
52 | -
53 | Billing Address
54 |
55 | {{ payment_form.address_name.value }}
56 | {{ payment_form.address_line_1.value }}
57 | {% if payment_form.address_line_2.value %}{{ payment_form.address_line_2.value }}
{% endif %}
58 | {% if payment_form.address_line_3.value %}{{ payment_form.address_line_3.value }}
{% endif %}
59 | {{ payment_form.address_city.value }},
60 | {{ payment_form.address_province.value }},
61 | {% if payment_form.address_postcode.value %}{{ payment_form.address_postcode.value }},{% endif %}
62 | {{ payment_form.address_country.value }}
63 |
64 |
65 |
66 |
67 |
68 |
76 |
77 |
86 |
87 |
88 |
89 | {% endblock %}
90 |
--------------------------------------------------------------------------------
/subscriptions/templates/subscriptions/plan_list.html:
--------------------------------------------------------------------------------
1 | {% extends 'subscriptions/base_developer.html' %}
2 |
3 | {% load i18n %}
4 | {% load currency_filters %}
5 |
6 | {% block title %}DFS Dashboard | Subscription Plans{% endblock %}
7 |
8 | {% block subscriptions_styles %}
9 |
38 | {% endblock %}
39 |
40 | {% block main %}
41 |
45 |
46 |
Subscription Plans
47 |
48 | {% include 'subscriptions/snippets/messages.html' %}
49 |
50 |
Create new plan
51 |
52 | {% if plans %}
53 |
54 |
60 |
61 | {% for plan in plans %}
62 |
63 |
64 | {% trans "Plan name" %}
65 | {{ plan.plan_name }}
66 |
67 |
68 | {% trans "Description" %}
69 | {{ plan.plan_description }}
70 |
71 |
72 | {% trans "Tags" %}
73 | {% if plan.display_tags %}
74 | {{ plan.display_tags }}
75 | {% else %}
76 | None
77 | {% endif %}
78 |
79 |
80 |
{% trans "Costs" %}
81 |
82 | {% for cost in plan.costs.all %}
83 | -
84 | {{ cost.cost|currency }}
85 | {{ cost.display_billing_frequency_text }}
86 |
87 | {% endfor %}
88 |
89 |
90 |
94 |
95 | {% endfor %}
96 |
97 |
98 |
Create new plan
99 | {% else %}
100 |
{% trans "No subscription plans have been added yet." %}
101 | {% endif %}
102 | {% endblock %}
103 |
--------------------------------------------------------------------------------
/tests/subscriptions/test_forms.py:
--------------------------------------------------------------------------------
1 | """Tests for the models module."""
2 | from datetime import datetime
3 | from unittest.mock import patch, MagicMock
4 | from uuid import uuid4
5 |
6 | import pytest
7 |
8 | from subscriptions import forms, models
9 |
10 |
11 | pytestmark = pytest.mark.django_db # pylint: disable=invalid-name
12 |
13 |
14 | def create_plan(plan_name='1', plan_description='2'):
15 | """Creates and returns SubscriptionPlan instance."""
16 | return models.SubscriptionPlan.objects.create(
17 | plan_name=plan_name, plan_description=plan_description
18 | )
19 |
20 |
21 | def create_cost(plan=None, period=1, unit=models.MONTH, cost='1.00'):
22 | """Creates and returns PlanCost instance."""
23 | return models.PlanCost.objects.create(
24 | plan=plan, recurrence_period=period, recurrence_unit=unit, cost=cost
25 | )
26 |
27 |
28 | # General Functions
29 | # -----------------------------------------------------------------------------
30 | @patch(
31 | 'subscriptions.forms.timezone.now',
32 | MagicMock(return_value=datetime(2000, 1, 1))
33 | )
34 | def test_assemble_cc_years_correct_output():
35 | """Tests that assemble_cc_years returns the expected 60 years of data."""
36 | cc_years = forms.assemble_cc_years()
37 |
38 | assert len(cc_years) == 60
39 | assert cc_years[0] == (2000, 2000)
40 | assert cc_years[-1] == (2059, 2059)
41 |
42 |
43 | # SubscriptionPlanCostForm
44 | # -----------------------------------------------------------------------------
45 | def test_subscription_plan_cost_form_with_plan():
46 | """Tests minimal creation of SubscriptionPlanCostForm."""
47 | plan = create_plan()
48 | create_cost(plan=plan)
49 |
50 | try:
51 | forms.SubscriptionPlanCostForm(subscription_plan=plan)
52 | except KeyError:
53 | assert False
54 | else:
55 | assert True
56 |
57 |
58 | def test_subscription_plan_cost_form_without_plan():
59 | """Tests that SubscriptionPlanCostForm requires a plan."""
60 | try:
61 | forms.SubscriptionPlanCostForm()
62 | except KeyError:
63 | assert True
64 | else:
65 | assert False
66 |
67 |
68 | def test_subscription_plan_cost_form_proper_widget_values():
69 | """Tests that widget values are properly added."""
70 | plan = create_plan()
71 | create_cost(plan, period=3, unit=models.HOUR, cost='3.00')
72 | create_cost(plan, period=1, unit=models.SECOND, cost='1.00')
73 | create_cost(plan, period=2, unit=models.MINUTE, cost='2.00')
74 |
75 | form = forms.SubscriptionPlanCostForm(subscription_plan=plan)
76 | choices = form.fields['plan_cost'].widget.choices
77 | assert choices[0][1] == '$1.00 per second'
78 | assert choices[1][1] == '$2.00 every 2 minutes'
79 | assert choices[2][1] == '$3.00 every 3 hours'
80 |
81 |
82 | def test_subscription_plan_cost_form_clean_plan_cost_value():
83 | """Tests that clean returns PlanCost instance."""
84 | plan = create_plan()
85 | cost = create_cost(plan=plan)
86 | cost_form = forms.SubscriptionPlanCostForm(
87 | {'plan_cost': str(cost.id)}, subscription_plan=plan
88 | )
89 |
90 | assert cost_form.is_valid()
91 | assert cost_form.cleaned_data['plan_cost'] == cost
92 |
93 |
94 | def test_subscription_plan_cost_form_clean_plan_cost_invalid_uuid():
95 | """Tests that clean_pan_cost returns error if instance not found."""
96 | plan = create_plan()
97 | create_cost(plan=plan)
98 | cost_form = forms.SubscriptionPlanCostForm(
99 | {'plan_cost': str(uuid4())}, subscription_plan=plan
100 | )
101 |
102 | assert cost_form.is_valid() is False
103 | assert cost_form.errors == {'plan_cost': ['Invalid plan cost submitted.']}
104 |
--------------------------------------------------------------------------------
/sandbox/settings.py:
--------------------------------------------------------------------------------
1 | """Django settings file to get basic Django instance running."""
2 | from pathlib import Path
3 |
4 | # SETTINGS FILE
5 | # Add all environment variables to config.env in root directory
6 | ROOT_DIR = Path(__file__).parent.absolute()
7 |
8 | # DEBUG SETTINGS
9 | # Used for sandbox - DO NOT USE IN PRODUCTION
10 | DEBUG = True
11 | TEMPLATE_DEBUG = True
12 | SQL_DEBUG = True
13 |
14 | # BASE DJANGO SETTINGS
15 | SECRET_KEY = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ1234567890'
16 | SITE_ID = 1
17 | INTERNAL_IPS = ('127.0.0.1',)
18 | ROOT_URLCONF = 'urls'
19 | APPEND_SLASH = True
20 |
21 | # ADMIN SETTINGS
22 | ADMINS = (
23 | # ('Your Name', 'your_email@domain.com'),
24 | )
25 | MANAGERS = ADMINS
26 |
27 | # EMAIL SETTINGS
28 | EMAIL_BACKEND = 'django.core.mail.backends.console.EmailBackend'
29 |
30 | # LOCALIZATION SETTINGS
31 | USE_TZ = True
32 | TIME_ZONE = 'UTC'
33 | LANGUAGE_CODE = 'en-ca'
34 | LANGUAGES = [
35 | ('en-ca', 'English')
36 | ]
37 | USE_I18N = True
38 | USE_L10N = True
39 |
40 | # DJANGO APPLICATIONS
41 | INSTALLED_APPS = [
42 | # Django Apps
43 | 'django.contrib.auth',
44 | 'django.contrib.admin',
45 | 'django.contrib.contenttypes',
46 | 'django.contrib.messages',
47 | 'django.contrib.sessions',
48 | 'django.contrib.sites',
49 | 'django.contrib.flatpages',
50 | 'django.contrib.staticfiles',
51 | # External Apps
52 | 'debug_toolbar',
53 | # Local Apps
54 | 'subscriptions',
55 | ]
56 |
57 | # DJANGO MIDDLEWARE
58 | MIDDLEWARE = (
59 | 'debug_toolbar.middleware.DebugToolbarMiddleware',
60 | 'django.middleware.common.CommonMiddleware',
61 | 'django.contrib.sessions.middleware.SessionMiddleware',
62 | 'django.middleware.csrf.CsrfViewMiddleware',
63 | 'django.contrib.auth.middleware.AuthenticationMiddleware',
64 | 'django.contrib.messages.middleware.MessageMiddleware',
65 | )
66 |
67 | # DATABASE SETTINGS
68 | DATABASES = {
69 | 'default': {
70 | 'ENGINE': 'django.db.backends.sqlite3',
71 | 'NAME': str(ROOT_DIR.joinpath('db.sqlite3')),
72 | }
73 | }
74 | ATOMIC_REQUESTS = True
75 |
76 | HAYSTACK_CONNECTIONS = {
77 | 'default': {
78 | 'ENGINE': 'haystack.backends.simple_backend.SimpleEngine',
79 | },
80 | }
81 |
82 | # TEMPLATE SETTINGS
83 | TEMPLATES = [
84 | {
85 | 'BACKEND': 'django.template.backends.django.DjangoTemplates',
86 | 'DIRS': [
87 | ROOT_DIR.joinpath('templates'),
88 | ],
89 | 'OPTIONS': {
90 | 'loaders': [
91 | 'django.template.loaders.filesystem.Loader',
92 | 'django.template.loaders.app_directories.Loader',
93 | ],
94 | 'context_processors': [
95 | 'django.contrib.auth.context_processors.auth',
96 | 'django.template.context_processors.request',
97 | 'django.template.context_processors.debug',
98 | 'django.template.context_processors.i18n',
99 | 'django.template.context_processors.media',
100 | 'django.template.context_processors.static',
101 | 'django.contrib.messages.context_processors.messages',
102 | ],
103 | }
104 | }
105 | ]
106 |
107 | # AUTHENTICATION SETTINGS
108 | AUTHENTICATION_BACKENDS = (
109 | 'django.contrib.auth.backends.ModelBackend',
110 | )
111 | LOGIN_REDIRECT_URL = '/'
112 | LOGOUT_REDIRECT_URL = '/'
113 |
114 | # STATIC SETTINGS
115 | STATIC_URL = '/static/'
116 | STATIC_ROOT = ROOT_DIR.joinpath('static')
117 |
118 | # django-flexible-subscriptions settings
119 | # -----------------------------------------------------------------------------
120 | DFS_CURRENCY_LOCALE = 'en_ca'
121 | DFS_ENABLE_ADMIN = True
122 | DFS_BASE_TEMPLATE = 'subscriptions/base.html'
123 |
--------------------------------------------------------------------------------
/README.rst:
--------------------------------------------------------------------------------
1 | =============================
2 | django-flexible-subscriptions
3 | =============================
4 |
5 | |PyPI|_ |PythonVersions| |DjangoVersions| |License|_
6 |
7 | |BuildStatus|_ |Coverage|_
8 |
9 | .. |PyPI| image:: https://img.shields.io/pypi/v/django-flexible-subscriptions.svg
10 | :alt: PyPI
11 |
12 | .. _PyPI: https://pypi.org/project/django-flexible-subscriptions/
13 |
14 | .. |PythonVersions| image:: https://img.shields.io/pypi/pyversions/django-flexible-subscriptions.svg
15 | :alt: PyPI - Python Version
16 |
17 | .. |DjangoVersions| image:: https://img.shields.io/pypi/djversions/django-flexible-subscriptions.svg
18 | :alt: PyPI - Django Version
19 |
20 | .. |BuildStatus| image:: https://travis-ci.com/studybuffalo/django-flexible-subscriptions.svg?branch=master
21 | :alt: Travis-CI build status
22 |
23 | .. _BuildStatus: https://travis-ci.com/studybuffalo/django-flexible-subscriptions
24 |
25 | .. |Coverage| image:: https://codecov.io/gh/studybuffalo/django-flexible-subscriptions/branch/master/graph/badge.svg
26 | :alt: Codecov code coverage
27 |
28 | .. _Coverage: https://codecov.io/gh/studybuffalo/django-flexible-subscriptions
29 |
30 | .. |License| image:: https://img.shields.io/github/license/studybuffalo/django-flexible-subscriptions.svg
31 | :alt: License
32 |
33 | .. _License: https://github.com/studybuffalo/django-flexible-subscriptions/blob/master/LICENSE
34 |
35 | Django Flexible Subscriptions provides subscription and recurrent
36 | billing for `Django`_ applications. Any payment provider can be quickly
37 | added by overriding the placeholder methods.
38 |
39 | .. _Django: https://www.djangoproject.com/
40 |
41 | ---------------
42 | Getting Started
43 | ---------------
44 |
45 | Instructions on installing and configuration can be found on
46 | `Read The Docs`_.
47 |
48 | .. _Read The Docs: https://django-flexible-subscriptions.readthedocs.io/en/latest/
49 |
50 | -------
51 | Support
52 | -------
53 |
54 | The `docs provide examples for setup and common issues`_ to be aware
55 | of. For any other issues, you can submit a `GitHub Issue`_.
56 |
57 | .. _docs provide examples for setup and common issues: https://django-flexible-subscriptions.readthedocs.io/en/latest/installation.html
58 |
59 | .. _GitHub Issue: https://github.com/studybuffalo/django-flexible-subscriptions/issues
60 |
61 | ------------
62 | Contributing
63 | ------------
64 |
65 | Contributions are welcome, especially to address bugs and extend
66 | functionality. Full `details on contributing can be found in the docs`_.
67 |
68 | .. _details on contributing can be found in the docs: https://django-flexible-subscriptions.readthedocs.io/en/latest/contributing.html
69 |
70 | ----------
71 | Versioning
72 | ----------
73 |
74 | This package uses a MAJOR.MINOR.PATCH versioning, as outlined at
75 | `Semantic Versioning 2.0.0`_.
76 |
77 | .. _Semantic Versioning 2.0.0: https://semver.org/
78 |
79 | -------
80 | Authors
81 | -------
82 |
83 | Joshua Robert Torrance (StudyBuffalo_)
84 |
85 | .. _StudyBuffalo: https://github.com/studybuffalo
86 |
87 | -------
88 | License
89 | -------
90 |
91 | This project is licensed under the GPLv3. Please see the LICENSE_ file for details.
92 |
93 | .. _LICENSE: https://github.com/studybuffalo/django-flexible-subscriptions/blob/master/LICENSE
94 |
95 | ----------------
96 | Acknowledgements
97 | ----------------
98 |
99 | * `Django Oscar`_ and `Django Subscription`_ for inspiring many of the
100 | initial design decisions.
101 |
102 | .. _Django Oscar: https://github.com/django-oscar/django-oscar
103 | .. _Django Subscription: https://github.com/zhaque/django-subscription
104 |
105 | ---------
106 | Changelog
107 | ---------
108 |
109 | You can view all `package changes on the docs`_.
110 |
111 | .. _package changes on the docs: https://django-flexible-subscriptions.readthedocs.io/en/latest/changelog.html
112 |
--------------------------------------------------------------------------------
/subscriptions/templates/subscriptions/subscription_list.html:
--------------------------------------------------------------------------------
1 | {% extends 'subscriptions/base_developer.html' %}
2 |
3 | {% load i18n %}
4 | {% load currency_filters %}
5 |
6 | {% block title %}DFS Dashboard | User Subscriptions{% endblock %}
7 |
8 | {% block subscriptions_styles %}
9 |
28 | {% endblock %}
29 |
30 | {% block main %}
31 |
35 |
36 |
User Subscriptions
37 |
38 | {% include 'subscriptions/snippets/messages.html' %}
39 |
40 |
Create new user subscription
41 |
42 | {% if users %}
43 |
44 |
54 |
55 | {% for user in users %}
56 | {% for subscription in user.subscriptions.all %}
57 |
58 |
59 | {% trans "User" %}
60 | {{ user }}
61 |
62 |
63 | {% trans "Plan" %}
64 | {{ subscription.subscription.plan }}
65 | {{ subscription.subscription.cost|currency }}
66 | {{ subscription.subscription.display_billing_frequency_text }}
67 |
68 |
69 | {% trans "Billing start date" %}
70 | {{ subscription.date_billing_start }}
71 |
72 |
73 | {% trans "Billing end date" %}
74 | {{ subscription.date_billing_end }}
75 |
76 |
77 | {% trans "Last billing date" %}
78 | {{ subscription.date_billing_last }}
79 |
80 |
81 | {% trans "Next billing date" %}
82 | {{ subscription.date_billing_next }}
83 |
84 |
85 | {% trans "Active?" %}
86 | {{ subscription.active }}
87 |
88 |
89 | {% trans "Cancelled?" %}
90 | {{ subscription.cancelled }}
91 |
92 |
96 |
97 | {% endfor %}
98 | {% endfor %}
99 |
100 |
101 |
Create new user subscription
102 | {% else %}
103 |
{% trans "No user subscriptions have been added yet." %}
104 | {% endif %}
105 |
106 | {% include 'subscriptions/snippets/pagination.html' with page_obj=page_obj %}
107 | {% endblock %}
108 |
--------------------------------------------------------------------------------
/subscriptions/abstract.py:
--------------------------------------------------------------------------------
1 | """Abstract templates for the Djanog Flexible Subscriptions app."""
2 | from django.views import generic
3 |
4 | from subscriptions.conf import SETTINGS
5 |
6 | BASE_TEMPLATE = SETTINGS['base_template']
7 |
8 |
9 | class TemplateView(generic.TemplateView):
10 | """Extends TemplateView to specify of extensible HTML template.
11 |
12 | Attributes:
13 | template_extends (str): Path to HTML template that this
14 | view extends.
15 | """
16 | template_extends = BASE_TEMPLATE
17 |
18 | def get_context_data(self, **kwargs):
19 | """Overriding get_context_data to add additional context."""
20 | context = super().get_context_data(**kwargs)
21 |
22 | # Provides the base template to extend from
23 | context['template_extends'] = self.template_extends
24 |
25 | return context
26 |
27 |
28 | class ListView(generic.ListView):
29 | """Extends ListView to specify of extensible HTML template
30 |
31 | Attributes:
32 | template_extends (str): Path to HTML template that this
33 | view extends.
34 | """
35 | template_extends = BASE_TEMPLATE
36 |
37 | def get_context_data(self, *, object_list=None, **kwargs): # pylint: disable=unused-argument
38 | """Overriding get_context_data to add additional context."""
39 | context = super().get_context_data(**kwargs)
40 |
41 | # Provides the base template to extend from
42 | context['template_extends'] = self.template_extends
43 |
44 | return context
45 |
46 |
47 | class DetailView(generic.DetailView):
48 | """Extends DetailView to specify of extensible HTML template
49 |
50 | Attributes:
51 | template_extends (str): Path to HTML template that this
52 | view extends.
53 | """
54 | template_extends = BASE_TEMPLATE
55 |
56 | def get_context_data(self, **kwargs):
57 | """Overriding get_context_data to add additional context."""
58 | context = super().get_context_data(**kwargs)
59 |
60 | # Provides the base template to extend from
61 | context['template_extends'] = self.template_extends
62 |
63 | return context
64 |
65 |
66 | class CreateView(generic.CreateView):
67 | """Extends CreateView to specify of extensible HTML template
68 |
69 | Attributes:
70 | template_extends (str): Path to HTML template that this
71 | view extends.
72 | """
73 | template_extends = BASE_TEMPLATE
74 |
75 | def get_context_data(self, **kwargs):
76 | """Overriding get_context_data to add additional context."""
77 | context = super().get_context_data(**kwargs)
78 |
79 | # Provides the base template to extend from
80 | context['template_extends'] = self.template_extends
81 |
82 | return context
83 |
84 |
85 | class UpdateView(generic.UpdateView):
86 | """Extends UpdateView to specify of extensible HTML template
87 |
88 | Attributes:
89 | template_extends (str): Path to HTML template that this
90 | view extends.
91 | """
92 | template_extends = BASE_TEMPLATE
93 |
94 | def get_context_data(self, **kwargs):
95 | """Overriding get_context_data to add additional context."""
96 | context = super().get_context_data(**kwargs)
97 |
98 | # Provides the base template to extend from
99 | context['template_extends'] = self.template_extends
100 |
101 | return context
102 |
103 |
104 | class DeleteView(generic.DeleteView):
105 | """Extends DeleteView to specify of extensible HTML template
106 |
107 | Attributes:
108 | template_extends (str): Path to HTML template that this
109 | view extends.
110 | """
111 | template_extends = BASE_TEMPLATE
112 |
113 | def get_context_data(self, **kwargs):
114 | """Overriding get_context_data to add additional context."""
115 | context = super().get_context_data(**kwargs)
116 |
117 | # Provides the base template to extend from
118 | context['template_extends'] = self.template_extends
119 |
120 | return context
121 |
--------------------------------------------------------------------------------
/subscriptions/migrations/0002_plan_list_addition.py:
--------------------------------------------------------------------------------
1 | """Create models to support Plan List functionality."""
2 | # pylint: disable=missing-docstring, invalid-name
3 | from django.db import migrations, models
4 | import django.db.models.deletion
5 |
6 |
7 | class Migration(migrations.Migration):
8 | dependencies = [
9 | ('subscriptions', '0001_initial'),
10 | ]
11 |
12 | operations = [
13 | migrations.CreateModel(
14 | name='PlanList',
15 | fields=[
16 | (
17 | 'id',
18 | models.AutoField(
19 | auto_created=True,
20 | primary_key=True,
21 | serialize=False,
22 | verbose_name='ID',
23 | ),
24 | ),
25 | (
26 | 'title',
27 | models.TextField(
28 | blank=True,
29 | help_text='title to display on the subscription plan list page',
30 | null=True,
31 | ),
32 | ),
33 | (
34 | 'subtitle',
35 | models.TextField(
36 | blank=True,
37 | help_text='subtitle to display on the subscription plan list page',
38 | null=True,
39 | ),
40 | ),
41 | (
42 | 'header',
43 | models.TextField(
44 | blank=True,
45 | help_text='header text to display on the subscription plan list page',
46 | null=True,
47 | ),
48 | ),
49 | (
50 | 'footer',
51 | models.TextField(
52 | blank=True,
53 | help_text='header text to display on the subscription plan list page',
54 | null=True,
55 | ),
56 | ),
57 | (
58 | 'active',
59 | models.BooleanField(
60 | default=True,
61 | help_text='whether this plan list is active or not.',
62 | ),
63 | ),
64 | ],
65 | ),
66 | migrations.CreateModel(
67 | name='PlanListDetail',
68 | fields=[
69 | (
70 | 'id',
71 | models.AutoField(
72 | auto_created=True,
73 | primary_key=True,
74 | serialize=False,
75 | verbose_name='ID',
76 | ),
77 | ),
78 | (
79 | 'html_content',
80 | models.TextField(
81 | blank=True,
82 | help_text='HTML content to display for plan',
83 | null=True,
84 | ),
85 | ),
86 | (
87 | 'subscribe_button_text',
88 | models.CharField(
89 | blank=True,
90 | default='Subscribe',
91 | max_length=128,
92 | null=True,
93 | ),
94 | ),
95 | (
96 | 'plan',
97 | models.ForeignKey(
98 | on_delete=django.db.models.deletion.CASCADE,
99 | to='subscriptions.SubscriptionPlan',
100 | ),
101 | ),
102 | (
103 | 'plan_list',
104 | models.ForeignKey(
105 | on_delete=django.db.models.deletion.CASCADE,
106 | to='subscriptions.PlanList',
107 | ),
108 | ),
109 | ],
110 | ),
111 | migrations.AddField(
112 | model_name='planlist',
113 | name='plans',
114 | field=models.ManyToManyField(
115 | blank=True,
116 | related_name='plan_lists',
117 | through='subscriptions.PlanListDetail',
118 | to='subscriptions.SubscriptionPlan',
119 | ),
120 | ),
121 | ]
122 |
--------------------------------------------------------------------------------
/subscriptions/urls.py:
--------------------------------------------------------------------------------
1 | """paths for the Flexible Subscriptions app."""
2 | # pylint: disable=line-too-long
3 | import importlib
4 |
5 | from django.urls import path
6 |
7 | from subscriptions import views
8 | from subscriptions.conf import SETTINGS
9 |
10 |
11 | # Retrieve the proper subscribe view
12 | SubscribeView = getattr( # pylint: disable=invalid-name
13 | importlib.import_module(SETTINGS['subscribe_view']['module']),
14 | SETTINGS['subscribe_view']['class']
15 | )
16 |
17 |
18 | urlpatterns = [
19 | path(
20 | 'subscribe/',
21 | views.SubscribeList.as_view(),
22 | name='dfs_subscribe_list',
23 | ),
24 | path(
25 | 'subscribe/add/',
26 | SubscribeView.as_view(),
27 | name='dfs_subscribe_add',
28 | ),
29 | path(
30 | 'subscribe/thank-you/
/',
31 | views.SubscribeThankYouView.as_view(),
32 | name='dfs_subscribe_thank_you',
33 | ),
34 | path(
35 | 'subscribe/cancel//',
36 | views.SubscribeCancelView.as_view(),
37 | name='dfs_subscribe_cancel',
38 | ),
39 | path(
40 | 'subscriptions/',
41 | views.SubscribeUserList.as_view(),
42 | name='dfs_subscribe_user_list',
43 | ),
44 | path(
45 | 'dfs/tags/',
46 | views.TagListView.as_view(),
47 | name='dfs_tag_list',
48 | ),
49 | path(
50 | 'dfs/tags/create/',
51 | views.TagCreateView.as_view(),
52 | name='dfs_tag_create',
53 | ),
54 | path(
55 | 'dfs/tags//',
56 | views.TagUpdateView.as_view(),
57 | name='dfs_tag_update',
58 | ),
59 | path(
60 | 'dfs/tags//delete/',
61 | views.TagDeleteView.as_view(),
62 | name='dfs_tag_delete',
63 | ),
64 | path(
65 | 'dfs/plans/',
66 | views.PlanListView.as_view(),
67 | name='dfs_plan_list',
68 | ),
69 | path(
70 | 'dfs/plans/create/',
71 | views.PlanCreateView.as_view(),
72 | name='dfs_plan_create',
73 | ),
74 | path(
75 | 'dfs/plans//',
76 | views.PlanUpdateView.as_view(),
77 | name='dfs_plan_update',
78 | ),
79 | path(
80 | 'dfs/plans//delete/',
81 | views.PlanDeleteView.as_view(),
82 | name='dfs_plan_delete',
83 | ),
84 | path(
85 | 'dfs/plan-lists/',
86 | views.PlanListListView.as_view(),
87 | name='dfs_plan_list_list',
88 | ),
89 | path(
90 | 'dfs/plan-lists/create/',
91 | views.PlanListCreateView.as_view(),
92 | name='dfs_plan_list_create',
93 | ),
94 | path(
95 | 'dfs/plan-lists//',
96 | views.PlanListUpdateView.as_view(),
97 | name='dfs_plan_list_update',
98 | ),
99 | path(
100 | 'dfs/plan-lists//delete/',
101 | views.PlanListDeleteView.as_view(),
102 | name='dfs_plan_list_delete',
103 | ),
104 | path(
105 | 'dfs/plan-lists//details/',
106 | views.PlanListDetailListView.as_view(),
107 | name='dfs_plan_list_detail_list',
108 | ),
109 | path(
110 | 'dfs/plan-lists//details/create/',
111 | views.PlanListDetailCreateView.as_view(),
112 | name='dfs_plan_list_detail_create',
113 | ),
114 | path(
115 | 'dfs/plan-lists//details//',
116 | views.PlanListDetailUpdateView.as_view(),
117 | name='dfs_plan_list_detail_update',
118 | ),
119 | path(
120 | 'dfs/plan-lists//details//delete/',
121 | views.PlanListDetailDeleteView.as_view(),
122 | name='dfs_plan_list_detail_delete',
123 | ),
124 | path(
125 | 'dfs/subscriptions/',
126 | views.SubscriptionListView.as_view(),
127 | name='dfs_subscription_list',
128 | ),
129 | path(
130 | 'dfs/subscriptions/create/',
131 | views.SubscriptionCreateView.as_view(),
132 | name='dfs_subscription_create',
133 | ),
134 | path(
135 | 'dfs/subscriptions//',
136 | views.SubscriptionUpdateView.as_view(),
137 | name='dfs_subscription_update',
138 | ),
139 | path(
140 | 'dfs/subscriptions//delete/',
141 | views.SubscriptionDeleteView.as_view(),
142 | name='dfs_subscription_delete',
143 | ),
144 | path(
145 | 'dfs/transactions/',
146 | views.TransactionListView.as_view(),
147 | name='dfs_transaction_list',
148 | ),
149 | path(
150 | 'dfs/transactions//',
151 | views.TransactionDetailView.as_view(),
152 | name='dfs_transaction_detail',
153 | ),
154 | path(
155 | 'dfs/',
156 | views.DashboardView.as_view(),
157 | name='dfs_dashboard',
158 | ),
159 | ]
160 |
--------------------------------------------------------------------------------
/subscriptions/conf.py:
--------------------------------------------------------------------------------
1 | """Functions for general package configuration."""
2 | import warnings
3 |
4 | from django.conf import settings
5 | from django.core.exceptions import ImproperlyConfigured
6 |
7 | from subscriptions.currency import Currency, CURRENCY
8 |
9 |
10 | def string_to_module_and_class(string):
11 | """Breaks a string to a module and class name component."""
12 | components = string.split('.')
13 | component_class = components.pop()
14 | component_module = '.'.join(components)
15 |
16 | return {
17 | 'module': component_module,
18 | 'class': component_class,
19 | }
20 |
21 |
22 | def validate_currency_settings(currency_locale):
23 | """Validates provided currency settings.
24 |
25 | Parameters
26 | currency_locale (str or dict): a currency locale string or
27 | a dictionary defining custom currency formating
28 | conventions.
29 |
30 | Raises:
31 | ImproperlyConfigured: specified string currency_locale not
32 | support.
33 | TypeError: invalid parameter type provided.
34 | """
35 | # STRING VALIDATION
36 | # ------------------------------------------------------------------------
37 | if isinstance(currency_locale, str):
38 | # Confirm that the provided locale is supported
39 | if currency_locale.lower() not in CURRENCY:
40 | raise ImproperlyConfigured(
41 | '{} is not a support DFS_CURRENCY_LOCALE value.'.format(currency_locale)
42 | )
43 | elif isinstance(currency_locale, dict):
44 | # Placeholder for any future specific dictionary validation
45 | pass
46 | else:
47 | raise TypeError(
48 | 'Invalid DFS_CURRENCY_LOCALE type: {}. Must be str or dict.'.format(
49 | type(currency_locale)
50 | )
51 | )
52 |
53 |
54 | def determine_currency_settings():
55 | """Determines details for Currency handling.
56 |
57 | Validates the provided currency locale setting and then returns
58 | a Currency object.
59 |
60 | Returns:
61 | obj: a Currency object for the provided setting.
62 | """
63 | # Get the proper setting attribute name
64 | # This block can be removed when DFS_CURRENCY_LOCALE has been
65 | # removed
66 | if hasattr(settings, 'DFS_CURRENCY'):
67 | currency_setting = 'DFS_CURRENCY'
68 | elif hasattr(settings, 'DFS_CURRENCY_LOCALE'):
69 | currency_setting = 'DFS_CURRENCY_LOCALE'
70 |
71 | deprecation_warning = (
72 | 'DFS_CURRENCY_LOCALE is deprecated and has been replaced by '
73 | 'DFS_CURRENCY. DFS_CURRENCY_LOCALE will be removed in a '
74 | 'future version of django-flexible-subscription.'
75 | )
76 | warnings.warn(deprecation_warning, DeprecationWarning)
77 | else:
78 | currency_setting = 'DFS_CURRENCY'
79 |
80 | # Get the value for the currency
81 | currency_value = getattr(settings, currency_setting, 'en_us')
82 |
83 | # Validate currency locale setting
84 | validate_currency_settings(currency_value)
85 |
86 | # Return the Currency object
87 | return Currency(currency_value)
88 |
89 |
90 | def compile_settings():
91 | """Compiles and validates all package settings and defaults.
92 |
93 | Provides basic checks to ensure required settings are declared
94 | and applies defaults for all missing settings.
95 |
96 | Returns:
97 | dict: All possible Django Flexible Subscriptions settings.
98 | """
99 | # ADMIN SETTINGS
100 | # -------------------------------------------------------------------------
101 | enable_admin = getattr(settings, 'DFS_ENABLE_ADMIN', False)
102 |
103 | # CURRENCY SETTINGS
104 | # -------------------------------------------------------------------------
105 | currency = determine_currency_settings()
106 |
107 | # TEMPLATE & VIEW SETTINGS
108 | # -------------------------------------------------------------------------
109 | base_template = getattr(
110 | settings, 'DFS_BASE_TEMPLATE', 'subscriptions/base.html'
111 | )
112 |
113 | # Get module and class for SubscribeView
114 | subscribe_view_path = getattr(
115 | settings, 'DFS_SUBSCRIBE_VIEW', 'subscriptions.views.SubscribeView'
116 | )
117 | subscribe_view = string_to_module_and_class(subscribe_view_path)
118 |
119 | # MANAGEMENT COMMANDS SETTINGS
120 | # ------------------------------------------------------------------------
121 | # Get module and class for the Management Command Manager class
122 | manager_object = getattr(
123 | settings,
124 | 'DFS_MANAGER_CLASS',
125 | 'subscriptions.management.commands._manager.Manager',
126 | )
127 | management_manager = string_to_module_and_class(manager_object)
128 |
129 | return {
130 | 'enable_admin': enable_admin,
131 | 'currency': currency,
132 | 'base_template': base_template,
133 | 'subscribe_view': subscribe_view,
134 | 'management_manager': management_manager,
135 | }
136 |
137 |
138 | SETTINGS = compile_settings()
139 |
--------------------------------------------------------------------------------
/docs/conf.py:
--------------------------------------------------------------------------------
1 | # -*- coding: utf-8 -*-
2 | # pylint: disable=missing-docstring,invalid-name,redefined-builtin
3 |
4 | # Configuration file for the Sphinx documentation builder.
5 | #
6 | # Sphinx documentation: http://www.sphinx-doc.org/en/master/config
7 |
8 | # -- Required imports --------------------------------------------------------
9 | import os
10 | import sys
11 | import django
12 | from django.conf import settings
13 |
14 |
15 | # -- Path setup --------------------------------------------------------------
16 | # Path to any external modules (i.e. outside of the docs directory)
17 | sys.path.insert(0, os.path.abspath('../')) # Parent directory
18 |
19 |
20 | # -- Project information -----------------------------------------------------
21 | from subscriptions import __version__ # pylint: disable=wrong-import-position
22 |
23 | project = 'django-flexible-subscriptions'
24 | copyright = '2019, Joshua Robert Torrance'
25 | author = 'Joshua Robert Torrance'
26 | version = __version__
27 | release = __version__
28 |
29 |
30 | # -- General configuration ---------------------------------------------------
31 | # Sphinx extensions
32 | extensions = [
33 | 'sphinx.ext.autodoc',
34 | 'sphinx.ext.napoleon',
35 | ]
36 |
37 | # Template paths (relative to this directory)
38 | templates_path = []
39 |
40 | # Suffix(es) of source filenames
41 | source_suffix = '.rst'
42 |
43 | # Master toctree document.
44 | master_doc = 'index'
45 |
46 | # Language for content autogenerated by Sphinx.
47 | language = 'en'
48 |
49 | # List of patterns, relative to source directory, that match files and
50 | # directories to ignore when looking for source files (also affects
51 | # html_static_path and html_extra_path)
52 | exclude_patterns = [
53 | '_build', 'Thumbs.db', '.DS_Store', 'subscriptions/migrations',
54 | ]
55 |
56 | # The name of the Pygments (syntax highlighting) style to use
57 | pygments_style = 'sphinx'
58 |
59 |
60 | # -- Options for HTML output -------------------------------------------------
61 | # HTML theme to use
62 | html_theme = 'sphinx_rtd_theme'
63 |
64 | # Path to any custom static files (overrides defaults if names match)
65 | html_static_path = []
66 |
67 |
68 | # -- Options for HTMLHelp output ---------------------------------------------
69 | # Output file base name for HTML help builder
70 | htmlhelp_basename = 'django-flexible-subscriptions-doc'
71 |
72 |
73 | # -- Options for LaTeX output ------------------------------------------------
74 | latex_elements = {}
75 |
76 | # Grouping the document tree into LaTeX files - list of tuples as follows:
77 | # (source start file, target name, title, author,
78 | # documentclass [howto, manual, or own class]).
79 | latex_documents = [
80 | (
81 | master_doc,
82 | 'django-flexible-subscriptions.tex',
83 | 'django-flexible-subscriptions Documentation',
84 | 'Joshua Robert Torrance',
85 | 'manual'
86 | ),
87 | ]
88 |
89 |
90 | # -- Options for manual page output ------------------------------------------
91 | # One entry per manual page - list of tuples as follows:
92 | # (source start file, name, description, authors, manual section)
93 | man_pages = [
94 | (
95 | master_doc,
96 | 'django-flexible-subscriptions',
97 | 'django-flexible-subscriptions Documentation',
98 | [author],
99 | 1
100 | ),
101 | ]
102 |
103 |
104 | # -- Options for Texinfo output ----------------------------------------------
105 | # Grouping the document tree into Texinfo files - list of tuples as follows:
106 | # (source start file, target name, title, author, dir menu entry, description,
107 | # category)
108 | texinfo_documents = [
109 | (
110 | master_doc,
111 | 'django-flexible-subscriptions',
112 | 'django-flexible-subscriptions Documentation',
113 | author,
114 | 'A subscription and recurrent billing application for Django.',
115 | 'Miscellaneous'
116 | ),
117 | ]
118 |
119 |
120 | # -- Options and settings for autodoc ----------------------------------------
121 | # Any autodoc imports to mock to prevent import errors
122 | autodoc_mock_imports = []
123 |
124 | # Minimal Django settings to import subscriptions module for autodoc
125 | django_settings = {
126 | 'DATABASES': {
127 | 'default': {
128 | 'ENGINE': 'django.db.backends.sqlite3',
129 | 'NAME': ':memory:',
130 | }
131 | },
132 | 'INSTALLED_APPS': {
133 | 'django.contrib.admin',
134 | 'django.contrib.auth',
135 | 'django.contrib.contenttypes',
136 | 'django.contrib.sessions',
137 | 'django.contrib.sites',
138 | 'subscriptions',
139 | },
140 | 'MIDDLEWARE': [
141 | 'django.contrib.sessions.middleware.SessionMiddleware',
142 | 'django.contrib.auth.middleware.AuthenticationMiddleware',
143 | 'django.contrib.messages.middleware.MessageMiddleware',
144 | ],
145 | 'ROOT_URLCONF': 'subscriptions.urls',
146 | 'TEMPLATES': [
147 | {
148 | 'BACKEND': 'django.template.backends.django.DjangoTemplates',
149 | 'APP_DIRS': True,
150 | },
151 | ],
152 | }
153 |
154 | settings.configure(**django_settings)
155 |
156 | # Initiate Django
157 | django.setup()
158 |
159 |
160 | # -- Options for napoleon ----------------------------------------------------
161 | napoleon_google_docstring = True
162 | napoleon_numpy_docstring = False
163 | napoleon_include_private_with_doc = False
164 |
--------------------------------------------------------------------------------
/docs/settings.rst:
--------------------------------------------------------------------------------
1 | ========
2 | Settings
3 | ========
4 |
5 | Below is a comprehensive list of all the settings for
6 | Django Flexible Subscriptions.
7 |
8 | --------------
9 | Admin Settings
10 | --------------
11 |
12 | These are settings to control aspects of the Django admin support.
13 |
14 | ``DFS_ENABLE_ADMIN``
15 | ====================
16 |
17 | **Required:** ``False``
18 |
19 | **Default (boolean):** ``False``
20 |
21 | Whether to enable the Django Admin views or not.
22 |
23 | -----------------
24 | Currency Settings
25 | -----------------
26 |
27 | These are the settings to control aspects of currency repsentation.
28 |
29 | .. _settings-dfs_currency:
30 |
31 | ``DFS_CURRENCY``
32 | ================
33 |
34 | **Required:** ``False``
35 |
36 | **Default (string):** ``en_us``
37 |
38 | The currency to use for currency formating. You may either specify a
39 | ``str`` value for the language code you want to use or a ``dict`` value
40 | that declares all the required monetary conventions.
41 |
42 | The following ``str`` values are available:
43 |
44 | * ``de_de`` (Germany, German)
45 | * ``en_au`` (Australia, English)
46 | * ``en_ca`` (Canada, English)
47 | * ``en_us`` (United States of America, English)
48 | * ``fa_ir`` (Iran, Persian)
49 | * ``fr_ca`` (Canada, French)
50 | * ``fr_ch`` (Swiss Confederation, French)
51 | * ``fr_fr`` (France, French)
52 | * ``it_it`` (Itality, Italian)
53 | * ``pl_pl`` (Republic of Poland, Polish)
54 | * ``pt_br`` (Federative Republic of Brazil, Portuguese)
55 | * ``en_in`` (India, English)
56 | * ``en_ph`` (Philippines, English)
57 |
58 | Additional values can be added by submitting a pull request with the
59 | details added to the ``CURRENCY`` dictionary in the
60 | ``subscriptions.currency`` module.
61 |
62 | To specify a custom format, you can specify the following details
63 | in a dictionary:
64 |
65 | * ``currency_symbol`` (``str``): The symbol used for this currency.
66 | * ``int_currency_symbol`` (``str``): The symbol used for this currency
67 | for international formatting.
68 | * ``p_cs_precedes`` (``bool``): Whether the currency symbol precedes
69 | positive values.
70 | * ``n_cs_precedes`` (``bool``): Whether the currency symbol precedes
71 | negative values.
72 | * ``p_sep_by_space`` (``bool``): Whether the currency symbol is
73 | separated from positive values by a space.
74 | * ``n_sep_by_space`` (``bool``): Whether the currency symbol is
75 | separated from negative values by a space.
76 | * ``mon_decimal_point`` (``str``): The character used for decimal points.
77 | * ``mon_thousands_sep`` (``str``): The character used for separating
78 | groups of numbers.
79 | * ``mon_grouping`` (``int``): The number of digits per groups.
80 | * ``frac_digits`` (``int``): The number of digits following the decimal
81 | place. Use 0 if this is a non-decimal currency.
82 | * ``int_frac_digits`` (``str``): The number of digits following the
83 | decimal place for international formatting. Use 0 if this is a
84 | non-decimal currency.
85 | * ``positive_sign`` (``str``): The symbol to use for the positive sign.
86 | * ``negative_sign`` (``str``): The symbol to use for the negative sign.
87 | * ``p_sign_posn`` (``str``): How the positive sign should be positioned
88 | relative to the currency symbol and value (see below).
89 | * ``n_sign_posn`` (``str``): How the positive sign should be positioned
90 | relative to the currency symbol and value (see below).
91 |
92 | The sign positions (``p_sign_posn`` and ``p_sign_posn``) use the
93 | following values:
94 |
95 | * ``0``: Currency and value are surrounded by parentheses.
96 | * ``1``: The sign should precede the value and currency symbol.
97 | * ``2``: The sign should follow the value and currency symbol.
98 | * ``3``: The sign should immediately precede the value.
99 | * ``4``: The sign should immediately follow the value.
100 |
101 | ``DFS_CURRENCY_LOCALE``
102 | =======================
103 |
104 | Deprecated - use :ref:`settings-dfs_currency` instead.
105 |
106 | ------------------------
107 | View & Template Settings
108 | ------------------------
109 |
110 | These control various aspects of HTML templates and Django views.
111 |
112 | ``DFS_BASE_TEMPLATE``
113 | =====================
114 |
115 | **Required:** ``False``
116 |
117 | **Default (string):** ``subscriptions/base.html``
118 |
119 | Path to an HTML template that is the 'base' template for the site. This
120 | allows you to easily specify the main site design for the provided
121 | Django Flexible Subscription views. The template must include a
122 | ``content`` block, which is what all the templates override.
123 |
124 | ``DFS_SUBSCRIBE_VIEW``
125 | ======================
126 |
127 | **Required:** ``False``
128 |
129 | **Default (string):** ``subscriptions.views.SubscribeView``
130 |
131 | The path to the SubscribeView to use with
132 | ``django-flexible-subscriptions``. This will generally be set to a
133 | class view the inherits from ``SubscribeView`` to allow customization
134 | of payment and subscription processing.
135 |
136 | ------------------------
137 | View & Template Settings
138 | ------------------------
139 |
140 | These control various aspects of the management commands.
141 |
142 | ``DFS_MANAGER_CLASS``
143 | ======================
144 |
145 | **Required:** ``False``
146 |
147 | **Default (string):** ``subscriptions.management.commands._manager.Manager``
148 |
149 | The path to the ``Manager`` object to use with the management commands.
150 | This will generally be set to a class that inherits from the
151 | ``django-flexible-subscriptions`` ``Manager`` class to allow
152 | customization of renewal billings and user notifications.
153 |
--------------------------------------------------------------------------------
/subscriptions/forms.py:
--------------------------------------------------------------------------------
1 | """Forms for Django Flexible Subscriptions."""
2 | # pylint: disable=invalid-name
3 | from django import forms
4 | from django.core import validators
5 | from django.forms import ModelForm
6 | from django.utils import timezone
7 |
8 | from subscriptions.conf import SETTINGS
9 | from subscriptions.models import SubscriptionPlan, PlanCost
10 |
11 |
12 | def assemble_cc_years():
13 | """Creates a list of the next 60 years."""
14 | cc_years = []
15 | now = timezone.now()
16 |
17 | for year in range(now.year, now.year + 60):
18 | cc_years.append((year, year))
19 |
20 | return cc_years
21 |
22 |
23 | class SubscriptionPlanForm(ModelForm):
24 | """Model Form for SubscriptionPlan model."""
25 | class Meta:
26 | model = SubscriptionPlan
27 | fields = [
28 | 'plan_name', 'plan_description', 'group', 'tags', 'grace_period',
29 | ]
30 |
31 |
32 | class PlanCostForm(ModelForm):
33 | """Form to use with inlineformset_factory and SubscriptionPlanForm."""
34 | class Meta:
35 | model = PlanCost
36 | fields = ['recurrence_period', 'recurrence_unit', 'cost']
37 |
38 |
39 | class PaymentForm(forms.Form):
40 | """Form to collect details required for payment billing."""
41 | CC_MONTHS = (
42 | ('1', '01 - January'),
43 | ('2', '02 - February'),
44 | ('3', '03 - March'),
45 | ('4', '04 - April'),
46 | ('5', '05 - May'),
47 | ('6', '06 - June'),
48 | ('7', '07 - July'),
49 | ('8', '08 - August'),
50 | ('9', '09 - September'),
51 | ('10', '10 - October'),
52 | ('11', '11 - November'),
53 | ('12', '12 - December'),
54 | )
55 | CC_YEARS = assemble_cc_years()
56 |
57 | cardholder_name = forms.CharField(
58 | label='Cardholder name',
59 | max_length=255,
60 | min_length=1,
61 | )
62 | card_number = forms.CharField(
63 | label='Card number',
64 | max_length=19,
65 | min_length=13,
66 | validators=[validators.RegexValidator(
67 | r'^\d{13,19}$',
68 | message='Invalid credit card number',
69 | )]
70 | )
71 | card_expiry_month = forms.ChoiceField(
72 | choices=CC_MONTHS,
73 | label='Card expiry (month)',
74 | )
75 | card_expiry_year = forms.ChoiceField(
76 | choices=CC_YEARS,
77 | label='Card expiry (year)',
78 | )
79 | card_cvv = forms.CharField(
80 | label='Card CVV',
81 | max_length=4,
82 | min_length=3,
83 | validators=[validators.RegexValidator(
84 | r'^\d{3,4}$',
85 | message='Invalid CVV2 number',
86 | )]
87 | )
88 | address_title = forms.CharField(
89 | label='Title',
90 | max_length=32,
91 | required=False,
92 | )
93 | address_name = forms.CharField(
94 | label='Name',
95 | max_length=128,
96 | )
97 | address_line_1 = forms.CharField(
98 | label='Line 1',
99 | max_length=256,
100 | )
101 | address_line_2 = forms.CharField(
102 | label='Line 2',
103 | max_length=256,
104 | required=False,
105 | )
106 | address_line_3 = forms.CharField(
107 | label='Line 3',
108 | max_length=256,
109 | required=False,
110 | )
111 | address_city = forms.CharField(
112 | label='City',
113 | max_length=128,
114 | min_length=1,
115 | )
116 | address_province = forms.CharField(
117 | label='Province/State',
118 | max_length=128,
119 | min_length=1,
120 | )
121 | address_postcode = forms.CharField(
122 | label='Postcode',
123 | max_length=16,
124 | required=False,
125 | )
126 | address_country = forms.CharField(
127 | label='Country',
128 | max_length=128,
129 | min_length=1,
130 | )
131 |
132 |
133 | class SubscriptionPlanCostForm(forms.Form):
134 | """Form to handle choosing a subscription plan for payment."""
135 | plan_cost = forms.UUIDField(
136 | label='',
137 | widget=forms.RadioSelect(),
138 | )
139 |
140 | def __init__(self, *args, **kwargs):
141 | """Overrides the plan_cost widget with available selections.
142 |
143 | For a provided subscription plan, provides a widget that
144 | lists all possible plan costs for selection.
145 |
146 | Keyword Arguments:
147 | subscription_plan (obj): A SubscriptionPlan instance.
148 | """
149 | costs = kwargs.pop('subscription_plan').costs.all()
150 | PLAN_COST_CHOICES = []
151 |
152 | for cost in costs:
153 | radio_text = '{} {}'.format(
154 | SETTINGS['currency'].format_currency(cost.cost),
155 | cost.display_billing_frequency_text
156 | )
157 | PLAN_COST_CHOICES.append((cost.id, radio_text))
158 |
159 | super().__init__(*args, **kwargs)
160 |
161 | # Update the radio widget with proper choices
162 | self.fields['plan_cost'].widget.choices = PLAN_COST_CHOICES
163 |
164 | # Set the last value as the default
165 | self.fields['plan_cost'].initial = [PLAN_COST_CHOICES[-1][0]]
166 |
167 | def clean_plan_cost(self):
168 | """Validates that UUID is valid and returns model instance."""
169 | try:
170 | data = PlanCost.objects.get(id=self.cleaned_data['plan_cost'])
171 | except PlanCost.DoesNotExist as error:
172 | raise forms.ValidationError('Invalid plan cost submitted.') from error
173 |
174 | return data
175 |
--------------------------------------------------------------------------------
/tests/subscriptions/test_conf.py:
--------------------------------------------------------------------------------
1 | """Tests for the conf module."""
2 | from django.conf import settings
3 | from django.core.exceptions import ImproperlyConfigured
4 | from django.test import override_settings
5 |
6 | from subscriptions import conf
7 |
8 |
9 | def test__string_to_module_and_class__one_period():
10 | """Tests handling of string with single period."""
11 | string = 'a.b'
12 | components = conf.string_to_module_and_class(string)
13 |
14 | assert components['module'] == 'a'
15 | assert components['class'] == 'b'
16 |
17 |
18 | def test__string_to_module_and_class__two_periods():
19 | """Tests handling of string with more than one period."""
20 | string = 'a.b.c'
21 | components = conf.string_to_module_and_class(string)
22 |
23 | assert components['module'] == 'a.b'
24 | assert components['class'] == 'c'
25 |
26 |
27 | def test__validate_currency_settings__valid_str():
28 | """Confirms no error when valid currency_locale string provided."""
29 | try:
30 | conf.validate_currency_settings('en_us')
31 | except ImproperlyConfigured:
32 | assert False
33 | else:
34 | assert True
35 |
36 |
37 | def test__validate_currency_settings__invalid_str():
38 | """Confirms error when invalid currency_locale string provided."""
39 | try:
40 | conf.validate_currency_settings('1')
41 | except ImproperlyConfigured as error:
42 | assert str(error) == '1 is not a support DFS_CURRENCY_LOCALE value.'
43 | else:
44 | assert False
45 |
46 |
47 | def test__validate_currency_settings__valid_dict():
48 | """Confirms no error when valid currency_locale string provided."""
49 | try:
50 | conf.validate_currency_settings({})
51 | except ImproperlyConfigured:
52 | assert False
53 | else:
54 | assert True
55 |
56 |
57 | def test__validate_currency_settings__invalid_type():
58 | """Confirms error when invalid currency_locale string provided."""
59 | try:
60 | conf.validate_currency_settings(True)
61 | except TypeError as error:
62 | assert str(error) == (
63 | "Invalid DFS_CURRENCY_LOCALE type: . Must be str or dict."
64 | )
65 | else:
66 | assert False
67 |
68 |
69 | @override_settings(
70 | DFS_CURRENCY='en_us',
71 | )
72 | def test__determine_currency_settings__dfs_currency_declared():
73 | """Confirms handling when DFS_CURRENCY declared."""
74 | # Clear any conflicting settings already provided
75 | del settings.DFS_CURRENCY_LOCALE
76 |
77 | currency_object = conf.determine_currency_settings()
78 |
79 | # Confirm a currency object was returned
80 | assert currency_object.locale == 'en_us'
81 |
82 |
83 | @override_settings(
84 | DFS_CURRENCY_LOCALE={},
85 | )
86 | def test__determine_currency_settings__dfs_currency_locale_declared(recwarn):
87 | """Confirms handling when DFS_CURRENCY_LOCALE declared."""
88 | # Clear any conflicting settings already provided
89 | del settings.DFS_CURRENCY
90 |
91 | currency_object = conf.determine_currency_settings()
92 |
93 | # Confirm a currency object was returned
94 | assert currency_object.locale == 'custom'
95 |
96 | # Confirm DeprecationWarning was raised
97 | currency_warning = recwarn.pop(DeprecationWarning)
98 | assert issubclass(currency_warning.category, DeprecationWarning)
99 |
100 | warning_text = (
101 | 'DFS_CURRENCY_LOCALE is deprecated and has been replaced by '
102 | 'DFS_CURRENCY. DFS_CURRENCY_LOCALE will be removed in a '
103 | 'future version of django-flexible-subscription.'
104 | )
105 | assert str(currency_warning.message) == warning_text
106 |
107 |
108 | @override_settings()
109 | def test__determine_currency_settings__not_declared():
110 | """Confirms handling when currency is not declared."""
111 | # Clear any settings already provided
112 | del settings.DFS_CURRENCY
113 | del settings.DFS_CURRENCY_LOCALE
114 |
115 | currency_object = conf.determine_currency_settings()
116 |
117 | # Confirm the default currency object was returned
118 | assert currency_object.locale == 'en_us'
119 |
120 |
121 | @override_settings(
122 | DFS_ENABLE_ADMIN=1,
123 | DFS_CURRENCY='en_us',
124 | DFS_BASE_TEMPLATE='3',
125 | DFS_SUBSCRIBE_VIEW='a.b',
126 | DFS_MANAGER_CLASS='a.b',
127 | )
128 | def test__compile_settings__assigned_properly():
129 | """Tests that Django settings all proper populate SETTINGS."""
130 | subscription_settings = conf.compile_settings()
131 |
132 | assert len(subscription_settings) == 5
133 | assert subscription_settings['enable_admin'] == 1
134 | assert subscription_settings['currency'].locale == 'en_us'
135 | assert subscription_settings['base_template'] == '3'
136 | assert subscription_settings['subscribe_view']['module'] == 'a'
137 | assert subscription_settings['subscribe_view']['class'] == 'b'
138 | assert subscription_settings['management_manager']['module'] == 'a'
139 | assert subscription_settings['management_manager']['class'] == 'b'
140 |
141 |
142 | @override_settings()
143 | def test__compile_settings__defaults():
144 | """Tests that SETTINGS adds all defaults properly."""
145 | # Clear any settings already provided
146 | del settings.DFS_ENABLE_ADMIN
147 | del settings.DFS_CURRENCY
148 | del settings.DFS_BASE_TEMPLATE
149 | del settings.DFS_SUBSCRIBE_VIEW
150 | del settings.DFS_MANAGER_CLASS
151 |
152 | subscription_settings = conf.compile_settings()
153 |
154 | assert len(subscription_settings) == 5
155 | assert subscription_settings['enable_admin'] is False
156 | assert subscription_settings['currency'].locale == 'en_us'
157 | assert subscription_settings['base_template'] == 'subscriptions/base.html'
158 | assert subscription_settings['subscribe_view']['module'] == (
159 | 'subscriptions.views'
160 | )
161 | assert subscription_settings['subscribe_view']['class'] == (
162 | 'SubscribeView'
163 | )
164 | assert subscription_settings['management_manager']['module'] == (
165 | 'subscriptions.management.commands._manager'
166 | )
167 | assert subscription_settings['management_manager']['class'] == (
168 | 'Manager'
169 | )
170 |
--------------------------------------------------------------------------------
/tests/subscriptions/test_views_transaction.py:
--------------------------------------------------------------------------------
1 | """Tests for the django-flexible-subscriptions PlanTag views."""
2 | from decimal import Decimal
3 | import pytest
4 |
5 | from django.contrib.auth.models import Permission
6 | from django.contrib.contenttypes.models import ContentType
7 | from django.urls import reverse
8 | from django.utils import timezone
9 |
10 | from subscriptions import models
11 |
12 |
13 | def create_plan(plan_name='1', plan_description='2'):
14 | """Creates and returns SubscriptionPlan instance."""
15 | return models.SubscriptionPlan.objects.create(
16 | plan_name=plan_name, plan_description=plan_description
17 | )
18 |
19 |
20 | def create_cost(plan=None, period=1, unit=models.MONTH, cost='1.00'):
21 | """Creates and returns PlanCost instance."""
22 | return models.PlanCost.objects.create(
23 | plan=plan, recurrence_period=period, recurrence_unit=unit, cost=cost
24 | )
25 |
26 |
27 | def create_transaction(user, cost, amount='1.00'):
28 | """Creates and returns a PlanTag instance."""
29 | return models.SubscriptionTransaction.objects.create(
30 | user=user,
31 | subscription=cost,
32 | date_transaction=timezone.now(),
33 | amount=amount,
34 | )
35 |
36 |
37 | # TransactionListView
38 | # -----------------------------------------------------------------------------
39 | @pytest.mark.django_db
40 | def test_transaction_list_template(admin_client):
41 | """Tests for proper transaction_list template."""
42 | response = admin_client.get(reverse('dfs_transaction_list'))
43 |
44 | assert 'subscriptions/transaction_list.html' in [
45 | t.name for t in response.templates
46 | ]
47 |
48 |
49 | @pytest.mark.django_db
50 | def test_transaction_list_403_if_not_authorized(client, django_user_model):
51 | """Tests for 403 error for tag list if inadequate permissions."""
52 | django_user_model.objects.create_user(username='user', password='password')
53 | client.login(username='user', password='password')
54 |
55 | response = client.get(reverse('dfs_tag_list'))
56 |
57 | assert response.status_code == 403
58 |
59 |
60 | @pytest.mark.django_db
61 | def test_transaction_list_200_if_authorized(client, django_user_model):
62 | """Tests 200 response for transaction list with adequate permissions."""
63 | # Retrieve proper permission, add to user, and login
64 | content = ContentType.objects.get_for_model(models.SubscriptionPlan)
65 | permission = Permission.objects.get(
66 | content_type=content, codename='subscriptions'
67 | )
68 | user = django_user_model.objects.create_user(
69 | username='user', password='password'
70 | )
71 | user.user_permissions.add(permission)
72 | client.login(username='user', password='password')
73 |
74 | response = client.get(reverse('dfs_tag_list'))
75 |
76 | assert response.status_code == 200
77 |
78 |
79 | @pytest.mark.django_db
80 | def test_transaction_list_retrives_all(admin_client, django_user_model):
81 | """Tests that the list view retrieves all the transactions."""
82 | # Create transactions to retrieve
83 | user = django_user_model.objects.create_user(username='a', password='b')
84 | cost = create_cost(plan=create_plan())
85 | create_transaction(user, cost, '1.00')
86 | create_transaction(user, cost, '2.00')
87 | create_transaction(user, cost, '3.00')
88 |
89 | response = admin_client.get(reverse('dfs_transaction_list'))
90 |
91 | assert len(response.context['transactions']) == 3
92 | assert response.context['transactions'][0].amount == Decimal('1.0000')
93 | assert response.context['transactions'][1].amount == Decimal('2.0000')
94 | assert response.context['transactions'][2].amount == Decimal('3.0000')
95 |
96 |
97 | # TransactionDetailView
98 | # -----------------------------------------------------------------------------
99 | @pytest.mark.django_db
100 | def test_transaction_detail_template(admin_client, django_user_model):
101 | """Tests for proper transaction_detail template."""
102 | user = django_user_model.objects.create_user(username='a', password='b')
103 | cost = create_cost(plan=create_plan())
104 | transaction = create_transaction(user, cost)
105 |
106 | response = admin_client.get(
107 | reverse(
108 | 'dfs_transaction_detail',
109 | kwargs={'transaction_id': transaction.id}
110 | )
111 | )
112 |
113 | assert 'subscriptions/transaction_detail.html' in [
114 | t.name for t in response.templates
115 | ]
116 |
117 |
118 | @pytest.mark.django_db
119 | def test_transaction_detail_403_if_not_authorized(client, django_user_model):
120 | """Tests 403 error for transaction detail if inadequate permissions."""
121 | user = django_user_model.objects.create_user(username='a', password='b')
122 | cost = create_cost(plan=create_plan())
123 | transaction = create_transaction(user, cost)
124 |
125 | django_user_model.objects.create_user(username='user', password='password')
126 | client.login(username='user', password='password')
127 |
128 | response = client.get(
129 | reverse(
130 | 'dfs_transaction_detail',
131 | kwargs={'transaction_id': transaction.id}
132 | )
133 | )
134 |
135 | assert response.status_code == 403
136 |
137 |
138 | @pytest.mark.django_db
139 | def test_transaction_detail_200_if_authorized(client, django_user_model):
140 | """Tests 200 response for transaction detail with adequate permissions."""
141 | user = django_user_model.objects.create_user(username='a', password='b')
142 | cost = create_cost(plan=create_plan())
143 | transaction = create_transaction(user, cost)
144 |
145 | # Retrieve proper permission, add to user, and login
146 | content = ContentType.objects.get_for_model(models.SubscriptionPlan)
147 | permission = Permission.objects.get(
148 | content_type=content, codename='subscriptions'
149 | )
150 | user = django_user_model.objects.create_user(
151 | username='user', password='password'
152 | )
153 | user.user_permissions.add(permission)
154 | client.login(username='user', password='password')
155 |
156 | response = client.get(
157 | reverse(
158 | 'dfs_transaction_detail',
159 | kwargs={'transaction_id': transaction.id}
160 | )
161 | )
162 |
163 | assert response.status_code == 200
164 |
--------------------------------------------------------------------------------
/tests/factories.py:
--------------------------------------------------------------------------------
1 | """Factories to create Subscription Plans."""
2 | # pylint: disable=unnecessary-lambda
3 | from datetime import datetime
4 |
5 | import factory
6 |
7 | from django.contrib.auth import get_user_model
8 |
9 | from subscriptions import models
10 |
11 |
12 | class PlanCostFactory(factory.django.DjangoModelFactory):
13 | """Factory to create PlanCost model instance."""
14 | recurrence_period = factory.Sequence(lambda n: int(n))
15 | recurrence_unit = models.DAY
16 | cost = factory.Sequence(lambda n: int(n))
17 |
18 | class Meta:
19 | model = models.PlanCost
20 |
21 |
22 | class SubscriptionPlanFactory(factory.django.DjangoModelFactory):
23 | """Factory to create SubscriptionPlan and PlanCost models."""
24 | plan_name = factory.Sequence(lambda n: 'Plan {}'.format(n))
25 | plan_description = factory.Sequence(lambda n: 'Description {}'.format(n))
26 | grace_period = factory.sequence(lambda n: int(n))
27 | cost = factory.RelatedFactory(PlanCostFactory, 'plan')
28 |
29 | class Meta:
30 | model = models.SubscriptionPlan
31 |
32 |
33 | class PlanListDetailFactory(factory.django.DjangoModelFactory):
34 | """Factory to create a PlanListDetail and related SubscriptionPlan."""
35 | html_content = factory.Sequence(lambda n: '{}'.format(n))
36 | subscribe_button_text = factory.Sequence(lambda n: 'Button {}'.format(n))
37 | order = factory.Sequence(lambda n: int(n))
38 |
39 | class Meta:
40 | model = models.PlanListDetail
41 |
42 |
43 | class PlanListFactory(factory.django.DjangoModelFactory):
44 | """Factory to create a PlanList and all related models."""
45 | title = factory.Sequence(lambda n: 'Plan List {}'.format(n))
46 | subtitle = factory.Sequence(lambda n: 'Subtitle {}'.format(n))
47 | header = factory.Sequence(lambda n: 'Header {}'.format(n))
48 | footer = factory.Sequence(lambda n: 'Footer {}'.format(n))
49 | active = True
50 |
51 | class Meta:
52 | model = models.PlanList
53 |
54 |
55 | class UserFactory(factory.django.DjangoModelFactory):
56 | """Creates a user model instance."""
57 | username = factory.Sequence(lambda n: 'User {}'.format(n))
58 | email = factory.Sequence(lambda n: 'user_{}@email.com'.format(n))
59 | password = 'password'
60 |
61 | class Meta:
62 | model = get_user_model()
63 |
64 | @classmethod
65 | def _create(cls, model_class, *args, **kwargs):
66 | """Override _create to use the create_user method."""
67 | manager = cls._get_manager(model_class)
68 |
69 | return manager.create_user(*args, **kwargs)
70 |
71 |
72 | class DFS:
73 | """Object to manage various model instances as needed."""
74 | def __init__(self):
75 | self._plan_list = None
76 | self._plan_list_detail = None
77 | self._plan = None
78 | self._cost = None
79 | self._subscription = None
80 | self._user = None
81 |
82 | @property
83 | def plan_list(self):
84 | """Returns the plan list instance."""
85 | # Create a PlanList instance if needed
86 | if self._plan_list:
87 | return self._plan_list
88 |
89 | # Create the PlanList instance
90 | self._plan_list = PlanListFactory()
91 |
92 | # Create the Subscription Plans
93 | plan_1 = SubscriptionPlanFactory()
94 | plan_2 = SubscriptionPlanFactory()
95 | plan_3 = SubscriptionPlanFactory()
96 |
97 | # Create the PlanList Details
98 | detail = PlanListDetailFactory(plan_list=self._plan_list, plan=plan_1)
99 | PlanListDetailFactory(plan_list=self._plan_list, plan=plan_2)
100 | PlanListDetailFactory(plan_list=self._plan_list, plan=plan_3)
101 |
102 | # Update the object attributes
103 | self._plan_list_detail = detail
104 | self._plan = plan_1
105 | self._cost = plan_1.costs.first()
106 |
107 | return self._plan_list
108 |
109 | @property
110 | def plan_list_detail(self):
111 | """Creates a PlanListDetail instance and associated models."""
112 | if self._plan_list_detail:
113 | return self._plan_list_detail
114 |
115 | # Create the model references
116 | self._plan_list_detail = PlanListDetailFactory()
117 | plan = SubscriptionPlanFactory()
118 |
119 | # pylint: disable=attribute-defined-outside-init
120 | self._plan_list_detail.plan = plan
121 | self._plan_list_detail.save()
122 |
123 | # Update the object attributes
124 | self._plan = plan
125 | self._cost = plan.costs.first()
126 |
127 | return self._plan_list_detail
128 |
129 | @property
130 | def plan(self):
131 | """Creates a SubscriptionPlan instance and associated models."""
132 | if self._plan:
133 | return self._plan
134 |
135 | self._plan = SubscriptionPlanFactory()
136 |
137 | # Update the object attributes
138 | self._cost = self._plan.costs.first()
139 |
140 | return self._plan
141 |
142 | @property
143 | def cost(self):
144 | """Creates a Cost instance and associated models."""
145 | if self._cost:
146 | return self._cost
147 |
148 | # Create a plan instance to retrieve the cost from
149 | self._plan # pylint: disable=pointless-statement
150 | self._cost = self._plan.costs.first()
151 |
152 | return self._cost
153 |
154 | @property
155 | def user(self):
156 | """Returns the user instance."""
157 | if not self._user:
158 | self._user = UserFactory()
159 |
160 | return self._user
161 |
162 | @property
163 | def subscription(self):
164 | """Returns a UserSubscription instance."""
165 | if self._subscription:
166 | return self._subscription
167 |
168 | # Create user if needed
169 | if not self._user:
170 | self.user # pylint: disable=pointless-statement
171 |
172 | # Create PlanCost if needed
173 | if not self._cost:
174 | self.plan # pylint: disable=pointless-statement
175 |
176 | self._subscription = models.UserSubscription.objects.create(
177 | user=self._user,
178 | subscription=self._cost,
179 | date_billing_start=datetime(2018, 1, 1, 1, 1, 1),
180 | date_billing_end=None,
181 | date_billing_last=datetime(2018, 1, 1, 1, 1, 1),
182 | date_billing_next=datetime(2018, 2, 1, 1, 1, 1),
183 | active=True,
184 | cancelled=False,
185 | )
186 |
187 | return self._subscription
188 |
--------------------------------------------------------------------------------
/docs/contributing.rst:
--------------------------------------------------------------------------------
1 | ============
2 | Contributing
3 | ============
4 |
5 | Contributions or forking of the project is always welcome. Below will
6 | provide a quick outline of how to get setup and things to be aware of
7 | when contributing.
8 |
9 | ----------------
10 | Reporting issues
11 | ----------------
12 |
13 | If you simply want to report an issue, you can use the
14 | `GitHub Issue page`_.
15 |
16 | .. _GitHub Issue page: https://github.com/studybuffalo/django-flexible-subscriptions/issues
17 |
18 | --------------------------------------
19 | Setting up the development environment
20 | --------------------------------------
21 |
22 | This package is built using Pipenv_, which will take care of both
23 | your virtual environment and package management. If needed, you can
24 | install ``pipenv`` through ``pip``::
25 |
26 | $ pip install pipenv
27 |
28 | .. _Pipenv: https://pipenv.readthedocs.io/en/latest/
29 |
30 | To download the repository from GitHub via ``git``::
31 |
32 | $ git clone git://github.com/studybuffalo/django-flexible-subscriptions.git
33 |
34 | You can then install all the required dependencies by changing to the
35 | package directory and installing from ``Pipfile.lock``::
36 |
37 | $ cd django-flexible-subscriptions
38 | $ pipenv install --ignore-pipfile --dev
39 |
40 | Finally, you will need to build the package::
41 |
42 | $ pipenv run python setup.py develop
43 |
44 | You should now have a working environment that you can use to run tests
45 | and setup the sandbox demo.
46 |
47 | -------
48 | Testing
49 | -------
50 |
51 | All pull requests must have unit tests built and must maintain
52 | or increase code coverage. The ultimate goal is to achieve a code
53 | coverage of 100%. While this may result in some superfluous tests,
54 | it sets a good minimum baseline for test construction.
55 |
56 | Testing format
57 | ==============
58 |
59 | All tests are built with the `pytest framework`_
60 | (and `pytest-django`_ for Django-specific components). There are no
61 | specific requirements on number or scope of tests, but at a bare
62 | minimum there should be tests to cover all common use cases. Wherever
63 | possible, try to test the smallest component possible.
64 |
65 | .. _pytest framework: https://docs.pytest.org/en/latest/
66 |
67 | .. _pytest-django: https://pytest-django.readthedocs.io/en/latest/
68 |
69 | Running Tests
70 | =============
71 |
72 | You can run all tests with the standard ``pytest`` command::
73 |
74 | $ pipenv run py.test
75 |
76 | To check test coverage, you can use the following::
77 |
78 | $ pipenv run py.test --cov=subscriptions --cov-report=html
79 |
80 | You may specify the output of the coverage report by changing the
81 | ``--cov-report`` option to ``html`` or ``xml``.
82 |
83 | Running Linters
84 | ===============
85 |
86 | This package makes use of two linters to improve code quality:
87 | `Pylint`_ and `pycodestyle`_. Any GitHub pull requests must pass all
88 | Linter requirements before they will be accepted.
89 |
90 | .. _Pylint: https://pylint.org/
91 |
92 | .. _pycodestyle: https://pypi.org/project/pycodestyle/
93 |
94 | You may run the linters within your IDE/editor or with the following
95 | commands::
96 |
97 | $ pipenv run pylint subscriptions/ sandbox/
98 | $ pipenv run pylint tests/ --min-similarity-lines=12
99 | $ pipenv run pycodestyle --show-source subscriptions/ sandbox/ tests/
100 |
101 | Of note, tests have relaxed rules for duplicate code warnings. This is
102 | to minimize the level of abstraction that occurs within the tests with
103 | the intent to improve readability.
104 |
105 | ----------------------
106 | Updating documentation
107 | ----------------------
108 |
109 | All documentation is hosted on `Read the Docs`_ and is built using
110 | Sphinx_. All the module content is automatically built from the
111 | docstrings and the `sphinx-apidoc`_ tool and the
112 | `sphinxcontrib-napoleon`_ extension.
113 |
114 | .. _Read the Docs: https://readthedocs.org/
115 | .. _Sphinx: http://www.sphinx-doc.org/en/master/
116 | .. _sphinx-apidoc: http://www.sphinx-doc.org/en/stable/man/sphinx-apidoc.html
117 | .. _sphinxcontrib-napoleon: https://sphinxcontrib-napoleon.readthedocs.io/en/latest/
118 |
119 | Docstring Format
120 | ================
121 |
122 | The docstrings of this package follow the `Google Python Style Guide`_
123 | wherever possible. This ensures proper formatting of the documentation
124 | generated automatically by Sphinx. Additional examples can be found on
125 | the `Sphinx napoleon extension documentation`_.
126 |
127 | .. _Google Python Style Guide: https://github.com/google/styleguide/blob/gh-pages/pyguide.md
128 | .. _Sphinx napoleon extension documentation: https://sphinxcontrib-napoleon.readthedocs.io/en/latest/
129 |
130 | Building package reference documentation
131 | ========================================
132 |
133 | The content for the Package reference is built using the
134 | ``sphinx-apidoc`` tool. If any files are added or removed from the
135 | ``subscriptions`` module you will need to rebuild the
136 | ``subscriptions.rst`` file for the changes to populate on Read
137 | the Docs. You can do this with the following command::
138 |
139 | $ pipenv run sphinx-apidoc -fTM -o docs subscriptions subscriptions/migrations subscriptions/urls.py subscriptions/apps.py subscriptions/admin.py
140 |
141 | Linting documentation
142 | =====================
143 |
144 | If you are having issues with the ReStructuredText (reST) formatting,
145 | you can use ``rst-lint`` to screen for syntax errors. You can run a
146 | check on a file with the following::
147 |
148 | $ pipenv run rst-lint /path/to/file.rst
149 |
150 | --------------------
151 | Distributing package
152 | --------------------
153 |
154 | Django Flexible Subscriptions is designed to be distributed with PyPI.
155 | While most contributors will not need to worry about uploading to PyPI,
156 | the following instructions list the general process in case anyone
157 | wishes to fork the repository or test out the process.
158 |
159 | .. note::
160 |
161 | It is recommended you use `TestPyPI`_ to test uploading your
162 | distribution while you are learning and seeing how things work. The
163 | following examples below will use TestPyPI as the upload target.
164 |
165 | .. _TestPyPI: https://test.pypi.org/
166 |
167 | To generate source archives and built distributions, you can use the
168 | following::
169 |
170 | $ pipenv run python setup.py sdist bdist_wheel
171 |
172 | To upload the distributions, you can use the following ``twine``
173 | commands::
174 |
175 | $ pipenv run twine upload --repository-url https://test.pypi.org/legacy/ dist/*
176 |
177 | You will need to provide a PyPI username and password before the upload
178 | will start.
179 |
--------------------------------------------------------------------------------
/subscriptions/management/commands/_manager.py:
--------------------------------------------------------------------------------
1 | """Utility/helper functions for Django Flexible Subscriptions."""
2 | from django.db.models import Q
3 | from django.utils import timezone
4 |
5 | from subscriptions import models
6 |
7 |
8 | class Manager():
9 | """Manager object to help manage subscriptions & billing."""
10 |
11 | def process_subscriptions(self):
12 | """Calls all required subscription processing functions."""
13 | current = timezone.now()
14 |
15 | # Handle expired subscriptions
16 | expired_subscriptions = models.UserSubscription.objects.filter(
17 | Q(active=True) & Q(cancelled=False)
18 | & Q(date_billing_end__lte=current)
19 | )
20 |
21 | for subscription in expired_subscriptions:
22 | self.process_expired(subscription)
23 |
24 | # Handle new subscriptions
25 | new_subscriptions = models.UserSubscription.objects.filter(
26 | Q(active=False) & Q(cancelled=False)
27 | & Q(date_billing_start__lte=current)
28 | )
29 |
30 | for subscription in new_subscriptions:
31 | self.process_new(subscription)
32 |
33 | # Handle subscriptions with billing due
34 | due_subscriptions = models.UserSubscription.objects.filter(
35 | Q(active=True) & Q(cancelled=False)
36 | & Q(date_billing_next__lte=current)
37 | )
38 |
39 | for subscription in due_subscriptions:
40 | self.process_due(subscription)
41 |
42 | def process_expired(self, subscription):
43 | """Handles processing of expired/cancelled subscriptions.
44 |
45 | Parameters:
46 | subscription (obj): A UserSubscription instance.
47 | """
48 | # Get all user subscriptions
49 | user = subscription.user
50 | user_subscriptions = user.subscriptions.all()
51 | subscription_group = subscription.subscription.plan.group
52 | group_matches = 0
53 |
54 | # Check if there is another subscription for this group
55 | for user_subscription in user_subscriptions:
56 | if user_subscription.subscription.plan.group == subscription_group:
57 | group_matches += 1
58 |
59 | # If no other subscription, can remove user from group
60 | if group_matches < 2:
61 | subscription_group.user_set.remove(user)
62 |
63 | # Update this specific UserSubscription instance
64 | subscription.active = False
65 | subscription.cancelled = True
66 | subscription.save()
67 |
68 | self.notify_expired(subscription)
69 |
70 | def process_new(self, subscription):
71 | """Handles processing of a new subscription.
72 |
73 | Parameters:
74 | subscription (obj): A UserSubscription instance.
75 | """
76 | user = subscription.user
77 | cost = subscription.subscription
78 | plan = cost.plan
79 |
80 | payment_transaction = self.process_payment(user=user, cost=cost)
81 |
82 | if payment_transaction:
83 | # Add user to the proper group
84 | try:
85 | plan.group.user_set.add(user)
86 | except AttributeError:
87 | # No group available to add user to
88 | pass
89 |
90 | # Update subscription details
91 | current = timezone.now()
92 | next_billing = cost.next_billing_datetime(
93 | subscription.date_billing_start
94 | )
95 | subscription.date_billing_last = current
96 | subscription.date_billing_next = next_billing
97 | subscription.active = True
98 | subscription.save()
99 |
100 | # Record the transaction details
101 | self.record_transaction(
102 | subscription,
103 | self.retrieve_transaction_date(payment_transaction)
104 | )
105 |
106 | # Send notifications
107 | self.notify_new(subscription)
108 |
109 | def process_due(self, subscription):
110 | """Handles processing of a due subscription.
111 |
112 | Parameters:
113 | subscription (obj): A UserSubscription instance.
114 | """
115 | user = subscription.user
116 | cost = subscription.subscription
117 |
118 | payment_transaction = self.process_payment(user=user, cost=cost)
119 |
120 | if payment_transaction:
121 | # Update subscription details
122 | current = timezone.now()
123 | next_billing = cost.next_billing_datetime(
124 | subscription.date_billing_next
125 | )
126 | subscription.date_billing_last = current
127 | subscription.date_billing_next = next_billing
128 | subscription.save()
129 |
130 | # Record the transaction details
131 | self.record_transaction(
132 | subscription,
133 | self.retrieve_transaction_date(payment_transaction)
134 | )
135 |
136 | def process_payment(self, *args, **kwargs): # pylint: disable=unused-argument, no-self-use
137 | """Processes payment and confirms if payment is accepted.
138 |
139 | This method needs to be overriden in a project to handle
140 | payment processing with the appropriate payment provider.
141 |
142 | Can return value that evalutes to ``True`` to indicate
143 | payment success and any value that evalutes to ``False`` to
144 | indicate payment error.
145 | """
146 | return True
147 |
148 | def retrieve_transaction_date(self, payment): # pylint: disable=unused-argument, no-self-use
149 | """Returns the transaction date from provided payment details.
150 |
151 | Method should be overriden to accomodate the implemented
152 | payment processing if a more accurate datetime is required.
153 |
154 |
155 | Returns
156 | obj: The current datetime.
157 | """
158 | return timezone.now()
159 |
160 | @staticmethod
161 | def record_transaction(subscription, transaction_date=None):
162 | """Records transaction details in SubscriptionTransaction.
163 |
164 | Parameters:
165 | subscription (obj): A UserSubscription object.
166 | transaction_date (obj): A DateTime object of when
167 | payment occurred (defaults to current datetime if
168 | none provided).
169 |
170 | Returns:
171 | obj: The created SubscriptionTransaction instance.
172 | """
173 | if transaction_date is None:
174 | transaction_date = timezone.now()
175 |
176 | return models.SubscriptionTransaction.objects.create(
177 | user=subscription.user,
178 | subscription=subscription.subscription,
179 | date_transaction=transaction_date,
180 | amount=subscription.subscription.cost,
181 | )
182 |
183 | def notify_expired(self, subscription):
184 | """Sends notification of expired subscription.
185 |
186 | Parameters:
187 | subscription (obj): A UserSubscription instance.
188 | """
189 |
190 | def notify_new(self, subscription):
191 | """Sends notification of newly active subscription
192 |
193 | Parameters:
194 | subscription (obj): A UserSubscription instance.
195 | """
196 |
197 | def notify_payment_error(self, subscription):
198 | """Sends notification of a payment error
199 |
200 | Parameters:
201 | subscription (obj): A UserSubscription instance.
202 | """
203 |
204 | def notify_payment_success(self, subscription):
205 | """Sends notifiation of a payment success
206 |
207 | Parameters:
208 | subscription (obj): A UserSubscription instance.
209 | """
210 |
--------------------------------------------------------------------------------
/docs/advanced.rst:
--------------------------------------------------------------------------------
1 | ==============
2 | Advanced usage
3 | ==============
4 |
5 | -----------------------------
6 | Changing styles and templates
7 | -----------------------------
8 |
9 | It is possible to override any component of the user interface by
10 | overriding the style file or the templates. To override a file, simply
11 | create a file with the same path noted in the list below.
12 |
13 | It is also possible to setup your ``django-flexible-subscriptions``
14 | to use a base template already in your project via your settings. See
15 | the :doc:`settings` section for more details.
16 |
17 | * **Core Files and Templates**
18 |
19 | * ``static/subscriptions/styles.css``
20 | (controls all template styles)
21 | * ``templates/subscriptions/base.html``
22 | (base template that all templates inherit from)
23 | * ``templates/subscriptions/subscribe_list.html``
24 | (user-facing; list and sign up for subscription plans)
25 | * ``templates/subscriptions/subscribe_preview.html``
26 | (user-facing; preview of subscription plan signup)
27 | * ``templates/subscriptions/subscribe_confirmation.html``
28 | (user-facing; confirmation of subscription plan signup)
29 | * ``templates/subscriptions/subscribe_thank_you.html``
30 | (user-facing; thank you page on successful subscription plan
31 | singup)
32 | * ``templates/subscriptions/subscribe_user_list.html``
33 | (user-facing; list of a user's subscriptions)
34 | * ``templates/subscriptions/subscribe_cancel.html``
35 | (user-facing; confirm cancellation of subscription)
36 |
37 | * **Developer-Facing Templates**
38 |
39 | * ``templates/subscriptions/base_developer.html``
40 | (base template that all developer dashboard templates inherit from)
41 | * ``templates/subscriptions/dashboard.html``
42 | (developer-facing; dashboard template)
43 | * ``templates/subscriptions/plan_list.html``
44 | (developer-facing; list of all subscription plans)
45 | * ``templates/subscriptions/plan_create.html``
46 | (developer-facing; create subscription plan)
47 | * ``templates/subscriptions/plan_update.html``
48 | (developer-facing; update subscription plan)
49 | * ``templates/subscriptions/plan_delete.html``
50 | (developer-facing; delete subscription plan)
51 | * ``templates/subscriptions/plan_list_list.html``
52 | (developer-facing; list of all plan lists)
53 | * ``templates/subscriptions/plan_list_create.html``
54 | (developer-facing; create new plan list)
55 | * ``templates/subscriptions/plan_list_update.html``
56 | (developer-facing; update plan list)
57 | * ``templates/subscriptions/plan_list_delete.html``
58 | (developer-facing; delete plan list)
59 | * ``templates/subscriptions/plan_list_detail_list.html``
60 | (developer-facing; list of plan list details)
61 | * ``templates/subscriptions/plan_list_detail_create.html``
62 | (developer-facing; create new plan list detail)
63 | * ``templates/subscriptions/plan_list_detail_update.html``
64 | (developer-facing; update plan list detail)
65 | * ``templates/subscriptions/plan_list_detail_delete.html``
66 | (developer-facing; delete plan list detail)
67 | * ``templates/subscriptions/subscription_list.html``
68 | (developer-facing; list all user's subscription plans)
69 | * ``templates/subscriptions/subscription_create.html``
70 | (developer-facing; create new subscription plan for user)
71 | * ``templates/subscriptions/subscription_update.html``
72 | (developer-facing; update subscription plan for user)
73 | * ``templates/subscriptions/subscription_delete.html``
74 | (developer-facing; delete subscription plan for user)
75 | * ``templates/subscriptions/tag_list.html``
76 | (developer-facing; list of tags)
77 | * ``templates/subscriptions/tag_create.html``
78 | (developer-facing; create new tag)
79 | * ``templates/subscriptions/tag_update.html``
80 | (developer-facing; update tag)
81 | * ``templates/subscriptions/tag_delete.html``
82 | (developer-facing; delete tag)
83 | * ``templates/subscriptions/transaction_list.html``
84 | (developer-facing; list of transactions)
85 | * ``templates/subscriptions/tag_detail.html``
86 | (developer-facing; details of a single transaction)
87 |
88 | -----------------
89 | Adding a currency
90 | -----------------
91 |
92 | Currently currencies are controlled by the ``CURRENCY`` dictionary in
93 | the ``conf.py`` file. New currencies can be added by making a pull
94 | request with the desired details. A future update will allow specifying
95 | currencies in the settings file.
96 |
97 | -------------------------------------
98 | Customizing new subscription handling
99 | -------------------------------------
100 |
101 | All subscriptions are handled via the ``SubscribeView``. It is expected
102 | that most applications will will extend this view to implement some
103 | custom handling (e.g. payment processing). To extend this view:
104 |
105 | 1. Create a new view file (e.g. ``/custom/views.py``) and extend the
106 | ``Subscribe View``
107 |
108 | .. code-block:: python
109 |
110 | # /custom/views.py
111 | from subscriptions import views
112 |
113 | class CustomSubscriptionView(views.SubscriptionView):
114 | pass
115 |
116 | 2. Update your settings file to point to the new view:
117 |
118 | .. code-block:: python
119 |
120 | DFS_SUBSCRIBE_VIEW = custom.views.CustomSubscriptionView
121 |
122 | From here you can override any attributes or methods to implement
123 | custom handling. A list of all attributes and methods can be found
124 | in the :doc:`package reference`.
125 |
126 | Adding payment processing
127 | =========================
128 |
129 | To implement payment processing, you will likely want to override
130 | the ``process_payment`` method in ``SubscribeView`` (see
131 | `Customizing new subscription handling`_. This method is called when a
132 | user confirms payment. The request must pass validation of form
133 | specified in the ``payment_form`` attribute (defaults to
134 | ``PaymentForm``).
135 |
136 | You may also need to implement a custom ``PaymentForm`` if you require
137 | different fields or validation than the default provided in
138 | ``django-flexible-subscriptions``. You can do this by creating a new
139 | form and assigning it as value for the ``payment_form`` attribute of a
140 | custom ``SubscribeView``:
141 |
142 | 1. Create a new view file (e.g. ``/custom/forms.py``) and create a
143 | a Django form or extend the ``django-flexible-subscriptions``
144 | ``PaymentForm``:
145 |
146 | .. code-block:: python
147 |
148 | # /custom/forms.py
149 | from subscriptions.forms import PaymentForm
150 |
151 | class CustomPaymentForm(PaymentForm):
152 | pass
153 |
154 | 2. Update your custom ``SubscribeView`` to point to your new form:
155 |
156 | .. code-block:: python
157 |
158 | # custom/views.py
159 | from custom.forms import CustomPaymentForm
160 |
161 | class CustomSubscriptionView(views.SubscriptionView):
162 | payment_form = CustomPaymentForm
163 |
164 | Between the PaymentForm and the SubscribeView you should be able to
165 | implement most payment providers. The exact details will depend on the
166 | payment provider you implement and is out of the scope of this
167 | documentation.
168 |
169 | ----------------------------------
170 | Subscription renewals and expiries
171 | ----------------------------------
172 |
173 | The management of subscription renewals and expiries must be handled by
174 | a task manager. Below will demonstrate this using ``cron``, but any
175 | application with similar functionality should work.
176 |
177 | Extending the subscription manager
178 | ==================================
179 |
180 | First, you will likely need to customize the subscription manager. This
181 | is necessary to accomodate payment processing with the subscription
182 | renewal process. You can do this by extending the supplied
183 | ``Manager`` class. For example:
184 |
185 | 1. Create a custom ``Manager`` class:
186 |
187 | .. code-block:: python
188 |
189 | # custom/manager.py
190 | from subscriptions.management.commands import _manager
191 |
192 | CustomManager(_manager.Manager):
193 | process_payment(self, *args, **kwargs):
194 | # Implement your payment processing here
195 |
196 | 2. Update your settings to point to your custom manager:
197 |
198 | .. code-block:: python
199 |
200 | ...
201 | # settings.py
202 | DFS_MANAGER_CLASS = 'custom.manager.CustomManager'
203 | ...
204 |
205 | Running the subscription manager
206 | ================================
207 |
208 | Once the subscription manager is setup, you will simply need to call
209 | the management command at a regular interval of your choosing. This
210 | command can be called via:
211 |
212 | .. code-block:: shell
213 |
214 | $ pipenv run python manage.py process_subscriptions
215 | > Processing subscriptions... Complete!
216 |
217 | If you wanted to renew and expire subscriptions daily, you could use
218 | the following ``cron`` command:
219 |
220 | .. code-block:: cron
221 |
222 | # ┌ Minute (0-59)
223 | # | ┌ Hour (0-23)
224 | # | | ┌ Day of Month (1-31)
225 | # | | | ┌ Month (1-12)
226 | # | | | | ┌ Day of week (0-6)
227 | # | | | | | ┌ cron command
228 | # | | | | | |
229 | 0 0 * * * /path/to/pipenv/python manage.py process_subscriptions
230 |
231 | This could be implemented in other task runners in a similar fashion
232 | (e.g. Windows Task Scheduler, Celery).
233 |
--------------------------------------------------------------------------------
/docs/changelog.rst:
--------------------------------------------------------------------------------
1 | =========
2 | Changelog
3 | =========
4 |
5 | ----------------
6 | Version 0 (Beta)
7 | ----------------
8 |
9 | 0.15.1 (2020-Aug-10)
10 | ====================
11 |
12 | Bug Fixes
13 | ---------
14 |
15 | * Removing ``order_by`` command from the ``SubscriptionListView`` to
16 | prevent errors with customized user models.
17 |
18 | 0.15.0 (2020-Jul-22)
19 | ====================
20 |
21 | Feature Updates
22 | ---------------
23 |
24 | * ``DFS_CURRENCY_LOCALE`` setting being deprecated in place of
25 | ``DFS_CURRENCY``. This new setting allows either a language code
26 | ``str`` or a ``dict` of currency formatting conventions to be passed.
27 | This is then used for subsequent currency formatting operations.
28 | * Adding currency support for India (INR).
29 |
30 | 0.14.0 (2020-Jun-07)
31 | ====================
32 |
33 | Feature Updates
34 | ---------------
35 |
36 | * Dropping support for Django 1.11. Various aspects of the package have
37 | been updated to leverage Django 2.2 features now (e.g. ``path`` for
38 | URLs).
39 |
40 | 0.13.0 (2020-May-23)
41 | ====================
42 |
43 | Feature Updates
44 | ---------------
45 |
46 | * Adding currency support for Brazil (BRL).
47 |
48 | 0.12.1 (2020-May-07)
49 | ====================
50 |
51 | Bug Fixes
52 | ---------
53 |
54 | * Fixing issue with TransactionDetailView and TransactionListView where
55 | templates were referencing ``SubscriptionTransaction.plan`` rather
56 | than ``SubscriptionTransaction.subscription.plan``.
57 |
58 | 0.12.0 (2020-Apr-29)
59 | ====================
60 |
61 | Feature Updates
62 | ---------------
63 |
64 | * Adding currency support for France (EUR).
65 | * Adding currency support for Italy (EUR).
66 | * Adding currency support for Swiss Franc (CHF).
67 |
68 | 0.11.1 (2020-Apr-15)
69 | ====================
70 |
71 | Bug Fixes
72 | ---------
73 |
74 | * Fixed issue where management command files were not included in
75 | PyPI release.
76 |
77 | 0.11.0 (2020-Apr-04)
78 | ====================
79 |
80 | Feature Updates
81 | ---------------
82 |
83 | * Adding currency support for Poland (PLN).
84 |
85 | 0.10.0 (2020-Feb-16)
86 | ====================
87 |
88 | Feature Updates
89 | ---------------
90 |
91 | * Switching ``ugettext_lazy`` to ``gettext_lazy`` (this function is
92 | being depreciated in Django 4.0).
93 | * Adding a slug field to ``SubscriptionPlan``, ``PlanCost``, and
94 | ``PlanList`` models. This will make it easier to reference specific
95 | subscription details in custom views.
96 |
97 | 0.9.0 (2020-Jan-15)
98 | ===================
99 |
100 | Feature Updates
101 | ---------------
102 |
103 | * Adding currency support for (the Islamic Republic of) Iran.
104 |
105 | Bug Fixes
106 | ---------
107 |
108 | * Fixed issues where currency display could not handle non-decimal
109 | currencies.
110 |
111 | 0.8.1 (2019-Dec-25)
112 | ===================
113 |
114 | Feature Updates
115 | ---------------
116 |
117 | * Removes ``django-environ`` from development dependencies and switches
118 | functionality over to ``pathlib``.
119 |
120 | Bug Fixes
121 | ---------
122 |
123 | * Fixing bug with sandbox settings and Django 3.0 involving declaration
124 | of languages.
125 | * Fixed issue where the ``RecurrenceUnit`` of the ``PlanCost`` model
126 | was trying to generate migration due to a change in the default
127 | value.
128 |
129 | 0.8.0 (2019-Dec-15)
130 | ===================
131 |
132 | Feature Updates
133 | ---------------
134 |
135 | * Removing official support for Django 2.1 (has reach end of life).
136 | * Removing Tox from testing. Too many conflicting issues and CI system
137 | can handle this better now.
138 |
139 | 0.7.0 (2019-Dec-01)
140 | ===================
141 |
142 | Feature Updates
143 | ---------------
144 |
145 | * Switching ``PlanCost`` ``recurrence_unit`` to a CharField to make
146 | it more clear what the values represent.
147 | * Adding ``PlanCost`` as an InlineAdmin field of ``SubscriptionPlan``.
148 |
149 | 0.6.0 (2019-Aug-19)
150 | ===================
151 |
152 | Feature Updates
153 | ---------------
154 |
155 | * Integrating subscription management utility functions into Django
156 | management commands. Documentation has been updated to explain this
157 | functionality.
158 |
159 | 0.5.0 (2019-Aug-18)
160 | ===================
161 |
162 | Bug Fixes
163 | ---------
164 |
165 | * Fixed issues where last billing date and end billing date were not
166 | diplaying properly when cancelling a subscription.
167 | * Fixing the ``SubscribeUserList`` view to not show inactive
168 | subscriptions.
169 |
170 | Feature Updates
171 | ---------------
172 |
173 | * Improving styling for user-facing views and refactoring style sheet.
174 | * Adding support for German (Germany) locale (``de_de``).
175 |
176 | 0.4.2 (2019-Aug-07)
177 | ===================
178 |
179 | Bug Fixes
180 | ---------
181 |
182 | * Resolving issue where subscription form would generate errors on
183 | initial display.
184 | * Fixed bug where ``PlanList`` would display ``SubscriptionPlan``
185 | instances without associated `PlanCost` instances, resulting in
186 | errors on subscription order preview.
187 |
188 | Feature Updates
189 | ---------------
190 |
191 | * Streamlining the ``PlanList`` - ``PlanListDetail`` -
192 | ``SubscriptionPlan`` relationship to make relationships more apparent
193 | and easier to query.
194 | * Added ``FactoryBoy`` factories to help streamline future test
195 | writing.
196 | * Added validation of ``PlanCost`` ``UUID`` in the
197 | ``SubscriptionPlanCostForm`` to confirm a valid UUID is provided and
198 | return the object immediately.
199 | * Updated ``PaymentForm to include validation of credit card numbers
200 | and CVV numbers and switched expiry months and years to
201 | ``ChoiceField`` to ensure valid data collected.
202 |
203 | 0.4.1 (2019-Aug-05)
204 | ===================
205 |
206 | Bug Fixes
207 | ---------
208 |
209 | * Adding ``styles.css`` to package data.
210 |
211 | 0.4.0 (2019-Aug-05)
212 | ===================
213 |
214 | Feature Updates
215 | ---------------
216 |
217 | * Adding responsive styling to all base HTML templates.
218 | * Updating sandbox site to improve demo and testing functions.
219 | * Breaking more template components into snippets and adding base
220 | templates to make it easier to override pages.
221 | * Adding pagination to views to better handle long lists.
222 | * Adding support for Django 2.2
223 |
224 | 0.3.2 (2019-Jul-17)
225 | ===================
226 |
227 | Bug Fixes
228 | ---------
229 |
230 | * Bug fixes with settings, sandbox site, and admin pages.
231 |
232 |
233 | 0.3.1 (2019-Jul-02)
234 | ===================
235 |
236 | Feature Updates
237 | ---------------
238 |
239 | * Adding Australian Dollars to available currencies.
240 |
241 | 0.3.0 (2019-Jan-30)
242 | ===================
243 |
244 | Feature Updates
245 | ---------------
246 |
247 | * Creating ``PlanList`` model to record group of ``SubscriptionPlan``
248 | models to display on a single page for user selection.
249 | * Creating a view and template to display the the oldest active
250 | ``PlanList``.
251 |
252 | 0.2.1 (2018-Dec-29)
253 | ===================
254 |
255 | Bug Fixes
256 | ---------
257 |
258 | * Adding missing methods to ``SubscribeView`` and ``Manager`` to record
259 | payment transactions. Added additional method
260 | (``retrieve_transaction_date``) to help with transaction date
261 | specification. Reworked method calls around payment processing to
262 | streamline passing of arguments between functions to reduce need to
263 | override methods.
264 | * Fixing issue in ``Manager`` class where the future billing date was
265 | based off the current datetime, rather than the last billed datetime.
266 | * Adding method to update next billing datetimes for due subscriptions
267 | in the ``Manager`` class.
268 | * Switching the default ``success_url`` for ``SubscribeView`` and
269 | ``CancelView`` to the user-specific list of their subscriptions,
270 | rather than the subscription CRUD dashboard.
271 |
272 | 0.2.0 (2018-Dec-28)
273 | ===================
274 |
275 | Feature Updates
276 | ---------------
277 | * Switching arguments for the ``process_payment`` call to keyword
278 | arguments (``kwargs``).
279 | * Allow the ``SubscriptionView`` class to be specified in the settings
280 | file to make overriding easier.
281 |
282 | Bug Fixes
283 | ---------
284 |
285 | * Passing the PlanCostForm form into the process_payment call to
286 | allow access to the amount to bill.
287 |
288 | 0.1.1 (2018-Dec-28)
289 | ===================
290 |
291 | Bug Fixes
292 | ---------
293 |
294 | * Adding the ``snippets`` folder to the PyPI package - was not included
295 | in previous build.
296 |
297 | 0.1.0 (2018-Dec-26)
298 | ===================
299 |
300 | Feature Updates
301 | ---------------
302 |
303 | * Initial package release.
304 | * Allows creation of subscription plans with multiple different costs
305 | and billing frequencies.
306 | * Provides interface to manage admin functions either via the Django
307 | admin interface or through basic CRUD views.
308 | * Provides user views to add, view, and cancel subscriptions.
309 | * Templates can be customized by either specifying the base HTML
310 | template and extending it or overriding templates entirely.
311 | * Template tags available to represent currencies on required locale.
312 | * Manager object available to integrate with a Task Scheduler to manage
313 | recurrent billings of subscriptions.
314 | * Sandbox site added to easily test out application functionality.
315 |
--------------------------------------------------------------------------------
/docs/installation.rst:
--------------------------------------------------------------------------------
1 | ===============
2 | Getting started
3 | ===============
4 |
5 | ----------------------
6 | Installation and Setup
7 | ----------------------
8 |
9 | Install django-flexible-subscriptions and its dependencies
10 | ==========================================================
11 |
12 | Install ``django-flexible-subscriptions`` (which will install Django
13 | as a dependency). It is strongly recommended you use a virtual
14 | environment for your projects. For example, you can do this easily
15 | with Pipenv_:
16 |
17 | .. code-block:: shell
18 |
19 | $ pipenv install django-flexible-subscriptions
20 |
21 | .. _Pipenv: https://pipenv.readthedocs.io/en/latest/
22 |
23 | Add django-flexible-subscriptions to your project
24 | =================================================
25 |
26 | 1. Update ``django-flexible-subscriptions`` to your settings file.
27 | While not mandatory, it is very likely you will also want to include
28 | the ``django.contrib.auth`` and ``django.contrib.admin`` apps
29 | as well (see Understanding a Description Plan for details).
30 |
31 | .. code-block:: python
32 |
33 | INSTALLED_APPS = [
34 | # Django applications
35 | 'django.contrib.auth',
36 | 'django.contrib.admin',
37 | ...
38 | # Your third party applications
39 | 'subscriptions',
40 | ...
41 | ]
42 |
43 | 2. Run the package migrations:
44 |
45 | .. code-block:: shell
46 |
47 | $ pipenv run python manage.py migrate
48 |
49 | 3. Add the ``django-flexible-subscriptions`` URLs to your project:
50 |
51 | .. code-block:: python
52 |
53 | import subscriptions
54 |
55 | from django.contrib import admin # Optional, but recommended
56 | from django.urls import include, urls
57 |
58 |
59 | urlpatterns = [
60 | ...
61 | path('subscriptions/', include('subscriptions.urls')),
62 | path('admin/', include(admin.site.urls), # Optional, but recommended
63 | ...
64 | ]
65 |
66 | 4. You can test that the project is properly setup by running the
67 | server (``pipenv run python manage.py runserver``) and visiting
68 | ``http://127.0.0.1:8000/subscriptions/subscribe/``.
69 |
70 | -------------
71 | Configuration
72 | -------------
73 |
74 | While not required, you are able to customize aspects of Django
75 | Flexible Subscriptions in your settings file. At a minimum, you will
76 | probably want to set the following settings:
77 |
78 | .. code-block:: python
79 |
80 | # Set your currency type
81 | DFS_CURRENCY_LOCALE = 'en_us'
82 |
83 | # Specify your base template file
84 | DFS_BASE_TEMPLATE = 'base.html'
85 |
86 | A full list of settings and their effects can be found in the
87 | :doc:`settings documentation`.
88 |
89 | ---------------------------------
90 | Understanding a Subscription Plan
91 | ---------------------------------
92 |
93 | Django Flexible Subscriptions uses a ``Plan`` model to describe a
94 | subscription plan. A ``Plan`` describes both billing details and
95 | user permissions granted.
96 |
97 | User permissions are dictacted by the Django ``Group`` model, which is
98 | included as part of the authentication system. Django Flexible
99 | Subscriptions will add or remove a ``Group`` from a ``User`` based on
100 | the status of the user subscription. You may specify the permissions
101 | the ``User`` is granted by associating them to that Group and running any
102 | permission checks as needed. See the `Django documenation on "User
103 | authentication in Django"`_ for more details. If you do not need to
104 | grant a user permissions with a subscription, you may ignore the
105 | ``Group`` model.
106 |
107 | .. _Django documenation on "User authentication in Django": https://docs.djangoproject.com/en/dev/topics/auth/
108 |
109 | A subscription ``Plan`` contains the following details to dictate
110 | how it functions:
111 |
112 | * **Plan name**: The name of the subscription plan. This will be
113 | displayed to the end user in various views.
114 | * **Plan description**: An optional internal description to help
115 | describe or differentiate the plan for the developer. The end user
116 | does not see this.
117 | * **Group**: The ``Group`` model(s) associated to this plan.
118 | * **Tag**: Custom tags associated with this plan. Can be used to
119 | organize or categorize related plans.
120 | * **Grade period**: The number of days a subscription will remain
121 | active for a user after a plan ends (e.g. due to non-payment).
122 | * **Plan cost**: Describes the pricing details of the plan.
123 |
124 | One or more ``PlanCost`` models may be associated to a ``Plan``. This
125 | allows you to offer the same plan at difference prices depending on
126 | how often the billing occurs. This would commonly be used to offer a
127 | discounted price when the user subscribes for a longer period of time
128 | (e.g. annually instead of monthly). A ``PlanCost`` will contain the
129 | following details:
130 |
131 | * **Recurrence period**: How often the plan is billed per recurrence
132 | unit.
133 | * **Recurrence unit**: The unit of measurement for the recurrence
134 | period. ``one-time``, ``second``, ``minute``, ``hour``, ``day``,
135 | ``week``, ``month``, and ``year`` are supported.
136 | * **Cost**: The amount to charge at each recurrence period.
137 |
138 | -------------------------
139 | Setup a Subscription Plan
140 | -------------------------
141 |
142 | Once Django Flexible Subscriptions is setup and running, you will be
143 | able to add your first subscription.
144 |
145 | .. note::
146 |
147 | You will need an account with staff/admin access to proceed with
148 | the following steps. All referenced URLs assume you have added
149 | the ``django-flexible-subscriptions`` URLs at ``/subscriptions/``.
150 |
151 | 1. Visit ``/subscriptions/dfs/`` to access the **Developer Dashboard**.
152 |
153 | 2. Click the **Subscription plans** link or visit
154 | ``/subscriptions/dfs/plans/``. Click on the **Create new plan** button.
155 |
156 | 3. Fill in the plan details and click the **Save** button.
157 |
158 | --------------------------------------
159 | Understanding a Subscription Plan List
160 | --------------------------------------
161 |
162 | Django Flexible Subscriptions provides basic support to add a
163 | "Subscribe" page to your site to allow users to select a subscription
164 | plan. The plans listed on this page are controlled by the ``PlanList``
165 | model. The ``PlanList`` model includes the following details:
166 |
167 | * **Title**: A title to display on the page (may include HTML content).
168 | * **Subttile**: A subtitle to display on the page (may include HTML
169 | content).
170 | * **Header**: Content to display before the subscription plans are
171 | listed (may include HTML content).
172 | * **Header**: Content to display after the subscription plans are
173 | listed (may include HTML content).
174 | * **Active**: Whether this list is active or not.
175 |
176 | .. note::
177 |
178 | The first active ``PlanList`` instance is used to populate the
179 | subscribe page. You will need to inactivate or delete older
180 | ``PlanList`` instances if you want a newer one to be used.
181 |
182 | Once a ``PlanList`` is created, you will be able to associate ``Plan``
183 | instances to specify the following details:
184 |
185 | * **HTML content**: How you want the plan details to be presented
186 | (may include HTML content).
187 | * **Subscribe button text**: The text to display on the "Subscribe"
188 | button at the end of the plan description.
189 |
190 | --------------------
191 | Creating a Plan List
192 | --------------------
193 |
194 | Once you have created you subscription plan, you can create your
195 | ``PlanList``.
196 |
197 | 1. Visit ``/subscriptions/dfs/`` to access the **Developer Dashboard**.
198 |
199 | 2. Click the **Plan lists** button or visit
200 | ``/subscriptions/dfs/plan-lists/``. Click on the **Create a new
201 | plan list** button.
202 |
203 | 3. Fill in the plan list details and click the **Save** button.
204 |
205 | 4. To add ``Plan`` instances to your ``PlanList`` click the **Manage
206 | plans** button on the Plan Lists page.
207 |
208 | 5. Click on the **Add plan** button, fill in the desired details and
209 | click the **Save** buton.
210 |
211 | 6. You can now visit ``/subscriptions/subscribe/`` to see your plan
212 | list.
213 |
214 | ----------
215 | Next Steps
216 | ----------
217 |
218 | If you completed all the steps above, you should now have a working
219 | subscription system on your development server. You will likely want
220 | to add payment handling and a task runner to automate subscription
221 | renewals and expiries. Instructions and examples for this can be found
222 | the :doc:`Advanced usage` section.
223 |
224 | -----------------------------
225 | Considerations for Production
226 | -----------------------------
227 |
228 | When moving Django Flexible Subscriptions to a production environment,
229 | you will probably want to consider the following:
230 |
231 | * ``django-flexible-subscriptions`` comes with its own ``styles.css``
232 | file - you will need to ensure you run the ``collectstatic``
233 | management command if you have not overriden it with your own file.
234 | * The ``SubscribeView`` included with ``django-flexible-subscriptions``
235 | is intended to be extended to implement payment processing. The base
236 | view will automatically approve all payment requests and should be
237 | overriden if this is not the desired behaviour.
238 | * ``django-flexible-subscriptions`` includes management commands to
239 | assist with managing subscription renewals and expiries. While these
240 | can be ran manually, you should consider implementing some task
241 | manager, such as ``cron`` or ``celery``, to run these commands on a
242 | regular basis.
243 |
--------------------------------------------------------------------------------
/tests/subscriptions/test_views_tag.py:
--------------------------------------------------------------------------------
1 | """Tests for the django-flexible-subscriptions PlanTag views."""
2 | import pytest
3 |
4 | from django.contrib.auth.models import Permission
5 | from django.contrib.contenttypes.models import ContentType
6 | from django.contrib.messages import get_messages
7 | from django.urls import reverse
8 |
9 | from subscriptions import models
10 |
11 |
12 | def create_tag(tag_text='test'):
13 | """Creates and returns a PlanTag instance."""
14 | return models.PlanTag.objects.create(tag=tag_text)
15 |
16 |
17 | # TagListView
18 | # -----------------------------------------------------------------------------
19 | @pytest.mark.django_db
20 | def test_tag_list_template(admin_client):
21 | """Tests for proper tag_list template."""
22 | response = admin_client.get(reverse('dfs_tag_list'))
23 |
24 | assert (
25 | 'subscriptions/tag_list.html' in [t.name for t in response.templates]
26 | )
27 |
28 |
29 | @pytest.mark.django_db
30 | def test_tag_list_403_if_not_authorized(client, django_user_model):
31 | """Tests for 403 error for tag list if inadequate permissions."""
32 | django_user_model.objects.create_user(username='user', password='password')
33 | client.login(username='user', password='password')
34 |
35 | response = client.get(reverse('dfs_tag_list'))
36 |
37 | assert response.status_code == 403
38 |
39 |
40 | @pytest.mark.django_db
41 | def test_tag_list_200_if_authorized(client, django_user_model):
42 | """Tests for 200 response for tag list with adequate permissions."""
43 | # Retrieve proper permission, add to user, and login
44 | content = ContentType.objects.get_for_model(models.SubscriptionPlan)
45 | permission = Permission.objects.get(
46 | content_type=content, codename='subscriptions'
47 | )
48 | user = django_user_model.objects.create_user(
49 | username='user', password='password'
50 | )
51 | user.user_permissions.add(permission)
52 | client.login(username='user', password='password')
53 |
54 | response = client.get(reverse('dfs_tag_list'))
55 |
56 | assert response.status_code == 200
57 |
58 |
59 | @pytest.mark.django_db
60 | def test_tag_list_retrives_all_tags(admin_client):
61 | """Tests that the list view retrieves all the tags."""
62 | # Create tags to retrieve
63 | create_tag('3')
64 | create_tag('1')
65 | create_tag('2')
66 |
67 | response = admin_client.get(reverse('dfs_tag_list'))
68 |
69 | assert len(response.context['tags']) == 3
70 | assert response.context['tags'][0].tag == '1'
71 | assert response.context['tags'][1].tag == '2'
72 | assert response.context['tags'][2].tag == '3'
73 |
74 |
75 | # TagCreateView
76 | # -----------------------------------------------------------------------------
77 | @pytest.mark.django_db
78 | def test_tag_create_template(admin_client):
79 | """Tests for proper tag_create template."""
80 | response = admin_client.get(reverse('dfs_tag_create'))
81 |
82 | assert (
83 | 'subscriptions/tag_create.html' in [t.name for t in response.templates]
84 | )
85 |
86 |
87 | @pytest.mark.django_db
88 | def test_tag_create_403_if_not_authorized(client, django_user_model):
89 | """Tests for 403 error for tag create if inadequate permissions."""
90 | django_user_model.objects.create_user(username='user', password='password')
91 | client.login(username='user', password='password')
92 |
93 | response = client.get(reverse('dfs_tag_create'))
94 |
95 | assert response.status_code == 403
96 |
97 |
98 | @pytest.mark.django_db
99 | def test_tag_create_200_if_authorized(client, django_user_model):
100 | """Tests for 200 response for tag create with adequate permissions."""
101 | # Retrieve proper permission, add to user, and login
102 | content = ContentType.objects.get_for_model(models.SubscriptionPlan)
103 | permission = Permission.objects.get(
104 | content_type=content, codename='subscriptions'
105 | )
106 | user = django_user_model.objects.create_user(
107 | username='user', password='password'
108 | )
109 | user.user_permissions.add(permission)
110 | client.login(username='user', password='password')
111 |
112 | response = client.get(reverse('dfs_tag_create'))
113 |
114 | assert response.status_code == 200
115 |
116 |
117 | @pytest.mark.django_db
118 | def test_tag_create_create_and_success(admin_client):
119 | """Tests that tag creation and success message works as expected."""
120 | tag_count = models.PlanTag.objects.all().count()
121 |
122 | response = admin_client.post(
123 | reverse('dfs_tag_create'),
124 | {'tag': '1'},
125 | follow=True,
126 | )
127 |
128 | messages = list(get_messages(response.wsgi_request))
129 |
130 | assert models.PlanTag.objects.all().count() == tag_count + 1
131 | assert messages[0].tags == 'success'
132 | assert messages[0].message == 'Tag successfully added'
133 |
134 |
135 | # TagUpdateView
136 | # -----------------------------------------------------------------------------
137 | @pytest.mark.django_db
138 | def test_tag_update_template(admin_client):
139 | """Tests for proper tag_update template."""
140 | tag = create_tag()
141 |
142 | response = admin_client.get(reverse(
143 | 'dfs_tag_update', kwargs={'tag_id': tag.id}
144 | ))
145 |
146 | assert (
147 | 'subscriptions/tag_update.html' in [t.name for t in response.templates]
148 | )
149 |
150 |
151 | @pytest.mark.django_db
152 | def test_tag_update_403_if_not_authorized(client, django_user_model):
153 | """Tests for 403 error for tag update if inadequate permissions."""
154 | tag = create_tag()
155 |
156 | django_user_model.objects.create_user(username='user', password='password')
157 | client.login(username='user', password='password')
158 |
159 | response = client.get(reverse(
160 | 'dfs_tag_update', kwargs={'tag_id': tag.id}
161 | ))
162 |
163 | assert response.status_code == 403
164 |
165 |
166 | @pytest.mark.django_db
167 | def test_tag_update_200_if_authorized(client, django_user_model):
168 | """Tests for 200 response for tag update with adequate permissions."""
169 | tag = create_tag()
170 |
171 | # Retrieve proper permission, add to user, and login
172 | content = ContentType.objects.get_for_model(models.SubscriptionPlan)
173 | permission = Permission.objects.get(
174 | content_type=content, codename='subscriptions'
175 | )
176 | user = django_user_model.objects.create_user(
177 | username='user', password='password'
178 | )
179 | user.user_permissions.add(permission)
180 | client.login(username='user', password='password')
181 |
182 | response = client.get(reverse(
183 | 'dfs_tag_update', kwargs={'tag_id': tag.id}
184 | ))
185 |
186 | assert response.status_code == 200
187 |
188 |
189 | @pytest.mark.django_db
190 | def test_tag_update_update_and_success(admin_client):
191 | """Tests that tag update and success message works as expected."""
192 | # Setup initial tag for update
193 | tag = create_tag('1')
194 | tag_count = models.PlanTag.objects.all().count()
195 |
196 | response = admin_client.post(
197 | reverse('dfs_tag_update', kwargs={'tag_id': tag.id}),
198 | {'tag': '2'},
199 | follow=True,
200 | )
201 |
202 | messages = list(get_messages(response.wsgi_request))
203 |
204 | assert models.PlanTag.objects.all().count() == tag_count
205 | assert models.PlanTag.objects.get(id=tag.id).tag == '2'
206 | assert messages[0].tags == 'success'
207 | assert messages[0].message == 'Tag successfully updated'
208 |
209 |
210 | # TagDeleteView
211 | # -----------------------------------------------------------------------------
212 | @pytest.mark.django_db
213 | def test_tag_delete_template(admin_client):
214 | """Tests for proper tag_delete template."""
215 | tag = create_tag()
216 |
217 | response = admin_client.get(reverse(
218 | 'dfs_tag_delete', kwargs={'tag_id': tag.id},
219 | ))
220 |
221 | assert (
222 | 'subscriptions/tag_delete.html' in [t.name for t in response.templates]
223 | )
224 |
225 |
226 | @pytest.mark.django_db
227 | def test_tag_delete_403_if_not_authorized(client, django_user_model):
228 | """Tests for 403 error for tag delete if inadequate permissions."""
229 | tag = create_tag()
230 |
231 | django_user_model.objects.create_user(username='user', password='password')
232 | client.login(username='user', password='password')
233 |
234 | response = client.get(reverse(
235 | 'dfs_tag_delete', kwargs={'tag_id': tag.id},
236 | ))
237 |
238 | assert response.status_code == 403
239 |
240 |
241 | @pytest.mark.django_db
242 | def test_tag_delete_200_if_authorized(client, django_user_model):
243 | """Tests for 200 response for tag delete with adequate permissions."""
244 | tag = create_tag()
245 |
246 | # Retrieve proper permission, add to user, and login
247 | content = ContentType.objects.get_for_model(models.SubscriptionPlan)
248 | permission = Permission.objects.get(
249 | content_type=content, codename='subscriptions'
250 | )
251 | user = django_user_model.objects.create_user(
252 | username='user', password='password'
253 | )
254 | user.user_permissions.add(permission)
255 | client.login(username='user', password='password')
256 |
257 | response = client.get(reverse(
258 | 'dfs_tag_delete', kwargs={'tag_id': tag.id},
259 | ))
260 |
261 | assert response.status_code == 200
262 |
263 |
264 | @pytest.mark.django_db
265 | def test_tag_delete_delete_and_success_message(admin_client):
266 | """Tests for success message on successful deletion."""
267 | tag = create_tag()
268 | tag_count = models.PlanTag.objects.all().count()
269 |
270 | response = admin_client.post(
271 | reverse('dfs_tag_delete', kwargs={'tag_id': tag.id}),
272 | follow=True,
273 | )
274 |
275 | messages = list(get_messages(response.wsgi_request))
276 |
277 | assert models.PlanTag.objects.all().count() == tag_count - 1
278 | assert messages[0].tags == 'success'
279 | assert messages[0].message == 'Tag successfully deleted'
280 |
--------------------------------------------------------------------------------
/tests/subscriptions/test_views_plan_list.py:
--------------------------------------------------------------------------------
1 | """Tests for the django-flexible-subscriptions PlanList views."""
2 | import pytest
3 |
4 | from django.contrib.auth.models import Permission
5 | from django.contrib.contenttypes.models import ContentType
6 | from django.contrib.messages import get_messages
7 | from django.urls import reverse
8 |
9 | from subscriptions import models
10 |
11 |
12 | def create_plan_list(title='test'):
13 | """Creates and returns a PlanList instance."""
14 | return models.PlanList.objects.create(title=title)
15 |
16 |
17 | # PlanListListView
18 | # -----------------------------------------------------------------------------
19 | @pytest.mark.django_db
20 | def test_plan_list_list_template(admin_client):
21 | """Tests for proper plan_list_list template."""
22 | response = admin_client.get(reverse('dfs_plan_list_list'))
23 |
24 | assert (
25 | 'subscriptions/plan_list_list.html' in [
26 | t.name for t in response.templates
27 | ]
28 | )
29 |
30 |
31 | @pytest.mark.django_db
32 | def test_plan_list_list_403_if_not_authorized(client, django_user_model):
33 | """Tests for 403 error for PlanList list if inadequate permissions."""
34 | django_user_model.objects.create_user(username='user', password='password')
35 | client.login(username='user', password='password')
36 |
37 | response = client.get(reverse('dfs_plan_list_list'))
38 |
39 | assert response.status_code == 403
40 |
41 |
42 | @pytest.mark.django_db
43 | def test_plan_list_list_200_if_authorized(client, django_user_model):
44 | """Tests for 200 response for PlanList list with adequate permissions."""
45 | # Retrieve proper permission, add to user, and login
46 | content = ContentType.objects.get_for_model(models.SubscriptionPlan)
47 | permission = Permission.objects.get(
48 | content_type=content, codename='subscriptions'
49 | )
50 | user = django_user_model.objects.create_user(
51 | username='user', password='password'
52 | )
53 | user.user_permissions.add(permission)
54 | client.login(username='user', password='password')
55 |
56 | response = client.get(reverse('dfs_plan_list_list'))
57 |
58 | assert response.status_code == 200
59 |
60 |
61 | @pytest.mark.django_db
62 | def test_plan_list_list_retrieves_all_plan_lists(admin_client):
63 | """Tests that the list view retrieves all the plan lists."""
64 | # Create plan lists to retrieve
65 | create_plan_list('3')
66 | create_plan_list('1')
67 | create_plan_list('2')
68 |
69 | response = admin_client.get(reverse('dfs_plan_list_list'))
70 |
71 | assert len(response.context['plan_lists']) == 3
72 | assert response.context['plan_lists'][0].title == '3'
73 | assert response.context['plan_lists'][1].title == '1'
74 | assert response.context['plan_lists'][2].title == '2'
75 |
76 |
77 | # PlanListCreateView
78 | # -----------------------------------------------------------------------------
79 | @pytest.mark.django_db
80 | def test_plan_list_create_template(admin_client):
81 | """Tests for proper plan_list_create template."""
82 | response = admin_client.get(reverse('dfs_plan_list_create'))
83 |
84 | assert (
85 | 'subscriptions/plan_list_create.html' in [
86 | t.name for t in response.templates
87 | ]
88 | )
89 |
90 |
91 | @pytest.mark.django_db
92 | def test_plan_list_create_403_if_not_authorized(client, django_user_model):
93 | """Tests for 403 error for PlanListCreate if inadequate permissions."""
94 | django_user_model.objects.create_user(username='user', password='password')
95 | client.login(username='user', password='password')
96 |
97 | response = client.get(reverse('dfs_plan_list_create'))
98 |
99 | assert response.status_code == 403
100 |
101 |
102 | @pytest.mark.django_db
103 | def test_plan_list_create_200_if_authorized(client, django_user_model):
104 | """Tests for 200 response for PlanListCreate with adequate permissions."""
105 | # Retrieve proper permission, add to user, and login
106 | content = ContentType.objects.get_for_model(models.SubscriptionPlan)
107 | permission = Permission.objects.get(
108 | content_type=content, codename='subscriptions'
109 | )
110 | user = django_user_model.objects.create_user(
111 | username='user', password='password'
112 | )
113 | user.user_permissions.add(permission)
114 | client.login(username='user', password='password')
115 |
116 | response = client.get(reverse('dfs_plan_list_create'))
117 |
118 | assert response.status_code == 200
119 |
120 |
121 | @pytest.mark.django_db
122 | def test_plan_list_create_create_and_success(admin_client):
123 | """Tests that plan list creation and success message works as expected."""
124 | plan_list_count = models.PlanList.objects.all().count()
125 |
126 | response = admin_client.post(
127 | reverse('dfs_plan_list_create'),
128 | {'title': '1'},
129 | follow=True,
130 | )
131 |
132 | messages = list(get_messages(response.wsgi_request))
133 |
134 | assert models.PlanList.objects.all().count() == plan_list_count + 1
135 | assert messages[0].tags == 'success'
136 | assert messages[0].message == 'Plan list successfully added'
137 |
138 |
139 | # PlanListUpdateView
140 | # -----------------------------------------------------------------------------
141 | @pytest.mark.django_db
142 | def test_plan_list_update_template(admin_client):
143 | """Tests for proper plan_list_update template."""
144 | plan_list = create_plan_list()
145 |
146 | response = admin_client.get(reverse(
147 | 'dfs_plan_list_update', kwargs={'plan_list_id': plan_list.id}
148 | ))
149 |
150 | assert (
151 | 'subscriptions/plan_list_update.html' in [
152 | t.name for t in response.templates
153 | ]
154 | )
155 |
156 |
157 | @pytest.mark.django_db
158 | def test_plan_list_update_403_if_not_authorized(client, django_user_model):
159 | """Tests for 403 error for PlanListUpdate if inadequate permissions."""
160 | plan_list = create_plan_list()
161 |
162 | django_user_model.objects.create_user(username='user', password='password')
163 | client.login(username='user', password='password')
164 |
165 | response = client.get(reverse(
166 | 'dfs_plan_list_update', kwargs={'plan_list_id': plan_list.id}
167 | ))
168 |
169 | assert response.status_code == 403
170 |
171 |
172 | @pytest.mark.django_db
173 | def test_plan_list_update_200_if_authorized(client, django_user_model):
174 | """Tests for 200 response for PlanListUpdate with adequate permissions."""
175 | plan_list = create_plan_list()
176 |
177 | # Retrieve proper permission, add to user, and login
178 | content = ContentType.objects.get_for_model(models.SubscriptionPlan)
179 | permission = Permission.objects.get(
180 | content_type=content, codename='subscriptions'
181 | )
182 | user = django_user_model.objects.create_user(
183 | username='user', password='password'
184 | )
185 | user.user_permissions.add(permission)
186 | client.login(username='user', password='password')
187 |
188 | response = client.get(reverse(
189 | 'dfs_plan_list_update', kwargs={'plan_list_id': plan_list.id}
190 | ))
191 |
192 | assert response.status_code == 200
193 |
194 |
195 | @pytest.mark.django_db
196 | def test_plan_list_update_update_and_success(admin_client):
197 | """Tests that plan list update and success message works as expected."""
198 | # Setup initial plan list for update
199 | plan_list = create_plan_list('1')
200 | plan_list_count = models.PlanList.objects.all().count()
201 |
202 | response = admin_client.post(
203 | reverse('dfs_plan_list_update', kwargs={'plan_list_id': plan_list.id}),
204 | {'title': '2'},
205 | follow=True,
206 | )
207 |
208 | messages = list(get_messages(response.wsgi_request))
209 |
210 | assert models.PlanList.objects.all().count() == plan_list_count
211 | assert models.PlanList.objects.get(id=plan_list.id).title == '2'
212 | assert messages[0].tags == 'success'
213 | assert messages[0].message == 'Plan list successfully updated'
214 |
215 |
216 | # PlanListDeleteView
217 | # -----------------------------------------------------------------------------
218 | @pytest.mark.django_db
219 | def test_plan_list_delete_template(admin_client):
220 | """Tests for proper pan_list_delete template."""
221 | plan_list = create_plan_list()
222 |
223 | response = admin_client.get(reverse(
224 | 'dfs_plan_list_delete', kwargs={'plan_list_id': plan_list.id},
225 | ))
226 |
227 | assert (
228 | 'subscriptions/plan_list_delete.html' in [
229 | t.name for t in response.templates
230 | ]
231 | )
232 |
233 |
234 | @pytest.mark.django_db
235 | def test_plan_list_delete_403_if_not_authorized(client, django_user_model):
236 | """Tests for 403 error for PlanListDelete if inadequate permissions."""
237 | plan_list = create_plan_list()
238 |
239 | django_user_model.objects.create_user(username='user', password='password')
240 | client.login(username='user', password='password')
241 |
242 | response = client.get(reverse(
243 | 'dfs_plan_list_delete', kwargs={'plan_list_id': plan_list.id},
244 | ))
245 |
246 | assert response.status_code == 403
247 |
248 |
249 | @pytest.mark.django_db
250 | def test_plan_list_delete_200_if_authorized(client, django_user_model):
251 | """Tests for 200 response for PlanListDelete with adequate permissions."""
252 | plan_list = create_plan_list()
253 |
254 | # Retrieve proper permission, add to user, and login
255 | content = ContentType.objects.get_for_model(models.SubscriptionPlan)
256 | permission = Permission.objects.get(
257 | content_type=content, codename='subscriptions'
258 | )
259 | user = django_user_model.objects.create_user(
260 | username='user', password='password'
261 | )
262 | user.user_permissions.add(permission)
263 | client.login(username='user', password='password')
264 |
265 | response = client.get(reverse(
266 | 'dfs_plan_list_delete', kwargs={'plan_list_id': plan_list.id},
267 | ))
268 |
269 | assert response.status_code == 200
270 |
271 |
272 | @pytest.mark.django_db
273 | def test_plan_list_delete_delete_and_success_message(admin_client):
274 | """Tests for success message on successful deletion."""
275 | plan_list = create_plan_list()
276 | plan_list_count = models.PlanList.objects.all().count()
277 |
278 | response = admin_client.post(
279 | reverse('dfs_plan_list_delete', kwargs={'plan_list_id': plan_list.id}),
280 | follow=True,
281 | )
282 |
283 | messages = list(get_messages(response.wsgi_request))
284 |
285 | assert models.PlanList.objects.all().count() == plan_list_count - 1
286 | assert messages[0].tags == 'success'
287 | assert messages[0].message == 'Plan list successfully deleted'
288 |
--------------------------------------------------------------------------------