Stripe checkout #104411
@ -52,3 +52,7 @@ MAILGUN_WEBHOOK_SECRET=
|
||||
GOOGLE_RECAPTCHA_SITE_KEY=
|
||||
GOOGLE_RECAPTCHA_SECRET_KEY=
|
||||
GOOGLE_ANALYTICS_TRACKING_ID=
|
||||
|
||||
STRIPE_API_PUBLISHABLE_KEY=
|
||||
STRIPE_API_SECRET_KEY=
|
||||
STRIPE_ENDPOINT_SECRET=
|
||||
|
@ -1,53 +1,10 @@
|
||||
from django.contrib.auth import get_user_model
|
||||
from django.db.models import signals
|
||||
|
||||
from factory.django import DjangoModelFactory
|
||||
import factory
|
||||
|
||||
import looper.models
|
||||
from looper.tests.factories import SubscriptionFactory
|
||||
|
||||
from common.tests.factories.users import UserFactory
|
||||
from subscriptions.models import Team
|
||||
|
||||
User = get_user_model()
|
||||
|
||||
|
||||
class PaymentMethodFactory(DjangoModelFactory):
|
||||
class Meta:
|
||||
model = looper.models.PaymentMethod
|
||||
|
||||
gateway_id = factory.LazyAttribute(lambda _: looper.models.Gateway.objects.first().pk)
|
||||
|
||||
user = factory.SubFactory('common.tests.factories.users.UserFactory')
|
||||
|
||||
|
||||
class SubscriptionFactory(DjangoModelFactory):
|
||||
class Meta:
|
||||
model = looper.models.Subscription
|
||||
|
||||
plan_id = factory.LazyAttribute(lambda _: looper.models.Plan.objects.first().pk)
|
||||
payment_method = factory.SubFactory(PaymentMethodFactory)
|
||||
|
||||
user = factory.SubFactory('common.tests.factories.users.UserFactory')
|
||||
|
||||
|
||||
class OrderFactory(DjangoModelFactory):
|
||||
class Meta:
|
||||
model = looper.models.Order
|
||||
|
||||
user = factory.SubFactory('common.tests.factories.users.UserFactory')
|
||||
subscription = factory.SubFactory(SubscriptionFactory)
|
||||
payment_method = factory.SubFactory(PaymentMethodFactory)
|
||||
|
||||
|
||||
class TransactionFactory(DjangoModelFactory):
|
||||
class Meta:
|
||||
model = looper.models.Transaction
|
||||
|
||||
user = factory.SubFactory('common.tests.factories.users.UserFactory')
|
||||
order = factory.SubFactory(OrderFactory)
|
||||
payment_method = factory.SubFactory(PaymentMethodFactory)
|
||||
|
||||
|
||||
class TeamFactory(DjangoModelFactory):
|
||||
class Meta:
|
||||
@ -55,38 +12,3 @@ class TeamFactory(DjangoModelFactory):
|
||||
|
||||
name = factory.Faker('text', max_nb_chars=15)
|
||||
subscription = factory.SubFactory(SubscriptionFactory)
|
||||
|
||||
|
||||
@factory.django.mute_signals(signals.pre_save, signals.post_save)
|
||||
class CustomerFactory(DjangoModelFactory):
|
||||
class Meta:
|
||||
model = looper.models.Customer
|
||||
|
||||
billing_email = factory.LazyAttribute(lambda o: '%s.billing@example.com' % o.user.username)
|
||||
user = factory.SubFactory('common.tests.factories.users.UserFactory')
|
||||
|
||||
|
||||
@factory.django.mute_signals(signals.pre_save, signals.post_save)
|
||||
class AddressFactory(DjangoModelFactory):
|
||||
class Meta:
|
||||
model = looper.models.Address
|
||||
|
||||
user = factory.SubFactory('common.tests.factories.users.UserFactory')
|
||||
|
||||
|
||||
# TODO(anna): this should probably move to looper
|
||||
@factory.django.mute_signals(signals.pre_save, signals.post_save)
|
||||
def create_customer_with_billing_address(**data):
|
||||
"""Use factories to create a User with a Customer and Address records."""
|
||||
customer_field_names = {f.name for f in looper.models.Customer._meta.get_fields()}
|
||||
address_field_names = {f.name for f in looper.models.Address._meta.get_fields()}
|
||||
user_field_names = {f.name for f in User._meta.get_fields()}
|
||||
|
||||
address_kwargs = {k: v for k, v in data.items() if k in address_field_names}
|
||||
customer_kwargs = {k: v for k, v in data.items() if k in customer_field_names}
|
||||
user_kwargs = {k: v for k, v in data.items() if k in user_field_names}
|
||||
|
||||
user = UserFactory(**user_kwargs)
|
||||
AddressFactory(user=user, **address_kwargs)
|
||||
CustomerFactory(user=user, **customer_kwargs)
|
||||
return user
|
||||
|
61
poetry.lock
generated
61
poetry.lock
generated
@ -504,19 +504,19 @@ test = ["djangorestframework", "graphene-django", "pytest", "pytest-cov", "pytes
|
||||
|
||||
[[package]]
|
||||
name = "django-debug-toolbar"
|
||||
version = "2.2.1"
|
||||
version = "4.3.0"
|
||||
description = "A configurable set of panels that display various debug information about the current request/response."
|
||||
category = "dev"
|
||||
optional = false
|
||||
python-versions = ">=3.5"
|
||||
python-versions = ">=3.8"
|
||||
files = [
|
||||
{file = "django-debug-toolbar-2.2.1.tar.gz", hash = "sha256:7aadab5240796ffe8e93cc7dfbe2f87a204054746ff7ff93cd6d4a0c3747c853"},
|
||||
{file = "django_debug_toolbar-2.2.1-py3-none-any.whl", hash = "sha256:7feaee934608f5cdd95432154be832fe30fda6c1249018191e2c27bc0b6a965e"},
|
||||
{file = "django_debug_toolbar-4.3.0-py3-none-any.whl", hash = "sha256:e09b7dcb8417b743234dfc57c95a7c1d1d87a88844abd13b4c5387f807b31bf6"},
|
||||
{file = "django_debug_toolbar-4.3.0.tar.gz", hash = "sha256:0b0dddee5ea29b9cb678593bc0d7a6d76b21d7799cb68e091a2148341a80f3c4"},
|
||||
]
|
||||
|
||||
[package.dependencies]
|
||||
Django = ">=1.11"
|
||||
sqlparse = ">=0.2.0"
|
||||
django = ">=3.2.4"
|
||||
sqlparse = ">=0.2"
|
||||
|
||||
[[package]]
|
||||
name = "django-loginas"
|
||||
@ -532,19 +532,22 @@ files = [
|
||||
|
||||
[[package]]
|
||||
name = "django-nested-admin"
|
||||
version = "3.4.0"
|
||||
version = "4.0.2"
|
||||
description = "Django admin classes that allow for nested inlines"
|
||||
category = "main"
|
||||
optional = false
|
||||
python-versions = "*"
|
||||
python-versions = ">=3.6"
|
||||
files = [
|
||||
{file = "django-nested-admin-3.4.0.tar.gz", hash = "sha256:fbcf20d75a73dcbcc6285793ff936eff8df4deba5b169e0c1ab765394c562805"},
|
||||
{file = "django_nested_admin-3.4.0-py2.py3-none-any.whl", hash = "sha256:c6852c5ac632f4e698b6beda455006fd464c852459e5e858a6db832cdb23d9e1"},
|
||||
{file = "django-nested-admin-4.0.2.tar.gz", hash = "sha256:79a5ce80b81c41a0f48f778fb556a3721029df48580bfce60a5962ffa2ef2d47"},
|
||||
{file = "django_nested_admin-4.0.2-py3-none-any.whl", hash = "sha256:74d8fc0d0f17862ffcece66d39b8814cb649878b3b6ba8438076145a688ea473"},
|
||||
]
|
||||
|
||||
[package.dependencies]
|
||||
python-monkey-business = ">=1.0.0"
|
||||
six = "*"
|
||||
|
||||
[package.extras]
|
||||
dev = ["Pillow", "black", "dj-database-url", "django-selenosis", "flake8", "pytest", "pytest-cov", "pytest-django", "pytest-xdist", "selenium"]
|
||||
test = ["Pillow", "dj-database-url", "django-selenosis", "pytest", "pytest-cov", "pytest-django", "pytest-xdist", "selenium"]
|
||||
|
||||
[[package]]
|
||||
name = "django-pipeline"
|
||||
@ -1297,7 +1300,7 @@ six = ">=1.11.0"
|
||||
|
||||
[[package]]
|
||||
name = "looper"
|
||||
version = "2.1.2"
|
||||
version = "3.2.9"
|
||||
description = ""
|
||||
category = "main"
|
||||
optional = false
|
||||
@ -1313,19 +1316,21 @@ braintree = "4.17.1"
|
||||
colorhash = "^1.0.3"
|
||||
django = "^2.2.0 || 3.0 || 3.0.* || 3.2.*"
|
||||
django-countries = "^7.2.1"
|
||||
django-nested-admin = "^4.0.2"
|
||||
django-pipeline = "^2.0.6"
|
||||
geoip2 = "^3.0"
|
||||
python-dateutil = "^2.7"
|
||||
python-stdnum = "^1.16"
|
||||
requests = "^2.22"
|
||||
stripe = "7.1.0"
|
||||
xhtml2pdf = "^0.2"
|
||||
zeep = "4.0.0"
|
||||
|
||||
[package.source]
|
||||
type = "git"
|
||||
url = "https://projects.blender.org/infrastructure/looper.git"
|
||||
reference = "c5f54b309d0"
|
||||
resolved_reference = "c5f54b309d001912ef29ea5482864f97be8a2773"
|
||||
reference = "56c6d4b"
|
||||
resolved_reference = "56c6d4b612a4b0cae032a6b5f9dad0ac4c3608ac"
|
||||
|
||||
[[package]]
|
||||
name = "lxml"
|
||||
@ -2674,6 +2679,22 @@ files = [
|
||||
{file = "sqlparse-0.4.3.tar.gz", hash = "sha256:69ca804846bb114d2ec380e4360a8a340db83f0ccf3afceeb1404df028f57268"},
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "stripe"
|
||||
version = "7.1.0"
|
||||
description = "Python bindings for the Stripe API"
|
||||
category = "main"
|
||||
optional = false
|
||||
python-versions = ">=3.6"
|
||||
files = [
|
||||
{file = "stripe-7.1.0-py2.py3-none-any.whl", hash = "sha256:efd1e54825752c41bb311497cb5b6ae745464a57ca63bbe2847984a2409bcb0a"},
|
||||
{file = "stripe-7.1.0.tar.gz", hash = "sha256:9cc2632230d5742eeb779af2b41c1510e724f498a296dfb40507de98d563f9a2"},
|
||||
]
|
||||
|
||||
[package.dependencies]
|
||||
requests = {version = ">=2.20", markers = "python_version >= \"3.0\""}
|
||||
typing-extensions = {version = ">=4.5.0", markers = "python_version >= \"3.7\""}
|
||||
|
||||
[[package]]
|
||||
name = "tblib"
|
||||
version = "3.0.0"
|
||||
@ -2770,14 +2791,14 @@ files = [
|
||||
|
||||
[[package]]
|
||||
name = "typing-extensions"
|
||||
version = "4.4.0"
|
||||
description = "Backported and Experimental Type Hints for Python 3.7+"
|
||||
version = "4.12.1"
|
||||
description = "Backported and Experimental Type Hints for Python 3.8+"
|
||||
category = "main"
|
||||
optional = false
|
||||
python-versions = ">=3.7"
|
||||
python-versions = ">=3.8"
|
||||
files = [
|
||||
{file = "typing_extensions-4.4.0-py3-none-any.whl", hash = "sha256:16fa4864408f655d35ec496218b85f79b3437c829e93320c7c9215ccfd92489e"},
|
||||
{file = "typing_extensions-4.4.0.tar.gz", hash = "sha256:1511434bb92bf8dd198c12b1cc812e800d4181cfcb867674e0f8279cc93087aa"},
|
||||
{file = "typing_extensions-4.12.1-py3-none-any.whl", hash = "sha256:6024b58b69089e5a89c347397254e35f1bf02a907728ec7fee9bf0fe837d203a"},
|
||||
{file = "typing_extensions-4.12.1.tar.gz", hash = "sha256:915f5e35ff76f56588223f15fdd5938f9a1cf9195c0de25130c627e4d597f6d1"},
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@ -2926,4 +2947,4 @@ testing = ["flake8 (<5)", "func-timeout", "jaraco.functools", "jaraco.itertools"
|
||||
[metadata]
|
||||
lock-version = "2.0"
|
||||
python-versions = "^3.8"
|
||||
content-hash = "11610e2657ee40c139b3aa89ccc597309b77d5b1fa8dcc9358cbe06fb3ac5796"
|
||||
content-hash = "8ca93ee62a2b6e396676492b70085f90ca192e5a84d742f032e884c3ef691d41"
|
||||
|
@ -14,7 +14,7 @@ libsasscompiler = "^0.1.5"
|
||||
jsmin = "3.0.0"
|
||||
sorl-thumbnail = "^12.10.0"
|
||||
mistune = "2.0.0a4"
|
||||
looper = {git = "https://projects.blender.org/infrastructure/looper.git", rev = "c5f54b309d0"}
|
||||
looper = {git = "https://projects.blender.org/infrastructure/looper.git", rev = "56c6d4b"}
|
||||
Pillow = "^8.0"
|
||||
django-storages = {extras = ["google"], version = "1.11.1"}
|
||||
pymongo = "^3.10.1"
|
||||
@ -32,7 +32,7 @@ requests-oauthlib = "^1.3.0"
|
||||
django-activity-stream = "^0.9.0"
|
||||
django-background-tasks-updated = {git = "https://projects.blender.org/infrastructure/django-background-tasks.git", rev ="2cbe547"}
|
||||
django-anymail = {extras = ["mailgun"], version = "8.2"}
|
||||
django-nested-admin = "^3.3.3"
|
||||
django-nested-admin = "^4.0.2"
|
||||
html5lib = "1.1"
|
||||
braintree = "4.17.1"
|
||||
python-stdnum = "^1.16"
|
||||
@ -57,7 +57,7 @@ django-stubs = "^1.5"
|
||||
pre-commit = "2.16.0"
|
||||
ipython = "^7.17"
|
||||
factory-boy = "^3.0"
|
||||
django-debug-toolbar = "^2.2"
|
||||
django-debug-toolbar = "^4.2.0"
|
||||
flake8 = "^3.8.3"
|
||||
flake8-docstrings = "^1.5.0"
|
||||
freezegun = "^1.0.0"
|
||||
|
@ -506,6 +506,12 @@ GATEWAYS = {
|
||||
'supported_collection_methods': {'automatic', 'manual'},
|
||||
},
|
||||
'bank': {'supported_collection_methods': {'manual'}},
|
||||
'stripe': {
|
||||
'api_publishable_key': _get('STRIPE_API_PUBLISHABLE_KEY'),
|
||||
'api_secret_key': _get('STRIPE_API_SECRET_KEY'),
|
||||
'endpoint_secret': _get('STRIPE_ENDPOINT_SECRET'),
|
||||
'supported_collection_methods': {'automatic'},
|
||||
},
|
||||
}
|
||||
|
||||
# Optional Sentry configuration
|
||||
@ -678,5 +684,15 @@ S3DIRECT_DESTINATIONS = {
|
||||
},
|
||||
}
|
||||
|
||||
# A list of payment method types used with stripe for setting a recurring payment:
|
||||
# https://stripe.com/docs/api/checkout/sessions/create#create_checkout_session-payment_method_types
|
||||
STRIPE_OFF_SESSION_PAYMENT_METHOD_TYPES = [
|
||||
'card',
|
||||
'link',
|
||||
'paypal',
|
||||
]
|
||||
|
||||
STRIPE_CHECKOUT_SUBMIT_TYPE = 'donate'
|
||||
|
||||
# Maximum number of attempts for failing background tasks
|
||||
MAX_ATTEMPTS = 3
|
||||
|
@ -5,13 +5,11 @@ import logging
|
||||
from django import forms
|
||||
from django.core.exceptions import ValidationError
|
||||
from django.forms.fields import Field
|
||||
from django.forms.models import model_to_dict
|
||||
|
||||
from localflavor.administrative_areas import ADMINISTRATIVE_AREAS
|
||||
from localflavor.generic.validators import validate_country_postcode
|
||||
from stdnum.eu import vat
|
||||
import localflavor.exceptions
|
||||
|
||||
import looper.form_fields
|
||||
import looper.forms
|
||||
import looper.models
|
||||
@ -48,14 +46,6 @@ REQUIRED_FIELDS = {
|
||||
|
||||
|
||||
class BillingAddressForm(forms.ModelForm):
|
||||
"""Unify Customer and Address in a single form."""
|
||||
|
||||
# Customer.billing_email is exposed as email in the Form
|
||||
# because Looper scripts and forms already use "email" everywhere.
|
||||
__customer_fields = {'billing_email': 'email', 'vat_number': 'vat_number'}
|
||||
# Colliding "full_name" and "company" values are taken from and saved to the Address.
|
||||
# FIXME(anna): do we need to use company and full_name on the Customer or only Address?
|
||||
|
||||
class Meta:
|
||||
model = looper.models.Address
|
||||
fields = looper.models.Address.PUBLIC_FIELDS
|
||||
@ -81,20 +71,6 @@ class BillingAddressForm(forms.ModelForm):
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
"""Load additional model data from Customer and set form placeholders."""
|
||||
instance: looper.models.Address = kwargs.get('instance')
|
||||
if instance:
|
||||
assert isinstance(instance, looper.models.Address), 'Must be an instance of Address'
|
||||
customer = instance.user.customer
|
||||
initial = kwargs.get('initial') or {}
|
||||
customer_data = model_to_dict(customer, self.__customer_fields.keys(), {})
|
||||
# Remap the fields, e.g. turning "billing_email" into "email"
|
||||
customer_form_data = {v: customer_data[k] for k, v in self.__customer_fields.items()}
|
||||
# Add Customer data into initial,
|
||||
# making sure that it still overrides the instance data, as it's supposed to
|
||||
kwargs['initial'] = {
|
||||
**customer_form_data,
|
||||
**initial,
|
||||
}
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
# Set placeholder values on all form fields
|
||||
@ -160,43 +136,33 @@ class BillingAddressForm(forms.ModelForm):
|
||||
)
|
||||
|
||||
def save(self, commit=True):
|
||||
"""Save Customer data as well."""
|
||||
"""Save cleared region field."""
|
||||
# Validation against region choices is already done, because choices are set on __init__,
|
||||
# however Django won't set the updated blank region value if was omitted from the form.
|
||||
if self.cleaned_data['region'] == '':
|
||||
self.instance.region = ''
|
||||
instance = super().save(commit=commit)
|
||||
|
||||
customer = instance.user.customer
|
||||
for model_field, form_field in self.__customer_fields.items():
|
||||
setattr(customer, model_field, self.cleaned_data[form_field])
|
||||
if commit:
|
||||
customer.save(update_fields=self.__customer_fields)
|
||||
return instance
|
||||
|
||||
|
||||
class BillingAddressReadonlyForm(forms.ModelForm):
|
||||
"""Display the billing details in a payment form but neither validate nor update them.
|
||||
class PaymentForm(BillingAddressForm):
|
||||
"""Handle PlanVariation ID and payment method details in the second step of the checkout.
|
||||
|
||||
Used in PaymentMethodChangeView and PayExistingOrderView.
|
||||
Billing details are displayed as read-only and cannot be edited,
|
||||
but are still used by the payment flow.
|
||||
"""
|
||||
|
||||
class Meta:
|
||||
model = looper.models.Address
|
||||
fields = looper.models.Address.PUBLIC_FIELDS
|
||||
gateway = looper.form_fields.GatewayChoiceField()
|
||||
price = forms.CharField(widget=forms.HiddenInput(), required=True)
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
"""Disable all the billing details fields.
|
||||
# These are used when a payment fails, so that the next attempt to pay can reuse
|
||||
# the already-created subscription and order.
|
||||
subscription_pk = forms.CharField(widget=forms.HiddenInput(), required=False)
|
||||
order_pk = forms.CharField(widget=forms.HiddenInput(), required=False)
|
||||
|
||||
The billing details are only for display and for use by the payment flow.
|
||||
"""
|
||||
super().__init__(*args, **kwargs)
|
||||
for field_name, field in self.fields.items():
|
||||
if field_name not in BILLING_DETAILS_PLACEHOLDERS:
|
||||
continue
|
||||
field.disabled = True
|
||||
|
||||
email = forms.EmailField(required=False, disabled=True)
|
||||
class AutomaticPaymentForm(PaymentForm):
|
||||
pass
|
||||
|
||||
|
||||
class SelectPlanVariationForm(forms.Form):
|
||||
@ -223,53 +189,13 @@ class SelectPlanVariationForm(forms.Form):
|
||||
)
|
||||
|
||||
|
||||
class PaymentForm(BillingAddressForm):
|
||||
"""Handle PlanVariation ID and payment method details in the second step of the checkout.
|
||||
|
||||
Billing details are displayed as read-only and cannot be edited,
|
||||
but are still used by the payment flow.
|
||||
"""
|
||||
|
||||
payment_method_nonce = forms.CharField(initial='set-in-javascript', widget=forms.HiddenInput())
|
||||
gateway = looper.form_fields.GatewayChoiceField()
|
||||
device_data = forms.CharField(
|
||||
initial='set-in-javascript', widget=forms.HiddenInput(), required=False
|
||||
)
|
||||
|
||||
price = forms.CharField(widget=forms.HiddenInput(), required=True)
|
||||
|
||||
# These are used when a payment fails, so that the next attempt to pay can reuse
|
||||
# the already-created subscription and order.
|
||||
subscription_pk = forms.CharField(widget=forms.HiddenInput(), required=False)
|
||||
order_pk = forms.CharField(widget=forms.HiddenInput(), required=False)
|
||||
|
||||
|
||||
class AutomaticPaymentForm(PaymentForm):
|
||||
"""Same as the PaymentForm, but only allows payment gateways that support transactions."""
|
||||
|
||||
gateway = looper.form_fields.GatewayChoiceField(
|
||||
queryset=looper.models.Gateway.objects.filter(
|
||||
name__in=looper.gateways.Registry.gateway_names_supports_transactions()
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
class PayExistingOrderForm(BillingAddressReadonlyForm):
|
||||
class PayExistingOrderForm(forms.Form): # TODO
|
||||
"""Same as AutomaticPaymentForm, but doesn't validate or update billing details."""
|
||||
|
||||
payment_method_nonce = forms.CharField(initial='set-in-javascript', widget=forms.HiddenInput())
|
||||
gateway = looper.form_fields.GatewayChoiceField(
|
||||
queryset=looper.models.Gateway.objects.filter(
|
||||
name__in=looper.gateways.Registry.gateway_names_supports_transactions()
|
||||
)
|
||||
)
|
||||
device_data = forms.CharField(
|
||||
initial='set-in-javascript', widget=forms.HiddenInput(), required=False
|
||||
)
|
||||
price = forms.CharField(widget=forms.HiddenInput(), required=True)
|
||||
|
||||
|
||||
class ChangePaymentMethodForm(BillingAddressReadonlyForm, looper.forms.ChangePaymentMethodForm):
|
||||
class ChangePaymentMethodForm(forms.Form): # TODO
|
||||
"""Add full billing address to the change payment form."""
|
||||
|
||||
pass
|
||||
|
@ -1,29 +1,10 @@
|
||||
from django.conf import settings
|
||||
from django.core.management import call_command
|
||||
from django.db import migrations
|
||||
|
||||
|
||||
form_description = {
|
||||
'bank': 'When choosing to pay by bank, you will be required to manually perform payments and the subscription will be activated only when we receive the funds.',
|
||||
'braintree': 'Automatic charges',
|
||||
}
|
||||
frontend_names = {
|
||||
'bank': 'Bank Transfer',
|
||||
'braintree': 'Credit Card or PayPal',
|
||||
}
|
||||
|
||||
|
||||
def add_gateways(apps, schema_editor):
|
||||
Gateway = apps.get_model('looper', 'Gateway')
|
||||
|
||||
for (name, _) in Gateway._meta.get_field('name').choices:
|
||||
if name == 'mock':
|
||||
continue
|
||||
Gateway.objects.get_or_create(
|
||||
name=name,
|
||||
frontend_name=frontend_names.get(name),
|
||||
is_default=name.lower() == 'braintree',
|
||||
form_description=form_description.get(name),
|
||||
)
|
||||
call_command('loaddata', 'gateways.json', app_label='looper')
|
||||
|
||||
|
||||
def remove_gateways(apps, schema_editor):
|
||||
|
@ -19,7 +19,7 @@ def has_active_subscription(user: User) -> bool:
|
||||
active_subscriptions: 'QuerySet[Subscription]' = Subscription.objects.active()
|
||||
|
||||
return active_subscriptions.filter(
|
||||
Q(user_id=user.id) | Q(team__team_users__user_id=user.id)
|
||||
Q(customer__user_id=user.id) | Q(team__team_users__user_id=user.id)
|
||||
).exists()
|
||||
|
||||
|
||||
@ -33,7 +33,9 @@ def has_non_legacy_subscription(user: User) -> bool:
|
||||
|
||||
subscriptions: 'QuerySet[Subscription]' = Subscription.objects.filter(is_legacy=False)
|
||||
|
||||
return subscriptions.filter(Q(user_id=user.id) | Q(team__team_users__user_id=user.id)).exists()
|
||||
return subscriptions.filter(
|
||||
Q(customer__user_id=user.id) | Q(team__team_users__user_id=user.id)
|
||||
).exists()
|
||||
|
||||
|
||||
def has_subscription(user: User) -> bool:
|
||||
@ -42,7 +44,7 @@ def has_subscription(user: User) -> bool:
|
||||
return False
|
||||
|
||||
return Subscription.objects.filter(
|
||||
Q(user_id=user.id) | Q(team__team_users__user_id=user.id)
|
||||
Q(customer__user_id=user.id) | Q(team__team_users__user_id=user.id)
|
||||
).exists()
|
||||
|
||||
|
||||
@ -51,7 +53,8 @@ def should_redirect_to_billing(user: User) -> bool:
|
||||
if not user.is_authenticated:
|
||||
return False
|
||||
|
||||
if user.subscription_set.exclude(status__in=Subscription._CANCELLED_STATUSES).count() == 0:
|
||||
customer = user.customer
|
||||
if customer.subscription_set.exclude(status__in=Subscription._CANCELLED_STATUSES).count() == 0:
|
||||
# Only cancelled subscriptions, no need to redirect to billing
|
||||
return False
|
||||
|
||||
@ -60,7 +63,7 @@ def should_redirect_to_billing(user: User) -> bool:
|
||||
# so this seems to be the only currently available way to tell
|
||||
# when to stop showing the checkout to the customer.
|
||||
subscription.latest_order() and subscription.payment_method
|
||||
for subscription in user.subscription_set.all()
|
||||
for subscription in customer.subscription_set.all()
|
||||
)
|
||||
|
||||
|
||||
@ -76,4 +79,4 @@ def has_not_yet_cancelled_subscription(user: User) -> bool:
|
||||
status__in=Subscription._CANCELLED_STATUSES
|
||||
)
|
||||
|
||||
return not_yet_cancelled_subscriptions.filter(Q(user_id=user.id)).exists()
|
||||
return not_yet_cancelled_subscriptions.filter(Q(customer__user_id=user.id)).exists()
|
||||
|
@ -3,12 +3,13 @@ from typing import Set
|
||||
import logging
|
||||
|
||||
from django.contrib.auth import get_user_model
|
||||
from django.db import transaction
|
||||
from django.db.models import Q
|
||||
from django.dispatch import receiver
|
||||
import alphabetic_timestamp as ats
|
||||
import django.db.models.signals as django_signals
|
||||
|
||||
from looper.models import Customer, Order
|
||||
from looper.models import Order
|
||||
import looper.admin_log
|
||||
import looper.signals
|
||||
|
||||
@ -30,17 +31,40 @@ def timebased_order_number():
|
||||
|
||||
|
||||
@receiver(django_signals.post_save, sender=User)
|
||||
def create_customer(sender, instance: User, created, **kwargs):
|
||||
def create_customer(sender, instance: User, created, raw, **kwargs):
|
||||
"""Create Customer on User creation."""
|
||||
from looper.models import Customer
|
||||
|
||||
if raw:
|
||||
return
|
||||
|
||||
if not created:
|
||||
return
|
||||
logger.debug("Creating Customer for user %i" % instance.id)
|
||||
# Assume billing name and email are the same, they should be able to change them later
|
||||
Customer.objects.create(
|
||||
user_id=instance.pk,
|
||||
billing_email=instance.email,
|
||||
full_name=instance.full_name,
|
||||
)
|
||||
|
||||
my_log = logger.getChild('create_customer')
|
||||
try:
|
||||
customer = instance.customer
|
||||
except Customer.DoesNotExist:
|
||||
pass
|
||||
else:
|
||||
my_log.debug(
|
||||
'Newly created User %d already has a Customer %d, not creating new one',
|
||||
instance.pk,
|
||||
customer.pk,
|
||||
)
|
||||
billing_address = customer.billing_address
|
||||
my_log.info('Creating new billing address due to creation of user %s', instance.pk)
|
||||
if not billing_address.pk:
|
||||
billing_address.email = instance.email
|
||||
billing_address.save()
|
||||
return
|
||||
|
||||
my_log.info('Creating new Customer due to creation of user %s', instance.pk)
|
||||
with transaction.atomic():
|
||||
customer = Customer.objects.create(user=instance)
|
||||
billing_address = customer.billing_address
|
||||
billing_address.email = instance.email
|
||||
billing_address.save()
|
||||
|
||||
|
||||
@receiver(django_signals.pre_save, sender=Order)
|
||||
|
@ -45,7 +45,7 @@ def send_mail_bank_transfer_required(subscription_id: int):
|
||||
"""Send out an email notifying about the required bank transfer payment."""
|
||||
subscription = looper.models.Subscription.objects.get(pk=subscription_id)
|
||||
user = subscription.user
|
||||
email = user.customer.billing_email or user.email
|
||||
email = user.customer.billing_address.email or user.email
|
||||
assert (
|
||||
email
|
||||
), f'Cannot send notification about bank payment for subscription {subscription.pk}: no email'
|
||||
@ -90,7 +90,7 @@ def send_mail_subscription_status_changed(subscription_id: int):
|
||||
"""Send out an email notifying about the activated subscription."""
|
||||
subscription = looper.models.Subscription.objects.get(pk=subscription_id)
|
||||
user = subscription.user
|
||||
email = user.customer.billing_email or user.email
|
||||
email = user.customer.billing_address.email or user.email
|
||||
assert email, f'Cannot send notification about subscription {subscription.pk} status: no email'
|
||||
if is_noreply(email):
|
||||
raise
|
||||
@ -135,7 +135,7 @@ def send_mail_automatic_payment_performed(order_id: int, transaction_id: int):
|
||||
transaction = looper.models.Transaction.objects.get(pk=transaction_id)
|
||||
user = order.user
|
||||
customer = user.customer
|
||||
email = customer.billing_email or user.email
|
||||
email = customer.billing_address.email or user.email
|
||||
logger.debug('Sending %r notification to %s', order.status, email)
|
||||
|
||||
# An Unsubscribe record will prevent this message from being delivered by Mailgun.
|
||||
@ -282,7 +282,7 @@ def send_mail_no_payment_method(order_id: int):
|
||||
|
||||
user = order.user
|
||||
customer = user.customer
|
||||
email = customer.billing_email or user.email
|
||||
email = customer.billing_address.email or user.email
|
||||
logger.debug('Sending %r notification to %s', order.status, email)
|
||||
|
||||
# An Unsubscribe record will prevent this message from being delivered by Mailgun.
|
||||
|
@ -3,7 +3,7 @@
|
||||
<table>
|
||||
<tbody>
|
||||
{# Owned subscriptions #}
|
||||
{% for subscription in user.subscription_set.all %}
|
||||
{% for subscription in user.customer.subscription_set.all %}
|
||||
<tr>
|
||||
<td class="fw-bold">{% if subscription.team %}Team {% endif %}Subscription #{{ subscription.pk }}</td>
|
||||
<td>{% include "subscriptions/components/pretty_status.html" %}</td>
|
||||
|
@ -6,7 +6,7 @@
|
||||
{% endblock header_logo %}
|
||||
|
||||
{% block body %}
|
||||
<p>Dear {% firstof user.customer.full_name user.customer.billing_address.full_name user.full_name user.email %},</p>
|
||||
<p>Dear {% firstof user.customer.billing_address.full_name user.customer.billing_address.full_name user.full_name user.email %},</p>
|
||||
{% block content %}{% endblock content %}
|
||||
<p>Manage subscription in your billing settings: <a href="{{ billing_url }}">{{ billing_url }}</a>.</p>
|
||||
<p>
|
||||
|
@ -1,4 +1,4 @@
|
||||
Dear {% firstof user.customer.full_name user.customer.billing_address.full_name user.full_name user.email %},
|
||||
Dear {% firstof user.customer.billing_address.full_name user.customer.billing_address.full_name user.full_name user.email %},
|
||||
{% block content %}{% endblock content %}
|
||||
Manage subscription in your billing settings: {{ billing_url }}.
|
||||
|
||||
|
@ -1,7 +1,7 @@
|
||||
{% extends "subscriptions/emails/base.html" %}
|
||||
{% block body %}
|
||||
<p>
|
||||
{{ user.customer.full_name|default:user.email }} has a {% include "subscriptions/components/info.html" %} that just passed its next payment date.
|
||||
{{ user.customer.billing_address.full_name|default:user.email }} has a {% include "subscriptions/components/info.html" %} that just passed its next payment date.
|
||||
|
||||
</p>
|
||||
<p>
|
||||
|
@ -1,3 +1,3 @@
|
||||
{{ user.customer.full_name|default:user.email }} has a {% include "subscriptions/components/info.txt" %} that just passed its next payment date.
|
||||
{{ user.customer.billing_address.full_name|default:user.email }} has a {% include "subscriptions/components/info.txt" %} that just passed its next payment date.
|
||||
|
||||
See {{ admin_url }} in the Blender Studio admin.
|
||||
|
@ -7,7 +7,7 @@
|
||||
{% endblock header_logo %}
|
||||
|
||||
{% block body %}
|
||||
<p>Dear {% firstof user.customer.full_name user.customer.billing_address.full_name user.full_name user.email %},</p>
|
||||
<p>Dear {% firstof user.customer.billing_address.full_name user.customer.billing_address.full_name user.full_name user.email %},</p>
|
||||
<p>
|
||||
As you may have heard, Blender Studio's subscription system recently got a new shiny update,
|
||||
more on that in <a href="https://studio.blender.org/blog/subscription-system-update-2021/">the blog post</a>.
|
||||
|
@ -1,4 +1,4 @@
|
||||
{% load subscriptions %}Dear {% firstof user.customer.full_name user.customer.billing_address.full_name user.full_name user.email %},
|
||||
{% load subscriptions %}Dear {% firstof user.customer.billing_address.full_name user.customer.billing_address.full_name user.full_name user.email %},
|
||||
|
||||
As you may have heard, Blender Studio's subscription system recently got a new shiny update,
|
||||
more on that in the blog post https://studio.blender.org/blog/subscription-system-update-2021/ .
|
||||
|
@ -6,7 +6,7 @@
|
||||
{% endblock header_logo %}
|
||||
|
||||
{% block body %}
|
||||
<p>Dear {% firstof user.customer.full_name user.customer.billing_address.full_name user.full_name user.email %},</p>
|
||||
<p>Dear {% firstof user.customer.billing_address.full_name user.customer.billing_address.full_name user.full_name user.email %},</p>
|
||||
<p>Your Blender Studio subscription #{{subscription.pk}} has expired a while back. We miss you -- and you are missing some exciting content on Blender Studio as well.</p>
|
||||
{% if latest_posts or latest_trainings %}
|
||||
<p>Just recently, we've published:</p>
|
||||
|
@ -1,4 +1,4 @@
|
||||
Dear {% firstof user.customer.full_name user.customer.billing_address.full_name user.full_name user.email %},
|
||||
Dear {% firstof user.customer.billing_address.full_name user.customer.billing_address.full_name user.full_name user.email %},
|
||||
|
||||
Your Blender Studio subscription #{{subscription.pk}} has expired a while back. We miss you -- and you are missing some exciting content on Blender Studio as well.{% if latest_posts or latest_trainings %} Just recently, we've published:
|
||||
{% for post in latest_posts|slice:":2" %}
|
||||
|
@ -5,7 +5,6 @@ from django import template
|
||||
|
||||
from looper.models import PlanVariation, Subscription
|
||||
import looper.money
|
||||
import looper.taxes
|
||||
|
||||
import subscriptions.queries
|
||||
|
||||
|
@ -8,7 +8,7 @@ from django.urls import reverse
|
||||
import factory
|
||||
import responses
|
||||
|
||||
from common.tests.factories.subscriptions import create_customer_with_billing_address
|
||||
from looper.tests.factories import create_customer_with_billing_address
|
||||
import users.tests.util as util
|
||||
|
||||
User = get_user_model()
|
||||
@ -33,7 +33,7 @@ class BaseSubscriptionTestCase(TestCase):
|
||||
# Create the admin user used for logging
|
||||
self.admin_user = util.create_admin_log_user()
|
||||
|
||||
self.user = create_customer_with_billing_address(
|
||||
self.customer = create_customer_with_billing_address(
|
||||
full_name='Алексей Н.',
|
||||
company='Testcompany B.V.',
|
||||
street_address='Billing street 1',
|
||||
@ -43,9 +43,9 @@ class BaseSubscriptionTestCase(TestCase):
|
||||
region='North Holland',
|
||||
country='NL',
|
||||
vat_number='NL-KVK-41202535',
|
||||
billing_email='billing@example.com',
|
||||
email='billing@example.com',
|
||||
)
|
||||
self.customer = self.user.customer
|
||||
self.user = self.customer.user
|
||||
self.billing_address = self.customer.billing_address
|
||||
|
||||
def _mock_vies_response(self, is_valid=True, is_broken=False):
|
||||
@ -319,7 +319,7 @@ class BaseSubscriptionTestCase(TestCase):
|
||||
self.assertContains(response, '<h2 class="h3">Bank details:</h2>', html=True)
|
||||
self.assertContains(response, 'on hold')
|
||||
self.assertContains(response, 'NL07 INGB 0008 4489 82')
|
||||
subscription = response.wsgi_request.user.subscription_set.first()
|
||||
subscription = response.wsgi_request.user.customer.subscription_set.first()
|
||||
self.assertContains(
|
||||
response, f'Blender Studio order-{subscription.latest_order().display_number}'
|
||||
)
|
||||
@ -341,11 +341,11 @@ class BaseSubscriptionTestCase(TestCase):
|
||||
self.assertEqual(len(mail.outbox), 0)
|
||||
|
||||
def _assert_bank_transfer_email_is_sent(self, subscription):
|
||||
user = subscription.user
|
||||
customer = subscription.customer
|
||||
self.assertEqual(len(mail.outbox), 1)
|
||||
_write_mail(mail)
|
||||
email = mail.outbox[0]
|
||||
self.assertEqual(email.to, [user.customer.billing_email])
|
||||
self.assertEqual(email.to, [customer.billing_address.email])
|
||||
# TODO(anna): set the correct reply_to
|
||||
self.assertEqual(email.reply_to, [])
|
||||
# TODO(anna): set the correct from_email DEFAULT_FROM_EMAIL
|
||||
@ -384,11 +384,11 @@ class BaseSubscriptionTestCase(TestCase):
|
||||
self.assertIn('Recurring total: €\xa026.45', email_body.replace(' ', ' '))
|
||||
|
||||
def _assert_subscription_activated_email_is_sent(self, subscription):
|
||||
user = subscription.user
|
||||
customer = subscription.customer
|
||||
self.assertEqual(len(mail.outbox), 1)
|
||||
_write_mail(mail)
|
||||
email = mail.outbox[0]
|
||||
self.assertEqual(email.to, [user.customer.billing_email])
|
||||
self.assertEqual(email.to, [customer.billing_address.email])
|
||||
# TODO(anna): set the correct reply_to
|
||||
self.assertEqual(email.reply_to, [])
|
||||
# TODO(anna): set the correct from_email DEFAULT_FROM_EMAIL
|
||||
@ -397,17 +397,17 @@ class BaseSubscriptionTestCase(TestCase):
|
||||
self.assertEqual(email.alternatives[0][1], 'text/html')
|
||||
for email_body in (email.body, email.alternatives[0][0]):
|
||||
self.assertIn('activated', email_body)
|
||||
self.assertIn(f'Dear {user.customer.full_name},', email_body)
|
||||
self.assertIn(f'Dear {customer.billing_address.full_name},', email_body)
|
||||
self.assertIn(reverse('user-settings-billing'), email_body)
|
||||
self.assertIn('Automatic renewal subscription', email_body)
|
||||
self.assertIn('Blender Studio Team', email_body)
|
||||
|
||||
def _assert_team_subscription_activated_email_is_sent(self, subscription):
|
||||
user = subscription.user
|
||||
customer = subscription.customer
|
||||
self.assertEqual(len(mail.outbox), 1)
|
||||
_write_mail(mail)
|
||||
email = mail.outbox[0]
|
||||
self.assertEqual(email.to, [user.customer.billing_email])
|
||||
self.assertEqual(email.to, [customer.billing_address.email])
|
||||
# TODO(anna): set the correct reply_to
|
||||
self.assertEqual(email.reply_to, [])
|
||||
# TODO(anna): set the correct from_email DEFAULT_FROM_EMAIL
|
||||
@ -416,17 +416,17 @@ class BaseSubscriptionTestCase(TestCase):
|
||||
self.assertEqual(email.alternatives[0][1], 'text/html')
|
||||
for email_body in (email.body, email.alternatives[0][0]):
|
||||
self.assertIn('activated', email_body)
|
||||
self.assertIn(f'Dear {user.customer.full_name},', email_body)
|
||||
self.assertIn(f'Dear {customer.billing_address.full_name},', email_body)
|
||||
self.assertIn(reverse('user-settings-billing'), email_body)
|
||||
self.assertIn('Automatic renewal, 15 seats subscription', email_body)
|
||||
self.assertIn('Blender Studio Team', email_body)
|
||||
|
||||
def _assert_subscription_deactivated_email_is_sent(self, subscription):
|
||||
user = subscription.user
|
||||
customer = subscription.customer
|
||||
self.assertEqual(len(mail.outbox), 1)
|
||||
_write_mail(mail)
|
||||
email = mail.outbox[0]
|
||||
self.assertEqual(email.to, [user.customer.billing_email])
|
||||
self.assertEqual(email.to, [customer.billing_address.email])
|
||||
# TODO(anna): set the correct reply_to
|
||||
self.assertEqual(email.reply_to, [])
|
||||
# TODO(anna): set the correct from_email DEFAULT_FROM_EMAIL
|
||||
@ -444,7 +444,7 @@ class BaseSubscriptionTestCase(TestCase):
|
||||
self.assertEqual(len(mail.outbox), 1)
|
||||
_write_mail(mail)
|
||||
email = mail.outbox[0]
|
||||
self.assertEqual(email.to, [user.customer.billing_email])
|
||||
self.assertEqual(email.to, [user.customer.billing_address.email])
|
||||
# TODO(anna): set the correct reply_to
|
||||
self.assertEqual(email.reply_to, [])
|
||||
# TODO(anna): set the correct from_email DEFAULT_FROM_EMAIL
|
||||
@ -454,7 +454,7 @@ class BaseSubscriptionTestCase(TestCase):
|
||||
)
|
||||
self.assertEqual(email.alternatives[0][1], 'text/html')
|
||||
for email_body in (email.body, email.alternatives[0][0]):
|
||||
self.assertIn(f'Dear {user.customer.full_name},', email_body)
|
||||
self.assertIn(f'Dear {user.customer.billing_address.full_name},', email_body)
|
||||
self.assertIn('Automatic payment', email_body)
|
||||
self.assertIn('failed', email_body)
|
||||
self.assertIn('try again', email_body)
|
||||
@ -474,7 +474,7 @@ class BaseSubscriptionTestCase(TestCase):
|
||||
self.assertEqual(len(mail.outbox), 1)
|
||||
_write_mail(mail)
|
||||
email = mail.outbox[0]
|
||||
self.assertEqual(email.to, [user.customer.billing_email])
|
||||
self.assertEqual(email.to, [user.customer.billing_address.email])
|
||||
# TODO(anna): set the correct reply_to
|
||||
self.assertEqual(email.reply_to, [])
|
||||
# TODO(anna): set the correct from_email DEFAULT_FROM_EMAIL
|
||||
@ -482,7 +482,7 @@ class BaseSubscriptionTestCase(TestCase):
|
||||
self.assertEqual(email.subject, 'Blender Studio Subscription: payment failed')
|
||||
self.assertEqual(email.alternatives[0][1], 'text/html')
|
||||
for email_body in (email.body, email.alternatives[0][0]):
|
||||
self.assertIn(f'Dear {user.customer.full_name},', email_body)
|
||||
self.assertIn(f'Dear {user.customer.billing_address.full_name},', email_body)
|
||||
self.assertIn('Automatic payment', email_body)
|
||||
self.assertIn('failed', email_body)
|
||||
self.assertIn('3 times', email_body)
|
||||
@ -501,7 +501,7 @@ class BaseSubscriptionTestCase(TestCase):
|
||||
self.assertEqual(len(mail.outbox), 1)
|
||||
_write_mail(mail)
|
||||
email = mail.outbox[0]
|
||||
self.assertEqual(email.to, [user.customer.billing_email])
|
||||
self.assertEqual(email.to, [user.customer.billing_address.email])
|
||||
# TODO(anna): set the correct reply_to
|
||||
self.assertEqual(email.reply_to, [])
|
||||
# TODO(anna): set the correct from_email DEFAULT_FROM_EMAIL
|
||||
@ -509,7 +509,7 @@ class BaseSubscriptionTestCase(TestCase):
|
||||
self.assertEqual(email.subject, 'Blender Studio Subscription: payment received')
|
||||
self.assertEqual(email.alternatives[0][1], 'text/html')
|
||||
for email_body in (email.body, email.alternatives[0][0]):
|
||||
self.assertIn(f'Dear {user.customer.full_name},', email_body)
|
||||
self.assertIn(f'Dear {user.customer.billing_address.full_name},', email_body)
|
||||
self.assertIn('Automatic monthly payment', email_body)
|
||||
self.assertIn('successful', email_body)
|
||||
self.assertIn('$\xa011.10', email_body)
|
||||
@ -533,7 +533,7 @@ class BaseSubscriptionTestCase(TestCase):
|
||||
self.assertEqual(email.subject, 'Blender Studio managed subscription needs attention')
|
||||
self.assertEqual(email.alternatives[0][1], 'text/html')
|
||||
for email_body in (email.body, email.alternatives[0][0]):
|
||||
self.assertIn(f'{user.customer.full_name} has', email_body)
|
||||
self.assertIn(f'{user.customer.billing_address.full_name} has', email_body)
|
||||
self.assertIn('its next payment date', email_body)
|
||||
self.assertIn('$\xa011.10', email_body)
|
||||
self.assertIn(
|
||||
@ -551,7 +551,7 @@ class BaseSubscriptionTestCase(TestCase):
|
||||
self.assertEqual(email.subject, 'We miss you at Blender Studio')
|
||||
self.assertEqual(email.alternatives[0][1], 'text/html')
|
||||
for email_body in (email.body, email.alternatives[0][0]):
|
||||
self.assertIn(f'Dear {user.customer.full_name}', email_body)
|
||||
self.assertIn(f'Dear {user.customer.billing_address.full_name}', email_body)
|
||||
self.assertIn(f'#{subscription.pk}', email_body)
|
||||
self.assertIn('has expired', email_body)
|
||||
self.assertIn(
|
||||
|
@ -11,11 +11,8 @@ from looper import admin_log
|
||||
from looper.clock import Clock
|
||||
from looper.models import Gateway, Subscription
|
||||
from looper.money import Money
|
||||
from looper.tests.factories import SubscriptionFactory, create_customer_with_billing_address
|
||||
|
||||
from common.tests.factories.subscriptions import (
|
||||
SubscriptionFactory,
|
||||
create_customer_with_billing_address,
|
||||
)
|
||||
from subscriptions.tests.base import BaseSubscriptionTestCase
|
||||
import subscriptions.tasks
|
||||
import users.tasks
|
||||
@ -31,7 +28,7 @@ class TestClock(BaseSubscriptionTestCase):
|
||||
# print('fake now:', mock_now.return_value)
|
||||
subscription = SubscriptionFactory(
|
||||
user=user,
|
||||
payment_method__user_id=user.pk,
|
||||
payment_method__customer_id=user.customer.pk,
|
||||
payment_method__recognisable_name='Test payment method',
|
||||
payment_method__gateway=Gateway.objects.get(name='braintree'),
|
||||
currency='USD',
|
||||
|
@ -21,7 +21,6 @@ class TestBillingAddressForm(BaseSubscriptionTestCase):
|
||||
def test_instance_loads_both_address_and_customer_data(self):
|
||||
form = BillingAddressForm(instance=self.billing_address)
|
||||
|
||||
# N.B.: email is loaded from Customer.billing_email
|
||||
self.assertEqual(form['email'].value(), 'billing@example.com')
|
||||
self.assertEqual(form['company'].value(), 'Testcompany B.V.')
|
||||
self.assertEqual(form['country'].value(), 'NL')
|
||||
@ -223,7 +222,6 @@ class TestPaymentForm(BaseSubscriptionTestCase):
|
||||
def test_instance_loads_both_address_and_customer_data(self):
|
||||
form = PaymentForm(instance=self.billing_address)
|
||||
|
||||
# N.B.: email is loaded from Customer.billing_email
|
||||
self.assertEqual(form['email'].value(), 'billing@example.com')
|
||||
self.assertEqual(form['company'].value(), 'Testcompany B.V.')
|
||||
self.assertEqual(form['country'].value(), 'NL')
|
||||
|
@ -1,7 +1,9 @@
|
||||
from django.test import TestCase
|
||||
|
||||
from looper.tests.factories import SubscriptionFactory
|
||||
|
||||
from common.tests.factories.users import UserFactory
|
||||
from common.tests.factories.subscriptions import SubscriptionFactory, TeamFactory
|
||||
from common.tests.factories.subscriptions import TeamFactory
|
||||
|
||||
import looper.models
|
||||
|
||||
|
@ -57,7 +57,7 @@ urlpatterns = [
|
||||
),
|
||||
path(
|
||||
'settings/billing-address/',
|
||||
settings.BillingAddressView.as_view(),
|
||||
looper_settings.BillingAddressView.as_view(),
|
||||
name='billing-address',
|
||||
),
|
||||
path('settings/receipts/', looper_settings.settings_receipts, name='receipts'),
|
||||
|
@ -10,7 +10,7 @@ from django.shortcuts import redirect, get_object_or_404
|
||||
from django.views.generic import FormView
|
||||
|
||||
from looper.middleware import COUNTRY_CODE_SESSION_KEY
|
||||
from looper.views.checkout import AbstractPaymentView, CheckoutView
|
||||
from looper.views.checkout_braintree import AbstractPaymentView, CheckoutView
|
||||
import looper.gateways
|
||||
import looper.middleware
|
||||
import looper.models
|
||||
@ -45,7 +45,7 @@ class _JoinMixin:
|
||||
|
||||
def _get_existing_subscription(self):
|
||||
# Exclude cancelled subscriptions because they cannot transition to active
|
||||
existing_subscriptions = self.request.user.subscription_set.exclude(
|
||||
existing_subscriptions = self.request.user.customer.subscription_set.exclude(
|
||||
status__in=looper.models.Subscription._CANCELLED_STATUSES
|
||||
)
|
||||
return existing_subscriptions.first()
|
||||
@ -213,7 +213,6 @@ class ConfirmAndPayView(_JoinMixin, LoginRequiredMixin, FormView):
|
||||
ctx = {
|
||||
**super().get_context_data(**kwargs),
|
||||
'current_plan_variation': self.plan_variation,
|
||||
'client_token': self.get_client_token(currency) if self.customer else None,
|
||||
'subscription': self.subscription,
|
||||
}
|
||||
return ctx
|
||||
|
@ -35,7 +35,9 @@ class SingleSubscriptionMixin(LoginRequiredMixin):
|
||||
|
||||
def get_subscription(self) -> Subscription:
|
||||
"""Retrieve Subscription object."""
|
||||
return get_object_or_404(self.request.user.subscription_set, pk=self.subscription_id)
|
||||
return get_object_or_404(
|
||||
self.request.user.customer.subscription_set, pk=self.subscription_id
|
||||
)
|
||||
|
||||
def get_context_data(self, **kwargs) -> dict:
|
||||
"""Add Subscription to the template context."""
|
||||
@ -51,7 +53,7 @@ class SingleSubscriptionMixin(LoginRequiredMixin):
|
||||
The AnonymousUser instance doesn't have a 'subscriptions' property,
|
||||
but login checking only happens in the super().dispatch() call.
|
||||
"""
|
||||
if not hasattr(request.user, 'subscription_set'):
|
||||
if not hasattr(request.user.customer, 'subscription_set'):
|
||||
return self.handle_no_permission()
|
||||
response = self.pre_dispatch(request, *args, **kwargs)
|
||||
if response:
|
||||
|
@ -5,7 +5,7 @@ import logging
|
||||
from django.shortcuts import redirect
|
||||
from django.views.generic import FormView
|
||||
|
||||
from looper.views.checkout import AbstractPaymentView
|
||||
from looper.views.checkout_braintree import AbstractPaymentView
|
||||
import looper.gateways
|
||||
import looper.middleware
|
||||
import looper.models
|
||||
|
@ -1,8 +1,6 @@
|
||||
"""Views handling subscription management."""
|
||||
from typing import Optional
|
||||
import logging
|
||||
|
||||
from django.contrib.auth.mixins import LoginRequiredMixin
|
||||
from django.contrib.messages.views import SuccessMessageMixin
|
||||
from django.http import HttpResponseForbidden
|
||||
from django.shortcuts import get_object_or_404
|
||||
@ -10,10 +8,11 @@ from django.urls import reverse_lazy, reverse
|
||||
from django.views.generic import UpdateView, FormView
|
||||
|
||||
import looper.models
|
||||
import looper.views.checkout_braintree
|
||||
import looper.views.settings
|
||||
import looper.views.settings_braintree
|
||||
|
||||
from subscriptions.forms import (
|
||||
BillingAddressForm,
|
||||
CancelSubscriptionForm,
|
||||
ChangePaymentMethodForm,
|
||||
PayExistingOrderForm,
|
||||
@ -26,25 +25,6 @@ logger = logging.getLogger(__name__)
|
||||
logger.setLevel(logging.DEBUG)
|
||||
|
||||
|
||||
class BillingAddressView(LoginRequiredMixin, UpdateView):
|
||||
"""Combine looper's Customer and Address into a billing address."""
|
||||
|
||||
template_name = 'settings/billing_address.html'
|
||||
model = looper.models.Address
|
||||
form_class = BillingAddressForm
|
||||
success_url = reverse_lazy('subscriptions:billing-address')
|
||||
|
||||
def _get_customer_object(self) -> Optional[looper.models.Customer]:
|
||||
if self.request.user.is_anonymous:
|
||||
return None
|
||||
return self.request.user.customer
|
||||
|
||||
def get_object(self, queryset=None) -> Optional[looper.models.Address]:
|
||||
"""Get billing address."""
|
||||
customer = self._get_customer_object()
|
||||
return customer.billing_address if customer else None
|
||||
|
||||
|
||||
class CancelSubscriptionView(SingleSubscriptionMixin, FormView):
|
||||
"""Confirm and cancel a subscription."""
|
||||
|
||||
@ -67,7 +47,7 @@ class CancelSubscriptionView(SingleSubscriptionMixin, FormView):
|
||||
return super().form_valid(form)
|
||||
|
||||
|
||||
class PaymentMethodChangeView(looper.views.settings.PaymentMethodChangeView):
|
||||
class PaymentMethodChangeView(looper.views.settings_braintree.PaymentMethodChangeView):
|
||||
"""Use the Braintree drop-in UI to switch a subscription's payment method."""
|
||||
|
||||
template_name = 'subscriptions/payment_method_change.html'
|
||||
@ -98,7 +78,7 @@ class PaymentMethodChangeView(looper.views.settings.PaymentMethodChangeView):
|
||||
return super().form_invalid(form)
|
||||
|
||||
|
||||
class PayExistingOrderView(looper.views.checkout.CheckoutExistingOrderView):
|
||||
class PayExistingOrderView(looper.views.checkout_braintree.CheckoutExistingOrderView):
|
||||
"""Override looper's view with our forms."""
|
||||
|
||||
# Redirect to LOGIN_URL instead of raising an exception
|
||||
@ -111,7 +91,7 @@ class PayExistingOrderView(looper.views.checkout.CheckoutExistingOrderView):
|
||||
"""Prefill the payment amount and missing form data, if any."""
|
||||
initial = {
|
||||
'price': self.order.price.decimals_string,
|
||||
'email': self.customer.billing_email,
|
||||
'email': self.customer.billing_address.email,
|
||||
}
|
||||
|
||||
# Only set initial values if they aren't already saved to the billing address.
|
||||
@ -137,7 +117,7 @@ class PayExistingOrderView(looper.views.checkout.CheckoutExistingOrderView):
|
||||
if request.user.is_authenticated and self.order.user_id != request.user.id:
|
||||
return HttpResponseForbidden()
|
||||
self.plan = self.order.subscription.plan
|
||||
return super(looper.views.checkout.CheckoutExistingOrderView, self).dispatch(
|
||||
return super(looper.views.checkout_braintree.CheckoutExistingOrderView, self).dispatch(
|
||||
request, *args, **kwargs
|
||||
)
|
||||
|
||||
|
@ -2,7 +2,7 @@
|
||||
from collections import defaultdict
|
||||
from django.views.generic.base import TemplateView
|
||||
|
||||
from looper.views.checkout import AbstractPaymentView
|
||||
from looper.views.checkout_braintree import AbstractPaymentView
|
||||
import looper.models
|
||||
|
||||
import subscriptions.models
|
||||
|
@ -12,7 +12,7 @@ from looper.tests.test_preferred_currency import EURO_IPV4, USA_IPV4, SINGAPORE_
|
||||
from looper.money import Money
|
||||
import looper.models
|
||||
|
||||
from common.tests.factories.subscriptions import create_customer_with_billing_address
|
||||
from looper.tests.factories import create_customer_with_billing_address
|
||||
from common.tests.factories.users import UserFactory
|
||||
from subscriptions.tests.base import BaseSubscriptionTestCase
|
||||
import subscriptions.tasks
|
||||
@ -445,7 +445,7 @@ class TestPOSTConfirmAndPayView(BaseSubscriptionTestCase):
|
||||
|
||||
self._assert_transactionless_done_page_displayed(response)
|
||||
|
||||
subscription = user.subscription_set.first()
|
||||
subscription = user.customer.subscription_set.first()
|
||||
self.assertEqual(subscription.status, 'on-hold')
|
||||
self.assertEqual(subscription.price, Money('EUR', 1490))
|
||||
self.assertEqual(subscription.tax, Money('EUR', 259))
|
||||
@ -524,7 +524,7 @@ class TestPOSTConfirmAndPayView(BaseSubscriptionTestCase):
|
||||
|
||||
self._assert_transactionless_done_page_displayed(response)
|
||||
|
||||
subscription = user.subscription_set.first()
|
||||
subscription = user.customer.subscription_set.first()
|
||||
self.assertEqual(subscription.status, 'on-hold')
|
||||
self.assertEqual(subscription.price, Money('EUR', 3200))
|
||||
self.assertEqual(subscription.tax, Money('EUR', 0))
|
||||
@ -607,7 +607,7 @@ class TestPOSTConfirmAndPayView(BaseSubscriptionTestCase):
|
||||
|
||||
self._assert_done_page_displayed(response)
|
||||
|
||||
subscription = user.subscription_set.first()
|
||||
subscription = user.customer.subscription_set.first()
|
||||
order = subscription.latest_order()
|
||||
self.assertEqual(subscription.status, 'active')
|
||||
self.assertEqual(subscription.price, Money('EUR', 990))
|
||||
@ -655,7 +655,7 @@ class TestPOSTConfirmAndPayView(BaseSubscriptionTestCase):
|
||||
|
||||
self._assert_done_page_displayed(response)
|
||||
|
||||
subscription = user.subscription_set.first()
|
||||
subscription = user.customer.subscription_set.first()
|
||||
order = subscription.latest_order()
|
||||
self.assertEqual(subscription.status, 'active')
|
||||
self.assertEqual(subscription.price, Money('EUR', 9000))
|
||||
@ -693,7 +693,7 @@ class TestPOSTConfirmAndPayView(BaseSubscriptionTestCase):
|
||||
|
||||
self._assert_done_page_displayed(response)
|
||||
|
||||
subscription = user.subscription_set.first()
|
||||
subscription = user.customer.subscription_set.first()
|
||||
self.assertEqual(subscription.status, 'active')
|
||||
self.assertEqual(subscription.price, Money('EUR', 1490))
|
||||
self.assertEqual(subscription.tax, Money('EUR', 0))
|
||||
|
@ -10,11 +10,9 @@ from freezegun import freeze_time
|
||||
from looper.tests.factories import PaymentMethodFactory, OrderFactory
|
||||
import looper.taxes
|
||||
|
||||
from common.tests.factories.subscriptions import (
|
||||
TeamFactory,
|
||||
create_customer_with_billing_address,
|
||||
)
|
||||
from common.tests.factories.subscriptions import TeamFactory
|
||||
from common.tests.factories.users import UserFactory
|
||||
from looper.tests.factories import create_customer_with_billing_address
|
||||
|
||||
expected_text_tmpl = '''Invoice
|
||||
Blender Studio B.V.
|
||||
|
@ -7,7 +7,7 @@ from freezegun import freeze_time
|
||||
|
||||
from looper.tests.test_preferred_currency import EURO_IPV4, USA_IPV4 # , SINGAPORE_IPV4
|
||||
|
||||
from common.tests.factories.subscriptions import create_customer_with_billing_address
|
||||
from looper.tests.factories import create_customer_with_billing_address
|
||||
from subscriptions.tests.base import BaseSubscriptionTestCase
|
||||
|
||||
# **N.B.**: test cases below require settings.GEOIP2_DB to point to an existing GeoLite2 database.
|
||||
|
@ -4,9 +4,9 @@ from django.urls import reverse
|
||||
|
||||
from looper.models import PaymentMethod, PaymentMethodAuthentication, Gateway
|
||||
from looper.money import Money
|
||||
from looper.tests.factories import SubscriptionFactory
|
||||
|
||||
from common.tests.factories.users import UserFactory
|
||||
from common.tests.factories.subscriptions import SubscriptionFactory
|
||||
from subscriptions.tests.base import BaseSubscriptionTestCase
|
||||
import subscriptions.tasks
|
||||
|
||||
@ -31,7 +31,7 @@ class TestSubscriptionSettingsBillingAddress(BaseSubscriptionTestCase):
|
||||
user = UserFactory()
|
||||
self.client.force_login(user)
|
||||
|
||||
url = reverse('subscriptions:billing-address')
|
||||
url = reverse('user-settings-billing')
|
||||
response = self.client.post(url, full_billing_address_data)
|
||||
|
||||
# Check that the redirect on success happened
|
||||
@ -51,15 +51,14 @@ class TestSubscriptionSettingsBillingAddress(BaseSubscriptionTestCase):
|
||||
self.assertEqual(address.company, 'Test LLC')
|
||||
|
||||
# Check that customer fields were updated as well
|
||||
self.assertEqual(customer.vat_number, 'NL818152011B01')
|
||||
# N.B.: email is saved as Customer.billing_email
|
||||
self.assertEqual(customer.billing_email, 'my.billing.email@example.com')
|
||||
self.assertEqual(customer.billing_address.vat_number, 'NL818152011B01')
|
||||
self.assertEqual(customer.billing_address.email, 'my.billing.email@example.com')
|
||||
|
||||
def test_invalid_missing_required_fields(self):
|
||||
user = UserFactory()
|
||||
self.client.force_login(user)
|
||||
|
||||
response = self.client.post(reverse('subscriptions:billing-address'), {})
|
||||
response = self.client.post(reverse('user-settings-billing'), {})
|
||||
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertContains(response, 'errorlist')
|
||||
@ -72,7 +71,7 @@ class TestSubscriptionSettingsBillingAddress(BaseSubscriptionTestCase):
|
||||
data = {
|
||||
'email': 'new@example.com',
|
||||
}
|
||||
response = self.client.post(reverse('subscriptions:billing-address'), data)
|
||||
response = self.client.post(reverse('user-settings-billing'), data)
|
||||
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertContains(response, 'errorlist')
|
||||
@ -85,7 +84,7 @@ class TestSubscriptionSettingsBillingAddress(BaseSubscriptionTestCase):
|
||||
data = {
|
||||
'full_name': 'New Full Name',
|
||||
}
|
||||
response = self.client.post(reverse('subscriptions:billing-address'), data)
|
||||
response = self.client.post(reverse('user-settings-billing'), data)
|
||||
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertContains(response, 'errorlist')
|
||||
@ -103,8 +102,8 @@ class TestSubscriptionSettingsChangePaymentMethod(BaseSubscriptionTestCase):
|
||||
def test_can_change_payment_method_from_bank_to_credit_card_with_sca(self):
|
||||
bank = Gateway.objects.get(name='bank')
|
||||
subscription = SubscriptionFactory(
|
||||
user=self.user,
|
||||
payment_method__user_id=self.user.pk,
|
||||
customer=self.user.customer,
|
||||
payment_method__customer_id=self.user.customer.pk,
|
||||
payment_method__gateway=bank,
|
||||
)
|
||||
self.assertEqual(PaymentMethod.objects.count(), 1)
|
||||
@ -139,8 +138,8 @@ class TestSubscriptionSettingsChangePaymentMethod(BaseSubscriptionTestCase):
|
||||
def test_can_change_payment_method_from_credit_card_to_bank(self):
|
||||
braintree = Gateway.objects.get(name='braintree')
|
||||
subscription = SubscriptionFactory(
|
||||
user=self.user,
|
||||
payment_method__user_id=self.user.pk,
|
||||
customer=self.user.customer,
|
||||
payment_method__customer_id=self.user.customer.pk,
|
||||
payment_method__gateway=braintree,
|
||||
)
|
||||
self.assertEqual(PaymentMethod.objects.count(), 1)
|
||||
@ -173,8 +172,8 @@ class TestSubscriptionCancel(BaseSubscriptionTestCase):
|
||||
|
||||
def test_can_cancel_when_on_hold(self):
|
||||
subscription = SubscriptionFactory(
|
||||
user=self.user,
|
||||
payment_method__user_id=self.user.pk,
|
||||
customer=self.user.customer,
|
||||
payment_method__customer_id=self.user.customer.pk,
|
||||
payment_method__gateway=Gateway.objects.get(name='bank'),
|
||||
status='on-hold',
|
||||
)
|
||||
@ -198,8 +197,8 @@ class TestSubscriptionCancel(BaseSubscriptionTestCase):
|
||||
|
||||
def test_can_cancel_when_active(self):
|
||||
subscription = SubscriptionFactory(
|
||||
user=self.user,
|
||||
payment_method__user_id=self.user.pk,
|
||||
customer=self.user.customer,
|
||||
payment_method__customer_id=self.user.customer.pk,
|
||||
payment_method__gateway=Gateway.objects.get(name='bank'),
|
||||
status='active',
|
||||
)
|
||||
@ -218,8 +217,8 @@ class TestSubscriptionCancel(BaseSubscriptionTestCase):
|
||||
|
||||
def test_email_sent_when_pending_cancellation_changes_to_cancelled(self):
|
||||
subscription = SubscriptionFactory(
|
||||
user=self.user,
|
||||
payment_method__user_id=self.user.pk,
|
||||
customer=self.user.customer,
|
||||
payment_method__customer_id=self.user.customer.pk,
|
||||
payment_method__gateway=Gateway.objects.get(name='bank'),
|
||||
status='pending-cancellation',
|
||||
)
|
||||
@ -244,8 +243,8 @@ class TestPayExistingOrder(BaseSubscriptionTestCase):
|
||||
|
||||
def test_redirect_to_login_when_anonymous(self):
|
||||
subscription = SubscriptionFactory(
|
||||
user=self.user,
|
||||
payment_method__user_id=self.user.pk,
|
||||
customer=self.user.customer,
|
||||
payment_method__customer_id=self.user.customer.pk,
|
||||
payment_method__gateway=Gateway.objects.get(name='bank'),
|
||||
status='on-hold',
|
||||
)
|
||||
@ -264,8 +263,8 @@ class TestPayExistingOrder(BaseSubscriptionTestCase):
|
||||
|
||||
def test_cannot_pay_someone_elses_order(self):
|
||||
subscription = SubscriptionFactory(
|
||||
user=self.user,
|
||||
payment_method__user_id=self.user.pk,
|
||||
customer=self.user.customer,
|
||||
payment_method__customer_id=self.user.customer.pk,
|
||||
payment_method__gateway=Gateway.objects.get(name='bank'),
|
||||
status='on-hold',
|
||||
)
|
||||
@ -284,8 +283,8 @@ class TestPayExistingOrder(BaseSubscriptionTestCase):
|
||||
|
||||
def test_invalid_missing_required_form_data(self):
|
||||
subscription = SubscriptionFactory(
|
||||
user=self.user,
|
||||
payment_method__user_id=self.user.pk,
|
||||
customer=self.user.customer,
|
||||
payment_method__customer_id=self.user.customer.pk,
|
||||
payment_method__gateway=Gateway.objects.get(name='bank'),
|
||||
status='on-hold',
|
||||
)
|
||||
@ -315,8 +314,8 @@ class TestPayExistingOrder(BaseSubscriptionTestCase):
|
||||
)
|
||||
def test_can_pay_for_manual_subscription_with_an_order(self):
|
||||
subscription = SubscriptionFactory(
|
||||
user=self.user,
|
||||
payment_method__user_id=self.user.pk,
|
||||
customer=self.user.customer,
|
||||
payment_method__customer_id=self.user.customer.pk,
|
||||
payment_method__gateway=Gateway.objects.get(name='bank'),
|
||||
currency='USD',
|
||||
price=Money('USD', 1110),
|
||||
|
@ -6,6 +6,9 @@ from django.utils.html import format_html
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
from rest_framework.authtoken.admin import TokenAdmin
|
||||
|
||||
import nested_admin
|
||||
|
||||
from looper.admin import REL_CUSTOMER_SEARCH_FIELDS
|
||||
import looper.admin
|
||||
import looper.models
|
||||
|
||||
@ -26,7 +29,7 @@ user_section_progress_link.short_description = 'Training sections progress'
|
||||
|
||||
def user_subscriptions_link(obj, title='View subscriptions of this user'):
|
||||
admin_view = looper.admin._get_admin_url_name(looper.models.Subscription, 'changelist')
|
||||
link = reverse(admin_view) + f'?user_id={obj.pk}'
|
||||
link = reverse(admin_view) + f'?customer_id={obj.customer.pk}'
|
||||
return format_html('<a href="{}">{}</a>', link, title)
|
||||
|
||||
|
||||
@ -80,7 +83,7 @@ class NumberOfBraintreeCustomerIDsFilter(admin.SimpleListFilter):
|
||||
|
||||
|
||||
@admin.register(get_user_model())
|
||||
class UserAdmin(auth_admin.UserAdmin):
|
||||
class UserAdmin(auth_admin.UserAdmin, nested_admin.NestedModelAdmin):
|
||||
change_form_template = 'loginas/change_form.html'
|
||||
|
||||
def has_add_permission(self, request):
|
||||
@ -91,9 +94,9 @@ class UserAdmin(auth_admin.UserAdmin):
|
||||
"""Count user subscriptions for subscription debugging purposes."""
|
||||
queryset = super().get_queryset(*args, **kwargs)
|
||||
queryset = queryset.annotate(
|
||||
subscriptions_count=Count('subscription', distinct=True),
|
||||
subscriptions_count=Count('customer__subscription', distinct=True),
|
||||
braintreecustomerids_count=Count(
|
||||
'gatewaycustomerid', Q(gateway__name='braintree'), distinct=True
|
||||
'customer__gatewaycustomerid', Q(customer__gateway__name='braintree'), distinct=True
|
||||
),
|
||||
)
|
||||
return queryset
|
||||
@ -147,13 +150,12 @@ class UserAdmin(auth_admin.UserAdmin):
|
||||
user_subscriptions_link,
|
||||
subscriptions,
|
||||
)
|
||||
inlines = [
|
||||
looper.admin.AddressInline,
|
||||
inlines = (
|
||||
looper.admin.LinkCustomerTokenInline,
|
||||
looper.admin.CustomerInline,
|
||||
looper.admin.GatewayCustomerIdInline,
|
||||
]
|
||||
)
|
||||
ordering = ['-date_joined']
|
||||
search_fields = ['email', 'full_name', 'username']
|
||||
search_fields = ['email', 'full_name', 'username', *REL_CUSTOMER_SEARCH_FIELDS]
|
||||
|
||||
def deletion_requested(self, obj):
|
||||
"""Display yes/no icon status of deletion request."""
|
||||
|
@ -107,7 +107,7 @@ def handle_tracking_event_unsubscribe(event_type: str, message_id: str, event: D
|
||||
def unsubscribe_from_newsletters(pk: int):
|
||||
"""Remove emails of user with given pk from newsletter lists."""
|
||||
user = User.objects.get(pk=pk)
|
||||
emails = (user.email, user.customer.billing_email)
|
||||
emails = (user.email, user.customer.billing_address.email)
|
||||
for email in emails:
|
||||
if not email:
|
||||
continue
|
||||
|
@ -4,7 +4,7 @@
|
||||
|
||||
{% block settings %}
|
||||
<p class="text-muted">Settings</p>
|
||||
<h1 class="mb-3">Subscription{% if user.subscription_set.count > 1 %}s{% endif %}</h1>
|
||||
<h1 class="mb-3">Subscription{% if user.customer.subscription_set.count > 1 %}s{% endif %}</h1>
|
||||
<p class="mb-2 text-muted">If you have any problems with billing, contact the team directly on <a href="mailto:{{ ADMIN_MAIL }}">{{ ADMIN_MAIL }}</a>.</p>
|
||||
<div>
|
||||
{% if user|has_group:"demo" %}
|
||||
|
@ -5,14 +5,15 @@ from django.contrib.auth import get_user_model
|
||||
from django.test import TestCase, override_settings
|
||||
from django.utils import timezone
|
||||
|
||||
from comments.queries import set_comment_like
|
||||
from common.tests.factories.comments import CommentFactory
|
||||
from common.tests.factories.subscriptions import (
|
||||
TeamFactory,
|
||||
from looper.tests.factories import (
|
||||
PaymentMethodFactory,
|
||||
TransactionFactory,
|
||||
create_customer_with_billing_address,
|
||||
)
|
||||
|
||||
from comments.queries import set_comment_like
|
||||
from common.tests.factories.comments import CommentFactory
|
||||
from common.tests.factories.subscriptions import TeamFactory
|
||||
from common.tests.factories.users import UserFactory
|
||||
import users.tasks as tasks
|
||||
import users.tests.util as util
|
||||
|
Loading…
Reference in New Issue
Block a user