├── reports └── .gitkeep ├── tests ├── __init__.py ├── subscriptions │ ├── __init__.py │ ├── test_currency_filters.py │ ├── test_views_dashboard.py │ ├── test_admin.py │ ├── test_init.py │ ├── test_abstract.py │ ├── test_forms.py │ ├── test_conf.py │ ├── test_views_transaction.py │ ├── test_views_tag.py │ └── test_views_plan_list.py ├── conftest.py └── factories.py ├── sandbox ├── __init__.py ├── manage.py ├── urls.py ├── templates │ └── sandbox │ │ └── index.html └── settings.py ├── CONTRIBUTERS ├── subscriptions ├── migrations │ ├── __init__.py │ ├── 0005_update_recurrence_unit_default.py │ ├── 0004_change_recurrence_unit_type_1.py │ ├── 0006_add_slugs.py │ ├── 0003_update_plan_list_detail.py │ ├── 0004_change_recurrence_unit_type_2.py │ └── 0002_plan_list_addition.py ├── templatetags │ ├── __init__.py │ └── currency_filters.py ├── templates │ └── subscriptions │ │ ├── dashboard.html │ │ ├── snippets │ │ ├── header.html │ │ ├── payment_details.html │ │ ├── messages.html │ │ ├── subscription_plan_cost.html │ │ ├── pagination.html │ │ ├── form_table.html │ │ └── menu.html │ │ ├── base_developer.html │ │ ├── subscribe_thank_you.html │ │ ├── base.html │ │ ├── tag_create.html │ │ ├── tag_update.html │ │ ├── plan_list_create.html │ │ ├── plan_list_update.html │ │ ├── plan_list_delete.html │ │ ├── subscribe_preview.html │ │ ├── subscription_create.html │ │ ├── subscription_update.html │ │ ├── plan_list_detail_update.html │ │ ├── tag_delete.html │ │ ├── plan_delete.html │ │ ├── plan_list_detail_create.html │ │ ├── subscribe_list.html │ │ ├── plan_list_detail_delete.html │ │ ├── plan_create.html │ │ ├── plan_update.html │ │ ├── transaction_detail.html │ │ ├── subscription_delete.html │ │ ├── tag_list.html │ │ ├── plan_list_detail_list.html │ │ ├── plan_list_list.html │ │ ├── subscribe_cancel.html │ │ ├── subscribe_user_list.html │ │ ├── transaction_list.html │ │ ├── subscribe_confirmation.html │ │ ├── plan_list.html │ │ └── subscription_list.html ├── apps.py ├── management │ └── commands │ │ ├── process_subscriptions.py │ │ └── _manager.py ├── __init__.py ├── admin.py ├── abstract.py ├── urls.py ├── conf.py └── forms.py ├── setup.cfg ├── .codecov.yml ├── docs ├── requirements.txt ├── toc.rst ├── index.rst ├── subscriptions.templatetags.rst ├── subscriptions.rst ├── conf.py ├── settings.rst ├── contributing.rst ├── advanced.rst ├── changelog.rst └── installation.rst ├── .coveragerc ├── .pylintrc ├── .htmlhintrc ├── .editorconfig ├── .gitignore ├── .travis.yml ├── Pipfile ├── setup.py └── README.rst /reports/.gitkeep: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tests/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /sandbox/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tests/subscriptions/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /CONTRIBUTERS: -------------------------------------------------------------------------------- 1 | Joshua Robert Torrance -------------------------------------------------------------------------------- /subscriptions/migrations/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /subscriptions/templatetags/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /setup.cfg: -------------------------------------------------------------------------------- 1 | [pycodestyle] 2 | max-line-length = 119 3 | show-source = True 4 | -------------------------------------------------------------------------------- /.codecov.yml: -------------------------------------------------------------------------------- 1 | coverage: 2 | precision: 2 3 | round: down 4 | range: 85..100 5 | -------------------------------------------------------------------------------- /docs/requirements.txt: -------------------------------------------------------------------------------- 1 | # Requirements required to properly generated docs 2 | django 3 | -------------------------------------------------------------------------------- /subscriptions/templates/subscriptions/dashboard.html: -------------------------------------------------------------------------------- 1 | {% extends 'subscriptions/base_developer.html' %} 2 | 3 | {% block title %}DFS Dashboard{% endblock %} 4 | -------------------------------------------------------------------------------- /subscriptions/templates/subscriptions/snippets/header.html: -------------------------------------------------------------------------------- 1 |
2 |

Django Flexible Subscriptions

3 | Developer Dashboard 4 |
5 | -------------------------------------------------------------------------------- /.coveragerc: -------------------------------------------------------------------------------- 1 | [run] 2 | branch = True 3 | data_file = reports/.coverage 4 | source = subscriptions 5 | omit = subscriptions/migrations/*, tests/* 6 | 7 | [html] 8 | directory = reports/htmlcov 9 | -------------------------------------------------------------------------------- /docs/toc.rst: -------------------------------------------------------------------------------- 1 | .. toctree:: 2 | :maxdepth: 2 3 | 4 | installation 5 | settings 6 | advanced 7 | contributing 8 | Package reference 9 | changelog 10 | -------------------------------------------------------------------------------- /subscriptions/templates/subscriptions/snippets/payment_details.html: -------------------------------------------------------------------------------- 1 | {% load i18n %} 2 | 3 | {% if form %} 4 |

Payment details

5 | 6 | {% include 'subscriptions/snippets/form_table.html' with form=form %} 7 | {% endif %} 8 | -------------------------------------------------------------------------------- /subscriptions/templates/subscriptions/snippets/messages.html: -------------------------------------------------------------------------------- 1 | {% if messages %} 2 | 7 | {% endif %} 8 | -------------------------------------------------------------------------------- /.pylintrc: -------------------------------------------------------------------------------- 1 | [MASTER] 2 | load-plugins=pylint_django 3 | 4 | [FORMAT] 5 | max-line-length=119 6 | 7 | [DESIGN] 8 | max-parents=12 9 | 10 | [SIMILARITIES] 11 | min-similarity-lines=4 12 | ignore-comments=yes 13 | ignore-docstrings=yes 14 | ignore-imports=yes 15 | -------------------------------------------------------------------------------- /subscriptions/templates/subscriptions/snippets/subscription_plan_cost.html: -------------------------------------------------------------------------------- 1 | {% load i18n %} 2 | 3 |

Subscription Details

4 | 5 |
{{ form.non_field_errors }}
6 | 7 |
8 | {{ form.plan_cost.errors }} 9 | {{ form.plan_cost }} 10 |
11 | -------------------------------------------------------------------------------- /sandbox/manage.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # pylint: disable=missing-docstring 3 | import os 4 | import sys 5 | 6 | if __name__ == "__main__": 7 | os.environ.setdefault("DJANGO_SETTINGS_MODULE", "settings") 8 | 9 | from django.core.management import execute_from_command_line 10 | 11 | execute_from_command_line(sys.argv) 12 | -------------------------------------------------------------------------------- /subscriptions/apps.py: -------------------------------------------------------------------------------- 1 | """Application configuration file for django-flexible-subscriptions.""" 2 | from django.apps import AppConfig 3 | 4 | 5 | class FlexibleSubscriptionsConfig(AppConfig): 6 | """Configuration details for django-flexible-subscriptions.""" 7 | name = 'subscriptions' 8 | verbose_name = 'django-flexible-subscriptions' 9 | -------------------------------------------------------------------------------- /.htmlhintrc: -------------------------------------------------------------------------------- 1 | { 2 | "tagname-lowercase": true, 3 | "attr-lowercase": true, 4 | "attr-value-double-quotes": true, 5 | "doctype-first": false, 6 | "tag-pair": false, 7 | "spec-char-escape": false, 8 | "id-unique": true, 9 | "src-not-empty": true, 10 | "attr-no-duplication": true, 11 | "title-require": true 12 | } 13 | -------------------------------------------------------------------------------- /subscriptions/templates/subscriptions/base_developer.html: -------------------------------------------------------------------------------- 1 | {% extends template_extends %} 2 | 3 | {% load i18n %} 4 | 5 | {% block content %} 6 |
7 | {% include 'subscriptions/snippets/header.html' %} 8 | 9 | {% include 'subscriptions/snippets/menu.html' %} 10 | 11 |
{% block main %}{% endblock %}
12 |
13 | {% endblock %} 14 | -------------------------------------------------------------------------------- /tests/subscriptions/test_currency_filters.py: -------------------------------------------------------------------------------- 1 | """Tests the currency_filters module.""" 2 | from unittest.mock import patch 3 | 4 | from subscriptions.templatetags.currency_filters import currency 5 | 6 | 7 | @patch.dict('subscriptions.conf.SETTINGS', currency_locale='en_us') 8 | def test_currency_filter(): 9 | """Tests that value is properly returned as currency.""" 10 | assert currency('1000.005') == '$1,000.01' 11 | -------------------------------------------------------------------------------- /subscriptions/templatetags/currency_filters.py: -------------------------------------------------------------------------------- 1 | """Template filters for Django Flexible Subscriptions.""" 2 | from django import template 3 | 4 | from subscriptions.conf import SETTINGS 5 | 6 | 7 | register = template.Library() 8 | 9 | 10 | @register.filter(name='currency') 11 | def currency(value): 12 | """Displays value as a currency based on the provided settings.""" 13 | return SETTINGS['currency'].format_currency(value) 14 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | # http://editorconfig.org 2 | 3 | root = true 4 | 5 | [*] 6 | charset = utf-8 7 | end_of_line = lf 8 | insert_final_newline = true 9 | trim_trailing_whitespace = true 10 | 11 | [*.{py,rst,ini}] 12 | indent_style = space 13 | indent_size = 4 14 | 15 | [*.py] 16 | line_length = 80 17 | multi_line_output = 3 18 | 19 | [*.{html,css,scss,json,yml,js}] 20 | indent_style = space 21 | indent_size = 2 22 | 23 | [Makefile] 24 | indent_style = tab 25 | 26 | -------------------------------------------------------------------------------- /subscriptions/templates/subscriptions/subscribe_thank_you.html: -------------------------------------------------------------------------------- 1 | {% extends template_extends %} 2 | 3 | {% load i18n %} 4 | 5 | {% block title %}Subscribe | Thank You{% endblock %} 6 | 7 | {% block content %} 8 |
9 |

Successfully subscribed!

10 | 11 |

You have been successfully subscribed!

12 | 13 |

You may save your transaction ID for your records: {{ transaction.id }}

14 |
15 | {% endblock %} 16 | -------------------------------------------------------------------------------- /docs/index.rst: -------------------------------------------------------------------------------- 1 | =========================================== 2 | Django Flexible Subscriptions Documentation 3 | =========================================== 4 | 5 | Django Flexible Subscriptions provides subscription and recurrent 6 | billing for Django applications. Any payment provider can be quickly 7 | added by overriding the placeholder functions. You can view the source 8 | code on GitHub_. 9 | 10 | .. _GitHub: https://github.com/studybuffalo/django-flexible-subscriptions 11 | 12 | .. include:: toc.rst 13 | -------------------------------------------------------------------------------- /docs/subscriptions.templatetags.rst: -------------------------------------------------------------------------------- 1 | subscriptions.templatetags package 2 | ================================== 3 | 4 | .. automodule:: subscriptions.templatetags 5 | :members: 6 | :undoc-members: 7 | :show-inheritance: 8 | 9 | Submodules 10 | ---------- 11 | 12 | subscriptions.templatetags.currency\_filters module 13 | --------------------------------------------------- 14 | 15 | .. automodule:: subscriptions.templatetags.currency_filters 16 | :members: 17 | :undoc-members: 18 | :show-inheritance: 19 | 20 | 21 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | ### Python template 2 | # Byte-compiled / optimized / DLL files 3 | __pycache__/ 4 | 5 | # Documentation Files 6 | _build/ 7 | _static/ 8 | _templates/ 9 | 10 | # Environment Files (to prevent accidental commit to repository) 11 | *.env 12 | 13 | # Testing files and reports 14 | .coverage 15 | .pytest_cache 16 | /reports/* 17 | !reports/.gitkeep 18 | 19 | # Distribution files 20 | *.egg-info 21 | dist/ 22 | build/ 23 | 24 | # Sandbox files 25 | sandbox/*.env 26 | sandbox/*.sqlite3 27 | 28 | # Emacs files 29 | .dir-locals.el 30 | 31 | # Node Modules 32 | /node_modules/* 33 | -------------------------------------------------------------------------------- /subscriptions/templates/subscriptions/snippets/pagination.html: -------------------------------------------------------------------------------- 1 | {% if is_paginated %} 2 | 17 | {% endif %} 18 | -------------------------------------------------------------------------------- /subscriptions/templates/subscriptions/snippets/form_table.html: -------------------------------------------------------------------------------- 1 |
{{ form.non_field_errors }}
2 | 3 | {% for field in form.visible_fields %} 4 |
5 | 11 | 12 | {% if field.help_text %} 13 | {{ field.help_text|capfirst }} 14 | {% endif %} 15 | 16 | {{ field.errors }} 17 |
18 | {% endfor %} 19 | 20 | {% for field in form.hidden_fields %} 21 | {{ field }} 22 | {% endfor %} 23 | -------------------------------------------------------------------------------- /subscriptions/templates/subscriptions/base.html: -------------------------------------------------------------------------------- 1 | {% load i18n %} 2 | {% load static %} 3 | 4 | 5 | 6 | 7 | 8 | {% block title %}Subscriptions{% endblock %} 9 | 10 | 11 | 12 | 13 | 14 | 15 | {% block subscriptions_styles %}{% endblock %} 16 | 17 | 18 | 19 |
20 | {% block content %}{% endblock %} 21 |
22 | 23 | 24 | -------------------------------------------------------------------------------- /subscriptions/templates/subscriptions/tag_create.html: -------------------------------------------------------------------------------- 1 | {% extends 'subscriptions/base_developer.html' %} 2 | 3 | {% load i18n %} 4 | 5 | {% block title %}DFS Dashboard | Create Tag{% endblock %} 6 | 7 | {% block main %} 8 | 13 | 14 |

Create Tag

15 | 16 | {% include 'subscriptions/snippets/messages.html' %} 17 | 18 |
19 | {% csrf_token %} 20 | 21 | {% include 'subscriptions/snippets/form_table.html' with form=form %} 22 | 23 | 24 |
25 | 26 | Cancel 27 | {% endblock %} 28 | -------------------------------------------------------------------------------- /subscriptions/templates/subscriptions/tag_update.html: -------------------------------------------------------------------------------- 1 | {% extends 'subscriptions/base_developer.html' %} 2 | 3 | {% load i18n %} 4 | 5 | {% block title %}DFS Dashboard | Update Tag{% endblock %} 6 | 7 | {% block main %} 8 | 13 | 14 |

Update Tag

15 | 16 | {% include 'subscriptions/snippets/messages.html' %} 17 | 18 |
19 | {% csrf_token %} 20 | 21 | {% include 'subscriptions/snippets/form_table.html' with form=form %} 22 | 23 | 24 |
25 | 26 | Cancel 27 | {% endblock %} 28 | -------------------------------------------------------------------------------- /subscriptions/templates/subscriptions/plan_list_create.html: -------------------------------------------------------------------------------- 1 | {% extends 'subscriptions/base_developer.html' %} 2 | 3 | {% load i18n %} 4 | 5 | {% block title %}DFS Dashboard | Create Plan List{% endblock %} 6 | 7 | {% block main %} 8 | 13 | 14 |

Create Plan List

15 | 16 | {% include 'subscriptions/snippets/messages.html' %} 17 | 18 |
19 | {% csrf_token %} 20 | 21 | {% include 'subscriptions/snippets/form_table.html' with form=form %} 22 | 23 | 24 |
25 | 26 | Cancel 27 | {% endblock %} 28 | -------------------------------------------------------------------------------- /subscriptions/templates/subscriptions/plan_list_update.html: -------------------------------------------------------------------------------- 1 | {% extends 'subscriptions/base_developer.html' %} 2 | 3 | {% load i18n %} 4 | 5 | {% block title %}DFS Dashboard | Update Plan List{% endblock %} 6 | 7 | {% block main %} 8 | 13 | 14 |

Update Plan List

15 | 16 | {% include 'subscriptions/snippets/messages.html' %} 17 | 18 |
19 | {% csrf_token %} 20 | 21 | {% include 'subscriptions/snippets/form_table.html' with form=form %} 22 | 23 | 24 |
25 | 26 | Cancel 27 | {% endblock %} 28 | -------------------------------------------------------------------------------- /subscriptions/templates/subscriptions/plan_list_delete.html: -------------------------------------------------------------------------------- 1 | {% extends 'subscriptions/base_developer.html' %} 2 | 3 | {% load i18n %} 4 | 5 | {% block title %}DFS Dashboard | Delete Plan List{% endblock %} 6 | 7 | {% block main %} 8 | 13 | 14 |

Delete Plan List

15 | 16 |
Are you sure you want to delete this plan list?
17 | 18 |
19 | Plan List: {{ plan_list.title }} 20 |
21 | 22 | 23 |
24 | {% csrf_token %} 25 | 26 |
27 | 28 | Cancel 29 | {% endblock %} 30 | -------------------------------------------------------------------------------- /subscriptions/templates/subscriptions/subscribe_preview.html: -------------------------------------------------------------------------------- 1 | {% extends template_extends %} 2 | 3 | {% load i18n %} 4 | 5 | {% block title %}Subscribe{% endblock %} 6 | 7 | {% block content %} 8 |
9 |

Subscribe

10 | {{ plan.plan_name }} 11 | 12 | 22 |
23 | {% endblock %} 24 | -------------------------------------------------------------------------------- /subscriptions/templates/subscriptions/subscription_create.html: -------------------------------------------------------------------------------- 1 | {% extends 'subscriptions/base_developer.html' %} 2 | 3 | {% load i18n %} 4 | 5 | {% block title %}DFS Dashboard | Create User Subscription{% endblock %} 6 | 7 | {% block main %} 8 | 13 | 14 |

Create User Subscription

15 | 16 | {% include 'subscriptions/snippets/messages.html' %} 17 | 18 |
19 | {% csrf_token %} 20 | 21 | {% include 'subscriptions/snippets/form_table.html' with form=form %} 22 | 23 | 24 |
25 | 26 | Cancel 27 | {% endblock %} 28 | -------------------------------------------------------------------------------- /subscriptions/templates/subscriptions/subscription_update.html: -------------------------------------------------------------------------------- 1 | {% extends 'subscriptions/base_developer.html' %} 2 | 3 | {% load i18n %} 4 | 5 | {% block title %}DFS Dashboard | Update User Subscription{% endblock %} 6 | 7 | {% block main %} 8 | 13 | 14 |

Update User Subscription

15 | 16 | {% include 'subscriptions/snippets/messages.html' %} 17 | 18 |
19 | {% csrf_token %} 20 | 21 | {% include 'subscriptions/snippets/form_table.html' with form=form %} 22 | 23 | 24 |
25 | 26 | Cancel 27 | {% endblock %} 28 | -------------------------------------------------------------------------------- /subscriptions/templates/subscriptions/plan_list_detail_update.html: -------------------------------------------------------------------------------- 1 | {% extends 'subscriptions/base_developer.html' %} 2 | 3 | {% load i18n %} 4 | 5 | {% block title %}DFS Dashboard | Update Subscription Plan{% endblock %} 6 | 7 | {% block main %} 8 | 14 | 15 |

Update Subscription Plan

16 | 17 |
18 | {% csrf_token %} 19 | 20 | {% include 'subscriptions/snippets/form_table.html' with form=form %} 21 | 22 | 23 |
24 | 25 | Cancel 26 | {% endblock %} 27 | -------------------------------------------------------------------------------- /subscriptions/templates/subscriptions/tag_delete.html: -------------------------------------------------------------------------------- 1 | {% extends 'subscriptions/base_developer.html' %} 2 | 3 | {% load i18n %} 4 | 5 | {% block title %}DFS Dashboard | Delete Tag{% endblock %} 6 | 7 | {% block main %} 8 | 13 | 14 |

Delete Tag

15 | 16 |
Are you sure you want to delete this tag?
17 | 18 |
19 | Tag: {{ token.tag }} 20 |
21 | 22 |
23 | {% csrf_token %} 24 | 25 |
26 | 27 |
28 | {% csrf_token %} 29 | 30 |
31 | 32 | Cancel 33 | {% endblock %} 34 | -------------------------------------------------------------------------------- /subscriptions/management/commands/process_subscriptions.py: -------------------------------------------------------------------------------- 1 | """Django management command to process subscriptions via task runner.""" 2 | import importlib 3 | 4 | from django.core.management.base import BaseCommand 5 | 6 | from subscriptions.conf import SETTINGS 7 | 8 | 9 | class Command(BaseCommand): 10 | """Django management command to process subscriptions via task runner.""" 11 | help = 'Processes all subscriptions to handle renewal and expiries.' 12 | 13 | def handle(self, *args, **options): 14 | """Runs Manager methods required to process subscriptions.""" 15 | Manager = getattr( # pylint: disable=invalid-name 16 | importlib.import_module(SETTINGS['management_manager']['module']), 17 | SETTINGS['management_manager']['class'] 18 | ) 19 | manager = Manager() 20 | 21 | self.stdout.write('Processing subscriptions... ', ending='') 22 | manager.process_subscriptions() 23 | self.stdout.write('Complete!') 24 | -------------------------------------------------------------------------------- /subscriptions/templates/subscriptions/plan_delete.html: -------------------------------------------------------------------------------- 1 | {% extends 'subscriptions/base_developer.html' %} 2 | 3 | {% load i18n %} 4 | 5 | {% block title %}DFS Dashboard | Delete Subscription Plan{% endblock %} 6 | 7 | {% block main %} 8 | 13 | 14 |

Delete Subscription Plan

15 | 16 |
Are you sure you want to delete this subscription plan?
17 | 18 |
19 | Plan name: {{ plan.plan_name }}
20 | Plan description: {{ plan.plan_description }} 21 |
22 | 23 |
24 | {% csrf_token %} 25 | 26 |
27 | 28 | Cancel 29 | {% endblock %} 30 | -------------------------------------------------------------------------------- /subscriptions/templates/subscriptions/plan_list_detail_create.html: -------------------------------------------------------------------------------- 1 | {% extends 'subscriptions/base_developer.html' %} 2 | 3 | {% load i18n %} 4 | 5 | {% block title %}DFS Dashboard | Add Subscription Plan{% endblock %} 6 | 7 | {% block main %} 8 | 14 | 15 |

Add Subscription Plan

16 | 17 | {% include 'subscriptions/snippets/messages.html' %} 18 | 19 |
20 | {% csrf_token %} 21 | 22 | {% include 'subscriptions/snippets/form_table.html' with form=form %} 23 | 24 | 25 |
26 | 27 | Cancel 28 | {% endblock %} 29 | -------------------------------------------------------------------------------- /subscriptions/templates/subscriptions/snippets/menu.html: -------------------------------------------------------------------------------- 1 | {% load i18n %} 2 | 31 | -------------------------------------------------------------------------------- /subscriptions/templates/subscriptions/subscribe_list.html: -------------------------------------------------------------------------------- 1 | {% extends template_extends %} 2 | 3 | {% load i18n %} 4 | 5 | {% block title %}Subscribe{% endblock %} 6 | 7 | {% block content %} 8 |
9 |

{{ plan_list.title|safe }}

10 | 11 | {{ plan_list.subtitle|safe }} 12 | 13 |
{{ plan_list.header|safe }}
14 | 15 |
16 | {% for detail in details %} 17 |
18 | {{ detail.plan }} 19 | {{ detail.html_content|safe }} 20 | 21 |
22 | {% csrf_token %} 23 | 24 | 25 |
26 |
27 | {% endfor %} 28 |
29 | 30 | 31 |
32 | {% endblock %} 33 | -------------------------------------------------------------------------------- /subscriptions/migrations/0005_update_recurrence_unit_default.py: -------------------------------------------------------------------------------- 1 | """Migration to fix incorrect default for recurrence unit.""" 2 | # pylint: disable=invalid-name 3 | from django.db import migrations, models 4 | 5 | 6 | class Migration(migrations.Migration): 7 | """Updates plancost recurrence_unit default to '6' instead of 'm'.""" 8 | dependencies = [ 9 | ('subscriptions', '0004_change_recurrence_unit_type_2'), 10 | ] 11 | 12 | operations = [ 13 | migrations.AlterField( 14 | model_name='plancost', 15 | name='recurrence_unit', 16 | field=models.CharField( 17 | choices=[ 18 | ('0', 'once'), 19 | ('1', 'second'), 20 | ('2', 'minute'), 21 | ('3', 'hour'), 22 | ('4', 'day'), 23 | ('5', 'week'), 24 | ('6', 'month'), 25 | ('7', 'year'), 26 | ], 27 | default='6', 28 | max_length=1 29 | ), 30 | ), 31 | ] 32 | -------------------------------------------------------------------------------- /subscriptions/templates/subscriptions/plan_list_detail_delete.html: -------------------------------------------------------------------------------- 1 | {% extends 'subscriptions/base_developer.html' %} 2 | 3 | {% load i18n %} 4 | 5 | {% block title %}DFS Dashboard | Remove Subscription Plan{% endblock %} 6 | 7 | {% block main %} 8 | 14 | 15 |

Remove Subscription Plan

16 | 17 |
Are you sure you want to remove this plan from the plan list?
18 | 19 |
20 | Plan List: {{ plan_list_detail.plan_list }}
21 | Subscription Plan: {{ plan_list_detail.plan.plan_name }}
22 |
23 | 24 |
25 | {% csrf_token %} 26 | 27 |
28 | 29 | Cancel 30 | {% endblock %} 31 | -------------------------------------------------------------------------------- /subscriptions/migrations/0004_change_recurrence_unit_type_1.py: -------------------------------------------------------------------------------- 1 | """Part 1 of migration to switch recurrence_unit to CharField.""" 2 | # pylint: disable=missing-docstring, invalid-name 3 | from django.db import migrations, models 4 | 5 | 6 | class Migration(migrations.Migration): 7 | dependencies = [ 8 | ('subscriptions', '0003_update_plan_list_detail'), 9 | ] 10 | 11 | operations = [ 12 | migrations.RenameField( 13 | model_name='plancost', 14 | old_name='recurrence_unit', 15 | new_name='old_recurrence_unit', 16 | ), 17 | migrations.AddField( 18 | model_name='plancost', 19 | name='recurrence_unit', 20 | field=models.CharField( 21 | choices=[ 22 | ('0', 'once'), 23 | ('1', 'second'), 24 | ('2', 'minute'), 25 | ('3', 'hour'), 26 | ('4', 'day'), 27 | ('5', 'week'), 28 | ('6', 'month'), 29 | ('7', 'year') 30 | ], 31 | default='m', 32 | max_length=1 33 | ), 34 | ), 35 | ] 36 | -------------------------------------------------------------------------------- /subscriptions/templates/subscriptions/plan_create.html: -------------------------------------------------------------------------------- 1 | {% extends 'subscriptions/base_developer.html' %} 2 | 3 | {% load i18n %} 4 | 5 | {% block title %}DFS Dashboard | Create Subscription Plan{% endblock %} 6 | 7 | {% block main %} 8 | 13 | 14 |

Create Subscription Plan

15 | 16 | {% include 'subscriptions/snippets/messages.html' %} 17 | 18 |
19 | {% csrf_token %} 20 | 21 | {% include 'subscriptions/snippets/form_table.html' with form=form %} 22 | 23 |
24 | Plan cost 25 | 26 |
{{ cost_forms.non_form_errors }}
27 | 28 | {% for cost_form in cost_forms %} 29 | {% include 'subscriptions/snippets/form_table.html' with form=cost_form %} 30 |
31 | {% endfor %} 32 | 33 | {{ cost_forms.management_form }} 34 |
35 | 36 | 37 |
38 | 39 | Cancel 40 | {% endblock %} 41 | -------------------------------------------------------------------------------- /subscriptions/templates/subscriptions/plan_update.html: -------------------------------------------------------------------------------- 1 | {% extends 'subscriptions/base_developer.html' %} 2 | 3 | {% load i18n %} 4 | 5 | {% block title %}DFS Dashboard | Update Subscription Plan{% endblock %} 6 | 7 | {% block main %} 8 | 13 | 14 |

Update Subscription Plan

15 | 16 | {% include 'subscriptions/snippets/messages.html' %} 17 | 18 |
19 | {% csrf_token %} 20 | 21 | {% include 'subscriptions/snippets/form_table.html' with form=form %} 22 | 23 |
24 | Plan cost 25 | 26 |
{{ cost_forms.non_form_errors }}
27 | 28 | {% for cost_form in cost_forms %} 29 | {% include 'subscriptions/snippets/form_table.html' with form=cost_form %} 30 |
31 | {% endfor %} 32 | 33 | {{ cost_forms.management_form }} 34 |
35 | 36 | 37 |
38 | 39 | Cancel 40 | {% endblock %} 41 | -------------------------------------------------------------------------------- /subscriptions/__init__.py: -------------------------------------------------------------------------------- 1 | # pylint: disable=missing-docstring, invalid-name 2 | import sys 3 | import re 4 | import warnings 5 | import django 6 | 7 | __version__ = '0.15.1' 8 | 9 | # Provide DeprecationWarning for older Python versions 10 | # Have to use sys.version while supporting Python 3.5 to enable testing 11 | # Once Python 3.5 is dropped can switch to version_info & compare tuples 12 | if re.match(r'^3\.5', sys.version): 13 | warnings.warn( 14 | ( 15 | 'django-flexible-subscription will stop supporting Python 3.5 ' 16 | 'once it reaches end-of-life (approximately September 2020). ' 17 | 'Ensure you have updated your Python version by then.' 18 | ), 19 | DeprecationWarning 20 | ) 21 | # Provide DeprecationWarning for older Django versions 22 | # if '3.0' in django.__version__: 23 | # warnings.warn( 24 | # ( 25 | # 'django-flexible-subscription will stop supporting Django 3.0 LTS ' 26 | # 'once it reaches end-of-life (approximately April 2021). ' 27 | # 'Ensure you have updated your Django version by then.' 28 | # ), 29 | # DeprecationWarning 30 | # ) 31 | 32 | # Django configuration details 33 | default_app_config = 'subscriptions.apps.FlexibleSubscriptionsConfig' 34 | -------------------------------------------------------------------------------- /subscriptions/templates/subscriptions/transaction_detail.html: -------------------------------------------------------------------------------- 1 | {% extends 'subscriptions/base_developer.html' %} 2 | 3 | {% load i18n %} 4 | {% load currency_filters %} 5 | 6 | {% block title %}DFS Dashboard | Transaction Detail{% endblock %} 7 | 8 | {% block main %} 9 | 14 | 15 |

Transaction Detail

16 | 17 |
18 | {% trans "User" %}: 19 | {% if transaction.user %}{{ transaction.user }}{% else %}No user{% endif %} 20 |
21 | 22 |
23 | {% trans "Plan" %}: 24 | {% if transaction.subscription %}{{ transaction.subscription.plan }}{% else %}No subscription plan{% endif %} 25 |
26 | 27 |
28 | {% trans "Transaction date" %}: 29 | {{ transaction.date_transaction }} 30 |
31 | 32 |
33 | {% trans "Amount" %}: 34 | {% if transaction.amount %}{{ transaction.amount|currency }}{% else %}{{ 0|currency }}{% endif %} 35 |
36 | 37 |

Back to Transactions
38 | {% endblock %} 39 | -------------------------------------------------------------------------------- /subscriptions/templates/subscriptions/subscription_delete.html: -------------------------------------------------------------------------------- 1 | {% extends 'subscriptions/base_developer.html' %} 2 | 3 | {% load i18n %} 4 | 5 | {% block title %}DFS Dashboard | Delete User Subscription{% endblock %} 6 | 7 | {% block main %} 8 | 13 | 14 |

Delete User Subscription

15 | 16 |
Are you sure you want to delete this user subscription?
17 | 18 |
19 | User: {{ subscription.user }}
20 | Plan: {{ subscription.plan }}
21 | Billing start date: {{ subscription.date_billing_start }}
22 | Billing end date: {{ subscription.date_billing_end }}
23 | Last billing date: {{ subscription.date_billing_last }}
24 | Next billing date: {{ subscription.date_billing_next }}
25 | Active?: {{ subscription.active }}
26 | Cancelled?: {{ subscription.cancelled }} 27 |
28 | 29 |
30 | {% csrf_token %} 31 | 32 |
33 | 34 | Cancel 35 | {% endblock %} 36 | -------------------------------------------------------------------------------- /subscriptions/migrations/0006_add_slugs.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 3.0.3 on 2020-02-17 01:21 2 | 3 | from django.db import migrations, models 4 | 5 | 6 | class Migration(migrations.Migration): 7 | 8 | dependencies = [ 9 | ('subscriptions', '0005_update_recurrence_unit_default'), 10 | ] 11 | 12 | operations = [ 13 | migrations.AddField( 14 | model_name='plancost', 15 | name='slug', 16 | field=models.SlugField( 17 | blank=True, 18 | help_text='slug to reference these cost details', 19 | max_length=128, 20 | null=True, 21 | unique=True, 22 | ), 23 | ), 24 | migrations.AddField( 25 | model_name='subscriptionplan', 26 | name='slug', 27 | field=models.SlugField( 28 | blank=True, 29 | help_text='slug to reference the subscription plan', 30 | max_length=128, 31 | null=True, 32 | unique=True, 33 | ), 34 | ), 35 | migrations.AddField( 36 | model_name='planlist', 37 | name='slug', 38 | field=models.SlugField( 39 | blank=True, 40 | help_text='slug to reference the subscription plan list', 41 | max_length=128, 42 | null=True, 43 | unique=True, 44 | ), 45 | ), 46 | ] 47 | -------------------------------------------------------------------------------- /subscriptions/migrations/0003_update_plan_list_detail.py: -------------------------------------------------------------------------------- 1 | # pylint: disable=missing-docstring 2 | from django.db import migrations, models 3 | import django.db.models.deletion 4 | 5 | 6 | class Migration(migrations.Migration): 7 | dependencies = [ 8 | ('subscriptions', '0002_plan_list_addition'), 9 | ] 10 | 11 | operations = [ 12 | migrations.AddField( 13 | model_name='planlistdetail', 14 | name='order', 15 | field=models.PositiveIntegerField( 16 | default=1, 17 | help_text='Order to display plan in (lower numbers displayed first)' 18 | ), 19 | ), 20 | migrations.RemoveField( 21 | model_name='planlist', 22 | name='plans', 23 | ), 24 | migrations.AlterField( 25 | model_name='planlistdetail', 26 | name='plan', 27 | field=models.ForeignKey( 28 | on_delete=django.db.models.deletion.CASCADE, 29 | related_name='plan_list_details', 30 | to='subscriptions.SubscriptionPlan' 31 | ), 32 | ), 33 | migrations.AlterField( 34 | model_name='planlistdetail', 35 | name='plan_list', 36 | field=models.ForeignKey( 37 | on_delete=django.db.models.deletion.CASCADE, 38 | related_name='plan_list_details', 39 | to='subscriptions.PlanList' 40 | ), 41 | ), 42 | ] 43 | -------------------------------------------------------------------------------- /docs/subscriptions.rst: -------------------------------------------------------------------------------- 1 | subscriptions package 2 | ===================== 3 | 4 | .. automodule:: subscriptions 5 | :members: 6 | :undoc-members: 7 | :show-inheritance: 8 | 9 | Subpackages 10 | ----------- 11 | 12 | .. toctree:: 13 | 14 | subscriptions.templatetags 15 | 16 | Submodules 17 | ---------- 18 | 19 | subscriptions.abstract module 20 | ----------------------------- 21 | 22 | .. automodule:: subscriptions.abstract 23 | :members: 24 | :undoc-members: 25 | :show-inheritance: 26 | 27 | subscriptions.conf module 28 | ------------------------- 29 | 30 | .. automodule:: subscriptions.conf 31 | :members: 32 | :undoc-members: 33 | :show-inheritance: 34 | 35 | subscriptions.forms module 36 | -------------------------- 37 | 38 | .. automodule:: subscriptions.forms 39 | :members: 40 | :undoc-members: 41 | :show-inheritance: 42 | 43 | subscriptions.models module 44 | --------------------------- 45 | 46 | .. automodule:: subscriptions.models 47 | :members: 48 | :undoc-members: 49 | :show-inheritance: 50 | 51 | subscriptions.management.commands._manager module 52 | ------------------------------------------------- 53 | 54 | .. automodule:: subscriptions.management.commands._manager 55 | :members: 56 | :undoc-members: 57 | :show-inheritance: 58 | 59 | subscriptions.views module 60 | -------------------------- 61 | 62 | .. automodule:: subscriptions.views 63 | :members: 64 | :undoc-members: 65 | :show-inheritance: 66 | 67 | 68 | -------------------------------------------------------------------------------- /subscriptions/migrations/0004_change_recurrence_unit_type_2.py: -------------------------------------------------------------------------------- 1 | """Part 2 of migration to switch recurrence_unit to CharField.""" 2 | # pylint: disable=invalid-name, missing-docstring 3 | from django.db import migrations 4 | 5 | 6 | def convert_recurrence_unit_forward(apps, schema_editor): # pylint: disable=unused-argument 7 | """Copy integer-based unit to char-based.""" 8 | PlanCost = apps.get_model('subscriptions', 'PlanCost') 9 | 10 | # Update recurrence unit for all PlanCost instances 11 | for cost in PlanCost.objects.all(): 12 | old_unit = cost.old_recurrence_unit 13 | new_unit = str(old_unit) 14 | cost.recurrence_unit = new_unit 15 | cost.save() 16 | 17 | 18 | def convert_recurrence_unit_reverse(apps, schema_editor): # pylint: disable=unused-argument 19 | """Copy char-based unit to integer-based.""" 20 | PlanCost = apps.get_model('subscriptions', 'PlanCost') 21 | 22 | # Update recurrence unit for all PlanCost instances 23 | for cost in PlanCost.objects.all(): 24 | new_unit = cost.recurrence_unit 25 | old_unit = int(new_unit) 26 | cost.old_recurrence_unit = old_unit 27 | cost.save() 28 | 29 | 30 | class Migration(migrations.Migration): 31 | dependencies = [ 32 | ('subscriptions', '0004_change_recurrence_unit_type_1'), 33 | ] 34 | 35 | operations = [ 36 | migrations.RunPython(convert_recurrence_unit_forward, convert_recurrence_unit_reverse), 37 | migrations.RemoveField('plancost', 'old_recurrence_unit'), 38 | ] 39 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: python 2 | sudo: required 3 | dist: xenial 4 | jobs: 5 | include: 6 | # Test all actively developed Python and Django combinations 7 | # https://devguide.python.org/#status-of-python-branches 8 | # https://www.djangoproject.com/download/#supported-versions 9 | # https://docs.djangoproject.com/en/dev/faq/install/#what-python-version-can-i-use-with-django 10 | # Python 3.5 and Django 2.2 11 | - python: 3.5 12 | env: DJANGO="django>=2.2,<3.0" 13 | # Python 3.6 and Django 2.2, 3.0 14 | - python: 3.6 15 | env: DJANGO="django>=2.2,<3.0" 16 | - python: 3.6 17 | env: DJANGO="django>=3.0,<3.1" 18 | # Python 3.7 and Django 2.2, 3.0 19 | - python: 3.7 20 | env: DJANGO="django>=2.2,<3.0" 21 | - python: 3.7 22 | env: DJANGO="django>=3.0,<3.1" 23 | # Python 3.8 and Django 2.2, 3.0 24 | - python: 3.8 25 | env: DJANGO="django>=2.2,<3.0" 26 | - python: 3.8 27 | env: DJANGO="django>=3.0,<3.1" 28 | install: 29 | # Need to run update because different python versions may have 30 | # different dependencies. Once pipenv supports arrayed versions this 31 | # could be removed. 32 | - pipenv update --dev 33 | - pipenv install codecov --dev 34 | - pipenv install $DJANGO 35 | script: 36 | # Run Tests 37 | - pipenv run pytest --cov=subscriptions 38 | - codecov 39 | # Run Linters 40 | - pipenv run pylint subscriptions/ sandbox/ 41 | - pipenv run pylint tests/ --min-similarity-lines=12 42 | - pipenv run pycodestyle --show-source subscriptions/ sandbox/ tests/ 43 | -------------------------------------------------------------------------------- /tests/conftest.py: -------------------------------------------------------------------------------- 1 | """Configuration file for pytest.""" 2 | import django 3 | from django.conf import settings 4 | 5 | import pytest 6 | 7 | 8 | def pytest_configure(): 9 | """Setups initial testing configuration.""" 10 | # Setup the bare minimum Django settings 11 | django_settings = { 12 | 'DATABASES': { 13 | 'default': { 14 | 'ENGINE': 'django.db.backends.sqlite3', 15 | 'NAME': ':memory:', 16 | } 17 | }, 18 | 'INSTALLED_APPS': { 19 | 'django.contrib.admin', 20 | 'django.contrib.auth', 21 | 'django.contrib.contenttypes', 22 | 'django.contrib.sessions', 23 | 'django.contrib.sites', 24 | 'subscriptions', 25 | }, 26 | 'MIDDLEWARE': [ 27 | 'django.contrib.sessions.middleware.SessionMiddleware', 28 | 'django.contrib.auth.middleware.AuthenticationMiddleware', 29 | 'django.contrib.messages.middleware.MessageMiddleware', 30 | ], 31 | 'ROOT_URLCONF': 'subscriptions.urls', 32 | 'TEMPLATES': [ 33 | { 34 | 'BACKEND': 'django.template.backends.django.DjangoTemplates', 35 | 'APP_DIRS': True, 36 | }, 37 | ], 38 | } 39 | 40 | settings.configure(**django_settings) 41 | 42 | # Initiate Django 43 | django.setup() 44 | 45 | 46 | @pytest.fixture 47 | def dfs(): 48 | """Fixture that returns all required models for testing DFS.""" 49 | from . import factories # pylint: disable=import-outside-toplevel 50 | 51 | return factories.DFS() 52 | -------------------------------------------------------------------------------- /subscriptions/templates/subscriptions/tag_list.html: -------------------------------------------------------------------------------- 1 | {% extends 'subscriptions/base_developer.html' %} 2 | 3 | {% load i18n %} 4 | 5 | {% block title %}DFS Dashboard | Tags{% endblock %} 6 | 7 | {% block subscriptions_styles %} 8 | 15 | {% endblock %} 16 | 17 | {% block main %} 18 | 22 | 23 |

Tags

24 | 25 | {% include 'subscriptions/snippets/messages.html' %} 26 | 27 | Create new tag 28 | 29 | {% if tags %} 30 |
31 |
32 |
{% trans "Tag name" %}
33 |
34 | 35 | {% for tag in tags %} 36 |
37 |
38 | {% trans "Tag name" %} 39 | {{ tag.tag }} 40 |
41 |
42 | {% trans "Edit" %} 43 | {% trans "Delete" %} 44 |
45 |
46 | {% endfor %} 47 | 48 | Create new tag 49 |
50 | {% else %} 51 |

{% trans "No tags have been added yet." %}

52 | {% endif %} 53 | {% endblock %} 54 | -------------------------------------------------------------------------------- /tests/subscriptions/test_views_dashboard.py: -------------------------------------------------------------------------------- 1 | """Tests for the DashboardView""" 2 | import pytest 3 | 4 | from django.contrib.auth.models import Permission 5 | from django.contrib.contenttypes.models import ContentType 6 | from django.urls import reverse 7 | 8 | from subscriptions import models 9 | 10 | 11 | def test_dashboard_template(admin_client): 12 | """Tests for proper plan_list template.""" 13 | response = admin_client.get(reverse('dfs_dashboard')) 14 | 15 | assert ( 16 | 'subscriptions/dashboard.html' in [t.name for t in response.templates] 17 | ) 18 | 19 | 20 | @pytest.mark.django_db 21 | def test_dashboard_403_if_not_authorized(client, django_user_model): 22 | """Tests for 403 error for plan list if inadequate permissions.""" 23 | django_user_model.objects.create_user(username='a', password='b') 24 | client.login(username='a', password='b') 25 | 26 | response = client.get(reverse('dfs_dashboard')) 27 | 28 | assert response.status_code == 403 29 | 30 | 31 | @pytest.mark.django_db 32 | def test_dashboard_200_if_authorized(client, django_user_model): 33 | """Tests for 200 response for plan list with adequate permissions.""" 34 | # Retrieve proper permission, add to user, and login 35 | content = ContentType.objects.get_for_model(models.SubscriptionPlan) 36 | permission = Permission.objects.get( 37 | content_type=content, codename='subscriptions' 38 | ) 39 | user = django_user_model.objects.create_user(username='a', password='b') 40 | user.user_permissions.add(permission) 41 | client.login(username='a', password='b') 42 | 43 | response = client.get(reverse('dfs_dashboard')) 44 | 45 | assert response.status_code == 200 46 | -------------------------------------------------------------------------------- /Pipfile: -------------------------------------------------------------------------------- 1 | [[source]] 2 | name = "pypi" 3 | url = "https://pypi.org/simple" 4 | verify_ssl = true 5 | 6 | [packages] 7 | django = "*" # https://github.com/django/django 8 | 9 | 10 | [dev-packages] 11 | # Django (for testing) 12 | # ------------------------------------------------------------------------------ 13 | django-debug-toolbar = "*" # https://github.com/jazzband/django-debug-toolbar 14 | 15 | # Code quality 16 | # ------------------------------------------------------------------------------ 17 | coverage = "*" # https://github.com/nedbat/coveragepy 18 | pycodestyle = "*" # https://github.com/PyCQA/pycodestyle 19 | pylint = "*" # https://github.com/PyCQA/pylint/ 20 | pylint-django = "*" # https://github.com/PyCQA/pylint-django 21 | restructuredtext-lint = "*" # https://github.com/twolfson/restructuredtext-lint 22 | 23 | # Testing 24 | # ------------------------------------------------------------------------------ 25 | factory_boy = "*" # https://github.com/FactoryBoy/factory_boy 26 | pytest = "*" # https://github.com/pytest-dev/pytest 27 | pytest-cov = "*" # https://github.com/pytest-dev/pytest-cov 28 | pytest-django = "*" # https://pytest-django.readthedocs.io/en/latest/ 29 | 30 | # Package Documentation 31 | # ------------------------------------------------------------------------------ 32 | sphinx = "*" # https://github.com/sphinx-doc/sphinx 33 | sphinx_rtd_theme = "*" # https://github.com/rtfd/sphinx_rtd_theme 34 | 35 | # Package Distribution 36 | # ------------------------------------------------------------------------------ 37 | setuptools = "*" # https://pypi.org/project/setuptools/#description 38 | wheel = "*" # https://pypi.org/project/wheel/ 39 | twine = "*" # https://github.com/pypa/twine 40 | -------------------------------------------------------------------------------- /sandbox/urls.py: -------------------------------------------------------------------------------- 1 | """URLs for the sandbox demo.""" 2 | 3 | from django.conf import settings 4 | from django.contrib import admin 5 | from django.contrib.auth.views import LoginView, LogoutView 6 | from django.urls import path, include 7 | from django.views.generic import TemplateView 8 | 9 | from subscriptions import models, urls as subscriptions_urls 10 | 11 | 12 | admin.autodiscover() 13 | 14 | urlpatterns = [ 15 | path('i18n/', include('django.conf.urls.i18n')), 16 | path('admin/', admin.site.urls), 17 | path( 18 | 'login/', 19 | LoginView.as_view( 20 | template_name='admin/login.html', 21 | extra_context={ 22 | 'title': 'django-flexible-subscriptions', 23 | 'site_title': 'Login', 24 | 'site_header': 'Sandbox site login', 25 | } 26 | ), 27 | name='login', 28 | ), 29 | path( 30 | 'logout/', 31 | LogoutView.as_view( 32 | extra_context={ 33 | 'title': 'django-flexible-subscriptions', 34 | 'site_title': 'Logout', 35 | 'site_header': 'Sandbox site logout', 36 | } 37 | ), 38 | name='logout', 39 | ), 40 | path('subscriptions/', include(subscriptions_urls)), 41 | path( 42 | '', 43 | TemplateView.as_view( 44 | extra_context={ 45 | 'plans': models.SubscriptionPlan.objects.all() 46 | }, 47 | template_name='sandbox/index.html' 48 | ) 49 | ), 50 | ] 51 | 52 | if settings.DEBUG: 53 | import debug_toolbar 54 | urlpatterns = [ 55 | path('__debug__/', include(debug_toolbar.urls)), 56 | ] + urlpatterns 57 | -------------------------------------------------------------------------------- /subscriptions/admin.py: -------------------------------------------------------------------------------- 1 | """Admin views for the Flexible Subscriptions app.""" 2 | from django.contrib import admin 3 | 4 | from subscriptions import models 5 | from subscriptions.conf import SETTINGS 6 | 7 | 8 | class PlanCostInline(admin.TabularInline): 9 | """Inline admin class for the PlanCost model.""" 10 | model = models.PlanCost 11 | fields = ( 12 | 'slug', 13 | 'recurrence_period', 14 | 'recurrence_unit', 15 | 'cost', 16 | ) 17 | extra = 0 18 | 19 | 20 | class SubscriptionPlanAdmin(admin.ModelAdmin): 21 | """Admin class for the SubscriptionPlan model.""" 22 | fields = ( 23 | 'plan_name', 24 | 'slug', 25 | 'plan_description', 26 | 'group', 27 | 'tags', 28 | 'grace_period', 29 | ) 30 | inlines = [PlanCostInline] 31 | list_display = ( 32 | 'plan_name', 33 | 'group', 34 | 'display_tags', 35 | ) 36 | prepopulated_fields = {'slug': ('plan_name',)} 37 | 38 | 39 | class UserSubscriptionAdmin(admin.ModelAdmin): 40 | """Admin class for the UserSubscription model.""" 41 | fields = ( 42 | 'user', 43 | 'date_billing_start', 44 | 'date_billing_end', 45 | 'date_billing_last', 46 | 'date_billing_next', 47 | 'active', 48 | 'cancelled', 49 | ) 50 | list_display = ( 51 | 'user', 52 | 'date_billing_last', 53 | 'date_billing_next', 54 | 'active', 55 | 'cancelled', 56 | ) 57 | 58 | 59 | class TransactionAdmin(admin.ModelAdmin): 60 | """Admin class for the SubscriptionTransaction model.""" 61 | 62 | 63 | if SETTINGS['enable_admin']: 64 | admin.site.register(models.SubscriptionPlan, SubscriptionPlanAdmin) 65 | admin.site.register(models.UserSubscription, UserSubscriptionAdmin) 66 | admin.site.register(models.SubscriptionTransaction, TransactionAdmin) 67 | -------------------------------------------------------------------------------- /tests/subscriptions/test_admin.py: -------------------------------------------------------------------------------- 1 | """Tests for the Subscriptions admin module.""" 2 | from importlib import reload 3 | from unittest.mock import patch 4 | 5 | from django.contrib import admin 6 | 7 | from subscriptions import admin as subscription_admin, models 8 | 9 | 10 | @patch.dict('subscriptions.conf.SETTINGS', {'enable_admin': True}) 11 | def test_admin_included_when_true_in_settings(): 12 | """Tests that admin views are loaded when enabled in settings.""" 13 | # pylint: disable=protected-access 14 | reload(subscription_admin) 15 | 16 | try: 17 | admin.site._registry[models.SubscriptionPlan] 18 | except KeyError: 19 | assert False 20 | else: 21 | # Remove the registered model to prevent impacting other tests 22 | admin.site._registry.pop(models.SubscriptionPlan) 23 | assert True 24 | 25 | try: 26 | admin.site._registry[models.UserSubscription] 27 | except KeyError: 28 | assert False 29 | else: 30 | # Remove the registered model to prevent impacting other tests 31 | admin.site._registry.pop(models.UserSubscription) 32 | assert True 33 | 34 | try: 35 | admin.site._registry[models.SubscriptionTransaction] 36 | except KeyError: 37 | assert False 38 | else: 39 | # Remove the registered model to prevent impacting other tests 40 | admin.site._registry.pop(models.SubscriptionTransaction) 41 | assert True 42 | 43 | 44 | @patch.dict('subscriptions.conf.SETTINGS', {'enable_admin': False}) 45 | def test_admin_excluded_when_false_in_settings(): 46 | """Tests that admin views are not loaded when disabled in settings.""" 47 | # pylint: disable=protected-access 48 | reload(subscription_admin) 49 | 50 | try: 51 | admin.site._registry[models.SubscriptionPlan] 52 | except KeyError: 53 | assert True 54 | else: 55 | assert False 56 | 57 | try: 58 | admin.site._registry[models.UserSubscription] 59 | except KeyError: 60 | assert True 61 | else: 62 | assert False 63 | try: 64 | admin.site._registry[models.SubscriptionTransaction] 65 | except KeyError: 66 | assert True 67 | else: 68 | assert False 69 | -------------------------------------------------------------------------------- /subscriptions/templates/subscriptions/plan_list_detail_list.html: -------------------------------------------------------------------------------- 1 | {% extends 'subscriptions/base_developer.html' %} 2 | 3 | {% load i18n %} 4 | 5 | {% block title %}DFS Dashboard | Manage Plan List{% endblock %} 6 | 7 | {% block subscriptions_styles %} 8 | 23 | {% endblock %} 24 | 25 | {% block main %} 26 | 31 | 32 |

Manage Plan List: {{ plan_list }}

33 | 34 | {% include 'subscriptions/snippets/messages.html' %} 35 | 36 | 37 | Add plan 38 | 39 | 40 | {% if plan_list.plan_list_details.all %} 41 |
42 |
43 |
{% trans "Subscription plan" %}
44 |
45 | 46 | {% for detail in plan_list.plan_list_details.all %} 47 |
48 |
49 | {% trans "Subscription plan" %} 50 | {{ detail.plan }} 51 |
52 |
53 | 54 | {% trans "Edit" %} 55 | 56 | 57 | {% trans "Remove" %} 58 | 59 |
60 |
61 | {% endfor %} 62 |
63 | 64 | 65 | Add plan 66 | 67 | {% else %} 68 |

{% trans "No plans have been added to this list yet." %}

69 | {% endif %} 70 | {% endblock %} 71 | -------------------------------------------------------------------------------- /tests/subscriptions/test_init.py: -------------------------------------------------------------------------------- 1 | """Tests for the __init__.py module.""" 2 | from importlib import reload 3 | from unittest.mock import patch 4 | 5 | import subscriptions 6 | 7 | 8 | @patch('subscriptions.sys.version', '3.5.1') 9 | @patch('subscriptions.django.__version__', '3.0.0') 10 | def test_python_35_depreciation_warning(recwarn): 11 | """Tests that depreciation warning fires for Python 3.5.x""" 12 | reload(subscriptions) 13 | 14 | assert len(recwarn) == 1 15 | 16 | django_111_warning = recwarn.pop(DeprecationWarning) 17 | assert issubclass(django_111_warning.category, DeprecationWarning) 18 | 19 | warning_text = ( 20 | 'django-flexible-subscription will stop supporting Python 3.5 ' 21 | 'once it reaches end-of-life (approximately September 2020). ' 22 | 'Ensure you have updated your Python version by then.' 23 | ) 24 | 25 | assert str(django_111_warning.message) == warning_text 26 | 27 | 28 | @patch('subscriptions.sys.version', '3.6.0') 29 | @patch('subscriptions.django.__version__', '3.0.0') 30 | def test_other_python_versions_depreciation_warning(recwarn): 31 | """Tests that warning doesn't fire for other Python versions.""" 32 | reload(subscriptions) 33 | 34 | assert len(recwarn) == 0 35 | 36 | 37 | # @patch('subscriptions.sys.version', '3.6.0') 38 | # @patch('subscriptions.django.__version__', '1.11.0') 39 | # def test_django_111_depreciation_warning(recwarn): 40 | # """Tests that the depreciation warning fires for Django 1.11.""" 41 | # reload(subscriptions) 42 | 43 | # assert len(recwarn) == 1 44 | 45 | # django_111_warning = recwarn.pop(DeprecationWarning) 46 | # assert issubclass(django_111_warning.category, DeprecationWarning) 47 | 48 | # warning_text = ( 49 | # 'django-flexible-subscription will stop supporting Django 1.11 LTS ' 50 | # 'once it reaches end-of-life (approximately April 2020). ' 51 | # 'Ensure you have updated your Django version by then.' 52 | # ) 53 | 54 | # assert str(django_111_warning.message) == warning_text 55 | 56 | 57 | # @patch('subscriptions.sys.version', '3.6.0') 58 | # @patch('subscriptions.django.__version__', '2.0.0') 59 | # def test_other_django_versions_depreciation_warning(recwarn): 60 | # """Tests that warning doesn't fire for other Django versions.""" 61 | # reload(subscriptions) 62 | 63 | # assert len(recwarn) == 0 64 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | """PyPI setup script for the django-flexible-subscriptions package.""" 2 | from setuptools import find_packages, setup 3 | 4 | from subscriptions import __version__ 5 | 6 | with open('README.rst', 'r') as readme_file: 7 | LONG_DESCRIPTION = readme_file.read() 8 | 9 | setup( 10 | name='django-flexible-subscriptions', 11 | version=__version__, 12 | url='https://github.com/studybuffalo/django-flexible-subscriptions', 13 | description=('A subscription and recurrent billing application for Django.'), 14 | long_description=LONG_DESCRIPTION, 15 | long_description_content_type='text/x-rst', 16 | author='Joshua Torrance', 17 | author_email='studybuffalo@gmail.com', 18 | keywords='Django, subscriptions, recurrent billing', 19 | platforms=['linux', 'windows'], 20 | packages=find_packages(exclude=['sandbox*', 'tests*']), 21 | package_data={ 22 | 'subscriptions': [ 23 | 'management/commands/*.py', 24 | 'static/subscriptions/*.css', 25 | 'templates/subscriptions/*.html', 26 | 'templates/subscriptions/snippets/*.html', 27 | ] 28 | }, 29 | project_urls={ 30 | 'Documentation': 'https://django-flexible-subscriptions.readthedocs.io/en/latest/', 31 | 'Source code': 'https://github.com/studybuffalo/django-flexible-subscriptions', 32 | 'Issues': 'https://github.com/studybuffalo/django-flexible-subscriptions/issues', 33 | }, 34 | python_requires='>=3.5', 35 | install_requires=[ 36 | 'django>=2.2', 37 | ], 38 | tests_require=[ 39 | 'pytest==6.0.1', 40 | 'pytest-cov==2.10.1', 41 | ], 42 | # See http://pypi.python.org/pypi?%3Aaction=list_classifiers 43 | classifiers=[ 44 | 'Development Status :: 4 - Beta', 45 | 'Environment :: Web Environment', 46 | 'Framework :: Django :: 2.2', 47 | 'Framework :: Django :: 3.0', 48 | 'Intended Audience :: Developers', 49 | 'License :: OSI Approved :: GNU General Public License v3 (GPLv3)', 50 | 'Operating System :: Unix', 51 | 'Programming Language :: Python :: 3', 52 | 'Programming Language :: Python :: 3.5', 53 | 'Programming Language :: Python :: 3.6', 54 | 'Programming Language :: Python :: 3.7', 55 | 'Programming Language :: Python :: 3.8', 56 | 'Topic :: Other/Nonlisted Topic' 57 | ], 58 | ) 59 | -------------------------------------------------------------------------------- /subscriptions/templates/subscriptions/plan_list_list.html: -------------------------------------------------------------------------------- 1 | {% extends 'subscriptions/base_developer.html' %} 2 | 3 | {% load i18n %} 4 | 5 | {% block title %}DFS Dashboard | Plan Lists{% endblock %} 6 | 7 | {% block subscriptions_styles %} 8 | 23 | {% endblock %} 24 | 25 | {% block main %} 26 | 30 | 31 |

Plan Lists

32 | 33 | {% include 'subscriptions/snippets/messages.html' %} 34 | 35 | Create new plan list 36 | 37 | {% if plan_lists %} 38 |
39 |
40 |
{% trans "Plan list title" %}
41 |
{% trans "Active?" %}
42 |
43 | 44 | {% for plan_list in plan_lists %} 45 |
46 |
47 | {% trans "Plan list title" %} 48 | {{ plan_list.title }} 49 |
50 |
51 | {% trans "Active?" %} 52 | {{ plan_list.active }} 53 |
54 |
55 | {% trans "Manage plans" %} 56 | {% trans "Edit" %} 57 | {% trans "Delete" %} 58 |
59 |
60 | {% endfor %} 61 | 62 | Create new plan list 63 | 64 | 65 | {% else %} 66 |

{% trans "No plan lists have been added yet." %}

67 | {% endif %} 68 | {% endblock %} 69 | -------------------------------------------------------------------------------- /sandbox/templates/sandbox/index.html: -------------------------------------------------------------------------------- 1 | {% load i18n %} 2 | 3 | 4 | 5 | 6 | 7 | Django Flexible Subscriptions Sandbox Site 8 | 9 | 10 | 11 | 62 | 63 | 64 | 65 |

Django Flexible Subscriptions Sandbox Site

66 | 67 |
68 | 90 |
91 | 92 | 93 | -------------------------------------------------------------------------------- /subscriptions/templates/subscriptions/subscribe_cancel.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 |

Cancel subscription?

31 | 32 |
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 | 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 |
43 |
{% trans "User" %}
44 |
{% trans "Plan" %}
45 |
{% trans "Transaction date" %}
46 |
{% trans "Amount" %}
47 |
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 |
68 | {% trans "View" %} 69 |
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 |
55 |
{% trans "Plan name" %}
56 |
{% trans "Description" %}
57 |
{% trans "Tags" %}
58 |
{% trans "Costs" %}
59 |
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 |
91 | {% trans "Edit" %} 92 | {% trans "Delete" %} 93 |
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 |
45 |
{% trans "User" %}
46 |
{% trans "Plan" %}
47 |
{% trans "Billing start date" %}
48 |
{% trans "Billing end date" %}
49 |
{% trans "Last billing date" %}
50 |
{% trans "Next billing date" %}
51 |
{% trans "Active?" %}
52 |
{% trans "Cancelled?" %}
53 |
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 |
93 | {% trans "Edit" %} 94 | {% trans "Delete" %} 95 |
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 | --------------------------------------------------------------------------------