Browse Source

Implement black and Pylint (#4126)

master
Hertog Jan 2 years ago
parent
commit
c9af48338d
No account linked to committer's email address
100 changed files with 4292 additions and 3291 deletions
  1. 2
    3
      .github/workflows/test.yml
  2. 10
    4
      Makefile
  3. 12
    0
      pylintrc
  4. 3
    3
      requirements.txt
  5. 1
    1
      setup.py
  6. 51
    53
      src/oscar/__init__.py
  7. 1
    1
      src/oscar/apps/address/__init__.py
  8. 311
    265
      src/oscar/apps/address/abstract_models.py
  9. 9
    16
      src/oscar/apps/address/admin.py
  10. 3
    3
      src/oscar/apps/address/apps.py
  11. 13
    9
      src/oscar/apps/address/forms.py
  12. 7
    6
      src/oscar/apps/address/models.py
  13. 1
    1
      src/oscar/apps/analytics/__init__.py
  14. 49
    47
      src/oscar/apps/analytics/abstract_models.py
  15. 16
    13
      src/oscar/apps/analytics/admin.py
  16. 5
    4
      src/oscar/apps/analytics/apps.py
  17. 17
    10
      src/oscar/apps/analytics/models.py
  18. 35
    29
      src/oscar/apps/analytics/receivers.py
  19. 48
    45
      src/oscar/apps/analytics/reports.py
  20. 4
    11
      src/oscar/apps/analytics/scores.py
  21. 1
    1
      src/oscar/apps/basket/__init__.py
  22. 215
    146
      src/oscar/apps/basket/abstract_models.py
  23. 42
    14
      src/oscar/apps/basket/admin.py
  24. 20
    14
      src/oscar/apps/basket/apps.py
  25. 87
    64
      src/oscar/apps/basket/forms.py
  26. 14
    14
      src/oscar/apps/basket/formsets.py
  27. 6
    8
      src/oscar/apps/basket/managers.py
  28. 38
    24
      src/oscar/apps/basket/middleware.py
  29. 14
    8
      src/oscar/apps/basket/models.py
  30. 74
    63
      src/oscar/apps/basket/reports.py
  31. 28
    23
      src/oscar/apps/basket/utils.py
  32. 178
    164
      src/oscar/apps/basket/views.py
  33. 1
    1
      src/oscar/apps/catalogue/__init__.py
  34. 377
    267
      src/oscar/apps/catalogue/abstract_models.py
  35. 37
    32
      src/oscar/apps/catalogue/admin.py
  36. 30
    21
      src/oscar/apps/catalogue/apps.py
  37. 12
    8
      src/oscar/apps/catalogue/categories.py
  38. 2
    0
      src/oscar/apps/catalogue/expressions.py
  39. 22
    10
      src/oscar/apps/catalogue/managers.py
  40. 37
    24
      src/oscar/apps/catalogue/models.py
  41. 1
    0
      src/oscar/apps/catalogue/product_attributes.py
  42. 6
    4
      src/oscar/apps/catalogue/receivers.py
  43. 1
    1
      src/oscar/apps/catalogue/reviews/__init__.py
  44. 47
    47
      src/oscar/apps/catalogue/reviews/abstract_models.py
  45. 13
    6
      src/oscar/apps/catalogue/reviews/admin.py
  46. 17
    12
      src/oscar/apps/catalogue/reviews/apps.py
  47. 17
    18
      src/oscar/apps/catalogue/reviews/forms.py
  48. 7
    3
      src/oscar/apps/catalogue/reviews/models.py
  49. 1
    1
      src/oscar/apps/catalogue/reviews/utils.py
  50. 39
    28
      src/oscar/apps/catalogue/reviews/views.py
  51. 20
    14
      src/oscar/apps/catalogue/search_handlers.py
  52. 45
    41
      src/oscar/apps/catalogue/utils.py
  53. 52
    41
      src/oscar/apps/catalogue/views.py
  54. 1
    1
      src/oscar/apps/checkout/__init__.py
  55. 5
    7
      src/oscar/apps/checkout/applicator.py
  56. 55
    28
      src/oscar/apps/checkout/apps.py
  57. 2
    2
      src/oscar/apps/checkout/calculators.py
  58. 2
    3
      src/oscar/apps/checkout/context_processors.py
  59. 1
    1
      src/oscar/apps/checkout/exceptions.py
  60. 41
    29
      src/oscar/apps/checkout/forms.py
  61. 93
    57
      src/oscar/apps/checkout/mixins.py
  62. 83
    83
      src/oscar/apps/checkout/session.py
  63. 4
    7
      src/oscar/apps/checkout/surcharges.py
  64. 33
    31
      src/oscar/apps/checkout/utils.py
  65. 197
    138
      src/oscar/apps/checkout/views.py
  66. 1
    1
      src/oscar/apps/communication/__init__.py
  67. 82
    62
      src/oscar/apps/communication/abstract_models.py
  68. 2
    2
      src/oscar/apps/communication/admin.py
  69. 99
    69
      src/oscar/apps/communication/app.py
  70. 3
    3
      src/oscar/apps/communication/apps.py
  71. 3
    6
      src/oscar/apps/communication/config.py
  72. 0
    1
      src/oscar/apps/communication/managers.py
  73. 11
    7
      src/oscar/apps/communication/models.py
  74. 5
    4
      src/oscar/apps/communication/notifications/context_processors.py
  75. 30
    31
      src/oscar/apps/communication/notifications/views.py
  76. 32
    22
      src/oscar/apps/communication/utils.py
  77. 1
    1
      src/oscar/apps/customer/__init__.py
  78. 68
    51
      src/oscar/apps/customer/abstract_models.py
  79. 4
    3
      src/oscar/apps/customer/alerts/receivers.py
  80. 33
    22
      src/oscar/apps/customer/alerts/utils.py
  81. 36
    36
      src/oscar/apps/customer/alerts/views.py
  82. 228
    125
      src/oscar/apps/customer/apps.py
  83. 14
    10
      src/oscar/apps/customer/auth_backends.py
  84. 135
    113
      src/oscar/apps/customer/forms.py
  85. 10
    9
      src/oscar/apps/customer/history.py
  86. 14
    14
      src/oscar/apps/customer/mixins.py
  87. 3
    2
      src/oscar/apps/customer/models.py
  88. 2
    1
      src/oscar/apps/customer/receivers.py
  89. 23
    16
      src/oscar/apps/customer/utils.py
  90. 207
    185
      src/oscar/apps/customer/views.py
  91. 111
    84
      src/oscar/apps/customer/wishlists/views.py
  92. 1
    1
      src/oscar/apps/dashboard/__init__.py
  93. 38
    35
      src/oscar/apps/dashboard/apps.py
  94. 1
    2
      src/oscar/apps/dashboard/catalogue/__init__.py
  95. 175
    96
      src/oscar/apps/dashboard/catalogue/apps.py
  96. 143
    121
      src/oscar/apps/dashboard/catalogue/forms.py
  97. 78
    66
      src/oscar/apps/dashboard/catalogue/formsets.py
  98. 2
    1
      src/oscar/apps/dashboard/catalogue/mixins.py
  99. 81
    56
      src/oscar/apps/dashboard/catalogue/tables.py
  100. 0
    0
      src/oscar/apps/dashboard/catalogue/views.py

+ 2
- 3
.github/workflows/test.yml View File

@@ -66,11 +66,10 @@ jobs:
66 66
     - name: Install dependencies
67 67
       run: |
68 68
         python -m pip install --upgrade pip
69
+        pip install -e .[test]
69 70
         pip install -r requirements.txt
70 71
     - name: Run linters
71
-      run: |
72
-        flake8 src tests setup.py
73
-        isort -c -q --diff src/ tests/
72
+      run: make lint
74 73
   lint_js:
75 74
     runs-on: ubuntu-latest
76 75
     steps:

+ 10
- 4
Makefile View File

@@ -80,10 +80,16 @@ retest: venv ## Run failed tests only
80 80
 coverage: venv ## Generate coverage report
81 81
 	$(PYTEST) --cov=oscar --cov-report=term-missing
82 82
 
83
-lint: ## Run flake8 and isort checks
84
-	flake8 src/oscar/
85
-	flake8 tests/
86
-	isort -c -q --diff src/ tests/
83
+lint:
84
+	@black --check --exclude "migrations/*" src/oscar/
85
+	@black --check --exclude "migrations/*" tests/
86
+	@pylint setup.py src/oscar/
87
+	@pylint setup.py tests/
88
+
89
+
90
+black:
91
+	@black --exclude "migrations/*" src/oscar/
92
+	@black --exclude "migrations/*" tests/
87 93
 
88 94
 test_migrations: install-migrations-testing-requirements ## Tests migrations
89 95
 	cd sandbox && ./test_migrations.sh

+ 12
- 0
pylintrc View File

@@ -0,0 +1,12 @@
1
+[MASTER]
2
+jobs = 1
3
+load-plugins = pylint_django
4
+score = n
5
+ignore = migrations
6
+django-settings-module = sandbox.settings
7
+
8
+[MESSAGES CONTROL]
9
+disable = R,C,W5103,W0707,W0719,W0718,W0212,E5110
10
+
11
+[TYPECHECK]
12
+ignored-classes = responses

+ 3
- 3
requirements.txt View File

@@ -16,9 +16,9 @@ uWSGI>=2.0.19
16 16
 whitenoise>=5.2,<6.5
17 17
 
18 18
 # Linting
19
-flake8<7.0.0
20
-flake8-debugger==4.1.2
21
-isort==5.10.1
19
+pylint>=2.17.4
20
+pylint-django>=2.5.3
21
+black>=23.3.0
22 22
 
23 23
 # Helpers
24 24
 pyprof2calltree>=1.4,<1.5

+ 1
- 1
setup.py View File

@@ -69,7 +69,7 @@ test_requires = [
69 69
     easy_thumbnails_version,
70 70
 ]
71 71
 
72
-with open(os.path.join(PROJECT_DIR, 'README.rst')) as fh:
72
+with open(os.path.join(PROJECT_DIR, 'README.rst'), encoding="utf-8") as fh:
73 73
     long_description = re.sub(
74 74
         '^.. start-no-pypi.*^.. end-no-pypi', '', fh.read(), flags=re.M | re.S)
75 75
 

+ 51
- 53
src/oscar/__init__.py View File

@@ -1,71 +1,69 @@
1 1
 # Use 'alpha', 'beta', 'rc' or 'final' as the 4th element to indicate release type.
2
-VERSION = (3, 2, 0, 'final')
2
+VERSION = (3, 2, 0, "final")
3 3
 
4 4
 
5 5
 def get_short_version():
6
-    return '%s.%s' % (VERSION[0], VERSION[1])
6
+    return "%s.%s" % (VERSION[0], VERSION[1])
7 7
 
8 8
 
9 9
 def get_version():
10
-    version = '%s.%s' % (VERSION[0], VERSION[1])
10
+    version = "%s.%s" % (VERSION[0], VERSION[1])
11 11
     # Append 3rd digit if > 0
12 12
     if VERSION[2]:
13
-        version = '%s.%s' % (version, VERSION[2])
14
-    elif VERSION[3] != 'final':
15
-        mapping = {'alpha': 'a', 'beta': 'b', 'rc': 'c'}
16
-        version = '%s%s' % (version, mapping[VERSION[3]])
13
+        version = "%s.%s" % (version, VERSION[2])
14
+    elif VERSION[3] != "final":
15
+        mapping = {"alpha": "a", "beta": "b", "rc": "c"}
16
+        version = "%s%s" % (version, mapping[VERSION[3]])
17 17
         if len(VERSION) == 5:
18
-            version = '%s%s' % (version, VERSION[4])
18
+            version = "%s%s" % (version, VERSION[4])
19 19
     return version
20 20
 
21 21
 
22 22
 INSTALLED_APPS = [
23
-    'django.contrib.admin',
24
-    'django.contrib.auth',
25
-    'django.contrib.contenttypes',
26
-    'django.contrib.sessions',
27
-    'django.contrib.messages',
28
-    'django.contrib.staticfiles',
29
-    'django.contrib.sites',
30
-    'django.contrib.flatpages',
31
-
32
-    'oscar.config.Shop',
33
-    'oscar.apps.analytics.apps.AnalyticsConfig',
34
-    'oscar.apps.checkout.apps.CheckoutConfig',
35
-    'oscar.apps.address.apps.AddressConfig',
36
-    'oscar.apps.shipping.apps.ShippingConfig',
37
-    'oscar.apps.catalogue.apps.CatalogueConfig',
38
-    'oscar.apps.catalogue.reviews.apps.CatalogueReviewsConfig',
39
-    'oscar.apps.communication.apps.CommunicationConfig',
40
-    'oscar.apps.partner.apps.PartnerConfig',
41
-    'oscar.apps.basket.apps.BasketConfig',
42
-    'oscar.apps.payment.apps.PaymentConfig',
43
-    'oscar.apps.offer.apps.OfferConfig',
44
-    'oscar.apps.order.apps.OrderConfig',
45
-    'oscar.apps.customer.apps.CustomerConfig',
46
-    'oscar.apps.search.apps.SearchConfig',
47
-    'oscar.apps.voucher.apps.VoucherConfig',
48
-    'oscar.apps.wishlists.apps.WishlistsConfig',
49
-    'oscar.apps.dashboard.apps.DashboardConfig',
50
-    'oscar.apps.dashboard.reports.apps.ReportsDashboardConfig',
51
-    'oscar.apps.dashboard.users.apps.UsersDashboardConfig',
52
-    'oscar.apps.dashboard.orders.apps.OrdersDashboardConfig',
53
-    'oscar.apps.dashboard.catalogue.apps.CatalogueDashboardConfig',
54
-    'oscar.apps.dashboard.offers.apps.OffersDashboardConfig',
55
-    'oscar.apps.dashboard.partners.apps.PartnersDashboardConfig',
56
-    'oscar.apps.dashboard.pages.apps.PagesDashboardConfig',
57
-    'oscar.apps.dashboard.ranges.apps.RangesDashboardConfig',
58
-    'oscar.apps.dashboard.reviews.apps.ReviewsDashboardConfig',
59
-    'oscar.apps.dashboard.vouchers.apps.VouchersDashboardConfig',
60
-    'oscar.apps.dashboard.communications.apps.CommunicationsDashboardConfig',
61
-    'oscar.apps.dashboard.shipping.apps.ShippingDashboardConfig',
62
-
23
+    "django.contrib.admin",
24
+    "django.contrib.auth",
25
+    "django.contrib.contenttypes",
26
+    "django.contrib.sessions",
27
+    "django.contrib.messages",
28
+    "django.contrib.staticfiles",
29
+    "django.contrib.sites",
30
+    "django.contrib.flatpages",
31
+    "oscar.config.Shop",
32
+    "oscar.apps.analytics.apps.AnalyticsConfig",
33
+    "oscar.apps.checkout.apps.CheckoutConfig",
34
+    "oscar.apps.address.apps.AddressConfig",
35
+    "oscar.apps.shipping.apps.ShippingConfig",
36
+    "oscar.apps.catalogue.apps.CatalogueConfig",
37
+    "oscar.apps.catalogue.reviews.apps.CatalogueReviewsConfig",
38
+    "oscar.apps.communication.apps.CommunicationConfig",
39
+    "oscar.apps.partner.apps.PartnerConfig",
40
+    "oscar.apps.basket.apps.BasketConfig",
41
+    "oscar.apps.payment.apps.PaymentConfig",
42
+    "oscar.apps.offer.apps.OfferConfig",
43
+    "oscar.apps.order.apps.OrderConfig",
44
+    "oscar.apps.customer.apps.CustomerConfig",
45
+    "oscar.apps.search.apps.SearchConfig",
46
+    "oscar.apps.voucher.apps.VoucherConfig",
47
+    "oscar.apps.wishlists.apps.WishlistsConfig",
48
+    "oscar.apps.dashboard.apps.DashboardConfig",
49
+    "oscar.apps.dashboard.reports.apps.ReportsDashboardConfig",
50
+    "oscar.apps.dashboard.users.apps.UsersDashboardConfig",
51
+    "oscar.apps.dashboard.orders.apps.OrdersDashboardConfig",
52
+    "oscar.apps.dashboard.catalogue.apps.CatalogueDashboardConfig",
53
+    "oscar.apps.dashboard.offers.apps.OffersDashboardConfig",
54
+    "oscar.apps.dashboard.partners.apps.PartnersDashboardConfig",
55
+    "oscar.apps.dashboard.pages.apps.PagesDashboardConfig",
56
+    "oscar.apps.dashboard.ranges.apps.RangesDashboardConfig",
57
+    "oscar.apps.dashboard.reviews.apps.ReviewsDashboardConfig",
58
+    "oscar.apps.dashboard.vouchers.apps.VouchersDashboardConfig",
59
+    "oscar.apps.dashboard.communications.apps.CommunicationsDashboardConfig",
60
+    "oscar.apps.dashboard.shipping.apps.ShippingDashboardConfig",
63 61
     # 3rd-party apps that oscar depends on
64
-    'widget_tweaks',
65
-    'haystack',
66
-    'treebeard',
67
-    'django_tables2',
62
+    "widget_tweaks",
63
+    "haystack",
64
+    "treebeard",
65
+    "django_tables2",
68 66
 ]
69 67
 
70 68
 
71
-default_app_config = 'oscar.config.Shop'
69
+default_app_config = "oscar.config.Shop"

+ 1
- 1
src/oscar/apps/address/__init__.py View File

@@ -1 +1 @@
1
-default_app_config = 'oscar.apps.address.apps.AddressConfig'
1
+default_app_config = "oscar.apps.address.apps.AddressConfig"

+ 311
- 265
src/oscar/apps/address/abstract_models.py View File

@@ -19,7 +19,8 @@ class AbstractAddress(models.Model):
19 19
     This is subclassed and extended to provide models for
20 20
     user, shipping and billing addresses.
21 21
     """
22
-    MR, MISS, MRS, MS, DR = ('Mr', 'Miss', 'Mrs', 'Ms', 'Dr')
22
+
23
+    MR, MISS, MRS, MS, DR = ("Mr", "Miss", "Mrs", "Ms", "Dr")
23 24
     TITLE_CHOICES = (
24 25
         (MR, _("Mr")),
25 26
         (MISS, _("Miss")),
@@ -28,229 +29,248 @@ class AbstractAddress(models.Model):
28 29
         (DR, _("Dr")),
29 30
     )
30 31
 
31
-    POSTCODE_REQUIRED = 'postcode' in settings.OSCAR_REQUIRED_ADDRESS_FIELDS
32
+    POSTCODE_REQUIRED = "postcode" in settings.OSCAR_REQUIRED_ADDRESS_FIELDS
32 33
 
33 34
     # Regex for each country. Not listed countries don't use postcodes
34 35
     # Based on http://en.wikipedia.org/wiki/List_of_postal_codes
35 36
     POSTCODES_REGEX = {
36
-        'AC': r'^[A-Z]{4}[0-9][A-Z]$',
37
-        'AD': r'^AD[0-9]{3}$',
38
-        'AF': r'^[0-9]{4}$',
39
-        'AI': r'^AI-2640$',
40
-        'AL': r'^[0-9]{4}$',
41
-        'AM': r'^[0-9]{4}$',
42
-        'AR': r'^([0-9]{4}|[A-Z][0-9]{4}[A-Z]{3})$',
43
-        'AS': r'^[0-9]{5}(-[0-9]{4}|-[0-9]{6})?$',
44
-        'AT': r'^[0-9]{4}$',
45
-        'AU': r'^[0-9]{4}$',
46
-        'AX': r'^[0-9]{5}$',
47
-        'AZ': r'^AZ[0-9]{4}$',
48
-        'BA': r'^[0-9]{5}$',
49
-        'BB': r'^BB[0-9]{5}$',
50
-        'BD': r'^[0-9]{4}$',
51
-        'BE': r'^[0-9]{4}$',
52
-        'BG': r'^[0-9]{4}$',
53
-        'BH': r'^[0-9]{3,4}$',
54
-        'BL': r'^[0-9]{5}$',
55
-        'BM': r'^[A-Z]{2}([0-9]{2}|[A-Z]{2})',
56
-        'BN': r'^[A-Z]{2}[0-9]{4}$',
57
-        'BO': r'^[0-9]{4}$',
58
-        'BR': r'^[0-9]{5}(-[0-9]{3})?$',
59
-        'BT': r'^[0-9]{3}$',
60
-        'BY': r'^[0-9]{6}$',
61
-        'CA': r'^[A-Z][0-9][A-Z][0-9][A-Z][0-9]$',
62
-        'CC': r'^[0-9]{4}$',
63
-        'CH': r'^[0-9]{4}$',
64
-        'CL': r'^([0-9]{7}|[0-9]{3}-[0-9]{4})$',
65
-        'CN': r'^[0-9]{6}$',
66
-        'CO': r'^[0-9]{6}$',
67
-        'CR': r'^[0-9]{4,5}$',
68
-        'CU': r'^[0-9]{5}$',
69
-        'CV': r'^[0-9]{4}$',
70
-        'CX': r'^[0-9]{4}$',
71
-        'CY': r'^[0-9]{4}$',
72
-        'CZ': r'^[0-9]{5}$',
73
-        'DE': r'^[0-9]{5}$',
74
-        'DK': r'^[0-9]{4}$',
75
-        'DO': r'^[0-9]{5}$',
76
-        'DZ': r'^[0-9]{5}$',
77
-        'EC': r'^EC[0-9]{6}$',
78
-        'EE': r'^[0-9]{5}$',
79
-        'EG': r'^[0-9]{5}$',
80
-        'ES': r'^[0-9]{5}$',
81
-        'ET': r'^[0-9]{4}$',
82
-        'FI': r'^[0-9]{5}$',
83
-        'FK': r'^[A-Z]{4}[0-9][A-Z]{2}$',
84
-        'FM': r'^[0-9]{5}(-[0-9]{4})?$',
85
-        'FO': r'^[0-9]{3}$',
86
-        'FR': r'^[0-9]{5}$',
87
-        'GA': r'^[0-9]{2}.*[0-9]{2}$',
88
-        'GB': r'^[A-Z][A-Z0-9]{1,3}[0-9][A-Z]{2}$',
89
-        'GE': r'^[0-9]{4}$',
90
-        'GF': r'^[0-9]{5}$',
91
-        'GG': r'^([A-Z]{2}[0-9]{2,3}[A-Z]{2})$',
92
-        'GI': r'^GX111AA$',
93
-        'GL': r'^[0-9]{4}$',
94
-        'GP': r'^[0-9]{5}$',
95
-        'GR': r'^[0-9]{5}$',
96
-        'GS': r'^SIQQ1ZZ$',
97
-        'GT': r'^[0-9]{5}$',
98
-        'GU': r'^[0-9]{5}$',
99
-        'GW': r'^[0-9]{4}$',
100
-        'HM': r'^[0-9]{4}$',
101
-        'HN': r'^[0-9]{5}$',
102
-        'HR': r'^[0-9]{5}$',
103
-        'HT': r'^[0-9]{4}$',
104
-        'HU': r'^[0-9]{4}$',
105
-        'ID': r'^[0-9]{5}$',
106
-        'IL': r'^([0-9]{5}|[0-9]{7})$',
107
-        'IM': r'^IM[0-9]{2,3}[A-Z]{2}$$',
108
-        'IN': r'^[0-9]{6}$',
109
-        'IO': r'^[A-Z]{4}[0-9][A-Z]{2}$',
110
-        'IQ': r'^[0-9]{5}$',
111
-        'IR': r'^[0-9]{5}-[0-9]{5}$',
112
-        'IS': r'^[0-9]{3}$',
113
-        'IT': r'^[0-9]{5}$',
114
-        'JE': r'^JE[0-9]{2}[A-Z]{2}$',
115
-        'JM': r'^JM[A-Z]{3}[0-9]{2}$',
116
-        'JO': r'^[0-9]{5}$',
117
-        'JP': r'^[0-9]{3}-?[0-9]{4}$',
118
-        'KE': r'^[0-9]{5}$',
119
-        'KG': r'^[0-9]{6}$',
120
-        'KH': r'^[0-9]{5}$',
121
-        'KR': r'^[0-9]{5}$',
122
-        'KY': r'^KY[0-9]-[0-9]{4}$',
123
-        'KZ': r'^[0-9]{6}$',
124
-        'LA': r'^[0-9]{5}$',
125
-        'LB': r'^[0-9]{8}$',
126
-        'LI': r'^[0-9]{4}$',
127
-        'LK': r'^[0-9]{5}$',
128
-        'LR': r'^[0-9]{4}$',
129
-        'LS': r'^[0-9]{3}$',
130
-        'LT': r'^(LT-)?[0-9]{5}$',
131
-        'LU': r'^[0-9]{4}$',
132
-        'LV': r'^LV-[0-9]{4}$',
133
-        'LY': r'^[0-9]{5}$',
134
-        'MA': r'^[0-9]{5}$',
135
-        'MC': r'^980[0-9]{2}$',
136
-        'MD': r'^MD-?[0-9]{4}$',
137
-        'ME': r'^[0-9]{5}$',
138
-        'MF': r'^[0-9]{5}$',
139
-        'MG': r'^[0-9]{3}$',
140
-        'MH': r'^[0-9]{5}$',
141
-        'MK': r'^[0-9]{4}$',
142
-        'MM': r'^[0-9]{5}$',
143
-        'MN': r'^[0-9]{5}$',
144
-        'MP': r'^[0-9]{5}$',
145
-        'MQ': r'^[0-9]{5}$',
146
-        'MT': r'^[A-Z]{3}[0-9]{4}$',
147
-        'MV': r'^[0-9]{4,5}$',
148
-        'MX': r'^[0-9]{5}$',
149
-        'MY': r'^[0-9]{5}$',
150
-        'MZ': r'^[0-9]{4}$',
151
-        'NA': r'^[0-9]{5}$',
152
-        'NC': r'^[0-9]{5}$',
153
-        'NE': r'^[0-9]{4}$',
154
-        'NF': r'^[0-9]{4}$',
155
-        'NG': r'^[0-9]{6}$',
156
-        'NI': r'^[0-9]{5}$',
157
-        'NL': r'^[0-9]{4}[A-Z]{2}$',
158
-        'NO': r'^[0-9]{4}$',
159
-        'NP': r'^[0-9]{5}$',
160
-        'NZ': r'^[0-9]{4}$',
161
-        'OM': r'^[0-9]{3}$',
162
-        'PA': r'^[0-9]{6}$',
163
-        'PE': r'^[0-9]{5}$',
164
-        'PF': r'^[0-9]{5}$',
165
-        'PG': r'^[0-9]{3}$',
166
-        'PH': r'^[0-9]{4}$',
167
-        'PK': r'^[0-9]{5}$',
168
-        'PL': r'^[0-9]{2}-?[0-9]{3}$',
169
-        'PM': r'^[0-9]{5}$',
170
-        'PN': r'^[A-Z]{4}[0-9][A-Z]{2}$',
171
-        'PR': r'^[0-9]{5}$',
172
-        'PT': r'^[0-9]{4}(-?[0-9]{3})?$',
173
-        'PW': r'^[0-9]{5}$',
174
-        'PY': r'^[0-9]{4}$',
175
-        'RE': r'^[0-9]{5}$',
176
-        'RO': r'^[0-9]{6}$',
177
-        'RS': r'^[0-9]{5}$',
178
-        'RU': r'^[0-9]{6}$',
179
-        'SA': r'^[0-9]{5}$',
180
-        'SD': r'^[0-9]{5}$',
181
-        'SE': r'^[0-9]{5}$',
182
-        'SG': r'^([0-9]{2}|[0-9]{4}|[0-9]{6})$',
183
-        'SH': r'^(STHL1ZZ|TDCU1ZZ)$',
184
-        'SI': r'^(SI-)?[0-9]{4}$',
185
-        'SK': r'^[0-9]{5}$',
186
-        'SM': r'^[0-9]{5}$',
187
-        'SN': r'^[0-9]{5}$',
188
-        'SV': r'^01101$',
189
-        'SZ': r'^[A-Z][0-9]{3}$',
190
-        'TC': r'^TKCA1ZZ$',
191
-        'TD': r'^[0-9]{5}$',
192
-        'TH': r'^[0-9]{5}$',
193
-        'TJ': r'^[0-9]{6}$',
194
-        'TM': r'^[0-9]{6}$',
195
-        'TN': r'^[0-9]{4}$',
196
-        'TR': r'^[0-9]{5}$',
197
-        'TT': r'^[0-9]{6}$',
198
-        'TW': r'^([0-9]{3}|[0-9]{5})$',
199
-        'UA': r'^[0-9]{5}$',
200
-        'US': r'^[0-9]{5}(-[0-9]{4}|-[0-9]{6})?$',
201
-        'UY': r'^[0-9]{5}$',
202
-        'UZ': r'^[0-9]{6}$',
203
-        'VA': r'^00120$',
204
-        'VC': r'^VC[0-9]{4}',
205
-        'VE': r'^[0-9]{4}[A-Z]?$',
206
-        'VG': r'^VG[0-9]{4}$',
207
-        'VI': r'^[0-9]{5}$',
208
-        'VN': r'^[0-9]{6}$',
209
-        'WF': r'^[0-9]{5}$',
210
-        'XK': r'^[0-9]{5}$',
211
-        'YT': r'^[0-9]{5}$',
212
-        'ZA': r'^[0-9]{4}$',
213
-        'ZM': r'^[0-9]{5}$',
37
+        "AC": r"^[A-Z]{4}[0-9][A-Z]$",
38
+        "AD": r"^AD[0-9]{3}$",
39
+        "AF": r"^[0-9]{4}$",
40
+        "AI": r"^AI-2640$",
41
+        "AL": r"^[0-9]{4}$",
42
+        "AM": r"^[0-9]{4}$",
43
+        "AR": r"^([0-9]{4}|[A-Z][0-9]{4}[A-Z]{3})$",
44
+        "AS": r"^[0-9]{5}(-[0-9]{4}|-[0-9]{6})?$",
45
+        "AT": r"^[0-9]{4}$",
46
+        "AU": r"^[0-9]{4}$",
47
+        "AX": r"^[0-9]{5}$",
48
+        "AZ": r"^AZ[0-9]{4}$",
49
+        "BA": r"^[0-9]{5}$",
50
+        "BB": r"^BB[0-9]{5}$",
51
+        "BD": r"^[0-9]{4}$",
52
+        "BE": r"^[0-9]{4}$",
53
+        "BG": r"^[0-9]{4}$",
54
+        "BH": r"^[0-9]{3,4}$",
55
+        "BL": r"^[0-9]{5}$",
56
+        "BM": r"^[A-Z]{2}([0-9]{2}|[A-Z]{2})",
57
+        "BN": r"^[A-Z]{2}[0-9]{4}$",
58
+        "BO": r"^[0-9]{4}$",
59
+        "BR": r"^[0-9]{5}(-[0-9]{3})?$",
60
+        "BT": r"^[0-9]{3}$",
61
+        "BY": r"^[0-9]{6}$",
62
+        "CA": r"^[A-Z][0-9][A-Z][0-9][A-Z][0-9]$",
63
+        "CC": r"^[0-9]{4}$",
64
+        "CH": r"^[0-9]{4}$",
65
+        "CL": r"^([0-9]{7}|[0-9]{3}-[0-9]{4})$",
66
+        "CN": r"^[0-9]{6}$",
67
+        "CO": r"^[0-9]{6}$",
68
+        "CR": r"^[0-9]{4,5}$",
69
+        "CU": r"^[0-9]{5}$",
70
+        "CV": r"^[0-9]{4}$",
71
+        "CX": r"^[0-9]{4}$",
72
+        "CY": r"^[0-9]{4}$",
73
+        "CZ": r"^[0-9]{5}$",
74
+        "DE": r"^[0-9]{5}$",
75
+        "DK": r"^[0-9]{4}$",
76
+        "DO": r"^[0-9]{5}$",
77
+        "DZ": r"^[0-9]{5}$",
78
+        "EC": r"^EC[0-9]{6}$",
79
+        "EE": r"^[0-9]{5}$",
80
+        "EG": r"^[0-9]{5}$",
81
+        "ES": r"^[0-9]{5}$",
82
+        "ET": r"^[0-9]{4}$",
83
+        "FI": r"^[0-9]{5}$",
84
+        "FK": r"^[A-Z]{4}[0-9][A-Z]{2}$",
85
+        "FM": r"^[0-9]{5}(-[0-9]{4})?$",
86
+        "FO": r"^[0-9]{3}$",
87
+        "FR": r"^[0-9]{5}$",
88
+        "GA": r"^[0-9]{2}.*[0-9]{2}$",
89
+        "GB": r"^[A-Z][A-Z0-9]{1,3}[0-9][A-Z]{2}$",
90
+        "GE": r"^[0-9]{4}$",
91
+        "GF": r"^[0-9]{5}$",
92
+        "GG": r"^([A-Z]{2}[0-9]{2,3}[A-Z]{2})$",
93
+        "GI": r"^GX111AA$",
94
+        "GL": r"^[0-9]{4}$",
95
+        "GP": r"^[0-9]{5}$",
96
+        "GR": r"^[0-9]{5}$",
97
+        "GS": r"^SIQQ1ZZ$",
98
+        "GT": r"^[0-9]{5}$",
99
+        "GU": r"^[0-9]{5}$",
100
+        "GW": r"^[0-9]{4}$",
101
+        "HM": r"^[0-9]{4}$",
102
+        "HN": r"^[0-9]{5}$",
103
+        "HR": r"^[0-9]{5}$",
104
+        "HT": r"^[0-9]{4}$",
105
+        "HU": r"^[0-9]{4}$",
106
+        "ID": r"^[0-9]{5}$",
107
+        "IL": r"^([0-9]{5}|[0-9]{7})$",
108
+        "IM": r"^IM[0-9]{2,3}[A-Z]{2}$$",
109
+        "IN": r"^[0-9]{6}$",
110
+        "IO": r"^[A-Z]{4}[0-9][A-Z]{2}$",
111
+        "IQ": r"^[0-9]{5}$",
112
+        "IR": r"^[0-9]{5}-[0-9]{5}$",
113
+        "IS": r"^[0-9]{3}$",
114
+        "IT": r"^[0-9]{5}$",
115
+        "JE": r"^JE[0-9]{2}[A-Z]{2}$",
116
+        "JM": r"^JM[A-Z]{3}[0-9]{2}$",
117
+        "JO": r"^[0-9]{5}$",
118
+        "JP": r"^[0-9]{3}-?[0-9]{4}$",
119
+        "KE": r"^[0-9]{5}$",
120
+        "KG": r"^[0-9]{6}$",
121
+        "KH": r"^[0-9]{5}$",
122
+        "KR": r"^[0-9]{5}$",
123
+        "KY": r"^KY[0-9]-[0-9]{4}$",
124
+        "KZ": r"^[0-9]{6}$",
125
+        "LA": r"^[0-9]{5}$",
126
+        "LB": r"^[0-9]{8}$",
127
+        "LI": r"^[0-9]{4}$",
128
+        "LK": r"^[0-9]{5}$",
129
+        "LR": r"^[0-9]{4}$",
130
+        "LS": r"^[0-9]{3}$",
131
+        "LT": r"^(LT-)?[0-9]{5}$",
132
+        "LU": r"^[0-9]{4}$",
133
+        "LV": r"^LV-[0-9]{4}$",
134
+        "LY": r"^[0-9]{5}$",
135
+        "MA": r"^[0-9]{5}$",
136
+        "MC": r"^980[0-9]{2}$",
137
+        "MD": r"^MD-?[0-9]{4}$",
138
+        "ME": r"^[0-9]{5}$",
139
+        "MF": r"^[0-9]{5}$",
140
+        "MG": r"^[0-9]{3}$",
141
+        "MH": r"^[0-9]{5}$",
142
+        "MK": r"^[0-9]{4}$",
143
+        "MM": r"^[0-9]{5}$",
144
+        "MN": r"^[0-9]{5}$",
145
+        "MP": r"^[0-9]{5}$",
146
+        "MQ": r"^[0-9]{5}$",
147
+        "MT": r"^[A-Z]{3}[0-9]{4}$",
148
+        "MV": r"^[0-9]{4,5}$",
149
+        "MX": r"^[0-9]{5}$",
150
+        "MY": r"^[0-9]{5}$",
151
+        "MZ": r"^[0-9]{4}$",
152
+        "NA": r"^[0-9]{5}$",
153
+        "NC": r"^[0-9]{5}$",
154
+        "NE": r"^[0-9]{4}$",
155
+        "NF": r"^[0-9]{4}$",
156
+        "NG": r"^[0-9]{6}$",
157
+        "NI": r"^[0-9]{5}$",
158
+        "NL": r"^[0-9]{4}[A-Z]{2}$",
159
+        "NO": r"^[0-9]{4}$",
160
+        "NP": r"^[0-9]{5}$",
161
+        "NZ": r"^[0-9]{4}$",
162
+        "OM": r"^[0-9]{3}$",
163
+        "PA": r"^[0-9]{6}$",
164
+        "PE": r"^[0-9]{5}$",
165
+        "PF": r"^[0-9]{5}$",
166
+        "PG": r"^[0-9]{3}$",
167
+        "PH": r"^[0-9]{4}$",
168
+        "PK": r"^[0-9]{5}$",
169
+        "PL": r"^[0-9]{2}-?[0-9]{3}$",
170
+        "PM": r"^[0-9]{5}$",
171
+        "PN": r"^[A-Z]{4}[0-9][A-Z]{2}$",
172
+        "PR": r"^[0-9]{5}$",
173
+        "PT": r"^[0-9]{4}(-?[0-9]{3})?$",
174
+        "PW": r"^[0-9]{5}$",
175
+        "PY": r"^[0-9]{4}$",
176
+        "RE": r"^[0-9]{5}$",
177
+        "RO": r"^[0-9]{6}$",
178
+        "RS": r"^[0-9]{5}$",
179
+        "RU": r"^[0-9]{6}$",
180
+        "SA": r"^[0-9]{5}$",
181
+        "SD": r"^[0-9]{5}$",
182
+        "SE": r"^[0-9]{5}$",
183
+        "SG": r"^([0-9]{2}|[0-9]{4}|[0-9]{6})$",
184
+        "SH": r"^(STHL1ZZ|TDCU1ZZ)$",
185
+        "SI": r"^(SI-)?[0-9]{4}$",
186
+        "SK": r"^[0-9]{5}$",
187
+        "SM": r"^[0-9]{5}$",
188
+        "SN": r"^[0-9]{5}$",
189
+        "SV": r"^01101$",
190
+        "SZ": r"^[A-Z][0-9]{3}$",
191
+        "TC": r"^TKCA1ZZ$",
192
+        "TD": r"^[0-9]{5}$",
193
+        "TH": r"^[0-9]{5}$",
194
+        "TJ": r"^[0-9]{6}$",
195
+        "TM": r"^[0-9]{6}$",
196
+        "TN": r"^[0-9]{4}$",
197
+        "TR": r"^[0-9]{5}$",
198
+        "TT": r"^[0-9]{6}$",
199
+        "TW": r"^([0-9]{3}|[0-9]{5})$",
200
+        "UA": r"^[0-9]{5}$",
201
+        "US": r"^[0-9]{5}(-[0-9]{4}|-[0-9]{6})?$",
202
+        "UY": r"^[0-9]{5}$",
203
+        "UZ": r"^[0-9]{6}$",
204
+        "VA": r"^00120$",
205
+        "VC": r"^VC[0-9]{4}",
206
+        "VE": r"^[0-9]{4}[A-Z]?$",
207
+        "VG": r"^VG[0-9]{4}$",
208
+        "VI": r"^[0-9]{5}$",
209
+        "VN": r"^[0-9]{6}$",
210
+        "WF": r"^[0-9]{5}$",
211
+        "XK": r"^[0-9]{5}$",
212
+        "YT": r"^[0-9]{5}$",
213
+        "ZA": r"^[0-9]{4}$",
214
+        "ZM": r"^[0-9]{5}$",
214 215
     }
215 216
 
216 217
     title = models.CharField(
217 218
         pgettext_lazy("Treatment Pronouns for the customer", "Title"),
218
-        max_length=64, choices=TITLE_CHOICES, blank=True)
219
+        max_length=64,
220
+        choices=TITLE_CHOICES,
221
+        blank=True,
222
+    )
219 223
     first_name = models.CharField(_("First name"), max_length=255, blank=True)
220 224
     last_name = models.CharField(_("Last name"), max_length=255, blank=True)
221 225
 
222 226
     # We use quite a few lines of an address as they are often quite long and
223 227
     # it's easier to just hide the unnecessary ones than add extra ones.
224 228
     line1 = models.CharField(_("First line of address"), max_length=255)
225
-    line2 = models.CharField(
226
-        _("Second line of address"), max_length=255, blank=True)
227
-    line3 = models.CharField(
228
-        _("Third line of address"), max_length=255, blank=True)
229
+    line2 = models.CharField(_("Second line of address"), max_length=255, blank=True)
230
+    line3 = models.CharField(_("Third line of address"), max_length=255, blank=True)
229 231
     line4 = models.CharField(_("City"), max_length=255, blank=True)
230 232
     state = models.CharField(_("State/County"), max_length=255, blank=True)
231
-    postcode = UppercaseCharField(
232
-        _("Post/Zip-code"), max_length=64, blank=True)
233
+    postcode = UppercaseCharField(_("Post/Zip-code"), max_length=64, blank=True)
233 234
     country = models.ForeignKey(
234
-        'address.Country',
235
-        on_delete=models.CASCADE,
236
-        verbose_name=_("Country"))
235
+        "address.Country", on_delete=models.CASCADE, verbose_name=_("Country")
236
+    )
237 237
 
238 238
     # A field only used for searching addresses - this contains all the
239 239
     # `search_fields`.  This is effectively a poor man's Solr text field.
240 240
     search_text = models.TextField(
241
-        _("Search text - used only for searching addresses"), editable=False)
242
-    search_fields = ['first_name', 'last_name', 'line1', 'line2', 'line3', 'line4', 'state', 'postcode', 'country']
241
+        _("Search text - used only for searching addresses"), editable=False
242
+    )
243
+    search_fields = [
244
+        "first_name",
245
+        "last_name",
246
+        "line1",
247
+        "line2",
248
+        "line3",
249
+        "line4",
250
+        "state",
251
+        "postcode",
252
+        "country",
253
+    ]
243 254
 
244 255
     # Fields, used for `summary` property definition and hash generation.
245
-    base_fields = hash_fields = ['salutation', 'line1', 'line2', 'line3', 'line4', 'state', 'postcode', 'country']
256
+    base_fields = hash_fields = [
257
+        "salutation",
258
+        "line1",
259
+        "line2",
260
+        "line3",
261
+        "line4",
262
+        "state",
263
+        "postcode",
264
+        "country",
265
+    ]
246 266
 
247 267
     def __str__(self):
248 268
         return self.summary
249 269
 
250 270
     class Meta:
251 271
         abstract = True
252
-        verbose_name = _('Address')
253
-        verbose_name_plural = _('Addresses')
272
+        verbose_name = _("Address")
273
+        verbose_name_plural = _("Addresses")
254 274
 
255 275
     # Saving
256 276
 
@@ -260,8 +280,16 @@ class AbstractAddress(models.Model):
260 280
 
261 281
     def clean(self):
262 282
         # Strip all whitespace
263
-        for field in ['first_name', 'last_name', 'line1', 'line2', 'line3',
264
-                      'line4', 'state', 'postcode']:
283
+        for field in [
284
+            "first_name",
285
+            "last_name",
286
+            "line1",
287
+            "line2",
288
+            "line3",
289
+            "line4",
290
+            "state",
291
+            "postcode",
292
+        ]:
265 293
             if self.__dict__[field]:
266 294
                 self.__dict__[field] = self.__dict__[field].strip()
267 295
 
@@ -276,27 +304,27 @@ class AbstractAddress(models.Model):
276 304
             country_code = self.country.iso_3166_1_a2
277 305
             regex = self.POSTCODES_REGEX.get(country_code, None)
278 306
             if regex:
279
-                msg = _("Addresses in %(country)s require a valid postcode") \
280
-                    % {'country': self.country}
307
+                msg = _("Addresses in %(country)s require a valid postcode") % {
308
+                    "country": self.country
309
+                }
281 310
                 raise exceptions.ValidationError(msg)
282 311
 
283 312
         if self.postcode and self.country_id:
284 313
             # Ensure postcodes are always uppercase
285
-            postcode = self.postcode.upper().replace(' ', '')
314
+            postcode = self.postcode.upper().replace(" ", "")
286 315
             country_code = self.country.iso_3166_1_a2
287 316
             regex = self.POSTCODES_REGEX.get(country_code, None)
288 317
 
289 318
             # Validate postcode against regex for the country if available
290 319
             if regex and not re.match(regex, postcode):
291
-                msg = _("The postcode '%(postcode)s' is not valid "
292
-                        "for %(country)s") \
293
-                    % {'postcode': self.postcode,
294
-                       'country': self.country}
295
-                raise exceptions.ValidationError(
296
-                    {'postcode': [msg]})
320
+                msg = _("The postcode '%(postcode)s' is not valid for %(country)s") % {
321
+                    "postcode": self.postcode,
322
+                    "country": self.country,
323
+                }
324
+                raise exceptions.ValidationError({"postcode": [msg]})
297 325
 
298 326
     def _update_search_text(self):
299
-        self.search_text = self.join_fields(self.search_fields, separator=' ')
327
+        self.search_text = self.join_fields(self.search_fields, separator=" ")
300 328
 
301 329
     # Properties
302 330
 
@@ -319,12 +347,12 @@ class AbstractAddress(models.Model):
319 347
         Name (including title)
320 348
         """
321 349
         return self.join_fields(
322
-            ('title', 'first_name', 'last_name'),
323
-            separator=" ").strip()
350
+            ("title", "first_name", "last_name"), separator=" "
351
+        ).strip()
324 352
 
325 353
     @property
326 354
     def name(self):
327
-        return self.join_fields(('first_name', 'last_name'), separator=" ")
355
+        return self.join_fields(("first_name", "last_name"), separator=" ")
328 356
 
329 357
     # Helpers
330 358
 
@@ -332,14 +360,14 @@ class AbstractAddress(models.Model):
332 360
         field_values = []
333 361
         for field in fields:
334 362
             # Title is special case
335
-            if field == 'title':
363
+            if field == "title":
336 364
                 value = self.get_title_display()
337
-            elif field == 'country':
365
+            elif field == "country":
338 366
                 try:
339 367
                     value = self.country.printable_name
340 368
                 except exceptions.ObjectDoesNotExist:
341
-                    value = ''
342
-            elif field == 'salutation':
369
+                    value = ""
370
+            elif field == "salutation":
343 371
                 value = self.salutation
344 372
             else:
345 373
                 value = getattr(self, field)
@@ -362,7 +390,7 @@ class AbstractAddress(models.Model):
362 390
         # Python 2 and 3 generates CRC checksum in different ranges, so
363 391
         # in order to generate platform-independent value we apply
364 392
         # `& 0xffffffff` expression.
365
-        return zlib.crc32(', '.join(field_values).upper().encode('UTF8')) & 0xffffffff
393
+        return zlib.crc32(", ".join(field_values).upper().encode("UTF8")) & 0xFFFFFFFF
366 394
 
367 395
     def join_fields(self, fields, separator=", "):
368 396
         """
@@ -379,10 +407,9 @@ class AbstractAddress(models.Model):
379 407
         This is used to convert a user address to a shipping address
380 408
         as part of the checkout process.
381 409
         """
382
-        destination_field_names = [
383
-            field.name for field in address_model._meta.fields]
410
+        destination_field_names = [field.name for field in address_model._meta.fields]
384 411
         for field_name in [field.name for field in self._meta.fields]:
385
-            if field_name in destination_field_names and field_name != 'id':
412
+            if field_name in destination_field_names and field_name != "id":
386 413
                 setattr(address_model, field_name, getattr(self, field_name))
387 414
 
388 415
     def active_address_fields(self):
@@ -401,32 +428,41 @@ class AbstractCountry(models.Model):
401 428
     The field names are a bit awkward, but kept for backwards compatibility.
402 429
     pycountry's syntax of alpha2, alpha3, name and official_name seems sane.
403 430
     """
431
+
404 432
     iso_3166_1_a2 = models.CharField(
405
-        _('ISO 3166-1 alpha-2'), max_length=2, primary_key=True)
406
-    iso_3166_1_a3 = models.CharField(
407
-        _('ISO 3166-1 alpha-3'), max_length=3, blank=True)
433
+        _("ISO 3166-1 alpha-2"), max_length=2, primary_key=True
434
+    )
435
+    iso_3166_1_a3 = models.CharField(_("ISO 3166-1 alpha-3"), max_length=3, blank=True)
408 436
     iso_3166_1_numeric = models.CharField(
409
-        _('ISO 3166-1 numeric'), blank=True, max_length=3)
437
+        _("ISO 3166-1 numeric"), blank=True, max_length=3
438
+    )
410 439
 
411 440
     #: The commonly used name; e.g. 'United Kingdom'
412
-    printable_name = models.CharField(_('Country name'), max_length=128, db_index=True)
441
+    printable_name = models.CharField(_("Country name"), max_length=128, db_index=True)
413 442
     #: The full official name of a country
414 443
     #: e.g. 'United Kingdom of Great Britain and Northern Ireland'
415
-    name = models.CharField(_('Official name'), max_length=128)
444
+    name = models.CharField(_("Official name"), max_length=128)
416 445
 
417 446
     display_order = models.PositiveSmallIntegerField(
418
-        _("Display order"), default=0, db_index=True,
419
-        help_text=_('Higher the number, higher the country in the list.'))
447
+        _("Display order"),
448
+        default=0,
449
+        db_index=True,
450
+        help_text=_("Higher the number, higher the country in the list."),
451
+    )
420 452
 
421 453
     is_shipping_country = models.BooleanField(
422
-        _("Is shipping country"), default=False, db_index=True)
454
+        _("Is shipping country"), default=False, db_index=True
455
+    )
423 456
 
424 457
     class Meta:
425 458
         abstract = True
426
-        app_label = 'address'
427
-        verbose_name = _('Country')
428
-        verbose_name_plural = _('Countries')
429
-        ordering = ('-display_order', 'printable_name',)
459
+        app_label = "address"
460
+        verbose_name = _("Country")
461
+        verbose_name_plural = _("Countries")
462
+        ordering = (
463
+            "-display_order",
464
+            "printable_name",
465
+        )
430 466
 
431 467
     def __str__(self):
432 468
         return self.printable_name or self.name
@@ -468,17 +504,20 @@ class AbstractShippingAddress(AbstractAddress):
468 504
     """
469 505
 
470 506
     phone_number = PhoneNumberField(
471
-        _("Phone number"), blank=True,
472
-        help_text=_("In case we need to call you about your order"))
507
+        _("Phone number"),
508
+        blank=True,
509
+        help_text=_("In case we need to call you about your order"),
510
+    )
473 511
     notes = models.TextField(
474
-        blank=True, verbose_name=_('Instructions'),
475
-        help_text=_("Tell us anything we should know when delivering "
476
-                    "your order."))
512
+        blank=True,
513
+        verbose_name=_("Instructions"),
514
+        help_text=_("Tell us anything we should know when delivering your order."),
515
+    )
477 516
 
478 517
     class Meta:
479 518
         abstract = True
480 519
         # ShippingAddress is registered in order/models.py
481
-        app_label = 'order'
520
+        app_label = "order"
482 521
         verbose_name = _("Shipping address")
483 522
         verbose_name_plural = _("Shipping addresses")
484 523
 
@@ -501,34 +540,41 @@ class AbstractUserAddress(AbstractShippingAddress):
501 540
     model, we allow users the ability to add/edit/delete from their address
502 541
     book without affecting orders already placed.
503 542
     """
543
+
504 544
     user = models.ForeignKey(
505 545
         AUTH_USER_MODEL,
506 546
         on_delete=models.CASCADE,
507
-        related_name='addresses',
508
-        verbose_name=_("User"))
547
+        related_name="addresses",
548
+        verbose_name=_("User"),
549
+    )
509 550
 
510 551
     #: Whether this address is the default for shipping
511 552
     is_default_for_shipping = models.BooleanField(
512
-        _("Default shipping address?"), default=False)
553
+        _("Default shipping address?"), default=False
554
+    )
513 555
 
514 556
     #: Whether this address should be the default for billing.
515 557
     is_default_for_billing = models.BooleanField(
516
-        _("Default billing address?"), default=False)
558
+        _("Default billing address?"), default=False
559
+    )
517 560
 
518 561
     #: We keep track of the number of times an address has been used
519 562
     #: as a shipping address so we can show the most popular ones
520 563
     #: first at the checkout.
521 564
     num_orders_as_shipping_address = models.PositiveIntegerField(
522
-        _("Number of Orders as Shipping Address"), default=0)
565
+        _("Number of Orders as Shipping Address"), default=0
566
+    )
523 567
 
524 568
     #: Same as previous, but for billing address.
525 569
     num_orders_as_billing_address = models.PositiveIntegerField(
526
-        _("Number of Orders as Billing Address"), default=0)
570
+        _("Number of Orders as Billing Address"), default=0
571
+    )
527 572
 
528 573
     #: A hash is kept to try and avoid duplicate addresses being added
529 574
     #: to the address book.
530
-    hash = models.CharField(_("Address Hash"), max_length=255, db_index=True,
531
-                            editable=False)
575
+    hash = models.CharField(
576
+        _("Address Hash"), max_length=255, db_index=True, editable=False
577
+    )
532 578
     date_created = models.DateTimeField(_("Date Created"), auto_now_add=True)
533 579
 
534 580
     def save(self, *args, **kwargs):
@@ -546,40 +592,38 @@ class AbstractUserAddress(AbstractShippingAddress):
546 592
 
547 593
     def _ensure_defaults_integrity(self):
548 594
         if self.is_default_for_shipping:
549
-            self.__class__._default_manager\
550
-                .filter(user=self.user, is_default_for_shipping=True)\
551
-                .update(is_default_for_shipping=False)
595
+            self.__class__._default_manager.filter(
596
+                user=self.user, is_default_for_shipping=True
597
+            ).update(is_default_for_shipping=False)
552 598
         if self.is_default_for_billing:
553
-            self.__class__._default_manager\
554
-                .filter(user=self.user, is_default_for_billing=True)\
555
-                .update(is_default_for_billing=False)
599
+            self.__class__._default_manager.filter(
600
+                user=self.user, is_default_for_billing=True
601
+            ).update(is_default_for_billing=False)
556 602
 
557 603
     class Meta:
558 604
         abstract = True
559
-        app_label = 'address'
605
+        app_label = "address"
560 606
         verbose_name = _("User address")
561 607
         verbose_name_plural = _("User addresses")
562
-        ordering = ['-num_orders_as_shipping_address']
563
-        unique_together = ('user', 'hash')
608
+        ordering = ["-num_orders_as_shipping_address"]
609
+        unique_together = ("user", "hash")
564 610
 
565 611
     def validate_unique(self, exclude=None):
566 612
         super().validate_unique(exclude)
567
-        qs = self.__class__.objects.filter(
568
-            user=self.user,
569
-            hash=self.generate_hash())
613
+        qs = self.__class__.objects.filter(user=self.user, hash=self.generate_hash())
570 614
         if self.id:
571 615
             qs = qs.exclude(id=self.id)
572 616
         if qs.exists():
573
-            raise exceptions.ValidationError({
574
-                '__all__': [_("This address is already in your address"
575
-                              " book")]})
617
+            raise exceptions.ValidationError(
618
+                {"__all__": [_("This address is already in your address book")]}
619
+            )
576 620
 
577 621
 
578 622
 class AbstractBillingAddress(AbstractAddress):
579 623
     class Meta:
580 624
         abstract = True
581 625
         # BillingAddress is registered in order/models.py
582
-        app_label = 'order'
626
+        app_label = "order"
583 627
         verbose_name = _("Billing address")
584 628
         verbose_name_plural = _("Billing addresses")
585 629
 
@@ -596,14 +640,16 @@ class AbstractPartnerAddress(AbstractAddress):
596 640
     A partner can have one or more addresses. This can be useful e.g. when
597 641
     determining US tax which depends on the origin of the shipment.
598 642
     """
643
+
599 644
     partner = models.ForeignKey(
600
-        'partner.Partner',
645
+        "partner.Partner",
601 646
         on_delete=models.CASCADE,
602
-        related_name='addresses',
603
-        verbose_name=_('Partner'))
647
+        related_name="addresses",
648
+        verbose_name=_("Partner"),
649
+    )
604 650
 
605 651
     class Meta:
606 652
         abstract = True
607
-        app_label = 'partner'
653
+        app_label = "partner"
608 654
         verbose_name = _("Partner address")
609 655
         verbose_name_plural = _("Partner addresses")

+ 9
- 16
src/oscar/apps/address/admin.py View File

@@ -4,24 +4,17 @@ from oscar.core.loading import get_model
4 4
 
5 5
 
6 6
 class UserAddressAdmin(admin.ModelAdmin):
7
-    readonly_fields = ('num_orders_as_billing_address', 'num_orders_as_shipping_address')
7
+    readonly_fields = (
8
+        "num_orders_as_billing_address",
9
+        "num_orders_as_shipping_address",
10
+    )
8 11
 
9 12
 
10 13
 class CountryAdmin(admin.ModelAdmin):
11
-    list_display = [
12
-        '__str__',
13
-        'display_order'
14
-    ]
15
-    list_filter = [
16
-        'is_shipping_country'
17
-    ]
18
-    search_fields = [
19
-        'name',
20
-        'printable_name',
21
-        'iso_3166_1_a2',
22
-        'iso_3166_1_a3'
23
-    ]
14
+    list_display = ["__str__", "display_order"]
15
+    list_filter = ["is_shipping_country"]
16
+    search_fields = ["name", "printable_name", "iso_3166_1_a2", "iso_3166_1_a3"]
24 17
 
25 18
 
26
-admin.site.register(get_model('address', 'useraddress'), UserAddressAdmin)
27
-admin.site.register(get_model('address', 'country'), CountryAdmin)
19
+admin.site.register(get_model("address", "useraddress"), UserAddressAdmin)
20
+admin.site.register(get_model("address", "country"), CountryAdmin)

+ 3
- 3
src/oscar/apps/address/apps.py View File

@@ -4,6 +4,6 @@ from oscar.core.application import OscarConfig
4 4
 
5 5
 
6 6
 class AddressConfig(OscarConfig):
7
-    label = 'address'
8
-    name = 'oscar.apps.address'
9
-    verbose_name = _('Address')
7
+    label = "address"
8
+    name = "oscar.apps.address"
9
+    verbose_name = _("Address")

+ 13
- 9
src/oscar/apps/address/forms.py View File

@@ -4,31 +4,35 @@ from django.conf import settings
4 4
 from oscar.core.loading import get_model
5 5
 from oscar.forms.mixins import PhoneNumberMixin
6 6
 
7
-UserAddress = get_model('address', 'useraddress')
7
+UserAddress = get_model("address", "useraddress")
8 8
 
9 9
 
10 10
 class AbstractAddressForm(forms.ModelForm):
11
-
12 11
     def __init__(self, *args, **kwargs):
13 12
         """
14 13
         Set fields in OSCAR_REQUIRED_ADDRESS_FIELDS as required.
15 14
         """
16 15
         super().__init__(*args, **kwargs)
17
-        field_names = (set(self.fields)
18
-                       & set(settings.OSCAR_REQUIRED_ADDRESS_FIELDS))
16
+        field_names = set(self.fields) & set(settings.OSCAR_REQUIRED_ADDRESS_FIELDS)
19 17
         for field_name in field_names:
20 18
             self.fields[field_name].required = True
21 19
 
22 20
 
23 21
 class UserAddressForm(PhoneNumberMixin, AbstractAddressForm):
24
-
25 22
     class Meta:
26 23
         model = UserAddress
27 24
         fields = [
28
-            'first_name', 'last_name',
29
-            'line1', 'line2', 'line3', 'line4',
30
-            'state', 'postcode', 'country',
31
-            'phone_number', 'notes',
25
+            "first_name",
26
+            "last_name",
27
+            "line1",
28
+            "line2",
29
+            "line3",
30
+            "line4",
31
+            "state",
32
+            "postcode",
33
+            "country",
34
+            "phone_number",
35
+            "notes",
32 36
         ]
33 37
 
34 38
     def __init__(self, user, *args, **kwargs):

+ 7
- 6
src/oscar/apps/address/models.py View File

@@ -1,19 +1,20 @@
1
-from oscar.apps.address.abstract_models import (
2
-    AbstractCountry, AbstractUserAddress)
1
+from oscar.apps.address.abstract_models import AbstractCountry, AbstractUserAddress
3 2
 from oscar.core.loading import is_model_registered
4 3
 
5 4
 __all__ = []
6 5
 
7 6
 
8
-if not is_model_registered('address', 'UserAddress'):
7
+if not is_model_registered("address", "UserAddress"):
8
+
9 9
     class UserAddress(AbstractUserAddress):
10 10
         pass
11 11
 
12
-    __all__.append('UserAddress')
12
+    __all__.append("UserAddress")
13
+
13 14
 
15
+if not is_model_registered("address", "Country"):
14 16
 
15
-if not is_model_registered('address', 'Country'):
16 17
     class Country(AbstractCountry):
17 18
         pass
18 19
 
19
-    __all__.append('Country')
20
+    __all__.append("Country")

+ 1
- 1
src/oscar/apps/analytics/__init__.py View File

@@ -1 +1 @@
1
-default_app_config = 'oscar.apps.analytics.apps.AnalyticsConfig'
1
+default_app_config = "oscar.apps.analytics.apps.AnalyticsConfig"

+ 49
- 47
src/oscar/apps/analytics/abstract_models.py View File

@@ -15,25 +15,28 @@ class AbstractProductRecord(models.Model):
15 15
     """
16 16
 
17 17
     product = models.OneToOneField(
18
-        'catalogue.Product', verbose_name=_("Product"),
19
-        related_name='stats', on_delete=models.CASCADE)
18
+        "catalogue.Product",
19
+        verbose_name=_("Product"),
20
+        related_name="stats",
21
+        on_delete=models.CASCADE,
22
+    )
20 23
 
21 24
     # Data used for generating a score
22
-    num_views = models.PositiveIntegerField(_('Views'), default=0)
23
-    num_basket_additions = models.PositiveIntegerField(
24
-        _('Basket Additions'), default=0)
25
+    num_views = models.PositiveIntegerField(_("Views"), default=0)
26
+    num_basket_additions = models.PositiveIntegerField(_("Basket Additions"), default=0)
25 27
     num_purchases = models.PositiveIntegerField(
26
-        _('Purchases'), default=0, db_index=True)
28
+        _("Purchases"), default=0, db_index=True
29
+    )
27 30
 
28 31
     # Product score - used within search
29
-    score = models.FloatField(_('Score'), default=0.00)
32
+    score = models.FloatField(_("Score"), default=0.00)
30 33
 
31 34
     class Meta:
32 35
         abstract = True
33
-        app_label = 'analytics'
34
-        ordering = ['-num_purchases']
35
-        verbose_name = _('Product record')
36
-        verbose_name_plural = _('Product records')
36
+        app_label = "analytics"
37
+        ordering = ["-num_purchases"]
38
+        verbose_name = _("Product record")
39
+        verbose_name_plural = _("Product records")
37 40
 
38 41
     def __str__(self):
39 42
         return _("Record for '%s'") % self.product
@@ -44,74 +47,73 @@ class AbstractUserRecord(models.Model):
44 47
     A record of a user's activity.
45 48
     """
46 49
 
47
-    user = models.OneToOneField(AUTH_USER_MODEL, verbose_name=_("User"),
48
-                                on_delete=models.CASCADE)
50
+    user = models.OneToOneField(
51
+        AUTH_USER_MODEL, verbose_name=_("User"), on_delete=models.CASCADE
52
+    )
49 53
 
50 54
     # Browsing stats
51
-    num_product_views = models.PositiveIntegerField(
52
-        _('Product Views'), default=0)
53
-    num_basket_additions = models.PositiveIntegerField(
54
-        _('Basket Additions'), default=0)
55
+    num_product_views = models.PositiveIntegerField(_("Product Views"), default=0)
56
+    num_basket_additions = models.PositiveIntegerField(_("Basket Additions"), default=0)
55 57
 
56 58
     # Order stats
57
-    num_orders = models.PositiveIntegerField(
58
-        _('Orders'), default=0, db_index=True)
59
+    num_orders = models.PositiveIntegerField(_("Orders"), default=0, db_index=True)
59 60
     num_order_lines = models.PositiveIntegerField(
60
-        _('Order Lines'), default=0, db_index=True)
61
+        _("Order Lines"), default=0, db_index=True
62
+    )
61 63
     num_order_items = models.PositiveIntegerField(
62
-        _('Order Items'), default=0, db_index=True)
63
-    total_spent = models.DecimalField(_('Total Spent'), decimal_places=2,
64
-                                      max_digits=12, default=Decimal('0.00'))
65
-    date_last_order = models.DateTimeField(
66
-        _('Last Order Date'), blank=True, null=True)
64
+        _("Order Items"), default=0, db_index=True
65
+    )
66
+    total_spent = models.DecimalField(
67
+        _("Total Spent"), decimal_places=2, max_digits=12, default=Decimal("0.00")
68
+    )
69
+    date_last_order = models.DateTimeField(_("Last Order Date"), blank=True, null=True)
67 70
 
68 71
     class Meta:
69 72
         abstract = True
70
-        app_label = 'analytics'
71
-        verbose_name = _('User record')
72
-        verbose_name_plural = _('User records')
73
+        app_label = "analytics"
74
+        verbose_name = _("User record")
75
+        verbose_name_plural = _("User records")
73 76
 
74 77
 
75 78
 class AbstractUserProductView(models.Model):
76
-
77 79
     user = models.ForeignKey(
78
-        AUTH_USER_MODEL, verbose_name=_("User"),
79
-        on_delete=models.CASCADE)
80
+        AUTH_USER_MODEL, verbose_name=_("User"), on_delete=models.CASCADE
81
+    )
80 82
     product = models.ForeignKey(
81
-        'catalogue.Product',
82
-        on_delete=models.CASCADE,
83
-        verbose_name=_("Product"))
83
+        "catalogue.Product", on_delete=models.CASCADE, verbose_name=_("Product")
84
+    )
84 85
     date_created = models.DateTimeField(_("Date Created"), auto_now_add=True)
85 86
 
86 87
     class Meta:
87 88
         abstract = True
88
-        app_label = 'analytics'
89
-        ordering = ['-pk']
90
-        verbose_name = _('User product view')
91
-        verbose_name_plural = _('User product views')
89
+        app_label = "analytics"
90
+        ordering = ["-pk"]
91
+        verbose_name = _("User product view")
92
+        verbose_name_plural = _("User product views")
92 93
 
93 94
     def __str__(self):
94 95
         return _("%(user)s viewed '%(product)s'") % {
95
-            'user': self.user, 'product': self.product}
96
+            "user": self.user,
97
+            "product": self.product,
98
+        }
96 99
 
97 100
 
98 101
 class AbstractUserSearch(models.Model):
99
-
100 102
     user = models.ForeignKey(
101
-        AUTH_USER_MODEL,
102
-        on_delete=models.CASCADE,
103
-        verbose_name=_("User"))
103
+        AUTH_USER_MODEL, on_delete=models.CASCADE, verbose_name=_("User")
104
+    )
104 105
     query = models.CharField(_("Search term"), max_length=255, db_index=True)
105 106
     date_created = models.DateTimeField(_("Date Created"), auto_now_add=True)
106 107
 
107 108
     class Meta:
108 109
         abstract = True
109
-        app_label = 'analytics'
110
-        ordering = ['-pk']
110
+        app_label = "analytics"
111
+        ordering = ["-pk"]
111 112
         verbose_name = _("User search query")
112 113
         verbose_name_plural = _("User search queries")
113 114
 
114 115
     def __str__(self):
115 116
         return _("%(user)s searched for '%(query)s'") % {
116
-            'user': self.user,
117
-            'query': self.query}
117
+            "user": self.user,
118
+            "query": self.query,
119
+        }

+ 16
- 13
src/oscar/apps/analytics/admin.py View File

@@ -4,22 +4,25 @@ from oscar.core.loading import get_model
4 4
 
5 5
 
6 6
 class ProductRecordAdmin(admin.ModelAdmin):
7
-    list_display = ('product', 'num_views', 'num_basket_additions',
8
-                    'num_purchases')
7
+    list_display = ("product", "num_views", "num_basket_additions", "num_purchases")
9 8
 
10 9
 
11 10
 class UserProductViewAdmin(admin.ModelAdmin):
12
-    list_display = ('user', 'product', 'date_created')
11
+    list_display = ("user", "product", "date_created")
13 12
 
14 13
 
15 14
 class UserRecordAdmin(admin.ModelAdmin):
16
-    list_display = ('user', 'num_product_views', 'num_basket_additions',
17
-                    'num_orders', 'total_spent', 'date_last_order')
18
-
19
-
20
-admin.site.register(get_model('analytics', 'productrecord'),
21
-                    ProductRecordAdmin)
22
-admin.site.register(get_model('analytics', 'userrecord'), UserRecordAdmin)
23
-admin.site.register(get_model('analytics', 'usersearch'))
24
-admin.site.register(get_model('analytics', 'userproductview'),
25
-                    UserProductViewAdmin)
15
+    list_display = (
16
+        "user",
17
+        "num_product_views",
18
+        "num_basket_additions",
19
+        "num_orders",
20
+        "total_spent",
21
+        "date_last_order",
22
+    )
23
+
24
+
25
+admin.site.register(get_model("analytics", "productrecord"), ProductRecordAdmin)
26
+admin.site.register(get_model("analytics", "userrecord"), UserRecordAdmin)
27
+admin.site.register(get_model("analytics", "usersearch"))
28
+admin.site.register(get_model("analytics", "userproductview"), UserProductViewAdmin)

+ 5
- 4
src/oscar/apps/analytics/apps.py View File

@@ -4,9 +4,10 @@ from oscar.core.application import OscarConfig
4 4
 
5 5
 
6 6
 class AnalyticsConfig(OscarConfig):
7
-    label = 'analytics'
8
-    name = 'oscar.apps.analytics'
9
-    verbose_name = _('Analytics')
7
+    label = "analytics"
8
+    name = "oscar.apps.analytics"
9
+    verbose_name = _("Analytics")
10 10
 
11
+    # pylint: disable=unused-import
11 12
     def ready(self):
12
-        from . import receivers  # noqa
13
+        from . import receivers

+ 17
- 10
src/oscar/apps/analytics/models.py View File

@@ -1,34 +1,41 @@
1 1
 from oscar.apps.analytics.abstract_models import (
2
-    AbstractProductRecord, AbstractUserProductView,
3
-    AbstractUserRecord, AbstractUserSearch)
2
+    AbstractProductRecord,
3
+    AbstractUserProductView,
4
+    AbstractUserRecord,
5
+    AbstractUserSearch,
6
+)
4 7
 from oscar.core.loading import is_model_registered
5 8
 
6 9
 __all__ = []
7 10
 
8 11
 
9
-if not is_model_registered('analytics', 'ProductRecord'):
12
+if not is_model_registered("analytics", "ProductRecord"):
13
+
10 14
     class ProductRecord(AbstractProductRecord):
11 15
         pass
12 16
 
13
-    __all__.append('ProductRecord')
17
+    __all__.append("ProductRecord")
18
+
14 19
 
20
+if not is_model_registered("analytics", "UserRecord"):
15 21
 
16
-if not is_model_registered('analytics', 'UserRecord'):
17 22
     class UserRecord(AbstractUserRecord):
18 23
         pass
19 24
 
20
-    __all__.append('UserRecord')
25
+    __all__.append("UserRecord")
21 26
 
22 27
 
23
-if not is_model_registered('analytics', 'UserProductView'):
28
+if not is_model_registered("analytics", "UserProductView"):
29
+
24 30
     class UserProductView(AbstractUserProductView):
25 31
         pass
26 32
 
27
-    __all__.append('UserProductView')
33
+    __all__.append("UserProductView")
34
+
28 35
 
36
+if not is_model_registered("analytics", "UserSearch"):
29 37
 
30
-if not is_model_registered('analytics', 'UserSearch'):
31 38
     class UserSearch(AbstractUserSearch):
32 39
         pass
33 40
 
34
-    __all__.append('UserSearch')
41
+    __all__.append("UserSearch")

+ 35
- 29
src/oscar/apps/analytics/receivers.py View File

@@ -10,14 +10,14 @@ from oscar.apps.order.signals import order_placed
10 10
 from oscar.apps.search.signals import user_search
11 11
 from oscar.core.loading import get_model
12 12
 
13
-ProductRecord = get_model('analytics', 'ProductRecord')
14
-UserProductView = get_model('analytics', 'UserProductView')
15
-UserRecord = get_model('analytics', 'UserRecord')
16
-UserSearch = get_model('analytics', 'UserSearch')
13
+ProductRecord = get_model("analytics", "ProductRecord")
14
+UserProductView = get_model("analytics", "UserProductView")
15
+UserRecord = get_model("analytics", "UserRecord")
16
+UserSearch = get_model("analytics", "UserSearch")
17 17
 
18 18
 # Helpers
19 19
 
20
-logger = logging.getLogger('oscar.analytics')
20
+logger = logging.getLogger("oscar.analytics")
21 21
 
22 22
 
23 23
 def _update_counter(model, field_name, filter_kwargs, increment=1):
@@ -38,72 +38,78 @@ def _update_counter(model, field_name, filter_kwargs, increment=1):
38 38
         if not affected:
39 39
             filter_kwargs[field_name] = increment
40 40
             model.objects.create(**filter_kwargs)
41
-    except IntegrityError:      # pragma: no cover
41
+    except IntegrityError:  # pragma: no cover
42 42
         # get_or_create has a race condition (we should use upsert in supported)
43 43
         # databases. For now just ignore these errors
44
-        logger.error(
45
-            "IntegrityError when updating analytics counter for %s", model)
44
+        logger.error("IntegrityError when updating analytics counter for %s", model)
46 45
 
47 46
 
48 47
 def _record_products_in_order(order):
49 48
     # surely there's a way to do this without causing a query for each line?
50 49
     for line in order.lines.all():
51 50
         _update_counter(
52
-            ProductRecord, 'num_purchases',
53
-            {'product': line.product}, line.quantity)
51
+            ProductRecord, "num_purchases", {"product": line.product}, line.quantity
52
+        )
54 53
 
55 54
 
56 55
 def _record_user_order(user, order):
57 56
     try:
58 57
         record = UserRecord.objects.filter(user=user)
59 58
         affected = record.update(
60
-            num_orders=F('num_orders') + 1,
61
-            num_order_lines=F('num_order_lines') + order.num_lines,
62
-            num_order_items=F('num_order_items') + order.num_items,
63
-            total_spent=F('total_spent') + order.total_incl_tax,
64
-            date_last_order=order.date_placed)
59
+            num_orders=F("num_orders") + 1,
60
+            num_order_lines=F("num_order_lines") + order.num_lines,
61
+            num_order_items=F("num_order_items") + order.num_items,
62
+            total_spent=F("total_spent") + order.total_incl_tax,
63
+            date_last_order=order.date_placed,
64
+        )
65 65
         if not affected:
66 66
             UserRecord.objects.create(
67
-                user=user, num_orders=1, num_order_lines=order.num_lines,
67
+                user=user,
68
+                num_orders=1,
69
+                num_order_lines=order.num_lines,
68 70
                 num_order_items=order.num_items,
69 71
                 total_spent=order.total_incl_tax,
70
-                date_last_order=order.date_placed)
71
-    except IntegrityError:      # pragma: no cover
72
-        logger.error(
73
-            "IntegrityError in analytics when recording a user order.")
72
+                date_last_order=order.date_placed,
73
+            )
74
+    except IntegrityError:  # pragma: no cover
75
+        logger.error("IntegrityError in analytics when recording a user order.")
74 76
 
75 77
 
76 78
 # Receivers
77 79
 
80
+
81
+# pylint: disable=unused-argument
78 82
 @receiver(product_viewed)
79 83
 def receive_product_view(sender, product, user, **kwargs):
80
-    if kwargs.get('raw', False):
84
+    if kwargs.get("raw", False):
81 85
         return
82
-    _update_counter(ProductRecord, 'num_views', {'product': product})
86
+    _update_counter(ProductRecord, "num_views", {"product": product})
83 87
     if user and user.is_authenticated:
84
-        _update_counter(UserRecord, 'num_product_views', {'user': user})
88
+        _update_counter(UserRecord, "num_product_views", {"user": user})
85 89
         UserProductView.objects.create(product=product, user=user)
86 90
 
87 91
 
92
+# pylint: disable=unused-argument
88 93
 @receiver(user_search)
89 94
 def receive_product_search(sender, query, user, **kwargs):
90
-    if user and user.is_authenticated and not kwargs.get('raw', False):
95
+    if user and user.is_authenticated and not kwargs.get("raw", False):
91 96
         UserSearch._default_manager.create(user=user, query=query)
92 97
 
93 98
 
99
+# pylint: disable=unused-argument
94 100
 @receiver(basket_addition)
95 101
 def receive_basket_addition(sender, product, user, **kwargs):
96
-    if kwargs.get('raw', False):
102
+    if kwargs.get("raw", False):
97 103
         return
98
-    _update_counter(
99
-        ProductRecord, 'num_basket_additions', {'product': product})
104
+    _update_counter(ProductRecord, "num_basket_additions", {"product": product})
100 105
     if user and user.is_authenticated:
101
-        _update_counter(UserRecord, 'num_basket_additions', {'user': user})
106
+        _update_counter(UserRecord, "num_basket_additions", {"user": user})
102 107
 
103 108
 
109
+# pylint: disable=unused-argument
104 110
 @receiver(order_placed)
105 111
 def receive_order_placed(sender, order, user, **kwargs):
106
-    if kwargs.get('raw', False):
112
+    if kwargs.get("raw", False):
107 113
         return
108 114
     _record_products_in_order(order)
109 115
     if user and user.is_authenticated:

+ 48
- 45
src/oscar/apps/analytics/reports.py View File

@@ -2,46 +2,44 @@ from django.utils.translation import gettext_lazy as _
2 2
 
3 3
 from oscar.core.loading import get_class, get_model
4 4
 
5
-ReportGenerator = get_class('dashboard.reports.reports', 'ReportGenerator')
6
-ReportCSVFormatter = get_class('dashboard.reports.reports',
7
-                               'ReportCSVFormatter')
8
-ReportHTMLFormatter = get_class('dashboard.reports.reports',
9
-                                'ReportHTMLFormatter')
10
-ProductRecord = get_model('analytics', 'ProductRecord')
11
-UserRecord = get_model('analytics', 'UserRecord')
5
+ReportGenerator = get_class("dashboard.reports.reports", "ReportGenerator")
6
+ReportCSVFormatter = get_class("dashboard.reports.reports", "ReportCSVFormatter")
7
+ReportHTMLFormatter = get_class("dashboard.reports.reports", "ReportHTMLFormatter")
8
+ProductRecord = get_model("analytics", "ProductRecord")
9
+UserRecord = get_model("analytics", "UserRecord")
12 10
 
13 11
 
14 12
 class ProductReportCSVFormatter(ReportCSVFormatter):
15
-    filename_template = 'conditional-offer-performance.csv'
13
+    filename_template = "conditional-offer-performance.csv"
16 14
 
17 15
     def generate_csv(self, response, products):
18 16
         writer = self.get_csv_writer(response)
19
-        header_row = [_('Product'),
20
-                      _('Views'),
21
-                      _('Basket additions'),
22
-                      _('Purchases')]
17
+        header_row = [_("Product"), _("Views"), _("Basket additions"), _("Purchases")]
23 18
         writer.writerow(header_row)
24 19
 
25 20
         for record in products:
26
-            row = [record.product,
27
-                   record.num_views,
28
-                   record.num_basket_additions,
29
-                   record.num_purchases]
21
+            row = [
22
+                record.product,
23
+                record.num_views,
24
+                record.num_basket_additions,
25
+                record.num_purchases,
26
+            ]
30 27
             writer.writerow(row)
31 28
 
32 29
 
33 30
 class ProductReportHTMLFormatter(ReportHTMLFormatter):
34
-    filename_template = 'oscar/dashboard/reports/partials/product_report.html'
31
+    filename_template = "oscar/dashboard/reports/partials/product_report.html"
35 32
 
36 33
 
37 34
 class ProductReportGenerator(ReportGenerator):
38
-    code = 'product_analytics'
39
-    description = _('Product analytics')
35
+    code = "product_analytics"
36
+    description = _("Product analytics")
40 37
     model_class = ProductRecord
41 38
 
42 39
     formatters = {
43
-        'CSV_formatter': ProductReportCSVFormatter,
44
-        'HTML_formatter': ProductReportHTMLFormatter}
40
+        "CSV_formatter": ProductReportCSVFormatter,
41
+        "HTML_formatter": ProductReportHTMLFormatter,
42
+    }
45 43
 
46 44
     def report_description(self):
47 45
         return self.description
@@ -51,46 +49,51 @@ class ProductReportGenerator(ReportGenerator):
51 49
 
52 50
 
53 51
 class UserReportCSVFormatter(ReportCSVFormatter):
54
-    filename_template = 'user-analytics.csv'
52
+    filename_template = "user-analytics.csv"
55 53
 
56 54
     def generate_csv(self, response, users):
57 55
         writer = self.get_csv_writer(response)
58
-        header_row = [_('Name'),
59
-                      _('Date registered'),
60
-                      _('Product views'),
61
-                      _('Basket additions'),
62
-                      _('Orders'),
63
-                      _('Order lines'),
64
-                      _('Order items'),
65
-                      _('Total spent'),
66
-                      _('Date of last order')]
56
+        header_row = [
57
+            _("Name"),
58
+            _("Date registered"),
59
+            _("Product views"),
60
+            _("Basket additions"),
61
+            _("Orders"),
62
+            _("Order lines"),
63
+            _("Order items"),
64
+            _("Total spent"),
65
+            _("Date of last order"),
66
+        ]
67 67
         writer.writerow(header_row)
68 68
 
69 69
         for record in users:
70
-            row = [record.user.get_full_name(),
71
-                   self.format_date(record.user.date_joined),
72
-                   record.num_product_views,
73
-                   record.num_basket_additions,
74
-                   record.num_orders,
75
-                   record.num_order_lines,
76
-                   record.num_order_items,
77
-                   record.total_spent,
78
-                   self.format_datetime(record.date_last_order)]
70
+            row = [
71
+                record.user.get_full_name(),
72
+                self.format_date(record.user.date_joined),
73
+                record.num_product_views,
74
+                record.num_basket_additions,
75
+                record.num_orders,
76
+                record.num_order_lines,
77
+                record.num_order_items,
78
+                record.total_spent,
79
+                self.format_datetime(record.date_last_order),
80
+            ]
79 81
             writer.writerow(row)
80 82
 
81 83
 
82 84
 class UserReportHTMLFormatter(ReportHTMLFormatter):
83
-    filename_template = 'oscar/dashboard/reports/partials/user_report.html'
85
+    filename_template = "oscar/dashboard/reports/partials/user_report.html"
84 86
 
85 87
 
86 88
 class UserReportGenerator(ReportGenerator):
87
-    code = 'user_analytics'
88
-    description = _('User analytics')
89
+    code = "user_analytics"
90
+    description = _("User analytics")
89 91
     queryset = UserRecord._default_manager.select_related().all()
90 92
 
91 93
     formatters = {
92
-        'CSV_formatter': UserReportCSVFormatter,
93
-        'HTML_formatter': UserReportHTMLFormatter}
94
+        "CSV_formatter": UserReportCSVFormatter,
95
+        "HTML_formatter": UserReportHTMLFormatter,
96
+    }
94 97
 
95 98
     def is_available_to(self, user):
96 99
         return user.is_staff

+ 4
- 11
src/oscar/apps/analytics/scores.py View File

@@ -2,17 +2,12 @@ from django.db.models import F
2 2
 
3 3
 from oscar.core.loading import get_model
4 4
 
5
-ProductRecord = get_model('analytics', 'ProductRecord')
5
+ProductRecord = get_model("analytics", "ProductRecord")
6 6
 
7 7
 
8 8
 class Calculator(object):
9
-
10 9
     # Map of field name to weight
11
-    weights = {
12
-        'num_views': 1,
13
-        'num_basket_additions': 3,
14
-        'num_purchases': 5
15
-    }
10
+    weights = {"num_views": 1, "num_basket_additions": 3, "num_purchases": 5}
16 11
 
17 12
     def __init__(self, logger):
18 13
         self.logger = logger
@@ -23,7 +18,5 @@ class Calculator(object):
23 18
     def calculate_scores(self):
24 19
         self.logger.info("Calculating product scores")
25 20
         total_weight = float(sum(self.weights.values()))
26
-        weighted_fields = [
27
-            self.weights[name] * F(name) for name in self.weights.keys()]
28
-        ProductRecord.objects.update(
29
-            score=sum(weighted_fields) / total_weight)
21
+        weighted_fields = [self.weights[name] * F(name) for name in self.weights.keys()]
22
+        ProductRecord.objects.update(score=sum(weighted_fields) / total_weight)

+ 1
- 1
src/oscar/apps/basket/__init__.py View File

@@ -1 +1 @@
1
-default_app_config = 'oscar.apps.basket.apps.BasketConfig'
1
+default_app_config = "oscar.apps.basket.apps.BasketConfig"

+ 215
- 146
src/oscar/apps/basket/abstract_models.py View File

@@ -17,30 +17,39 @@ from oscar.core.utils import get_default_currency, round_half_up
17 17
 from oscar.models.fields.slugfield import SlugField
18 18
 from oscar.templatetags.currency_filters import currency
19 19
 
20
-OfferApplications = get_class('offer.results', 'OfferApplications')
21
-Unavailable = get_class('partner.availability', 'Unavailable')
22
-LineOfferConsumer = get_class('basket.utils', 'LineOfferConsumer')
23
-OpenBasketManager, SavedBasketManager = get_classes('basket.managers', ['OpenBasketManager', 'SavedBasketManager'])
20
+OfferApplications = get_class("offer.results", "OfferApplications")
21
+Unavailable = get_class("partner.availability", "Unavailable")
22
+LineOfferConsumer = get_class("basket.utils", "LineOfferConsumer")
23
+OpenBasketManager, SavedBasketManager = get_classes(
24
+    "basket.managers", ["OpenBasketManager", "SavedBasketManager"]
25
+)
24 26
 
25 27
 
26 28
 class AbstractBasket(models.Model):
27 29
     """
28 30
     Basket object
29 31
     """
32
+
30 33
     # Baskets can be anonymously owned - hence this field is nullable.  When a
31 34
     # anon user signs in, their two baskets are merged.
32 35
     owner = models.ForeignKey(
33 36
         AUTH_USER_MODEL,
34 37
         null=True,
35
-        related_name='baskets',
38
+        related_name="baskets",
36 39
         on_delete=models.CASCADE,
37
-        verbose_name=_("Owner"))
40
+        verbose_name=_("Owner"),
41
+    )
38 42
 
39 43
     # Basket statuses
40 44
     # - Frozen is for when a basket is in the process of being submitted
41 45
     #   and we need to prevent any changes to it.
42 46
     OPEN, MERGED, SAVED, FROZEN, SUBMITTED = (
43
-        "Open", "Merged", "Saved", "Frozen", "Submitted")
47
+        "Open",
48
+        "Merged",
49
+        "Saved",
50
+        "Frozen",
51
+        "Submitted",
52
+    )
44 53
     STATUS_CHOICES = (
45 54
         (OPEN, _("Open - currently active")),
46 55
         (MERGED, _("Merged - superceded by another basket")),
@@ -49,27 +58,28 @@ class AbstractBasket(models.Model):
49 58
         (SUBMITTED, _("Submitted - has been ordered at the checkout")),
50 59
     )
51 60
     status = models.CharField(
52
-        _("Status"), max_length=128, default=OPEN, choices=STATUS_CHOICES)
61
+        _("Status"), max_length=128, default=OPEN, choices=STATUS_CHOICES
62
+    )
53 63
 
54 64
     # A basket can have many vouchers attached to it.  However, it is common
55 65
     # for sites to only allow one voucher per basket - this will need to be
56 66
     # enforced in the project's codebase.
57 67
     vouchers = models.ManyToManyField(
58
-        'voucher.Voucher', verbose_name=_("Vouchers"), blank=True)
68
+        "voucher.Voucher", verbose_name=_("Vouchers"), blank=True
69
+    )
59 70
 
60 71
     date_created = models.DateTimeField(_("Date created"), auto_now_add=True)
61 72
     date_merged = models.DateTimeField(_("Date merged"), null=True, blank=True)
62
-    date_submitted = models.DateTimeField(_("Date submitted"), null=True,
63
-                                          blank=True)
73
+    date_submitted = models.DateTimeField(_("Date submitted"), null=True, blank=True)
64 74
 
65 75
     # Only if a basket is in one of these statuses can it be edited
66 76
     editable_statuses = (OPEN, SAVED)
67 77
 
68 78
     class Meta:
69 79
         abstract = True
70
-        app_label = 'basket'
71
-        verbose_name = _('Basket')
72
-        verbose_name_plural = _('Baskets')
80
+        app_label = "basket"
81
+        verbose_name = _("Basket")
82
+        verbose_name_plural = _("Baskets")
73 83
 
74 84
     objects = models.Manager()
75 85
     open = OpenBasketManager()
@@ -87,11 +97,11 @@ class AbstractBasket(models.Model):
87 97
         self.offer_applications = OfferApplications()
88 98
 
89 99
     def __str__(self):
90
-        return _(
91
-            "%(status)s basket (owner: %(owner)s, lines: %(num_lines)d)") \
92
-            % {'status': self.status,
93
-               'owner': self.owner,
94
-               'num_lines': self.num_lines}
100
+        return _("%(status)s basket (owner: %(owner)s, lines: %(num_lines)d)") % {
101
+            "status": self.status,
102
+            "owner": self.owner,
103
+            "num_lines": self.num_lines,
104
+        }
95 105
 
96 106
     # ========
97 107
     # Strategy
@@ -99,7 +109,7 @@ class AbstractBasket(models.Model):
99 109
 
100 110
     @property
101 111
     def has_strategy(self):
102
-        return hasattr(self, '_strategy')
112
+        return hasattr(self, "_strategy")
103 113
 
104 114
     def _get_strategy(self):
105 115
         if not self.has_strategy:
@@ -113,7 +123,7 @@ class AbstractBasket(models.Model):
113 123
         return self._strategy
114 124
 
115 125
     def _set_strategy(self, strategy):
116
-        self._strategy = strategy
126
+        self._strategy = strategy  # pylint: disable=W0201
117 127
 
118 128
     strategy = property(_get_strategy, _set_strategy)
119 129
 
@@ -126,14 +136,13 @@ class AbstractBasket(models.Model):
126 136
         lost.
127 137
         """
128 138
         if self.id is None:
129
-            return self.lines.model.objects.none()
139
+            return self.lines.model.objects.none()  # pylint: disable=E1101
130 140
         if self._lines is None:
131 141
             self._lines = (
132
-                self.lines
133
-                .select_related('product', 'stockrecord')
134
-                .prefetch_related(
135
-                    'attributes', 'product__images')
136
-                .order_by(self._meta.pk.name))
142
+                self.lines.select_related("product", "stockrecord")
143
+                .prefetch_related("attributes", "product__images")
144
+                .order_by(self._meta.pk.name)
145
+            )
137 146
         return self._lines
138 147
 
139 148
     def max_allowed_quantity(self):
@@ -157,27 +166,34 @@ class AbstractBasket(models.Model):
157 166
         max_allowed, basket_threshold = self.max_allowed_quantity()
158 167
 
159 168
         if line is not None:
160
-            line_purchase_permitted, reason = line.purchase_info.availability.is_purchase_permitted(qty)
169
+            (
170
+                line_purchase_permitted,
171
+                reason,
172
+            ) = line.purchase_info.availability.is_purchase_permitted(qty)
161 173
 
162 174
             if not line_purchase_permitted:
163 175
                 return line_purchase_permitted, reason
164 176
 
165 177
             # Also check if it's permitted with potentional other lines of the same product & stocrecord
166 178
             total_lines_quantity = self.basket_quantity(line) + qty
167
-            line_purchase_permitted, reason = line.purchase_info.availability.is_purchase_permitted(
168
-                total_lines_quantity)
179
+            (
180
+                line_purchase_permitted,
181
+                reason,
182
+            ) = line.purchase_info.availability.is_purchase_permitted(
183
+                total_lines_quantity
184
+            )
169 185
 
170 186
             if not line_purchase_permitted:
171 187
                 return line_purchase_permitted, _(
172 188
                     "Available stock is only %(max)d, which has been exceeded because "
173 189
                     "multiple lines contain the same product."
174
-                ) % {'max': line.purchase_info.availability.num_available}
190
+                ) % {"max": line.purchase_info.availability.num_available}
175 191
 
176 192
         if max_allowed is not None and qty > max_allowed:
177 193
             return False, _(
178 194
                 "Due to technical limitations we are not able "
179
-                "to ship more than %(threshold)d items in one order.") \
180
-                % {'threshold': basket_threshold}
195
+                "to ship more than %(threshold)d items in one order."
196
+            ) % {"threshold": basket_threshold}
181 197
 
182 198
         return True, None
183 199
 
@@ -187,8 +203,9 @@ class AbstractBasket(models.Model):
187 203
         stockrecord, but different options. Those quantities are summed up.
188 204
         """
189 205
         matching_lines = self.lines.filter(stockrecord=line.stockrecord)
190
-        quantity = matching_lines.aggregate(Sum('quantity'))['quantity__sum']
206
+        quantity = matching_lines.aggregate(Sum("quantity"))["quantity__sum"]
191 207
         return quantity or 0
208
+
192 209
     # ============
193 210
     # Manipulation
194 211
     # ============
@@ -202,6 +219,7 @@ class AbstractBasket(models.Model):
202 219
         self.lines.all().delete()
203 220
         self._lines = None
204 221
 
222
+    # pylint: disable=unused-argument
205 223
     def get_stock_info(self, product, options):
206 224
         """
207 225
         Hook for implementing strategies that depend on product options
@@ -233,44 +251,51 @@ class AbstractBasket(models.Model):
233 251
         stock_info = self.get_stock_info(product, options)
234 252
 
235 253
         if not stock_info.price.exists:
236
-            raise ValueError(
237
-                "Strategy hasn't found a price for product %s" % product)
254
+            raise ValueError("Strategy hasn't found a price for product %s" % product)
238 255
 
239 256
         if price_currency and stock_info.price.currency != price_currency:
240
-            raise ValueError((
241
-                "Basket lines must all have the same currency. Proposed "
242
-                "line has currency %s, while basket has currency %s")
243
-                % (stock_info.price.currency, price_currency))
257
+            raise ValueError(
258
+                (
259
+                    "Basket lines must all have the same currency. Proposed "
260
+                    "line has currency %s, while basket has currency %s"
261
+                )
262
+                % (stock_info.price.currency, price_currency)
263
+            )
244 264
 
245 265
         if stock_info.stockrecord is None:
246
-            raise ValueError((
247
-                "Basket lines must all have stock records. Strategy hasn't "
248
-                "found any stock record for product %s") % product)
266
+            raise ValueError(
267
+                (
268
+                    "Basket lines must all have stock records. Strategy hasn't "
269
+                    "found any stock record for product %s"
270
+                )
271
+                % product
272
+            )
249 273
 
250 274
         # Line reference is used to distinguish between variations of the same
251 275
         # product (eg T-shirts with different personalisations)
252
-        line_ref = self._create_line_reference(
253
-            product, stock_info.stockrecord, options)
276
+        line_ref = self._create_line_reference(product, stock_info.stockrecord, options)
254 277
 
255 278
         # Determine price to store (if one exists).  It is only stored for
256 279
         # audit and sometimes caching.
257 280
         defaults = {
258
-            'quantity': quantity,
259
-            'price_excl_tax': stock_info.price.excl_tax,
260
-            'price_currency': stock_info.price.currency,
281
+            "quantity": quantity,
282
+            "price_excl_tax": stock_info.price.excl_tax,
283
+            "price_currency": stock_info.price.currency,
261 284
         }
262 285
         if stock_info.price.is_tax_known:
263
-            defaults['price_incl_tax'] = stock_info.price.incl_tax
286
+            defaults["price_incl_tax"] = stock_info.price.incl_tax
264 287
 
265 288
         line, created = self.lines.get_or_create(
266 289
             line_reference=line_ref,
267 290
             product=product,
268 291
             stockrecord=stock_info.stockrecord,
269
-            defaults=defaults)
292
+            defaults=defaults,
293
+        )
270 294
         if created:
271 295
             for option_dict in options:
272
-                line.attributes.create(option=option_dict['option'],
273
-                                       value=option_dict['value'])
296
+                line.attributes.create(
297
+                    option=option_dict["option"], value=option_dict["value"]
298
+                )
274 299
         else:
275 300
             line.quantity = max(0, line.quantity + quantity)
276 301
             line.save()
@@ -278,6 +303,7 @@ class AbstractBasket(models.Model):
278 303
 
279 304
         # Returning the line is useful when overriding this method.
280 305
         return line, created
306
+
281 307
     add_product.alters_data = True
282 308
     add = add_product
283 309
 
@@ -315,12 +341,12 @@ class AbstractBasket(models.Model):
315 341
             if add_quantities:
316 342
                 existing_line.quantity += line.quantity
317 343
             else:
318
-                existing_line.quantity = max(existing_line.quantity,
319
-                                             line.quantity)
344
+                existing_line.quantity = max(existing_line.quantity, line.quantity)
320 345
             existing_line.save()
321 346
             line.delete()
322 347
         finally:
323 348
             self._lines = None
349
+
324 350
     merge_line.alters_data = True
325 351
 
326 352
     def merge(self, basket, add_quantities=True):
@@ -342,6 +368,7 @@ class AbstractBasket(models.Model):
342 368
         for voucher in basket.vouchers.all():
343 369
             basket.vouchers.remove(voucher)
344 370
             self.vouchers.add(voucher)
371
+
345 372
     merge.alters_data = True
346 373
 
347 374
     def freeze(self):
@@ -350,6 +377,7 @@ class AbstractBasket(models.Model):
350 377
         """
351 378
         self.status = self.FROZEN
352 379
         self.save()
380
+
353 381
     freeze.alters_data = True
354 382
 
355 383
     def thaw(self):
@@ -358,6 +386,7 @@ class AbstractBasket(models.Model):
358 386
         """
359 387
         self.status = self.OPEN
360 388
         self.save()
389
+
361 390
     thaw.alters_data = True
362 391
 
363 392
     def submit(self):
@@ -367,6 +396,7 @@ class AbstractBasket(models.Model):
367 396
         self.status = self.SUBMITTED
368 397
         self.date_submitted = now()
369 398
         self.save()
399
+
370 400
     submit.alters_data = True
371 401
 
372 402
     # Kept for backwards compatibility
@@ -391,23 +421,25 @@ class AbstractBasket(models.Model):
391 421
         Returns a reference string for a line based on the item
392 422
         and its options.
393 423
         """
394
-        base = '%s_%s' % (product.id, stockrecord.id)
424
+        base = "%s_%s" % (product.id, stockrecord.id)
395 425
         if not options:
396 426
             return base
397
-        repr_options = [{'option': repr(option['option']),
398
-                         'value': repr(option['value'])} for option in options]
399
-        repr_options.sort(key=itemgetter('option'))
400
-        return "%s_%s" % (base, zlib.crc32(repr(repr_options).encode('utf8')))
427
+        repr_options = [
428
+            {"option": repr(option["option"]), "value": repr(option["value"])}
429
+            for option in options
430
+        ]
431
+        repr_options.sort(key=itemgetter("option"))
432
+        return "%s_%s" % (base, zlib.crc32(repr(repr_options).encode("utf8")))
401 433
 
402
-    def _get_total(self, property):
434
+    def _get_total(self, model_property):
403 435
         """
404 436
         For executing a named method on each line of the basket
405 437
         and returning the total.
406 438
         """
407
-        total = D('0.00')
439
+        total = D("0.00")
408 440
         for line in self.all_lines():
409 441
             try:
410
-                total += getattr(line, property)
442
+                total += getattr(line, model_property)
411 443
             except ObjectDoesNotExist:
412 444
                 # Handle situation where the product may have been deleted
413 445
                 pass
@@ -416,7 +448,6 @@ class AbstractBasket(models.Model):
416 448
                 info = self.get_stock_info(line.product, line.attributes.all())
417 449
                 if info.availability.is_available_to_buy:
418 450
                     raise
419
-                pass
420 451
         return total
421 452
 
422 453
     # ==========
@@ -442,30 +473,30 @@ class AbstractBasket(models.Model):
442 473
         """
443 474
         Return total line price excluding tax
444 475
         """
445
-        return self._get_total('line_price_excl_tax_incl_discounts')
476
+        return self._get_total("line_price_excl_tax_incl_discounts")
446 477
 
447 478
     @property
448 479
     def total_tax(self):
449 480
         """Return total tax for a line"""
450
-        return self._get_total('line_tax')
481
+        return self._get_total("line_tax")
451 482
 
452 483
     @property
453 484
     def total_incl_tax(self):
454 485
         """
455 486
         Return total price inclusive of tax and discounts
456 487
         """
457
-        return self._get_total('line_price_incl_tax_incl_discounts')
488
+        return self._get_total("line_price_incl_tax_incl_discounts")
458 489
 
459 490
     @property
460 491
     def total_incl_tax_excl_discounts(self):
461 492
         """
462 493
         Return total price inclusive of tax but exclusive discounts
463 494
         """
464
-        return self._get_total('line_price_incl_tax')
495
+        return self._get_total("line_price_incl_tax")
465 496
 
466 497
     @property
467 498
     def total_discount(self):
468
-        return self._get_total('discount_value')
499
+        return self._get_total("discount_value")
469 500
 
470 501
     @property
471 502
     def offer_discounts(self):
@@ -513,7 +544,7 @@ class AbstractBasket(models.Model):
513 544
         """
514 545
         Return total price excluding tax and discounts
515 546
         """
516
-        return self._get_total('line_price_excl_tax')
547
+        return self._get_total("line_price_excl_tax")
517 548
 
518 549
     @property
519 550
     def num_lines(self):
@@ -601,7 +632,7 @@ class AbstractBasket(models.Model):
601 632
         """
602 633
         if self.id:
603 634
             matching_lines = self.lines.filter(product=product)
604
-            quantity = matching_lines.aggregate(Sum('quantity'))['quantity__sum']
635
+            quantity = matching_lines.aggregate(Sum("quantity"))["quantity__sum"]
605 636
             return quantity or 0
606 637
 
607 638
         return 0
@@ -638,76 +669,83 @@ class AbstractLine(models.Model):
638 669
            their primary key, so no changes should be necessary there.
639 670
 
640 671
     """
672
+
641 673
     basket = models.ForeignKey(
642
-        'basket.Basket',
674
+        "basket.Basket",
643 675
         on_delete=models.CASCADE,
644
-        related_name='lines',
645
-        verbose_name=_("Basket"))
676
+        related_name="lines",
677
+        verbose_name=_("Basket"),
678
+    )
646 679
 
647 680
     # This is to determine which products belong to the same line
648 681
     # We can't just use product.id as you can have customised products
649 682
     # which should be treated as separate lines.  Set as a
650 683
     # SlugField as it is included in the path for certain views.
651
-    line_reference = SlugField(
652
-        _("Line Reference"), max_length=128, db_index=True)
684
+    line_reference = SlugField(_("Line Reference"), max_length=128, db_index=True)
653 685
 
654 686
     product = models.ForeignKey(
655
-        'catalogue.Product',
687
+        "catalogue.Product",
656 688
         on_delete=models.CASCADE,
657
-        related_name='basket_lines',
658
-        verbose_name=_("Product"))
689
+        related_name="basket_lines",
690
+        verbose_name=_("Product"),
691
+    )
659 692
 
660 693
     # We store the stockrecord that should be used to fulfil this line.
661 694
     stockrecord = models.ForeignKey(
662
-        'partner.StockRecord',
663
-        on_delete=models.CASCADE,
664
-        related_name='basket_lines')
695
+        "partner.StockRecord", on_delete=models.CASCADE, related_name="basket_lines"
696
+    )
665 697
 
666
-    quantity = models.PositiveIntegerField(_('Quantity'), default=1)
698
+    quantity = models.PositiveIntegerField(_("Quantity"), default=1)
667 699
 
668 700
     # We store the unit price incl tax of the product when it is first added to
669 701
     # the basket.  This allows us to tell if a product has changed price since
670 702
     # a person first added it to their basket.
671 703
     price_currency = models.CharField(
672
-        _("Currency"), max_length=12, default=get_default_currency)
704
+        _("Currency"), max_length=12, default=get_default_currency
705
+    )
673 706
     price_excl_tax = models.DecimalField(
674
-        _('Price excl. Tax'), decimal_places=2, max_digits=12,
675
-        null=True)
707
+        _("Price excl. Tax"), decimal_places=2, max_digits=12, null=True
708
+    )
676 709
     price_incl_tax = models.DecimalField(
677
-        _('Price incl. Tax'), decimal_places=2, max_digits=12, null=True)
710
+        _("Price incl. Tax"), decimal_places=2, max_digits=12, null=True
711
+    )
678 712
 
679 713
     # Track date of first addition
680
-    date_created = models.DateTimeField(_("Date Created"), auto_now_add=True, db_index=True)
714
+    date_created = models.DateTimeField(
715
+        _("Date Created"), auto_now_add=True, db_index=True
716
+    )
681 717
     date_updated = models.DateTimeField(_("Date Updated"), auto_now=True, db_index=True)
682 718
 
683 719
     def __init__(self, *args, **kwargs):
684 720
         super().__init__(*args, **kwargs)
685 721
         # Instance variables used to persist discount information
686
-        self._discount_excl_tax = D('0.00')
687
-        self._discount_incl_tax = D('0.00')
722
+        self._discount_excl_tax = D("0.00")
723
+        self._discount_incl_tax = D("0.00")
688 724
         self.consumer = LineOfferConsumer(self)
689 725
 
690 726
     class Meta:
691 727
         abstract = True
692
-        app_label = 'basket'
728
+        app_label = "basket"
693 729
         # Enforce sorting by order of creation.
694
-        ordering = ['date_created', 'pk']
730
+        ordering = ["date_created", "pk"]
695 731
         unique_together = ("basket", "line_reference")
696
-        verbose_name = _('Basket line')
697
-        verbose_name_plural = _('Basket lines')
732
+        verbose_name = _("Basket line")
733
+        verbose_name_plural = _("Basket lines")
698 734
 
699 735
     def __str__(self):
700 736
         return _(
701
-            "Basket #%(basket_id)d, Product #%(product_id)d, quantity"
702
-            " %(quantity)d") % {'basket_id': self.basket.pk,
703
-                                'product_id': self.product.pk,
704
-                                'quantity': self.quantity}
737
+            "Basket #%(basket_id)d, Product #%(product_id)d, quantity %(quantity)d"
738
+        ) % {
739
+            "basket_id": self.basket.pk,
740
+            "product_id": self.product.pk,
741
+            "quantity": self.quantity,
742
+        }
705 743
 
706 744
     def save(self, *args, **kwargs):
707 745
         if not self.basket.can_be_edited:
708 746
             raise PermissionDenied(
709
-                _("You cannot modify a %s basket") % (
710
-                    self.basket.status.lower(),))
747
+                _("You cannot modify a %s basket") % (self.basket.status.lower(),)
748
+            )
711 749
         return super().save(*args, **kwargs)
712 750
 
713 751
     # =============
@@ -718,12 +756,11 @@ class AbstractLine(models.Model):
718 756
         """
719 757
         Remove any discounts from this line.
720 758
         """
721
-        self._discount_excl_tax = D('0.00')
722
-        self._discount_incl_tax = D('0.00')
759
+        self._discount_excl_tax = D("0.00")
760
+        self._discount_incl_tax = D("0.00")
723 761
         self.consumer = LineOfferConsumer(self)
724 762
 
725
-    def discount(self, discount_value, affected_quantity, incl_tax=True,
726
-                 offer=None):
763
+    def discount(self, discount_value, affected_quantity, incl_tax=True, offer=None):
727 764
         """
728 765
         Apply a discount to this line
729 766
         """
@@ -731,13 +768,15 @@ class AbstractLine(models.Model):
731 768
             if self._discount_excl_tax > 0:
732 769
                 raise RuntimeError(
733 770
                     "Attempting to discount the tax-inclusive price of a line "
734
-                    "when tax-exclusive discounts are already applied")
771
+                    "when tax-exclusive discounts are already applied"
772
+                )
735 773
             self._discount_incl_tax += discount_value
736 774
         else:
737 775
             if self._discount_incl_tax > 0:
738 776
                 raise RuntimeError(
739 777
                     "Attempting to discount the tax-exclusive price of a line "
740
-                    "when tax-inclusive discounts are already applied")
778
+                    "when tax-inclusive discounts are already applied"
779
+                )
741 780
             self._discount_excl_tax += discount_value
742 781
         self.consume(affected_quantity, offer=offer)
743 782
 
@@ -757,26 +796,35 @@ class AbstractLine(models.Model):
757 796
         tuples.
758 797
         """
759 798
         if not self.is_tax_known:
760
-            raise RuntimeError("A price breakdown can only be determined "
761
-                               "when taxes are known")
799
+            raise RuntimeError(
800
+                "A price breakdown can only be determined when taxes are known"
801
+            )
762 802
         prices = []
763 803
         if not self.discount_value:
764
-            prices.append((self.unit_price_incl_tax, self.unit_price_excl_tax,
765
-                           self.quantity))
804
+            prices.append(
805
+                (self.unit_price_incl_tax, self.unit_price_excl_tax, self.quantity)
806
+            )
766 807
         else:
767 808
             # Need to split the discount among the affected quantity
768 809
             # of products.
769
-            item_incl_tax_discount = (
770
-                self.discount_value / int(self.consumer.consumed()))
810
+            item_incl_tax_discount = self.discount_value / int(self.consumer.consumed())
771 811
             item_excl_tax_discount = item_incl_tax_discount * self._tax_ratio
772 812
             item_excl_tax_discount = round_half_up(item_excl_tax_discount)
773
-            prices.append((self.unit_price_incl_tax - item_incl_tax_discount,
774
-                           self.unit_price_excl_tax - item_excl_tax_discount,
775
-                           self.consumer.consumed()))
813
+            prices.append(
814
+                (
815
+                    self.unit_price_incl_tax - item_incl_tax_discount,
816
+                    self.unit_price_excl_tax - item_excl_tax_discount,
817
+                    self.consumer.consumed(),
818
+                )
819
+            )
776 820
             if self.quantity_without_discount:
777
-                prices.append((self.unit_price_incl_tax,
778
-                               self.unit_price_excl_tax,
779
-                               self.quantity_without_discount))
821
+                prices.append(
822
+                    (
823
+                        self.unit_price_incl_tax,
824
+                        self.unit_price_excl_tax,
825
+                        self.quantity_without_discount,
826
+                    )
827
+                )
780 828
         return prices
781 829
 
782 830
     # =======
@@ -806,7 +854,9 @@ class AbstractLine(models.Model):
806 854
         return self.consumer.available(offer) > 0
807 855
 
808 856
     def quantity_available_for_offer(self, offer):
809
-        return self.quantity_without_offer_discount(offer) + self.quantity_with_offer_discount(offer)
857
+        return self.quantity_without_offer_discount(
858
+            offer
859
+        ) + self.quantity_with_offer_discount(offer)
810 860
 
811 861
     # ==========
812 862
     # Properties
@@ -829,15 +879,15 @@ class AbstractLine(models.Model):
829 879
         # Only one of the incl- and excl- discounts should be non-zero
830 880
         return max(self._discount_incl_tax, self._discount_excl_tax)
831 881
 
882
+    # pylint: disable=W0201
832 883
     @property
833 884
     def purchase_info(self):
834 885
         """
835 886
         Return the stock/price info
836 887
         """
837
-        if not hasattr(self, '_info'):
888
+        if not hasattr(self, "_info"):
838 889
             # Cache the PurchaseInfo instance.
839
-            self._info = self.basket.strategy.fetch_for_line(
840
-                self, self.stockrecord)
890
+            self._info = self.basket.strategy.fetch_for_line(self, self.stockrecord)
841 891
         return self._info
842 892
 
843 893
     @property
@@ -877,9 +927,11 @@ class AbstractLine(models.Model):
877 927
             # against tax inclusive prices but we need to guess how much of the
878 928
             # discount applies to tax-exclusive prices.  We do this by
879 929
             # assuming a linear tax and scaling down the original discount.
880
-            return max(0, self.line_price_excl_tax - round_half_up(
881
-                self._tax_ratio * self._discount_incl_tax
882
-            ))
930
+            return max(
931
+                0,
932
+                self.line_price_excl_tax
933
+                - round_half_up(self._tax_ratio * self._discount_incl_tax),
934
+            )
883 935
         return self.line_price_excl_tax
884 936
 
885 937
     @property
@@ -890,14 +942,23 @@ class AbstractLine(models.Model):
890 942
         if self.line_price_incl_tax is not None and self._discount_incl_tax:
891 943
             return max(0, self.line_price_incl_tax - self._discount_incl_tax)
892 944
         elif self.line_price_excl_tax is not None and self._discount_excl_tax:
893
-            return max(0, round_half_up((self.line_price_excl_tax - self._discount_excl_tax) / self._tax_ratio))
945
+            return max(
946
+                0,
947
+                round_half_up(
948
+                    (self.line_price_excl_tax - self._discount_excl_tax)
949
+                    / self._tax_ratio
950
+                ),
951
+            )
894 952
 
895 953
         return self.line_price_incl_tax
896 954
 
897 955
     @property
898 956
     def line_tax(self):
899 957
         if self.is_tax_known:
900
-            return self.line_price_incl_tax_incl_discounts - self.line_price_excl_tax_incl_discounts
958
+            return (
959
+                self.line_price_incl_tax_incl_discounts
960
+                - self.line_price_excl_tax_incl_discounts
961
+            )
901 962
 
902 963
     @property
903 964
     def line_price_incl_tax(self):
@@ -911,7 +972,10 @@ class AbstractLine(models.Model):
911 972
         for attribute in self.attributes.all():
912 973
             value = attribute.value
913 974
             if isinstance(value, list):
914
-                ops.append("%s = '%s'" % (attribute.option.name, (", ".join([str(v) for v in value]))))
975
+                ops.append(
976
+                    "%s = '%s'"
977
+                    % (attribute.option.name, (", ".join([str(v) for v in value])))
978
+                )
915 979
             else:
916 980
                 ops.append("%s = '%s'" % (attribute.option.name, value))
917 981
         if ops:
@@ -926,7 +990,7 @@ class AbstractLine(models.Model):
926 990
         """
927 991
         if isinstance(self.purchase_info.availability, Unavailable):
928 992
             msg = "'%(product)s' is no longer available"
929
-            return _(msg) % {'product': self.product.get_title()}
993
+            return _(msg) % {"product": self.product.get_title()}
930 994
 
931 995
         if not self.price_incl_tax:
932 996
             return
@@ -937,19 +1001,23 @@ class AbstractLine(models.Model):
937 1001
         current_price_incl_tax = self.purchase_info.price.incl_tax
938 1002
         if current_price_incl_tax != self.price_incl_tax:
939 1003
             product_prices = {
940
-                'product': self.product.get_title(),
941
-                'old_price': currency(self.price_incl_tax, self.price_currency),
942
-                'new_price': currency(current_price_incl_tax, self.price_currency)
1004
+                "product": self.product.get_title(),
1005
+                "old_price": currency(self.price_incl_tax, self.price_currency),
1006
+                "new_price": currency(current_price_incl_tax, self.price_currency),
943 1007
             }
944 1008
             if current_price_incl_tax > self.price_incl_tax:
945
-                warning = _("The price of '%(product)s' has increased from"
946
-                            " %(old_price)s to %(new_price)s since you added"
947
-                            " it to your basket")
1009
+                warning = _(
1010
+                    "The price of '%(product)s' has increased from"
1011
+                    " %(old_price)s to %(new_price)s since you added"
1012
+                    " it to your basket"
1013
+                )
948 1014
                 return warning % product_prices
949 1015
             else:
950
-                warning = _("The price of '%(product)s' has decreased from"
951
-                            " %(old_price)s to %(new_price)s since you added"
952
-                            " it to your basket")
1016
+                warning = _(
1017
+                    "The price of '%(product)s' has decreased from"
1018
+                    " %(old_price)s to %(new_price)s since you added"
1019
+                    " it to your basket"
1020
+                )
953 1021
                 return warning % product_prices
954 1022
 
955 1023
 
@@ -957,19 +1025,20 @@ class AbstractLineAttribute(models.Model):
957 1025
     """
958 1026
     An attribute of a basket line
959 1027
     """
1028
+
960 1029
     line = models.ForeignKey(
961
-        'basket.Line',
1030
+        "basket.Line",
962 1031
         on_delete=models.CASCADE,
963
-        related_name='attributes',
964
-        verbose_name=_("Line"))
1032
+        related_name="attributes",
1033
+        verbose_name=_("Line"),
1034
+    )
965 1035
     option = models.ForeignKey(
966
-        'catalogue.Option',
967
-        on_delete=models.CASCADE,
968
-        verbose_name=_("Option"))
1036
+        "catalogue.Option", on_delete=models.CASCADE, verbose_name=_("Option")
1037
+    )
969 1038
     value = models.JSONField(_("Value"), encoder=DjangoJSONEncoder)
970 1039
 
971 1040
     class Meta:
972 1041
         abstract = True
973
-        app_label = 'basket'
974
-        verbose_name = _('Line attribute')
975
-        verbose_name_plural = _('Line attributes')
1042
+        app_label = "basket"
1043
+        verbose_name = _("Line attribute")
1044
+        verbose_name_plural = _("Line attributes")

+ 42
- 14
src/oscar/apps/basket/admin.py View File

@@ -2,31 +2,59 @@ from django.contrib import admin
2 2
 
3 3
 from oscar.core.loading import get_model
4 4
 
5
-Line = get_model('basket', 'line')
5
+Line = get_model("basket", "line")
6 6
 
7 7
 
8 8
 class LineInline(admin.TabularInline):
9 9
     model = Line
10
-    readonly_fields = ('line_reference', 'product', 'price_excl_tax',
11
-                       'price_incl_tax', 'price_currency', 'stockrecord')
10
+    readonly_fields = (
11
+        "line_reference",
12
+        "product",
13
+        "price_excl_tax",
14
+        "price_incl_tax",
15
+        "price_currency",
16
+        "stockrecord",
17
+    )
12 18
 
13 19
 
14 20
 class LineAdmin(admin.ModelAdmin):
15
-    list_display = ('id', 'basket', 'product', 'stockrecord', 'quantity',
16
-                    'price_excl_tax', 'price_currency', 'date_created')
17
-    readonly_fields = ('basket', 'stockrecord', 'line_reference', 'product',
18
-                       'price_currency', 'price_incl_tax', 'price_excl_tax',
19
-                       'quantity')
21
+    list_display = (
22
+        "id",
23
+        "basket",
24
+        "product",
25
+        "stockrecord",
26
+        "quantity",
27
+        "price_excl_tax",
28
+        "price_currency",
29
+        "date_created",
30
+    )
31
+    readonly_fields = (
32
+        "basket",
33
+        "stockrecord",
34
+        "line_reference",
35
+        "product",
36
+        "price_currency",
37
+        "price_incl_tax",
38
+        "price_excl_tax",
39
+        "quantity",
40
+    )
20 41
 
21 42
 
22 43
 class BasketAdmin(admin.ModelAdmin):
23
-    list_display = ('id', 'owner', 'status', 'num_lines',
24
-                    'contains_a_voucher', 'date_created', 'date_submitted',
25
-                    'time_before_submit')
26
-    readonly_fields = ('owner', 'date_merged', 'date_submitted')
44
+    list_display = (
45
+        "id",
46
+        "owner",
47
+        "status",
48
+        "num_lines",
49
+        "contains_a_voucher",
50
+        "date_created",
51
+        "date_submitted",
52
+        "time_before_submit",
53
+    )
54
+    readonly_fields = ("owner", "date_merged", "date_submitted")
27 55
     inlines = [LineInline]
28 56
 
29 57
 
30
-admin.site.register(get_model('basket', 'basket'), BasketAdmin)
58
+admin.site.register(get_model("basket", "basket"), BasketAdmin)
31 59
 admin.site.register(Line, LineAdmin)
32
-admin.site.register(get_model('basket', 'LineAttribute'))
60
+admin.site.register(get_model("basket", "LineAttribute"))

+ 20
- 14
src/oscar/apps/basket/apps.py View File

@@ -1,3 +1,4 @@
1
+# pylint: disable=W0201
1 2
 from django.contrib.auth.decorators import login_required
2 3
 from django.urls import path
3 4
 from django.utils.translation import gettext_lazy as _
@@ -7,25 +8,30 @@ from oscar.core.loading import get_class
7 8
 
8 9
 
9 10
 class BasketConfig(OscarConfig):
10
-    label = 'basket'
11
-    name = 'oscar.apps.basket'
12
-    verbose_name = _('Basket')
11
+    label = "basket"
12
+    name = "oscar.apps.basket"
13
+    verbose_name = _("Basket")
13 14
 
14
-    namespace = 'basket'
15
+    namespace = "basket"
15 16
 
17
+    # pylint: disable=attribute-defined-outside-init
16 18
     def ready(self):
17
-        self.summary_view = get_class('basket.views', 'BasketView')
18
-        self.saved_view = get_class('basket.views', 'SavedView')
19
-        self.add_view = get_class('basket.views', 'BasketAddView')
20
-        self.add_voucher_view = get_class('basket.views', 'VoucherAddView')
21
-        self.remove_voucher_view = get_class('basket.views', 'VoucherRemoveView')
19
+        self.summary_view = get_class("basket.views", "BasketView")
20
+        self.saved_view = get_class("basket.views", "SavedView")
21
+        self.add_view = get_class("basket.views", "BasketAddView")
22
+        self.add_voucher_view = get_class("basket.views", "VoucherAddView")
23
+        self.remove_voucher_view = get_class("basket.views", "VoucherRemoveView")
22 24
 
23 25
     def get_urls(self):
24 26
         urls = [
25
-            path('', self.summary_view.as_view(), name='summary'),
26
-            path('add/<int:pk>/', self.add_view.as_view(), name='add'),
27
-            path('vouchers/add/', self.add_voucher_view.as_view(), name='vouchers-add'),
28
-            path('vouchers/<int:pk>/remove/', self.remove_voucher_view.as_view(), name='vouchers-remove'),
29
-            path('saved/', login_required(self.saved_view.as_view()), name='saved'),
27
+            path("", self.summary_view.as_view(), name="summary"),
28
+            path("add/<int:pk>/", self.add_view.as_view(), name="add"),
29
+            path("vouchers/add/", self.add_voucher_view.as_view(), name="vouchers-add"),
30
+            path(
31
+                "vouchers/<int:pk>/remove/",
32
+                self.remove_voucher_view.as_view(),
33
+                name="vouchers-remove",
34
+            ),
35
+            path("saved/", login_required(self.saved_view.as_view()), name="saved"),
30 36
         ]
31 37
         return self.post_process_urls(urls)

+ 87
- 64
src/oscar/apps/basket/forms.py View File

@@ -1,3 +1,4 @@
1
+# pylint: disable=unused-argument
1 2
 from django import forms
2 3
 from django.conf import settings
3 4
 from django.core.validators import EMPTY_VALUES
@@ -8,26 +9,34 @@ from django.utils.translation import gettext_lazy as _
8 9
 from oscar.core.loading import get_model
9 10
 from oscar.forms import widgets
10 11
 
11
-Line = get_model('basket', 'line')
12
-Basket = get_model('basket', 'basket')
13
-Option = get_model('catalogue', 'option')
14
-Product = get_model('catalogue', 'product')
12
+Line = get_model("basket", "line")
13
+Basket = get_model("basket", "basket")
14
+Option = get_model("catalogue", "option")
15
+Product = get_model("catalogue", "product")
15 16
 
16 17
 
17 18
 def _option_text_field(form, product, option):
18
-    return forms.CharField(label=option.name, required=option.required, help_text=option.help_text)
19
+    return forms.CharField(
20
+        label=option.name, required=option.required, help_text=option.help_text
21
+    )
19 22
 
20 23
 
21 24
 def _option_integer_field(form, product, option):
22
-    return forms.IntegerField(label=option.name, required=option.required, help_text=option.help_text)
25
+    return forms.IntegerField(
26
+        label=option.name, required=option.required, help_text=option.help_text
27
+    )
23 28
 
24 29
 
25 30
 def _option_boolean_field(form, product, option):
26
-    return forms.BooleanField(label=option.name, required=option.required, help_text=option.help_text)
31
+    return forms.BooleanField(
32
+        label=option.name, required=option.required, help_text=option.help_text
33
+    )
27 34
 
28 35
 
29 36
 def _option_float_field(form, product, option):
30
-    return forms.FloatField(label=option.name, required=option.required, help_text=option.help_text)
37
+    return forms.FloatField(
38
+        label=option.name, required=option.required, help_text=option.help_text
39
+    )
31 40
 
32 41
 
33 42
 def _option_date_field(form, product, option):
@@ -35,7 +44,7 @@ def _option_date_field(form, product, option):
35 44
         label=option.name,
36 45
         required=option.required,
37 46
         widget=forms.widgets.SelectDateWidget,
38
-        help_text=option.help_text
47
+        help_text=option.help_text,
39 48
     )
40 49
 
41 50
 
@@ -44,7 +53,7 @@ def _option_select_field(form, product, option):
44 53
         label=option.name,
45 54
         required=option.required,
46 55
         choices=option.get_choices(),
47
-        help_text=option.help_text
56
+        help_text=option.help_text,
48 57
     )
49 58
 
50 59
 
@@ -54,7 +63,7 @@ def _option_radio_field(form, product, option):
54 63
         required=option.required,
55 64
         choices=option.get_choices(),
56 65
         widget=forms.RadioSelect,
57
-        help_text=option.help_text
66
+        help_text=option.help_text,
58 67
     )
59 68
 
60 69
 
@@ -63,7 +72,7 @@ def _option_multi_select_field(form, product, option):
63 72
         label=option.name,
64 73
         required=option.required,
65 74
         choices=option.get_choices(),
66
-        help_text=option.help_text
75
+        help_text=option.help_text,
67 76
     )
68 77
 
69 78
 
@@ -73,14 +82,17 @@ def _option_checkbox_field(form, product, option):
73 82
         required=option.required,
74 83
         choices=option.get_choices(),
75 84
         widget=forms.CheckboxSelectMultiple,
76
-        help_text=option.help_text
85
+        help_text=option.help_text,
77 86
     )
78 87
 
79 88
 
80 89
 class BasketLineForm(forms.ModelForm):
81
-    quantity = forms.IntegerField(label=_('Quantity'), min_value=0, required=False, initial=1)
90
+    quantity = forms.IntegerField(
91
+        label=_("Quantity"), min_value=0, required=False, initial=1
92
+    )
82 93
     save_for_later = forms.BooleanField(
83
-        initial=False, required=False, label=_('Save for Later'))
94
+        initial=False, required=False, label=_("Save for Later")
95
+    )
84 96
 
85 97
     def __init__(self, strategy, *args, **kwargs):
86 98
         super().__init__(*args, **kwargs)
@@ -91,15 +103,18 @@ class BasketLineForm(forms.ModelForm):
91 103
         # https://github.com/django-oscar/django-oscar/issues/2873.
92 104
         if self.instance.id:
93 105
             max_allowed_quantity = None
94
-            num_available = getattr(self.instance.purchase_info.availability, 'num_available', None)
106
+            num_available = getattr(
107
+                self.instance.purchase_info.availability, "num_available", None
108
+            )
95 109
             basket_max_allowed_quantity = self.instance.basket.max_allowed_quantity()[0]
96 110
             if all([num_available, basket_max_allowed_quantity]):
97 111
                 max_allowed_quantity = min(num_available, basket_max_allowed_quantity)
98 112
             else:
99 113
                 max_allowed_quantity = num_available or basket_max_allowed_quantity
100 114
             if max_allowed_quantity:
101
-                self.fields['quantity'].widget.attrs['max'] = max_allowed_quantity
115
+                self.fields["quantity"].widget.attrs["max"] = max_allowed_quantity
102 116
 
117
+    # pylint: disable=W0201
103 118
     def full_clean(self):
104 119
         if not self.instance.id:
105 120
             self.cleaned_data = {}
@@ -113,7 +128,7 @@ class BasketLineForm(forms.ModelForm):
113 128
         return super().has_changed()
114 129
 
115 130
     def clean_quantity(self):
116
-        qty = self.cleaned_data['quantity'] or 0
131
+        qty = self.cleaned_data["quantity"] or 0
117 132
         if qty > 0:
118 133
             self.check_max_allowed_quantity(qty)
119 134
             self.check_permission(qty)
@@ -126,29 +141,31 @@ class BasketLineForm(forms.ModelForm):
126 141
         # number and updated. Thus, product already in the basket and we don't
127 142
         # add second time, just updating number of items.
128 143
         qty_delta = qty - self.instance.quantity
129
-        is_allowed, reason = self.instance.basket.is_quantity_allowed(qty_delta, line=self.instance)
144
+        is_allowed, reason = self.instance.basket.is_quantity_allowed(
145
+            qty_delta, line=self.instance
146
+        )
130 147
         if not is_allowed:
131 148
             raise forms.ValidationError(reason)
132 149
 
133 150
     def check_permission(self, qty):
134 151
         policy = self.instance.purchase_info.availability
135
-        is_available, reason = policy.is_purchase_permitted(
136
-            quantity=qty)
152
+        is_available, reason = policy.is_purchase_permitted(quantity=qty)
137 153
         if not is_available:
138 154
             raise forms.ValidationError(reason)
139 155
 
140 156
     class Meta:
141 157
         model = Line
142
-        fields = ['quantity']
158
+        fields = ["quantity"]
143 159
 
144 160
 
145 161
 class SavedLineForm(forms.ModelForm):
146
-    move_to_basket = forms.BooleanField(initial=False, required=False,
147
-                                        label=_('Move to Basket'))
162
+    move_to_basket = forms.BooleanField(
163
+        initial=False, required=False, label=_("Move to Basket")
164
+    )
148 165
 
149 166
     class Meta:
150 167
         model = Line
151
-        fields = ('id', 'move_to_basket')
168
+        fields = ("id", "move_to_basket")
152 169
 
153 170
     def __init__(self, strategy, basket, *args, **kwargs):
154 171
         self.strategy = strategy
@@ -157,36 +174,36 @@ class SavedLineForm(forms.ModelForm):
157 174
 
158 175
     def clean(self):
159 176
         cleaned_data = super().clean()
160
-        if not cleaned_data['move_to_basket']:
177
+        if not cleaned_data["move_to_basket"]:
161 178
             # skip further validation (see issue #666)
162 179
             return cleaned_data
163 180
 
164 181
         # Get total quantity of all lines with this product (there's normally
165 182
         # only one but there can be more if you allow product options).
166 183
         lines = self.basket.lines.filter(product=self.instance.product)
167
-        current_qty = lines.aggregate(Sum('quantity'))['quantity__sum'] or 0
184
+        current_qty = lines.aggregate(Sum("quantity"))["quantity__sum"] or 0
168 185
         desired_qty = current_qty + self.instance.quantity
169 186
 
170 187
         result = self.strategy.fetch_for_product(self.instance.product)
171 188
         is_available, reason = result.availability.is_purchase_permitted(
172
-            quantity=desired_qty)
189
+            quantity=desired_qty
190
+        )
173 191
         if not is_available:
174 192
             raise forms.ValidationError(reason)
175 193
         return cleaned_data
176 194
 
177 195
 
178 196
 class BasketVoucherForm(forms.Form):
179
-    code = forms.CharField(max_length=128, label=_('Code'))
197
+    code = forms.CharField(max_length=128, label=_("Code"))
180 198
 
181 199
     def __init__(self, *args, **kwargs):
182 200
         super().__init__(*args, **kwargs)
183 201
 
184 202
     def clean_code(self):
185
-        return self.cleaned_data['code'].strip().upper()
203
+        return self.cleaned_data["code"].strip().upper()
186 204
 
187 205
 
188 206
 class AddToBasketForm(forms.Form):
189
-
190 207
     OPTION_FIELD_FACTORIES = {
191 208
         Option.TEXT: _option_text_field,
192 209
         Option.INTEGER: _option_integer_field,
@@ -199,7 +216,7 @@ class AddToBasketForm(forms.Form):
199 216
         Option.CHECKBOX: _option_checkbox_field,
200 217
     }
201 218
 
202
-    quantity = forms.IntegerField(initial=1, min_value=1, label=_('Quantity'))
219
+    quantity = forms.IntegerField(initial=1, min_value=1, label=_("Quantity"))
203 220
 
204 221
     def __init__(self, basket, product, *args, **kwargs):
205 222
         # Note, the product passed in here isn't necessarily the product being
@@ -243,9 +260,11 @@ class AddToBasketForm(forms.Form):
243 260
 
244 261
             choices.append((child.id, summary))
245 262
 
246
-        self.fields['child_id'] = forms.ChoiceField(
247
-            choices=tuple(choices), label=_("Variant"),
248
-            widget=widgets.AdvancedSelect(disabled_values=disabled_values))
263
+        self.fields["child_id"] = forms.ChoiceField(
264
+            choices=tuple(choices),
265
+            label=_("Variant"),
266
+            widget=widgets.AdvancedSelect(disabled_values=disabled_values),
267
+        )
249 268
 
250 269
     def _create_product_fields(self, product):
251 270
         """
@@ -261,39 +280,41 @@ class AddToBasketForm(forms.Form):
261 280
         This is designed to be overridden so that specific widgets can be used
262 281
         for certain types of options.
263 282
         """
264
-        option_field = self.OPTION_FIELD_FACTORIES.get(option.type, _option_text_field)(self, product, option)
283
+        option_field = self.OPTION_FIELD_FACTORIES.get(option.type, _option_text_field)(
284
+            self, product, option
285
+        )
265 286
         self.fields[option.code] = option_field
266 287
 
267 288
     # Cleaning
268 289
 
269 290
     def clean_child_id(self):
270 291
         try:
271
-            child = self.parent_product.children.get(
272
-                id=self.cleaned_data['child_id'])
292
+            child = self.parent_product.children.get(id=self.cleaned_data["child_id"])
273 293
         except Product.DoesNotExist:
274
-            raise forms.ValidationError(
275
-                _("Please select a valid product"))
294
+            raise forms.ValidationError(_("Please select a valid product"))
276 295
 
277 296
         # To avoid duplicate SQL queries, we cache a copy of the loaded child
278 297
         # product as we're going to need it later.
279
-        self.child_product = child
298
+        self.child_product = child  # pylint: disable=W0201
280 299
 
281
-        return self.cleaned_data['child_id']
300
+        return self.cleaned_data["child_id"]
282 301
 
283 302
     def clean_quantity(self):
284 303
         # Check that the proposed new line quantity is sensible
285
-        qty = self.cleaned_data['quantity']
304
+        qty = self.cleaned_data["quantity"]
286 305
         basket_threshold = settings.OSCAR_MAX_BASKET_QUANTITY_THRESHOLD
287 306
         if basket_threshold:
288 307
             total_basket_quantity = self.basket.num_items
289 308
             max_allowed = basket_threshold - total_basket_quantity
290 309
             if qty > max_allowed:
291 310
                 raise forms.ValidationError(
292
-                    _("Due to technical limitations we are not able to ship"
293
-                      " more than %(threshold)d items in one order. Your"
294
-                      " basket currently has %(basket)d items.")
295
-                    % {'threshold': basket_threshold,
296
-                       'basket': total_basket_quantity})
311
+                    _(
312
+                        "Due to technical limitations we are not able to ship"
313
+                        " more than %(threshold)d items in one order. Your"
314
+                        " basket currently has %(basket)d items."
315
+                    )
316
+                    % {"threshold": basket_threshold, "basket": total_basket_quantity}
317
+                )
297 318
         return qty
298 319
 
299 320
     @property
@@ -303,7 +324,7 @@ class AddToBasketForm(forms.Form):
303 324
         """
304 325
         # Note, the child product attribute is saved in the clean_child_id
305 326
         # method
306
-        return getattr(self, 'child_product', self.parent_product)
327
+        return getattr(self, "child_product", self.parent_product)
307 328
 
308 329
     def clean(self):
309 330
         info = self.basket.strategy.fetch_for_product(self.product)
@@ -311,22 +332,26 @@ class AddToBasketForm(forms.Form):
311 332
         # Check that a price was found by the strategy
312 333
         if not info.price.exists:
313 334
             raise forms.ValidationError(
314
-                _("This product cannot be added to the basket because a "
315
-                  "price could not be determined for it."))
335
+                _(
336
+                    "This product cannot be added to the basket because a "
337
+                    "price could not be determined for it."
338
+                )
339
+            )
316 340
 
317 341
         # Check currencies are sensible
318
-        if (self.basket.currency
319
-                and info.price.currency != self.basket.currency):
342
+        if self.basket.currency and info.price.currency != self.basket.currency:
320 343
             raise forms.ValidationError(
321
-                _("This product cannot be added to the basket as its currency "
322
-                  "isn't the same as other products in your basket"))
344
+                _(
345
+                    "This product cannot be added to the basket as its currency "
346
+                    "isn't the same as other products in your basket"
347
+                )
348
+            )
323 349
 
324 350
         # Check user has permission to add the desired quantity to their
325 351
         # basket.
326 352
         current_qty = self.basket.product_quantity(self.product)
327
-        desired_qty = current_qty + self.cleaned_data.get('quantity', 1)
328
-        is_permitted, reason = info.availability.is_purchase_permitted(
329
-            desired_qty)
353
+        desired_qty = current_qty + self.cleaned_data.get("quantity", 1)
354
+        is_permitted, reason = info.availability.is_purchase_permitted(desired_qty)
330 355
         if not is_permitted:
331 356
             raise forms.ValidationError(reason)
332 357
 
@@ -343,18 +368,16 @@ class AddToBasketForm(forms.Form):
343 368
             if option.code in self.cleaned_data:
344 369
                 value = self.cleaned_data[option.code]
345 370
                 if option.required or value not in EMPTY_VALUES:
346
-                    options.append(
347
-                        {"option": option, "value": value}
348
-                    )
371
+                    options.append({"option": option, "value": value})
349 372
         return options
350 373
 
351 374
 
352 375
 class SimpleAddToBasketMixin:
353 376
     def __init__(self, *args, **kwargs):
354 377
         super().__init__(*args, **kwargs)
355
-        if 'quantity' in self.fields:
356
-            self.fields['quantity'].initial = 1
357
-            self.fields['quantity'].widget = forms.HiddenInput()
378
+        if "quantity" in self.fields:
379
+            self.fields["quantity"].initial = 1
380
+            self.fields["quantity"].widget = forms.HiddenInput()
358 381
 
359 382
 
360 383
 class SimpleAddToBasketForm(SimpleAddToBasketMixin, AddToBasketForm):

+ 14
- 14
src/oscar/apps/basket/formsets.py View File

@@ -3,19 +3,19 @@ from django.utils.functional import cached_property
3 3
 
4 4
 from oscar.core.loading import get_classes, get_model
5 5
 
6
-Line = get_model('basket', 'line')
7
-BasketLineForm, SavedLineForm = get_classes('basket.forms', ['BasketLineForm', 'SavedLineForm'])
6
+Line = get_model("basket", "line")
7
+BasketLineForm, SavedLineForm = get_classes(
8
+    "basket.forms", ["BasketLineForm", "SavedLineForm"]
9
+)
8 10
 
9 11
 
10 12
 class BaseBasketLineFormSet(BaseModelFormSet):
11
-
12 13
     def __init__(self, strategy, *args, **kwargs):
13 14
         self.strategy = strategy
14 15
         super().__init__(*args, **kwargs)
15 16
 
16 17
     def _construct_form(self, i, **kwargs):
17
-        return super()._construct_form(
18
-            i, strategy=self.strategy, **kwargs)
18
+        return super()._construct_form(i, strategy=self.strategy, **kwargs)
19 19
 
20 20
     def _should_delete_form(self, form):
21 21
         """
@@ -29,8 +29,8 @@ class BaseBasketLineFormSet(BaseModelFormSet):
29 29
         # as well.
30 30
         if not form.instance.id:
31 31
             return True
32
-        if self.can_delete and 'quantity' in form.cleaned_data:
33
-            return form.cleaned_data['quantity'] == 0
32
+        if self.can_delete and "quantity" in form.cleaned_data:  # pylint: disable=E1101
33
+            return form.cleaned_data["quantity"] == 0
34 34
 
35 35
     @cached_property
36 36
     def forms_with_instances(self):
@@ -44,12 +44,11 @@ class BaseBasketLineFormSet(BaseModelFormSet):
44 44
 
45 45
 
46 46
 BasketLineFormSet = modelformset_factory(
47
-    Line, form=BasketLineForm, formset=BaseBasketLineFormSet, extra=0,
48
-    can_delete=True)
47
+    Line, form=BasketLineForm, formset=BaseBasketLineFormSet, extra=0, can_delete=True
48
+)
49 49
 
50 50
 
51 51
 class BaseSavedLineFormSet(BaseModelFormSet):
52
-
53 52
     def __init__(self, strategy, basket, *args, **kwargs):
54 53
         self.strategy = strategy
55 54
         self.basket = basket
@@ -57,9 +56,10 @@ class BaseSavedLineFormSet(BaseModelFormSet):
57 56
 
58 57
     def _construct_form(self, i, **kwargs):
59 58
         return super()._construct_form(
60
-            i, strategy=self.strategy, basket=self.basket, **kwargs)
59
+            i, strategy=self.strategy, basket=self.basket, **kwargs
60
+        )
61 61
 
62 62
 
63
-SavedLineFormSet = modelformset_factory(Line, form=SavedLineForm,
64
-                                        formset=BaseSavedLineFormSet, extra=0,
65
-                                        can_delete=True)
63
+SavedLineFormSet = modelformset_factory(
64
+    Line, form=SavedLineForm, formset=BaseSavedLineFormSet, extra=0, can_delete=True
65
+)

+ 6
- 8
src/oscar/apps/basket/managers.py View File

@@ -3,28 +3,26 @@ from django.db import models
3 3
 
4 4
 class OpenBasketManager(models.Manager):
5 5
     """For searching/creating OPEN baskets only."""
6
+
6 7
     status_filter = "Open"
7 8
 
8 9
     def get_queryset(self):
9
-        return super().get_queryset().filter(
10
-            status=self.status_filter)
10
+        return super().get_queryset().filter(status=self.status_filter)
11 11
 
12 12
     def get_or_create(self, **kwargs):
13
-        return self.get_queryset().get_or_create(
14
-            status=self.status_filter, **kwargs)
13
+        return self.get_queryset().get_or_create(status=self.status_filter, **kwargs)
15 14
 
16 15
 
17 16
 class SavedBasketManager(models.Manager):
18 17
     """For searching/creating SAVED baskets only."""
18
+
19 19
     status_filter = "Saved"
20 20
 
21 21
     def get_queryset(self):
22
-        return super().get_queryset().filter(
23
-            status=self.status_filter)
22
+        return super().get_queryset().filter(status=self.status_filter)
24 23
 
25 24
     def create(self, **kwargs):
26 25
         return self.get_queryset().create(status=self.status_filter, **kwargs)
27 26
 
28 27
     def get_or_create(self, **kwargs):
29
-        return self.get_queryset().get_or_create(
30
-            status=self.status_filter, **kwargs)
28
+        return self.get_queryset().get_or_create(status=self.status_filter, **kwargs)

+ 38
- 24
src/oscar/apps/basket/middleware.py View File

@@ -6,15 +6,14 @@ from django.utils.translation import gettext_lazy as _
6 6
 
7 7
 from oscar.core.loading import get_class, get_model
8 8
 
9
-Applicator = get_class('offer.applicator', 'Applicator')
10
-Basket = get_model('basket', 'basket')
11
-Selector = get_class('partner.strategy', 'Selector')
9
+Applicator = get_class("offer.applicator", "Applicator")
10
+Basket = get_model("basket", "basket")
11
+Selector = get_class("partner.strategy", "Selector")
12 12
 
13 13
 selector = Selector()
14 14
 
15 15
 
16 16
 class BasketMiddleware:
17
-
18 17
     def __init__(self, get_response):
19 18
         self.get_response = get_response
20 19
 
@@ -63,34 +62,42 @@ class BasketMiddleware:
63 62
 
64 63
     def process_response(self, request, response):
65 64
         # Delete any surplus cookies
66
-        cookies_to_delete = getattr(request, 'cookies_to_delete', [])
65
+        cookies_to_delete = getattr(request, "cookies_to_delete", [])
67 66
         for cookie_key in cookies_to_delete:
68 67
             response.delete_cookie(cookie_key)
69 68
 
70
-        if not hasattr(request, 'basket'):
69
+        if not hasattr(request, "basket"):
71 70
             return response
72 71
 
73 72
         # If the basket was never initialized we can safely return
74
-        if (isinstance(request.basket, SimpleLazyObject)
75
-                and request.basket._wrapped is empty):
73
+        if (
74
+            isinstance(request.basket, SimpleLazyObject)
75
+            and request.basket._wrapped is empty
76
+        ):
76 77
             return response
77 78
 
78 79
         cookie_key = self.get_cookie_key(request)
79 80
         # Check if we need to set a cookie. If the cookies is already available
80 81
         # but is set in the cookies_to_delete list then we need to re-set it.
81 82
         has_basket_cookie = (
82
-            cookie_key in request.COOKIES
83
-            and cookie_key not in cookies_to_delete)
83
+            cookie_key in request.COOKIES and cookie_key not in cookies_to_delete
84
+        )
84 85
 
85 86
         # If a basket has had products added to it, but the user is anonymous
86 87
         # then we need to assign it to a cookie
87
-        if (request.basket.id and not request.user.is_authenticated
88
-                and not has_basket_cookie):
88
+        if (
89
+            request.basket.id
90
+            and not request.user.is_authenticated
91
+            and not has_basket_cookie
92
+        ):
89 93
             cookie = self.get_basket_hash(request.basket.id)
90 94
             response.set_cookie(
91
-                cookie_key, cookie,
95
+                cookie_key,
96
+                cookie,
92 97
                 max_age=settings.OSCAR_BASKET_COOKIE_LIFETIME,
93
-                secure=settings.OSCAR_BASKET_COOKIE_SECURE, httponly=True)
98
+                secure=settings.OSCAR_BASKET_COOKIE_SECURE,
99
+                httponly=True,
100
+            )
94 101
         return response
95 102
 
96 103
     def get_cookie_key(self, request):
@@ -103,11 +110,11 @@ class BasketMiddleware:
103 110
         return settings.OSCAR_BASKET_COOKIE_OPEN
104 111
 
105 112
     def process_template_response(self, request, response):
106
-        if hasattr(response, 'context_data'):
113
+        if hasattr(response, "context_data"):
107 114
             if response.context_data is None:
108 115
                 response.context_data = {}
109
-            if 'basket' not in response.context_data:
110
-                response.context_data['basket'] = request.basket
116
+            if "basket" not in response.context_data:
117
+                response.context_data["basket"] = request.basket
111 118
             else:
112 119
                 # Occasionally, a view will want to pass an alternative basket
113 120
                 # to be rendered.  This can happen as part of checkout
@@ -117,7 +124,7 @@ class BasketMiddleware:
117 124
                 # template, we need to ensure that the frozen basket gets
118 125
                 # rendered (not request.basket).  We still keep a reference to
119 126
                 # the request basket (just in case).
120
-                response.context_data['request_basket'] = request.basket
127
+                response.context_data["request_basket"] = request.basket
121 128
         return response
122 129
 
123 130
     # Helper methods
@@ -134,7 +141,7 @@ class BasketMiddleware:
134 141
         cookie_key = self.get_cookie_key(request)
135 142
         cookie_basket = self.get_cookie_basket(cookie_key, request, manager)
136 143
 
137
-        if hasattr(request, 'user') and request.user.is_authenticated:
144
+        if hasattr(request, "user") and request.user.is_authenticated:
138 145
             # Signed-in user: if they have a cookie basket too, it means
139 146
             # that they have just signed in and we need to merge their cookie
140 147
             # basket into their user basket, then delete the cookie.
@@ -170,9 +177,14 @@ class BasketMiddleware:
170 177
         request._basket_cache = basket
171 178
 
172 179
         if num_baskets_merged > 0:
173
-            messages.add_message(request, messages.WARNING,
174
-                                 _("We have merged a basket from a previous session. Its contents "
175
-                                   "might have changed."))
180
+            messages.add_message(
181
+                request,
182
+                messages.WARNING,
183
+                _(
184
+                    "We have merged a basket from a previous session. Its contents "
185
+                    "might have changed."
186
+                ),
187
+            )
176 188
 
177 189
         return basket
178 190
 
@@ -184,6 +196,7 @@ class BasketMiddleware:
184 196
         """
185 197
         master.merge(slave, add_quantities=False)
186 198
 
199
+    # pylint: disable=unused-argument
187 200
     def get_cookie_basket(self, cookie_key, request, manager):
188 201
         """
189 202
         Looks for a basket which is referenced by a cookie.
@@ -196,8 +209,9 @@ class BasketMiddleware:
196 209
             basket_hash = request.COOKIES[cookie_key]
197 210
             try:
198 211
                 basket_id = Signer().unsign(basket_hash)
199
-                basket = Basket.objects.get(pk=basket_id, owner=None,
200
-                                            status=Basket.OPEN)
212
+                basket = Basket.objects.get(
213
+                    pk=basket_id, owner=None, status=Basket.OPEN
214
+                )
201 215
             except (BadSignature, Basket.DoesNotExist):
202 216
                 request.cookies_to_delete.append(cookie_key)
203 217
         return basket

+ 14
- 8
src/oscar/apps/basket/models.py View File

@@ -1,9 +1,12 @@
1 1
 from oscar.apps.basket.abstract_models import (
2
-    AbstractBasket, AbstractLine, AbstractLineAttribute)
2
+    AbstractBasket,
3
+    AbstractLine,
4
+    AbstractLineAttribute,
5
+)
3 6
 from oscar.core.loading import is_model_registered
4 7
 
5 8
 __all__ = [
6
-    'InvalidBasketLineError',
9
+    "InvalidBasketLineError",
7 10
 ]
8 11
 
9 12
 
@@ -11,22 +14,25 @@ class InvalidBasketLineError(Exception):
11 14
     pass
12 15
 
13 16
 
14
-if not is_model_registered('basket', 'Basket'):
17
+if not is_model_registered("basket", "Basket"):
18
+
15 19
     class Basket(AbstractBasket):
16 20
         pass
17 21
 
18
-    __all__.append('Basket')
22
+    __all__.append("Basket")
23
+
19 24
 
25
+if not is_model_registered("basket", "Line"):
20 26
 
21
-if not is_model_registered('basket', 'Line'):
22 27
     class Line(AbstractLine):
23 28
         pass
24 29
 
25
-    __all__.append('Line')
30
+    __all__.append("Line")
31
+
26 32
 
33
+if not is_model_registered("basket", "LineAttribute"):
27 34
 
28
-if not is_model_registered('basket', 'LineAttribute'):
29 35
     class LineAttribute(AbstractLineAttribute):
30 36
         pass
31 37
 
32
-    __all__.append('LineAttribute')
38
+    __all__.append("LineAttribute")

+ 74
- 63
src/oscar/apps/basket/reports.py View File

@@ -2,122 +2,133 @@ from django.utils.translation import gettext_lazy as _
2 2
 
3 3
 from oscar.core.loading import get_class, get_model
4 4
 
5
-ReportGenerator = get_class('dashboard.reports.reports', 'ReportGenerator')
6
-ReportCSVFormatter = get_class('dashboard.reports.reports',
7
-                               'ReportCSVFormatter')
8
-ReportHTMLFormatter = get_class('dashboard.reports.reports',
9
-                                'ReportHTMLFormatter')
10
-Basket = get_model('basket', 'Basket')
5
+ReportGenerator = get_class("dashboard.reports.reports", "ReportGenerator")
6
+ReportCSVFormatter = get_class("dashboard.reports.reports", "ReportCSVFormatter")
7
+ReportHTMLFormatter = get_class("dashboard.reports.reports", "ReportHTMLFormatter")
8
+Basket = get_model("basket", "Basket")
11 9
 
12 10
 
13 11
 class OpenBasketReportCSVFormatter(ReportCSVFormatter):
14
-    filename_template = 'open-baskets-%s-%s.csv'
12
+    filename_template = "open-baskets-%s-%s.csv"
15 13
 
16 14
     def generate_csv(self, response, baskets):
17 15
         writer = self.get_csv_writer(response)
18
-        header_row = [_('User ID'),
19
-                      _('Name'),
20
-                      _('Email'),
21
-                      _('Basket status'),
22
-                      _('Num lines'),
23
-                      _('Num items'),
24
-                      _('Date of creation'),
25
-                      _('Time since creation'),
26
-                      ]
16
+        header_row = [
17
+            _("User ID"),
18
+            _("Name"),
19
+            _("Email"),
20
+            _("Basket status"),
21
+            _("Num lines"),
22
+            _("Num items"),
23
+            _("Date of creation"),
24
+            _("Time since creation"),
25
+        ]
27 26
         writer.writerow(header_row)
28 27
 
29 28
         for basket in baskets:
30 29
             if basket.owner:
31
-                row = [basket.owner_id, basket.owner.get_full_name(),
32
-                       basket.owner.email,
33
-                       basket.status, basket.num_lines, basket.num_items,
34
-                       self.format_datetime(basket.date_created),
35
-                       basket.time_since_creation]
30
+                row = [
31
+                    basket.owner_id,
32
+                    basket.owner.get_full_name(),
33
+                    basket.owner.email,
34
+                    basket.status,
35
+                    basket.num_lines,
36
+                    basket.num_items,
37
+                    self.format_datetime(basket.date_created),
38
+                    basket.time_since_creation,
39
+                ]
36 40
             else:
37
-                row = [basket.owner_id, None, None, basket.status,
38
-                       basket.num_lines, basket.num_items,
39
-                       self.format_datetime(basket.date_created),
40
-                       self.format_timedelta(basket.time_since_creation)]
41
+                row = [
42
+                    basket.owner_id,
43
+                    None,
44
+                    None,
45
+                    basket.status,
46
+                    basket.num_lines,
47
+                    basket.num_items,
48
+                    self.format_datetime(basket.date_created),
49
+                    self.format_timedelta(basket.time_since_creation),
50
+                ]
41 51
             writer.writerow(row)
42 52
 
43 53
     def filename(self, **kwargs):
44
-        return self.filename_template % (kwargs['start_date'],
45
-                                         kwargs['end_date'])
54
+        return self.filename_template % (kwargs["start_date"], kwargs["end_date"])
46 55
 
47 56
 
48 57
 class OpenBasketReportHTMLFormatter(ReportHTMLFormatter):
49
-    filename_template = 'oscar/dashboard/reports/partials/open_basket_report.html'
58
+    filename_template = "oscar/dashboard/reports/partials/open_basket_report.html"
50 59
 
51 60
 
52 61
 class OpenBasketReportGenerator(ReportGenerator):
53 62
     """
54 63
     Report of baskets which haven't been submitted yet
55 64
     """
56
-    code = 'open_baskets'
57
-    description = _('Open baskets')
58
-    date_range_field_name = 'date_created'
65
+
66
+    code = "open_baskets"
67
+    description = _("Open baskets")
68
+    date_range_field_name = "date_created"
59 69
     queryset = Basket._default_manager.filter(status=Basket.OPEN)
60 70
 
61 71
     formatters = {
62
-        'CSV_formatter': OpenBasketReportCSVFormatter,
63
-        'HTML_formatter': OpenBasketReportHTMLFormatter}
72
+        "CSV_formatter": OpenBasketReportCSVFormatter,
73
+        "HTML_formatter": OpenBasketReportHTMLFormatter,
74
+    }
64 75
 
65 76
     def generate(self):
66
-        additional_data = {
67
-            'start_date': self.start_date,
68
-            'end_date': self.end_date}
77
+        additional_data = {"start_date": self.start_date, "end_date": self.end_date}
69 78
         return self.formatter.generate_response(self.queryset, **additional_data)
70 79
 
71 80
 
72 81
 class SubmittedBasketReportCSVFormatter(ReportCSVFormatter):
73
-    filename_template = 'submitted_baskets-%s-%s.csv'
82
+    filename_template = "submitted_baskets-%s-%s.csv"
74 83
 
75 84
     def generate_csv(self, response, baskets):
76 85
         writer = self.get_csv_writer(response)
77
-        header_row = [_('User ID'),
78
-                      _('User'),
79
-                      _('Basket status'),
80
-                      _('Num lines'),
81
-                      _('Num items'),
82
-                      _('Date created'),
83
-                      _('Time between creation and submission'),
84
-                      ]
86
+        header_row = [
87
+            _("User ID"),
88
+            _("User"),
89
+            _("Basket status"),
90
+            _("Num lines"),
91
+            _("Num items"),
92
+            _("Date created"),
93
+            _("Time between creation and submission"),
94
+        ]
85 95
         writer.writerow(header_row)
86 96
 
87 97
         for basket in baskets:
88
-            row = [basket.owner_id,
89
-                   basket.owner,
90
-                   basket.status,
91
-                   basket.num_lines,
92
-                   basket.num_items,
93
-                   self.format_datetime(basket.date_created),
94
-                   basket.time_before_submit]
98
+            row = [
99
+                basket.owner_id,
100
+                basket.owner,
101
+                basket.status,
102
+                basket.num_lines,
103
+                basket.num_items,
104
+                self.format_datetime(basket.date_created),
105
+                basket.time_before_submit,
106
+            ]
95 107
             writer.writerow(row)
96 108
 
97 109
     def filename(self, **kwargs):
98
-        return self.filename_template % (kwargs['start_date'],
99
-                                         kwargs['end_date'])
110
+        return self.filename_template % (kwargs["start_date"], kwargs["end_date"])
100 111
 
101 112
 
102 113
 class SubmittedBasketReportHTMLFormatter(ReportHTMLFormatter):
103
-    filename_template = 'oscar/dashboard/reports/partials/submitted_basket_report.html'
114
+    filename_template = "oscar/dashboard/reports/partials/submitted_basket_report.html"
104 115
 
105 116
 
106 117
 class SubmittedBasketReportGenerator(ReportGenerator):
107 118
     """
108 119
     Report of baskets that have been submitted
109 120
     """
110
-    code = 'submitted_baskets'
111
-    description = _('Submitted baskets')
112
-    date_range_field_name = 'date_submitted'
121
+
122
+    code = "submitted_baskets"
123
+    description = _("Submitted baskets")
124
+    date_range_field_name = "date_submitted"
113 125
     queryset = Basket._default_manager.filter(status=Basket.SUBMITTED)
114 126
 
115 127
     formatters = {
116
-        'CSV_formatter': SubmittedBasketReportCSVFormatter,
117
-        'HTML_formatter': SubmittedBasketReportHTMLFormatter}
128
+        "CSV_formatter": SubmittedBasketReportCSVFormatter,
129
+        "HTML_formatter": SubmittedBasketReportHTMLFormatter,
130
+    }
118 131
 
119 132
     def generate(self):
120
-        additional_data = {
121
-            'start_date': self.start_date,
122
-            'end_date': self.end_date}
133
+        additional_data = {"start_date": self.start_date, "end_date": self.end_date}
123 134
         return self.formatter.generate_response(self.queryset, **additional_data)

+ 28
- 23
src/oscar/apps/basket/utils.py View File

@@ -5,23 +5,23 @@ from django.template.loader import render_to_string
5 5
 
6 6
 from oscar.core.loading import get_class, get_model
7 7
 
8
-Applicator = get_class('offer.applicator', 'Applicator')
9
-ConditionalOffer = get_model('offer', 'ConditionalOffer')
8
+Applicator = get_class("offer.applicator", "Applicator")
9
+ConditionalOffer = get_model("offer", "ConditionalOffer")
10 10
 
11 11
 
12 12
 class BasketMessageGenerator(object):
13
-
14
-    new_total_template_name = 'oscar/basket/messages/new_total.html'
15
-    offer_lost_template_name = 'oscar/basket/messages/offer_lost.html'
16
-    offer_gained_template_name = 'oscar/basket/messages/offer_gained.html'
13
+    new_total_template_name = "oscar/basket/messages/new_total.html"
14
+    offer_lost_template_name = "oscar/basket/messages/offer_lost.html"
15
+    offer_gained_template_name = "oscar/basket/messages/offer_gained.html"
17 16
 
18 17
     def get_new_total_messages(self, basket, include_buttons=True):
19 18
         new_total_messages = []
20 19
         # We use the 'include_buttons' parameter to determine whether to show the
21 20
         # 'Checkout now' buttons.  We don't want to show these on the basket page.
22
-        msg = render_to_string(self.new_total_template_name,
23
-                               {'basket': basket,
24
-                                'include_buttons': include_buttons})
21
+        msg = render_to_string(
22
+            self.new_total_template_name,
23
+            {"basket": basket, "include_buttons": include_buttons},
24
+        )
25 25
         new_total_messages.append((messages.INFO, msg))
26 26
 
27 27
         return new_total_messages
@@ -30,7 +30,7 @@ class BasketMessageGenerator(object):
30 30
         offer_messages = []
31 31
         for offer_id in set(offers_before).difference(offers_after):
32 32
             offer = offers_before[offer_id]
33
-            msg = render_to_string(self.offer_lost_template_name, {'offer': offer})
33
+            msg = render_to_string(self.offer_lost_template_name, {"offer": offer})
34 34
             offer_messages.append((messages.WARNING, msg))
35 35
         return offer_messages
36 36
 
@@ -38,21 +38,23 @@ class BasketMessageGenerator(object):
38 38
         offer_messages = []
39 39
         for offer_id in set(offers_after).difference(offers_before):
40 40
             offer = offers_after[offer_id]
41
-            msg = render_to_string(self.offer_gained_template_name, {'offer': offer})
41
+            msg = render_to_string(self.offer_gained_template_name, {"offer": offer})
42 42
             offer_messages.append((messages.SUCCESS, msg))
43 43
         return offer_messages
44 44
 
45 45
     def get_offer_messages(self, offers_before, offers_after):
46 46
         offer_messages = []
47 47
         offer_messages.extend(self.get_offer_lost_messages(offers_before, offers_after))
48
-        offer_messages.extend(self.get_offer_gained_messages(offers_before, offers_after))
48
+        offer_messages.extend(
49
+            self.get_offer_gained_messages(offers_before, offers_after)
50
+        )
49 51
         return offer_messages
50 52
 
51 53
     def get_messages(self, basket, offers_before, offers_after, include_buttons=True):
52
-        messages = []
53
-        messages.extend(self.get_offer_messages(offers_before, offers_after))
54
-        messages.extend(self.get_new_total_messages(basket, include_buttons))
55
-        return messages
54
+        message_list = []
55
+        message_list.extend(self.get_offer_messages(offers_before, offers_after))
56
+        message_list.extend(self.get_new_total_messages(basket, include_buttons))
57
+        return message_list
56 58
 
57 59
     def apply_messages(self, request, offers_before):
58 60
         """
@@ -63,8 +65,10 @@ class BasketMessageGenerator(object):
63 65
         Applicator().apply(request.basket, request.user, request)
64 66
         offers_after = request.basket.applied_offers()
65 67
 
66
-        for level, msg in self.get_messages(request.basket, offers_before, offers_after):
67
-            messages.add_message(request, level, msg, extra_tags='safe noicon')
68
+        for level, msg in self.get_messages(
69
+            request.basket, offers_before, offers_after
70
+        ):
71
+            messages.add_message(request, level, msg, extra_tags="safe noicon")
68 72
 
69 73
 
70 74
 class LineOfferConsumer(object):
@@ -155,16 +159,17 @@ class LineOfferConsumer(object):
155 159
         max_affected_items = self._line.quantity
156 160
 
157 161
         if offer and isinstance(offer, ConditionalOffer):
158
-
159 162
             applied = [x for x in self.consumers if x != offer]
160 163
 
161 164
             if offer.exclusive:
162 165
                 for a in applied:
163 166
                     if a.exclusive:
164
-                        if any([
165
-                            a.priority > offer.priority,
166
-                            a.priority == offer.priority and a.id != offer.id
167
-                        ]):
167
+                        if any(
168
+                            [
169
+                                a.priority > offer.priority,
170
+                                a.priority == offer.priority and a.id != offer.id,
171
+                            ]
172
+                        ):
168 173
                             # Exclusive offers cannot be applied if any other exclusive
169 174
                             # offer with higher priority is active already.
170 175
                             max_affected_items = max_affected_items - self.consumed(a)

+ 178
- 164
src/oscar/apps/basket/views.py View File

@@ -11,62 +11,66 @@ from django.utils.translation import gettext_lazy as _
11 11
 from django.views.generic import FormView, View
12 12
 from extra_views import ModelFormSetView
13 13
 
14
-from oscar.apps.basket.signals import (
15
-    basket_addition, voucher_addition, voucher_removal)
14
+from oscar.apps.basket.signals import basket_addition, voucher_addition, voucher_removal
16 15
 from oscar.core import ajax
17 16
 from oscar.core.loading import get_class, get_classes, get_model
18 17
 from oscar.core.utils import is_ajax, redirect_to_referrer, safe_referrer
19 18
 
20
-Applicator = get_class('offer.applicator', 'Applicator')
19
+Applicator = get_class("offer.applicator", "Applicator")
21 20
 (BasketLineForm, AddToBasketForm, BasketVoucherForm, SavedLineForm) = get_classes(
22
-    'basket.forms', ('BasketLineForm', 'AddToBasketForm',
23
-                     'BasketVoucherForm', 'SavedLineForm'))
21
+    "basket.forms",
22
+    ("BasketLineForm", "AddToBasketForm", "BasketVoucherForm", "SavedLineForm"),
23
+)
24 24
 BasketLineFormSet, SavedLineFormSet = get_classes(
25
-    'basket.formsets', ('BasketLineFormSet', 'SavedLineFormSet'))
26
-Repository = get_class('shipping.repository', 'Repository')
25
+    "basket.formsets", ("BasketLineFormSet", "SavedLineFormSet")
26
+)
27
+Repository = get_class("shipping.repository", "Repository")
27 28
 
28
-OrderTotalCalculator = get_class(
29
-    'checkout.calculators', 'OrderTotalCalculator')
30
-BasketMessageGenerator = get_class('basket.utils', 'BasketMessageGenerator')
29
+OrderTotalCalculator = get_class("checkout.calculators", "OrderTotalCalculator")
30
+BasketMessageGenerator = get_class("basket.utils", "BasketMessageGenerator")
31 31
 SurchargeApplicator = get_class("checkout.applicator", "SurchargeApplicator")
32 32
 
33 33
 
34 34
 class BasketView(ModelFormSetView):
35
-    model = get_model('basket', 'Line')
36
-    basket_model = get_model('basket', 'Basket')
35
+    model = get_model("basket", "Line")
36
+    basket_model = get_model("basket", "Basket")
37 37
     formset_class = BasketLineFormSet
38 38
     form_class = BasketLineForm
39
-    factory_kwargs = {
40
-        'extra': 0,
41
-        'can_delete': True
42
-    }
43
-    template_name = 'oscar/basket/basket.html'
39
+    factory_kwargs = {"extra": 0, "can_delete": True}
40
+    template_name = "oscar/basket/basket.html"
44 41
 
45 42
     def get_formset_kwargs(self):
46 43
         kwargs = super().get_formset_kwargs()
47
-        kwargs['strategy'] = self.request.strategy
44
+        kwargs["strategy"] = self.request.strategy
48 45
         return kwargs
49 46
 
50 47
     def get_queryset(self):
51 48
         """
52 49
         Return list of :py:class:`Line <oscar.apps.basket.abstract_models.AbstractLine>`
53 50
         instances associated with the current basket.
54
-        """  # noqa: E501
51
+        """
55 52
         return self.request.basket.all_lines()
56 53
 
54
+    # pylint: disable=unused-argument
57 55
     def get_shipping_methods(self, basket):
58 56
         return Repository().get_shipping_methods(
59
-            basket=self.request.basket, user=self.request.user,
60
-            request=self.request)
57
+            basket=self.request.basket, user=self.request.user, request=self.request
58
+        )
61 59
 
62 60
     def get_default_shipping_address(self):
63 61
         if self.request.user.is_authenticated:
64
-            return self.request.user.addresses.filter(is_default_for_shipping=True).first()
62
+            return self.request.user.addresses.filter(
63
+                is_default_for_shipping=True
64
+            ).first()
65 65
 
66
+    # pylint: disable=unused-argument
66 67
     def get_default_shipping_method(self, basket):
67 68
         return Repository().get_default_shipping_method(
68
-            basket=self.request.basket, user=self.request.user,
69
-            request=self.request, shipping_addr=self.get_default_shipping_address())
69
+            basket=self.request.basket,
70
+            user=self.request.user,
71
+            request=self.request,
72
+            shipping_addr=self.get_default_shipping_address(),
73
+        )
70 74
 
71 75
     def get_basket_warnings(self, basket):
72 76
         """
@@ -80,16 +84,15 @@ class BasketView(ModelFormSetView):
80 84
         return warnings
81 85
 
82 86
     def get_upsell_messages(self, basket):
83
-        offers = Applicator().get_offers(basket, self.request.user,
84
-                                         self.request)
87
+        offers = Applicator().get_offers(basket, self.request.user, self.request)
85 88
         applied_offers = list(basket.offer_applications.offers.values())
86 89
         msgs = []
87 90
         for offer in offers:
88
-            if offer.is_condition_partially_satisfied(basket) \
89
-                    and offer not in applied_offers:
90
-                data = {
91
-                    'message': offer.get_upsell_message(basket),
92
-                    'offer': offer}
91
+            if (
92
+                offer.is_condition_partially_satisfied(basket)
93
+                and offer not in applied_offers
94
+            ):
95
+                data = {"message": offer.get_upsell_message(basket), "offer": offer}
93 96
                 msgs.append(data)
94 97
         return msgs
95 98
 
@@ -102,52 +105,53 @@ class BasketView(ModelFormSetView):
102 105
 
103 106
     def get_context_data(self, **kwargs):
104 107
         context = super().get_context_data(**kwargs)
105
-        context['voucher_form'] = self.get_basket_voucher_form()
108
+        context["voucher_form"] = self.get_basket_voucher_form()
106 109
 
107 110
         # Shipping information is included to give an idea of the total order
108 111
         # cost.  It is also important for PayPal Express where the customer
109 112
         # gets redirected away from the basket page and needs to see what the
110 113
         # estimated order total is beforehand.
111
-        context['shipping_methods'] = self.get_shipping_methods(
112
-            self.request.basket)
114
+        context["shipping_methods"] = self.get_shipping_methods(self.request.basket)
113 115
         method = self.get_default_shipping_method(self.request.basket)
114
-        context['shipping_method'] = method
116
+        context["shipping_method"] = method
115 117
         shipping_charge = method.calculate(self.request.basket)
116
-        context['shipping_charge'] = shipping_charge
118
+        context["shipping_charge"] = shipping_charge
117 119
         if method.is_discounted:
118 120
             excl_discount = method.calculate_excl_discount(self.request.basket)
119
-            context['shipping_charge_excl_discount'] = excl_discount
120
-        context['basket_warnings'] = self.get_basket_warnings(
121
-            self.request.basket)
122
-        context['upsell_messages'] = self.get_upsell_messages(
123
-            self.request.basket)
121
+            context["shipping_charge_excl_discount"] = excl_discount
122
+        context["basket_warnings"] = self.get_basket_warnings(self.request.basket)
123
+        context["upsell_messages"] = self.get_upsell_messages(self.request.basket)
124 124
 
125 125
         if self.request.user.is_authenticated:
126 126
             try:
127
-                saved_basket = self.basket_model.saved.get(
128
-                    owner=self.request.user)
127
+                saved_basket = self.basket_model.saved.get(owner=self.request.user)
129 128
             except self.basket_model.DoesNotExist:
130 129
                 pass
131 130
             else:
132 131
                 saved_basket.strategy = self.request.basket.strategy
133 132
                 if not saved_basket.is_empty:
134 133
                     saved_queryset = saved_basket.all_lines()
135
-                    formset = SavedLineFormSet(strategy=self.request.strategy,
136
-                                               basket=self.request.basket,
137
-                                               queryset=saved_queryset,
138
-                                               prefix='saved')
139
-                    context['saved_formset'] = formset
140
-
141
-        surcharges = SurchargeApplicator(self.request, context).get_applicable_surcharges(
134
+                    formset = SavedLineFormSet(
135
+                        strategy=self.request.strategy,
136
+                        basket=self.request.basket,
137
+                        queryset=saved_queryset,
138
+                        prefix="saved",
139
+                    )
140
+                    context["saved_formset"] = formset
141
+
142
+        surcharges = SurchargeApplicator(
143
+            self.request, context
144
+        ).get_applicable_surcharges(
142 145
             self.request.basket, shipping_charge=shipping_charge
143 146
         )
144
-        context['surcharges'] = surcharges
145
-        context['order_total'] = OrderTotalCalculator().calculate(
146
-            self.request.basket, shipping_charge, surcharges=surcharges)
147
+        context["surcharges"] = surcharges
148
+        context["order_total"] = OrderTotalCalculator().calculate(
149
+            self.request.basket, shipping_charge, surcharges=surcharges
150
+        )
147 151
         return context
148 152
 
149 153
     def get_success_url(self):
150
-        return safe_referrer(self.request, 'basket:summary')
154
+        return safe_referrer(self.request, "basket:summary")
151 155
 
152 156
     def formset_valid(self, formset):
153 157
         # Store offers before any changes are made so we can inform the user of
@@ -161,21 +165,21 @@ class BasketView(ModelFormSetView):
161 165
         flash_messages = ajax.FlashMessages()
162 166
 
163 167
         for form in formset:
164
-            if (hasattr(form, 'cleaned_data')
165
-                    and form.cleaned_data.get('save_for_later', False)):
168
+            if hasattr(form, "cleaned_data") and form.cleaned_data.get(
169
+                "save_for_later", False
170
+            ):
166 171
                 line = form.instance
167 172
                 if self.request.user.is_authenticated:
168 173
                     self.move_line_to_saved_basket(line)
169 174
 
170 175
                     msg = render_to_string(
171
-                        'oscar/basket/messages/line_saved.html',
172
-                        {'line': line})
176
+                        "oscar/basket/messages/line_saved.html", {"line": line}
177
+                    )
173 178
                     flash_messages.info(msg)
174 179
 
175 180
                     save_for_later = True
176 181
                 else:
177
-                    msg = _("You can't save an item for later if you're "
178
-                            "not logged in!")
182
+                    msg = _("You can't save an item for later if you're not logged in!")
179 183
                     flash_messages.error(msg)
180 184
                     return redirect(self.get_success_url())
181 185
 
@@ -189,15 +193,16 @@ class BasketView(ModelFormSetView):
189 193
         # If AJAX submission, don't redirect but reload the basket content HTML
190 194
         if is_ajax(self.request):
191 195
             # Reload basket and apply offers again
192
-            self.request.basket = get_model('basket', 'Basket').objects.get(
193
-                id=self.request.basket.id)
196
+            self.request.basket = get_model("basket", "Basket").objects.get(
197
+                id=self.request.basket.id
198
+            )
194 199
             self.request.basket.strategy = self.request.strategy
195
-            Applicator().apply(self.request.basket, self.request.user,
196
-                               self.request)
200
+            Applicator().apply(self.request.basket, self.request.user, self.request)
197 201
             offers_after = self.request.basket.applied_offers()
198 202
 
199 203
             for level, msg in BasketMessageGenerator().get_messages(
200
-                    self.request.basket, offers_before, offers_after, include_buttons=False):
204
+                self.request.basket, offers_before, offers_after, include_buttons=False
205
+            ):
201 206
                 flash_messages.add_message(level, msg)
202 207
 
203 208
             # Reload formset - we have to remove the POST fields from the
@@ -205,14 +210,12 @@ class BasketView(ModelFormSetView):
205 210
             # correctly as there will be a state mismatch between the
206 211
             # management form and the database.
207 212
             kwargs = self.get_formset_kwargs()
208
-            del kwargs['data']
209
-            del kwargs['files']
210
-            if 'queryset' in kwargs:
211
-                del kwargs['queryset']
212
-            formset = self.get_formset()(queryset=self.get_queryset(),
213
-                                         **kwargs)
214
-            ctx = self.get_context_data(formset=formset,
215
-                                        basket=self.request.basket)
213
+            del kwargs["data"]
214
+            del kwargs["files"]
215
+            if "queryset" in kwargs:
216
+                del kwargs["queryset"]
217
+            formset = self.get_formset()(queryset=self.get_queryset(), **kwargs)
218
+            ctx = self.get_context_data(formset=formset, basket=self.request.basket)
216 219
             return self.json_response(ctx, flash_messages)
217 220
 
218 221
         BasketMessageGenerator().apply_messages(self.request, offers_before)
@@ -221,36 +224,42 @@ class BasketView(ModelFormSetView):
221 224
 
222 225
     def json_response(self, ctx, flash_messages):
223 226
         basket_html = render_to_string(
224
-            'oscar/basket/partials/basket_content.html',
225
-            context=ctx, request=self.request)
227
+            "oscar/basket/partials/basket_content.html",
228
+            context=ctx,
229
+            request=self.request,
230
+        )
226 231
 
227
-        return JsonResponse({
228
-            'content_html': basket_html,
229
-            'messages': flash_messages.as_dict()
230
-        })
232
+        return JsonResponse(
233
+            {"content_html": basket_html, "messages": flash_messages.as_dict()}
234
+        )
231 235
 
232 236
     def move_line_to_saved_basket(self, line):
233
-        saved_basket, _ = get_model('basket', 'basket').saved.get_or_create(
234
-            owner=self.request.user)
237
+        saved_basket, _ = get_model("basket", "basket").saved.get_or_create(
238
+            owner=self.request.user
239
+        )
235 240
         saved_basket.merge_line(line)
236 241
 
237 242
     def formset_invalid(self, formset):
238 243
         has_deletion = any(formset._should_delete_form(form) for form in formset.forms)
239
-        has_no_invalid_non_deletion = all(form.is_valid() or formset._should_delete_form(form)
240
-                                          for form in formset.forms)
244
+        has_no_invalid_non_deletion = all(
245
+            form.is_valid() or formset._should_delete_form(form)
246
+            for form in formset.forms
247
+        )
241 248
         if has_deletion:
242 249
             self.remove_deleted_forms(formset)
243 250
             if has_no_invalid_non_deletion:
244 251
                 return self.formset_valid(formset)
245 252
 
246 253
         flash_messages = ajax.FlashMessages()
247
-        flash_messages.warning(_(
248
-            "Your basket has got some issues. "
249
-            "Please correct any validation errors below."))
254
+        flash_messages.warning(
255
+            _(
256
+                "Your basket has got some issues. "
257
+                "Please correct any validation errors below."
258
+            )
259
+        )
250 260
 
251 261
         if is_ajax(self.request):
252
-            ctx = self.get_context_data(formset=formset,
253
-                                        basket=self.request.basket)
262
+            ctx = self.get_context_data(formset=formset, basket=self.request.basket)
254 263
             return self.json_response(ctx, flash_messages)
255 264
 
256 265
         flash_messages.apply_to_request(self.request)
@@ -282,13 +291,15 @@ class BasketView(ModelFormSetView):
282 291
                     form.prefix = new_form_prefix
283 292
                     new_prefixed_field_name = form.add_prefix(field_name)
284 293
                     try:
285
-                        form_data[new_prefixed_field_name] = formset.data[old_prefixed_field_name]
294
+                        form_data[new_prefixed_field_name] = formset.data[
295
+                            old_prefixed_field_name
296
+                        ]
286 297
                     except KeyError:
287 298
                         pass
288 299
                 form_index += 1
289 300
         for field_name in formset.management_form.fields:
290 301
             prefixed_field_name = formset.management_form.add_prefix(field_name)
291
-            if field_name in ['INITIAL_FORMS', 'TOTAL_FORMS']:
302
+            if field_name in ["INITIAL_FORMS", "TOTAL_FORMS"]:
292 303
                 form_data[prefixed_field_name] = str(form_index)
293 304
             else:
294 305
                 form_data[prefixed_field_name] = formset.data[prefixed_field_name]
@@ -310,27 +321,28 @@ class BasketAddView(FormView):
310 321
     parts of the site. The add-to-basket form is loaded into templates using
311 322
     a templatetag from :py:mod:`oscar.templatetags.basket_tags`.
312 323
     """
324
+
313 325
     form_class = AddToBasketForm
314
-    product_model = get_model('catalogue', 'product')
326
+    product_model = get_model("catalogue", "product")
315 327
     add_signal = basket_addition
316
-    http_method_names = ['post']
328
+    http_method_names = ["post"]
317 329
 
318 330
     def post(self, request, *args, **kwargs):
319
-        self.product = shortcuts.get_object_or_404(
320
-            self.product_model, pk=kwargs['pk'])
331
+        # pylint: disable=W0201
332
+        self.product = shortcuts.get_object_or_404(self.product_model, pk=kwargs["pk"])
321 333
         return super().post(request, *args, **kwargs)
322 334
 
323 335
     def get_form_kwargs(self):
324 336
         kwargs = super().get_form_kwargs()
325
-        kwargs['basket'] = self.request.basket
326
-        kwargs['product'] = self.product
337
+        kwargs["basket"] = self.request.basket
338
+        kwargs["product"] = self.product
327 339
         return kwargs
328 340
 
329 341
     def form_invalid(self, form):
330 342
         msgs = []
331 343
         for error in form.errors.values():
332 344
             msgs.append(error.as_text())
333
-        clean_msgs = [m.replace('* ', '') for m in msgs if m.startswith('* ')]
345
+        clean_msgs = [m.replace("* ", "") for m in msgs if m.startswith("* ")]
334 346
         messages.error(self.request, ",".join(clean_msgs))
335 347
 
336 348
         # We serialize the POST data with JSONSerializer before adding it to the session.
@@ -338,64 +350,72 @@ class BasketAddView(FormView):
338 350
         # if the SESSION_SERIALIZER has been configured to 'django.contrib.sessions.serializers.PickleSerializer'.
339 351
         # see: https://docs.djangoproject.com/en/3.2/topics/http/sessions/#cookie-session-backend
340 352
         serialized_data = JSONSerializer().dumps(self.request.POST)
341
-        self.request.session["add_to_basket_form_post_data_%s" % self.product.pk] = serialized_data.decode("latin-1")
353
+        self.request.session[
354
+            "add_to_basket_form_post_data_%s" % self.product.pk
355
+        ] = serialized_data.decode("latin-1")
342 356
 
343
-        return redirect_to_referrer(self.request, 'basket:summary')
357
+        return redirect_to_referrer(self.request, "basket:summary")
344 358
 
345 359
     def form_valid(self, form):
346 360
         offers_before = self.request.basket.applied_offers()
347 361
 
348 362
         self.request.basket.add_product(
349
-            form.product, form.cleaned_data['quantity'],
350
-            form.cleaned_options())
363
+            form.product, form.cleaned_data["quantity"], form.cleaned_options()
364
+        )
351 365
 
352
-        messages.success(self.request, self.get_success_message(form),
353
-                         extra_tags='safe noicon')
366
+        messages.success(
367
+            self.request, self.get_success_message(form), extra_tags="safe noicon"
368
+        )
354 369
 
355 370
         # Check for additional offer messages
356 371
         BasketMessageGenerator().apply_messages(self.request, offers_before)
357 372
 
358 373
         # Send signal for basket addition
359 374
         self.add_signal.send(
360
-            sender=self, product=form.product, user=self.request.user,
361
-            request=self.request)
375
+            sender=self,
376
+            product=form.product,
377
+            user=self.request.user,
378
+            request=self.request,
379
+        )
362 380
 
363 381
         return super().form_valid(form)
364 382
 
365 383
     def get_success_message(self, form):
366 384
         return render_to_string(
367
-            'oscar/basket/messages/addition.html',
368
-            {'product': form.product,
369
-             'quantity': form.cleaned_data['quantity']})
385
+            "oscar/basket/messages/addition.html",
386
+            {"product": form.product, "quantity": form.cleaned_data["quantity"]},
387
+        )
370 388
 
371 389
     def get_success_url(self):
372
-        post_url = self.request.POST.get('next')
373
-        if post_url and url_has_allowed_host_and_scheme(post_url, self.request.get_host()):
390
+        post_url = self.request.POST.get("next")
391
+        if post_url and url_has_allowed_host_and_scheme(
392
+            post_url, self.request.get_host()
393
+        ):
374 394
             return post_url
375
-        return safe_referrer(self.request, 'basket:summary')
395
+        return safe_referrer(self.request, "basket:summary")
376 396
 
377 397
 
378 398
 class VoucherAddView(FormView):
379 399
     form_class = BasketVoucherForm
380
-    voucher_model = get_model('voucher', 'voucher')
400
+    voucher_model = get_model("voucher", "voucher")
381 401
     add_signal = voucher_addition
382 402
 
383 403
     def get(self, request, *args, **kwargs):
384
-        return redirect('basket:summary')
404
+        return redirect("basket:summary")
385 405
 
386 406
     def apply_voucher_to_basket(self, voucher):
387 407
         if voucher.is_expired():
388 408
             messages.error(
389 409
                 self.request,
390
-                _("The '%(code)s' voucher has expired") % {
391
-                    'code': voucher.code})
410
+                _("The '%(code)s' voucher has expired") % {"code": voucher.code},
411
+            )
392 412
             return
393 413
 
394 414
         if not voucher.is_active():
395 415
             messages.error(
396 416
                 self.request,
397
-                _("The '%(code)s' voucher is not active") % {
398
-                    'code': voucher.code})
417
+                _("The '%(code)s' voucher is not active") % {"code": voucher.code},
418
+            )
399 419
             return
400 420
 
401 421
         is_available, message = voucher.is_available_to_user(self.request.user)
@@ -406,66 +426,65 @@ class VoucherAddView(FormView):
406 426
         self.request.basket.vouchers.add(voucher)
407 427
 
408 428
         # Raise signal
409
-        self.add_signal.send(
410
-            sender=self, basket=self.request.basket, voucher=voucher)
429
+        self.add_signal.send(sender=self, basket=self.request.basket, voucher=voucher)
411 430
 
412 431
         # Recalculate discounts to see if the voucher gives any
413
-        Applicator().apply(self.request.basket, self.request.user,
414
-                           self.request)
432
+        Applicator().apply(self.request.basket, self.request.user, self.request)
415 433
         discounts_after = self.request.basket.offer_applications
416 434
 
417 435
         # Look for discounts from this new voucher
418 436
         found_discount = False
419 437
         for discount in discounts_after:
420
-            if discount['voucher'] and discount['voucher'] == voucher:
438
+            if discount["voucher"] and discount["voucher"] == voucher:
421 439
                 found_discount = True
422 440
                 break
423 441
         if not found_discount:
424 442
             messages.warning(
425
-                self.request,
426
-                _("Your basket does not qualify for a voucher discount"))
443
+                self.request, _("Your basket does not qualify for a voucher discount")
444
+            )
427 445
             self.request.basket.vouchers.remove(voucher)
428 446
         else:
429 447
             messages.info(
430 448
                 self.request,
431
-                _("Voucher '%(code)s' added to basket") % {
432
-                    'code': voucher.code})
449
+                _("Voucher '%(code)s' added to basket") % {"code": voucher.code},
450
+            )
433 451
 
434 452
     def form_valid(self, form):
435
-        code = form.cleaned_data['code']
453
+        code = form.cleaned_data["code"]
436 454
         if not self.request.basket.id:
437
-            return redirect_to_referrer(self.request, 'basket:summary')
455
+            return redirect_to_referrer(self.request, "basket:summary")
438 456
         if self.request.basket.contains_voucher(code):
439 457
             messages.error(
440 458
                 self.request,
441
-                _("You have already added the '%(code)s' voucher to "
442
-                  "your basket") % {'code': code})
459
+                _("You have already added the '%(code)s' voucher to your basket")
460
+                % {"code": code},
461
+            )
443 462
         else:
444 463
             try:
445 464
                 voucher = self.voucher_model._default_manager.get(code=code)
446 465
             except self.voucher_model.DoesNotExist:
447 466
                 messages.error(
448 467
                     self.request,
449
-                    _("No voucher found with code '%(code)s'") % {
450
-                        'code': code})
468
+                    _("No voucher found with code '%(code)s'") % {"code": code},
469
+                )
451 470
             else:
452 471
                 self.apply_voucher_to_basket(voucher)
453
-        return redirect_to_referrer(self.request, 'basket:summary')
472
+        return redirect_to_referrer(self.request, "basket:summary")
454 473
 
455 474
     def form_invalid(self, form):
456 475
         messages.error(self.request, _("Please enter a voucher code"))
457
-        return redirect(reverse('basket:summary') + '#voucher')
476
+        return redirect(reverse("basket:summary") + "#voucher")
458 477
 
459 478
 
460 479
 class VoucherRemoveView(View):
461
-    voucher_model = get_model('voucher', 'voucher')
480
+    voucher_model = get_model("voucher", "voucher")
462 481
     remove_signal = voucher_removal
463
-    http_method_names = ['post']
482
+    http_method_names = ["post"]
464 483
 
465 484
     def post(self, request, *args, **kwargs):
466
-        response = redirect('basket:summary')
485
+        response = redirect("basket:summary")
467 486
 
468
-        voucher_id = kwargs['pk']
487
+        voucher_id = kwargs["pk"]
469 488
         if not request.basket.id:
470 489
             # Hacking attempt - the basket must be saved for it to have
471 490
             # a voucher in it.
@@ -473,37 +492,31 @@ class VoucherRemoveView(View):
473 492
         try:
474 493
             voucher = request.basket.vouchers.get(id=voucher_id)
475 494
         except ObjectDoesNotExist:
476
-            messages.error(
477
-                request, _("No voucher found with id '%s'") % voucher_id)
495
+            messages.error(request, _("No voucher found with id '%s'") % voucher_id)
478 496
         else:
479 497
             request.basket.vouchers.remove(voucher)
480
-            self.remove_signal.send(
481
-                sender=self, basket=request.basket, voucher=voucher)
482
-            messages.info(
483
-                request, _("Voucher '%s' removed from basket") % voucher.code)
498
+            self.remove_signal.send(sender=self, basket=request.basket, voucher=voucher)
499
+            messages.info(request, _("Voucher '%s' removed from basket") % voucher.code)
484 500
 
485 501
         return response
486 502
 
487 503
 
488 504
 class SavedView(ModelFormSetView):
489
-    model = get_model('basket', 'line')
490
-    basket_model = get_model('basket', 'basket')
505
+    model = get_model("basket", "line")
506
+    basket_model = get_model("basket", "basket")
491 507
     formset_class = SavedLineFormSet
492 508
     form_class = SavedLineForm
493
-    factory_kwargs = {
494
-        'extra': 0,
495
-        'can_delete': True
496
-    }
509
+    factory_kwargs = {"extra": 0, "can_delete": True}
497 510
 
498 511
     def get(self, request, *args, **kwargs):
499
-        return redirect('basket:summary')
512
+        return redirect("basket:summary")
500 513
 
501 514
     def get_queryset(self):
502 515
         """
503 516
         Return list of :py:class:`Line <oscar.apps.basket.abstract_models.AbstractLine>`
504 517
         instances associated with the saved basked associated with the currently
505 518
         authenticated user.
506
-        """  # noqa: E501
519
+        """
507 520
         try:
508 521
             saved_basket = self.basket_model.saved.get(owner=self.request.user)
509 522
             saved_basket.strategy = self.request.strategy
@@ -512,13 +525,13 @@ class SavedView(ModelFormSetView):
512 525
             return []
513 526
 
514 527
     def get_success_url(self):
515
-        return safe_referrer(self.request, 'basket:summary')
528
+        return safe_referrer(self.request, "basket:summary")
516 529
 
517 530
     def get_formset_kwargs(self):
518 531
         kwargs = super().get_formset_kwargs()
519
-        kwargs['prefix'] = 'saved'
520
-        kwargs['basket'] = self.request.basket
521
-        kwargs['strategy'] = self.request.strategy
532
+        kwargs["prefix"] = "saved"
533
+        kwargs["basket"] = self.request.basket
534
+        kwargs["strategy"] = self.request.strategy
522 535
         return kwargs
523 536
 
524 537
     def formset_valid(self, formset):
@@ -526,12 +539,12 @@ class SavedView(ModelFormSetView):
526 539
 
527 540
         is_move = False
528 541
         for form in formset:
529
-            if form.cleaned_data.get('move_to_basket', False):
542
+            if form.cleaned_data.get("move_to_basket", False):
530 543
                 is_move = True
531 544
                 msg = render_to_string(
532
-                    'oscar/basket/messages/line_restored.html',
533
-                    {'line': form.instance})
534
-                messages.info(self.request, msg, extra_tags='safe noicon')
545
+                    "oscar/basket/messages/line_restored.html", {"line": form.instance}
546
+                )
547
+                messages.info(self.request, msg, extra_tags="safe noicon")
535 548
                 real_basket = self.request.basket
536 549
                 real_basket.merge_line(form.instance)
537 550
 
@@ -547,7 +560,8 @@ class SavedView(ModelFormSetView):
547 560
     def formset_invalid(self, formset):
548 561
         messages.error(
549 562
             self.request,
550
-            '\n'.join(
551
-                error for ed in formset.errors for el
552
-                in ed.values() for error in el))
553
-        return redirect_to_referrer(self.request, 'basket:summary')
563
+            "\n".join(
564
+                error for ed in formset.errors for el in ed.values() for error in el
565
+            ),
566
+        )
567
+        return redirect_to_referrer(self.request, "basket:summary")

+ 1
- 1
src/oscar/apps/catalogue/__init__.py View File

@@ -1 +1 @@
1
-default_app_config = 'oscar.apps.catalogue.apps.CatalogueConfig'
1
+default_app_config = "oscar.apps.catalogue.apps.CatalogueConfig"

+ 377
- 267
src/oscar/apps/catalogue/abstract_models.py
File diff suppressed because it is too large
View File


+ 37
- 32
src/oscar/apps/catalogue/admin.py View File

@@ -4,17 +4,17 @@ from treebeard.forms import movenodeform_factory
4 4
 
5 5
 from oscar.core.loading import get_model
6 6
 
7
-AttributeOption = get_model('catalogue', 'AttributeOption')
8
-AttributeOptionGroup = get_model('catalogue', 'AttributeOptionGroup')
9
-Category = get_model('catalogue', 'Category')
10
-Option = get_model('catalogue', 'Option')
11
-Product = get_model('catalogue', 'Product')
12
-ProductAttribute = get_model('catalogue', 'ProductAttribute')
13
-ProductAttributeValue = get_model('catalogue', 'ProductAttributeValue')
14
-ProductCategory = get_model('catalogue', 'ProductCategory')
15
-ProductClass = get_model('catalogue', 'ProductClass')
16
-ProductImage = get_model('catalogue', 'ProductImage')
17
-ProductRecommendation = get_model('catalogue', 'ProductRecommendation')
7
+AttributeOption = get_model("catalogue", "AttributeOption")
8
+AttributeOptionGroup = get_model("catalogue", "AttributeOptionGroup")
9
+Category = get_model("catalogue", "Category")
10
+Option = get_model("catalogue", "Option")
11
+Product = get_model("catalogue", "Product")
12
+ProductAttribute = get_model("catalogue", "ProductAttribute")
13
+ProductAttributeValue = get_model("catalogue", "ProductAttributeValue")
14
+ProductCategory = get_model("catalogue", "ProductCategory")
15
+ProductClass = get_model("catalogue", "ProductClass")
16
+ProductImage = get_model("catalogue", "ProductImage")
17
+ProductRecommendation = get_model("catalogue", "ProductRecommendation")
18 18
 
19 19
 
20 20
 class AttributeInline(admin.TabularInline):
@@ -23,8 +23,8 @@ class AttributeInline(admin.TabularInline):
23 23
 
24 24
 class ProductRecommendationInline(admin.TabularInline):
25 25
     model = ProductRecommendation
26
-    fk_name = 'primary'
27
-    raw_id_fields = ['primary', 'recommendation']
26
+    fk_name = "primary"
27
+    raw_id_fields = ["primary", "recommendation"]
28 28
 
29 29
 
30 30
 class CategoryInline(admin.TabularInline):
@@ -38,33 +38,36 @@ class ProductAttributeInline(admin.TabularInline):
38 38
 
39 39
 
40 40
 class ProductClassAdmin(admin.ModelAdmin):
41
-    list_display = ('name', 'requires_shipping', 'track_stock')
41
+    list_display = ("name", "requires_shipping", "track_stock")
42 42
     inlines = [ProductAttributeInline]
43 43
 
44 44
 
45 45
 class ProductAdmin(admin.ModelAdmin):
46
-    date_hierarchy = 'date_created'
47
-    list_display = ('get_title', 'upc', 'get_product_class', 'structure',
48
-                    'attribute_summary', 'date_created')
49
-    list_filter = ['structure', 'is_discountable']
50
-    raw_id_fields = ['parent']
46
+    date_hierarchy = "date_created"
47
+    list_display = (
48
+        "get_title",
49
+        "upc",
50
+        "get_product_class",
51
+        "structure",
52
+        "attribute_summary",
53
+        "date_created",
54
+    )
55
+    list_filter = ["structure", "is_discountable"]
56
+    raw_id_fields = ["parent"]
51 57
     inlines = [AttributeInline, CategoryInline, ProductRecommendationInline]
52 58
     prepopulated_fields = {"slug": ("title",)}
53
-    search_fields = ['upc', 'title']
59
+    search_fields = ["upc", "title"]
54 60
 
55 61
     def get_queryset(self, request):
56 62
         qs = super().get_queryset(request)
57
-        return (
58
-            qs
59
-            .select_related('product_class', 'parent')
60
-            .prefetch_related(
61
-                'attribute_values',
62
-                'attribute_values__attribute'))
63
+        return qs.select_related("product_class", "parent").prefetch_related(
64
+            "attribute_values", "attribute_values__attribute"
65
+        )
63 66
 
64 67
 
65 68
 class ProductAttributeAdmin(admin.ModelAdmin):
66
-    list_display = ('name', 'code', 'product_class', 'type')
67
-    prepopulated_fields = {"code": ("name", )}
69
+    list_display = ("name", "code", "product_class", "type")
70
+    prepopulated_fields = {"code": ("name",)}
68 71
 
69 72
 
70 73
 class OptionAdmin(admin.ModelAdmin):
@@ -72,7 +75,7 @@ class OptionAdmin(admin.ModelAdmin):
72 75
 
73 76
 
74 77
 class ProductAttributeValueAdmin(admin.ModelAdmin):
75
-    list_display = ('product', 'attribute', 'value')
78
+    list_display = ("product", "attribute", "value")
76 79
 
77 80
 
78 81
 class AttributeOptionInline(admin.TabularInline):
@@ -80,13 +83,15 @@ class AttributeOptionInline(admin.TabularInline):
80 83
 
81 84
 
82 85
 class AttributeOptionGroupAdmin(admin.ModelAdmin):
83
-    list_display = ('name', 'option_summary')
84
-    inlines = [AttributeOptionInline, ]
86
+    list_display = ("name", "option_summary")
87
+    inlines = [
88
+        AttributeOptionInline,
89
+    ]
85 90
 
86 91
 
87 92
 class CategoryAdmin(TreeAdmin):
88 93
     form = movenodeform_factory(Category)
89
-    list_display = ('name', 'slug')
94
+    list_display = ("name", "slug")
90 95
 
91 96
 
92 97
 admin.site.register(ProductClass, ProductClassAdmin)

+ 30
- 21
src/oscar/apps/catalogue/apps.py View File

@@ -7,53 +7,62 @@ from oscar.core.loading import get_class
7 7
 
8 8
 
9 9
 class CatalogueOnlyConfig(OscarConfig):
10
-    label = 'catalogue'
11
-    name = 'oscar.apps.catalogue'
12
-    verbose_name = _('Catalogue')
10
+    label = "catalogue"
11
+    name = "oscar.apps.catalogue"
12
+    verbose_name = _("Catalogue")
13 13
 
14
-    namespace = 'catalogue'
14
+    namespace = "catalogue"
15 15
 
16
+    # pylint: disable=attribute-defined-outside-init, unused-import
16 17
     def ready(self):
17
-        from . import receivers  # noqa
18
+        from . import receivers
18 19
 
19 20
         super().ready()
20 21
 
21
-        self.detail_view = get_class('catalogue.views', 'ProductDetailView')
22
-        self.catalogue_view = get_class('catalogue.views', 'CatalogueView')
23
-        self.category_view = get_class('catalogue.views', 'ProductCategoryView')
24
-        self.range_view = get_class('offer.views', 'RangeDetailView')
22
+        self.detail_view = get_class("catalogue.views", "ProductDetailView")
23
+        self.catalogue_view = get_class("catalogue.views", "CatalogueView")
24
+        self.category_view = get_class("catalogue.views", "ProductCategoryView")
25
+        self.range_view = get_class("offer.views", "RangeDetailView")
25 26
 
26 27
     def get_urls(self):
27 28
         urls = super().get_urls()
28 29
         urls += [
29
-            path('', self.catalogue_view.as_view(), name='index'),
30
+            path("", self.catalogue_view.as_view(), name="index"),
30 31
             re_path(
31
-                r'^(?P<product_slug>[\w-]*)_(?P<pk>\d+)/$',
32
-                self.detail_view.as_view(), name='detail'),
32
+                r"^(?P<product_slug>[\w-]*)_(?P<pk>\d+)/$",
33
+                self.detail_view.as_view(),
34
+                name="detail",
35
+            ),
33 36
             re_path(
34
-                r'^category/(?P<category_slug>[\w-]+(/[\w-]+)*)_(?P<pk>\d+)/$',
35
-                self.category_view.as_view(), name='category'),
36
-            path('ranges/<slug:slug>/', self.range_view.as_view(), name='range'),
37
+                r"^category/(?P<category_slug>[\w-]+(/[\w-]+)*)_(?P<pk>\d+)/$",
38
+                self.category_view.as_view(),
39
+                name="category",
40
+            ),
41
+            path("ranges/<slug:slug>/", self.range_view.as_view(), name="range"),
37 42
         ]
38 43
         return self.post_process_urls(urls)
39 44
 
40 45
 
41 46
 class CatalogueReviewsOnlyConfig(OscarConfig):
42
-    label = 'catalogue'
43
-    name = 'oscar.apps.catalogue'
44
-    verbose_name = _('Catalogue')
47
+    label = "catalogue"
48
+    name = "oscar.apps.catalogue"
49
+    verbose_name = _("Catalogue")
45 50
 
51
+    # pylint: disable=attribute-defined-outside-init, unused-import
46 52
     def ready(self):
47
-        from . import receivers  # noqa
53
+        from . import receivers
48 54
 
49 55
         super().ready()
50 56
 
51
-        self.reviews_app = apps.get_app_config('reviews')
57
+        self.reviews_app = apps.get_app_config("reviews")
52 58
 
53 59
     def get_urls(self):
54 60
         urls = super().get_urls()
55 61
         urls += [
56
-            re_path(r'^(?P<product_slug>[\w-]*)_(?P<product_pk>\d+)/reviews/', include(self.reviews_app.urls[0])),
62
+            re_path(
63
+                r"^(?P<product_slug>[\w-]*)_(?P<product_pk>\d+)/reviews/",
64
+                include(self.reviews_app.urls[0]),
65
+            ),
57 66
         ]
58 67
         return self.post_process_urls(urls)
59 68
 

+ 12
- 8
src/oscar/apps/catalogue/categories.py View File

@@ -1,6 +1,6 @@
1 1
 from oscar.core.loading import get_model
2 2
 
3
-Category = get_model('catalogue', 'category')
3
+Category = get_model("catalogue", "category")
4 4
 
5 5
 
6 6
 def create_from_sequence(bits):
@@ -16,9 +16,9 @@ def create_from_sequence(bits):
16 16
         except Category.DoesNotExist:
17 17
             root = Category.add_root(name=name)
18 18
         except Category.MultipleObjectsReturned:
19
-            raise ValueError((
20
-                "There are more than one categories with name "
21
-                "%s at depth=1") % name)
19
+            raise ValueError(
20
+                ("There are more than one categories with name %s at depth=1") % name
21
+            )
22 22
         return [root]
23 23
     else:
24 24
         parents = create_from_sequence(bits[:-1])
@@ -28,14 +28,18 @@ def create_from_sequence(bits):
28 28
         except Category.DoesNotExist:
29 29
             child = parent.add_child(name=name)
30 30
         except Category.MultipleObjectsReturned:
31
-            raise ValueError((
32
-                "There are more than one categories with name "
33
-                "%s which are children of %s") % (name, parent))
31
+            raise ValueError(
32
+                (
33
+                    "There are more than one categories with name "
34
+                    "%s which are children of %s"
35
+                )
36
+                % (name, parent)
37
+            )
34 38
         parents.append(child)
35 39
         return parents
36 40
 
37 41
 
38
-def create_from_breadcrumbs(breadcrumb_str, separator='>'):
42
+def create_from_breadcrumbs(breadcrumb_str, separator=">"):
39 43
     """
40 44
     Create categories from a breadcrumb string
41 45
     """

+ 2
- 0
src/oscar/apps/catalogue/expressions.py View File

@@ -20,6 +20,7 @@ WHERE "CATALOGUE_CATEGORY_BASE"."id" IN (%(subquery)s))
20 20
 """
21 21
 
22 22
 
23
+# pylint: disable=abstract-method
23 24
 class ExpandUpwardsCategoryQueryset(Subquery):
24 25
     template = EXPAND_UPWARDS_CATEGORY_QUERY
25 26
 
@@ -27,6 +28,7 @@ class ExpandUpwardsCategoryQueryset(Subquery):
27 28
         return super().as_sql(compiler, connection, self.template[1:-1])
28 29
 
29 30
 
31
+# pylint: disable=abstract-method
30 32
 class ExpandDownwardsCategoryQueryset(Subquery):
31 33
     template = EXPAND_DOWNWARDS_CATEGORY_QUERY
32 34
 

+ 22
- 10
src/oscar/apps/catalogue/managers.py View File

@@ -57,7 +57,9 @@ class AttributeFilter(dict):
57 57
 
58 58
         for code, (lookup, value) in self.items():
59 59
             selected_values = self._select_value(typedict[code], lookup, value)
60
-            if not selected_values:  # if no value clause can be formed, no result can be formed.
60
+            if (
61
+                not selected_values
62
+            ):  # if no value clause can be formed, no result can be formed.
61 63
                 return queryset.none()
62 64
 
63 65
             qs = qs.filter(
@@ -69,7 +71,6 @@ class AttributeFilter(dict):
69 71
 
70 72
 
71 73
 class ProductQuerySet(models.query.QuerySet):
72
-
73 74
     def filter_by_attributes(self, **filter_kwargs):
74 75
         """
75 76
         Allows querying by attribute as if the attributes where fields on the
@@ -93,13 +94,25 @@ class ProductQuerySet(models.query.QuerySet):
93 94
         Applies select_related and prefetch_related for commonly related
94 95
         models to save on queries
95 96
         """
96
-        Option = get_model('catalogue', 'Option')
97
-        product_class_options = Option.objects.filter(productclass=OuterRef('product_class'))
98
-        product_options = Option.objects.filter(product=OuterRef('pk'))
99
-        return self.select_related('product_class')\
100
-            .prefetch_related('children', 'product_options', 'product_class__options', 'stockrecords', 'images') \
101
-            .annotate(has_product_class_options=Exists(product_class_options),
102
-                      has_product_options=Exists(product_options))
97
+        Option = get_model("catalogue", "Option")
98
+        product_class_options = Option.objects.filter(
99
+            productclass=OuterRef("product_class")
100
+        )
101
+        product_options = Option.objects.filter(product=OuterRef("pk"))
102
+        return (
103
+            self.select_related("product_class")
104
+            .prefetch_related(
105
+                "children",
106
+                "product_options",
107
+                "product_class__options",
108
+                "stockrecords",
109
+                "images",
110
+            )
111
+            .annotate(
112
+                has_product_class_options=Exists(product_class_options),
113
+                has_product_options=Exists(product_options),
114
+            )
115
+        )
103 116
 
104 117
     def browsable(self):
105 118
         """
@@ -123,7 +136,6 @@ class ProductQuerySet(models.query.QuerySet):
123 136
 
124 137
 
125 138
 class CategoryQuerySet(MP_NodeQuerySet):
126
-
127 139
     def browsable(self):
128 140
         """
129 141
         Excludes non-public categories

+ 37
- 24
src/oscar/apps/catalogue/models.py View File

@@ -1,84 +1,97 @@
1
+# pylint: disable=wildcard-import, unused-wildcard-import
2
+
1 3
 """
2 4
 Vanilla product models
3 5
 """
4
-from oscar.apps.catalogue.abstract_models import *  # noqa
6
+from oscar.apps.catalogue.abstract_models import *
5 7
 from oscar.core.loading import is_model_registered
6 8
 
7
-__all__ = ['ProductAttributesContainer']
9
+__all__ = ["ProductAttributesContainer"]
10
+
8 11
 
12
+if not is_model_registered("catalogue", "ProductClass"):
9 13
 
10
-if not is_model_registered('catalogue', 'ProductClass'):
11 14
     class ProductClass(AbstractProductClass):
12 15
         pass
13 16
 
14
-    __all__.append('ProductClass')
17
+    __all__.append("ProductClass")
18
+
15 19
 
20
+if not is_model_registered("catalogue", "Category"):
16 21
 
17
-if not is_model_registered('catalogue', 'Category'):
18 22
     class Category(AbstractCategory):
19 23
         pass
20 24
 
21
-    __all__.append('Category')
25
+    __all__.append("Category")
22 26
 
23 27
 
24
-if not is_model_registered('catalogue', 'ProductCategory'):
28
+if not is_model_registered("catalogue", "ProductCategory"):
29
+
25 30
     class ProductCategory(AbstractProductCategory):
26 31
         pass
27 32
 
28
-    __all__.append('ProductCategory')
33
+    __all__.append("ProductCategory")
34
+
29 35
 
36
+if not is_model_registered("catalogue", "Product"):
30 37
 
31
-if not is_model_registered('catalogue', 'Product'):
32 38
     class Product(AbstractProduct):
33 39
         pass
34 40
 
35
-    __all__.append('Product')
41
+    __all__.append("Product")
42
+
36 43
 
44
+if not is_model_registered("catalogue", "ProductRecommendation"):
37 45
 
38
-if not is_model_registered('catalogue', 'ProductRecommendation'):
39 46
     class ProductRecommendation(AbstractProductRecommendation):
40 47
         pass
41 48
 
42
-    __all__.append('ProductRecommendation')
49
+    __all__.append("ProductRecommendation")
43 50
 
44 51
 
45
-if not is_model_registered('catalogue', 'ProductAttribute'):
52
+if not is_model_registered("catalogue", "ProductAttribute"):
53
+
46 54
     class ProductAttribute(AbstractProductAttribute):
47 55
         pass
48 56
 
49
-    __all__.append('ProductAttribute')
57
+    __all__.append("ProductAttribute")
58
+
50 59
 
60
+if not is_model_registered("catalogue", "ProductAttributeValue"):
51 61
 
52
-if not is_model_registered('catalogue', 'ProductAttributeValue'):
53 62
     class ProductAttributeValue(AbstractProductAttributeValue):
54 63
         pass
55 64
 
56
-    __all__.append('ProductAttributeValue')
65
+    __all__.append("ProductAttributeValue")
66
+
57 67
 
68
+if not is_model_registered("catalogue", "AttributeOptionGroup"):
58 69
 
59
-if not is_model_registered('catalogue', 'AttributeOptionGroup'):
60 70
     class AttributeOptionGroup(AbstractAttributeOptionGroup):
61 71
         pass
62 72
 
63
-    __all__.append('AttributeOptionGroup')
73
+    __all__.append("AttributeOptionGroup")
64 74
 
65 75
 
66
-if not is_model_registered('catalogue', 'AttributeOption'):
76
+if not is_model_registered("catalogue", "AttributeOption"):
77
+
67 78
     class AttributeOption(AbstractAttributeOption):
68 79
         pass
69 80
 
70
-    __all__.append('AttributeOption')
81
+    __all__.append("AttributeOption")
82
+
71 83
 
84
+if not is_model_registered("catalogue", "Option"):
72 85
 
73
-if not is_model_registered('catalogue', 'Option'):
74 86
     class Option(AbstractOption):
75 87
         pass
76 88
 
77
-    __all__.append('Option')
89
+    __all__.append("Option")
90
+
78 91
 
92
+if not is_model_registered("catalogue", "ProductImage"):
79 93
 
80
-if not is_model_registered('catalogue', 'ProductImage'):
81 94
     class ProductImage(AbstractProductImage):
82 95
         pass
83 96
 
84
-    __all__.append('ProductImage')
97
+    __all__.append("ProductImage")

+ 1
- 0
src/oscar/apps/catalogue/product_attributes.py View File

@@ -15,6 +15,7 @@ class ProductAttributesContainer:
15 15
         product.attr.refresh()
16 16
     """
17 17
 
18
+    # pylint: disable=access-member-before-definition
18 19
     def __setstate__(self, state):
19 20
         self.__dict__.setdefault("_product", None)
20 21
         self.__dict__.setdefault("_initialized", False)

+ 6
- 4
src/oscar/apps/catalogue/receivers.py View File

@@ -13,8 +13,9 @@ if settings.OSCAR_DELETE_IMAGE_FILES:
13 13
 
14 14
     from oscar.core.thumbnails import get_thumbnailer
15 15
 
16
-    ProductImage = get_model('catalogue', 'ProductImage')
16
+    ProductImage = get_model("catalogue", "ProductImage")
17 17
 
18
+    # pylint: disable=unused-argument
18 19
     def delete_image_files(sender, instance, **kwargs):
19 20
         """
20 21
         Deletes the original image and created thumbnails.
@@ -29,11 +30,12 @@ if settings.OSCAR_DELETE_IMAGE_FILES:
29 30
 
30 31
     # Connect for all models with ImageFields - add as needed
31 32
     models_with_images = [ProductImage, Category]
32
-    for sender in models_with_images:
33
-        post_delete.connect(delete_image_files, sender=sender)
33
+    for image_instance in models_with_images:
34
+        post_delete.connect(delete_image_files, sender=image_instance)
34 35
 
35 36
 
36
-@receiver(post_save, sender=Category, dispatch_uid='set_ancestors_are_public')
37
+# pylint: disable=unused-argument
38
+@receiver(post_save, sender=Category, dispatch_uid="set_ancestors_are_public")
37 39
 def post_save_set_ancestors_are_public(sender, instance, **kwargs):
38 40
     if kwargs.get("raw"):
39 41
         return

+ 1
- 1
src/oscar/apps/catalogue/reviews/__init__.py View File

@@ -1 +1 @@
1
-default_app_config = 'oscar.apps.catalogue.reviews.apps.CatalogueReviewsConfig'
1
+default_app_config = "oscar.apps.catalogue.reviews.apps.CatalogueReviewsConfig"

+ 47
- 47
src/oscar/apps/catalogue/reviews/abstract_models.py View File

@@ -10,7 +10,7 @@ from oscar.core import validators
10 10
 from oscar.core.compat import AUTH_USER_MODEL
11 11
 from oscar.core.loading import get_class
12 12
 
13
-ProductReviewQuerySet = get_class('catalogue.reviews.managers', 'ProductReviewQuerySet')
13
+ProductReviewQuerySet = get_class("catalogue.reviews.managers", "ProductReviewQuerySet")
14 14
 
15 15
 
16 16
 class AbstractProductReview(models.Model):
@@ -21,8 +21,8 @@ class AbstractProductReview(models.Model):
21 21
     """
22 22
 
23 23
     product = models.ForeignKey(
24
-        'catalogue.Product', related_name='reviews', null=True,
25
-        on_delete=models.CASCADE)
24
+        "catalogue.Product", related_name="reviews", null=True, on_delete=models.CASCADE
25
+    )
26 26
 
27 27
     # Scores are between 0 and 5
28 28
     SCORE_CHOICES = tuple([(x, x) for x in range(0, 6)])
@@ -30,7 +30,9 @@ class AbstractProductReview(models.Model):
30 30
 
31 31
     title = models.CharField(
32 32
         verbose_name=pgettext_lazy("Product review title", "Title"),
33
-        max_length=255, validators=[validators.non_whitespace])
33
+        max_length=255,
34
+        validators=[validators.non_whitespace],
35
+    )
34 36
 
35 37
     body = models.TextField(_("Body"))
36 38
 
@@ -40,12 +42,13 @@ class AbstractProductReview(models.Model):
40 42
         blank=True,
41 43
         null=True,
42 44
         on_delete=models.CASCADE,
43
-        related_name='reviews')
45
+        related_name="reviews",
46
+    )
44 47
 
45 48
     # Fields to be completed if user is anonymous
46 49
     name = models.CharField(
47
-        pgettext_lazy("Anonymous reviewer name", "Name"),
48
-        max_length=255, blank=True)
50
+        pgettext_lazy("Anonymous reviewer name", "Name"), max_length=255, blank=True
51
+    )
49 52
     email = models.EmailField(_("Email"), blank=True)
50 53
     homepage = models.URLField(_("URL"), blank=True)
51 54
 
@@ -57,13 +60,16 @@ class AbstractProductReview(models.Model):
57 60
     )
58 61
 
59 62
     status = models.SmallIntegerField(
60
-        _("Status"), choices=STATUS_CHOICES, default=get_default_review_status)
63
+        _("Status"), choices=STATUS_CHOICES, default=get_default_review_status
64
+    )
61 65
 
62 66
     # Denormalised vote totals
63 67
     total_votes = models.IntegerField(
64
-        _("Total Votes"), default=0)  # upvotes + down votes
68
+        _("Total Votes"), default=0
69
+    )  # upvotes + down votes
65 70
     delta_votes = models.IntegerField(
66
-        _("Delta Votes"), default=0, db_index=True)  # upvotes - down votes
71
+        _("Delta Votes"), default=0, db_index=True
72
+    )  # upvotes - down votes
67 73
 
68 74
     date_created = models.DateTimeField(auto_now_add=True)
69 75
 
@@ -72,19 +78,19 @@ class AbstractProductReview(models.Model):
72 78
 
73 79
     class Meta:
74 80
         abstract = True
75
-        app_label = 'reviews'
76
-        ordering = ['-delta_votes', 'id']
77
-        unique_together = (('product', 'user'),)
78
-        verbose_name = _('Product review')
79
-        verbose_name_plural = _('Product reviews')
81
+        app_label = "reviews"
82
+        ordering = ["-delta_votes", "id"]
83
+        unique_together = (("product", "user"),)
84
+        verbose_name = _("Product review")
85
+        verbose_name_plural = _("Product reviews")
80 86
 
81 87
     def get_absolute_url(self):
82 88
         kwargs = {
83
-            'product_slug': self.product.slug,
84
-            'product_pk': self.product.id,
85
-            'pk': self.id
89
+            "product_slug": self.product.slug,
90
+            "product_pk": self.product.id,
91
+            "pk": self.id,
86 92
         }
87
-        return reverse('catalogue:reviews-detail', kwargs=kwargs)
93
+        return reverse("catalogue:reviews-detail", kwargs=kwargs)
88 94
 
89 95
     def __str__(self):
90 96
         return self.title
@@ -94,7 +100,8 @@ class AbstractProductReview(models.Model):
94 100
         self.body = self.body.strip()
95 101
         if not self.user and not (self.name and self.email):
96 102
             raise ValidationError(
97
-                _("Anonymous reviews must include a name and an email"))
103
+                _("Anonymous reviews must include a name and an email")
104
+            )
98 105
 
99 106
     def vote_up(self, user):
100 107
         self.votes.create(user=user, delta=AbstractVote.UP)
@@ -147,7 +154,7 @@ class AbstractProductReview(models.Model):
147 154
     def reviewer_name(self):
148 155
         if self.user:
149 156
             name = self.user.get_full_name()
150
-            return name if name else _('anonymous')
157
+            return name if name else _("anonymous")
151 158
         else:
152 159
             return self.name
153 160
 
@@ -157,10 +164,9 @@ class AbstractProductReview(models.Model):
157 164
         """
158 165
         Update total and delta votes
159 166
         """
160
-        result = self.votes.aggregate(
161
-            score=Sum('delta'), total_votes=Count('id'))
162
-        self.total_votes = result['total_votes'] or 0
163
-        self.delta_votes = result['score'] or 0
167
+        result = self.votes.aggregate(score=Sum("delta"), total_votes=Count("id"))
168
+        self.total_votes = result["total_votes"] or 0
169
+        self.delta_votes = result["score"] or 0
164 170
         self.save()
165 171
 
166 172
     def can_user_vote(self, user):
@@ -170,6 +176,7 @@ class AbstractProductReview(models.Model):
170 176
         """
171 177
         if not user.is_authenticated:
172 178
             return False, _("Only signed in users can vote")
179
+        # pylint: disable=no-member
173 180
         vote = self.votes.model(review=self, user=user, delta=1)
174 181
         try:
175 182
             vote.full_clean()
@@ -185,44 +192,37 @@ class AbstractVote(models.Model):
185 192
     * Only signed-in users can vote.
186 193
     * Each user can vote only once.
187 194
     """
195
+
188 196
     review = models.ForeignKey(
189
-        'reviews.ProductReview',
190
-        on_delete=models.CASCADE,
191
-        related_name='votes')
197
+        "reviews.ProductReview", on_delete=models.CASCADE, related_name="votes"
198
+    )
192 199
     user = models.ForeignKey(
193
-        AUTH_USER_MODEL,
194
-        related_name='review_votes',
195
-        on_delete=models.CASCADE)
196
-    UP, DOWN = 1, -1
197
-    VOTE_CHOICES = (
198
-        (UP, _("Up")),
199
-        (DOWN, _("Down"))
200
+        AUTH_USER_MODEL, related_name="review_votes", on_delete=models.CASCADE
200 201
     )
201
-    delta = models.SmallIntegerField(_('Delta'), choices=VOTE_CHOICES)
202
+    UP, DOWN = 1, -1
203
+    VOTE_CHOICES = ((UP, _("Up")), (DOWN, _("Down")))
204
+    delta = models.SmallIntegerField(_("Delta"), choices=VOTE_CHOICES)
202 205
     date_created = models.DateTimeField(auto_now_add=True)
203 206
 
204 207
     class Meta:
205 208
         abstract = True
206
-        app_label = 'reviews'
207
-        ordering = ['-date_created']
208
-        unique_together = (('user', 'review'),)
209
-        verbose_name = _('Vote')
210
-        verbose_name_plural = _('Votes')
209
+        app_label = "reviews"
210
+        ordering = ["-date_created"]
211
+        unique_together = (("user", "review"),)
212
+        verbose_name = _("Vote")
213
+        verbose_name_plural = _("Votes")
211 214
 
212 215
     def __str__(self):
213 216
         return "%s vote for %s" % (self.delta, self.review)
214 217
 
215 218
     def clean(self):
216 219
         if not self.review.is_anonymous and self.review.user == self.user:
217
-            raise ValidationError(_(
218
-                "You cannot vote on your own reviews"))
220
+            raise ValidationError(_("You cannot vote on your own reviews"))
219 221
         if not self.user.id:
220
-            raise ValidationError(_(
221
-                "Only signed-in users can vote on reviews"))
222
+            raise ValidationError(_("Only signed-in users can vote on reviews"))
222 223
         previous_votes = self.review.votes.filter(user=self.user)
223 224
         if len(previous_votes) > 0:
224
-            raise ValidationError(_(
225
-                "You can only vote once on a review"))
225
+            raise ValidationError(_("You can only vote once on a review"))
226 226
 
227 227
     def save(self, *args, **kwargs):
228 228
         super().save(*args, **kwargs)

+ 13
- 6
src/oscar/apps/catalogue/reviews/admin.py View File

@@ -2,18 +2,25 @@ from django.contrib import admin
2 2
 
3 3
 from oscar.core.loading import get_model
4 4
 
5
-ProductReview = get_model('reviews', 'ProductReview')
6
-Vote = get_model('reviews', 'Vote')
5
+ProductReview = get_model("reviews", "ProductReview")
6
+Vote = get_model("reviews", "Vote")
7 7
 
8 8
 
9 9
 class ProductReviewAdmin(admin.ModelAdmin):
10
-    list_display = ('product', 'title', 'score', 'status', 'total_votes',
11
-                    'delta_votes', 'date_created')
12
-    readonly_fields = ('total_votes', 'delta_votes')
10
+    list_display = (
11
+        "product",
12
+        "title",
13
+        "score",
14
+        "status",
15
+        "total_votes",
16
+        "delta_votes",
17
+        "date_created",
18
+    )
19
+    readonly_fields = ("total_votes", "delta_votes")
13 20
 
14 21
 
15 22
 class VoteAdmin(admin.ModelAdmin):
16
-    list_display = ('review', 'user', 'delta', 'date_created')
23
+    list_display = ("review", "user", "delta", "date_created")
17 24
 
18 25
 
19 26
 admin.site.register(ProductReview, ProductReviewAdmin)

+ 17
- 12
src/oscar/apps/catalogue/reviews/apps.py View File

@@ -7,23 +7,28 @@ from oscar.core.loading import get_class
7 7
 
8 8
 
9 9
 class CatalogueReviewsConfig(OscarConfig):
10
-    label = 'reviews'
11
-    name = 'oscar.apps.catalogue.reviews'
12
-    verbose_name = _('Catalogue reviews')
10
+    label = "reviews"
11
+    name = "oscar.apps.catalogue.reviews"
12
+    verbose_name = _("Catalogue reviews")
13 13
 
14
-    hidable_feature_name = 'reviews'
14
+    hidable_feature_name = "reviews"
15 15
 
16
+    # pylint: disable=attribute-defined-outside-init
16 17
     def ready(self):
17
-        self.detail_view = get_class('catalogue.reviews.views', 'ProductReviewDetail')
18
-        self.create_view = get_class('catalogue.reviews.views', 'CreateProductReview')
19
-        self.vote_view = get_class('catalogue.reviews.views', 'AddVoteView')
20
-        self.list_view = get_class('catalogue.reviews.views', 'ProductReviewList')
18
+        self.detail_view = get_class("catalogue.reviews.views", "ProductReviewDetail")
19
+        self.create_view = get_class("catalogue.reviews.views", "CreateProductReview")
20
+        self.vote_view = get_class("catalogue.reviews.views", "AddVoteView")
21
+        self.list_view = get_class("catalogue.reviews.views", "ProductReviewList")
21 22
 
22 23
     def get_urls(self):
23 24
         urls = [
24
-            path('<int:pk>/', self.detail_view.as_view(), name='reviews-detail'),
25
-            path('add/', self.create_view.as_view(), name='reviews-add'),
26
-            path('<int:pk>/vote/', login_required(self.vote_view.as_view()), name='reviews-vote'),
27
-            path('', self.list_view.as_view(), name='reviews-list'),
25
+            path("<int:pk>/", self.detail_view.as_view(), name="reviews-detail"),
26
+            path("add/", self.create_view.as_view(), name="reviews-add"),
27
+            path(
28
+                "<int:pk>/vote/",
29
+                login_required(self.vote_view.as_view()),
30
+                name="reviews-vote",
31
+            ),
32
+            path("", self.list_view.as_view(), name="reviews-list"),
28 33
         ]
29 34
         return self.post_process_urls(urls)

+ 17
- 18
src/oscar/apps/catalogue/reviews/forms.py View File

@@ -3,32 +3,31 @@ from django.utils.translation import gettext_lazy as _
3 3
 
4 4
 from oscar.core.loading import get_model
5 5
 
6
-Vote = get_model('reviews', 'vote')
7
-ProductReview = get_model('reviews', 'productreview')
6
+Vote = get_model("reviews", "vote")
7
+ProductReview = get_model("reviews", "productreview")
8 8
 
9 9
 
10 10
 class ProductReviewForm(forms.ModelForm):
11
-    name = forms.CharField(label=_('Name'), required=True)
12
-    email = forms.EmailField(label=_('Email'), required=True)
11
+    name = forms.CharField(label=_("Name"), required=True)
12
+    email = forms.EmailField(label=_("Email"), required=True)
13 13
 
14
-    def __init__(self, product, user=None, *args, **kwargs):
14
+    def __init__(self, product, *args, user=None, **kwargs):
15 15
         super().__init__(*args, **kwargs)
16 16
         self.instance.product = product
17 17
         if user and user.is_authenticated:
18 18
             self.instance.user = user
19
-            del self.fields['name']
20
-            del self.fields['email']
19
+            del self.fields["name"]
20
+            del self.fields["email"]
21 21
 
22 22
     class Meta:
23 23
         model = ProductReview
24
-        fields = ('title', 'score', 'body', 'name', 'email')
24
+        fields = ("title", "score", "body", "name", "email")
25 25
 
26 26
 
27 27
 class VoteForm(forms.ModelForm):
28
-
29 28
     class Meta:
30 29
         model = Vote
31
-        fields = ('delta',)
30
+        fields = ("delta",)
32 31
 
33 32
     def __init__(self, review, user, *args, **kwargs):
34 33
         super().__init__(*args, **kwargs)
@@ -37,24 +36,24 @@ class VoteForm(forms.ModelForm):
37 36
 
38 37
     @property
39 38
     def is_up_vote(self):
40
-        return self.cleaned_data['delta'] == Vote.UP
39
+        return self.cleaned_data["delta"] == Vote.UP
41 40
 
42 41
     @property
43 42
     def is_down_vote(self):
44
-        return self.cleaned_data['delta'] == Vote.DOWN
43
+        return self.cleaned_data["delta"] == Vote.DOWN
45 44
 
46 45
 
47 46
 class SortReviewsForm(forms.Form):
48
-    SORT_BY_SCORE = 'score'
49
-    SORT_BY_RECENCY = 'recency'
47
+    SORT_BY_SCORE = "score"
48
+    SORT_BY_RECENCY = "recency"
50 49
     SORT_REVIEWS_BY_CHOICES = (
51
-        (SORT_BY_SCORE, _('Score')),
52
-        (SORT_BY_RECENCY, _('Recency')),
50
+        (SORT_BY_SCORE, _("Score")),
51
+        (SORT_BY_RECENCY, _("Recency")),
53 52
     )
54 53
 
55 54
     sort_by = forms.ChoiceField(
56 55
         choices=SORT_REVIEWS_BY_CHOICES,
57
-        label=_('Sort by'),
56
+        label=_("Sort by"),
58 57
         initial=SORT_BY_SCORE,
59
-        required=False
58
+        required=False,
60 59
     )

+ 7
- 3
src/oscar/apps/catalogue/reviews/models.py View File

@@ -1,12 +1,16 @@
1 1
 from oscar.apps.catalogue.reviews.abstract_models import (
2
-    AbstractProductReview, AbstractVote)
2
+    AbstractProductReview,
3
+    AbstractVote,
4
+)
3 5
 from oscar.core.loading import is_model_registered
4 6
 
5
-if not is_model_registered('reviews', 'ProductReview'):
7
+if not is_model_registered("reviews", "ProductReview"):
8
+
6 9
     class ProductReview(AbstractProductReview):
7 10
         pass
8 11
 
9 12
 
10
-if not is_model_registered('reviews', 'Vote'):
13
+if not is_model_registered("reviews", "Vote"):
14
+
11 15
     class Vote(AbstractVote):
12 16
         pass

+ 1
- 1
src/oscar/apps/catalogue/reviews/utils.py View File

@@ -4,7 +4,7 @@ from oscar.core.loading import get_model
4 4
 
5 5
 
6 6
 def get_default_review_status():
7
-    ProductReview = get_model('reviews', 'ProductReview')
7
+    ProductReview = get_model("reviews", "ProductReview")
8 8
 
9 9
     if settings.OSCAR_MODERATE_REVIEWS:
10 10
         return ProductReview.FOR_MODERATION

+ 39
- 28
src/oscar/apps/catalogue/reviews/views.py View File

@@ -9,12 +9,12 @@ from oscar.core.loading import get_classes, get_model
9 9
 from oscar.core.utils import redirect_to_referrer
10 10
 
11 11
 ProductReviewForm, VoteForm, SortReviewsForm = get_classes(
12
-    'catalogue.reviews.forms',
13
-    ['ProductReviewForm', 'VoteForm', 'SortReviewsForm'])
12
+    "catalogue.reviews.forms", ["ProductReviewForm", "VoteForm", "SortReviewsForm"]
13
+)
14 14
 
15
-Vote = get_model('reviews', 'vote')
16
-ProductReview = get_model('reviews', 'ProductReview')
17
-Product = get_model('catalogue', 'product')
15
+Vote = get_model("reviews", "vote")
16
+ProductReview = get_model("reviews", "ProductReview")
17
+Product = get_model("catalogue", "product")
18 18
 
19 19
 
20 20
 class CreateProductReview(CreateView):
@@ -25,8 +25,10 @@ class CreateProductReview(CreateView):
25 25
     view_signal = review_added
26 26
 
27 27
     def dispatch(self, request, *args, **kwargs):
28
+        # pylint: disable=attribute-defined-outside-init
28 29
         self.product = get_object_or_404(
29
-            self.product_model, pk=kwargs['product_pk'], is_public=True)
30
+            self.product_model, pk=kwargs["product_pk"], is_public=True
31
+        )
30 32
         # check permission to leave review
31 33
         if not self.product.is_review_permitted(request.user):
32 34
             if self.product.has_review_by(request.user):
@@ -36,18 +38,17 @@ class CreateProductReview(CreateView):
36 38
             messages.warning(self.request, message)
37 39
             return redirect(self.product.get_absolute_url())
38 40
 
39
-        return super().dispatch(
40
-            request, *args, **kwargs)
41
+        return super().dispatch(request, *args, **kwargs)
41 42
 
42 43
     def get_context_data(self, **kwargs):
43 44
         context = super().get_context_data(**kwargs)
44
-        context['product'] = self.product
45
+        context["product"] = self.product
45 46
         return context
46 47
 
47 48
     def get_form_kwargs(self):
48 49
         kwargs = super().get_form_kwargs()
49
-        kwargs['product'] = self.product
50
-        kwargs['user'] = self.request.user
50
+        kwargs["product"] = self.product
51
+        kwargs["user"] = self.request.user
51 52
         return kwargs
52 53
 
53 54
     def form_valid(self, form):
@@ -56,24 +57,29 @@ class CreateProductReview(CreateView):
56 57
         return response
57 58
 
58 59
     def get_success_url(self):
59
-        messages.success(
60
-            self.request, _("Thank you for reviewing this product"))
60
+        messages.success(self.request, _("Thank you for reviewing this product"))
61 61
         return self.product.get_absolute_url()
62 62
 
63 63
     def send_signal(self, request, response, review):
64
-        self.view_signal.send(sender=self, review=review, user=request.user,
65
-                              request=request, response=response)
64
+        self.view_signal.send(
65
+            sender=self,
66
+            review=review,
67
+            user=request.user,
68
+            request=request,
69
+            response=response,
70
+        )
66 71
 
67 72
 
68 73
 class ProductReviewDetail(DetailView):
69 74
     template_name = "oscar/catalogue/reviews/review_detail.html"
70
-    context_object_name = 'review'
75
+    context_object_name = "review"
71 76
     model = ProductReview
72 77
 
73 78
     def get_context_data(self, **kwargs):
74 79
         context = super().get_context_data(**kwargs)
75
-        context['product'] = get_object_or_404(
76
-            Product, pk=self.kwargs['product_pk'], is_public=True)
80
+        context["product"] = get_object_or_404(
81
+            Product, pk=self.kwargs["product_pk"], is_public=True
82
+        )
77 83
         return context
78 84
 
79 85
 
@@ -86,8 +92,10 @@ class AddVoteView(View):
86 92
     """
87 93
 
88 94
     def post(self, request, *args, **kwargs):
89
-        product = get_object_or_404(Product, pk=self.kwargs['product_pk'], is_public=True)
90
-        review = get_object_or_404(ProductReview, pk=self.kwargs['pk'])
95
+        product = get_object_or_404(
96
+            Product, pk=self.kwargs["product_pk"], is_public=True
97
+        )
98
+        review = get_object_or_404(ProductReview, pk=self.kwargs["pk"])
91 99
 
92 100
         form = VoteForm(review, request.user, request.POST)
93 101
         if form.is_valid():
@@ -107,24 +115,27 @@ class ProductReviewList(ListView):
107 115
     """
108 116
     Browse reviews for a product
109 117
     """
110
-    template_name = 'oscar/catalogue/reviews/review_list.html'
118
+
119
+    template_name = "oscar/catalogue/reviews/review_list.html"
111 120
     context_object_name = "reviews"
112 121
     model = ProductReview
113 122
     product_model = Product
114 123
     paginate_by = settings.OSCAR_REVIEWS_PER_PAGE
115 124
 
116 125
     def get_queryset(self):
117
-        qs = self.model.objects.approved().filter(product=self.kwargs['product_pk'])
126
+        qs = self.model.objects.approved().filter(product=self.kwargs["product_pk"])
127
+        # pylint: disable=attribute-defined-outside-init
118 128
         self.form = SortReviewsForm(self.request.GET)
119 129
         if self.request.GET and self.form.is_valid():
120
-            sort_by = self.form.cleaned_data['sort_by']
130
+            sort_by = self.form.cleaned_data["sort_by"]
121 131
             if sort_by == SortReviewsForm.SORT_BY_RECENCY:
122
-                return qs.order_by('-date_created')
123
-        return qs.order_by('-score')
132
+                return qs.order_by("-date_created")
133
+        return qs.order_by("-score")
124 134
 
125 135
     def get_context_data(self, **kwargs):
126 136
         context = super().get_context_data(**kwargs)
127
-        context['product'] = get_object_or_404(
128
-            self.product_model, pk=self.kwargs['product_pk'], is_public=True)
129
-        context['form'] = self.form
137
+        context["product"] = get_object_or_404(
138
+            self.product_model, pk=self.kwargs["product_pk"], is_public=True
139
+        )
140
+        context["form"] = self.form
130 141
         return context

+ 20
- 14
src/oscar/apps/catalogue/search_handlers.py View File

@@ -4,12 +4,13 @@ from django.views.generic.list import MultipleObjectMixin
4 4
 
5 5
 from oscar.core.loading import get_class, get_classes, get_model
6 6
 
7
-BrowseCategoryForm = get_class('search.forms', 'BrowseCategoryForm')
7
+BrowseCategoryForm = get_class("search.forms", "BrowseCategoryForm")
8 8
 SearchHandler, SearchResultsPaginationMixin = get_classes(
9
-    'search.search_handlers', ('SearchHandler', 'SearchResultsPaginationMixin'))
10
-is_solr_supported = get_class('search.features', 'is_solr_supported')
11
-is_elasticsearch_supported = get_class('search.features', 'is_elasticsearch_supported')
12
-Product = get_model('catalogue', 'Product')
9
+    "search.search_handlers", ("SearchHandler", "SearchResultsPaginationMixin")
10
+)
11
+is_solr_supported = get_class("search.features", "is_solr_supported")
12
+is_elasticsearch_supported = get_class("search.features", "is_elasticsearch_supported")
13
+Product = get_model("catalogue", "Product")
13 14
 
14 15
 
15 16
 def get_product_search_handler_class():
@@ -23,14 +24,14 @@ def get_product_search_handler_class():
23 24
     if settings.OSCAR_PRODUCT_SEARCH_HANDLER is not None:
24 25
         return import_string(settings.OSCAR_PRODUCT_SEARCH_HANDLER)
25 26
     if is_solr_supported():
26
-        return get_class('catalogue.search_handlers', 'SolrProductSearchHandler')
27
+        return get_class("catalogue.search_handlers", "SolrProductSearchHandler")
27 28
     elif is_elasticsearch_supported():
28 29
         return get_class(
29
-            'catalogue.search_handlers', 'ESProductSearchHandler',
30
+            "catalogue.search_handlers",
31
+            "ESProductSearchHandler",
30 32
         )
31 33
     else:
32
-        return get_class(
33
-            'catalogue.search_handlers', 'SimpleProductSearchHandler')
34
+        return get_class("catalogue.search_handlers", "SimpleProductSearchHandler")
34 35
 
35 36
 
36 37
 class SolrProductSearchHandler(SearchHandler):
@@ -38,6 +39,7 @@ class SolrProductSearchHandler(SearchHandler):
38 39
     Search handler specialised for searching products.  Comes with optional
39 40
     category filtering. To be used with a Solr search backend.
40 41
     """
42
+
41 43
     form_class = BrowseCategoryForm
42 44
     model_whitelist = [Product]
43 45
     paginate_by = settings.OSCAR_PRODUCTS_PER_PAGE
@@ -51,9 +53,10 @@ class SolrProductSearchHandler(SearchHandler):
51 53
         if self.categories:
52 54
             # We use 'narrow' API to ensure Solr's 'fq' filtering is used as
53 55
             # opposed to filtering using 'q'.
54
-            pattern = ' OR '.join([
55
-                '"%s"' % sqs.query.clean(c.full_name) for c in self.categories])
56
-            sqs = sqs.narrow('category_exact:(%s)' % pattern)
56
+            pattern = " OR ".join(
57
+                ['"%s"' % sqs.query.clean(c.full_name) for c in self.categories]
58
+            )
59
+            sqs = sqs.narrow("category_exact:(%s)" % pattern)
57 60
         return sqs
58 61
 
59 62
 
@@ -62,6 +65,7 @@ class ESProductSearchHandler(SearchHandler):
62 65
     Search handler specialised for searching products.  Comes with optional
63 66
     category filtering. To be used with an ElasticSearch search backend.
64 67
     """
68
+
65 69
     form_class = BrowseCategoryForm
66 70
     model_whitelist = [Product]
67 71
     paginate_by = settings.OSCAR_PRODUCTS_PER_PAGE
@@ -87,12 +91,14 @@ class SimpleProductSearchHandler(SearchResultsPaginationMixin, MultipleObjectMix
87 91
     Note that is meant as a replacement search handler and not as a view
88 92
     mixin; the mixin just does most of what we need it to do.
89 93
     """
94
+
90 95
     paginate_by = settings.OSCAR_PRODUCTS_PER_PAGE
91 96
 
97
+    # pylint: disable=unused-argument
92 98
     def __init__(self, request_data, full_path, categories=None):
93 99
         self.request_data = request_data
94 100
         self.categories = categories
95
-        self.kwargs = {'page': request_data.get('page') or 1}
101
+        self.kwargs = {"page": request_data.get("page") or 1}
96 102
         self.object_list = self.get_queryset()
97 103
 
98 104
     def get_queryset(self):
@@ -106,5 +112,5 @@ class SimpleProductSearchHandler(SearchResultsPaginationMixin, MultipleObjectMix
106 112
         # internally by MultipleObjectMixin
107 113
         self.context_object_name = context_object_name
108 114
         context = self.get_context_data(object_list=self.object_list)
109
-        context[context_object_name] = context['page_obj'].object_list
115
+        context[context_object_name] = context["page_obj"].object_list
110 116
         return context

+ 45
- 41
src/oscar/apps/catalogue/utils.py View File

@@ -12,66 +12,68 @@ from django.utils.translation import gettext_lazy as _
12 12
 from PIL import Image
13 13
 
14 14
 from oscar.apps.catalogue.exceptions import (
15
-    IdenticalImageError, ImageImportError, InvalidImageArchive)
15
+    IdenticalImageError,
16
+    ImageImportError,
17
+    InvalidImageArchive,
18
+)
16 19
 from oscar.core.loading import get_model
17 20
 
18
-Product = get_model('catalogue', 'product')
19
-ProductImage = get_model('catalogue', 'productimage')
21
+Product = get_model("catalogue", "product")
22
+ProductImage = get_model("catalogue", "productimage")
20 23
 
21 24
 
22 25
 # This is an old class only really intended to be used by the internal sandbox
23 26
 # site. It's not recommended to be used by your project.
24 27
 class Importer(object):
25
-
26
-    allowed_extensions = ['.jpeg', '.jpg', '.gif', '.png']
28
+    allowed_extensions = [".jpeg", ".jpg", ".gif", ".png"]
27 29
 
28 30
     def __init__(self, logger, field):
29 31
         self.logger = logger
30 32
         self._field = field
31 33
 
32
-    @atomic  # noqa (too complex (10))
34
+    @atomic
33 35
     def handle(self, dirname):
34
-        stats = {
35
-            'num_processed': 0,
36
-            'num_skipped': 0,
37
-            'num_invalid': 0}
36
+        stats = {"num_processed": 0, "num_skipped": 0, "num_invalid": 0}
38 37
         image_dir, filenames = self._get_image_files(dirname)
39 38
         if image_dir:
40 39
             for filename in filenames:
41 40
                 try:
42
-                    lookup_value \
43
-                        = self._get_lookup_value_from_filename(filename)
41
+                    lookup_value = self._get_lookup_value_from_filename(filename)
44 42
                     self._process_image(image_dir, filename, lookup_value)
45
-                    stats['num_processed'] += 1
43
+                    stats["num_processed"] += 1
46 44
                 except Product.MultipleObjectsReturned:
47
-                    self.logger.warning("Multiple products matching %s='%s',"
48
-                                        " skipping"
49
-                                        % (self._field, lookup_value))
50
-                    stats['num_skipped'] += 1
45
+                    self.logger.warning(
46
+                        "Multiple products matching %s='%s',"
47
+                        " skipping" % (self._field, lookup_value)
48
+                    )
49
+                    stats["num_skipped"] += 1
51 50
                 except Product.DoesNotExist:
52
-                    self.logger.warning("No item matching %s='%s'"
53
-                                        % (self._field, lookup_value))
54
-                    stats['num_skipped'] += 1
51
+                    self.logger.warning(
52
+                        "No item matching %s='%s'" % (self._field, lookup_value)
53
+                    )
54
+                    stats["num_skipped"] += 1
55 55
                 except IdenticalImageError:
56
-                    self.logger.warning("Identical image already exists for"
57
-                                        " %s='%s', skipping"
58
-                                        % (self._field, lookup_value))
59
-                    stats['num_skipped'] += 1
56
+                    self.logger.warning(
57
+                        "Identical image already exists for"
58
+                        " %s='%s', skipping" % (self._field, lookup_value)
59
+                    )
60
+                    stats["num_skipped"] += 1
60 61
                 except IOError as e:
61
-                    stats['num_invalid'] += 1
62
-                    raise ImageImportError(_('%(filename)s is not a valid'
63
-                                             ' image (%(error)s)')
64
-                                           % {'filename': filename,
65
-                                              'error': e})
62
+                    stats["num_invalid"] += 1
63
+                    raise ImageImportError(
64
+                        _("%(filename)s is not a valid image (%(error)s)")
65
+                        % {"filename": filename, "error": e}
66
+                    )
66 67
                 except FieldError as e:
67 68
                     raise ImageImportError(e)
68 69
             if image_dir != dirname:
69 70
                 shutil.rmtree(image_dir)
70 71
         else:
71
-            raise InvalidImageArchive(_('%s is not a valid image archive')
72
-                                      % dirname)
73
-        self.logger.info("Finished image import: %(num_processed)d imported,"
74
-                         " %(num_skipped)d skipped" % stats)
72
+            raise InvalidImageArchive(_("%s is not a valid image archive") % dirname)
73
+        self.logger.info(
74
+            "Finished image import: %(num_processed)d imported,"
75
+            " %(num_skipped)d skipped" % stats
76
+        )
75 77
 
76 78
     def _get_image_files(self, dirname):
77 79
         filenames = []
@@ -79,24 +81,26 @@ class Importer(object):
79 81
         if image_dir:
80 82
             for filename in os.listdir(image_dir):
81 83
                 ext = os.path.splitext(filename)[1]
82
-                if os.path.isfile(os.path.join(image_dir, filename)) \
83
-                        and ext in self.allowed_extensions:
84
+                if (
85
+                    os.path.isfile(os.path.join(image_dir, filename))
86
+                    and ext in self.allowed_extensions
87
+                ):
84 88
                     filenames.append(filename)
85 89
         return image_dir, filenames
86 90
 
87 91
     def _extract_images(self, dirname):
88
-        '''
92
+        """
89 93
         Returns path to directory containing images in dirname if successful.
90 94
         Returns empty string if dirname does not exist, or could not be opened.
91 95
         Assumes that if dirname is a directory, then it contains images.
92 96
         If dirname is an archive (tar/zip file) then the path returned is to a
93 97
         temporary directory that should be deleted when no longer required.
94
-        '''
98
+        """
95 99
         if os.path.isdir(dirname):
96 100
             return dirname
97 101
 
98 102
         ext = os.path.splitext(dirname)[1]
99
-        if ext in ['.gz', '.tar']:
103
+        if ext in [".gz", ".tar"]:
100 104
             image_dir = tempfile.mkdtemp()
101 105
             try:
102 106
                 tar_file = tarfile.open(dirname)
@@ -105,7 +109,7 @@ class Importer(object):
105 109
                 return image_dir
106 110
             except (tarfile.TarError, zlib.error):
107 111
                 return ""
108
-        elif ext == '.zip':
112
+        elif ext == ".zip":
109 113
             image_dir = tempfile.mkdtemp()
110 114
             try:
111 115
                 zip_file = zipfile.ZipFile(dirname)
@@ -125,7 +129,7 @@ class Importer(object):
125 129
         kwargs = {self._field: lookup_value}
126 130
         item = Product._default_manager.get(**kwargs)
127 131
 
128
-        new_data = open(file_path, 'rb').read()
132
+        new_data = open(file_path, "rb").read()
129 133
         next_index = 0
130 134
         for existing in item.images.all():
131 135
             next_index = existing.display_order + 1
@@ -136,7 +140,7 @@ class Importer(object):
136 140
                 # File probably doesn't exist
137 141
                 existing.delete()
138 142
 
139
-        new_file = File(open(file_path, 'rb'))
143
+        new_file = File(open(file_path, "rb"))
140 144
         im = ProductImage(product=item, display_order=next_index)
141 145
         im.original.save(filename, new_file, save=False)
142 146
         im.save()

+ 52
- 41
src/oscar/apps/catalogue/views.py View File

@@ -10,16 +10,17 @@ from django.views.generic import DetailView, TemplateView
10 10
 from oscar.apps.catalogue.signals import product_viewed
11 11
 from oscar.core.loading import get_class, get_model
12 12
 
13
-Product = get_model('catalogue', 'product')
14
-Category = get_model('catalogue', 'category')
15
-ProductAlert = get_model('customer', 'ProductAlert')
16
-ProductAlertForm = get_class('customer.forms', 'ProductAlertForm')
13
+Product = get_model("catalogue", "product")
14
+Category = get_model("catalogue", "category")
15
+ProductAlert = get_model("customer", "ProductAlert")
16
+ProductAlertForm = get_class("customer.forms", "ProductAlertForm")
17 17
 get_product_search_handler_class = get_class(
18
-    'catalogue.search_handlers', 'get_product_search_handler_class')
18
+    "catalogue.search_handlers", "get_product_search_handler_class"
19
+)
19 20
 
20 21
 
21 22
 class ProductDetailView(DetailView):
22
-    context_object_name = 'product'
23
+    context_object_name = "product"
23 24
     model = Product
24 25
     view_signal = product_viewed
25 26
     template_folder = "catalogue"
@@ -32,21 +33,21 @@ class ProductDetailView(DetailView):
32 33
     # displayed on parent product page.
33 34
     enforce_parent = False
34 35
 
35
-    def get(self, request, **kwargs):
36
+    def get(self, request, *args, **kwargs):
36 37
         """
37 38
         Ensures that the correct URL is used before rendering a response
38 39
         """
40
+        # pylint: disable=attribute-defined-outside-init
39 41
         self.object = product = self.get_object()
40 42
 
41
-        redirect = self.redirect_if_necessary(request.path, product)
42
-        if redirect is not None:
43
-            return redirect
43
+        if redirect_to := self.redirect_if_necessary(request.path, product):
44
+            return redirect_to
44 45
 
45 46
         # Do allow staff members so they can test layout etc.
46 47
         if not self.is_viewable(product, request):
47 48
             raise Http404()
48 49
 
49
-        response = super().get(request, **kwargs)
50
+        response = super().get(request, *args, **kwargs)
50 51
         self.send_signal(request, response, product)
51 52
         return response
52 53
 
@@ -55,15 +56,14 @@ class ProductDetailView(DetailView):
55 56
 
56 57
     def get_object(self, queryset=None):
57 58
         # Check if self.object is already set to prevent unnecessary DB calls
58
-        if hasattr(self, 'object'):
59
+        if hasattr(self, "object"):
59 60
             return self.object
60 61
         else:
61 62
             return super().get_object(queryset)
62 63
 
63 64
     def redirect_if_necessary(self, current_path, product):
64 65
         if self.enforce_parent and product.is_child:
65
-            return HttpResponsePermanentRedirect(
66
-                product.parent.get_absolute_url())
66
+            return HttpResponsePermanentRedirect(product.parent.get_absolute_url())
67 67
 
68 68
         if self.enforce_paths:
69 69
             expected_path = product.get_absolute_url()
@@ -72,8 +72,8 @@ class ProductDetailView(DetailView):
72 72
 
73 73
     def get_context_data(self, **kwargs):
74 74
         ctx = super().get_context_data(**kwargs)
75
-        ctx['alert_form'] = self.get_alert_form()
76
-        ctx['has_active_alert'] = self.get_alert_status()
75
+        ctx["alert_form"] = self.get_alert_form()
76
+        ctx["has_active_alert"] = self.get_alert_status()
77 77
         return ctx
78 78
 
79 79
     def get_alert_status(self):
@@ -81,19 +81,22 @@ class ProductDetailView(DetailView):
81 81
         has_alert = False
82 82
         if self.request.user.is_authenticated:
83 83
             alerts = ProductAlert.objects.filter(
84
-                product=self.object, user=self.request.user,
85
-                status=ProductAlert.ACTIVE)
84
+                product=self.object, user=self.request.user, status=ProductAlert.ACTIVE
85
+            )
86 86
             has_alert = alerts.exists()
87 87
         return has_alert
88 88
 
89 89
     def get_alert_form(self):
90
-        return ProductAlertForm(
91
-            user=self.request.user, product=self.object)
90
+        return ProductAlertForm(user=self.request.user, product=self.object)
92 91
 
93 92
     def send_signal(self, request, response, product):
94 93
         self.view_signal.send(
95
-            sender=self, product=product, user=request.user, request=request,
96
-            response=response)
94
+            sender=self,
95
+            product=product,
96
+            user=request.user,
97
+            request=request,
98
+            response=response,
99
+        )
97 100
 
98 101
     def get_template_names(self):
99 102
         """
@@ -112,29 +115,32 @@ class ProductDetailView(DetailView):
112 115
             return [self.template_name]
113 116
 
114 117
         return [
115
-            'oscar/%s/detail-for-upc-%s.html' % (
116
-                self.template_folder, self.object.upc),
117
-            'oscar/%s/detail-for-class-%s.html' % (
118
-                self.template_folder, self.object.get_product_class().slug),
119
-            'oscar/%s/detail.html' % self.template_folder]
118
+            "oscar/%s/detail-for-upc-%s.html" % (self.template_folder, self.object.upc),
119
+            "oscar/%s/detail-for-class-%s.html"
120
+            % (self.template_folder, self.object.get_product_class().slug),
121
+            "oscar/%s/detail.html" % self.template_folder,
122
+        ]
120 123
 
121 124
 
122 125
 class CatalogueView(TemplateView):
123 126
     """
124 127
     Browse all products in the catalogue
125 128
     """
129
+
126 130
     context_object_name = "products"
127
-    template_name = 'oscar/catalogue/browse.html'
131
+    template_name = "oscar/catalogue/browse.html"
128 132
 
129 133
     def get(self, request, *args, **kwargs):
130 134
         try:
135
+            # pylint: disable=attribute-defined-outside-init
131 136
             self.search_handler = self.get_search_handler(
132
-                self.request.GET, request.get_full_path(), [])
137
+                self.request.GET, request.get_full_path(), []
138
+            )
133 139
             response = super().get(request, *args, **kwargs)
134 140
         except InvalidPage:
135 141
             # Redirect to page one.
136
-            messages.error(request, _('The given page number was invalid.'))
137
-            return redirect('catalogue:index')
142
+            messages.error(request, _("The given page number was invalid."))
143
+            return redirect("catalogue:index")
138 144
         return response
139 145
 
140 146
     def get_search_handler(self, *args, **kwargs):
@@ -142,9 +148,10 @@ class CatalogueView(TemplateView):
142 148
 
143 149
     def get_context_data(self, **kwargs):
144 150
         ctx = {}
145
-        ctx['summary'] = _("All products")
151
+        ctx["summary"] = _("All products")
146 152
         search_context = self.search_handler.get_search_context_data(
147
-            self.context_object_name)
153
+            self.context_object_name
154
+        )
148 155
         ctx.update(search_context)
149 156
         return ctx
150 157
 
@@ -153,29 +160,32 @@ class ProductCategoryView(TemplateView):
153 160
     """
154 161
     Browse products in a given category
155 162
     """
163
+
156 164
     context_object_name = "products"
157
-    template_name = 'oscar/catalogue/category.html'
165
+    template_name = "oscar/catalogue/category.html"
158 166
     enforce_paths = True
159 167
 
160 168
     def get(self, request, *args, **kwargs):
161 169
         # Fetch the category; return 404 or redirect as needed
170
+        # pylint: disable=attribute-defined-outside-init
162 171
         self.category = self.get_category()
163 172
 
164 173
         # Allow staff members so they can test layout etc.
165 174
         if not self.is_viewable(self.category, request):
166 175
             raise Http404()
167 176
 
168
-        potential_redirect = self.redirect_if_necessary(
169
-            request.path, self.category)
177
+        potential_redirect = self.redirect_if_necessary(request.path, self.category)
170 178
         if potential_redirect is not None:
171 179
             return potential_redirect
172 180
 
173 181
         try:
182
+            # pylint: disable=attribute-defined-outside-init
174 183
             self.search_handler = self.get_search_handler(
175
-                request.GET, request.get_full_path(), self.get_categories())
184
+                request.GET, request.get_full_path(), self.get_categories()
185
+            )
176 186
             response = super().get(request, *args, **kwargs)
177 187
         except InvalidPage:
178
-            messages.error(request, _('The given page number was invalid.'))
188
+            messages.error(request, _("The given page number was invalid."))
179 189
             return redirect(self.category.get_absolute_url())
180 190
 
181 191
         return response
@@ -184,7 +194,7 @@ class ProductCategoryView(TemplateView):
184 194
         return category.is_public or request.user.is_staff
185 195
 
186 196
     def get_category(self):
187
-        return get_object_or_404(Category, pk=self.kwargs['pk'])
197
+        return get_object_or_404(Category, pk=self.kwargs["pk"])
188 198
 
189 199
     def redirect_if_necessary(self, current_path, category):
190 200
         if self.enforce_paths:
@@ -205,8 +215,9 @@ class ProductCategoryView(TemplateView):
205 215
 
206 216
     def get_context_data(self, **kwargs):
207 217
         context = super().get_context_data(**kwargs)
208
-        context['category'] = self.category
218
+        context["category"] = self.category
209 219
         search_context = self.search_handler.get_search_context_data(
210
-            self.context_object_name)
220
+            self.context_object_name
221
+        )
211 222
         context.update(search_context)
212 223
         return context

+ 1
- 1
src/oscar/apps/checkout/__init__.py View File

@@ -1 +1 @@
1
-default_app_config = 'oscar.apps.checkout.apps.CheckoutConfig'
1
+default_app_config = "oscar.apps.checkout.apps.CheckoutConfig"

+ 5
- 7
src/oscar/apps/checkout/applicator.py View File

@@ -4,7 +4,7 @@ class SurchargeList(list):
4 4
         return sum([surcharge.price for surcharge in self])
5 5
 
6 6
 
7
-class SurchargePrice():
7
+class SurchargePrice:
8 8
     surcharge = None
9 9
     price = None
10 10
 
@@ -13,12 +13,12 @@ class SurchargePrice():
13 13
         self.price = price
14 14
 
15 15
 
16
-class SurchargeApplicator():
17
-
16
+class SurchargeApplicator:
18 17
     def __init__(self, request=None, context=None):
19 18
         self.context = context
20 19
         self.request = request
21 20
 
21
+    # pylint: disable=unused-argument
22 22
     def get_surcharges(self, basket, **kwargs):
23 23
         """
24 24
         For example::
@@ -35,10 +35,7 @@ class SurchargeApplicator():
35 35
 
36 36
     def get_applicable_surcharges(self, basket, **kwargs):
37 37
         methods = [
38
-            SurchargePrice(
39
-                surcharge,
40
-                surcharge.calculate(basket=basket, **kwargs)
41
-            )
38
+            SurchargePrice(surcharge, surcharge.calculate(basket=basket, **kwargs))
42 39
             for surcharge in self.get_surcharges(basket=basket, **kwargs)
43 40
             if self.is_applicable(surcharge=surcharge, basket=basket, **kwargs)
44 41
         ]
@@ -48,6 +45,7 @@ class SurchargeApplicator():
48 45
         else:
49 46
             return None
50 47
 
48
+    # pylint: disable=unused-argument
51 49
     def is_applicable(self, surcharge, basket, **kwargs):
52 50
         """
53 51
         Checks if surcharge is applicable to certain conditions

+ 55
- 28
src/oscar/apps/checkout/apps.py View File

@@ -8,49 +8,76 @@ from oscar.core.loading import get_class
8 8
 
9 9
 
10 10
 class CheckoutConfig(OscarConfig):
11
-    label = 'checkout'
12
-    name = 'oscar.apps.checkout'
13
-    verbose_name = _('Checkout')
11
+    label = "checkout"
12
+    name = "oscar.apps.checkout"
13
+    verbose_name = _("Checkout")
14 14
 
15
-    namespace = 'checkout'
15
+    namespace = "checkout"
16 16
 
17
+    # pylint: disable=attribute-defined-outside-init
17 18
     def ready(self):
18
-        self.index_view = get_class('checkout.views', 'IndexView')
19
-        self.shipping_address_view = get_class('checkout.views', 'ShippingAddressView')
20
-        self.user_address_update_view = get_class('checkout.views',
21
-                                                  'UserAddressUpdateView')
22
-        self.user_address_delete_view = get_class('checkout.views',
23
-                                                  'UserAddressDeleteView')
24
-        self.shipping_method_view = get_class('checkout.views', 'ShippingMethodView')
25
-        self.payment_method_view = get_class('checkout.views', 'PaymentMethodView')
26
-        self.payment_details_view = get_class('checkout.views', 'PaymentDetailsView')
27
-        self.thankyou_view = get_class('checkout.views', 'ThankYouView')
19
+        self.index_view = get_class("checkout.views", "IndexView")
20
+        self.shipping_address_view = get_class("checkout.views", "ShippingAddressView")
21
+        self.user_address_update_view = get_class(
22
+            "checkout.views", "UserAddressUpdateView"
23
+        )
24
+        self.user_address_delete_view = get_class(
25
+            "checkout.views", "UserAddressDeleteView"
26
+        )
27
+        self.shipping_method_view = get_class("checkout.views", "ShippingMethodView")
28
+        self.payment_method_view = get_class("checkout.views", "PaymentMethodView")
29
+        self.payment_details_view = get_class("checkout.views", "PaymentDetailsView")
30
+        self.thankyou_view = get_class("checkout.views", "ThankYouView")
28 31
 
29 32
     def get_urls(self):
30 33
         urls = [
31
-            path('', self.index_view.as_view(), name='index'),
32
-
34
+            path("", self.index_view.as_view(), name="index"),
33 35
             # Shipping/user address views
34
-            path('shipping-address/', self.shipping_address_view.as_view(), name='shipping-address'),
35
-            path('user-address/edit/<int:pk>/', self.user_address_update_view.as_view(), name='user-address-update'),
36
-            path('user-address/delete/<int:pk>/', self.user_address_delete_view.as_view(), name='user-address-delete'),
37
-
36
+            path(
37
+                "shipping-address/",
38
+                self.shipping_address_view.as_view(),
39
+                name="shipping-address",
40
+            ),
41
+            path(
42
+                "user-address/edit/<int:pk>/",
43
+                self.user_address_update_view.as_view(),
44
+                name="user-address-update",
45
+            ),
46
+            path(
47
+                "user-address/delete/<int:pk>/",
48
+                self.user_address_delete_view.as_view(),
49
+                name="user-address-delete",
50
+            ),
38 51
             # Shipping method views
39
-            path('shipping-method/', self.shipping_method_view.as_view(), name='shipping-method'),
40
-
52
+            path(
53
+                "shipping-method/",
54
+                self.shipping_method_view.as_view(),
55
+                name="shipping-method",
56
+            ),
41 57
             # Payment views
42
-            path('payment-method/', self.payment_method_view.as_view(), name='payment-method'),
43
-            path('payment-details/', self.payment_details_view.as_view(), name='payment-details'),
44
-
58
+            path(
59
+                "payment-method/",
60
+                self.payment_method_view.as_view(),
61
+                name="payment-method",
62
+            ),
63
+            path(
64
+                "payment-details/",
65
+                self.payment_details_view.as_view(),
66
+                name="payment-details",
67
+            ),
45 68
             # Preview and thankyou
46
-            path('preview/', self.payment_details_view.as_view(preview=True), name='preview'),
47
-            path('thank-you/', self.thankyou_view.as_view(), name='thank-you'),
69
+            path(
70
+                "preview/",
71
+                self.payment_details_view.as_view(preview=True),
72
+                name="preview",
73
+            ),
74
+            path("thank-you/", self.thankyou_view.as_view(), name="thank-you"),
48 75
         ]
49 76
         return self.post_process_urls(urls)
50 77
 
51 78
     def get_url_decorator(self, pattern):
52 79
         if not settings.OSCAR_ALLOW_ANON_CHECKOUT:
53 80
             return login_required
54
-        if pattern.name.startswith('user-address'):
81
+        if pattern.name.startswith("user-address"):
55 82
             return login_required
56 83
         return None

+ 2
- 2
src/oscar/apps/checkout/calculators.py View File

@@ -26,5 +26,5 @@ class OrderTotalCalculator(object):
26 26
                 incl_tax += surcharges.total.incl_tax
27 27
 
28 28
         return prices.Price(
29
-            currency=basket.currency,
30
-            excl_tax=excl_tax, incl_tax=incl_tax)
29
+            currency=basket.currency, excl_tax=excl_tax, incl_tax=incl_tax
30
+        )

+ 2
- 3
src/oscar/apps/checkout/context_processors.py View File

@@ -2,6 +2,5 @@ from django.conf import settings
2 2
 
3 3
 
4 4
 def checkout(request):
5
-    anon_checkout_allowed \
6
-        = getattr(settings, 'OSCAR_ALLOW_ANON_CHECKOUT', False)
7
-    return {'anon_checkout_allowed': anon_checkout_allowed}
5
+    anon_checkout_allowed = getattr(settings, "OSCAR_ALLOW_ANON_CHECKOUT", False)
6
+    return {"anon_checkout_allowed": anon_checkout_allowed}

+ 1
- 1
src/oscar/apps/checkout/exceptions.py View File

@@ -1,5 +1,4 @@
1 1
 class FailedPreCondition(Exception):
2
-
3 2
     def __init__(self, url, message=None, messages=None):
4 3
         self.url = url
5 4
         if message:
@@ -15,5 +14,6 @@ class PassedSkipCondition(Exception):
15 14
     To be raised when a skip condition has been passed and the current view
16 15
     should be skipped. The passed URL dictates where to.
17 16
     """
17
+
18 18
     def __init__(self, url):
19 19
         self.url = url

+ 41
- 29
src/oscar/apps/checkout/forms.py View File

@@ -8,35 +8,40 @@ from oscar.core.loading import get_class, get_model
8 8
 from oscar.forms.mixins import PhoneNumberMixin
9 9
 
10 10
 User = get_user_model()
11
-AbstractAddressForm = get_class('address.forms', 'AbstractAddressForm')
12
-Country = get_model('address', 'Country')
11
+AbstractAddressForm = get_class("address.forms", "AbstractAddressForm")
12
+Country = get_model("address", "Country")
13 13
 
14 14
 
15 15
 class ShippingAddressForm(PhoneNumberMixin, AbstractAddressForm):
16
-
17 16
     def __init__(self, *args, **kwargs):
18 17
         super().__init__(*args, **kwargs)
19 18
         self.adjust_country_field()
20 19
 
21 20
     def adjust_country_field(self):
22
-        countries = Country._default_manager.filter(
23
-            is_shipping_country=True)
21
+        countries = Country._default_manager.filter(is_shipping_country=True)
24 22
 
25 23
         # No need to show country dropdown if there is only one option
26 24
         if len(countries) == 1:
27
-            self.fields.pop('country', None)
25
+            self.fields.pop("country", None)
28 26
             self.instance.country = countries[0]
29 27
         else:
30
-            self.fields['country'].queryset = countries
31
-            self.fields['country'].empty_label = None
28
+            self.fields["country"].queryset = countries
29
+            self.fields["country"].empty_label = None
32 30
 
33 31
     class Meta:
34
-        model = get_model('order', 'shippingaddress')
32
+        model = get_model("order", "shippingaddress")
35 33
         fields = [
36
-            'first_name', 'last_name',
37
-            'line1', 'line2', 'line3', 'line4',
38
-            'state', 'postcode', 'country',
39
-            'phone_number', 'notes',
34
+            "first_name",
35
+            "last_name",
36
+            "line1",
37
+            "line2",
38
+            "line3",
39
+            "line4",
40
+            "state",
41
+            "postcode",
42
+            "country",
43
+            "phone_number",
44
+            "notes",
40 45
         ]
41 46
 
42 47
 
@@ -44,31 +49,38 @@ class ShippingMethodForm(forms.Form):
44 49
     method_code = forms.ChoiceField(widget=forms.HiddenInput)
45 50
 
46 51
     def __init__(self, *args, **kwargs):
47
-        methods = kwargs.pop('methods', [])
52
+        methods = kwargs.pop("methods", [])
48 53
         super().__init__(*args, **kwargs)
49
-        self.fields['method_code'].choices = ((m.code, m.name) for m in methods)
54
+        self.fields["method_code"].choices = ((m.code, m.name) for m in methods)
50 55
 
51 56
 
52 57
 class GatewayForm(AuthenticationForm):
53 58
     username = forms.EmailField(label=_("My email address is"))
54
-    GUEST, NEW, EXISTING = 'anonymous', 'new', 'existing'
59
+    GUEST, NEW, EXISTING = "anonymous", "new", "existing"
55 60
     CHOICES = (
56
-        (GUEST, _('I am a new customer and want to checkout as a guest')),
57
-        (NEW, _('I am a new customer and want to create an account '
58
-                'before checking out')),
59
-        (EXISTING, _('I am a returning customer, and my password is')))
60
-    options = forms.ChoiceField(widget=forms.widgets.RadioSelect,
61
-                                choices=CHOICES, initial=GUEST)
61
+        (GUEST, _("I am a new customer and want to checkout as a guest")),
62
+        (
63
+            NEW,
64
+            _(
65
+                "I am a new customer and want to create an account "
66
+                "before checking out"
67
+            ),
68
+        ),
69
+        (EXISTING, _("I am a returning customer, and my password is")),
70
+    )
71
+    options = forms.ChoiceField(
72
+        widget=forms.widgets.RadioSelect, choices=CHOICES, initial=GUEST
73
+    )
62 74
 
63 75
     def clean_username(self):
64
-        return normalise_email(self.cleaned_data['username'])
76
+        return normalise_email(self.cleaned_data["username"])
65 77
 
66 78
     def clean(self):
67 79
         if self.is_guest_checkout() or self.is_new_account_checkout():
68
-            if 'password' in self.errors:
69
-                del self.errors['password']
70
-            if 'username' in self.cleaned_data:
71
-                email = normalise_email(self.cleaned_data['username'])
80
+            if "password" in self.errors:
81
+                del self.errors["password"]
82
+            if "username" in self.cleaned_data:
83
+                email = normalise_email(self.cleaned_data["username"])
72 84
                 if User._default_manager.filter(email__iexact=email).exists():
73 85
                     msg = _("A user with that email address already exists")
74 86
                     self._errors["username"] = self.error_class([msg])
@@ -76,10 +88,10 @@ class GatewayForm(AuthenticationForm):
76 88
         return super().clean()
77 89
 
78 90
     def is_guest_checkout(self):
79
-        return self.cleaned_data.get('options', None) == self.GUEST
91
+        return self.cleaned_data.get("options", None) == self.GUEST
80 92
 
81 93
     def is_new_account_checkout(self):
82
-        return self.cleaned_data.get('options', None) == self.NEW
94
+        return self.cleaned_data.get("options", None) == self.NEW
83 95
 
84 96
 
85 97
 # The BillingAddress form is in oscar.apps.payment.forms

+ 93
- 57
src/oscar/apps/checkout/mixins.py View File

@@ -8,20 +8,20 @@ from django.urls import NoReverseMatch, reverse
8 8
 from oscar.apps.checkout.signals import post_checkout
9 9
 from oscar.core.loading import get_class, get_model
10 10
 
11
-OrderCreator = get_class('order.utils', 'OrderCreator')
12
-OrderDispatcher = get_class('order.utils', 'OrderDispatcher')
13
-CheckoutSessionMixin = get_class('checkout.session', 'CheckoutSessionMixin')
14
-BillingAddress = get_model('order', 'BillingAddress')
15
-ShippingAddress = get_model('order', 'ShippingAddress')
16
-OrderNumberGenerator = get_class('order.utils', 'OrderNumberGenerator')
17
-PaymentEventType = get_model('order', 'PaymentEventType')
18
-PaymentEvent = get_model('order', 'PaymentEvent')
19
-PaymentEventQuantity = get_model('order', 'PaymentEventQuantity')
20
-UserAddress = get_model('address', 'UserAddress')
21
-Basket = get_model('basket', 'Basket')
11
+OrderCreator = get_class("order.utils", "OrderCreator")
12
+OrderDispatcher = get_class("order.utils", "OrderDispatcher")
13
+CheckoutSessionMixin = get_class("checkout.session", "CheckoutSessionMixin")
14
+BillingAddress = get_model("order", "BillingAddress")
15
+ShippingAddress = get_model("order", "ShippingAddress")
16
+OrderNumberGenerator = get_class("order.utils", "OrderNumberGenerator")
17
+PaymentEventType = get_model("order", "PaymentEventType")
18
+PaymentEvent = get_model("order", "PaymentEvent")
19
+PaymentEventQuantity = get_model("order", "PaymentEventQuantity")
20
+UserAddress = get_model("address", "UserAddress")
21
+Basket = get_model("basket", "Basket")
22 22
 
23 23
 # Standard logger for checkout events
24
-logger = logging.getLogger('oscar.checkout')
24
+logger = logging.getLogger("oscar.checkout")
25 25
 
26 26
 
27 27
 class OrderPlacementMixin(CheckoutSessionMixin):
@@ -30,6 +30,7 @@ class OrderPlacementMixin(CheckoutSessionMixin):
30 30
 
31 31
     Any view class which needs to place an order should use this mixin.
32 32
     """
33
+
33 34
     # Any payment sources should be added to this list as part of the
34 35
     # handle_payment method.  If the order is placed successfully, then
35 36
     # they will be persisted. We need to have the order instance before the
@@ -57,7 +58,6 @@ class OrderPlacementMixin(CheckoutSessionMixin):
57 58
         events (using add_payment_event) so they can be
58 59
         linked to the order when it is saved later on.
59 60
         """
60
-        pass
61 61
 
62 62
     def add_payment_source(self, source):
63 63
         """
@@ -67,18 +67,15 @@ class OrderPlacementMixin(CheckoutSessionMixin):
67 67
             self._payment_sources = []
68 68
         self._payment_sources.append(source)
69 69
 
70
-    def add_payment_event(self, event_type_name, amount, reference=''):
70
+    def add_payment_event(self, event_type_name, amount, reference=""):
71 71
         """
72 72
         Record a payment event for creation once the order is placed
73 73
         """
74
-        event_type, __ = PaymentEventType.objects.get_or_create(
75
-            name=event_type_name)
74
+        event_type, __ = PaymentEventType.objects.get_or_create(name=event_type_name)
76 75
         # We keep a local cache of (unsaved) payment events
77 76
         if self._payment_events is None:
78 77
             self._payment_events = []
79
-        event = PaymentEvent(
80
-            event_type=event_type, amount=amount,
81
-            reference=reference)
78
+        event = PaymentEvent(event_type=event_type, amount=amount, reference=reference)
82 79
         self._payment_events.append(event)
83 80
 
84 81
     # Placing order methods
@@ -90,10 +87,19 @@ class OrderPlacementMixin(CheckoutSessionMixin):
90 87
         """
91 88
         return OrderNumberGenerator().order_number(basket)
92 89
 
93
-    def handle_order_placement(self, order_number, user, basket,
94
-                               shipping_address, shipping_method,
95
-                               shipping_charge, billing_address, order_total,
96
-                               surcharges=None, **kwargs):
90
+    def handle_order_placement(
91
+        self,
92
+        order_number,
93
+        user,
94
+        basket,
95
+        shipping_address,
96
+        shipping_method,
97
+        shipping_charge,
98
+        billing_address,
99
+        order_total,
100
+        surcharges=None,
101
+        **kwargs
102
+    ):
97 103
         """
98 104
         Write out the order models and return the appropriate HTTP response
99 105
 
@@ -102,16 +108,33 @@ class OrderPlacementMixin(CheckoutSessionMixin):
102 108
         can happen when a basket gets frozen.
103 109
         """
104 110
         order = self.place_order(
105
-            order_number=order_number, user=user, basket=basket,
106
-            shipping_address=shipping_address, shipping_method=shipping_method,
107
-            shipping_charge=shipping_charge, order_total=order_total,
108
-            billing_address=billing_address, surcharges=surcharges, **kwargs)
111
+            order_number=order_number,
112
+            user=user,
113
+            basket=basket,
114
+            shipping_address=shipping_address,
115
+            shipping_method=shipping_method,
116
+            shipping_charge=shipping_charge,
117
+            order_total=order_total,
118
+            billing_address=billing_address,
119
+            surcharges=surcharges,
120
+            **kwargs
121
+        )
109 122
         basket.submit()
110 123
         return self.handle_successful_order(order)
111 124
 
112
-    def place_order(self, order_number, user, basket, shipping_address,
113
-                    shipping_method, shipping_charge, order_total,
114
-                    billing_address=None, surcharges=None, **kwargs):
125
+    def place_order(
126
+        self,
127
+        order_number,
128
+        user,
129
+        basket,
130
+        shipping_address,
131
+        shipping_method,
132
+        shipping_charge,
133
+        order_total,
134
+        billing_address=None,
135
+        surcharges=None,
136
+        **kwargs
137
+    ):
115 138
         """
116 139
         Writes the order out to the DB including the payment models
117 140
         """
@@ -122,17 +145,19 @@ class OrderPlacementMixin(CheckoutSessionMixin):
122 145
         # We pass the kwargs as they often include the billing address form
123 146
         # which will be needed to save a billing address.
124 147
         billing_address = self.create_billing_address(
125
-            user, billing_address, shipping_address, **kwargs)
148
+            user, billing_address, shipping_address, **kwargs
149
+        )
126 150
 
127
-        if 'status' not in kwargs:
151
+        if "status" not in kwargs:
152
+            # pylint: disable=assignment-from-none
128 153
             status = self.get_initial_order_status(basket)
129 154
         else:
130
-            status = kwargs.pop('status')
155
+            status = kwargs.pop("status")
131 156
 
132
-        if 'request' not in kwargs:
133
-            request = getattr(self, 'request', None)
157
+        if "request" not in kwargs:
158
+            request = getattr(self, "request", None)
134 159
         else:
135
-            request = kwargs.pop('request')
160
+            request = kwargs.pop("request")
136 161
 
137 162
         order = OrderCreator().place_order(
138 163
             user=user,
@@ -146,7 +171,8 @@ class OrderPlacementMixin(CheckoutSessionMixin):
146 171
             status=status,
147 172
             request=request,
148 173
             surcharges=surcharges,
149
-            **kwargs)
174
+            **kwargs
175
+        )
150 176
         self.save_payment_details(order)
151 177
         return order
152 178
 
@@ -171,8 +197,7 @@ class OrderPlacementMixin(CheckoutSessionMixin):
171 197
         Update the user's address book based on the new shipping address
172 198
         """
173 199
         try:
174
-            user_addr = user.addresses.get(
175
-                hash=addr.generate_hash())
200
+            user_addr = user.addresses.get(hash=addr.generate_hash())
176 201
         except ObjectDoesNotExist:
177 202
             # Create a new user address
178 203
             user_addr = UserAddress(user=user)
@@ -183,8 +208,10 @@ class OrderPlacementMixin(CheckoutSessionMixin):
183 208
             user_addr.num_orders_as_billing_address += 1
184 209
         user_addr.save()
185 210
 
186
-    def create_billing_address(self, user, billing_address=None,
187
-                               shipping_address=None, **kwargs):
211
+    # pylint: disable=unused-argument
212
+    def create_billing_address(
213
+        self, user, billing_address=None, shipping_address=None, **kwargs
214
+    ):
188 215
         """
189 216
         Saves any relevant billing data (e.g. a billing address).
190 217
         """
@@ -214,7 +241,8 @@ class OrderPlacementMixin(CheckoutSessionMixin):
214 241
             event.save()
215 242
             for line in order.lines.all():
216 243
                 PaymentEventQuantity.objects.create(
217
-                    event=event, line=line, quantity=line.quantity)
244
+                    event=event, line=line, quantity=line.quantity
245
+                )
218 246
 
219 247
     def save_payment_sources(self, order):
220 248
         """
@@ -229,6 +257,7 @@ class OrderPlacementMixin(CheckoutSessionMixin):
229 257
             source.order = order
230 258
             source.save()
231 259
 
260
+    # pylint: disable=unused-argument
232 261
     def get_initial_order_status(self, basket):
233 262
         return None
234 263
 
@@ -250,7 +279,7 @@ class OrderPlacementMixin(CheckoutSessionMixin):
250 279
         self.checkout_session.flush()
251 280
 
252 281
         # Save order id in session so thank-you page can load it
253
-        self.request.session['checkout_order_id'] = order.id
282
+        self.request.session["checkout_order_id"] = order.id
254 283
 
255 284
         response = HttpResponseRedirect(self.get_success_url())
256 285
         self.send_signal(self.request, response, order)
@@ -258,11 +287,15 @@ class OrderPlacementMixin(CheckoutSessionMixin):
258 287
 
259 288
     def send_signal(self, request, response, order):
260 289
         self.view_signal.send(
261
-            sender=self, order=order, user=request.user,
262
-            request=request, response=response)
290
+            sender=self,
291
+            order=order,
292
+            user=request.user,
293
+            request=request,
294
+            response=response,
295
+        )
263 296
 
264 297
     def get_success_url(self):
265
-        return reverse('checkout:thank-you')
298
+        return reverse("checkout:thank-you")
266 299
 
267 300
     def send_order_placed_email(self, order):
268 301
         extra_context = self.get_message_context(order)
@@ -271,30 +304,33 @@ class OrderPlacementMixin(CheckoutSessionMixin):
271 304
 
272 305
     def get_message_context(self, order):
273 306
         ctx = {
274
-            'user': self.request.user,
275
-            'order': order,
276
-            'lines': order.lines.all(),
277
-            'request': self.request,
307
+            "user": self.request.user,
308
+            "order": order,
309
+            "lines": order.lines.all(),
310
+            "request": self.request,
278 311
         }
279 312
 
280 313
         # Attempt to add the order status URL to the email template ctx.
281 314
         try:
282 315
             if self.request.user.is_authenticated:
283
-                path = reverse('customer:order',
284
-                               kwargs={'order_number': order.number})
316
+                path = reverse("customer:order", kwargs={"order_number": order.number})
285 317
             else:
286
-                path = reverse('customer:anon-order',
287
-                               kwargs={'order_number': order.number,
288
-                                       'hash': order.verification_hash()})
318
+                path = reverse(
319
+                    "customer:anon-order",
320
+                    kwargs={
321
+                        "order_number": order.number,
322
+                        "hash": order.verification_hash(),
323
+                    },
324
+                )
289 325
         except NoReverseMatch:
290 326
             # We don't care that much if we can't resolve the URL
291 327
             pass
292 328
         else:
293
-            ctx['status_path'] = path
329
+            ctx["status_path"] = path
294 330
 
295 331
             # status_url is deprecated, see https://github.com/django-oscar/django-oscar/issues/3826
296 332
             site = Site.objects.get_current(self.request)
297
-            ctx['status_url'] = 'http://%s%s' % (site.domain, path)
333
+            ctx["status_url"] = "http://%s%s" % (site.domain, path)
298 334
         return ctx
299 335
 
300 336
     # Basket helpers

+ 83
- 83
src/oscar/apps/checkout/session.py View File

@@ -11,15 +11,13 @@ from oscar.core.loading import get_class, get_model
11 11
 
12 12
 from . import exceptions
13 13
 
14
-Repository = get_class('shipping.repository', 'Repository')
14
+Repository = get_class("shipping.repository", "Repository")
15 15
 SurchargeApplicator = get_class("checkout.applicator", "SurchargeApplicator")
16
-OrderTotalCalculator = get_class(
17
-    'checkout.calculators', 'OrderTotalCalculator')
18
-CheckoutSessionData = get_class(
19
-    'checkout.utils', 'CheckoutSessionData')
20
-ShippingAddress = get_model('order', 'ShippingAddress')
21
-BillingAddress = get_model('order', 'BillingAddress')
22
-UserAddress = get_model('address', 'UserAddress')
16
+OrderTotalCalculator = get_class("checkout.calculators", "OrderTotalCalculator")
17
+CheckoutSessionData = get_class("checkout.utils", "CheckoutSessionData")
18
+ShippingAddress = get_model("order", "ShippingAddress")
19
+BillingAddress = get_model("order", "BillingAddress")
20
+UserAddress = get_model("address", "UserAddress")
23 21
 
24 22
 
25 23
 class CheckoutSessionMixin(object):
@@ -67,16 +65,15 @@ class CheckoutSessionMixin(object):
67 65
                 messages.warning(request, message)
68 66
             return http.HttpResponseRedirect(e.url)
69 67
 
70
-        return super().dispatch(
71
-            request, *args, **kwargs)
68
+        return super().dispatch(request, *args, **kwargs)
72 69
 
73 70
     def check_pre_conditions(self, request):
74 71
         pre_conditions = self.get_pre_conditions(request)
75 72
         for method_name in pre_conditions:
76 73
             if not hasattr(self, method_name):
77 74
                 raise ImproperlyConfigured(
78
-                    "There is no method '%s' to call as a pre-condition" % (
79
-                        method_name))
75
+                    "There is no method '%s' to call as a pre-condition" % (method_name)
76
+                )
80 77
             getattr(self, method_name)(request)
81 78
 
82 79
     def get_pre_conditions(self, request):
@@ -92,8 +89,9 @@ class CheckoutSessionMixin(object):
92 89
         for method_name in skip_conditions:
93 90
             if not hasattr(self, method_name):
94 91
                 raise ImproperlyConfigured(
95
-                    "There is no method '%s' to call as a skip-condition" % (
96
-                        method_name))
92
+                    "There is no method '%s' to call as a skip-condition"
93
+                    % (method_name)
94
+                )
97 95
             getattr(self, method_name)(request)
98 96
 
99 97
     def get_skip_conditions(self, request):
@@ -109,9 +107,8 @@ class CheckoutSessionMixin(object):
109 107
     def check_basket_is_not_empty(self, request):
110 108
         if request.basket.is_empty:
111 109
             raise exceptions.FailedPreCondition(
112
-                url=reverse('basket:summary'),
113
-                message=_(
114
-                    "You need to add some items to your basket to checkout")
110
+                url=reverse("basket:summary"),
111
+                message=_("You need to add some items to your basket to checkout"),
115 112
             )
116 113
 
117 114
     def check_basket_is_valid(self, request):
@@ -120,44 +117,42 @@ class CheckoutSessionMixin(object):
120 117
         is, all the basket lines are available to buy - nothing has gone out of
121 118
         stock since it was added to the basket.
122 119
         """
123
-        messages = []
120
+        messages_list = []
124 121
         strategy = request.strategy
125 122
         for line in request.basket.all_lines():
126 123
             result = strategy.fetch_for_line(line)
127 124
             is_permitted, reason = result.availability.is_purchase_permitted(
128
-                line.quantity)
125
+                line.quantity
126
+            )
129 127
             if not is_permitted:
130 128
                 # Create a more meaningful message to show on the basket page
131 129
                 msg = _(
132 130
                     "'%(title)s' is no longer available to buy (%(reason)s). "
133 131
                     "Please adjust your basket to continue"
134
-                ) % {
135
-                    'title': line.product.get_title(),
136
-                    'reason': reason}
137
-                messages.append(msg)
138
-        if messages:
132
+                ) % {"title": line.product.get_title(), "reason": reason}
133
+                messages_list.append(msg)
134
+        if messages_list:
139 135
             raise exceptions.FailedPreCondition(
140
-                url=reverse('basket:summary'),
141
-                messages=messages
136
+                url=reverse("basket:summary"), messages=messages_list
142 137
             )
143 138
 
144 139
     def check_user_email_is_captured(self, request):
145
-        if not request.user.is_authenticated \
146
-                and not self.checkout_session.get_guest_email():
140
+        if (
141
+            not request.user.is_authenticated
142
+            and not self.checkout_session.get_guest_email()
143
+        ):
147 144
             raise exceptions.FailedPreCondition(
148
-                url=reverse('checkout:index'),
149
-                message=_(
150
-                    "Please either sign in or enter your email address")
145
+                url=reverse("checkout:index"),
146
+                message=_("Please either sign in or enter your email address"),
151 147
             )
152 148
 
153 149
     def check_shipping_data_is_captured(self, request):
154 150
         if not request.basket.is_shipping_required():
155 151
             # Even without shipping being required, we still need to check that
156 152
             # a shipping method code has been set.
157
-            if not self.checkout_session.is_shipping_method_set(
158
-                    self.request.basket):
153
+            if not self.checkout_session.is_shipping_method_set(self.request.basket):
159 154
                 raise exceptions.FailedPreCondition(
160
-                    url=reverse('checkout:shipping-method'),
155
+                    url=reverse("checkout:shipping-method"),
161 156
                 )
162 157
             return
163 158
 
@@ -170,40 +165,41 @@ class CheckoutSessionMixin(object):
170 165
         # Check that shipping address has been completed
171 166
         if not self.checkout_session.is_shipping_address_set():
172 167
             raise exceptions.FailedPreCondition(
173
-                url=reverse('checkout:shipping-address'),
174
-                message=_("Please choose a shipping address")
168
+                url=reverse("checkout:shipping-address"),
169
+                message=_("Please choose a shipping address"),
175 170
             )
176 171
 
177 172
         # Check that the previously chosen shipping address is still valid
178
-        shipping_address = self.get_shipping_address(
179
-            basket=self.request.basket)
173
+        shipping_address = self.get_shipping_address(basket=self.request.basket)
180 174
         if not shipping_address:
181 175
             raise exceptions.FailedPreCondition(
182
-                url=reverse('checkout:shipping-address'),
183
-                message=_("Your previously chosen shipping address is "
184
-                          "no longer valid.  Please choose another one")
176
+                url=reverse("checkout:shipping-address"),
177
+                message=_(
178
+                    "Your previously chosen shipping address is "
179
+                    "no longer valid.  Please choose another one"
180
+                ),
185 181
             )
186 182
 
187 183
     def check_a_valid_shipping_method_is_captured(self):
188 184
         # Check that shipping method has been set
189
-        if not self.checkout_session.is_shipping_method_set(
190
-                self.request.basket):
185
+        if not self.checkout_session.is_shipping_method_set(self.request.basket):
191 186
             raise exceptions.FailedPreCondition(
192
-                url=reverse('checkout:shipping-method'),
193
-                message=_("Please choose a shipping method")
187
+                url=reverse("checkout:shipping-method"),
188
+                message=_("Please choose a shipping method"),
194 189
             )
195 190
 
196 191
         # Check that a *valid* shipping method has been set
197
-        shipping_address = self.get_shipping_address(
198
-            basket=self.request.basket)
192
+        shipping_address = self.get_shipping_address(basket=self.request.basket)
199 193
         shipping_method = self.get_shipping_method(
200
-            basket=self.request.basket,
201
-            shipping_address=shipping_address)
194
+            basket=self.request.basket, shipping_address=shipping_address
195
+        )
202 196
         if not shipping_method:
203 197
             raise exceptions.FailedPreCondition(
204
-                url=reverse('checkout:shipping-method'),
205
-                message=_("Your previously chosen shipping method is "
206
-                          "no longer valid.  Please choose another one")
198
+                url=reverse("checkout:shipping-method"),
199
+                message=_(
200
+                    "Your previously chosen shipping method is "
201
+                    "no longer valid.  Please choose another one"
202
+                ),
207 203
             )
208 204
 
209 205
     def check_payment_data_is_captured(self, request):
@@ -221,14 +217,13 @@ class CheckoutSessionMixin(object):
221 217
         # not be if the basket is purely downloads
222 218
         if not request.basket.is_shipping_required():
223 219
             raise exceptions.PassedSkipCondition(
224
-                url=reverse('checkout:shipping-method')
220
+                url=reverse("checkout:shipping-method")
225 221
             )
226 222
 
227 223
     def skip_unless_payment_is_required(self, request):
228 224
         # Check to see if payment is actually required for this order.
229 225
         shipping_address = self.get_shipping_address(request.basket)
230
-        shipping_method = self.get_shipping_method(
231
-            request.basket, shipping_address)
226
+        shipping_method = self.get_shipping_method(request.basket, shipping_address)
232 227
         if shipping_method:
233 228
             shipping_charge = shipping_method.calculate(request.basket)
234 229
         else:
@@ -236,18 +231,15 @@ class CheckoutSessionMixin(object):
236 231
             # the time this skip-condition is called. In the absence of any
237 232
             # other evidence, we assume the shipping charge is zero.
238 233
             shipping_charge = prices.Price(
239
-                currency=request.basket.currency, excl_tax=D('0.00'),
240
-                tax=D('0.00')
234
+                currency=request.basket.currency, excl_tax=D("0.00"), tax=D("0.00")
241 235
             )
242 236
 
243 237
         surcharges = SurchargeApplicator(request).get_applicable_surcharges(
244 238
             basket=request.basket, shipping_charge=shipping_charge
245 239
         )
246 240
         total = self.get_order_totals(request.basket, shipping_charge, surcharges)
247
-        if total.excl_tax == D('0.00'):
248
-            raise exceptions.PassedSkipCondition(
249
-                url=reverse('checkout:preview')
250
-            )
241
+        if total.excl_tax == D("0.00"):
242
+            raise exceptions.PassedSkipCondition(url=reverse("checkout:preview"))
251 243
 
252 244
     # Helpers
253 245
 
@@ -257,7 +249,7 @@ class CheckoutSessionMixin(object):
257 249
         ctx = super().get_context_data()
258 250
         ctx.update(self.build_submission(**kwargs))
259 251
         ctx.update(kwargs)
260
-        ctx.update(ctx['order_kwargs'])
252
+        ctx.update(ctx["order_kwargs"])
261 253
         return ctx
262 254
 
263 255
     def build_submission(self, **kwargs):
@@ -270,34 +262,36 @@ class CheckoutSessionMixin(object):
270 262
         """
271 263
         # Pop the basket if there is one, because we pass it as a positional
272 264
         # argument to methods below
273
-        basket = kwargs.pop('basket', self.request.basket)
265
+        basket = kwargs.pop("basket", self.request.basket)
274 266
         shipping_address = self.get_shipping_address(basket)
275
-        shipping_method = self.get_shipping_method(
276
-            basket, shipping_address)
267
+        shipping_method = self.get_shipping_method(basket, shipping_address)
277 268
         billing_address = self.get_billing_address(shipping_address)
278 269
         submission = {
279
-            'user': self.request.user,
280
-            'basket': basket,
281
-            'shipping_address': shipping_address,
282
-            'shipping_method': shipping_method,
283
-            'billing_address': billing_address,
284
-            'order_kwargs': {},
285
-            'payment_kwargs': {}
270
+            "user": self.request.user,
271
+            "basket": basket,
272
+            "shipping_address": shipping_address,
273
+            "shipping_method": shipping_method,
274
+            "billing_address": billing_address,
275
+            "order_kwargs": {},
276
+            "payment_kwargs": {},
286 277
         }
287 278
 
288 279
         if not shipping_method:
289 280
             total = shipping_charge = surcharges = None
290 281
         else:
291 282
             shipping_charge = shipping_method.calculate(basket)
292
-            surcharges = SurchargeApplicator(self.request, submission).get_applicable_surcharges(
283
+            surcharges = SurchargeApplicator(
284
+                self.request, submission
285
+            ).get_applicable_surcharges(
293 286
                 self.request.basket, shipping_charge=shipping_charge
294 287
             )
295 288
             total = self.get_order_totals(
296
-                basket, shipping_charge=shipping_charge, surcharges=surcharges, **kwargs)
289
+                basket, shipping_charge=shipping_charge, surcharges=surcharges, **kwargs
290
+            )
297 291
 
298 292
         submission["shipping_charge"] = shipping_charge
299 293
         submission["order_total"] = total
300
-        submission['surcharges'] = surcharges
294
+        submission["surcharges"] = surcharges
301 295
 
302 296
         # If there is a billing address, add it to the payment kwargs as calls
303 297
         # to payment gateways generally require the billing address. Note, that
@@ -305,18 +299,20 @@ class CheckoutSessionMixin(object):
305 299
         # billing address information. That way, if payment fails, you can
306 300
         # render bound forms in the template to make re-submission easier.
307 301
         if billing_address:
308
-            submission['payment_kwargs']['billing_address'] = billing_address
302
+            submission["payment_kwargs"]["billing_address"] = billing_address
309 303
 
310 304
         # Allow overrides to be passed in
311 305
         submission.update(kwargs)
312 306
 
313 307
         # Set guest email after overrides as we need to update the order_kwargs
314 308
         # entry.
315
-        user = submission['user']
316
-        if (not user.is_authenticated
317
-                and 'guest_email' not in submission['order_kwargs']):
309
+        user = submission["user"]
310
+        if (
311
+            not user.is_authenticated
312
+            and "guest_email" not in submission["order_kwargs"]
313
+        ):
318 314
             email = self.checkout_session.get_guest_email()
319
-            submission['order_kwargs']['guest_email'] = email
315
+            submission["order_kwargs"]["guest_email"] = email
320 316
         return submission
321 317
 
322 318
     def get_shipping_address(self, basket):
@@ -370,8 +366,11 @@ class CheckoutSessionMixin(object):
370 366
         """
371 367
         code = self.checkout_session.shipping_method_code(basket)
372 368
         methods = Repository().get_shipping_methods(
373
-            basket=basket, user=self.request.user,
374
-            shipping_addr=shipping_address, request=self.request)
369
+            basket=basket,
370
+            user=self.request.user,
371
+            shipping_addr=shipping_address,
372
+            request=self.request,
373
+        )
375 374
         for method in methods:
376 375
             if method.code == code:
377 376
                 return method
@@ -425,4 +424,5 @@ class CheckoutSessionMixin(object):
425 424
         Returns the total for the order with and without tax
426 425
         """
427 426
         return OrderTotalCalculator(self.request).calculate(
428
-            basket, shipping_charge, surcharges, **kwargs)
427
+            basket, shipping_charge, surcharges, **kwargs
428
+        )

+ 4
- 7
src/oscar/apps/checkout/surcharges.py View File

@@ -40,13 +40,11 @@ class PercentageCharge(BaseSurcharge):
40 40
             return prices.Price(
41 41
                 currency=basket.currency,
42 42
                 excl_tax=total_excl_tax * self.percentage / 100,
43
-                incl_tax=total_incl_tax * self.percentage / 100
43
+                incl_tax=total_incl_tax * self.percentage / 100,
44 44
             )
45 45
         else:
46 46
             return prices.Price(
47
-                currency=basket.currency,
48
-                excl_tax=D('0.0'),
49
-                incl_tax=D('0.0')
47
+                currency=basket.currency, excl_tax=D("0.0"), incl_tax=D("0.0")
50 48
             )
51 49
 
52 50
 
@@ -60,6 +58,5 @@ class FlatCharge(BaseSurcharge):
60 58
 
61 59
     def calculate(self, basket, **kwargs):
62 60
         return prices.Price(
63
-            currency=basket.currency,
64
-            excl_tax=self.excl_tax,
65
-            incl_tax=self.incl_tax)
61
+            currency=basket.currency, excl_tax=self.excl_tax, incl_tax=self.incl_tax
62
+        )

+ 33
- 31
src/oscar/apps/checkout/utils.py View File

@@ -10,7 +10,8 @@ class CheckoutSessionData(object):
10 10
     organise checkout form data until it is required to write out the final
11 11
     order.
12 12
     """
13
-    SESSION_KEY = 'checkout_data'
13
+
14
+    SESSION_KEY = "checkout_data"
14 15
 
15 16
     def __init__(self, request):
16 17
         self.request = request
@@ -67,10 +68,10 @@ class CheckoutSessionData(object):
67 68
     # ==============
68 69
 
69 70
     def set_guest_email(self, email):
70
-        self._set('guest', 'email', email)
71
+        self._set("guest", "email", email)
71 72
 
72 73
     def get_guest_email(self):
73
-        return self._get('guest', 'email')
74
+        return self._get("guest", "email")
74 75
 
75 76
     # Shipping address
76 77
     # ================
@@ -80,39 +81,39 @@ class CheckoutSessionData(object):
80 81
     # 3. Ship to an address book address (address chosen from list)
81 82
 
82 83
     def reset_shipping_data(self):
83
-        self._flush_namespace('shipping')
84
+        self._flush_namespace("shipping")
84 85
 
85 86
     def ship_to_user_address(self, address):
86 87
         """
87 88
         Use an user address (from an address book) as the shipping address.
88 89
         """
89 90
         self.reset_shipping_data()
90
-        self._set('shipping', 'user_address_id', address.id)
91
+        self._set("shipping", "user_address_id", address.id)
91 92
 
92 93
     def ship_to_new_address(self, address_fields):
93 94
         """
94 95
         Use a manually entered address as the shipping address
95 96
         """
96
-        self._unset('shipping', 'new_address_fields')
97
-        phone_number = address_fields.get('phone_number')
97
+        self._unset("shipping", "new_address_fields")
98
+        phone_number = address_fields.get("phone_number")
98 99
         if phone_number:
99 100
             # Phone number is stored as a PhoneNumber instance. As we store
100 101
             # strings in the session, we need to serialize it.
101 102
             address_fields = address_fields.copy()
102
-            address_fields['phone_number'] = phone_number.as_international
103
-        self._set('shipping', 'new_address_fields', address_fields)
103
+            address_fields["phone_number"] = phone_number.as_international
104
+        self._set("shipping", "new_address_fields", address_fields)
104 105
 
105 106
     def new_shipping_address_fields(self):
106 107
         """
107 108
         Return shipping address fields
108 109
         """
109
-        return self._get('shipping', 'new_address_fields')
110
+        return self._get("shipping", "new_address_fields")
110 111
 
111 112
     def shipping_user_address_id(self):
112 113
         """
113 114
         Return user address id
114 115
         """
115
-        return self._get('shipping', 'user_address_id')
116
+        return self._get("shipping", "user_address_id")
116 117
 
117 118
     # Legacy accessor
118 119
     user_address_id = shipping_user_address_id
@@ -136,19 +137,20 @@ class CheckoutSessionData(object):
136 137
         """
137 138
         Set "free shipping" code to session
138 139
         """
139
-        self._set('shipping', 'method_code', '__free__')
140
+        self._set("shipping", "method_code", "__free__")
140 141
 
141 142
     def use_shipping_method(self, code):
142 143
         """
143 144
         Set shipping method code to session
144 145
         """
145
-        self._set('shipping', 'method_code', code)
146
+        self._set("shipping", "method_code", code)
146 147
 
148
+    # pylint: disable=unused-argument
147 149
     def shipping_method_code(self, basket):
148 150
         """
149 151
         Return the shipping method code
150 152
         """
151
-        return self._get('shipping', 'method_code')
153
+        return self._get("shipping", "method_code")
152 154
 
153 155
     def is_shipping_method_set(self, basket):
154 156
         """
@@ -168,14 +170,14 @@ class CheckoutSessionData(object):
168 170
         """
169 171
         Store address fields for a billing address.
170 172
         """
171
-        self._unset('billing', 'new_address_fields')
172
-        phone_number = address_fields.get('phone_number')
173
+        self._unset("billing", "new_address_fields")
174
+        phone_number = address_fields.get("phone_number")
173 175
         if phone_number and isinstance(phone_number, PhoneNumber):
174 176
             # Phone number is stored as a PhoneNumber instance. As we store
175 177
             # strings in the session, we need to serialize it.
176 178
             address_fields = address_fields.copy()
177
-            address_fields['phone_number'] = phone_number.as_international
178
-        self._set('billing', 'new_address_fields', address_fields)
179
+            address_fields["phone_number"] = phone_number.as_international
180
+        self._set("billing", "new_address_fields", address_fields)
179 181
 
180 182
     def bill_to_user_address(self, address):
181 183
         """
@@ -183,34 +185,34 @@ class CheckoutSessionData(object):
183 185
 
184 186
         :address: The address object
185 187
         """
186
-        self._flush_namespace('billing')
187
-        self._set('billing', 'user_address_id', address.id)
188
+        self._flush_namespace("billing")
189
+        self._set("billing", "user_address_id", address.id)
188 190
 
189 191
     def bill_to_shipping_address(self):
190 192
         """
191 193
         Record fact that the billing address is to be the same as
192 194
         the shipping address.
193 195
         """
194
-        self._flush_namespace('billing')
195
-        self._set('billing', 'billing_address_same_as_shipping', True)
196
+        self._flush_namespace("billing")
197
+        self._set("billing", "billing_address_same_as_shipping", True)
196 198
 
197 199
     # Legacy method name
198 200
     billing_address_same_as_shipping = bill_to_shipping_address
199 201
 
200 202
     def is_billing_address_same_as_shipping(self):
201
-        return self._get('billing', 'billing_address_same_as_shipping', False)
203
+        return self._get("billing", "billing_address_same_as_shipping", False)
202 204
 
203 205
     def billing_user_address_id(self):
204 206
         """
205 207
         Return the ID of the user address being used for billing
206 208
         """
207
-        return self._get('billing', 'user_address_id')
209
+        return self._get("billing", "user_address_id")
208 210
 
209 211
     def new_billing_address_fields(self):
210 212
         """
211 213
         Return fields for a billing address
212 214
         """
213
-        return self._get('billing', 'new_address_fields')
215
+        return self._get("billing", "new_address_fields")
214 216
 
215 217
     def is_billing_address_set(self):
216 218
         """
@@ -230,22 +232,22 @@ class CheckoutSessionData(object):
230 232
     # ===============
231 233
 
232 234
     def pay_by(self, method):
233
-        self._set('payment', 'method', method)
235
+        self._set("payment", "method", method)
234 236
 
235 237
     def payment_method(self):
236
-        return self._get('payment', 'method')
238
+        return self._get("payment", "method")
237 239
 
238 240
     # Submission methods
239 241
     # ==================
240 242
 
241 243
     def set_order_number(self, order_number):
242
-        self._set('submission', 'order_number', order_number)
244
+        self._set("submission", "order_number", order_number)
243 245
 
244 246
     def get_order_number(self):
245
-        return self._get('submission', 'order_number')
247
+        return self._get("submission", "order_number")
246 248
 
247 249
     def set_submitted_basket(self, basket):
248
-        self._set('submission', 'basket_id', basket.id)
250
+        self._set("submission", "basket_id", basket.id)
249 251
 
250 252
     def get_submitted_basket_id(self):
251
-        return self._get('submission', 'basket_id')
253
+        return self._get("submission", "basket_id")

+ 197
- 138
src/oscar/apps/checkout/views.py View File

@@ -14,25 +14,25 @@ from oscar.core.loading import get_class, get_classes, get_model
14 14
 
15 15
 from . import signals
16 16
 
17
-ShippingAddressForm, ShippingMethodForm, GatewayForm \
18
-    = get_classes('checkout.forms', ['ShippingAddressForm', 'ShippingMethodForm', 'GatewayForm'])
19
-UserAddressForm = get_class('address.forms', 'UserAddressForm')
20
-Repository = get_class('shipping.repository', 'Repository')
21
-RedirectRequired, UnableToTakePayment, PaymentError \
22
-    = get_classes('payment.exceptions', ['RedirectRequired',
23
-                                         'UnableToTakePayment',
24
-                                         'PaymentError'])
25
-UnableToPlaceOrder = get_class('order.exceptions', 'UnableToPlaceOrder')
26
-OrderPlacementMixin = get_class('checkout.mixins', 'OrderPlacementMixin')
27
-CheckoutSessionMixin = get_class('checkout.session', 'CheckoutSessionMixin')
28
-NoShippingRequired = get_class('shipping.methods', 'NoShippingRequired')
29
-Order = get_model('order', 'Order')
30
-ShippingAddress = get_model('order', 'ShippingAddress')
31
-UserAddress = get_model('address', 'UserAddress')
32
-Country = get_model('address', 'Country')
17
+ShippingAddressForm, ShippingMethodForm, GatewayForm = get_classes(
18
+    "checkout.forms", ["ShippingAddressForm", "ShippingMethodForm", "GatewayForm"]
19
+)
20
+UserAddressForm = get_class("address.forms", "UserAddressForm")
21
+Repository = get_class("shipping.repository", "Repository")
22
+RedirectRequired, UnableToTakePayment, PaymentError = get_classes(
23
+    "payment.exceptions", ["RedirectRequired", "UnableToTakePayment", "PaymentError"]
24
+)
25
+UnableToPlaceOrder = get_class("order.exceptions", "UnableToPlaceOrder")
26
+OrderPlacementMixin = get_class("checkout.mixins", "OrderPlacementMixin")
27
+CheckoutSessionMixin = get_class("checkout.session", "CheckoutSessionMixin")
28
+NoShippingRequired = get_class("shipping.methods", "NoShippingRequired")
29
+Order = get_model("order", "Order")
30
+ShippingAddress = get_model("order", "ShippingAddress")
31
+UserAddress = get_model("address", "UserAddress")
32
+Country = get_model("address", "Country")
33 33
 
34 34
 # Standard logger for checkout events
35
-logger = logging.getLogger('oscar.checkout')
35
+logger = logging.getLogger("oscar.checkout")
36 36
 
37 37
 
38 38
 class IndexView(CheckoutSessionMixin, generic.FormView):
@@ -40,12 +40,11 @@ class IndexView(CheckoutSessionMixin, generic.FormView):
40 40
     First page of the checkout.  We prompt user to either sign in, or
41 41
     to proceed as a guest (where we still collect their email address).
42 42
     """
43
-    template_name = 'oscar/checkout/gateway.html'
43
+
44
+    template_name = "oscar/checkout/gateway.html"
44 45
     form_class = GatewayForm
45
-    success_url = reverse_lazy('checkout:shipping-address')
46
-    pre_conditions = [
47
-        'check_basket_is_not_empty',
48
-        'check_basket_is_valid']
46
+    success_url = reverse_lazy("checkout:shipping-address")
47
+    pre_conditions = ["check_basket_is_not_empty", "check_basket_is_valid"]
49 48
 
50 49
     def get(self, request, *args, **kwargs):
51 50
         # We redirect immediately to shipping address stage if the user is
@@ -53,8 +52,7 @@ class IndexView(CheckoutSessionMixin, generic.FormView):
53 52
         if request.user.is_authenticated:
54 53
             # We raise a signal to indicate that the user has entered the
55 54
             # checkout process so analytics tools can track this event.
56
-            signals.start_checkout.send_robust(
57
-                sender=self, request=request)
55
+            signals.start_checkout.send_robust(sender=self, request=request)
58 56
             return self.get_success_response()
59 57
         return super().get(request, *args, **kwargs)
60 58
 
@@ -62,30 +60,34 @@ class IndexView(CheckoutSessionMixin, generic.FormView):
62 60
         kwargs = super().get_form_kwargs()
63 61
         email = self.checkout_session.get_guest_email()
64 62
         if email:
65
-            kwargs['initial'] = {
66
-                'username': email,
63
+            kwargs["initial"] = {
64
+                "username": email,
67 65
             }
68 66
         return kwargs
69 67
 
70 68
     def form_valid(self, form):
71 69
         if form.is_guest_checkout() or form.is_new_account_checkout():
72
-            email = form.cleaned_data['username']
70
+            email = form.cleaned_data["username"]
73 71
             self.checkout_session.set_guest_email(email)
74 72
 
75 73
             # We raise a signal to indicate that the user has entered the
76 74
             # checkout process by specifying an email address.
77 75
             signals.start_checkout.send_robust(
78
-                sender=self, request=self.request, email=email)
76
+                sender=self, request=self.request, email=email
77
+            )
79 78
 
80 79
             if form.is_new_account_checkout():
81 80
                 messages.info(
82 81
                     self.request,
83
-                    _("Create your account and then you will be redirected "
84
-                      "back to the checkout process"))
82
+                    _(
83
+                        "Create your account and then you will be redirected "
84
+                        "back to the checkout process"
85
+                    ),
86
+                )
85 87
                 self.success_url = "%s?next=%s&email=%s" % (
86
-                    reverse('customer:register'),
87
-                    reverse('checkout:shipping-address'),
88
-                    quote(email)
88
+                    reverse("customer:register"),
89
+                    reverse("checkout:shipping-address"),
90
+                    quote(email),
89 91
                 )
90 92
         else:
91 93
             user = form.get_user()
@@ -93,8 +95,7 @@ class IndexView(CheckoutSessionMixin, generic.FormView):
93 95
 
94 96
             # We raise a signal to indicate that the user has entered the
95 97
             # checkout process.
96
-            signals.start_checkout.send_robust(
97
-                sender=self, request=self.request)
98
+            signals.start_checkout.send_robust(sender=self, request=self.request)
98 99
 
99 100
         return redirect(self.get_success_url())
100 101
 
@@ -121,13 +122,16 @@ class ShippingAddressView(CheckoutSessionMixin, generic.FormView):
121 122
     saved in the session and later saved as ShippingAddress model when the
122 123
     order is successfully submitted.
123 124
     """
124
-    template_name = 'oscar/checkout/shipping_address.html'
125
+
126
+    template_name = "oscar/checkout/shipping_address.html"
125 127
     form_class = ShippingAddressForm
126
-    success_url = reverse_lazy('checkout:shipping-method')
127
-    pre_conditions = ['check_basket_is_not_empty',
128
-                      'check_basket_is_valid',
129
-                      'check_user_email_is_captured']
130
-    skip_conditions = ['skip_unless_basket_requires_shipping']
128
+    success_url = reverse_lazy("checkout:shipping-method")
129
+    pre_conditions = [
130
+        "check_basket_is_not_empty",
131
+        "check_basket_is_valid",
132
+        "check_user_email_is_captured",
133
+    ]
134
+    skip_conditions = ["skip_unless_basket_requires_shipping"]
131 135
 
132 136
     def get_initial(self):
133 137
         initial = self.checkout_session.new_shipping_address_fields()
@@ -136,8 +140,9 @@ class ShippingAddressView(CheckoutSessionMixin, generic.FormView):
136 140
             # Convert the primary key stored in the session into a Country
137 141
             # instance
138 142
             try:
139
-                initial['country'] = Country.objects.get(
140
-                    iso_3166_1_a2=initial.pop('country_id'))
143
+                initial["country"] = Country.objects.get(
144
+                    iso_3166_1_a2=initial.pop("country_id")
145
+                )
141 146
             except Country.DoesNotExist:
142 147
                 # Hmm, the previously selected Country no longer exists. We
143 148
                 # ignore this.
@@ -148,7 +153,7 @@ class ShippingAddressView(CheckoutSessionMixin, generic.FormView):
148 153
         ctx = super().get_context_data(**kwargs)
149 154
         if self.request.user.is_authenticated:
150 155
             # Look up address book data
151
-            ctx['addresses'] = self.get_available_addresses()
156
+            ctx["addresses"] = self.get_available_addresses()
152 157
         return ctx
153 158
 
154 159
     def get_available_addresses(self):
@@ -156,32 +161,31 @@ class ShippingAddressView(CheckoutSessionMixin, generic.FormView):
156 161
         # shipping. Also, use ordering to ensure the default address comes
157 162
         # first.
158 163
         return self.request.user.addresses.filter(
159
-            country__is_shipping_country=True).order_by(
160
-            '-is_default_for_shipping')
164
+            country__is_shipping_country=True
165
+        ).order_by("-is_default_for_shipping")
161 166
 
162 167
     def post(self, request, *args, **kwargs):
163 168
         # Check if a shipping address was selected directly (e.g. no form was
164 169
         # filled in)
165
-        if self.request.user.is_authenticated \
166
-                and 'address_id' in self.request.POST:
170
+        if self.request.user.is_authenticated and "address_id" in self.request.POST:
167 171
             address = UserAddress._default_manager.get(
168
-                pk=self.request.POST['address_id'], user=self.request.user)
169
-            action = self.request.POST.get('action', None)
170
-            if action == 'ship_to':
172
+                pk=self.request.POST["address_id"], user=self.request.user
173
+            )
174
+            action = self.request.POST.get("action", None)
175
+            if action == "ship_to":
171 176
                 # User has selected a previous address to ship to
172 177
                 self.checkout_session.ship_to_user_address(address)
173 178
                 return redirect(self.get_success_url())
174 179
             else:
175 180
                 return http.HttpResponseBadRequest()
176 181
         else:
177
-            return super().post(
178
-                request, *args, **kwargs)
182
+            return super().post(request, *args, **kwargs)
179 183
 
180 184
     def form_valid(self, form):
181 185
         # Store the address details in the session and redirect to next step
182 186
         address_fields = dict(
183
-            (k, v) for (k, v) in form.instance.__dict__.items()
184
-            if not k.startswith('_'))
187
+            (k, v) for (k, v) in form.instance.__dict__.items() if not k.startswith("_")
188
+        )
185 189
         self.checkout_session.ship_to_new_address(address_fields)
186 190
         return super().form_valid(form)
187 191
 
@@ -190,16 +194,17 @@ class UserAddressUpdateView(CheckoutSessionMixin, generic.UpdateView):
190 194
     """
191 195
     Update a user address
192 196
     """
193
-    template_name = 'oscar/checkout/user_address_form.html'
197
+
198
+    template_name = "oscar/checkout/user_address_form.html"
194 199
     form_class = UserAddressForm
195
-    success_url = reverse_lazy('checkout:shipping-address')
200
+    success_url = reverse_lazy("checkout:shipping-address")
196 201
 
197 202
     def get_queryset(self):
198 203
         return self.request.user.addresses.all()
199 204
 
200 205
     def get_form_kwargs(self):
201 206
         kwargs = super().get_form_kwargs()
202
-        kwargs['user'] = self.request.user
207
+        kwargs["user"] = self.request.user
203 208
         return kwargs
204 209
 
205 210
     def get_success_url(self):
@@ -211,8 +216,9 @@ class UserAddressDeleteView(CheckoutSessionMixin, generic.DeleteView):
211 216
     """
212 217
     Delete an address from a user's address book.
213 218
     """
214
-    template_name = 'oscar/checkout/user_address_delete.html'
215
-    success_url = reverse_lazy('checkout:shipping-address')
219
+
220
+    template_name = "oscar/checkout/user_address_delete.html"
221
+    success_url = reverse_lazy("checkout:shipping-address")
216 222
 
217 223
     def get_queryset(self):
218 224
         return self.request.user.addresses.all()
@@ -227,6 +233,7 @@ class UserAddressDeleteView(CheckoutSessionMixin, generic.DeleteView):
227 233
 # ===============
228 234
 
229 235
 
236
+# pylint: disable=attribute-defined-outside-init
230 237
 class ShippingMethodView(CheckoutSessionMixin, generic.FormView):
231 238
     """
232 239
     View for allowing a user to choose a shipping method.
@@ -239,12 +246,15 @@ class ShippingMethodView(CheckoutSessionMixin, generic.FormView):
239 246
     automatically selected.  Otherwise, a page is rendered where
240 247
     the user can choose the appropriate one.
241 248
     """
242
-    template_name = 'oscar/checkout/shipping_methods.html'
249
+
250
+    template_name = "oscar/checkout/shipping_methods.html"
243 251
     form_class = ShippingMethodForm
244
-    pre_conditions = ['check_basket_is_not_empty',
245
-                      'check_basket_is_valid',
246
-                      'check_user_email_is_captured']
247
-    success_url = reverse_lazy('checkout:payment-method')
252
+    pre_conditions = [
253
+        "check_basket_is_not_empty",
254
+        "check_basket_is_valid",
255
+        "check_user_email_is_captured",
256
+    ]
257
+    success_url = reverse_lazy("checkout:payment-method")
248 258
 
249 259
     def post(self, request, *args, **kwargs):
250 260
         self._methods = self.get_available_shipping_methods()
@@ -258,24 +268,27 @@ class ShippingMethodView(CheckoutSessionMixin, generic.FormView):
258 268
         # Check that shipping is required at all
259 269
         if not request.basket.is_shipping_required():
260 270
             # No shipping required - we store a special code to indicate so.
261
-            self.checkout_session.use_shipping_method(
262
-                NoShippingRequired().code)
271
+            self.checkout_session.use_shipping_method(NoShippingRequired().code)
263 272
             return self.get_success_response()
264 273
 
265 274
         # Check that shipping address has been completed
266 275
         if not self.checkout_session.is_shipping_address_set():
267 276
             messages.error(request, _("Please choose a shipping address"))
268
-            return redirect('checkout:shipping-address')
277
+            return redirect("checkout:shipping-address")
269 278
 
270 279
         # Save shipping methods as instance var as we need them both here
271 280
         # and when setting the context vars.
272 281
         self._methods = self.get_available_shipping_methods()
273 282
         if len(self._methods) == 0:
274 283
             # No shipping methods available for given address
275
-            messages.warning(request, _(
276
-                "Shipping is unavailable for your chosen address - please "
277
-                "choose another"))
278
-            return redirect('checkout:shipping-address')
284
+            messages.warning(
285
+                request,
286
+                _(
287
+                    "Shipping is unavailable for your chosen address - please "
288
+                    "choose another"
289
+                ),
290
+            )
291
+            return redirect("checkout:shipping-address")
279 292
         elif len(self._methods) == 1:
280 293
             # Only one shipping method - set this and redirect onto the next
281 294
             # step
@@ -288,12 +301,12 @@ class ShippingMethodView(CheckoutSessionMixin, generic.FormView):
288 301
 
289 302
     def get_context_data(self, **kwargs):
290 303
         kwargs = super().get_context_data(**kwargs)
291
-        kwargs['methods'] = self._methods
304
+        kwargs["methods"] = self._methods
292 305
         return kwargs
293 306
 
294 307
     def get_form_kwargs(self):
295 308
         kwargs = super().get_form_kwargs()
296
-        kwargs['methods'] = self._methods
309
+        kwargs["methods"] = self._methods
297 310
         return kwargs
298 311
 
299 312
     def get_available_shipping_methods(self):
@@ -305,19 +318,22 @@ class ShippingMethodView(CheckoutSessionMixin, generic.FormView):
305 318
         # repository).  I haven't come across a scenario that doesn't fit this
306 319
         # system.
307 320
         return Repository().get_shipping_methods(
308
-            basket=self.request.basket, user=self.request.user,
321
+            basket=self.request.basket,
322
+            user=self.request.user,
309 323
             shipping_addr=self.get_shipping_address(self.request.basket),
310
-            request=self.request)
324
+            request=self.request,
325
+        )
311 326
 
312 327
     def form_valid(self, form):
313 328
         # Save the code for the chosen shipping method in the session
314 329
         # and continue to the next step.
315
-        self.checkout_session.use_shipping_method(form.cleaned_data['method_code'])
330
+        self.checkout_session.use_shipping_method(form.cleaned_data["method_code"])
316 331
         return self.get_success_response()
317 332
 
318 333
     def form_invalid(self, form):
319
-        messages.error(self.request, _("Your submitted shipping method is not"
320
-                                       " permitted"))
334
+        messages.error(
335
+            self.request, _("Your submitted shipping method is not permitted")
336
+        )
321 337
         return super().form_invalid(form)
322 338
 
323 339
     def get_success_response(self):
@@ -337,13 +353,15 @@ class PaymentMethodView(CheckoutSessionMixin, generic.TemplateView):
337 353
     between multiple sources. It's not the place for entering sensitive details
338 354
     like bankcard numbers though - that belongs on the payment details view.
339 355
     """
356
+
340 357
     pre_conditions = [
341
-        'check_basket_is_not_empty',
342
-        'check_basket_is_valid',
343
-        'check_user_email_is_captured',
344
-        'check_shipping_data_is_captured']
345
-    skip_conditions = ['skip_unless_payment_is_required']
346
-    success_url = reverse_lazy('checkout:payment-details')
358
+        "check_basket_is_not_empty",
359
+        "check_basket_is_valid",
360
+        "check_user_email_is_captured",
361
+        "check_shipping_data_is_captured",
362
+    ]
363
+    skip_conditions = ["skip_unless_payment_is_required"]
364
+    success_url = reverse_lazy("checkout:payment-details")
347 365
 
348 366
     def get(self, request, *args, **kwargs):
349 367
         # By default we redirect straight onto the payment details view. Shops
@@ -395,16 +413,18 @@ class PaymentDetailsView(OrderPlacementMixin, generic.TemplateView):
395 413
     All projects will need to subclass and customise this class as no payment
396 414
     is taken by default.
397 415
     """
398
-    template_name = 'oscar/checkout/payment_details.html'
399
-    template_name_preview = 'oscar/checkout/preview.html'
416
+
417
+    template_name = "oscar/checkout/payment_details.html"
418
+    template_name_preview = "oscar/checkout/preview.html"
400 419
 
401 420
     # These conditions are extended at runtime depending on whether we are in
402 421
     # 'preview' mode or not.
403 422
     pre_conditions = [
404
-        'check_basket_is_not_empty',
405
-        'check_basket_is_valid',
406
-        'check_user_email_is_captured',
407
-        'check_shipping_data_is_captured']
423
+        "check_basket_is_not_empty",
424
+        "check_basket_is_valid",
425
+        "check_user_email_is_captured",
426
+        "check_shipping_data_is_captured",
427
+    ]
408 428
 
409 429
     # If preview=True, then we render a preview template that shows all order
410 430
     # details ready for submission.
@@ -414,13 +434,13 @@ class PaymentDetailsView(OrderPlacementMixin, generic.TemplateView):
414 434
         if self.preview:
415 435
             # The preview view needs to ensure payment information has been
416 436
             # correctly captured.
417
-            return self.pre_conditions + ['check_payment_data_is_captured']
437
+            return self.pre_conditions + ["check_payment_data_is_captured"]
418 438
         return super().get_pre_conditions(request)
419 439
 
420 440
     def get_skip_conditions(self, request):
421 441
         if not self.preview:
422 442
             # Payment details should only be collected if necessary
423
-            return ['skip_unless_payment_is_required']
443
+            return ["skip_unless_payment_is_required"]
424 444
         return super().get_skip_conditions(request)
425 445
 
426 446
     def post(self, request, *args, **kwargs):
@@ -433,7 +453,7 @@ class PaymentDetailsView(OrderPlacementMixin, generic.TemplateView):
433 453
         # an order (normally from the preview page).  Without this, we assume a
434 454
         # payment form is being submitted from the payment details view. In
435 455
         # this case, the form needs validating and the order preview shown.
436
-        if request.POST.get('action', '') == 'place_order':
456
+        if request.POST.get("action", "") == "place_order":
437 457
             return self.handle_place_order_submission(request)
438 458
         return self.handle_payment_details_submission(request)
439 459
 
@@ -511,9 +531,19 @@ class PaymentDetailsView(OrderPlacementMixin, generic.TemplateView):
511 531
         except UserAddress.DoesNotExist:
512 532
             return None
513 533
 
514
-    def submit(self, user, basket, shipping_address, shipping_method,  # noqa (too complex (10))
515
-               shipping_charge, billing_address, order_total,
516
-               payment_kwargs=None, order_kwargs=None, surcharges=None):
534
+    def submit(
535
+        self,
536
+        user,
537
+        basket,
538
+        shipping_address,
539
+        shipping_method,
540
+        shipping_charge,
541
+        billing_address,
542
+        order_total,
543
+        payment_kwargs=None,
544
+        order_kwargs=None,
545
+        surcharges=None,
546
+    ):
517 547
         """
518 548
         Submit a basket for order placement.
519 549
 
@@ -541,10 +571,12 @@ class PaymentDetailsView(OrderPlacementMixin, generic.TemplateView):
541 571
             order_kwargs = {}
542 572
 
543 573
         # Taxes must be known at this point
544
-        assert basket.is_tax_known, (
545
-            "Basket tax must be set before a user can place an order")
546
-        assert shipping_charge.is_tax_known, (
547
-            "Shipping charge tax must be set before a user can place an order")
574
+        assert (
575
+            basket.is_tax_known
576
+        ), "Basket tax must be set before a user can place an order"
577
+        assert (
578
+            shipping_charge.is_tax_known
579
+        ), "Shipping charge tax must be set before a user can place an order"
548 580
 
549 581
         # We generate the order number first as this will be used
550 582
         # in payment requests (ie before the order model has been
@@ -553,8 +585,11 @@ class PaymentDetailsView(OrderPlacementMixin, generic.TemplateView):
553 585
         # the order on a different request).
554 586
         order_number = self.generate_order_number(basket)
555 587
         self.checkout_session.set_order_number(order_number)
556
-        logger.info("Order #%s: beginning submission process for basket #%d",
557
-                    order_number, basket.id)
588
+        logger.info(
589
+            "Order #%s: beginning submission process for basket #%d",
590
+            order_number,
591
+            basket.id,
592
+        )
558 593
 
559 594
         # Freeze the basket so it cannot be manipulated while the customer is
560 595
         # completing payment on a 3rd party site.  Also, store a reference to
@@ -566,9 +601,11 @@ class PaymentDetailsView(OrderPlacementMixin, generic.TemplateView):
566 601
 
567 602
         # We define a general error message for when an unanticipated payment
568 603
         # error occurs.
569
-        error_msg = _("A problem occurred while processing payment for this "
570
-                      "order - no payment has been taken.  Please "
571
-                      "contact customer services if this problem persists")
604
+        error_msg = _(
605
+            "A problem occurred while processing payment for this "
606
+            "order - no payment has been taken.  Please "
607
+            "contact customer services if this problem persists"
608
+        )
572 609
 
573 610
         signals.pre_payment.send_robust(sender=self, view=self)
574 611
 
@@ -586,13 +623,16 @@ class PaymentDetailsView(OrderPlacementMixin, generic.TemplateView):
586 623
             msg = str(e)
587 624
             logger.warning(
588 625
                 "Order #%s: unable to take payment (%s) - restoring basket",
589
-                order_number, msg)
626
+                order_number,
627
+                msg,
628
+            )
590 629
             self.restore_frozen_basket()
591 630
 
592 631
             # We assume that the details submitted on the payment details view
593 632
             # were invalid (e.g. expired bankcard).
594 633
             return self.render_payment_details(
595
-                self.request, error=msg, **payment_kwargs)
634
+                self.request, error=msg, **payment_kwargs
635
+            )
596 636
         except PaymentError as e:
597 637
             # A general payment error - Something went wrong which wasn't
598 638
             # anticipated.  Eg, the payment gateway is down (it happens), your
@@ -601,51 +641,68 @@ class PaymentDetailsView(OrderPlacementMixin, generic.TemplateView):
601 641
             # mail admins on an error as this issue warrants some further
602 642
             # investigation.
603 643
             msg = str(e)
604
-            logger.error("Order #%s: payment error (%s)", order_number, msg,
605
-                         exc_info=True)
644
+            logger.error(
645
+                "Order #%s: payment error (%s)", order_number, msg, exc_info=True
646
+            )
606 647
             self.restore_frozen_basket()
607
-            return self.render_preview(
608
-                self.request, error=error_msg, **payment_kwargs)
648
+            return self.render_preview(self.request, error=error_msg, **payment_kwargs)
609 649
         except Exception as e:
610 650
             # Unhandled exception - hopefully, you will only ever see this in
611 651
             # development...
612 652
             logger.exception(
613 653
                 "Order #%s: unhandled exception while taking payment (%s)",
614
-                order_number, e)
654
+                order_number,
655
+                e,
656
+            )
615 657
             self.restore_frozen_basket()
616
-            return self.render_preview(
617
-                self.request, error=error_msg, **payment_kwargs)
658
+            return self.render_preview(self.request, error=error_msg, **payment_kwargs)
618 659
 
619 660
         signals.post_payment.send_robust(sender=self, view=self)
620 661
 
621 662
         # If all is ok with payment, try and place order
622
-        logger.info("Order #%s: payment successful, placing order",
623
-                    order_number)
663
+        logger.info("Order #%s: payment successful, placing order", order_number)
624 664
         try:
625 665
             return self.handle_order_placement(
626
-                order_number, user, basket, shipping_address, shipping_method,
627
-                shipping_charge, billing_address, order_total, surcharges=surcharges, **order_kwargs)
666
+                order_number,
667
+                user,
668
+                basket,
669
+                shipping_address,
670
+                shipping_method,
671
+                shipping_charge,
672
+                billing_address,
673
+                order_total,
674
+                surcharges=surcharges,
675
+                **order_kwargs
676
+            )
628 677
         except UnableToPlaceOrder as e:
629 678
             # It's possible that something will go wrong while trying to
630 679
             # actually place an order.  Not a good situation to be in as a
631 680
             # payment transaction may already have taken place, but needs
632 681
             # to be handled gracefully.
633 682
             msg = str(e)
634
-            logger.error("Order #%s: unable to place order - %s",
635
-                         order_number, msg, exc_info=True)
683
+            logger.error(
684
+                "Order #%s: unable to place order - %s",
685
+                order_number,
686
+                msg,
687
+                exc_info=True,
688
+            )
636 689
             self.restore_frozen_basket()
637
-            return self.render_preview(
638
-                self.request, error=msg, **payment_kwargs)
690
+            return self.render_preview(self.request, error=msg, **payment_kwargs)
639 691
         except Exception as e:
640 692
             # Hopefully you only ever reach this in development
641
-            logger.exception("Order #%s: unhandled exception while placing order (%s)", order_number, e)
642
-            error_msg = _("A problem occurred while placing this order. Please contact customer services.")
693
+            logger.exception(
694
+                "Order #%s: unhandled exception while placing order (%s)",
695
+                order_number,
696
+                e,
697
+            )
698
+            error_msg = _(
699
+                "A problem occurred while placing this order. Please contact customer services."
700
+            )
643 701
             self.restore_frozen_basket()
644 702
             return self.render_preview(self.request, error=error_msg, **payment_kwargs)
645 703
 
646 704
     def get_template_names(self):
647
-        return [self.template_name_preview] if self.preview else [
648
-            self.template_name]
705
+        return [self.template_name_preview] if self.preview else [self.template_name]
649 706
 
650 707
 
651 708
 # =========
@@ -657,8 +714,9 @@ class ThankYouView(generic.DetailView):
657 714
     """
658 715
     Displays the 'thank you' page which summarises the order just submitted.
659 716
     """
660
-    template_name = 'oscar/checkout/thank_you.html'
661
-    context_object_name = 'order'
717
+
718
+    template_name = "oscar/checkout/thank_you.html"
719
+    context_object_name = "order"
662 720
 
663 721
     def get(self, request, *args, **kwargs):
664 722
         self.object = self.get_object()
@@ -672,27 +730,28 @@ class ThankYouView(generic.DetailView):
672 730
         order = None
673 731
         if self.request.user.is_superuser:
674 732
             kwargs = {}
675
-            if 'order_number' in self.request.GET:
676
-                kwargs['number'] = self.request.GET['order_number']
677
-            elif 'order_id' in self.request.GET:
678
-                kwargs['id'] = self.request.GET['order_id']
733
+            if "order_number" in self.request.GET:
734
+                kwargs["number"] = self.request.GET["order_number"]
735
+            elif "order_id" in self.request.GET:
736
+                kwargs["id"] = self.request.GET["order_id"]
679 737
             order = Order._default_manager.filter(**kwargs).first()
680 738
 
681 739
         if not order:
682
-            if 'checkout_order_id' in self.request.session:
740
+            if "checkout_order_id" in self.request.session:
683 741
                 order = Order._default_manager.filter(
684
-                    pk=self.request.session['checkout_order_id']).first()
742
+                    pk=self.request.session["checkout_order_id"]
743
+                ).first()
685 744
         return order
686 745
 
687 746
     def get_context_data(self, *args, **kwargs):
688 747
         ctx = super().get_context_data(*args, **kwargs)
689 748
         # Remember whether this view has been loaded.
690 749
         # Only send tracking information on the first load.
691
-        key = 'order_{}_thankyou_viewed'.format(ctx['order'].pk)
750
+        key = "order_{}_thankyou_viewed".format(ctx["order"].pk)
692 751
         if not self.request.session.get(key, False):
693 752
             self.request.session[key] = True
694
-            ctx['send_analytics_event'] = True
753
+            ctx["send_analytics_event"] = True
695 754
         else:
696
-            ctx['send_analytics_event'] = False
755
+            ctx["send_analytics_event"] = False
697 756
 
698 757
         return ctx

+ 1
- 1
src/oscar/apps/communication/__init__.py View File

@@ -1 +1 @@
1
-default_app_config = 'oscar.apps.communication.apps.CommunicationConfig'
1
+default_app_config = "oscar.apps.communication.apps.CommunicationConfig"

+ 82
- 62
src/oscar/apps/communication/abstract_models.py View File

@@ -15,32 +15,38 @@ class AbstractEmail(models.Model):
15 15
     """
16 16
     This is a record of an email sent to a customer.
17 17
     """
18
+
18 19
     user = models.ForeignKey(
19 20
         AUTH_USER_MODEL,
20 21
         on_delete=models.CASCADE,
21
-        related_name='emails',
22
+        related_name="emails",
22 23
         verbose_name=_("User"),
23
-        null=True)
24
-    email = models.EmailField(_('Email Address'), null=True, blank=True)
25
-    subject = models.TextField(_('Subject'), max_length=255)
24
+        null=True,
25
+    )
26
+    email = models.EmailField(_("Email Address"), null=True, blank=True)
27
+    subject = models.TextField(_("Subject"), max_length=255)
26 28
     body_text = models.TextField(_("Body Text"))
27 29
     body_html = models.TextField(_("Body HTML"), blank=True)
28 30
     date_sent = models.DateTimeField(_("Date Sent"), auto_now_add=True)
29 31
 
30 32
     class Meta:
31 33
         abstract = True
32
-        app_label = 'communication'
33
-        ordering = ['-date_sent']
34
-        verbose_name = _('Email')
35
-        verbose_name_plural = _('Emails')
34
+        app_label = "communication"
35
+        ordering = ["-date_sent"]
36
+        verbose_name = _("Email")
37
+        verbose_name_plural = _("Emails")
36 38
 
37 39
     def __str__(self):
38 40
         if self.user:
39 41
             return _("Email to %(user)s with subject '%(subject)s'") % {
40
-                'user': self.user.get_username(), 'subject': self.subject}
42
+                "user": self.user.get_username(),
43
+                "subject": self.subject,
44
+            }
41 45
         else:
42 46
             return _("Email to %(email)s with subject '%(subject)s'") % {
43
-                'email': self.email, 'subject': self.subject}
47
+                "email": self.email,
48
+                "subject": self.subject,
49
+            }
44 50
 
45 51
 
46 52
 class AbstractCommunicationEventType(models.Model):
@@ -52,48 +58,66 @@ class AbstractCommunicationEventType(models.Model):
52 58
     # e.g. PASSWORD_RESET. AutoSlugField uppercases the code for us because
53 59
     # it's a useful convention that's been enforced in previous Oscar versions
54 60
     code = AutoSlugField(
55
-        _('Code'), max_length=128, unique=True, populate_from='name',
56
-        separator='_', uppercase=True, editable=True,
61
+        _("Code"),
62
+        max_length=128,
63
+        unique=True,
64
+        populate_from="name",
65
+        separator="_",
66
+        uppercase=True,
67
+        editable=True,
57 68
         validators=[
58 69
             RegexValidator(
59
-                regex=r'^[A-Z_][0-9A-Z_]*$',
70
+                regex=r"^[A-Z_][0-9A-Z_]*$",
60 71
                 message=_(
61 72
                     "Code can only contain the uppercase letters (A-Z), "
62
-                    "digits, and underscores, and can't start with a digit."))],
63
-        help_text=_("Code used for looking up this event programmatically"))
73
+                    "digits, and underscores, and can't start with a digit."
74
+                ),
75
+            )
76
+        ],
77
+        help_text=_("Code used for looking up this event programmatically"),
78
+    )
64 79
 
65 80
     #: Name is the friendly description of an event for use in the admin
66
-    name = models.CharField(_('Name'), max_length=255, db_index=True)
81
+    name = models.CharField(_("Name"), max_length=255, db_index=True)
67 82
 
68 83
     # We allow communication types to be categorised
69 84
     # For backwards-compatibility, the choice values are quite verbose
70
-    ORDER_RELATED = 'Order related'
71
-    USER_RELATED = 'User related'
85
+    ORDER_RELATED = "Order related"
86
+    USER_RELATED = "User related"
72 87
     CATEGORY_CHOICES = (
73
-        (ORDER_RELATED, _('Order related')),
74
-        (USER_RELATED, _('User related'))
88
+        (ORDER_RELATED, _("Order related")),
89
+        (USER_RELATED, _("User related")),
75 90
     )
76 91
 
77 92
     category = models.CharField(
78
-        _('Category'), max_length=255, default=ORDER_RELATED,
79
-        choices=CATEGORY_CHOICES)
93
+        _("Category"), max_length=255, default=ORDER_RELATED, choices=CATEGORY_CHOICES
94
+    )
80 95
 
81 96
     # Template content for emails
82 97
     # NOTE: There's an intentional distinction between None and ''. None
83 98
     # instructs Oscar to look for a file-based template, '' is just an empty
84 99
     # template.
85 100
     email_subject_template = models.CharField(
86
-        _('Email Subject Template'), max_length=255, blank=True, null=True)
101
+        _("Email Subject Template"), max_length=255, blank=True, null=True
102
+    )
87 103
     email_body_template = models.TextField(
88
-        _('Email Body Template'), blank=True, null=True)
104
+        _("Email Body Template"), blank=True, null=True
105
+    )
89 106
     email_body_html_template = models.TextField(
90
-        _('Email Body HTML Template'), blank=True, null=True,
91
-        help_text=_("HTML template"))
107
+        _("Email Body HTML Template"),
108
+        blank=True,
109
+        null=True,
110
+        help_text=_("HTML template"),
111
+    )
92 112
 
93 113
     # Template content for SMS messages
94
-    sms_template = models.CharField(_('SMS Template'), max_length=170,
95
-                                    blank=True, null=True,
96
-                                    help_text=_("SMS template"))
114
+    sms_template = models.CharField(
115
+        _("SMS Template"),
116
+        max_length=170,
117
+        blank=True,
118
+        null=True,
119
+        help_text=_("SMS template"),
120
+    )
97 121
 
98 122
     date_created = models.DateTimeField(_("Date Created"), auto_now_add=True)
99 123
     date_updated = models.DateTimeField(_("Date Updated"), auto_now=True)
@@ -101,15 +125,15 @@ class AbstractCommunicationEventType(models.Model):
101 125
     objects = CommunicationTypeManager()
102 126
 
103 127
     # File templates
104
-    email_subject_template_file = 'oscar/communication/emails/commtype_%s_subject.txt'
105
-    email_body_template_file = 'oscar/communication/emails/commtype_%s_body.txt'
106
-    email_body_html_template_file = 'oscar/communication/emails/commtype_%s_body.html'
107
-    sms_template_file = 'oscar/communication/sms/commtype_%s_body.txt'
128
+    email_subject_template_file = "oscar/communication/emails/commtype_%s_subject.txt"
129
+    email_body_template_file = "oscar/communication/emails/commtype_%s_body.txt"
130
+    email_body_html_template_file = "oscar/communication/emails/commtype_%s_body.html"
131
+    sms_template_file = "oscar/communication/sms/commtype_%s_body.txt"
108 132
 
109 133
     class Meta:
110 134
         abstract = True
111
-        app_label = 'communication'
112
-        ordering = ['name']
135
+        app_label = "communication"
136
+        ordering = ["name"]
113 137
         verbose_name = _("Communication event type")
114 138
         verbose_name_plural = _("Communication event types")
115 139
 
@@ -123,15 +147,17 @@ class AbstractCommunicationEventType(models.Model):
123 147
         code = self.code.lower()
124 148
 
125 149
         # Build a dict of message name to Template instances
126
-        templates = {'subject': 'email_subject_template',
127
-                     'body': 'email_body_template',
128
-                     'html': 'email_body_html_template',
129
-                     'sms': 'sms_template'}
150
+        templates = {
151
+            "subject": "email_subject_template",
152
+            "body": "email_body_template",
153
+            "html": "email_body_html_template",
154
+            "sms": "sms_template",
155
+        }
130 156
         for name, attr_name in templates.items():
131 157
             field = getattr(self, attr_name, None)
132 158
             if field is not None:
133 159
                 # Template content is in a model field
134
-                templates[name] = engines['django'].from_string(field)
160
+                templates[name] = engines["django"].from_string(field)
135 161
             else:
136 162
                 # Model field is empty - look for a file template
137 163
                 template_name = getattr(self, "%s_file" % attr_name) % code
@@ -143,16 +169,16 @@ class AbstractCommunicationEventType(models.Model):
143 169
         # Pass base URL for serving images within HTML emails
144 170
         if ctx is None:
145 171
             ctx = {}
146
-        ctx['static_base_url'] = getattr(
147
-            settings, 'OSCAR_STATIC_BASE_URL', None)
172
+        ctx["static_base_url"] = getattr(settings, "OSCAR_STATIC_BASE_URL", None)
148 173
 
149 174
         messages = {}
150 175
         for name, template in templates.items():
151
-            messages[name] = template.render(ctx) if template else ''
176
+            # pylint: disable=no-member
177
+            messages[name] = template.render(ctx) if template else ""
152 178
 
153 179
         # Ensure the email subject doesn't contain any newlines
154
-        messages['subject'] = messages['subject'].replace("\n", "")
155
-        messages['subject'] = messages['subject'].replace("\r", "")
180
+        messages["subject"] = messages["subject"].replace("\n", "")
181
+        messages["subject"] = messages["subject"].replace("\r", "")
156 182
 
157 183
         return messages
158 184
 
@@ -168,36 +194,29 @@ class AbstractCommunicationEventType(models.Model):
168 194
 
169 195
 class AbstractNotification(models.Model):
170 196
     recipient = models.ForeignKey(
171
-        AUTH_USER_MODEL,
172
-        on_delete=models.CASCADE,
173
-        related_name='notifications')
197
+        AUTH_USER_MODEL, on_delete=models.CASCADE, related_name="notifications"
198
+    )
174 199
 
175 200
     # Not all notifications will have a sender.
176
-    sender = models.ForeignKey(
177
-        AUTH_USER_MODEL,
178
-        on_delete=models.CASCADE,
179
-        null=True)
201
+    sender = models.ForeignKey(AUTH_USER_MODEL, on_delete=models.CASCADE, null=True)
180 202
 
181 203
     # HTML is allowed in this field as it can contain links
182 204
     subject = models.CharField(max_length=255)
183 205
     body = models.TextField()
184 206
 
185
-    INBOX, ARCHIVE = 'Inbox', 'Archive'
186
-    choices = (
187
-        (INBOX, _('Inbox')),
188
-        (ARCHIVE, _('Archive')))
189
-    location = models.CharField(max_length=32, choices=choices,
190
-                                default=INBOX)
207
+    INBOX, ARCHIVE = "Inbox", "Archive"
208
+    choices = ((INBOX, _("Inbox")), (ARCHIVE, _("Archive")))
209
+    location = models.CharField(max_length=32, choices=choices, default=INBOX)
191 210
 
192 211
     date_sent = models.DateTimeField(auto_now_add=True)
193 212
     date_read = models.DateTimeField(blank=True, null=True)
194 213
 
195 214
     class Meta:
196 215
         abstract = True
197
-        app_label = 'communication'
198
-        ordering = ('-date_sent',)
199
-        verbose_name = _('Notification')
200
-        verbose_name_plural = _('Notifications')
216
+        app_label = "communication"
217
+        ordering = ("-date_sent",)
218
+        verbose_name = _("Notification")
219
+        verbose_name_plural = _("Notifications")
201 220
 
202 221
     def __str__(self):
203 222
         return self.subject
@@ -205,6 +224,7 @@ class AbstractNotification(models.Model):
205 224
     def archive(self):
206 225
         self.location = self.ARCHIVE
207 226
         self.save()
227
+
208 228
     archive.alters_data = True
209 229
 
210 230
     @property

+ 2
- 2
src/oscar/apps/communication/admin.py View File

@@ -2,8 +2,8 @@ from django.contrib import admin
2 2
 
3 3
 from oscar.core.loading import get_model
4 4
 
5
-CommunicationEventType = get_model('communication', 'CommunicationEventType')
6
-Email = get_model('communication', 'Email')
5
+CommunicationEventType = get_model("communication", "CommunicationEventType")
6
+Email = get_model("communication", "Email")
7 7
 
8 8
 
9 9
 admin.site.register(Email)

+ 99
- 69
src/oscar/apps/communication/app.py View File

@@ -1,69 +1,99 @@
1
-from django.contrib.auth.decorators import login_required
2
-from django.urls import path
3
-from django.views import generic
4
-
5
-from oscar.core.application import Application
6
-from oscar.core.loading import get_class
7
-
8
-
9
-class CommunicationApplication(Application):
10
-    name = 'communication'
11
-
12
-    alert_list_view = get_class(
13
-        'communication.alerts.views', 'ProductAlertListView')
14
-    alert_create_view = get_class(
15
-        'communication.alerts.views', 'ProductAlertCreateView')
16
-    alert_confirm_view = get_class(
17
-        'communication.alerts.views', 'ProductAlertConfirmView')
18
-    alert_cancel_view = get_class(
19
-        'communication.alerts.views', 'ProductAlertCancelView')
20
-
21
-    notification_inbox_view = get_class(
22
-        'communication.notifications.views', 'InboxView')
23
-    notification_archive_view = get_class(
24
-        'communication.notifications.views', 'ArchiveView')
25
-    notification_update_view = get_class(
26
-        'communication.notifications.views', 'UpdateView')
27
-    notification_detail_view = get_class(
28
-        'communication.notifications.views', 'DetailView')
29
-
30
-    def get_urls(self):
31
-        urls = [
32
-            # Alerts
33
-            # Alerts can be setup by anonymous users: some views do not
34
-            # require login
35
-            path('alerts/', login_required(self.alert_list_view.as_view()), name='alerts-list'),
36
-            path('alerts/create/<int:pk>/', self.alert_create_view.as_view(), name='alert-create'),
37
-            path('alerts/confirm/<str:key>/', self.alert_confirm_view.as_view(), name='alerts-confirm'),
38
-            path('alerts/cancel/key/<str:key>/', self.alert_cancel_view.as_view(), name='alerts-cancel-by-key'),
39
-            path(
40
-                'alerts/cancel/<int:pk>/',
41
-                login_required(self.alert_cancel_view.as_view()),
42
-                name='alerts-cancel-by-pk'),
43
-
44
-            # Notifications
45
-            # Redirect to notification inbox
46
-            path(
47
-                'notifications/', generic.RedirectView.as_view(url='/accounts/notifications/inbox/', permanent=False)),
48
-            path(
49
-                'notifications/inbox/',
50
-                login_required(self.notification_inbox_view.as_view()),
51
-                name='notifications-inbox'),
52
-            path(
53
-                'notifications/archive/',
54
-                login_required(self.notification_archive_view.as_view()),
55
-                name='notifications-archive'),
56
-            path(
57
-                'notifications/update/',
58
-                login_required(self.notification_update_view.as_view()),
59
-                name='notifications-update'),
60
-            path(
61
-                'notifications/<int:pk>/',
62
-                login_required(self.notification_detail_view.as_view()),
63
-                name='notifications-detail'),
64
-        ]
65
-
66
-        return self.post_process_urls(urls)
67
-
68
-
69
-application = CommunicationApplication()
1
+# from django.contrib.auth.decorators import login_required
2
+# from django.urls import path
3
+# from django.views import generic
4
+#
5
+# from oscar.core.application import Application
6
+# from oscar.core.loading import get_class
7
+#
8
+#
9
+# class CommunicationApplication(Application):
10
+#     name = "communication"
11
+#
12
+#     alert_list_view = get_class("communication.alerts.views", "ProductAlertListView")
13
+#     alert_create_view = get_class(
14
+#         "communication.alerts.views", "ProductAlertCreateView"
15
+#     )
16
+#     alert_confirm_view = get_class(
17
+#         "communication.alerts.views", "ProductAlertConfirmView"
18
+#     )
19
+#     alert_cancel_view = get_class(
20
+#         "communication.alerts.views", "ProductAlertCancelView"
21
+#     )
22
+#
23
+#     notification_inbox_view = get_class(
24
+#         "communication.notifications.views", "InboxView"
25
+#     )
26
+#     notification_archive_view = get_class(
27
+#         "communication.notifications.views", "ArchiveView"
28
+#     )
29
+#     notification_update_view = get_class(
30
+#         "communication.notifications.views", "UpdateView"
31
+#     )
32
+#     notification_detail_view = get_class(
33
+#         "communication.notifications.views", "DetailView"
34
+#     )
35
+#
36
+#     def get_urls(self):
37
+#         urls = [
38
+#             # Alerts
39
+#             # Alerts can be setup by anonymous users: some views do not
40
+#             # require login
41
+#             path(
42
+#                 "alerts/",
43
+#                 login_required(self.alert_list_view.as_view()),
44
+#                 name="alerts-list",
45
+#             ),
46
+#             path(
47
+#                 "alerts/create/<int:pk>/",
48
+#                 self.alert_create_view.as_view(),
49
+#                 name="alert-create",
50
+#             ),
51
+#             path(
52
+#                 "alerts/confirm/<str:key>/",
53
+#                 self.alert_confirm_view.as_view(),
54
+#                 name="alerts-confirm",
55
+#             ),
56
+#             path(
57
+#                 "alerts/cancel/key/<str:key>/",
58
+#                 self.alert_cancel_view.as_view(),
59
+#                 name="alerts-cancel-by-key",
60
+#             ),
61
+#             path(
62
+#                 "alerts/cancel/<int:pk>/",
63
+#                 login_required(self.alert_cancel_view.as_view()),
64
+#                 name="alerts-cancel-by-pk",
65
+#             ),
66
+#             # Notifications
67
+#             # Redirect to notification inbox
68
+#             path(
69
+#                 "notifications/",
70
+#                 generic.RedirectView.as_view(
71
+#                     url="/accounts/notifications/inbox/", permanent=False
72
+#                 ),
73
+#             ),
74
+#             path(
75
+#                 "notifications/inbox/",
76
+#                 login_required(self.notification_inbox_view.as_view()),
77
+#                 name="notifications-inbox",
78
+#             ),
79
+#             path(
80
+#                 "notifications/archive/",
81
+#                 login_required(self.notification_archive_view.as_view()),
82
+#                 name="notifications-archive",
83
+#             ),
84
+#             path(
85
+#                 "notifications/update/",
86
+#                 login_required(self.notification_update_view.as_view()),
87
+#                 name="notifications-update",
88
+#             ),
89
+#             path(
90
+#                 "notifications/<int:pk>/",
91
+#                 login_required(self.notification_detail_view.as_view()),
92
+#                 name="notifications-detail",
93
+#             ),
94
+#         ]
95
+#
96
+#         return self.post_process_urls(urls)
97
+#
98
+#
99
+# application = CommunicationApplication()

+ 3
- 3
src/oscar/apps/communication/apps.py View File

@@ -4,6 +4,6 @@ from oscar.core.application import OscarConfig
4 4
 
5 5
 
6 6
 class CommunicationConfig(OscarConfig):
7
-    label = 'communication'
8
-    name = 'oscar.apps.communication'
9
-    verbose_name = _('Communication')
7
+    label = "communication"
8
+    name = "oscar.apps.communication"
9
+    verbose_name = _("Communication")

+ 3
- 6
src/oscar/apps/communication/config.py View File

@@ -3,9 +3,6 @@ from django.utils.translation import gettext_lazy as _
3 3
 
4 4
 
5 5
 class CommunicationConfig(AppConfig):
6
-    label = 'communication'
7
-    name = 'oscar.apps.communication'
8
-    verbose_name = _('Communication')
9
-
10
-    def ready(self):
11
-        from .alerts import receivers  # noqa
6
+    label = "communication"
7
+    name = "oscar.apps.communication"
8
+    verbose_name = _("Communication")

+ 0
- 1
src/oscar/apps/communication/managers.py View File

@@ -2,7 +2,6 @@ from django.db import models
2 2
 
3 3
 
4 4
 class CommunicationTypeManager(models.Manager):
5
-
6 5
     def get_and_render(self, code, context):
7 6
         """
8 7
         Return a dictionary of rendered messages, ready for sending.

+ 11
- 7
src/oscar/apps/communication/models.py View File

@@ -1,26 +1,30 @@
1
+# pylint: disable=wildcard-import, unused-wildcard-import
1 2
 from oscar.core.loading import is_model_registered
2 3
 
3
-from .abstract_models import *  # noqa
4
+from .abstract_models import *
4 5
 
5 6
 __all__ = []
6 7
 
7 8
 
8
-if not is_model_registered('communication', 'Email'):
9
+if not is_model_registered("communication", "Email"):
10
+
9 11
     class Email(AbstractEmail):
10 12
         pass
11 13
 
12
-    __all__.append('Email')
14
+    __all__.append("Email")
15
+
13 16
 
17
+if not is_model_registered("communication", "CommunicationEventType"):
14 18
 
15
-if not is_model_registered('communication', 'CommunicationEventType'):
16 19
     class CommunicationEventType(AbstractCommunicationEventType):
17 20
         pass
18 21
 
19
-    __all__.append('CommunicationEventType')
22
+    __all__.append("CommunicationEventType")
23
+
20 24
 
25
+if not is_model_registered("communication", "Notification"):
21 26
 
22
-if not is_model_registered('communication', 'Notification'):
23 27
     class Notification(AbstractNotification):
24 28
         pass
25 29
 
26
-    __all__.append('Notification')
30
+    __all__.append("Notification")

+ 5
- 4
src/oscar/apps/communication/notifications/context_processors.py View File

@@ -1,12 +1,13 @@
1 1
 from oscar.core.loading import get_model
2 2
 
3
-Notification = get_model('communication', 'Notification')
3
+Notification = get_model("communication", "Notification")
4 4
 
5 5
 
6 6
 def notifications(request):
7 7
     ctx = {}
8
-    if getattr(request, 'user', None) and request.user.is_authenticated:
8
+    if getattr(request, "user", None) and request.user.is_authenticated:
9 9
         num_unread = Notification.objects.filter(
10
-            recipient=request.user, date_read=None).count()
11
-        ctx['num_unread_notifications'] = num_unread
10
+            recipient=request.user, date_read=None
11
+        ).count()
12
+        ctx["num_unread_notifications"] = num_unread
12 13
     return ctx

+ 30
- 31
src/oscar/apps/communication/notifications/views.py View File

@@ -10,47 +10,47 @@ from oscar.core.loading import get_class, get_model
10 10
 from oscar.core.utils import redirect_to_referrer
11 11
 from oscar.views.generic import BulkEditMixin
12 12
 
13
-PageTitleMixin = get_class('customer.mixins', 'PageTitleMixin')
14
-Notification = get_model('communication', 'Notification')
13
+PageTitleMixin = get_class("customer.mixins", "PageTitleMixin")
14
+Notification = get_model("communication", "Notification")
15 15
 
16 16
 
17 17
 class NotificationListView(PageTitleMixin, generic.ListView):
18 18
     model = Notification
19
-    template_name = 'oscar/communication/notifications/list.html'
20
-    context_object_name = 'notifications'
19
+    template_name = "oscar/communication/notifications/list.html"
20
+    context_object_name = "notifications"
21 21
     paginate_by = settings.OSCAR_NOTIFICATIONS_PER_PAGE
22 22
     page_title = _("Notifications")
23
-    active_tab = 'notifications'
23
+    active_tab = "notifications"
24 24
 
25 25
     def get_context_data(self, **kwargs):
26 26
         ctx = super().get_context_data(**kwargs)
27
-        ctx['list_type'] = self.list_type
27
+        ctx["list_type"] = self.list_type
28 28
         return ctx
29 29
 
30 30
 
31 31
 class InboxView(NotificationListView):
32
-    list_type = 'inbox'
32
+    list_type = "inbox"
33 33
 
34 34
     def get_queryset(self):
35 35
         return self.model._default_manager.filter(
36
-            recipient=self.request.user,
37
-            location=self.model.INBOX)
36
+            recipient=self.request.user, location=self.model.INBOX
37
+        )
38 38
 
39 39
 
40 40
 class ArchiveView(NotificationListView):
41
-    list_type = 'archive'
41
+    list_type = "archive"
42 42
 
43 43
     def get_queryset(self):
44 44
         return self.model._default_manager.filter(
45
-            recipient=self.request.user,
46
-            location=self.model.ARCHIVE)
45
+            recipient=self.request.user, location=self.model.ARCHIVE
46
+        )
47 47
 
48 48
 
49 49
 class DetailView(PageTitleMixin, generic.DetailView):
50 50
     model = Notification
51
-    template_name = 'oscar/communication/notifications/detail.html'
52
-    context_object_name = 'notification'
53
-    active_tab = 'notifications'
51
+    template_name = "oscar/communication/notifications/detail.html"
52
+    context_object_name = "notification"
53
+    active_tab = "notifications"
54 54
 
55 55
     def get_object(self, queryset=None):
56 56
         obj = super().get_object()
@@ -62,34 +62,32 @@ class DetailView(PageTitleMixin, generic.DetailView):
62 62
     def get_page_title(self):
63 63
         """Append subject to page title"""
64 64
         title = strip_tags(self.object.subject)
65
-        return '%s: %s' % (_('Notification'), title)
65
+        return "%s: %s" % (_("Notification"), title)
66 66
 
67 67
     def get_queryset(self):
68
-        return self.model._default_manager.filter(
69
-            recipient=self.request.user)
68
+        return self.model._default_manager.filter(recipient=self.request.user)
70 69
 
71 70
 
72 71
 class UpdateView(BulkEditMixin, generic.View):
73 72
     model = Notification
74
-    http_method_names = ['post']
75
-    actions = ('archive', 'delete')
76
-    checkbox_object_name = 'notification'
73
+    http_method_names = ["post"]
74
+    actions = ("archive", "delete")
75
+    checkbox_object_name = "notification"
77 76
 
78 77
     def get_object_dict(self, ids):
79
-        return self.model.objects.filter(
80
-            recipient=self.request.user).in_bulk(ids)
78
+        return self.model.objects.filter(recipient=self.request.user).in_bulk(ids)
81 79
 
82 80
     def get_success_response(self):
83
-        return redirect_to_referrer(
84
-            self.request, 'communication:notifications-inbox')
81
+        return redirect_to_referrer(self.request, "communication:notifications-inbox")
85 82
 
86 83
     def archive(self, request, notifications):
87 84
         for notification in notifications:
88 85
             notification.archive()
89 86
         msg = ngettext(
90
-            '%(count)d notification archived',
91
-            '%(count)d notifications archived', len(notifications)) \
92
-            % {'count': len(notifications)}
87
+            "%(count)d notification archived",
88
+            "%(count)d notifications archived",
89
+            len(notifications),
90
+        ) % {"count": len(notifications)}
93 91
         messages.success(request, msg)
94 92
         return self.get_success_response()
95 93
 
@@ -97,8 +95,9 @@ class UpdateView(BulkEditMixin, generic.View):
97 95
         for notification in notifications:
98 96
             notification.delete()
99 97
         msg = ngettext(
100
-            '%(count)d notification deleted',
101
-            '%(count)d notifications deleted', len(notifications)) \
102
-            % {'count': len(notifications)}
98
+            "%(count)d notification deleted",
99
+            "%(count)d notifications deleted",
100
+            len(notifications),
101
+        ) % {"count": len(notifications)}
103 102
         messages.success(request, msg)
104 103
         return self.get_success_response()

+ 32
- 22
src/oscar/apps/communication/utils.py View File

@@ -6,13 +6,12 @@ from django.core.mail import EmailMessage, EmailMultiAlternatives
6 6
 
7 7
 from oscar.core.loading import get_model
8 8
 
9
-CommunicationEventType = get_model('communication', 'CommunicationEventType')
10
-Email = get_model('communication', 'Email')
11
-Notification = get_model('communication', 'Notification')
9
+CommunicationEventType = get_model("communication", "CommunicationEventType")
10
+Email = get_model("communication", "Email")
11
+Notification = get_model("communication", "Notification")
12 12
 
13 13
 
14 14
 class Dispatcher(object):
15
-
16 15
     def __init__(self, logger=None, mail_connection=None):
17 16
         if not logger:
18 17
             logger = logging.getLogger(__name__)
@@ -27,13 +26,18 @@ class Dispatcher(object):
27 26
         """
28 27
         Dispatch one-off messages to explicitly specified recipient email.
29 28
         """
30
-        if messages['subject'] and (messages['body'] or messages['html']):
31
-            return self.send_email_messages(recipient_email, messages, attachments=attachments)
29
+        if messages["subject"] and (messages["body"] or messages["html"]):
30
+            return self.send_email_messages(
31
+                recipient_email, messages, attachments=attachments
32
+            )
32 33
 
33 34
     def dispatch_anonymous_messages(self, email, messages, attachments=None):
34 35
         dispatched_messages = {}
35 36
         if email:
36
-            dispatched_messages['email'] = self.send_email_messages(email, messages, attachments=attachments), None
37
+            dispatched_messages["email"] = (
38
+                self.send_email_messages(email, messages, attachments=attachments),
39
+                None,
40
+            )
37 41
         return dispatched_messages
38 42
 
39 43
     def dispatch_user_messages(self, user, messages, attachments=None):
@@ -41,10 +45,12 @@ class Dispatcher(object):
41 45
         Send messages to a site user
42 46
         """
43 47
         dispatched_messages = {}
44
-        if messages['subject'] and (messages['body'] or messages['html']):
45
-            dispatched_messages['email'] = self.send_user_email_messages(user, messages, attachments)
46
-        if messages['sms']:
47
-            dispatched_messages['sms'] = self.send_text_message(user, messages['sms'])
48
+        if messages["subject"] and (messages["body"] or messages["html"]):
49
+            dispatched_messages["email"] = self.send_user_email_messages(
50
+                user, messages, attachments
51
+            )
52
+        if messages["sms"]:
53
+            dispatched_messages["sms"] = self.send_text_message(user, messages["sms"])
48 54
         return dispatched_messages
49 55
 
50 56
     def notify_user(self, user, subject, **kwargs):
@@ -72,7 +78,7 @@ class Dispatcher(object):
72 78
                 email=user.email,
73 79
                 subject=email.subject,
74 80
                 body_text=email.body,
75
-                body_html=messages['html'],
81
+                body_html=messages["html"],
76 82
             )
77 83
 
78 84
     def send_user_email_messages(self, user, messages, attachments=None):
@@ -80,8 +86,10 @@ class Dispatcher(object):
80 86
         Send message to the registered user / customer and collect data in database.
81 87
         """
82 88
         if not user.email:
83
-            self.logger.warning("Unable to send email messages as user #%d has"
84
-                                " no email address", user.id)
89
+            self.logger.warning(
90
+                "Unable to send email messages as user #%d has no email address",
91
+                user.id,
92
+            )
85 93
             return None
86 94
 
87 95
         email = self.send_email_messages(user.email, messages, attachments=attachments)
@@ -91,7 +99,9 @@ class Dispatcher(object):
91 99
 
92 100
         return email
93 101
 
94
-    def send_email_messages(self, recipient_email, messages, from_email=None, attachments=None):
102
+    def send_email_messages(
103
+        self, recipient_email, messages, from_email=None, attachments=None
104
+    ):
95 105
         """
96 106
         Send email to recipient, HTML attachment optional.
97 107
         """
@@ -100,19 +110,19 @@ class Dispatcher(object):
100 110
         content_attachments, file_attachments = self.prepare_attachments(attachments)
101 111
 
102 112
         # Determine whether we are sending a HTML version too
103
-        if messages['html']:
113
+        if messages["html"]:
104 114
             email = EmailMultiAlternatives(
105
-                messages['subject'],
106
-                messages['body'],
115
+                messages["subject"],
116
+                messages["body"],
107 117
                 from_email=from_email,
108 118
                 to=[recipient_email],
109 119
                 attachments=content_attachments,
110 120
             )
111
-            email.attach_alternative(messages['html'], "text/html")
121
+            email.attach_alternative(messages["html"], "text/html")
112 122
         else:
113 123
             email = EmailMessage(
114
-                messages['subject'],
115
-                messages['body'],
124
+                messages["subject"],
125
+                messages["body"],
116 126
                 from_email=from_email,
117 127
                 to=[recipient_email],
118 128
                 attachments=content_attachments,
@@ -120,7 +130,7 @@ class Dispatcher(object):
120 130
         for attachment in file_attachments:
121 131
             email.attach_file(attachment)
122 132
 
123
-        self.logger.info("Sending email to %s" % recipient_email)
133
+        self.logger.info("Sending email to %s", recipient_email)
124 134
 
125 135
         if self.mail_connection:
126 136
             self.mail_connection.send_messages([email])

+ 1
- 1
src/oscar/apps/customer/__init__.py View File

@@ -1 +1 @@
1
-default_app_config = 'oscar.apps.customer.apps.CustomerConfig'
1
+default_app_config = "oscar.apps.customer.apps.CustomerConfig"

+ 68
- 51
src/oscar/apps/customer/abstract_models.py View File

@@ -10,7 +10,6 @@ from oscar.core.compat import AUTH_USER_MODEL
10 10
 
11 11
 
12 12
 class UserManager(auth_models.BaseUserManager):
13
-
14 13
     def create_user(self, email, password=None, **extra_fields):
15 14
         """
16 15
         Creates and saves a User with the given email and
@@ -18,12 +17,17 @@ class UserManager(auth_models.BaseUserManager):
18 17
         """
19 18
         now = timezone.now()
20 19
         if not email:
21
-            raise ValueError('The given email must be set')
20
+            raise ValueError("The given email must be set")
22 21
         email = UserManager.normalize_email(email)
23 22
         user = self.model(
24
-            email=email, is_staff=False, is_active=True,
23
+            email=email,
24
+            is_staff=False,
25
+            is_active=True,
25 26
             is_superuser=False,
26
-            last_login=now, date_joined=now, **extra_fields)
27
+            last_login=now,
28
+            date_joined=now,
29
+            **extra_fields
30
+        )
27 31
 
28 32
         user.set_password(password)
29 33
         user.save(using=self._db)
@@ -38,38 +42,40 @@ class UserManager(auth_models.BaseUserManager):
38 42
         return u
39 43
 
40 44
 
41
-class AbstractUser(auth_models.AbstractBaseUser,
42
-                   auth_models.PermissionsMixin):
45
+class AbstractUser(auth_models.AbstractBaseUser, auth_models.PermissionsMixin):
43 46
     """
44 47
     An abstract base user suitable for use in Oscar projects.
45 48
 
46 49
     This is basically a copy of the core AbstractUser model but without a
47 50
     username field
48 51
     """
49
-    email = models.EmailField(_('email address'), unique=True)
50
-    first_name = models.CharField(
51
-        _('First name'), max_length=255, blank=True)
52
-    last_name = models.CharField(
53
-        _('Last name'), max_length=255, blank=True)
52
+
53
+    email = models.EmailField(_("email address"), unique=True)
54
+    first_name = models.CharField(_("First name"), max_length=255, blank=True)
55
+    last_name = models.CharField(_("Last name"), max_length=255, blank=True)
54 56
     is_staff = models.BooleanField(
55
-        _('Staff status'), default=False,
56
-        help_text=_('Designates whether the user can log into this admin '
57
-                    'site.'))
57
+        _("Staff status"),
58
+        default=False,
59
+        help_text=_("Designates whether the user can log into this admin site."),
60
+    )
58 61
     is_active = models.BooleanField(
59
-        _('Active'), default=True,
60
-        help_text=_('Designates whether this user should be treated as '
61
-                    'active. Unselect this instead of deleting accounts.'))
62
-    date_joined = models.DateTimeField(_('date joined'),
63
-                                       default=timezone.now)
62
+        _("Active"),
63
+        default=True,
64
+        help_text=_(
65
+            "Designates whether this user should be treated as "
66
+            "active. Unselect this instead of deleting accounts."
67
+        ),
68
+    )
69
+    date_joined = models.DateTimeField(_("date joined"), default=timezone.now)
64 70
 
65 71
     objects = UserManager()
66 72
 
67
-    USERNAME_FIELD = 'email'
73
+    USERNAME_FIELD = "email"
68 74
 
69 75
     class Meta:
70 76
         abstract = True
71
-        verbose_name = _('User')
72
-        verbose_name_plural = _('Users')
77
+        verbose_name = _("User")
78
+        verbose_name_plural = _("Users")
73 79
 
74 80
     def clean(self):
75 81
         super().clean()
@@ -79,7 +85,7 @@ class AbstractUser(auth_models.AbstractBaseUser,
79 85
         """
80 86
         Return the first_name plus the last_name, with a space in between.
81 87
         """
82
-        full_name = '%s %s' % (self.first_name, self.last_name)
88
+        full_name = "%s %s" % (self.first_name, self.last_name)
83 89
         return full_name.strip()
84 90
 
85 91
     def get_short_name(self):
@@ -99,10 +105,12 @@ class AbstractUser(auth_models.AbstractBaseUser,
99 105
         Transfer any active alerts linked to a user's email address to the
100 106
         newly registered user.
101 107
         """
108
+        # pylint: disable=no-member
102 109
         ProductAlert = self.alerts.model
103 110
         alerts = ProductAlert.objects.filter(
104
-            email=self.email, status=ProductAlert.ACTIVE)
105
-        alerts.update(user=self, key='', email='')
111
+            email=self.email, status=ProductAlert.ACTIVE
112
+        )
113
+        alerts.update(user=self, key="", email="")
106 114
 
107 115
     def save(self, *args, **kwargs):
108 116
         super().save(*args, **kwargs)
@@ -117,9 +125,8 @@ class AbstractProductAlert(models.Model):
117 125
     """
118 126
     An alert for when a product comes back in stock
119 127
     """
120
-    product = models.ForeignKey(
121
-        'catalogue.Product',
122
-        on_delete=models.CASCADE)
128
+
129
+    product = models.ForeignKey("catalogue.Product", on_delete=models.CASCADE)
123 130
 
124 131
     # A user is only required if the notification is created by a
125 132
     # registered user, anonymous users will only have an email address
@@ -130,7 +137,8 @@ class AbstractProductAlert(models.Model):
130 137
         null=True,
131 138
         on_delete=models.CASCADE,
132 139
         related_name="alerts",
133
-        verbose_name=_('User'))
140
+        verbose_name=_("User"),
141
+    )
134 142
     email = models.EmailField(_("Email"), db_index=True, blank=True)
135 143
 
136 144
     # This key are used to confirm and cancel alerts for anon users
@@ -143,29 +151,34 @@ class AbstractProductAlert(models.Model):
143 151
     # the first time and can be used to confirm and unsubscribe the
144 152
     # notifications.
145 153
     UNCONFIRMED, ACTIVE, CANCELLED, CLOSED = (
146
-        'Unconfirmed', 'Active', 'Cancelled', 'Closed')
154
+        "Unconfirmed",
155
+        "Active",
156
+        "Cancelled",
157
+        "Closed",
158
+    )
147 159
     STATUS_CHOICES = (
148
-        (UNCONFIRMED, _('Not yet confirmed')),
149
-        (ACTIVE, _('Active')),
150
-        (CANCELLED, _('Cancelled')),
151
-        (CLOSED, _('Closed')),
160
+        (UNCONFIRMED, _("Not yet confirmed")),
161
+        (ACTIVE, _("Active")),
162
+        (CANCELLED, _("Cancelled")),
163
+        (CLOSED, _("Closed")),
152 164
     )
153
-    status = models.CharField(_("Status"), max_length=20,
154
-                              choices=STATUS_CHOICES, default=ACTIVE)
155
-
156
-    date_created = models.DateTimeField(_("Date created"), auto_now_add=True, db_index=True)
157
-    date_confirmed = models.DateTimeField(_("Date confirmed"), blank=True,
158
-                                          null=True)
159
-    date_cancelled = models.DateTimeField(_("Date cancelled"), blank=True,
160
-                                          null=True)
165
+    status = models.CharField(
166
+        _("Status"), max_length=20, choices=STATUS_CHOICES, default=ACTIVE
167
+    )
168
+
169
+    date_created = models.DateTimeField(
170
+        _("Date created"), auto_now_add=True, db_index=True
171
+    )
172
+    date_confirmed = models.DateTimeField(_("Date confirmed"), blank=True, null=True)
173
+    date_cancelled = models.DateTimeField(_("Date cancelled"), blank=True, null=True)
161 174
     date_closed = models.DateTimeField(_("Date closed"), blank=True, null=True)
162 175
 
163 176
     class Meta:
164 177
         abstract = True
165
-        app_label = 'customer'
166
-        ordering = ['-date_created']
167
-        verbose_name = _('Product alert')
168
-        verbose_name_plural = _('Product alerts')
178
+        app_label = "customer"
179
+        ordering = ["-date_created"]
180
+        verbose_name = _("Product alert")
181
+        verbose_name_plural = _("Product alerts")
169 182
 
170 183
     @property
171 184
     def is_anonymous(self):
@@ -191,18 +204,21 @@ class AbstractProductAlert(models.Model):
191 204
         self.status = self.ACTIVE
192 205
         self.date_confirmed = timezone.now()
193 206
         self.save()
207
+
194 208
     confirm.alters_data = True
195 209
 
196 210
     def cancel(self):
197 211
         self.status = self.CANCELLED
198 212
         self.date_cancelled = timezone.now()
199 213
         self.save()
214
+
200 215
     cancel.alters_data = True
201 216
 
202 217
     def close(self):
203 218
         self.status = self.CLOSED
204 219
         self.date_closed = timezone.now()
205 220
         self.save()
221
+
206 222
     close.alters_data = True
207 223
 
208 224
     def get_email_address(self):
@@ -219,8 +235,7 @@ class AbstractProductAlert(models.Model):
219 235
         # calls save, and doesn't call the methods cancel(), confirm() etc).
220 236
         if self.status == self.CANCELLED and self.date_cancelled is None:
221 237
             self.date_cancelled = timezone.now()
222
-        if not self.user and self.status == self.ACTIVE \
223
-                and self.date_confirmed is None:
238
+        if not self.user and self.status == self.ACTIVE and self.date_confirmed is None:
224 239
             self.date_confirmed = timezone.now()
225 240
         if self.status == self.CLOSED and self.date_closed is None:
226 241
             self.date_closed = timezone.now()
@@ -228,10 +243,12 @@ class AbstractProductAlert(models.Model):
228 243
         return super().save(*args, **kwargs)
229 244
 
230 245
     def get_random_key(self):
231
-        return get_random_string(length=40, allowed_chars='abcdefghijklmnopqrstuvwxyz0123456789')
246
+        return get_random_string(
247
+            length=40, allowed_chars="abcdefghijklmnopqrstuvwxyz0123456789"
248
+        )
232 249
 
233 250
     def get_confirm_url(self):
234
-        return reverse('customer:alerts-confirm', kwargs={'key': self.key})
251
+        return reverse("customer:alerts-confirm", kwargs={"key": self.key})
235 252
 
236 253
     def get_cancel_url(self):
237
-        return reverse('customer:alerts-cancel-by-key', kwargs={'key': self.key})
254
+        return reverse("customer:alerts-cancel-by-key", kwargs={"key": self.key})

+ 4
- 3
src/oscar/apps/customer/alerts/receivers.py View File

@@ -3,15 +3,16 @@ from django.db.models.signals import post_save
3 3
 
4 4
 from oscar.core.loading import get_class, get_model
5 5
 
6
-AlertsDispatcher = get_class('customer.alerts.utils', 'AlertsDispatcher')
6
+AlertsDispatcher = get_class("customer.alerts.utils", "AlertsDispatcher")
7 7
 
8 8
 
9
+# pylint: disable=unused-argument
9 10
 def send_product_alerts(sender, instance, created, **kwargs):
10
-    if kwargs.get('raw', False):
11
+    if kwargs.get("raw", False):
11 12
         return
12 13
     AlertsDispatcher().send_product_alert_email_for_user(instance.product)
13 14
 
14 15
 
15 16
 if settings.OSCAR_EAGER_ALERTS:
16
-    StockRecord = get_model('partner', 'StockRecord')
17
+    StockRecord = get_model("partner", "StockRecord")
17 18
     post_save.connect(send_product_alerts, sender=StockRecord)

+ 33
- 22
src/oscar/apps/customer/alerts/utils.py View File

@@ -5,12 +5,12 @@ from django.template import loader
5 5
 
6 6
 from oscar.core.loading import get_class, get_model
7 7
 
8
-ProductAlert = get_model('customer', 'ProductAlert')
9
-Product = get_model('catalogue', 'Product')
10
-Dispatcher = get_class('communication.utils', 'Dispatcher')
11
-Selector = get_class('partner.strategy', 'Selector')
8
+ProductAlert = get_model("customer", "ProductAlert")
9
+Product = get_model("catalogue", "Product")
10
+Dispatcher = get_class("communication.utils", "Dispatcher")
11
+Selector = get_class("partner.strategy", "Selector")
12 12
 
13
-alerts_logger = logging.getLogger('oscar.alerts')
13
+alerts_logger = logging.getLogger("oscar.alerts")
14 14
 
15 15
 
16 16
 class AlertsDispatcher:
@@ -20,8 +20,8 @@ class AlertsDispatcher:
20 20
     """
21 21
 
22 22
     # Event codes
23
-    PRODUCT_ALERT_EVENT_CODE = 'PRODUCT_ALERT'
24
-    PRODUCT_ALERT_CONFIRMATION_EVENT_CODE = 'PRODUCT_ALERT_CONFIRMATION'
23
+    PRODUCT_ALERT_EVENT_CODE = "PRODUCT_ALERT"
24
+    PRODUCT_ALERT_CONFIRMATION_EVENT_CODE = "PRODUCT_ALERT_CONFIRMATION"
25 25
 
26 26
     def __init__(self, logger=None, mail_connection=None):
27 27
         self.dispatcher = Dispatcher(
@@ -30,7 +30,11 @@ class AlertsDispatcher:
30 30
         )
31 31
 
32 32
     def get_queryset(self):
33
-        return Product.objects.browsable().filter(productalert__status=ProductAlert.ACTIVE).distinct()
33
+        return (
34
+            Product.objects.browsable()
35
+            .filter(productalert__status=ProductAlert.ACTIVE)
36
+            .distinct()
37
+        )
34 38
 
35 39
     def send_alerts(self):
36 40
         """
@@ -39,11 +43,13 @@ class AlertsDispatcher:
39 43
         available to buy.
40 44
         """
41 45
         products = self.get_queryset()
42
-        self.dispatcher.logger.info("Found %d products with active alerts", products.count())
46
+        self.dispatcher.logger.info(
47
+            "Found %d products with active alerts", products.count()
48
+        )
43 49
         for product in products:
44 50
             self.send_product_alert_email_for_user(product)
45 51
 
46
-    def send_product_alert_email_for_user(self, product):  # noqa: C901 too complex
52
+    def send_product_alert_email_for_user(self, product):
47 53
         """
48 54
         Check for notifications for this product and send email to users
49 55
         if the product is back in stock. Add a little 'hurry' note if the
@@ -64,8 +70,8 @@ class AlertsDispatcher:
64 70
         if num_stockrecords == 1:
65 71
             num_in_stock = stockrecords[0].num_in_stock
66 72
         else:
67
-            result = stockrecords.aggregate(max_in_stock=Max('num_in_stock'))
68
-            num_in_stock = result['max_in_stock']
73
+            result = stockrecords.aggregate(max_in_stock=Max("num_in_stock"))
74
+            num_in_stock = result["max_in_stock"]
69 75
 
70 76
         # 'hurry_mode' is false if 'num_in_stock' is None
71 77
         hurry_mode = num_in_stock is not None and alerts.count() > num_in_stock
@@ -82,17 +88,19 @@ class AlertsDispatcher:
82 88
                 continue
83 89
 
84 90
             extra_context = {
85
-                'alert': alert,
86
-                'hurry': hurry_mode,
91
+                "alert": alert,
92
+                "hurry": hurry_mode,
87 93
             }
88 94
             if alert.user:
89 95
                 # Send a site notification
90 96
                 num_notifications += 1
91 97
                 self.notify_user_about_product_alert(alert.user, extra_context)
92 98
 
93
-            messages = self.dispatcher.get_messages(self.PRODUCT_ALERT_EVENT_CODE, extra_context)
99
+            messages = self.dispatcher.get_messages(
100
+                self.PRODUCT_ALERT_EVENT_CODE, extra_context
101
+            )
94 102
 
95
-            if messages and messages['body']:
103
+            if messages and messages["body"]:
96 104
                 if alert.user:
97 105
                     user_messages_to_send.append((alert.user, messages))
98 106
                 else:
@@ -107,7 +115,8 @@ class AlertsDispatcher:
107 115
 
108 116
         self.dispatcher.logger.info(
109 117
             "Sent %d notifications and %d messages",
110
-            num_notifications, len(messages_to_send) + len(user_messages_to_send)
118
+            num_notifications,
119
+            len(messages_to_send) + len(user_messages_to_send),
111 120
         )
112 121
 
113 122
     def send_product_alert_confirmation_email_for_user(self, alert, extra_context=None):
@@ -115,15 +124,17 @@ class AlertsDispatcher:
115 124
         Send an alert confirmation email.
116 125
         """
117 126
         if extra_context is None:
118
-            extra_context = {'alert': alert}
119
-        messages = self.dispatcher.get_messages(self.PRODUCT_ALERT_CONFIRMATION_EVENT_CODE, extra_context)
127
+            extra_context = {"alert": alert}
128
+        messages = self.dispatcher.get_messages(
129
+            self.PRODUCT_ALERT_CONFIRMATION_EVENT_CODE, extra_context
130
+        )
120 131
         self.dispatcher.dispatch_direct_messages(alert.email, messages)
121 132
 
122 133
     def notify_user_about_product_alert(self, user, context):
123
-        subj_tpl = loader.get_template('oscar/customer/alerts/message_subject.html')
124
-        message_tpl = loader.get_template('oscar/customer/alerts/message.html')
134
+        subj_tpl = loader.get_template("oscar/customer/alerts/message_subject.html")
135
+        message_tpl = loader.get_template("oscar/customer/alerts/message.html")
125 136
         self.dispatcher.notify_user(
126 137
             user,
127 138
             subj_tpl.render(context).strip(),
128
-            body=message_tpl.render(context).strip()
139
+            body=message_tpl.render(context).strip(),
129 140
         )

+ 36
- 36
src/oscar/apps/customer/alerts/views.py View File

@@ -1,3 +1,4 @@
1
+# pylint: disable=attribute-defined-outside-init
1 2
 from django import http
2 3
 from django.contrib import messages
3 4
 from django.http import Http404
@@ -7,19 +8,19 @@ from django.views import generic
7 8
 
8 9
 from oscar.core.loading import get_class, get_model
9 10
 
10
-Product = get_model('catalogue', 'Product')
11
-ProductAlert = get_model('customer', 'ProductAlert')
12
-PageTitleMixin = get_class('customer.mixins', 'PageTitleMixin')
13
-ProductAlertForm = get_class('customer.forms', 'ProductAlertForm')
14
-AlertsDispatcher = get_class('customer.alerts.utils', 'AlertsDispatcher')
11
+Product = get_model("catalogue", "Product")
12
+ProductAlert = get_model("customer", "ProductAlert")
13
+PageTitleMixin = get_class("customer.mixins", "PageTitleMixin")
14
+ProductAlertForm = get_class("customer.forms", "ProductAlertForm")
15
+AlertsDispatcher = get_class("customer.alerts.utils", "AlertsDispatcher")
15 16
 
16 17
 
17 18
 class ProductAlertListView(PageTitleMixin, generic.ListView):
18 19
     model = ProductAlert
19
-    template_name = 'oscar/customer/alerts/alert_list.html'
20
-    context_object_name = 'alerts'
21
-    page_title = _('Product Alerts')
22
-    active_tab = 'alerts'
20
+    template_name = "oscar/customer/alerts/alert_list.html"
21
+    context_object_name = "alerts"
22
+    page_title = _("Product Alerts")
23
+    active_tab = "alerts"
23 24
 
24 25
     def get_queryset(self):
25 26
         return ProductAlert.objects.select_related().filter(
@@ -33,42 +34,44 @@ class ProductAlertCreateView(generic.CreateView):
33 34
     View to create a new product alert based on a registered user
34 35
     or an email address provided by an anonymous user.
35 36
     """
37
+
36 38
     model = ProductAlert
37 39
     form_class = ProductAlertForm
38
-    template_name = 'oscar/customer/alerts/form.html'
40
+    template_name = "oscar/customer/alerts/form.html"
39 41
 
40 42
     def get_context_data(self, **kwargs):
41 43
         ctx = super().get_context_data(**kwargs)
42
-        ctx['product'] = self.product
43
-        ctx['alert_form'] = ctx.pop('form')
44
+        ctx["product"] = self.product
45
+        ctx["alert_form"] = ctx.pop("form")
44 46
         return ctx
45 47
 
46 48
     def get(self, request, *args, **kwargs):
47
-        product = get_object_or_404(Product, pk=self.kwargs['pk'])
49
+        product = get_object_or_404(Product, pk=self.kwargs["pk"])
48 50
         return http.HttpResponseRedirect(product.get_absolute_url())
49 51
 
50 52
     def post(self, request, *args, **kwargs):
51
-        self.product = get_object_or_404(Product, pk=self.kwargs['pk'])
53
+        self.product = get_object_or_404(Product, pk=self.kwargs["pk"])
52 54
         return super().post(request, *args, **kwargs)
53 55
 
54 56
     def get_form_kwargs(self):
55 57
         kwargs = super().get_form_kwargs()
56
-        kwargs['user'] = self.request.user
57
-        kwargs['product'] = self.product
58
+        kwargs["user"] = self.request.user
59
+        kwargs["product"] = self.product
58 60
         return kwargs
59 61
 
60 62
     def form_valid(self, form):
61 63
         response = super().form_valid(form)
62 64
         if self.object.is_anonymous:
63
-            AlertsDispatcher().send_product_alert_confirmation_email_for_user(self.object)
65
+            AlertsDispatcher().send_product_alert_confirmation_email_for_user(
66
+                self.object
67
+            )
64 68
         return response
65 69
 
66 70
     def get_success_url(self):
67 71
         if self.object.user:
68 72
             msg = _("An alert has been created")
69 73
         else:
70
-            msg = _("A confirmation email has been sent to %s") \
71
-                % self.object.email
74
+            msg = _("A confirmation email has been sent to %s") % self.object.email
72 75
         messages.success(self.request, msg)
73 76
         return self.object.product.get_absolute_url()
74 77
 
@@ -77,7 +80,7 @@ class ProductAlertConfirmView(generic.RedirectView):
77 80
     permanent = False
78 81
 
79 82
     def get(self, request, *args, **kwargs):
80
-        self.alert = get_object_or_404(ProductAlert, key=kwargs['key'])
83
+        self.alert = get_object_or_404(ProductAlert, key=kwargs["key"])
81 84
         self.update_alert()
82 85
         return super().get(request, *args, **kwargs)
83 86
 
@@ -86,10 +89,9 @@ class ProductAlertConfirmView(generic.RedirectView):
86 89
             self.alert.confirm()
87 90
             messages.success(self.request, _("Your stock alert is now active"))
88 91
         else:
89
-            messages.error(self.request, _("Your stock alert cannot be"
90
-                                           " confirmed"))
92
+            messages.error(self.request, _("Your stock alert cannot be confirmed"))
91 93
 
92
-    def get_redirect_url(self, **kwargs):
94
+    def get_redirect_url(self, *args, **kwargs):
93 95
         return self.alert.product.get_absolute_url()
94 96
 
95 97
 
@@ -102,15 +104,16 @@ class ProductAlertCancelView(generic.RedirectView):
102 104
     Specifying the redirect url is possible by supplying a 'next' GET
103 105
     parameter.  It defaults to showing the associated product page.
104 106
     """
107
+
105 108
     permanent = False
106 109
 
107 110
     def get(self, request, *args, **kwargs):
108
-        if 'key' in kwargs:
109
-            self.alert = get_object_or_404(ProductAlert, key=kwargs['key'])
110
-        elif 'pk' in kwargs and request.user.is_authenticated:
111
-            self.alert = get_object_or_404(ProductAlert,
112
-                                           user=self.request.user,
113
-                                           pk=kwargs['pk'])
111
+        if "key" in kwargs:
112
+            self.alert = get_object_or_404(ProductAlert, key=kwargs["key"])
113
+        elif "pk" in kwargs and request.user.is_authenticated:
114
+            self.alert = get_object_or_404(
115
+                ProductAlert, user=self.request.user, pk=kwargs["pk"]
116
+            )
114 117
         else:
115 118
             raise Http404
116 119
         self.update_alert()
@@ -119,12 +122,9 @@ class ProductAlertCancelView(generic.RedirectView):
119 122
     def update_alert(self):
120 123
         if self.alert.can_be_cancelled:
121 124
             self.alert.cancel()
122
-            messages.success(self.request, _("Your stock alert has been"
123
-                                             " cancelled"))
125
+            messages.success(self.request, _("Your stock alert has been cancelled"))
124 126
         else:
125
-            messages.error(self.request, _("Your stock alert cannot be"
126
-                                           " cancelled"))
127
+            messages.error(self.request, _("Your stock alert cannot be cancelled"))
127 128
 
128
-    def get_redirect_url(self, **kwargs):
129
-        return self.request.GET.get('next',
130
-                                    self.alert.product.get_absolute_url())
129
+    def get_redirect_url(self, *args, **kwargs):
130
+        return self.request.GET.get("next", self.alert.product.get_absolute_url())

+ 228
- 125
src/oscar/apps/customer/apps.py View File

@@ -8,193 +8,296 @@ from oscar.core.loading import get_class
8 8
 
9 9
 
10 10
 class CustomerConfig(OscarConfig):
11
-    label = 'customer'
12
-    name = 'oscar.apps.customer'
13
-    verbose_name = _('Customer')
11
+    label = "customer"
12
+    name = "oscar.apps.customer"
13
+    verbose_name = _("Customer")
14 14
 
15
-    namespace = 'customer'
15
+    namespace = "customer"
16 16
 
17
+    # pylint: disable=attribute-defined-outside-init, reimported, unused-import
17 18
     def ready(self):
18
-        from . import receivers  # noqa
19
-        from .alerts import receivers  # noqa
19
+        from . import receivers
20
+        from .alerts import receivers
20 21
 
21
-        self.summary_view = get_class('customer.views', 'AccountSummaryView')
22
-        self.order_history_view = get_class('customer.views', 'OrderHistoryView')
23
-        self.order_detail_view = get_class('customer.views', 'OrderDetailView')
24
-        self.anon_order_detail_view = get_class('customer.views',
25
-                                                'AnonymousOrderDetailView')
26
-        self.order_line_view = get_class('customer.views', 'OrderLineView')
22
+        self.summary_view = get_class("customer.views", "AccountSummaryView")
23
+        self.order_history_view = get_class("customer.views", "OrderHistoryView")
24
+        self.order_detail_view = get_class("customer.views", "OrderDetailView")
25
+        self.anon_order_detail_view = get_class(
26
+            "customer.views", "AnonymousOrderDetailView"
27
+        )
28
+        self.order_line_view = get_class("customer.views", "OrderLineView")
27 29
 
28
-        self.address_list_view = get_class('customer.views', 'AddressListView')
29
-        self.address_create_view = get_class('customer.views', 'AddressCreateView')
30
-        self.address_update_view = get_class('customer.views', 'AddressUpdateView')
31
-        self.address_delete_view = get_class('customer.views', 'AddressDeleteView')
32
-        self.address_change_status_view = get_class('customer.views',
33
-                                                    'AddressChangeStatusView')
30
+        self.address_list_view = get_class("customer.views", "AddressListView")
31
+        self.address_create_view = get_class("customer.views", "AddressCreateView")
32
+        self.address_update_view = get_class("customer.views", "AddressUpdateView")
33
+        self.address_delete_view = get_class("customer.views", "AddressDeleteView")
34
+        self.address_change_status_view = get_class(
35
+            "customer.views", "AddressChangeStatusView"
36
+        )
34 37
 
35
-        self.email_list_view = get_class('customer.views', 'EmailHistoryView')
36
-        self.email_detail_view = get_class('customer.views', 'EmailDetailView')
37
-        self.login_view = get_class('customer.views', 'AccountAuthView')
38
-        self.logout_view = get_class('customer.views', 'LogoutView')
39
-        self.register_view = get_class('customer.views', 'AccountRegistrationView')
40
-        self.profile_view = get_class('customer.views', 'ProfileView')
41
-        self.profile_update_view = get_class('customer.views', 'ProfileUpdateView')
42
-        self.profile_delete_view = get_class('customer.views', 'ProfileDeleteView')
43
-        self.change_password_view = get_class('customer.views', 'ChangePasswordView')
38
+        self.email_list_view = get_class("customer.views", "EmailHistoryView")
39
+        self.email_detail_view = get_class("customer.views", "EmailDetailView")
40
+        self.login_view = get_class("customer.views", "AccountAuthView")
41
+        self.logout_view = get_class("customer.views", "LogoutView")
42
+        self.register_view = get_class("customer.views", "AccountRegistrationView")
43
+        self.profile_view = get_class("customer.views", "ProfileView")
44
+        self.profile_update_view = get_class("customer.views", "ProfileUpdateView")
45
+        self.profile_delete_view = get_class("customer.views", "ProfileDeleteView")
46
+        self.change_password_view = get_class("customer.views", "ChangePasswordView")
44 47
 
45
-        self.notification_inbox_view = get_class('communication.notifications.views',
46
-                                                 'InboxView')
47
-        self.notification_archive_view = get_class('communication.notifications.views',
48
-                                                   'ArchiveView')
49
-        self.notification_update_view = get_class('communication.notifications.views',
50
-                                                  'UpdateView')
51
-        self.notification_detail_view = get_class('communication.notifications.views',
52
-                                                  'DetailView')
48
+        self.notification_inbox_view = get_class(
49
+            "communication.notifications.views", "InboxView"
50
+        )
51
+        self.notification_archive_view = get_class(
52
+            "communication.notifications.views", "ArchiveView"
53
+        )
54
+        self.notification_update_view = get_class(
55
+            "communication.notifications.views", "UpdateView"
56
+        )
57
+        self.notification_detail_view = get_class(
58
+            "communication.notifications.views", "DetailView"
59
+        )
53 60
 
54
-        self.alert_list_view = get_class('customer.alerts.views',
55
-                                         'ProductAlertListView')
56
-        self.alert_create_view = get_class('customer.alerts.views',
57
-                                           'ProductAlertCreateView')
58
-        self.alert_confirm_view = get_class('customer.alerts.views',
59
-                                            'ProductAlertConfirmView')
60
-        self.alert_cancel_view = get_class('customer.alerts.views',
61
-                                           'ProductAlertCancelView')
61
+        self.alert_list_view = get_class(
62
+            "customer.alerts.views", "ProductAlertListView"
63
+        )
64
+        self.alert_create_view = get_class(
65
+            "customer.alerts.views", "ProductAlertCreateView"
66
+        )
67
+        self.alert_confirm_view = get_class(
68
+            "customer.alerts.views", "ProductAlertConfirmView"
69
+        )
70
+        self.alert_cancel_view = get_class(
71
+            "customer.alerts.views", "ProductAlertCancelView"
72
+        )
62 73
 
63
-        self.wishlists_add_product_view = get_class('customer.wishlists.views',
64
-                                                    'WishListAddProduct')
65
-        self.wishlists_list_view = get_class('customer.wishlists.views',
66
-                                             'WishListListView')
67
-        self.wishlists_detail_view = get_class('customer.wishlists.views',
68
-                                               'WishListDetailView')
69
-        self.wishlists_create_view = get_class('customer.wishlists.views',
70
-                                               'WishListCreateView')
71
-        self.wishlists_create_with_product_view = get_class('customer.wishlists.views',
72
-                                                            'WishListCreateView')
73
-        self.wishlists_update_view = get_class('customer.wishlists.views',
74
-                                               'WishListUpdateView')
75
-        self.wishlists_delete_view = get_class('customer.wishlists.views',
76
-                                               'WishListDeleteView')
77
-        self.wishlists_remove_product_view = get_class('customer.wishlists.views',
78
-                                                       'WishListRemoveProduct')
74
+        self.wishlists_add_product_view = get_class(
75
+            "customer.wishlists.views", "WishListAddProduct"
76
+        )
77
+        self.wishlists_list_view = get_class(
78
+            "customer.wishlists.views", "WishListListView"
79
+        )
80
+        self.wishlists_detail_view = get_class(
81
+            "customer.wishlists.views", "WishListDetailView"
82
+        )
83
+        self.wishlists_create_view = get_class(
84
+            "customer.wishlists.views", "WishListCreateView"
85
+        )
86
+        self.wishlists_create_with_product_view = get_class(
87
+            "customer.wishlists.views", "WishListCreateView"
88
+        )
89
+        self.wishlists_update_view = get_class(
90
+            "customer.wishlists.views", "WishListUpdateView"
91
+        )
92
+        self.wishlists_delete_view = get_class(
93
+            "customer.wishlists.views", "WishListDeleteView"
94
+        )
95
+        self.wishlists_remove_product_view = get_class(
96
+            "customer.wishlists.views", "WishListRemoveProduct"
97
+        )
79 98
         self.wishlists_move_product_to_another_view = get_class(
80
-            'customer.wishlists.views', 'WishListMoveProductToAnotherWishList')
99
+            "customer.wishlists.views", "WishListMoveProductToAnotherWishList"
100
+        )
81 101
 
82 102
     def get_urls(self):
83 103
         urls = [
84 104
             # Login, logout and register doesn't require login
85
-            path('login/', self.login_view.as_view(), name='login'),
86
-            path('logout/', self.logout_view.as_view(), name='logout'),
87
-            path('register/', self.register_view.as_view(), name='register'),
88
-            path('', login_required(self.summary_view.as_view()), name='summary'),
89
-            path('change-password/', login_required(self.change_password_view.as_view()), name='change-password'),
90
-
105
+            path("login/", self.login_view.as_view(), name="login"),
106
+            path("logout/", self.logout_view.as_view(), name="logout"),
107
+            path("register/", self.register_view.as_view(), name="register"),
108
+            path("", login_required(self.summary_view.as_view()), name="summary"),
109
+            path(
110
+                "change-password/",
111
+                login_required(self.change_password_view.as_view()),
112
+                name="change-password",
113
+            ),
91 114
             # Profile
92
-            path('profile/', login_required(self.profile_view.as_view()), name='profile-view'),
93
-            path('profile/edit/', login_required(self.profile_update_view.as_view()), name='profile-update'),
94
-            path('profile/delete/', login_required(self.profile_delete_view.as_view()), name='profile-delete'),
95
-
115
+            path(
116
+                "profile/",
117
+                login_required(self.profile_view.as_view()),
118
+                name="profile-view",
119
+            ),
120
+            path(
121
+                "profile/edit/",
122
+                login_required(self.profile_update_view.as_view()),
123
+                name="profile-update",
124
+            ),
125
+            path(
126
+                "profile/delete/",
127
+                login_required(self.profile_delete_view.as_view()),
128
+                name="profile-delete",
129
+            ),
96 130
             # Order history
97
-            path('orders/', login_required(self.order_history_view.as_view()), name='order-list'),
131
+            path(
132
+                "orders/",
133
+                login_required(self.order_history_view.as_view()),
134
+                name="order-list",
135
+            ),
98 136
             re_path(
99
-                r'^order-status/(?P<order_number>[\w-]*)/(?P<hash>[A-z0-9-_=:]+)/$',
100
-                self.anon_order_detail_view.as_view(), name='anon-order'
137
+                r"^order-status/(?P<order_number>[\w-]*)/(?P<hash>[A-z0-9-_=:]+)/$",
138
+                self.anon_order_detail_view.as_view(),
139
+                name="anon-order",
101 140
             ),
102
-            path('orders/<str:order_number>/', login_required(self.order_detail_view.as_view()), name='order'),
103 141
             path(
104
-                'orders/<str:order_number>/<int:line_id>/',
142
+                "orders/<str:order_number>/",
143
+                login_required(self.order_detail_view.as_view()),
144
+                name="order",
145
+            ),
146
+            path(
147
+                "orders/<str:order_number>/<int:line_id>/",
105 148
                 login_required(self.order_line_view.as_view()),
106
-                name='order-line'),
107
-
149
+                name="order-line",
150
+            ),
108 151
             # Address book
109
-            path('addresses/', login_required(self.address_list_view.as_view()), name='address-list'),
110
-            path('addresses/add/', login_required(self.address_create_view.as_view()), name='address-create'),
111
-            path('addresses/<int:pk>/', login_required(self.address_update_view.as_view()), name='address-detail'),
112 152
             path(
113
-                'addresses/<int:pk>/delete/',
153
+                "addresses/",
154
+                login_required(self.address_list_view.as_view()),
155
+                name="address-list",
156
+            ),
157
+            path(
158
+                "addresses/add/",
159
+                login_required(self.address_create_view.as_view()),
160
+                name="address-create",
161
+            ),
162
+            path(
163
+                "addresses/<int:pk>/",
164
+                login_required(self.address_update_view.as_view()),
165
+                name="address-detail",
166
+            ),
167
+            path(
168
+                "addresses/<int:pk>/delete/",
114 169
                 login_required(self.address_delete_view.as_view()),
115
-                name='address-delete'),
170
+                name="address-delete",
171
+            ),
116 172
             re_path(
117
-                r'^addresses/(?P<pk>\d+)/(?P<action>default_for_(billing|shipping))/$',
173
+                r"^addresses/(?P<pk>\d+)/(?P<action>default_for_(billing|shipping))/$",
118 174
                 login_required(self.address_change_status_view.as_view()),
119
-                name='address-change-status'),
120
-
175
+                name="address-change-status",
176
+            ),
121 177
             # Email history
122
-            path('emails/', login_required(self.email_list_view.as_view()), name='email-list'),
123
-            path('emails/<int:email_id>/', login_required(self.email_detail_view.as_view()), name='email-detail'),
124
-
178
+            path(
179
+                "emails/",
180
+                login_required(self.email_list_view.as_view()),
181
+                name="email-list",
182
+            ),
183
+            path(
184
+                "emails/<int:email_id>/",
185
+                login_required(self.email_detail_view.as_view()),
186
+                name="email-detail",
187
+            ),
125 188
             # Notifications
126 189
             # Redirect to notification inbox
127 190
             path(
128
-                'notifications/', generic.RedirectView.as_view(url='/accounts/notifications/inbox/', permanent=False)),
191
+                "notifications/",
192
+                generic.RedirectView.as_view(
193
+                    url="/accounts/notifications/inbox/", permanent=False
194
+                ),
195
+            ),
129 196
             path(
130
-                'notifications/inbox/',
197
+                "notifications/inbox/",
131 198
                 login_required(self.notification_inbox_view.as_view()),
132
-                name='notifications-inbox'),
199
+                name="notifications-inbox",
200
+            ),
133 201
             path(
134
-                'notifications/archive/',
202
+                "notifications/archive/",
135 203
                 login_required(self.notification_archive_view.as_view()),
136
-                name='notifications-archive'),
204
+                name="notifications-archive",
205
+            ),
137 206
             path(
138
-                'notifications/update/',
207
+                "notifications/update/",
139 208
                 login_required(self.notification_update_view.as_view()),
140
-                name='notifications-update'),
209
+                name="notifications-update",
210
+            ),
141 211
             path(
142
-                'notifications/<int:pk>/',
212
+                "notifications/<int:pk>/",
143 213
                 login_required(self.notification_detail_view.as_view()),
144
-                name='notifications-detail'),
145
-
214
+                name="notifications-detail",
215
+            ),
146 216
             # Alerts
147 217
             # Alerts can be setup by anonymous users: some views do not
148 218
             # require login
149
-            path('alerts/', login_required(self.alert_list_view.as_view()), name='alerts-list'),
150
-            path('alerts/create/<int:pk>/', self.alert_create_view.as_view(), name='alert-create'),
151
-            path('alerts/confirm/<str:key>/', self.alert_confirm_view.as_view(), name='alerts-confirm'),
152
-            path('alerts/cancel/key/<str:key>/', self.alert_cancel_view.as_view(), name='alerts-cancel-by-key'),
153 219
             path(
154
-                'alerts/cancel/<int:pk>/',
220
+                "alerts/",
221
+                login_required(self.alert_list_view.as_view()),
222
+                name="alerts-list",
223
+            ),
224
+            path(
225
+                "alerts/create/<int:pk>/",
226
+                self.alert_create_view.as_view(),
227
+                name="alert-create",
228
+            ),
229
+            path(
230
+                "alerts/confirm/<str:key>/",
231
+                self.alert_confirm_view.as_view(),
232
+                name="alerts-confirm",
233
+            ),
234
+            path(
235
+                "alerts/cancel/key/<str:key>/",
236
+                self.alert_cancel_view.as_view(),
237
+                name="alerts-cancel-by-key",
238
+            ),
239
+            path(
240
+                "alerts/cancel/<int:pk>/",
155 241
                 login_required(self.alert_cancel_view.as_view()),
156
-                name='alerts-cancel-by-pk'),
157
-
242
+                name="alerts-cancel-by-pk",
243
+            ),
158 244
             # Wishlists
159
-            path('wishlists/', login_required(self.wishlists_list_view.as_view()), name='wishlists-list'),
160 245
             path(
161
-                'wishlists/add/<int:product_pk>/',
246
+                "wishlists/",
247
+                login_required(self.wishlists_list_view.as_view()),
248
+                name="wishlists-list",
249
+            ),
250
+            path(
251
+                "wishlists/add/<int:product_pk>/",
162 252
                 login_required(self.wishlists_add_product_view.as_view()),
163
-                name='wishlists-add-product'),
253
+                name="wishlists-add-product",
254
+            ),
164 255
             path(
165
-                'wishlists/<str:key>/add/<int:product_pk>/',
256
+                "wishlists/<str:key>/add/<int:product_pk>/",
166 257
                 login_required(self.wishlists_add_product_view.as_view()),
167
-                name='wishlists-add-product'),
258
+                name="wishlists-add-product",
259
+            ),
168 260
             path(
169
-                'wishlists/create/',
261
+                "wishlists/create/",
170 262
                 login_required(self.wishlists_create_view.as_view()),
171
-                name='wishlists-create'),
263
+                name="wishlists-create",
264
+            ),
172 265
             path(
173
-                'wishlists/create/with-product/<int:product_pk>/',
266
+                "wishlists/create/with-product/<int:product_pk>/",
174 267
                 login_required(self.wishlists_create_view.as_view()),
175
-                name='wishlists-create-with-product'),
268
+                name="wishlists-create-with-product",
269
+            ),
176 270
             # Wishlists can be publicly shared, no login required
177
-            path('wishlists/<str:key>/', self.wishlists_detail_view.as_view(), name='wishlists-detail'),
178 271
             path(
179
-                'wishlists/<str:key>/update/',
272
+                "wishlists/<str:key>/",
273
+                self.wishlists_detail_view.as_view(),
274
+                name="wishlists-detail",
275
+            ),
276
+            path(
277
+                "wishlists/<str:key>/update/",
180 278
                 login_required(self.wishlists_update_view.as_view()),
181
-                name='wishlists-update'),
279
+                name="wishlists-update",
280
+            ),
182 281
             path(
183
-                'wishlists/<str:key>/delete/',
282
+                "wishlists/<str:key>/delete/",
184 283
                 login_required(self.wishlists_delete_view.as_view()),
185
-                name='wishlists-delete'),
284
+                name="wishlists-delete",
285
+            ),
186 286
             path(
187
-                'wishlists/<str:key>/lines/<int:line_pk>/delete/',
287
+                "wishlists/<str:key>/lines/<int:line_pk>/delete/",
188 288
                 login_required(self.wishlists_remove_product_view.as_view()),
189
-                name='wishlists-remove-product'),
289
+                name="wishlists-remove-product",
290
+            ),
190 291
             path(
191
-                'wishlists/<str:key>/products/<int:product_pk>/delete/',
292
+                "wishlists/<str:key>/products/<int:product_pk>/delete/",
192 293
                 login_required(self.wishlists_remove_product_view.as_view()),
193
-                name='wishlists-remove-product'),
294
+                name="wishlists-remove-product",
295
+            ),
194 296
             path(
195
-                'wishlists/<str:key>/lines/<int:line_pk>/move-to/<str:to_key>/',
297
+                "wishlists/<str:key>/lines/<int:line_pk>/move-to/<str:to_key>/",
196 298
                 login_required(self.wishlists_move_product_to_another_view.as_view()),
197
-                name='wishlists-move-product-to-another')
299
+                name="wishlists-move-product-to-another",
300
+            ),
198 301
         ]
199 302
 
200 303
         return self.post_process_urls(urls)

+ 14
- 10
src/oscar/apps/customer/auth_backends.py View File

@@ -6,11 +6,11 @@ from oscar.core.compat import get_user_model
6 6
 
7 7
 User = get_user_model()
8 8
 
9
-if hasattr(User, 'REQUIRED_FIELDS'):
10
-    if not (User.USERNAME_FIELD == 'email' or 'email' in User.REQUIRED_FIELDS):
9
+if hasattr(User, "REQUIRED_FIELDS"):
10
+    if not (User.USERNAME_FIELD == "email" or "email" in User.REQUIRED_FIELDS):
11 11
         raise ImproperlyConfigured(
12
-            "EmailBackend: Your User model must have an email"
13
-            " field with blank=False")
12
+            "EmailBackend: Your User model must have an email field with blank=False"
13
+        )
14 14
 
15 15
 
16 16
 class EmailBackend(ModelBackend):
@@ -20,16 +20,17 @@ class EmailBackend(ModelBackend):
20 20
     For this to work, the User model must have an 'email' field
21 21
     """
22 22
 
23
+    # pylint: disable=keyword-arg-before-vararg
23 24
     def _authenticate(self, request, email=None, password=None, *args, **kwargs):
24 25
         if email is None:
25
-            if 'username' not in kwargs or kwargs['username'] is None:
26
+            if "username" not in kwargs or kwargs["username"] is None:
26 27
                 return None
27
-            clean_email = normalise_email(kwargs['username'])
28
+            clean_email = normalise_email(kwargs["username"])
28 29
         else:
29 30
             clean_email = normalise_email(email)
30 31
 
31 32
         # Check if we're dealing with an email address
32
-        if '@' not in clean_email:
33
+        if "@" not in clean_email:
33 34
             return None
34 35
 
35 36
         # Since Django doesn't enforce emails to be unique, we look for all
@@ -40,7 +41,10 @@ class EmailBackend(ModelBackend):
40 41
         # We make a case-insensitive match when looking for emails.
41 42
         matching_users = User.objects.filter(email__iexact=clean_email)
42 43
         authenticated_users = [
43
-            user for user in matching_users if (user.check_password(password) and self.user_can_authenticate(user))]
44
+            user
45
+            for user in matching_users
46
+            if (user.check_password(password) and self.user_can_authenticate(user))
47
+        ]
44 48
         if len(authenticated_users) == 1:
45 49
             # Happy path
46 50
             return authenticated_users[0]
@@ -49,8 +53,8 @@ class EmailBackend(ModelBackend):
49 53
             # the same email address AND password. We can't safely authenticate
50 54
             # either.
51 55
             raise User.MultipleObjectsReturned(
52
-                "There are multiple users with the given email address and "
53
-                "password")
56
+                "There are multiple users with the given email address and password"
57
+            )
54 58
         return None
55 59
 
56 60
     def authenticate(self, *args, **kwargs):

+ 135
- 113
src/oscar/apps/customer/forms.py View File

@@ -19,14 +19,14 @@ from oscar.core.loading import get_class, get_model, get_profile_class
19 19
 from oscar.core.utils import datetime_combine
20 20
 from oscar.forms import widgets
21 21
 
22
-CustomerDispatcher = get_class('customer.utils', 'CustomerDispatcher')
23
-ProductAlert = get_model('customer', 'ProductAlert')
22
+CustomerDispatcher = get_class("customer.utils", "CustomerDispatcher")
23
+ProductAlert = get_model("customer", "ProductAlert")
24 24
 User = get_user_model()
25 25
 
26 26
 
27 27
 def generate_username():
28 28
     letters = string.ascii_letters
29
-    allowed_chars = letters + string.digits + '_'
29
+    allowed_chars = letters + string.digits + "_"
30 30
     uname = get_random_string(length=30, allowed_chars=allowed_chars)
31 31
     try:
32 32
         User.objects.get(username=uname)
@@ -40,7 +40,7 @@ class PasswordResetForm(auth_forms.PasswordResetForm):
40 40
     This form takes the same structure as its parent from :py:mod:`django.contrib.auth`
41 41
     """
42 42
 
43
-    def save(self, domain_override=None, request=None, **kwargs):
43
+    def save(self, *args, domain_override=None, request=None, **kwargs):
44 44
         """
45 45
         Generates a one-use only link for resetting password and sends to the
46 46
         user.
@@ -48,15 +48,15 @@ class PasswordResetForm(auth_forms.PasswordResetForm):
48 48
         site = get_current_site(request)
49 49
         if domain_override is not None:
50 50
             site.domain = site.name = domain_override
51
-        for user in self.get_users(self.cleaned_data['email']):
51
+        for user in self.get_users(self.cleaned_data["email"]):
52 52
             self.send_password_reset_email(site, user, request)
53 53
 
54 54
     def send_password_reset_email(self, site, user, request=None):
55 55
         extra_context = {
56
-            'user': user,
57
-            'site': site,
58
-            'reset_url': get_password_reset_url(user),
59
-            'request': request,
56
+            "user": user,
57
+            "site": site,
58
+            "reset_url": get_password_reset_url(user),
59
+            "request": request,
60 60
         }
61 61
         CustomerDispatcher().send_password_reset_email_for_user(user, extra_context)
62 62
 
@@ -67,16 +67,16 @@ class EmailAuthenticationForm(AuthenticationForm):
67 67
     usernames. 75 character usernames are needed to support the EmailOrUsername
68 68
     authentication backend.
69 69
     """
70
-    username = forms.EmailField(label=_('Email address'))
71
-    redirect_url = forms.CharField(
72
-        widget=forms.HiddenInput, required=False)
70
+
71
+    username = forms.EmailField(label=_("Email address"))
72
+    redirect_url = forms.CharField(widget=forms.HiddenInput, required=False)
73 73
 
74 74
     def __init__(self, host, *args, **kwargs):
75 75
         self.host = host
76 76
         super().__init__(*args, **kwargs)
77 77
 
78 78
     def clean_redirect_url(self):
79
-        url = self.cleaned_data['redirect_url'].strip()
79
+        url = self.cleaned_data["redirect_url"].strip()
80 80
         if url and url_has_allowed_host_and_scheme(url, self.host):
81 81
             return url
82 82
 
@@ -87,6 +87,7 @@ class ConfirmPasswordForm(forms.Form):
87 87
     usernames. 75 character usernames are needed to support the EmailOrUsername
88 88
     authentication backend.
89 89
     """
90
+
90 91
     password = forms.CharField(label=_("Password"), widget=forms.PasswordInput)
91 92
 
92 93
     def __init__(self, user, *args, **kwargs):
@@ -94,33 +95,29 @@ class ConfirmPasswordForm(forms.Form):
94 95
         self.user = user
95 96
 
96 97
     def clean_password(self):
97
-        password = self.cleaned_data['password']
98
+        password = self.cleaned_data["password"]
98 99
         if not self.user.check_password(password):
99
-            raise forms.ValidationError(
100
-                _("The entered password is not valid!"))
100
+            raise forms.ValidationError(_("The entered password is not valid!"))
101 101
         return password
102 102
 
103 103
 
104 104
 class EmailUserCreationForm(forms.ModelForm):
105
-    email = forms.EmailField(label=_('Email address'))
106
-    password1 = forms.CharField(
107
-        label=_('Password'), widget=forms.PasswordInput)
108
-    password2 = forms.CharField(
109
-        label=_('Confirm password'), widget=forms.PasswordInput)
110
-    redirect_url = forms.CharField(
111
-        widget=forms.HiddenInput, required=False)
105
+    email = forms.EmailField(label=_("Email address"))
106
+    password1 = forms.CharField(label=_("Password"), widget=forms.PasswordInput)
107
+    password2 = forms.CharField(label=_("Confirm password"), widget=forms.PasswordInput)
108
+    redirect_url = forms.CharField(widget=forms.HiddenInput, required=False)
112 109
 
113 110
     class Meta:
114 111
         model = User
115
-        fields = ('email',)
112
+        fields = ("email",)
116 113
 
117
-    def __init__(self, host=None, *args, **kwargs):
114
+    def __init__(self, *args, host=None, **kwargs):
118 115
         self.host = host
119 116
         super().__init__(*args, **kwargs)
120 117
 
121 118
     def _post_clean(self):
122 119
         super()._post_clean()
123
-        password = self.cleaned_data.get('password2')
120
+        password = self.cleaned_data.get("password2")
124 121
         # Validate after self.instance is updated with form data
125 122
         # otherwise validators can't access email
126 123
         # see django.contrib.auth.forms.UserCreationForm
@@ -128,37 +125,37 @@ class EmailUserCreationForm(forms.ModelForm):
128 125
             try:
129 126
                 validate_password(password, self.instance)
130 127
             except forms.ValidationError as error:
131
-                self.add_error('password2', error)
128
+                self.add_error("password2", error)
132 129
 
133 130
     def clean_email(self):
134 131
         """
135 132
         Checks for existing users with the supplied email address.
136 133
         """
137
-        email = normalise_email(self.cleaned_data['email'])
134
+        email = normalise_email(self.cleaned_data["email"])
138 135
         if User._default_manager.filter(email__iexact=email).exists():
139 136
             raise forms.ValidationError(
140
-                _("A user with that email address already exists"))
137
+                _("A user with that email address already exists")
138
+            )
141 139
         return email
142 140
 
143 141
     def clean_password2(self):
144
-        password1 = self.cleaned_data.get('password1', '')
145
-        password2 = self.cleaned_data.get('password2', '')
142
+        password1 = self.cleaned_data.get("password1", "")
143
+        password2 = self.cleaned_data.get("password2", "")
146 144
         if password1 != password2:
147
-            raise forms.ValidationError(
148
-                _("The two password fields didn't match."))
145
+            raise forms.ValidationError(_("The two password fields didn't match."))
149 146
         return password2
150 147
 
151 148
     def clean_redirect_url(self):
152
-        url = self.cleaned_data['redirect_url'].strip()
149
+        url = self.cleaned_data["redirect_url"].strip()
153 150
         if url and url_has_allowed_host_and_scheme(url, self.host):
154 151
             return url
155 152
         return settings.LOGIN_REDIRECT_URL
156 153
 
157 154
     def save(self, commit=True):
158 155
         user = super().save(commit=False)
159
-        user.set_password(self.cleaned_data['password1'])
156
+        user.set_password(self.cleaned_data["password1"])
160 157
 
161
-        if 'username' in [f.name for f in User._meta.fields]:
158
+        if "username" in [f.name for f in User._meta.fields]:
162 159
             user.username = generate_username()
163 160
         if commit:
164 161
             user.save()
@@ -167,17 +164,25 @@ class EmailUserCreationForm(forms.ModelForm):
167 164
 
168 165
 class OrderSearchForm(forms.Form):
169 166
     date_from = forms.DateField(
170
-        required=False, label=pgettext_lazy("start date", "From"),
171
-        widget=widgets.DatePickerInput())
167
+        required=False,
168
+        label=pgettext_lazy("start date", "From"),
169
+        widget=widgets.DatePickerInput(),
170
+    )
172 171
     date_to = forms.DateField(
173
-        required=False, label=pgettext_lazy("end date", "To"),
174
-        widget=widgets.DatePickerInput())
172
+        required=False,
173
+        label=pgettext_lazy("end date", "To"),
174
+        widget=widgets.DatePickerInput(),
175
+    )
175 176
     order_number = forms.CharField(required=False, label=_("Order number"))
176 177
 
177 178
     def clean(self):
178
-        if self.is_valid() and not any([self.cleaned_data['date_from'],
179
-                                        self.cleaned_data['date_to'],
180
-                                        self.cleaned_data['order_number']]):
179
+        if self.is_valid() and not any(
180
+            [
181
+                self.cleaned_data["date_from"],
182
+                self.cleaned_data["date_to"],
183
+                self.cleaned_data["order_number"],
184
+            ]
185
+        ):
181 186
             raise forms.ValidationError(_("At least one field is required."))
182 187
         return super().clean()
183 188
 
@@ -187,67 +192,71 @@ class OrderSearchForm(forms.Form):
187 192
         are listed.
188 193
         """
189 194
         if not self.is_bound or not self.is_valid():
190
-            return _('All orders')
195
+            return _("All orders")
191 196
         else:
192
-            date_from = self.cleaned_data['date_from']
193
-            date_to = self.cleaned_data['date_to']
194
-            order_number = self.cleaned_data['order_number']
197
+            date_from = self.cleaned_data["date_from"]
198
+            date_to = self.cleaned_data["date_to"]
199
+            order_number = self.cleaned_data["order_number"]
195 200
             return self._orders_description(date_from, date_to, order_number)
196 201
 
197 202
     def _orders_description(self, date_from, date_to, order_number):
198 203
         if date_from and date_to:
199 204
             if order_number:
200
-                desc = _('Orders placed between %(date_from)s and '
201
-                         '%(date_to)s and order number containing '
202
-                         '%(order_number)s')
205
+                desc = _(
206
+                    "Orders placed between %(date_from)s and "
207
+                    "%(date_to)s and order number containing "
208
+                    "%(order_number)s"
209
+                )
203 210
             else:
204
-                desc = _('Orders placed between %(date_from)s and '
205
-                         '%(date_to)s')
211
+                desc = _("Orders placed between %(date_from)s and% (date_to)s")
206 212
         elif date_from:
207 213
             if order_number:
208
-                desc = _('Orders placed since %(date_from)s and '
209
-                         'order number containing %(order_number)s')
214
+                desc = _(
215
+                    "Orders placed since %(date_from)s and "
216
+                    "order number containing %(order_number)s"
217
+                )
210 218
             else:
211
-                desc = _('Orders placed since %(date_from)s')
219
+                desc = _("Orders placed since %(date_from)s")
212 220
         elif date_to:
213 221
             if order_number:
214
-                desc = _('Orders placed until %(date_to)s and '
215
-                         'order number containing %(order_number)s')
222
+                desc = _(
223
+                    "Orders placed until %(date_to)s and "
224
+                    "order number containing %(order_number)s"
225
+                )
216 226
             else:
217
-                desc = _('Orders placed until %(date_to)s')
227
+                desc = _("Orders placed until %(date_to)s")
218 228
         elif order_number:
219
-            desc = _('Orders with order number containing %(order_number)s')
229
+            desc = _("Orders with order number containing %(order_number)s")
220 230
         else:
221 231
             return None
222 232
         params = {
223
-            'date_from': date_from,
224
-            'date_to': date_to,
225
-            'order_number': order_number,
233
+            "date_from": date_from,
234
+            "date_to": date_to,
235
+            "order_number": order_number,
226 236
         }
227 237
         return desc % params
228 238
 
229 239
     def get_filters(self):
230
-        date_from = self.cleaned_data['date_from']
231
-        date_to = self.cleaned_data['date_to']
232
-        order_number = self.cleaned_data['order_number']
240
+        date_from = self.cleaned_data["date_from"]
241
+        date_to = self.cleaned_data["date_to"]
242
+        order_number = self.cleaned_data["order_number"]
233 243
         kwargs = {}
234 244
         if date_from:
235
-            kwargs['date_placed__gte'] = datetime_combine(date_from, datetime.time.min)
245
+            kwargs["date_placed__gte"] = datetime_combine(date_from, datetime.time.min)
236 246
         if date_to:
237
-            kwargs['date_placed__lte'] = datetime_combine(date_to, datetime.time.max)
247
+            kwargs["date_placed__lte"] = datetime_combine(date_to, datetime.time.max)
238 248
         if order_number:
239
-            kwargs['number__contains'] = order_number
249
+            kwargs["number__contains"] = order_number
240 250
         return kwargs
241 251
 
242 252
 
243 253
 class UserForm(forms.ModelForm):
244
-
245 254
     def __init__(self, user, *args, **kwargs):
246 255
         self.user = user
247
-        kwargs['instance'] = user
256
+        kwargs["instance"] = user
248 257
         super().__init__(*args, **kwargs)
249
-        if 'email' in self.fields:
250
-            self.fields['email'].required = True
258
+        if "email" in self.fields:
259
+            self.fields["email"].required = True
251 260
 
252 261
     def clean_email(self):
253 262
         """
@@ -256,31 +265,32 @@ class UserForm(forms.ModelForm):
256 265
         uniqueness of email addresses is *not* enforced on the model
257 266
         level in ``django.contrib.auth.models.User``.
258 267
         """
259
-        email = normalise_email(self.cleaned_data['email'])
260
-        if User._default_manager.filter(
261
-                email__iexact=email).exclude(id=self.user.id).exists():
262
-            raise ValidationError(
263
-                _("A user with this email address already exists"))
268
+        email = normalise_email(self.cleaned_data["email"])
269
+        if (
270
+            User._default_manager.filter(email__iexact=email)
271
+            .exclude(id=self.user.id)
272
+            .exists()
273
+        ):
274
+            raise ValidationError(_("A user with this email address already exists"))
264 275
         # Save the email unaltered
265 276
         return email
266 277
 
267 278
     class Meta:
268 279
         model = User
269
-        fields = existing_user_fields(['first_name', 'last_name', 'email'])
280
+        fields = existing_user_fields(["first_name", "last_name", "email"])
270 281
 
271 282
 
272 283
 Profile = get_profile_class()
273
-if Profile:  # noqa (too complex (12))
284
+if Profile:
274 285
 
275 286
     class UserAndProfileForm(forms.ModelForm):
276
-
277 287
         def __init__(self, user, *args, **kwargs):
278 288
             try:
279 289
                 instance = Profile.objects.get(user=user)
280 290
             except Profile.DoesNotExist:
281 291
                 # User has no profile, try a blank one
282 292
                 instance = Profile(user=user)
283
-            kwargs['instance'] = instance
293
+            kwargs["instance"] = instance
284 294
 
285 295
             super().__init__(*args, **kwargs)
286 296
 
@@ -289,8 +299,8 @@ if Profile:  # noqa (too complex (12))
289 299
 
290 300
             # Get user field names (we look for core user fields first)
291 301
             core_field_names = set([f.name for f in User._meta.fields])
292
-            user_field_names = ['email']
293
-            for field_name in ('first_name', 'last_name'):
302
+            user_field_names = ["email"]
303
+            for field_name in ("first_name", "last_name"):
294 304
                 if field_name in core_field_names:
295 305
                     user_field_names.append(field_name)
296 306
             user_field_names.extend(User._meta.additional_fields)
@@ -299,12 +309,11 @@ if Profile:  # noqa (too complex (12))
299 309
             self.user_field_names = user_field_names
300 310
 
301 311
             # Add additional user form fields
302
-            additional_fields = forms.fields_for_model(
303
-                User, fields=user_field_names)
312
+            additional_fields = forms.fields_for_model(User, fields=user_field_names)
304 313
             self.fields.update(additional_fields)
305 314
 
306 315
             # Ensure email is required and initialised correctly
307
-            self.fields['email'].required = True
316
+            self.fields["email"].required = True
308 317
 
309 318
             # Set initial values
310 319
             for field_name in user_field_names:
@@ -316,16 +325,19 @@ if Profile:  # noqa (too complex (12))
316 325
 
317 326
         class Meta:
318 327
             model = Profile
319
-            exclude = ('user',)
328
+            # pylint: disable=modelform-uses-exclude
329
+            exclude = ("user",)
320 330
 
321 331
         def clean_email(self):
322
-            email = normalise_email(self.cleaned_data['email'])
332
+            email = normalise_email(self.cleaned_data["email"])
323 333
 
324 334
             users_with_email = User._default_manager.filter(
325
-                email__iexact=email).exclude(id=self.instance.user.id)
335
+                email__iexact=email
336
+            ).exclude(id=self.instance.user.id)
326 337
             if users_with_email.exists():
327 338
                 raise ValidationError(
328
-                    _("A user with this email address already exists"))
339
+                    _("A user with this email address already exists")
340
+                )
329 341
             return email
330 342
 
331 343
         def save(self, *args, **kwargs):
@@ -344,10 +356,11 @@ else:
344 356
 
345 357
 
346 358
 class ProductAlertForm(forms.ModelForm):
347
-    email = forms.EmailField(required=True, label=_('Send notification to'),
348
-                             widget=forms.TextInput(attrs={
349
-                                 'placeholder': _('Enter your email')
350
-                             }))
359
+    email = forms.EmailField(
360
+        required=True,
361
+        label=_("Send notification to"),
362
+        widget=forms.TextInput(attrs={"placeholder": _("Enter your email")}),
363
+    )
351 364
 
352 365
     def __init__(self, user, product, *args, **kwargs):
353 366
         self.user = user
@@ -356,8 +369,8 @@ class ProductAlertForm(forms.ModelForm):
356 369
 
357 370
         # Only show email field to unauthenticated users
358 371
         if user and user.is_authenticated:
359
-            self.fields['email'].widget = forms.HiddenInput()
360
-            self.fields['email'].required = False
372
+            self.fields["email"].widget = forms.HiddenInput()
373
+            self.fields["email"].required = False
361 374
 
362 375
     def save(self, commit=True):
363 376
         alert = super().save(commit=False)
@@ -370,39 +383,48 @@ class ProductAlertForm(forms.ModelForm):
370 383
 
371 384
     def clean(self):
372 385
         cleaned_data = self.cleaned_data
373
-        email = cleaned_data.get('email')
386
+        email = cleaned_data.get("email")
374 387
         if email:
375 388
             try:
376 389
                 ProductAlert.objects.get(
377
-                    product=self.product, email__iexact=email,
378
-                    status=ProductAlert.ACTIVE)
390
+                    product=self.product,
391
+                    email__iexact=email,
392
+                    status=ProductAlert.ACTIVE,
393
+                )
379 394
             except ProductAlert.DoesNotExist:
380 395
                 pass
381 396
             else:
382
-                raise forms.ValidationError(_(
383
-                    "There is already an active stock alert for %s") % email)
397
+                raise forms.ValidationError(
398
+                    _("There is already an active stock alert for %s") % email
399
+                )
384 400
 
385 401
             # Check that the email address hasn't got other unconfirmed alerts.
386 402
             # If they do then we don't want to spam them with more until they
387 403
             # have confirmed or cancelled the existing alert.
388
-            if ProductAlert.objects.filter(email__iexact=email,
389
-                                           status=ProductAlert.UNCONFIRMED).count():
390
-                raise forms.ValidationError(_(
391
-                    "%s has been sent a confirmation email for another product "
392
-                    "alert on this site. Please confirm or cancel that request "
393
-                    "before signing up for more alerts.") % email)
404
+            if ProductAlert.objects.filter(
405
+                email__iexact=email, status=ProductAlert.UNCONFIRMED
406
+            ).count():
407
+                raise forms.ValidationError(
408
+                    _(
409
+                        "%s has been sent a confirmation email for another product "
410
+                        "alert on this site. Please confirm or cancel that request "
411
+                        "before signing up for more alerts."
412
+                    )
413
+                    % email
414
+                )
394 415
         elif self.user.is_authenticated:
395 416
             try:
396
-                ProductAlert.objects.get(product=self.product,
397
-                                         user=self.user,
398
-                                         status=ProductAlert.ACTIVE)
417
+                ProductAlert.objects.get(
418
+                    product=self.product, user=self.user, status=ProductAlert.ACTIVE
419
+                )
399 420
             except ProductAlert.DoesNotExist:
400 421
                 pass
401 422
             else:
402
-                raise forms.ValidationError(_(
403
-                    "You already have an active alert for this product"))
423
+                raise forms.ValidationError(
424
+                    _("You already have an active alert for this product")
425
+                )
404 426
         return cleaned_data
405 427
 
406 428
     class Meta:
407 429
         model = ProductAlert
408
-        fields = ['email']
430
+        fields = ["email"]

+ 10
- 9
src/oscar/apps/customer/history.py View File

@@ -4,15 +4,15 @@ from django.conf import settings
4 4
 
5 5
 from oscar.core.loading import get_model
6 6
 
7
-Product = get_model('catalogue', 'Product')
7
+Product = get_model("catalogue", "Product")
8 8
 
9 9
 
10 10
 class CustomerHistoryManager:
11 11
     cookie_name = settings.OSCAR_RECENTLY_VIEWED_COOKIE_NAME
12 12
     cookie_kwargs = {
13
-        'max_age': settings.OSCAR_RECENTLY_VIEWED_COOKIE_LIFETIME,
14
-        'secure': settings.OSCAR_RECENTLY_VIEWED_COOKIE_SECURE,
15
-        'httponly': True,
13
+        "max_age": settings.OSCAR_RECENTLY_VIEWED_COOKIE_LIFETIME,
14
+        "secure": settings.OSCAR_RECENTLY_VIEWED_COOKIE_SECURE,
15
+        "httponly": True,
16 16
     }
17 17
     max_products = settings.OSCAR_RECENTLY_VIEWED_PRODUCTS
18 18
 
@@ -26,7 +26,9 @@ class CustomerHistoryManager:
26 26
         # Reordering as the ID order gets messed up in the query
27 27
         product_dict = Product.objects.browsable().in_bulk(ids)
28 28
         ids.reverse()
29
-        return [product_dict[product_id] for product_id in ids if product_id in product_dict]
29
+        return [
30
+            product_dict[product_id] for product_id in ids if product_id in product_dict
31
+        ]
30 32
 
31 33
     @classmethod
32 34
     def extract(cls, request, response=None):
@@ -56,7 +58,7 @@ class CustomerHistoryManager:
56 58
             ids.remove(new_id)
57 59
         ids.append(new_id)
58 60
         if len(ids) > cls.max_products:
59
-            ids = ids[len(ids) - cls.max_products:]
61
+            ids = ids[len(ids) - cls.max_products :]
60 62
         return ids
61 63
 
62 64
     @classmethod
@@ -68,6 +70,5 @@ class CustomerHistoryManager:
68 70
         ids = cls.extract(request, response)
69 71
         updated_ids = cls.add(ids, product.id)
70 72
         response.set_cookie(
71
-            cls.cookie_name,
72
-            json.dumps(updated_ids),
73
-            **cls.cookie_kwargs)
73
+            cls.cookie_name, json.dumps(updated_ids), **cls.cookie_kwargs
74
+        )

+ 14
- 14
src/oscar/apps/customer/mixins.py View File

@@ -9,10 +9,10 @@ from oscar.core.compat import get_user_model
9 9
 from oscar.core.loading import get_class, get_model
10 10
 
11 11
 User = get_user_model()
12
-CommunicationEventType = get_model('communication', 'CommunicationEventType')
13
-CustomerDispatcher = get_class('customer.utils', 'CustomerDispatcher')
12
+CommunicationEventType = get_model("communication", "CommunicationEventType")
13
+CustomerDispatcher = get_class("customer.utils", "CustomerDispatcher")
14 14
 
15
-logger = logging.getLogger('oscar.customer')
15
+logger = logging.getLogger("oscar.customer")
16 16
 
17 17
 
18 18
 class PageTitleMixin(object):
@@ -22,6 +22,7 @@ class PageTitleMixin(object):
22 22
 
23 23
     Dynamic page titles are possible by overriding get_page_title.
24 24
     """
25
+
25 26
     page_title = None
26 27
     active_tab = None
27 28
 
@@ -31,13 +32,12 @@ class PageTitleMixin(object):
31 32
 
32 33
     def get_context_data(self, **kwargs):
33 34
         ctx = super().get_context_data(**kwargs)
34
-        ctx.setdefault('page_title', self.get_page_title())
35
-        ctx.setdefault('active_tab', self.active_tab)
35
+        ctx.setdefault("page_title", self.get_page_title())
36
+        ctx.setdefault("active_tab", self.active_tab)
36 37
         return ctx
37 38
 
38 39
 
39 40
 class RegisterUserMixin(object):
40
-
41 41
     def register_user(self, form):
42 42
         """
43 43
         Create a user instance and send a new registration email (if configured
@@ -47,17 +47,16 @@ class RegisterUserMixin(object):
47 47
 
48 48
         # Raise signal robustly (we don't want exceptions to crash the request
49 49
         # handling).
50
-        user_registered.send_robust(
51
-            sender=self, request=self.request, user=user)
50
+        user_registered.send_robust(sender=self, request=self.request, user=user)
52 51
 
53
-        if getattr(settings, 'OSCAR_SEND_REGISTRATION_EMAIL', True):
52
+        if getattr(settings, "OSCAR_SEND_REGISTRATION_EMAIL", True):
54 53
             self.send_registration_email(user)
55 54
 
56 55
         # We have to authenticate before login
57 56
         try:
58 57
             user = authenticate(
59
-                username=user.email,
60
-                password=form.cleaned_data['password1'])
58
+                username=user.email, password=form.cleaned_data["password1"]
59
+            )
61 60
         except User.MultipleObjectsReturned:
62 61
             # Handle race condition where the registration request is made
63 62
             # multiple times in quick succession.  This leads to both requests
@@ -65,8 +64,9 @@ class RegisterUserMixin(object):
65 64
             # hasn't committed when the second one runs the check).  We retain
66 65
             # the first one and deactivate the dupes.
67 66
             logger.warning(
68
-                'Multiple users with identical email address and password'
69
-                'were found. Marking all but one as not active.')
67
+                "Multiple users with identical email address and password"
68
+                "were found. Marking all but one as not active."
69
+            )
70 70
             # As this section explicitly deals with the form being submitted
71 71
             # twice, this is about the only place in Oscar where we don't
72 72
             # ignore capitalisation when looking up an email address.
@@ -82,5 +82,5 @@ class RegisterUserMixin(object):
82 82
         return user
83 83
 
84 84
     def send_registration_email(self, user):
85
-        extra_context = {'user': user, 'request': self.request}
85
+        extra_context = {"user": user, "request": self.request}
86 86
         CustomerDispatcher().send_registration_email_for_user(user, extra_context)

+ 3
- 2
src/oscar/apps/customer/models.py View File

@@ -4,8 +4,9 @@ from oscar.core.loading import is_model_registered
4 4
 __all__ = []
5 5
 
6 6
 
7
-if not is_model_registered('customer', 'ProductAlert'):
7
+if not is_model_registered("customer", "ProductAlert"):
8
+
8 9
     class ProductAlert(abstract_models.AbstractProductAlert):
9 10
         pass
10 11
 
11
-    __all__.append('ProductAlert')
12
+    __all__.append("ProductAlert")

+ 2
- 1
src/oscar/apps/customer/receivers.py View File

@@ -3,9 +3,10 @@ from django.dispatch import receiver
3 3
 from oscar.apps.catalogue.signals import product_viewed
4 4
 from oscar.core.loading import get_class
5 5
 
6
-CustomerHistoryManager = get_class('customer.history', 'CustomerHistoryManager')
6
+CustomerHistoryManager = get_class("customer.history", "CustomerHistoryManager")
7 7
 
8 8
 
9
+# pylint: disable=unused-argument
9 10
 @receiver(product_viewed)
10 11
 def receive_product_view(sender, product, user, request, response, **kwargs):
11 12
     """

+ 23
- 16
src/oscar/apps/customer/utils.py View File

@@ -5,8 +5,8 @@ from django.utils.http import urlsafe_base64_encode
5 5
 
6 6
 from oscar.core.loading import get_class
7 7
 
8
-Dispatcher = get_class('communication.utils', 'Dispatcher')
9
-Selector = get_class('partner.strategy', 'Selector')
8
+Dispatcher = get_class("communication.utils", "Dispatcher")
9
+Selector = get_class("partner.strategy", "Selector")
10 10
 
11 11
 
12 12
 class CustomerDispatcher:
@@ -15,29 +15,36 @@ class CustomerDispatcher:
15 15
     """
16 16
 
17 17
     # Event codes
18
-    REGISTRATION_EVENT_CODE = 'REGISTRATION'
19
-    PASSWORD_RESET_EVENT_CODE = 'PASSWORD_RESET'
20
-    PASSWORD_CHANGED_EVENT_CODE = 'PASSWORD_CHANGED'
21
-    EMAIL_CHANGED_EVENT_CODE = 'EMAIL_CHANGED'
18
+    REGISTRATION_EVENT_CODE = "REGISTRATION"
19
+    PASSWORD_RESET_EVENT_CODE = "PASSWORD_RESET"
20
+    PASSWORD_CHANGED_EVENT_CODE = "PASSWORD_CHANGED"
21
+    EMAIL_CHANGED_EVENT_CODE = "EMAIL_CHANGED"
22 22
 
23 23
     def __init__(self, logger=None, mail_connection=None):
24 24
         self.dispatcher = Dispatcher(logger=logger, mail_connection=mail_connection)
25 25
 
26 26
     def send_registration_email_for_user(self, user, extra_context):
27
-        messages = self.dispatcher.get_messages(self.REGISTRATION_EVENT_CODE, extra_context)
27
+        messages = self.dispatcher.get_messages(
28
+            self.REGISTRATION_EVENT_CODE, extra_context
29
+        )
28 30
         self.dispatcher.dispatch_user_messages(user, messages)
29 31
 
30 32
     def send_password_reset_email_for_user(self, user, extra_context):
31
-        messages = self.dispatcher.get_messages(self.PASSWORD_RESET_EVENT_CODE, extra_context)
33
+        messages = self.dispatcher.get_messages(
34
+            self.PASSWORD_RESET_EVENT_CODE, extra_context
35
+        )
32 36
         self.dispatcher.dispatch_user_messages(user, messages)
33 37
 
34 38
     def send_password_changed_email_for_user(self, user, extra_context):
35
-        messages = self.dispatcher.get_messages(self.PASSWORD_CHANGED_EVENT_CODE, extra_context)
39
+        messages = self.dispatcher.get_messages(
40
+            self.PASSWORD_CHANGED_EVENT_CODE, extra_context
41
+        )
36 42
         self.dispatcher.dispatch_user_messages(user, messages)
37 43
 
38 44
     def send_email_changed_email_for_user(self, user, extra_context):
39 45
         messages = self.dispatcher.get_messages(
40
-            self.EMAIL_CHANGED_EVENT_CODE, extra_context)
46
+            self.EMAIL_CHANGED_EVENT_CODE, extra_context
47
+        )
41 48
         self.dispatcher.dispatch_user_messages(user, messages)
42 49
 
43 50
 
@@ -46,10 +53,10 @@ def get_password_reset_url(user, token_generator=default_token_generator):
46 53
     Generate a password-reset URL for a given user
47 54
     """
48 55
     kwargs = {
49
-        'token': token_generator.make_token(user),
50
-        'uidb64': urlsafe_base64_encode(force_bytes(user.id)),
56
+        "token": token_generator.make_token(user),
57
+        "uidb64": urlsafe_base64_encode(force_bytes(user.id)),
51 58
     }
52
-    return reverse('password-reset-confirm', kwargs=kwargs)
59
+    return reverse("password-reset-confirm", kwargs=kwargs)
53 60
 
54 61
 
55 62
 def normalise_email(email):
@@ -59,7 +66,7 @@ def normalise_email(email):
59 66
     handling.
60 67
     """
61 68
     clean_email = email.strip()
62
-    if '@' in clean_email:
63
-        local, host = clean_email.rsplit('@', 1)
64
-        return local + '@' + host.lower()
69
+    if "@" in clean_email:
70
+        local, host = clean_email.rsplit("@", 1)
71
+        return local + "@" + host.lower()
65 72
     return clean_email

+ 207
- 185
src/oscar/apps/customer/views.py View File

@@ -1,3 +1,4 @@
1
+# pylint: disable=attribute-defined-outside-init
1 2
 from django import http
2 3
 from django.conf import settings
3 4
 from django.contrib import messages
@@ -13,25 +14,27 @@ from django.views import generic
13 14
 
14 15
 from oscar.apps.customer.utils import get_password_reset_url
15 16
 from oscar.core.compat import get_user_model
16
-from oscar.core.loading import (
17
-    get_class, get_classes, get_model, get_profile_class)
17
+from oscar.core.loading import get_class, get_classes, get_model, get_profile_class
18 18
 from oscar.core.utils import safe_referrer
19 19
 from oscar.views.generic import PostActionMixin
20 20
 
21 21
 from . import signals
22 22
 
23 23
 PageTitleMixin, RegisterUserMixin = get_classes(
24
-    'customer.mixins', ['PageTitleMixin', 'RegisterUserMixin'])
25
-CustomerDispatcher = get_class('customer.utils', 'CustomerDispatcher')
24
+    "customer.mixins", ["PageTitleMixin", "RegisterUserMixin"]
25
+)
26
+CustomerDispatcher = get_class("customer.utils", "CustomerDispatcher")
26 27
 EmailAuthenticationForm, EmailUserCreationForm, OrderSearchForm = get_classes(
27
-    'customer.forms', ['EmailAuthenticationForm', 'EmailUserCreationForm',
28
-                       'OrderSearchForm'])
28
+    "customer.forms",
29
+    ["EmailAuthenticationForm", "EmailUserCreationForm", "OrderSearchForm"],
30
+)
29 31
 ProfileForm, ConfirmPasswordForm = get_classes(
30
-    'customer.forms', ['ProfileForm', 'ConfirmPasswordForm'])
31
-UserAddressForm = get_class('address.forms', 'UserAddressForm')
32
-Order = get_model('order', 'Order')
33
-UserAddress = get_model('address', 'UserAddress')
34
-Email = get_model('communication', 'Email')
32
+    "customer.forms", ["ProfileForm", "ConfirmPasswordForm"]
33
+)
34
+UserAddressForm = get_class("address.forms", "UserAddressForm")
35
+Order = get_model("order", "Order")
36
+UserAddress = get_model("address", "UserAddress")
37
+Email = get_model("communication", "Email")
35 38
 
36 39
 User = get_user_model()
37 40
 
@@ -52,42 +55,41 @@ class AccountSummaryView(generic.RedirectView):
52 55
     such like. The presence of this view allows just that, without
53 56
     having to change a lot of templates.
54 57
     """
58
+
55 59
     pattern_name = settings.OSCAR_ACCOUNTS_REDIRECT_URL
56 60
     permanent = False
57 61
 
58 62
 
59 63
 class AccountRegistrationView(RegisterUserMixin, generic.FormView):
60 64
     form_class = EmailUserCreationForm
61
-    template_name = 'oscar/customer/registration.html'
62
-    redirect_field_name = 'next'
65
+    template_name = "oscar/customer/registration.html"
66
+    redirect_field_name = "next"
63 67
 
64 68
     def get(self, request, *args, **kwargs):
65 69
         if request.user.is_authenticated:
66 70
             return redirect(settings.LOGIN_REDIRECT_URL)
67
-        return super().get(
68
-            request, *args, **kwargs)
71
+        return super().get(request, *args, **kwargs)
69 72
 
70 73
     def get_logged_in_redirect(self):
71
-        return reverse('customer:summary')
74
+        return reverse("customer:summary")
72 75
 
73 76
     def get_form_kwargs(self):
74 77
         kwargs = super().get_form_kwargs()
75
-        kwargs['initial'] = {
76
-            'email': self.request.GET.get('email', ''),
77
-            'redirect_url': self.request.GET.get(self.redirect_field_name, '')
78
+        kwargs["initial"] = {
79
+            "email": self.request.GET.get("email", ""),
80
+            "redirect_url": self.request.GET.get(self.redirect_field_name, ""),
78 81
         }
79
-        kwargs['host'] = self.request.get_host()
82
+        kwargs["host"] = self.request.get_host()
80 83
         return kwargs
81 84
 
82 85
     def get_context_data(self, *args, **kwargs):
83
-        ctx = super().get_context_data(
84
-            *args, **kwargs)
85
-        ctx['cancel_url'] = safe_referrer(self.request, '')
86
+        ctx = super().get_context_data(*args, **kwargs)
87
+        ctx["cancel_url"] = safe_referrer(self.request, "")
86 88
         return ctx
87 89
 
88 90
     def form_valid(self, form):
89 91
         self.register_user(form)
90
-        return redirect(form.cleaned_data['redirect_url'])
92
+        return redirect(form.cleaned_data["redirect_url"])
91 93
 
92 94
 
93 95
 class AccountAuthView(RegisterUserMixin, generic.TemplateView):
@@ -95,53 +97,54 @@ class AccountAuthView(RegisterUserMixin, generic.TemplateView):
95 97
     This is actually a slightly odd double form view that allows a customer to
96 98
     either login or register.
97 99
     """
98
-    template_name = 'oscar/customer/login_registration.html'
99
-    login_prefix, registration_prefix = 'login', 'registration'
100
+
101
+    template_name = "oscar/customer/login_registration.html"
102
+    login_prefix, registration_prefix = "login", "registration"
100 103
     login_form_class = EmailAuthenticationForm
101 104
     registration_form_class = EmailUserCreationForm
102
-    redirect_field_name = 'next'
105
+    redirect_field_name = "next"
103 106
 
104 107
     def get(self, request, *args, **kwargs):
105 108
         if request.user.is_authenticated:
106 109
             return redirect(settings.LOGIN_REDIRECT_URL)
107
-        return super().get(
108
-            request, *args, **kwargs)
110
+        return super().get(request, *args, **kwargs)
109 111
 
110 112
     def get_context_data(self, *args, **kwargs):
111 113
         ctx = super().get_context_data(*args, **kwargs)
112
-        if 'login_form' not in kwargs:
113
-            ctx['login_form'] = self.get_login_form()
114
-        if 'registration_form' not in kwargs:
115
-            ctx['registration_form'] = self.get_registration_form()
114
+        if "login_form" not in kwargs:
115
+            ctx["login_form"] = self.get_login_form()
116
+        if "registration_form" not in kwargs:
117
+            ctx["registration_form"] = self.get_registration_form()
116 118
         return ctx
117 119
 
118 120
     def post(self, request, *args, **kwargs):
119 121
         # Use the name of the submit button to determine which form to validate
120
-        if 'login_submit' in request.POST:
122
+        if "login_submit" in request.POST:
121 123
             return self.validate_login_form()
122
-        elif 'registration_submit' in request.POST:
124
+        elif "registration_submit" in request.POST:
123 125
             return self.validate_registration_form()
124 126
         return http.HttpResponseBadRequest()
125 127
 
126 128
     # LOGIN
127 129
 
128 130
     def get_login_form(self, bind_data=False):
129
-        return self.login_form_class(
130
-            **self.get_login_form_kwargs(bind_data))
131
+        return self.login_form_class(**self.get_login_form_kwargs(bind_data))
131 132
 
132 133
     def get_login_form_kwargs(self, bind_data=False):
133 134
         kwargs = {}
134
-        kwargs['request'] = self.request
135
-        kwargs['host'] = self.request.get_host()
136
-        kwargs['prefix'] = self.login_prefix
137
-        kwargs['initial'] = {
138
-            'redirect_url': self.request.GET.get(self.redirect_field_name, ''),
135
+        kwargs["request"] = self.request
136
+        kwargs["host"] = self.request.get_host()
137
+        kwargs["prefix"] = self.login_prefix
138
+        kwargs["initial"] = {
139
+            "redirect_url": self.request.GET.get(self.redirect_field_name, ""),
139 140
         }
140
-        if bind_data and self.request.method in ('POST', 'PUT'):
141
-            kwargs.update({
142
-                'data': self.request.POST,
143
-                'files': self.request.FILES,
144
-            })
141
+        if bind_data and self.request.method in ("POST", "PUT"):
142
+            kwargs.update(
143
+                {
144
+                    "data": self.request.POST,
145
+                    "files": self.request.FILES,
146
+                }
147
+            )
145 148
         return kwargs
146 149
 
147 150
     def validate_login_form(self):
@@ -158,8 +161,11 @@ class AccountAuthView(RegisterUserMixin, generic.TemplateView):
158 161
             # request handling). We use a custom signal as we want to track the
159 162
             # session key before calling login (which cycles the session ID).
160 163
             signals.user_logged_in.send_robust(
161
-                sender=self, request=self.request, user=user,
162
-                old_session_key=old_session_key)
164
+                sender=self,
165
+                request=self.request,
166
+                user=user,
167
+                old_session_key=old_session_key,
168
+            )
163 169
 
164 170
             msg = self.get_login_success_message(form)
165 171
             if msg:
@@ -170,18 +176,19 @@ class AccountAuthView(RegisterUserMixin, generic.TemplateView):
170 176
         ctx = self.get_context_data(login_form=form)
171 177
         return self.render_to_response(ctx)
172 178
 
179
+    # pylint: disable=unused-argument
173 180
     def get_login_success_message(self, form):
174 181
         return _("Welcome back")
175 182
 
176 183
     def get_login_success_url(self, form):
177
-        redirect_url = form.cleaned_data['redirect_url']
184
+        redirect_url = form.cleaned_data["redirect_url"]
178 185
         if redirect_url:
179 186
             return redirect_url
180 187
 
181 188
         # Redirect staff members to dashboard as that's the most likely place
182 189
         # they'll want to visit if they're logging in.
183 190
         if self.request.user.is_staff:
184
-            return reverse('dashboard:index')
191
+            return reverse("dashboard:index")
185 192
 
186 193
         return settings.LOGIN_REDIRECT_URL
187 194
 
@@ -189,20 +196,23 @@ class AccountAuthView(RegisterUserMixin, generic.TemplateView):
189 196
 
190 197
     def get_registration_form(self, bind_data=False):
191 198
         return self.registration_form_class(
192
-            **self.get_registration_form_kwargs(bind_data))
199
+            **self.get_registration_form_kwargs(bind_data)
200
+        )
193 201
 
194 202
     def get_registration_form_kwargs(self, bind_data=False):
195 203
         kwargs = {}
196
-        kwargs['host'] = self.request.get_host()
197
-        kwargs['prefix'] = self.registration_prefix
198
-        kwargs['initial'] = {
199
-            'redirect_url': self.request.GET.get(self.redirect_field_name, ''),
204
+        kwargs["host"] = self.request.get_host()
205
+        kwargs["prefix"] = self.registration_prefix
206
+        kwargs["initial"] = {
207
+            "redirect_url": self.request.GET.get(self.redirect_field_name, ""),
200 208
         }
201
-        if bind_data and self.request.method in ('POST', 'PUT'):
202
-            kwargs.update({
203
-                'data': self.request.POST,
204
-                'files': self.request.FILES,
205
-            })
209
+        if bind_data and self.request.method in ("POST", "PUT"):
210
+            kwargs.update(
211
+                {
212
+                    "data": self.request.POST,
213
+                    "files": self.request.FILES,
214
+                }
215
+            )
206 216
         return kwargs
207 217
 
208 218
     def validate_registration_form(self):
@@ -218,11 +228,12 @@ class AccountAuthView(RegisterUserMixin, generic.TemplateView):
218 228
         ctx = self.get_context_data(registration_form=form)
219 229
         return self.render_to_response(ctx)
220 230
 
231
+    # pylint: disable=unused-argument
221 232
     def get_registration_success_message(self, form):
222 233
         return _("Thanks for registering!")
223 234
 
224 235
     def get_registration_success_url(self, form):
225
-        redirect_url = form.cleaned_data['redirect_url']
236
+        redirect_url = form.cleaned_data["redirect_url"]
226 237
         if redirect_url:
227 238
             return redirect_url
228 239
 
@@ -249,13 +260,13 @@ class LogoutView(generic.RedirectView):
249 260
 
250 261
 
251 262
 class ProfileView(PageTitleMixin, generic.TemplateView):
252
-    template_name = 'oscar/customer/profile/profile.html'
253
-    page_title = _('Profile')
254
-    active_tab = 'profile'
263
+    template_name = "oscar/customer/profile/profile.html"
264
+    page_title = _("Profile")
265
+    active_tab = "profile"
255 266
 
256 267
     def get_context_data(self, **kwargs):
257 268
         ctx = super().get_context_data(**kwargs)
258
-        ctx['profile_fields'] = self.get_profile_fields(self.request.user)
269
+        ctx["profile_fields"] = self.get_profile_fields(self.request.user)
259 270
         return ctx
260 271
 
261 272
     def get_profile_fields(self, user):
@@ -263,8 +274,7 @@ class ProfileView(PageTitleMixin, generic.TemplateView):
263 274
 
264 275
         # Check for custom user model
265 276
         for field_name in User._meta.additional_fields:
266
-            field_data.append(
267
-                self.get_model_field_data(user, field_name))
277
+            field_data.append(self.get_model_field_data(user, field_name))
268 278
 
269 279
         # Check for profile class
270 280
         profile_class = get_profile_class()
@@ -276,10 +286,9 @@ class ProfileView(PageTitleMixin, generic.TemplateView):
276 286
 
277 287
             field_names = [f.name for f in profile._meta.local_fields]
278 288
             for field_name in field_names:
279
-                if field_name in ('user', 'id'):
289
+                if field_name in ("user", "id"):
280 290
                     continue
281
-                field_data.append(
282
-                    self.get_model_field_data(profile, field_name))
291
+                field_data.append(self.get_model_field_data(profile, field_name))
283 292
 
284 293
         return field_data
285 294
 
@@ -289,25 +298,25 @@ class ProfileView(PageTitleMixin, generic.TemplateView):
289 298
         """
290 299
         field = model_class._meta.get_field(field_name)
291 300
         if field.choices:
292
-            value = getattr(model_class, 'get_%s_display' % field_name)()
301
+            value = getattr(model_class, "get_%s_display" % field_name)()
293 302
         else:
294 303
             value = getattr(model_class, field_name)
295 304
         return {
296
-            'name': getattr(field, 'verbose_name'),
297
-            'value': value,
305
+            "name": getattr(field, "verbose_name"),
306
+            "value": value,
298 307
         }
299 308
 
300 309
 
301 310
 class ProfileUpdateView(PageTitleMixin, generic.FormView):
302 311
     form_class = ProfileForm
303
-    template_name = 'oscar/customer/profile/profile_form.html'
304
-    page_title = _('Edit Profile')
305
-    active_tab = 'profile'
306
-    success_url = reverse_lazy('customer:profile-view')
312
+    template_name = "oscar/customer/profile/profile_form.html"
313
+    page_title = _("Edit Profile")
314
+    active_tab = "profile"
315
+    success_url = reverse_lazy("customer:profile-view")
307 316
 
308 317
     def get_form_kwargs(self):
309 318
         kwargs = super().get_form_kwargs()
310
-        kwargs['user'] = self.request.user
319
+        kwargs["user"] = self.request.user
311 320
         return kwargs
312 321
 
313 322
     def form_valid(self, form):
@@ -324,7 +333,7 @@ class ProfileUpdateView(PageTitleMixin, generic.FormView):
324 333
         # cleaned data because the object created by form.save() can
325 334
         # either be a user or profile instance depending whether a profile
326 335
         # class has been specified by the AUTH_PROFILE_MODULE setting.
327
-        new_email = form.cleaned_data.get('email')
336
+        new_email = form.cleaned_data.get("email")
328 337
         if new_email and old_user and new_email != old_user.email:
329 338
             # Email address has changed - send a confirmation email to the old
330 339
             # address including a password reset link in case this is a
@@ -337,44 +346,45 @@ class ProfileUpdateView(PageTitleMixin, generic.FormView):
337 346
     def send_email_changed_email(self, old_user, new_email):
338 347
         user = self.request.user
339 348
         extra_context = {
340
-            'user': user,
341
-            'reset_url': get_password_reset_url(old_user),
342
-            'new_email': new_email,
343
-            'request': self.request,
349
+            "user": user,
350
+            "reset_url": get_password_reset_url(old_user),
351
+            "new_email": new_email,
352
+            "request": self.request,
344 353
         }
345 354
         CustomerDispatcher().send_email_changed_email_for_user(old_user, extra_context)
346 355
 
347 356
 
348 357
 class ProfileDeleteView(PageTitleMixin, generic.FormView):
349 358
     form_class = ConfirmPasswordForm
350
-    template_name = 'oscar/customer/profile/profile_delete.html'
351
-    page_title = _('Delete profile')
352
-    active_tab = 'profile'
359
+    template_name = "oscar/customer/profile/profile_delete.html"
360
+    page_title = _("Delete profile")
361
+    active_tab = "profile"
353 362
     success_url = settings.OSCAR_HOMEPAGE
354 363
 
355 364
     def get_form_kwargs(self):
356 365
         kwargs = super().get_form_kwargs()
357
-        kwargs['user'] = self.request.user
366
+        kwargs["user"] = self.request.user
358 367
         return kwargs
359 368
 
360 369
     def form_valid(self, form):
361 370
         self.request.user.delete()
362 371
         messages.success(
363 372
             self.request,
364
-            _("Your profile has now been deleted. Thanks for using the site."))
373
+            _("Your profile has now been deleted. Thanks for using the site."),
374
+        )
365 375
         return redirect(self.get_success_url())
366 376
 
367 377
 
368 378
 class ChangePasswordView(PageTitleMixin, generic.FormView):
369 379
     form_class = PasswordChangeForm
370
-    template_name = 'oscar/customer/profile/change_password_form.html'
371
-    page_title = _('Change Password')
372
-    active_tab = 'profile'
373
-    success_url = reverse_lazy('customer:profile-view')
380
+    template_name = "oscar/customer/profile/change_password_form.html"
381
+    page_title = _("Change Password")
382
+    active_tab = "profile"
383
+    success_url = reverse_lazy("customer:profile-view")
374 384
 
375 385
     def get_form_kwargs(self):
376 386
         kwargs = super().get_form_kwargs()
377
-        kwargs['user'] = self.request.user
387
+        kwargs["user"] = self.request.user
378 388
         return kwargs
379 389
 
380 390
     def form_valid(self, form):
@@ -389,9 +399,9 @@ class ChangePasswordView(PageTitleMixin, generic.FormView):
389 399
     def send_password_changed_email(self):
390 400
         user = self.request.user
391 401
         extra_context = {
392
-            'user': user,
393
-            'reset_url': get_password_reset_url(self.request.user),
394
-            'request': self.request,
402
+            "user": user,
403
+            "reset_url": get_password_reset_url(self.request.user),
404
+            "request": self.request,
395 405
         }
396 406
         CustomerDispatcher().send_password_changed_email_for_user(user, extra_context)
397 407
 
@@ -400,54 +410,59 @@ class ChangePasswordView(PageTitleMixin, generic.FormView):
400 410
 # Email history
401 411
 # =============
402 412
 
413
+
403 414
 class EmailHistoryView(PageTitleMixin, generic.ListView):
404 415
     context_object_name = "emails"
405
-    template_name = 'oscar/communication/email/email_list.html'
416
+    template_name = "oscar/communication/email/email_list.html"
406 417
     paginate_by = settings.OSCAR_EMAILS_PER_PAGE
407
-    page_title = _('Email History')
408
-    active_tab = 'emails'
418
+    page_title = _("Email History")
419
+    active_tab = "emails"
409 420
 
410 421
     def get_queryset(self):
411 422
         """
412 423
         Return Queryset of :py:class:`Email <oscar.apps.customer.abstract_models.AbstractEmail>`
413 424
         instances, that has been sent to the currently authenticated user.
414
-        """  # noqa
425
+        """
415 426
         return Email._default_manager.filter(user=self.request.user)
416 427
 
417 428
 
418 429
 class EmailDetailView(PageTitleMixin, generic.DetailView):
419 430
     """Customer email"""
431
+
420 432
     template_name = "oscar/communication/email/email_detail.html"
421
-    context_object_name = 'email'
422
-    active_tab = 'emails'
433
+    context_object_name = "email"
434
+    active_tab = "emails"
423 435
 
424 436
     def get_object(self, queryset=None):
425
-        return get_object_or_404(Email, user=self.request.user,
426
-                                 id=self.kwargs['email_id'])
437
+        return get_object_or_404(
438
+            Email, user=self.request.user, id=self.kwargs["email_id"]
439
+        )
427 440
 
428 441
     def get_page_title(self):
429 442
         """Append email subject to page title"""
430
-        return '%s: %s' % (_('Email'), self.object.subject)
443
+        return "%s: %s" % (_("Email"), self.object.subject)
431 444
 
432 445
 
433 446
 # =============
434 447
 # Order history
435 448
 # =============
436 449
 
450
+
437 451
 class OrderHistoryView(PageTitleMixin, generic.ListView):
438 452
     """
439 453
     Customer order history
440 454
     """
455
+
441 456
     context_object_name = "orders"
442
-    template_name = 'oscar/customer/order/order_list.html'
457
+    template_name = "oscar/customer/order/order_list.html"
443 458
     paginate_by = settings.OSCAR_ORDERS_PER_PAGE
444 459
     model = Order
445 460
     form_class = OrderSearchForm
446
-    page_title = _('Order History')
447
-    active_tab = 'orders'
461
+    page_title = _("Order History")
462
+    active_tab = "orders"
448 463
 
449 464
     def get(self, request, *args, **kwargs):
450
-        if 'date_from' in request.GET:
465
+        if "date_from" in request.GET:
451 466
             self.form = self.form_class(self.request.GET)
452 467
             if not self.form.is_valid():
453 468
                 self.object_list = self.get_queryset()
@@ -457,16 +472,15 @@ class OrderHistoryView(PageTitleMixin, generic.ListView):
457 472
 
458 473
             # If the user has just entered an order number, try and look it up
459 474
             # and redirect immediately to the order detail page.
460
-            if data['order_number'] and not (data['date_to']
461
-                                             or data['date_from']):
475
+            if data["order_number"] and not (data["date_to"] or data["date_from"]):
462 476
                 try:
463 477
                     order = Order.objects.get(
464
-                        number=data['order_number'], user=self.request.user)
478
+                        number=data["order_number"], user=self.request.user
479
+                    )
465 480
                 except Order.DoesNotExist:
466 481
                     pass
467 482
                 else:
468
-                    return redirect(
469
-                        'customer:order', order_number=order.number)
483
+                    return redirect("customer:order", order_number=order.number)
470 484
         else:
471 485
             self.form = self.form_class()
472 486
         return super().get(request, *args, **kwargs)
@@ -475,7 +489,7 @@ class OrderHistoryView(PageTitleMixin, generic.ListView):
475 489
         """
476 490
         Return Queryset of :py:class:`Order <oscar.apps.order.abstract_models.AbstractOrder>`
477 491
         instances for the currently authenticated user.
478
-        """  # noqa
492
+        """
479 493
         qs = self.model._default_manager.filter(user=self.request.user)
480 494
         if self.form.is_bound and self.form.is_valid():
481 495
             qs = qs.filter(**self.form.get_filters())
@@ -483,13 +497,13 @@ class OrderHistoryView(PageTitleMixin, generic.ListView):
483 497
 
484 498
     def get_context_data(self, *args, **kwargs):
485 499
         ctx = super().get_context_data(*args, **kwargs)
486
-        ctx['form'] = self.form
500
+        ctx["form"] = self.form
487 501
         return ctx
488 502
 
489 503
 
490 504
 class OrderDetailView(PageTitleMixin, PostActionMixin, generic.DetailView):
491 505
     model = Order
492
-    active_tab = 'orders'
506
+    active_tab = "orders"
493 507
 
494 508
     def get_template_names(self):
495 509
         return ["oscar/customer/order/order_detail.html"]
@@ -498,13 +512,14 @@ class OrderDetailView(PageTitleMixin, PostActionMixin, generic.DetailView):
498 512
         """
499 513
         Order number as page title
500 514
         """
501
-        return '%s #%s' % (_('Order'), self.object.number)
515
+        return "%s #%s" % (_("Order"), self.object.number)
502 516
 
503 517
     def get_object(self, queryset=None):
504
-        return get_object_or_404(self.model, user=self.request.user,
505
-                                 number=self.kwargs['order_number'])
518
+        return get_object_or_404(
519
+            self.model, user=self.request.user, number=self.kwargs["order_number"]
520
+        )
506 521
 
507
-    def do_reorder(self, order):  # noqa (too complex (10))
522
+    def do_reorder(self, order):
508 523
         """
509 524
         'Re-order' a previous order.
510 525
 
@@ -517,7 +532,8 @@ class OrderDetailView(PageTitleMixin, PostActionMixin, generic.DetailView):
517 532
         warnings = []
518 533
         for line in order.lines.all():
519 534
             is_available, reason = line.is_available_to_reorder(
520
-                basket, self.request.strategy)
535
+                basket, self.request.strategy
536
+            )
521 537
             if is_available:
522 538
                 lines_to_add.append(line)
523 539
             else:
@@ -526,11 +542,10 @@ class OrderDetailView(PageTitleMixin, PostActionMixin, generic.DetailView):
526 542
         # Check whether the number of items in the basket won't exceed the
527 543
         # maximum.
528 544
         total_quantity = sum([line.quantity for line in lines_to_add])
529
-        is_quantity_allowed, reason = basket.is_quantity_allowed(
530
-            total_quantity)
545
+        is_quantity_allowed, reason = basket.is_quantity_allowed(total_quantity)
531 546
         if not is_quantity_allowed:
532 547
             messages.warning(self.request, reason)
533
-            self.response = redirect('customer:order-list')
548
+            self.response = redirect("customer:order-list")
534 549
             return
535 550
 
536 551
         # Add any warnings
@@ -541,40 +556,49 @@ class OrderDetailView(PageTitleMixin, PostActionMixin, generic.DetailView):
541 556
             options = []
542 557
             for attribute in line.attributes.all():
543 558
                 if attribute.option:
544
-                    options.append({
545
-                        'option': attribute.option,
546
-                        'value': attribute.value})
559
+                    options.append(
560
+                        {"option": attribute.option, "value": attribute.value}
561
+                    )
547 562
             basket.add_product(line.product, line.quantity, options)
548 563
 
549 564
         if len(lines_to_add) > 0:
550
-            self.response = redirect('basket:summary')
565
+            self.response = redirect("basket:summary")
551 566
             messages.info(
552 567
                 self.request,
553
-                _("All available lines from order %(number)s "
554
-                  "have been added to your basket") % {'number': order.number})
568
+                _(
569
+                    "All available lines from order %(number)s "
570
+                    "have been added to your basket"
571
+                )
572
+                % {"number": order.number},
573
+            )
555 574
         else:
556
-            self.response = redirect('customer:order-list')
575
+            self.response = redirect("customer:order-list")
557 576
             messages.warning(
558 577
                 self.request,
559
-                _("It is not possible to re-order order %(number)s "
560
-                  "as none of its lines are available to purchase") %
561
-                {'number': order.number})
578
+                _(
579
+                    "It is not possible to re-order order %(number)s "
580
+                    "as none of its lines are available to purchase"
581
+                )
582
+                % {"number": order.number},
583
+            )
562 584
 
563 585
 
564 586
 class OrderLineView(PostActionMixin, generic.DetailView):
565 587
     """Customer order line"""
566 588
 
567 589
     def get_object(self, queryset=None):
568
-        order = get_object_or_404(Order, user=self.request.user,
569
-                                  number=self.kwargs['order_number'])
570
-        return order.lines.get(id=self.kwargs['line_id'])
590
+        order = get_object_or_404(
591
+            Order, user=self.request.user, number=self.kwargs["order_number"]
592
+        )
593
+        return order.lines.get(id=self.kwargs["line_id"])
571 594
 
572 595
     def do_reorder(self, line):
573
-        self.response = redirect('customer:order', self.kwargs['order_number'])
596
+        self.response = redirect("customer:order", self.kwargs["order_number"])
574 597
         basket = self.request.basket
575 598
 
576 599
         line_available_to_reorder, reason = line.is_available_to_reorder(
577
-            basket, self.request.strategy)
600
+            basket, self.request.strategy
601
+        )
578 602
 
579 603
         if not line_available_to_reorder:
580 604
             messages.warning(self.request, reason)
@@ -582,20 +606,19 @@ class OrderLineView(PostActionMixin, generic.DetailView):
582 606
 
583 607
         # We need to pass response to the get_or_create... method
584 608
         # as a new basket might need to be created
585
-        self.response = redirect('basket:summary')
609
+        self.response = redirect("basket:summary")
586 610
 
587 611
         # Convert line attributes into basket options
588 612
         options = []
589 613
         for attribute in line.attributes.all():
590 614
             if attribute.option:
591
-                options.append({'option': attribute.option,
592
-                                'value': attribute.value})
615
+                options.append({"option": attribute.option, "value": attribute.value})
593 616
         basket.add_product(line.product, line.quantity, options)
594 617
 
595 618
         if line.quantity > 1:
596
-            msg = _("%(qty)d copies of '%(product)s' have been added to your"
597
-                    " basket") % {
598
-                'qty': line.quantity, 'product': line.product}
619
+            msg = _(
620
+                "%(qty)d copies of '%(product)s' have been added to your basket"
621
+            ) % {"qty": line.quantity, "product": line.product}
599 622
         else:
600 623
             msg = _("'%s' has been added to your basket") % line.product
601 624
 
@@ -608,9 +631,10 @@ class AnonymousOrderDetailView(generic.DetailView):
608 631
 
609 632
     def get_object(self, queryset=None):
610 633
         # Check URL hash matches that for order to prevent spoof attacks
611
-        order = get_object_or_404(self.model, user=None,
612
-                                  number=self.kwargs['order_number'])
613
-        if not order.check_verification_hash(self.kwargs['hash']):
634
+        order = get_object_or_404(
635
+            self.model, user=None, number=self.kwargs["order_number"]
636
+        )
637
+        if not order.check_verification_hash(self.kwargs["hash"]):
614 638
             raise http.Http404()
615 639
         return order
616 640
 
@@ -619,13 +643,15 @@ class AnonymousOrderDetailView(generic.DetailView):
619 643
 # Address book
620 644
 # ------------
621 645
 
646
+
622 647
 class AddressListView(PageTitleMixin, generic.ListView):
623 648
     """Customer address book"""
649
+
624 650
     context_object_name = "addresses"
625
-    template_name = 'oscar/customer/address/address_list.html'
651
+    template_name = "oscar/customer/address/address_list.html"
626 652
     paginate_by = settings.OSCAR_ADDRESSES_PER_PAGE
627
-    active_tab = 'addresses'
628
-    page_title = _('Address Book')
653
+    active_tab = "addresses"
654
+    page_title = _("Address Book")
629 655
 
630 656
     def get_queryset(self):
631 657
         """Return customer's addresses"""
@@ -635,68 +661,65 @@ class AddressListView(PageTitleMixin, generic.ListView):
635 661
 class AddressCreateView(PageTitleMixin, generic.CreateView):
636 662
     form_class = UserAddressForm
637 663
     model = UserAddress
638
-    template_name = 'oscar/customer/address/address_form.html'
639
-    active_tab = 'addresses'
640
-    page_title = _('Add a new address')
641
-    success_url = reverse_lazy('customer:address-list')
664
+    template_name = "oscar/customer/address/address_form.html"
665
+    active_tab = "addresses"
666
+    page_title = _("Add a new address")
667
+    success_url = reverse_lazy("customer:address-list")
642 668
 
643 669
     def get_form_kwargs(self):
644 670
         kwargs = super().get_form_kwargs()
645
-        kwargs['user'] = self.request.user
671
+        kwargs["user"] = self.request.user
646 672
         return kwargs
647 673
 
648 674
     def get_context_data(self, **kwargs):
649 675
         ctx = super().get_context_data(**kwargs)
650
-        ctx['title'] = _('Add a new address')
676
+        ctx["title"] = _("Add a new address")
651 677
         return ctx
652 678
 
653 679
     def get_success_url(self):
654
-        messages.success(self.request,
655
-                         _("Address '%s' created") % self.object.summary)
680
+        messages.success(self.request, _("Address '%s' created") % self.object.summary)
656 681
         return super().get_success_url()
657 682
 
658 683
 
659 684
 class AddressUpdateView(PageTitleMixin, generic.UpdateView):
660 685
     form_class = UserAddressForm
661 686
     model = UserAddress
662
-    template_name = 'oscar/customer/address/address_form.html'
663
-    active_tab = 'addresses'
664
-    page_title = _('Edit address')
665
-    success_url = reverse_lazy('customer:address-list')
687
+    template_name = "oscar/customer/address/address_form.html"
688
+    active_tab = "addresses"
689
+    page_title = _("Edit address")
690
+    success_url = reverse_lazy("customer:address-list")
666 691
 
667 692
     def get_form_kwargs(self):
668 693
         kwargs = super().get_form_kwargs()
669
-        kwargs['user'] = self.request.user
694
+        kwargs["user"] = self.request.user
670 695
         return kwargs
671 696
 
672 697
     def get_context_data(self, **kwargs):
673 698
         ctx = super().get_context_data(**kwargs)
674
-        ctx['title'] = _('Edit address')
699
+        ctx["title"] = _("Edit address")
675 700
         return ctx
676 701
 
677 702
     def get_queryset(self):
678 703
         return self.request.user.addresses.all()
679 704
 
680 705
     def get_success_url(self):
681
-        messages.success(self.request,
682
-                         _("Address '%s' updated") % self.object.summary)
706
+        messages.success(self.request, _("Address '%s' updated") % self.object.summary)
683 707
         return super().get_success_url()
684 708
 
685 709
 
686 710
 class AddressDeleteView(PageTitleMixin, generic.DeleteView):
687 711
     model = UserAddress
688 712
     template_name = "oscar/customer/address/address_delete.html"
689
-    page_title = _('Delete address?')
690
-    active_tab = 'addresses'
691
-    context_object_name = 'address'
692
-    success_url = reverse_lazy('customer:address-list')
713
+    page_title = _("Delete address?")
714
+    active_tab = "addresses"
715
+    context_object_name = "address"
716
+    success_url = reverse_lazy("customer:address-list")
693 717
 
694 718
     def get_queryset(self):
695 719
         return UserAddress._default_manager.filter(user=self.request.user)
696 720
 
697 721
     def get_success_url(self):
698
-        messages.success(self.request,
699
-                         _("Address '%s' deleted") % self.object.summary)
722
+        messages.success(self.request, _("Address '%s' deleted") % self.object.summary)
700 723
         return super().get_success_url()
701 724
 
702 725
 
@@ -704,21 +727,20 @@ class AddressChangeStatusView(generic.RedirectView):
704 727
     """
705 728
     Sets an address as default_for_(billing|shipping)
706 729
     """
707
-    url = reverse_lazy('customer:address-list')
730
+
731
+    url = reverse_lazy("customer:address-list")
708 732
     permanent = False
709 733
 
710
-    def get(self, request, pk=None, action=None, *args, **kwargs):
711
-        address = get_object_or_404(UserAddress, user=self.request.user,
712
-                                    pk=pk)
734
+    def get(self, request, *args, pk=None, action=None, **kwargs):
735
+        address = get_object_or_404(UserAddress, user=self.request.user, pk=pk)
713 736
         #  We don't want the user to set an address as the default shipping
714 737
         #  address, though they should be able to set it as their billing
715 738
         #  address.
716 739
         if address.country.is_shipping_country:
717
-            setattr(address, 'is_%s' % action, True)
718
-        elif action == 'default_for_billing':
719
-            setattr(address, 'is_default_for_billing', True)
740
+            setattr(address, "is_%s" % action, True)
741
+        elif action == "default_for_billing":
742
+            setattr(address, "is_default_for_billing", True)
720 743
         else:
721
-            messages.error(request, _('We do not ship to this country'))
744
+            messages.error(request, _("We do not ship to this country"))
722 745
         address.save()
723
-        return super().get(
724
-            request, *args, **kwargs)
746
+        return super().get(request, *args, **kwargs)

+ 111
- 84
src/oscar/apps/customer/wishlists/views.py View File

@@ -6,24 +6,31 @@ from django.shortcuts import get_object_or_404, redirect
6 6
 from django.urls import reverse
7 7
 from django.utils.translation import gettext_lazy as _
8 8
 from django.views.generic import (
9
-    CreateView, DeleteView, FormView, ListView, UpdateView, View)
9
+    CreateView,
10
+    DeleteView,
11
+    FormView,
12
+    ListView,
13
+    UpdateView,
14
+    View,
15
+)
10 16
 
11 17
 from oscar.core.loading import get_class, get_classes, get_model
12 18
 from oscar.core.utils import redirect_to_referrer, safe_referrer
13 19
 
14
-WishList = get_model('wishlists', 'WishList')
15
-Line = get_model('wishlists', 'Line')
16
-Product = get_model('catalogue', 'Product')
17
-WishListForm = get_class('wishlists.forms', 'WishListForm')
20
+WishList = get_model("wishlists", "WishList")
21
+Line = get_model("wishlists", "Line")
22
+Product = get_model("catalogue", "Product")
23
+WishListForm = get_class("wishlists.forms", "WishListForm")
18 24
 LineFormset, WishListSharedEmailFormset = get_classes(
19
-    'wishlists.formsets', ('LineFormset', 'WishListSharedEmailFormset'))
20
-PageTitleMixin = get_class('customer.mixins', 'PageTitleMixin')
25
+    "wishlists.formsets", ("LineFormset", "WishListSharedEmailFormset")
26
+)
27
+PageTitleMixin = get_class("customer.mixins", "PageTitleMixin")
21 28
 
22 29
 
23 30
 class WishListListView(PageTitleMixin, ListView):
24 31
     context_object_name = active_tab = "wishlists"
25
-    template_name = 'oscar/customer/wishlists/wishlists_list.html'
26
-    page_title = _('Wish Lists')
32
+    template_name = "oscar/customer/wishlists/wishlists_list.html"
33
+    page_title = _("Wish Lists")
27 34
 
28 35
     def get_queryset(self):
29 36
         """
@@ -41,12 +48,14 @@ class WishListDetailView(PageTitleMixin, FormView):
41 48
     It is implemented as FormView because it's easier to adapt a FormView to
42 49
     display a product then adapt a DetailView to handle form validation.
43 50
     """
44
-    template_name = 'oscar/customer/wishlists/wishlists_detail.html'
51
+
52
+    template_name = "oscar/customer/wishlists/wishlists_detail.html"
45 53
     active_tab = "wishlists"
46 54
     form_class = LineFormset
47 55
 
48 56
     def dispatch(self, request, *args, **kwargs):
49
-        self.object = self.get_wishlist_or_404(kwargs['key'], request.user)
57
+        # pylint: disable=attribute-defined-outside-init
58
+        self.object = self.get_wishlist_or_404(kwargs["key"], request.user)
50 59
         return super().dispatch(request, *args, **kwargs)
51 60
 
52 61
     def get_wishlist_or_404(self, key, user):
@@ -61,25 +70,24 @@ class WishListDetailView(PageTitleMixin, FormView):
61 70
 
62 71
     def get_form_kwargs(self):
63 72
         kwargs = super().get_form_kwargs()
64
-        kwargs['instance'] = self.object
73
+        kwargs["instance"] = self.object
65 74
         return kwargs
66 75
 
67 76
     def get_context_data(self, **kwargs):
68 77
         ctx = super().get_context_data(**kwargs)
69
-        ctx['wishlist'] = self.object
70
-        other_wishlists = self.request.user.wishlists.exclude(
71
-            pk=self.object.pk)
72
-        ctx['other_wishlists'] = other_wishlists
78
+        ctx["wishlist"] = self.object
79
+        other_wishlists = self.request.user.wishlists.exclude(pk=self.object.pk)
80
+        ctx["other_wishlists"] = other_wishlists
73 81
         return ctx
74 82
 
75 83
     def form_valid(self, form):
76 84
         for subform in form:
77
-            if subform.cleaned_data['quantity'] <= 0:
85
+            if subform.cleaned_data["quantity"] <= 0:
78 86
                 subform.instance.delete()
79 87
             else:
80 88
                 subform.save()
81
-        messages.success(self.request, _('Quantities updated.'))
82
-        return redirect('customer:wishlists-detail', key=self.object.key)
89
+        messages.success(self.request, _("Quantities updated."))
90
+        return redirect("customer:wishlists-detail", key=self.object.key)
83 91
 
84 92
 
85 93
 class WishListCreateUpdateViewMixin(PageTitleMixin):
@@ -89,6 +97,7 @@ class WishListCreateUpdateViewMixin(PageTitleMixin):
89 97
     if both forms are valid. If one of the forms is not valid, the user will be redirected to the original
90 98
     view with form errors.
91 99
     """
100
+
92 101
     def process_wishlist_forms(self, wishlist_form, shared_emails_formset):
93 102
         wishlist = wishlist_form.save()
94 103
 
@@ -104,11 +113,15 @@ class WishListCreateUpdateViewMixin(PageTitleMixin):
104 113
 
105 114
         if wishlist.shared_emails.exists() and wishlist.visibility != WishList.SHARED:
106 115
             if wishlist.visibility == WishList.PRIVATE:
107
-                msg = _("The shared accounts won't be able to access your wishlist "
108
-                        "because the visiblity is set to private.")
116
+                msg = _(
117
+                    "The shared accounts won't be able to access your wishlist "
118
+                    "because the visiblity is set to private."
119
+                )
109 120
             elif wishlist.visibility == WishList.PUBLIC:
110
-                msg = _("You have added shared accounts to your wishlist but the visiblity "
111
-                        "is public, this means everyone with a link has access to it.")
121
+                msg = _(
122
+                    "You have added shared accounts to your wishlist but the visiblity "
123
+                    "is public, this means everyone with a link has access to it."
124
+                )
112 125
             messages.warning(self.request, msg)
113 126
 
114 127
         return wishlist
@@ -123,13 +136,17 @@ class WishListCreateUpdateViewMixin(PageTitleMixin):
123 136
             self.object = None
124 137
 
125 138
         form = self.get_form()
126
-        shared_emails_formset = WishListSharedEmailFormset(request.POST, instance=self.object)
139
+        shared_emails_formset = WishListSharedEmailFormset(
140
+            request.POST, instance=self.object
141
+        )
127 142
 
128 143
         if form.is_valid() and shared_emails_formset.is_valid():
129 144
             wishlist = self.process_wishlist_forms(form, shared_emails_formset)
130 145
             return self.form_valid(wishlist)
131 146
 
132
-        context = self.get_context_data(form=form, shared_emails_formset=shared_emails_formset)
147
+        context = self.get_context_data(
148
+            form=form, shared_emails_formset=shared_emails_formset
149
+        )
133 150
         return self.render_to_response(context)
134 151
 
135 152
 
@@ -140,27 +157,26 @@ class WishListCreateView(WishListCreateUpdateViewMixin, CreateView):
140 157
     If a product ID is passed as a kwargs, then this product will be added to
141 158
     the wishlist.
142 159
     """
160
+
143 161
     model = WishList
144
-    template_name = 'oscar/customer/wishlists/wishlists_form.html'
162
+    template_name = "oscar/customer/wishlists/wishlists_form.html"
145 163
     active_tab = "wishlists"
146
-    page_title = _('Create a new wish list')
164
+    page_title = _("Create a new wish list")
147 165
     form_class = WishListForm
148 166
     product = None
149 167
 
150 168
     def dispatch(self, request, *args, **kwargs):
151
-        if 'product_pk' in kwargs:
169
+        if "product_pk" in kwargs:
152 170
             try:
153
-                self.product = Product.objects.get(pk=kwargs['product_pk'])
171
+                self.product = Product.objects.get(pk=kwargs["product_pk"])
154 172
             except ObjectDoesNotExist:
155
-                messages.error(
156
-                    request, _("The requested product no longer exists"))
157
-                return redirect('wishlists-create')
158
-        return super().dispatch(
159
-            request, *args, **kwargs)
173
+                messages.error(request, _("The requested product no longer exists"))
174
+                return redirect("wishlists-create")
175
+        return super().dispatch(request, *args, **kwargs)
160 176
 
161 177
     def get_context_data(self, **kwargs):
162 178
         ctx = super().get_context_data(**kwargs)
163
-        ctx['product'] = self.product
179
+        ctx["product"] = self.product
164 180
 
165 181
         # Invalid post response passes this to the context.
166 182
         if "shared_emails_formset" not in kwargs:
@@ -170,7 +186,7 @@ class WishListCreateView(WishListCreateUpdateViewMixin, CreateView):
170 186
 
171 187
     def get_form_kwargs(self):
172 188
         kwargs = super().get_form_kwargs()
173
-        kwargs['user'] = self.request.user
189
+        kwargs["user"] = self.request.user
174 190
         return kwargs
175 191
 
176 192
     def form_valid(self, form):
@@ -181,9 +197,9 @@ class WishListCreateView(WishListCreateUpdateViewMixin, CreateView):
181 197
         wishlist = form
182 198
         if self.product:
183 199
             wishlist.add(self.product)
184
-            msg = _("Your wishlist has been created and '%(name)s "
185
-                    "has been added") \
186
-                % {'name': self.product.get_title()}
200
+            msg = _("Your wishlist has been created and '%(name)s has been added") % {
201
+                "name": self.product.get_title()
202
+            }
187 203
         else:
188 204
             msg = _("Your wishlist has been created")
189 205
         messages.success(self.request, msg)
@@ -197,7 +213,7 @@ class WishListCreateWithProductView(View):
197 213
     """
198 214
 
199 215
     def post(self, request, *args, **kwargs):
200
-        product = get_object_or_404(Product, pk=kwargs['product_pk'])
216
+        product = get_object_or_404(Product, pk=kwargs["product_pk"])
201 217
         wishlists = request.user.wishlists.all()
202 218
         if len(wishlists) == 0:
203 219
             wishlist = request.user.wishlists.create()
@@ -208,28 +224,31 @@ class WishListCreateWithProductView(View):
208 224
             wishlist = wishlists[0]
209 225
         wishlist.add(product)
210 226
         messages.success(
211
-            request, _("%(title)s has been added to your wishlist") % {
212
-                'title': product.get_title()})
227
+            request,
228
+            _("%(title)s has been added to your wishlist")
229
+            % {"title": product.get_title()},
230
+        )
213 231
         return redirect_to_referrer(request, wishlist.get_absolute_url())
214 232
 
215 233
 
216 234
 class WishListUpdateView(WishListCreateUpdateViewMixin, UpdateView):
217 235
     model = WishList
218
-    template_name = 'oscar/customer/wishlists/wishlists_form.html'
236
+    template_name = "oscar/customer/wishlists/wishlists_form.html"
219 237
     active_tab = "wishlists"
220 238
     form_class = WishListForm
221
-    context_object_name = 'wishlist'
239
+    context_object_name = "wishlist"
222 240
 
223 241
     def get_page_title(self):
224 242
         return self.object.name
225 243
 
226 244
     def get_object(self, queryset=None):
227
-        return get_object_or_404(WishList, owner=self.request.user,
228
-                                 key=self.kwargs['key'])
245
+        return get_object_or_404(
246
+            WishList, owner=self.request.user, key=self.kwargs["key"]
247
+        )
229 248
 
230 249
     def get_form_kwargs(self):
231 250
         kwargs = super().get_form_kwargs()
232
-        kwargs['user'] = self.request.user
251
+        kwargs["user"] = self.request.user
233 252
         return kwargs
234 253
 
235 254
     def get_context_data(self, **kwargs):
@@ -237,15 +256,17 @@ class WishListUpdateView(WishListCreateUpdateViewMixin, UpdateView):
237 256
 
238 257
         # Invalid post response passes this to the context.
239 258
         if "shared_emails_formset" not in kwargs:
240
-            ctx["shared_emails_formset"] = WishListSharedEmailFormset(instance=self.object)
259
+            ctx["shared_emails_formset"] = WishListSharedEmailFormset(
260
+                instance=self.object
261
+            )
241 262
 
242 263
         return ctx
243 264
 
244 265
     def get_success_url(self):
245 266
         messages.success(
246
-            self.request, _("Your '%s' wishlist has been updated")
247
-            % self.object.name)
248
-        return reverse('customer:wishlists-list')
267
+            self.request, _("Your '%s' wishlist has been updated") % self.object.name
268
+        )
269
+        return reverse("customer:wishlists-list")
249 270
 
250 271
     def form_valid(self, form):
251 272
         return redirect(self.get_success_url())
@@ -253,21 +274,22 @@ class WishListUpdateView(WishListCreateUpdateViewMixin, UpdateView):
253 274
 
254 275
 class WishListDeleteView(PageTitleMixin, DeleteView):
255 276
     model = WishList
256
-    template_name = 'oscar/customer/wishlists/wishlists_delete.html'
277
+    template_name = "oscar/customer/wishlists/wishlists_delete.html"
257 278
     active_tab = "wishlists"
258 279
 
259 280
     def get_page_title(self):
260
-        return _('Delete %s') % self.object.name
281
+        return _("Delete %s") % self.object.name
261 282
 
262 283
     def get_object(self, queryset=None):
263
-        return get_object_or_404(WishList, owner=self.request.user,
264
-                                 key=self.kwargs['key'])
284
+        return get_object_or_404(
285
+            WishList, owner=self.request.user, key=self.kwargs["key"]
286
+        )
265 287
 
266 288
     def get_success_url(self):
267 289
         messages.success(
268
-            self.request, _("Your '%s' wish list has been deleted")
269
-            % self.object.name)
270
-        return reverse('customer:wishlists-list')
290
+            self.request, _("Your '%s' wish list has been deleted") % self.object.name
291
+        )
292
+        return reverse("customer:wishlists-list")
271 293
 
272 294
 
273 295
 class WishListAddProduct(View):
@@ -279,15 +301,17 @@ class WishListAddProduct(View):
279 301
     - If the product is already in the wish list, its quantity is increased.
280 302
     """
281 303
 
304
+    # pylint: disable=attribute-defined-outside-init
282 305
     def dispatch(self, request, *args, **kwargs):
283
-        self.product = get_object_or_404(Product, pk=kwargs['product_pk'])
306
+        self.product = get_object_or_404(Product, pk=kwargs["product_pk"])
284 307
         self.wishlist = self.get_or_create_wishlist(request, *args, **kwargs)
285 308
         return super().dispatch(request)
286 309
 
287 310
     def get_or_create_wishlist(self, request, *args, **kwargs):
288
-        if 'key' in kwargs:
311
+        if "key" in kwargs:
289 312
             wishlist = get_object_or_404(
290
-                WishList, key=kwargs['key'], owner=request.user)
313
+                WishList, key=kwargs["key"], owner=request.user
314
+            )
291 315
         else:
292 316
             wishlists = request.user.wishlists.all()[:1]
293 317
             if not wishlists:
@@ -311,8 +335,7 @@ class WishListAddProduct(View):
311 335
         self.wishlist.add(self.product)
312 336
         msg = _("'%s' was added to your wish list.") % self.product.get_title()
313 337
         messages.success(self.request, msg)
314
-        return redirect_to_referrer(
315
-            self.request, self.product.get_absolute_url())
338
+        return redirect_to_referrer(self.request, self.product.get_absolute_url())
316 339
 
317 340
 
318 341
 class LineMixin(object):
@@ -351,68 +374,72 @@ class LineMixin(object):
351 374
 
352 375
 
353 376
 class WishListRemoveProduct(LineMixin, PageTitleMixin, DeleteView):
354
-    template_name = 'oscar/customer/wishlists/wishlists_delete_product.html'
377
+    template_name = "oscar/customer/wishlists/wishlists_delete_product.html"
355 378
     active_tab = "wishlists"
356 379
 
357 380
     def get_page_title(self):
358
-        return _('Remove %s') % self.object.get_title()
381
+        return _("Remove %s") % self.object.get_title()
359 382
 
360 383
     def get_object(self, queryset=None):
361 384
         self.fetch_line(
362 385
             self.request.user,
363
-            self.kwargs['key'],
364
-            self.kwargs.get('line_pk'),
365
-            self.kwargs.get('product_pk')
386
+            self.kwargs["key"],
387
+            self.kwargs.get("line_pk"),
388
+            self.kwargs.get("product_pk"),
366 389
         )
367 390
         return self.line
368 391
 
369 392
     def get_context_data(self, **kwargs):
370 393
         ctx = super().get_context_data(**kwargs)
371
-        ctx['wishlist'] = self.wishlist
372
-        ctx['product'] = self.product
394
+        ctx["wishlist"] = self.wishlist
395
+        ctx["product"] = self.product
373 396
         return ctx
374 397
 
375 398
     def get_success_url(self):
376 399
         msg = _("'%(title)s' was removed from your '%(name)s' wish list") % {
377
-            'title': self.line.get_title(),
378
-            'name': self.wishlist.name}
400
+            "title": self.line.get_title(),
401
+            "name": self.wishlist.name,
402
+        }
379 403
         messages.success(self.request, msg)
380 404
 
381 405
         # We post directly to this view on product pages; and should send the
382 406
         # user back there if that was the case
383
-        referrer = safe_referrer(self.request, '')
384
-        if (referrer and self.product
385
-                and self.product.get_absolute_url() in referrer):
407
+        referrer = safe_referrer(self.request, "")
408
+        if referrer and self.product and self.product.get_absolute_url() in referrer:
386 409
             return referrer
387 410
         else:
388 411
             return reverse(
389
-                'customer:wishlists-detail', kwargs={'key': self.wishlist.key})
412
+                "customer:wishlists-detail", kwargs={"key": self.wishlist.key}
413
+            )
390 414
 
391 415
 
392 416
 class WishListMoveProductToAnotherWishList(LineMixin, View):
393
-
394 417
     def dispatch(self, request, *args, **kwargs):
395
-        self.fetch_line(request.user, kwargs['key'], line_pk=kwargs['line_pk'])
418
+        self.fetch_line(request.user, kwargs["key"], line_pk=kwargs["line_pk"])
396 419
         return super().dispatch(request, *args, **kwargs)
397 420
 
398 421
     def get(self, request, *args, **kwargs):
399 422
         to_wishlist = get_object_or_404(
400
-            WishList, owner=request.user, key=kwargs['to_key'])
423
+            WishList, owner=request.user, key=kwargs["to_key"]
424
+        )
401 425
 
402 426
         if to_wishlist.lines.filter(product=self.line.product).count() > 0:
403 427
             msg = _("Wish list '%(name)s' already containing '%(title)s'") % {
404
-                'title': self.product.get_title(),
405
-                'name': to_wishlist.name}
428
+                "title": self.product.get_title(),
429
+                "name": to_wishlist.name,
430
+            }
406 431
             messages.error(self.request, msg)
407 432
         else:
408 433
             self.line.wishlist = to_wishlist
409 434
             self.line.save()
410 435
 
411 436
             msg = _("'%(title)s' moved to '%(name)s' wishlist") % {
412
-                'title': self.product.get_title(),
413
-                'name': to_wishlist.name}
437
+                "title": self.product.get_title(),
438
+                "name": to_wishlist.name,
439
+            }
414 440
             messages.success(self.request, msg)
415 441
 
416 442
         default_url = reverse(
417
-            'customer:wishlists-detail', kwargs={'key': self.wishlist.key})
443
+            "customer:wishlists-detail", kwargs={"key": self.wishlist.key}
444
+        )
418 445
         return redirect_to_referrer(self.request, default_url)

+ 1
- 1
src/oscar/apps/dashboard/__init__.py View File

@@ -1 +1 @@
1
-default_app_config = 'oscar.apps.dashboard.apps.DashboardConfig'
1
+default_app_config = "oscar.apps.dashboard.apps.DashboardConfig"

+ 38
- 35
src/oscar/apps/dashboard/apps.py View File

@@ -7,50 +7,53 @@ from oscar.core.loading import get_class
7 7
 
8 8
 
9 9
 class DashboardConfig(OscarDashboardConfig):
10
-    label = 'dashboard'
11
-    name = 'oscar.apps.dashboard'
12
-    verbose_name = _('Dashboard')
10
+    label = "dashboard"
11
+    name = "oscar.apps.dashboard"
12
+    verbose_name = _("Dashboard")
13 13
 
14
-    namespace = 'dashboard'
14
+    namespace = "dashboard"
15 15
     permissions_map = {
16
-        'index': (['is_staff'], ['partner.dashboard_access']),
16
+        "index": (["is_staff"], ["partner.dashboard_access"]),
17 17
     }
18 18
 
19
+    # pylint: disable=attribute-defined-outside-init
19 20
     def ready(self):
20
-        self.index_view = get_class('dashboard.views', 'IndexView')
21
-        self.login_view = get_class('dashboard.views', 'LoginView')
22
-
23
-        self.catalogue_app = apps.get_app_config('catalogue_dashboard')
24
-        self.reports_app = apps.get_app_config('reports_dashboard')
25
-        self.orders_app = apps.get_app_config('orders_dashboard')
26
-        self.users_app = apps.get_app_config('users_dashboard')
27
-        self.pages_app = apps.get_app_config('pages_dashboard')
28
-        self.partners_app = apps.get_app_config('partners_dashboard')
29
-        self.offers_app = apps.get_app_config('offers_dashboard')
30
-        self.ranges_app = apps.get_app_config('ranges_dashboard')
31
-        self.reviews_app = apps.get_app_config('reviews_dashboard')
32
-        self.vouchers_app = apps.get_app_config('vouchers_dashboard')
33
-        self.comms_app = apps.get_app_config('communications_dashboard')
34
-        self.shipping_app = apps.get_app_config('shipping_dashboard')
21
+        self.index_view = get_class("dashboard.views", "IndexView")
22
+        self.login_view = get_class("dashboard.views", "LoginView")
23
+
24
+        self.catalogue_app = apps.get_app_config("catalogue_dashboard")
25
+        self.reports_app = apps.get_app_config("reports_dashboard")
26
+        self.orders_app = apps.get_app_config("orders_dashboard")
27
+        self.users_app = apps.get_app_config("users_dashboard")
28
+        self.pages_app = apps.get_app_config("pages_dashboard")
29
+        self.partners_app = apps.get_app_config("partners_dashboard")
30
+        self.offers_app = apps.get_app_config("offers_dashboard")
31
+        self.ranges_app = apps.get_app_config("ranges_dashboard")
32
+        self.reviews_app = apps.get_app_config("reviews_dashboard")
33
+        self.vouchers_app = apps.get_app_config("vouchers_dashboard")
34
+        self.comms_app = apps.get_app_config("communications_dashboard")
35
+        self.shipping_app = apps.get_app_config("shipping_dashboard")
35 36
 
36 37
     def get_urls(self):
37 38
         from django.contrib.auth import views as auth_views
38 39
 
39 40
         urls = [
40
-            path('', self.index_view.as_view(), name='index'),
41
-            path('catalogue/', include(self.catalogue_app.urls[0])),
42
-            path('reports/', include(self.reports_app.urls[0])),
43
-            path('orders/', include(self.orders_app.urls[0])),
44
-            path('users/', include(self.users_app.urls[0])),
45
-            path('pages/', include(self.pages_app.urls[0])),
46
-            path('partners/', include(self.partners_app.urls[0])),
47
-            path('offers/', include(self.offers_app.urls[0])),
48
-            path('ranges/', include(self.ranges_app.urls[0])),
49
-            path('reviews/', include(self.reviews_app.urls[0])),
50
-            path('vouchers/', include(self.vouchers_app.urls[0])),
51
-            path('comms/', include(self.comms_app.urls[0])),
52
-            path('shipping/', include(self.shipping_app.urls[0])),
53
-            path('login/', self.login_view.as_view(), name='login'),
54
-            path('logout/', auth_views.LogoutView.as_view(next_page='/'), name='logout'),
41
+            path("", self.index_view.as_view(), name="index"),
42
+            path("catalogue/", include(self.catalogue_app.urls[0])),
43
+            path("reports/", include(self.reports_app.urls[0])),
44
+            path("orders/", include(self.orders_app.urls[0])),
45
+            path("users/", include(self.users_app.urls[0])),
46
+            path("pages/", include(self.pages_app.urls[0])),
47
+            path("partners/", include(self.partners_app.urls[0])),
48
+            path("offers/", include(self.offers_app.urls[0])),
49
+            path("ranges/", include(self.ranges_app.urls[0])),
50
+            path("reviews/", include(self.reviews_app.urls[0])),
51
+            path("vouchers/", include(self.vouchers_app.urls[0])),
52
+            path("comms/", include(self.comms_app.urls[0])),
53
+            path("shipping/", include(self.shipping_app.urls[0])),
54
+            path("login/", self.login_view.as_view(), name="login"),
55
+            path(
56
+                "logout/", auth_views.LogoutView.as_view(next_page="/"), name="logout"
57
+            ),
55 58
         ]
56 59
         return self.post_process_urls(urls)

+ 1
- 2
src/oscar/apps/dashboard/catalogue/__init__.py View File

@@ -1,2 +1 @@
1
-default_app_config = (
2
-    'oscar.apps.dashboard.catalogue.apps.CatalogueDashboardConfig')
1
+default_app_config = "oscar.apps.dashboard.catalogue.apps.CatalogueDashboardConfig"

+ 175
- 96
src/oscar/apps/dashboard/catalogue/apps.py View File

@@ -6,146 +6,225 @@ from oscar.core.loading import get_class
6 6
 
7 7
 
8 8
 class CatalogueDashboardConfig(OscarDashboardConfig):
9
-    label = 'catalogue_dashboard'
10
-    name = 'oscar.apps.dashboard.catalogue'
11
-    verbose_name = _('Catalogue')
9
+    label = "catalogue_dashboard"
10
+    name = "oscar.apps.dashboard.catalogue"
11
+    verbose_name = _("Catalogue")
12 12
 
13
-    default_permissions = ['is_staff', ]
13
+    default_permissions = [
14
+        "is_staff",
15
+    ]
14 16
     permissions_map = _map = {
15
-        'catalogue-product': (['is_staff'], ['partner.dashboard_access']),
16
-        'catalogue-product-create': (['is_staff'],
17
-                                     ['partner.dashboard_access']),
18
-        'catalogue-product-list': (['is_staff'], ['partner.dashboard_access']),
19
-        'catalogue-product-delete': (['is_staff'],
20
-                                     ['partner.dashboard_access']),
21
-        'catalogue-product-lookup': (['is_staff'],
22
-                                     ['partner.dashboard_access']),
17
+        "catalogue-product": (["is_staff"], ["partner.dashboard_access"]),
18
+        "catalogue-product-create": (["is_staff"], ["partner.dashboard_access"]),
19
+        "catalogue-product-list": (["is_staff"], ["partner.dashboard_access"]),
20
+        "catalogue-product-delete": (["is_staff"], ["partner.dashboard_access"]),
21
+        "catalogue-product-lookup": (["is_staff"], ["partner.dashboard_access"]),
23 22
     }
24 23
 
24
+    # pylint: disable=attribute-defined-outside-init
25 25
     def ready(self):
26
-        self.product_list_view = get_class('dashboard.catalogue.views',
27
-                                           'ProductListView')
28
-        self.product_lookup_view = get_class('dashboard.catalogue.views',
29
-                                             'ProductLookupView')
30
-        self.product_create_redirect_view = get_class('dashboard.catalogue.views',
31
-                                                      'ProductCreateRedirectView')
32
-        self.product_createupdate_view = get_class('dashboard.catalogue.views',
33
-                                                   'ProductCreateUpdateView')
34
-        self.product_delete_view = get_class('dashboard.catalogue.views',
35
-                                             'ProductDeleteView')
26
+        self.product_list_view = get_class(
27
+            "dashboard.catalogue.views", "ProductListView"
28
+        )
29
+        self.product_lookup_view = get_class(
30
+            "dashboard.catalogue.views", "ProductLookupView"
31
+        )
32
+        self.product_create_redirect_view = get_class(
33
+            "dashboard.catalogue.views", "ProductCreateRedirectView"
34
+        )
35
+        self.product_createupdate_view = get_class(
36
+            "dashboard.catalogue.views", "ProductCreateUpdateView"
37
+        )
38
+        self.product_delete_view = get_class(
39
+            "dashboard.catalogue.views", "ProductDeleteView"
40
+        )
36 41
 
37
-        self.product_class_create_view = get_class('dashboard.catalogue.views',
38
-                                                   'ProductClassCreateView')
39
-        self.product_class_update_view = get_class('dashboard.catalogue.views',
40
-                                                   'ProductClassUpdateView')
41
-        self.product_class_list_view = get_class('dashboard.catalogue.views',
42
-                                                 'ProductClassListView')
43
-        self.product_class_delete_view = get_class('dashboard.catalogue.views',
44
-                                                   'ProductClassDeleteView')
42
+        self.product_class_create_view = get_class(
43
+            "dashboard.catalogue.views", "ProductClassCreateView"
44
+        )
45
+        self.product_class_update_view = get_class(
46
+            "dashboard.catalogue.views", "ProductClassUpdateView"
47
+        )
48
+        self.product_class_list_view = get_class(
49
+            "dashboard.catalogue.views", "ProductClassListView"
50
+        )
51
+        self.product_class_delete_view = get_class(
52
+            "dashboard.catalogue.views", "ProductClassDeleteView"
53
+        )
45 54
 
46
-        self.category_list_view = get_class('dashboard.catalogue.views',
47
-                                            'CategoryListView')
48
-        self.category_detail_list_view = get_class('dashboard.catalogue.views',
49
-                                                   'CategoryDetailListView')
50
-        self.category_create_view = get_class('dashboard.catalogue.views',
51
-                                              'CategoryCreateView')
52
-        self.category_update_view = get_class('dashboard.catalogue.views',
53
-                                              'CategoryUpdateView')
54
-        self.category_delete_view = get_class('dashboard.catalogue.views',
55
-                                              'CategoryDeleteView')
55
+        self.category_list_view = get_class(
56
+            "dashboard.catalogue.views", "CategoryListView"
57
+        )
58
+        self.category_detail_list_view = get_class(
59
+            "dashboard.catalogue.views", "CategoryDetailListView"
60
+        )
61
+        self.category_create_view = get_class(
62
+            "dashboard.catalogue.views", "CategoryCreateView"
63
+        )
64
+        self.category_update_view = get_class(
65
+            "dashboard.catalogue.views", "CategoryUpdateView"
66
+        )
67
+        self.category_delete_view = get_class(
68
+            "dashboard.catalogue.views", "CategoryDeleteView"
69
+        )
56 70
 
57
-        self.stock_alert_view = get_class('dashboard.catalogue.views',
58
-                                          'StockAlertListView')
71
+        self.stock_alert_view = get_class(
72
+            "dashboard.catalogue.views", "StockAlertListView"
73
+        )
59 74
 
60
-        self.attribute_option_group_create_view = get_class('dashboard.catalogue.views',
61
-                                                            'AttributeOptionGroupCreateView')
62
-        self.attribute_option_group_list_view = get_class('dashboard.catalogue.views',
63
-                                                          'AttributeOptionGroupListView')
64
-        self.attribute_option_group_update_view = get_class('dashboard.catalogue.views',
65
-                                                            'AttributeOptionGroupUpdateView')
66
-        self.attribute_option_group_delete_view = get_class('dashboard.catalogue.views',
67
-                                                            'AttributeOptionGroupDeleteView')
75
+        self.attribute_option_group_create_view = get_class(
76
+            "dashboard.catalogue.views", "AttributeOptionGroupCreateView"
77
+        )
78
+        self.attribute_option_group_list_view = get_class(
79
+            "dashboard.catalogue.views", "AttributeOptionGroupListView"
80
+        )
81
+        self.attribute_option_group_update_view = get_class(
82
+            "dashboard.catalogue.views", "AttributeOptionGroupUpdateView"
83
+        )
84
+        self.attribute_option_group_delete_view = get_class(
85
+            "dashboard.catalogue.views", "AttributeOptionGroupDeleteView"
86
+        )
68 87
 
69
-        self.option_list_view = get_class('dashboard.catalogue.views', 'OptionListView')
70
-        self.option_create_view = get_class('dashboard.catalogue.views', 'OptionCreateView')
71
-        self.option_update_view = get_class('dashboard.catalogue.views', 'OptionUpdateView')
72
-        self.option_delete_view = get_class('dashboard.catalogue.views', 'OptionDeleteView')
88
+        self.option_list_view = get_class("dashboard.catalogue.views", "OptionListView")
89
+        self.option_create_view = get_class(
90
+            "dashboard.catalogue.views", "OptionCreateView"
91
+        )
92
+        self.option_update_view = get_class(
93
+            "dashboard.catalogue.views", "OptionUpdateView"
94
+        )
95
+        self.option_delete_view = get_class(
96
+            "dashboard.catalogue.views", "OptionDeleteView"
97
+        )
73 98
 
74 99
     def get_urls(self):
75 100
         urls = [
76
-            path('products/<int:pk>/', self.product_createupdate_view.as_view(), name='catalogue-product'),
77
-            path('products/create/', self.product_create_redirect_view.as_view(), name='catalogue-product-create'),
78 101
             path(
79
-                'products/create/<slug:product_class_slug>/',
102
+                "products/<int:pk>/",
80 103
                 self.product_createupdate_view.as_view(),
81
-                name='catalogue-product-create'),
104
+                name="catalogue-product",
105
+            ),
82 106
             path(
83
-                'products/<int:parent_pk>/create-variant/',
107
+                "products/create/",
108
+                self.product_create_redirect_view.as_view(),
109
+                name="catalogue-product-create",
110
+            ),
111
+            path(
112
+                "products/create/<slug:product_class_slug>/",
113
+                self.product_createupdate_view.as_view(),
114
+                name="catalogue-product-create",
115
+            ),
116
+            path(
117
+                "products/<int:parent_pk>/create-variant/",
84 118
                 self.product_createupdate_view.as_view(),
85
-                name='catalogue-product-create-child'),
86
-            path('products/<int:pk>/delete/', self.product_delete_view.as_view(), name='catalogue-product-delete'),
87
-            path('', self.product_list_view.as_view(), name='catalogue-product-list'),
88
-            path('stock-alerts/', self.stock_alert_view.as_view(), name='stock-alert-list'),
89
-            path('product-lookup/', self.product_lookup_view.as_view(), name='catalogue-product-lookup'),
90
-            path('categories/', self.category_list_view.as_view(), name='catalogue-category-list'),
91
-            path(
92
-                'categories/<int:pk>/',
119
+                name="catalogue-product-create-child",
120
+            ),
121
+            path(
122
+                "products/<int:pk>/delete/",
123
+                self.product_delete_view.as_view(),
124
+                name="catalogue-product-delete",
125
+            ),
126
+            path("", self.product_list_view.as_view(), name="catalogue-product-list"),
127
+            path(
128
+                "stock-alerts/",
129
+                self.stock_alert_view.as_view(),
130
+                name="stock-alert-list",
131
+            ),
132
+            path(
133
+                "product-lookup/",
134
+                self.product_lookup_view.as_view(),
135
+                name="catalogue-product-lookup",
136
+            ),
137
+            path(
138
+                "categories/",
139
+                self.category_list_view.as_view(),
140
+                name="catalogue-category-list",
141
+            ),
142
+            path(
143
+                "categories/<int:pk>/",
93 144
                 self.category_detail_list_view.as_view(),
94
-                name='catalogue-category-detail-list'),
145
+                name="catalogue-category-detail-list",
146
+            ),
95 147
             path(
96
-                'categories/create/', self.category_create_view.as_view(),
97
-                name='catalogue-category-create'),
148
+                "categories/create/",
149
+                self.category_create_view.as_view(),
150
+                name="catalogue-category-create",
151
+            ),
98 152
             path(
99
-                'categories/create/<int:parent>/',
153
+                "categories/create/<int:parent>/",
100 154
                 self.category_create_view.as_view(),
101
-                name='catalogue-category-create-child'),
155
+                name="catalogue-category-create-child",
156
+            ),
102 157
             path(
103
-                'categories/<int:pk>/update/',
158
+                "categories/<int:pk>/update/",
104 159
                 self.category_update_view.as_view(),
105
-                name='catalogue-category-update'),
160
+                name="catalogue-category-update",
161
+            ),
106 162
             path(
107
-                'categories/<int:pk>/delete/',
163
+                "categories/<int:pk>/delete/",
108 164
                 self.category_delete_view.as_view(),
109
-                name='catalogue-category-delete'),
165
+                name="catalogue-category-delete",
166
+            ),
110 167
             path(
111
-                'product-type/create/',
168
+                "product-type/create/",
112 169
                 self.product_class_create_view.as_view(),
113
-                name='catalogue-class-create'),
170
+                name="catalogue-class-create",
171
+            ),
114 172
             path(
115
-                'product-types/',
173
+                "product-types/",
116 174
                 self.product_class_list_view.as_view(),
117
-                name='catalogue-class-list'),
175
+                name="catalogue-class-list",
176
+            ),
118 177
             path(
119
-                'product-type/<int:pk>/update/',
178
+                "product-type/<int:pk>/update/",
120 179
                 self.product_class_update_view.as_view(),
121
-                name='catalogue-class-update'),
180
+                name="catalogue-class-update",
181
+            ),
122 182
             path(
123
-                'product-type/<int:pk>/delete/',
183
+                "product-type/<int:pk>/delete/",
124 184
                 self.product_class_delete_view.as_view(),
125
-                name='catalogue-class-delete'),
185
+                name="catalogue-class-delete",
186
+            ),
126 187
             path(
127
-                'attribute-option-group/create/',
188
+                "attribute-option-group/create/",
128 189
                 self.attribute_option_group_create_view.as_view(),
129
-                name='catalogue-attribute-option-group-create'),
190
+                name="catalogue-attribute-option-group-create",
191
+            ),
130 192
             path(
131
-                'attribute-option-group/',
193
+                "attribute-option-group/",
132 194
                 self.attribute_option_group_list_view.as_view(),
133
-                name='catalogue-attribute-option-group-list'),
195
+                name="catalogue-attribute-option-group-list",
196
+            ),
134 197
             # The RelatedFieldWidgetWrapper code does something funny with
135 198
             # placeholder urls, so it does need to match more than just a pk
136 199
             path(
137
-                'attribute-option-group/<str:pk>/update/',
200
+                "attribute-option-group/<str:pk>/update/",
138 201
                 self.attribute_option_group_update_view.as_view(),
139
-                name='catalogue-attribute-option-group-update'),
202
+                name="catalogue-attribute-option-group-update",
203
+            ),
140 204
             # The RelatedFieldWidgetWrapper code does something funny with
141 205
             # placeholder urls, so it does need to match more than just a pk
142 206
             path(
143
-                'attribute-option-group/<str:pk>/delete/',
207
+                "attribute-option-group/<str:pk>/delete/",
144 208
                 self.attribute_option_group_delete_view.as_view(),
145
-                name='catalogue-attribute-option-group-delete'),
146
-            path('option/', self.option_list_view.as_view(), name='catalogue-option-list'),
147
-            path('option/create/', self.option_create_view.as_view(), name='catalogue-option-create'),
148
-            path('option/<str:pk>/update/', self.option_update_view.as_view(), name='catalogue-option-update'),
149
-            path('option/<str:pk>/delete/', self.option_delete_view.as_view(), name='catalogue-option-delete'),
209
+                name="catalogue-attribute-option-group-delete",
210
+            ),
211
+            path(
212
+                "option/", self.option_list_view.as_view(), name="catalogue-option-list"
213
+            ),
214
+            path(
215
+                "option/create/",
216
+                self.option_create_view.as_view(),
217
+                name="catalogue-option-create",
218
+            ),
219
+            path(
220
+                "option/<str:pk>/update/",
221
+                self.option_update_view.as_view(),
222
+                name="catalogue-option-update",
223
+            ),
224
+            path(
225
+                "option/<str:pk>/delete/",
226
+                self.option_delete_view.as_view(),
227
+                name="catalogue-option-delete",
228
+            ),
150 229
         ]
151 230
         return self.post_process_urls(urls)

+ 143
- 121
src/oscar/apps/dashboard/catalogue/forms.py View File

@@ -7,36 +7,49 @@ from oscar.core.loading import get_class, get_classes, get_model
7 7
 from oscar.core.utils import slugify
8 8
 from oscar.forms.widgets import DateTimePickerInput, ImageInput
9 9
 
10
-Product = get_model('catalogue', 'Product')
11
-ProductClass = get_model('catalogue', 'ProductClass')
12
-ProductAttribute = get_model('catalogue', 'ProductAttribute')
13
-Category = get_model('catalogue', 'Category')
14
-StockRecord = get_model('partner', 'StockRecord')
15
-ProductCategory = get_model('catalogue', 'ProductCategory')
16
-ProductImage = get_model('catalogue', 'ProductImage')
17
-ProductRecommendation = get_model('catalogue', 'ProductRecommendation')
18
-AttributeOptionGroup = get_model('catalogue', 'AttributeOptionGroup')
19
-AttributeOption = get_model('catalogue', 'AttributeOption')
20
-Option = get_model('catalogue', 'Option')
21
-ProductSelect = get_class('dashboard.catalogue.widgets', 'ProductSelect')
22
-(RelatedFieldWidgetWrapper,
23
- RelatedMultipleFieldWidgetWrapper) = get_classes('dashboard.widgets',
24
-                                                  ('RelatedFieldWidgetWrapper',
25
-                                                   'RelatedMultipleFieldWidgetWrapper'))
10
+Product = get_model("catalogue", "Product")
11
+ProductClass = get_model("catalogue", "ProductClass")
12
+ProductAttribute = get_model("catalogue", "ProductAttribute")
13
+Category = get_model("catalogue", "Category")
14
+StockRecord = get_model("partner", "StockRecord")
15
+ProductCategory = get_model("catalogue", "ProductCategory")
16
+ProductImage = get_model("catalogue", "ProductImage")
17
+ProductRecommendation = get_model("catalogue", "ProductRecommendation")
18
+AttributeOptionGroup = get_model("catalogue", "AttributeOptionGroup")
19
+AttributeOption = get_model("catalogue", "AttributeOption")
20
+Option = get_model("catalogue", "Option")
21
+ProductSelect = get_class("dashboard.catalogue.widgets", "ProductSelect")
22
+(RelatedFieldWidgetWrapper, RelatedMultipleFieldWidgetWrapper) = get_classes(
23
+    "dashboard.widgets",
24
+    ("RelatedFieldWidgetWrapper", "RelatedMultipleFieldWidgetWrapper"),
25
+)
26 26
 
27 27
 
28 28
 BaseCategoryForm = movenodeform_factory(
29 29
     Category,
30
-    fields=['name', 'slug', 'description', 'image', 'is_public', 'meta_title', 'meta_description'],
31
-    exclude=['ancestors_are_public'],
32
-    widgets={'meta_description': forms.Textarea(attrs={'class': 'no-widget-init'})})
30
+    fields=[
31
+        "name",
32
+        "slug",
33
+        "description",
34
+        "image",
35
+        "is_public",
36
+        "meta_title",
37
+        "meta_description",
38
+    ],
39
+    exclude=["ancestors_are_public"],
40
+    widgets={"meta_description": forms.Textarea(attrs={"class": "no-widget-init"})},
41
+)
33 42
 
34 43
 
35 44
 class SEOFormMixin:
36
-    seo_fields = ['meta_title', 'meta_description', 'slug']
45
+    seo_fields = ["meta_title", "meta_description", "slug"]
37 46
 
38 47
     def primary_form_fields(self):
39
-        return [field for field in self if not field.is_hidden and not self.is_seo_field(field)]
48
+        return [
49
+            field
50
+            for field in self
51
+            if not field.is_hidden and not self.is_seo_field(field)
52
+        ]
40 53
 
41 54
     def seo_form_fields(self):
42 55
         return [field for field in self if self.is_seo_field(field)]
@@ -48,9 +61,11 @@ class SEOFormMixin:
48 61
 class CategoryForm(SEOFormMixin, BaseCategoryForm):
49 62
     def __init__(self, *args, **kwargs):
50 63
         super().__init__(*args, **kwargs)
51
-        if 'slug' in self.fields:
52
-            self.fields['slug'].required = False
53
-            self.fields['slug'].help_text = _('Leave blank to generate from category name')
64
+        if "slug" in self.fields:
65
+            self.fields["slug"].required = False
66
+            self.fields["slug"].help_text = _(
67
+                "Leave blank to generate from category name"
68
+            )
54 69
 
55 70
 
56 71
 class ProductClassSelectForm(forms.Form):
@@ -61,32 +76,31 @@ class ProductClassSelectForm(forms.Form):
61 76
     product_class = forms.ModelChoiceField(
62 77
         label=_("Create a new product of type"),
63 78
         empty_label=_("-- Choose type --"),
64
-        queryset=ProductClass.objects.all())
79
+        queryset=ProductClass.objects.all(),
80
+    )
65 81
 
66 82
     def __init__(self, *args, **kwargs):
67 83
         """
68 84
         If there's only one product class, pre-select it
69 85
         """
70 86
         super().__init__(*args, **kwargs)
71
-        qs = self.fields['product_class'].queryset
72
-        if not kwargs.get('initial') and len(qs) == 1:
73
-            self.fields['product_class'].initial = qs[0]
87
+        qs = self.fields["product_class"].queryset
88
+        if not kwargs.get("initial") and len(qs) == 1:
89
+            self.fields["product_class"].initial = qs[0]
74 90
 
75 91
 
76 92
 class ProductSearchForm(forms.Form):
77
-    upc = forms.CharField(max_length=64, required=False, label=_('UPC'))
78
-    title = forms.CharField(
79
-        max_length=255, required=False, label=_('Product title'))
93
+    upc = forms.CharField(max_length=64, required=False, label=_("UPC"))
94
+    title = forms.CharField(max_length=255, required=False, label=_("Product title"))
80 95
 
81 96
     def clean(self):
82 97
         cleaned_data = super().clean()
83
-        cleaned_data['upc'] = cleaned_data['upc'].strip()
84
-        cleaned_data['title'] = cleaned_data['title'].strip()
98
+        cleaned_data["upc"] = cleaned_data["upc"].strip()
99
+        cleaned_data["title"] = cleaned_data["title"].strip()
85 100
         return cleaned_data
86 101
 
87 102
 
88 103
 class StockRecordForm(forms.ModelForm):
89
-
90 104
     def __init__(self, product_class, user, *args, **kwargs):
91 105
         # The user kwarg is not used by stock StockRecordForm. We pass it
92 106
         # anyway in case one wishes to customise the partner queryset
@@ -95,79 +109,83 @@ class StockRecordForm(forms.ModelForm):
95 109
 
96 110
         # Restrict accessible partners for non-staff users
97 111
         if not self.user.is_staff:
98
-            self.fields['partner'].queryset = self.user.partners.all()
112
+            self.fields["partner"].queryset = self.user.partners.all()
99 113
 
100 114
         # If not tracking stock, we hide the fields
101 115
         if not product_class.track_stock:
102
-            for field_name in ['num_in_stock', 'low_stock_threshold']:
116
+            for field_name in ["num_in_stock", "low_stock_threshold"]:
103 117
                 if field_name in self.fields:
104 118
                     del self.fields[field_name]
105 119
         else:
106
-            for field_name in ['price', 'num_in_stock']:
120
+            for field_name in ["price", "num_in_stock"]:
107 121
                 if field_name in self.fields:
108 122
                     self.fields[field_name].required = True
109 123
 
110 124
     class Meta:
111 125
         model = StockRecord
112 126
         fields = [
113
-            'partner', 'partner_sku',
114
-            'price_currency', 'price',
115
-            'num_in_stock', 'low_stock_threshold',
127
+            "partner",
128
+            "partner_sku",
129
+            "price_currency",
130
+            "price",
131
+            "num_in_stock",
132
+            "low_stock_threshold",
116 133
         ]
117 134
 
118 135
 
119 136
 def _attr_text_field(attribute):
120
-    return forms.CharField(label=attribute.name,
121
-                           required=attribute.required)
137
+    return forms.CharField(label=attribute.name, required=attribute.required)
122 138
 
123 139
 
124 140
 def _attr_textarea_field(attribute):
125
-    return forms.CharField(label=attribute.name,
126
-                           widget=forms.Textarea(),
127
-                           required=attribute.required)
141
+    return forms.CharField(
142
+        label=attribute.name, widget=forms.Textarea(), required=attribute.required
143
+    )
128 144
 
129 145
 
130 146
 def _attr_integer_field(attribute):
131
-    return forms.IntegerField(label=attribute.name,
132
-                              required=attribute.required)
147
+    return forms.IntegerField(label=attribute.name, required=attribute.required)
133 148
 
134 149
 
135 150
 def _attr_boolean_field(attribute):
136
-    return forms.BooleanField(label=attribute.name,
137
-                              required=attribute.required)
151
+    return forms.BooleanField(label=attribute.name, required=attribute.required)
138 152
 
139 153
 
140 154
 def _attr_float_field(attribute):
141
-    return forms.FloatField(label=attribute.name,
142
-                            required=attribute.required)
155
+    return forms.FloatField(label=attribute.name, required=attribute.required)
143 156
 
144 157
 
145 158
 def _attr_date_field(attribute):
146
-    return forms.DateField(label=attribute.name,
147
-                           required=attribute.required,
148
-                           widget=forms.widgets.DateInput)
159
+    return forms.DateField(
160
+        label=attribute.name,
161
+        required=attribute.required,
162
+        widget=forms.widgets.DateInput,
163
+    )
149 164
 
150 165
 
151 166
 def _attr_datetime_field(attribute):
152
-    return forms.DateTimeField(label=attribute.name,
153
-                               required=attribute.required,
154
-                               widget=DateTimePickerInput())
167
+    return forms.DateTimeField(
168
+        label=attribute.name, required=attribute.required, widget=DateTimePickerInput()
169
+    )
155 170
 
156 171
 
157 172
 def _attr_option_field(attribute):
158 173
     return forms.ModelChoiceField(
159 174
         label=attribute.name,
160 175
         required=attribute.required,
161
-        queryset=attribute.option_group.options.all())
176
+        queryset=attribute.option_group.options.all(),
177
+    )
162 178
 
163 179
 
164 180
 def _attr_multi_option_field(attribute):
165 181
     return forms.ModelMultipleChoiceField(
166 182
         label=attribute.name,
167 183
         required=attribute.required,
168
-        queryset=attribute.option_group.options.all())
184
+        queryset=attribute.option_group.options.all(),
185
+    )
169 186
 
170 187
 
188
+# pylint: disable=unused-argument
171 189
 def _attr_entity_field(attribute):
172 190
     # Product entities don't have out-of-the-box supported in the ProductForm.
173 191
     # There is no ModelChoiceField for generic foreign keys, and there's no
@@ -177,18 +195,15 @@ def _attr_entity_field(attribute):
177 195
 
178 196
 
179 197
 def _attr_numeric_field(attribute):
180
-    return forms.FloatField(label=attribute.name,
181
-                            required=attribute.required)
198
+    return forms.FloatField(label=attribute.name, required=attribute.required)
182 199
 
183 200
 
184 201
 def _attr_file_field(attribute):
185
-    return forms.FileField(
186
-        label=attribute.name, required=attribute.required)
202
+    return forms.FileField(label=attribute.name, required=attribute.required)
187 203
 
188 204
 
189 205
 def _attr_image_field(attribute):
190
-    return forms.ImageField(
191
-        label=attribute.name, required=attribute.required)
206
+    return forms.ImageField(label=attribute.name, required=attribute.required)
192 207
 
193 208
 
194 209
 class ProductForm(SEOFormMixin, forms.ModelForm):
@@ -211,14 +226,22 @@ class ProductForm(SEOFormMixin, forms.ModelForm):
211 226
     class Meta:
212 227
         model = Product
213 228
         fields = [
214
-            'title', 'upc', 'description', 'is_public', 'is_discountable', 'structure', 'slug', 'meta_title',
215
-            'meta_description']
229
+            "title",
230
+            "upc",
231
+            "description",
232
+            "is_public",
233
+            "is_discountable",
234
+            "structure",
235
+            "slug",
236
+            "meta_title",
237
+            "meta_description",
238
+        ]
216 239
         widgets = {
217
-            'structure': forms.HiddenInput(),
218
-            'meta_description': forms.Textarea(attrs={'class': 'no-widget-init'})
240
+            "structure": forms.HiddenInput(),
241
+            "meta_description": forms.Textarea(attrs={"class": "no-widget-init"}),
219 242
         }
220 243
 
221
-    def __init__(self, product_class, data=None, parent=None, *args, **kwargs):
244
+    def __init__(self, product_class, *args, data=None, parent=None, **kwargs):
222 245
         self.set_initial(product_class, parent, kwargs)
223 246
         super().__init__(data, *args, **kwargs)
224 247
         if parent:
@@ -235,12 +258,13 @@ class ProductForm(SEOFormMixin, forms.ModelForm):
235 258
             self.instance.product_class = product_class
236 259
         self.add_attribute_fields(product_class, self.instance.is_parent)
237 260
 
238
-        if 'slug' in self.fields:
239
-            self.fields['slug'].required = False
240
-            self.fields['slug'].help_text = _('Leave blank to generate from product title')
241
-        if 'title' in self.fields:
242
-            self.fields['title'].widget = forms.TextInput(
243
-                attrs={'autocomplete': 'off'})
261
+        if "slug" in self.fields:
262
+            self.fields["slug"].required = False
263
+            self.fields["slug"].help_text = _(
264
+                "Leave blank to generate from product title"
265
+            )
266
+        if "title" in self.fields:
267
+            self.fields["title"].widget = forms.TextInput(attrs={"autocomplete": "off"})
244 268
 
245 269
     def set_initial(self, product_class, parent, kwargs):
246 270
         """
@@ -248,28 +272,27 @@ class ProductForm(SEOFormMixin, forms.ModelForm):
248 272
         and fetches initial values for the dynamically constructed attribute
249 273
         fields.
250 274
         """
251
-        if 'initial' not in kwargs:
252
-            kwargs['initial'] = {}
275
+        if "initial" not in kwargs:
276
+            kwargs["initial"] = {}
253 277
         self.set_initial_attribute_values(product_class, kwargs)
254 278
         if parent:
255
-            kwargs['initial']['structure'] = Product.CHILD
279
+            kwargs["initial"]["structure"] = Product.CHILD
256 280
 
257 281
     def set_initial_attribute_values(self, product_class, kwargs):
258 282
         """
259 283
         Update the kwargs['initial'] value to have the initial values based on
260 284
         the product instance's attributes
261 285
         """
262
-        instance = kwargs.get('instance')
286
+        instance = kwargs.get("instance")
263 287
         if instance is None:
264 288
             return
265 289
         for attribute in product_class.attributes.all():
266 290
             try:
267
-                value = instance.attribute_values.get(
268
-                    attribute=attribute).value
291
+                value = instance.attribute_values.get(attribute=attribute).value
269 292
             except exceptions.ObjectDoesNotExist:
270 293
                 pass
271 294
             else:
272
-                kwargs['initial']['attr_%s' % attribute.code] = value
295
+                kwargs["initial"]["attr_%s" % attribute.code] = value
273 296
 
274 297
     def add_attribute_fields(self, product_class, is_parent=False):
275 298
         """
@@ -279,10 +302,10 @@ class ProductForm(SEOFormMixin, forms.ModelForm):
279 302
         for attribute in product_class.attributes.all():
280 303
             field = self.get_attribute_field(attribute)
281 304
             if field:
282
-                self.fields['attr_%s' % attribute.code] = field
305
+                self.fields["attr_%s" % attribute.code] = field
283 306
                 # Attributes are not required for a parent product
284 307
                 if is_parent:
285
-                    self.fields['attr_%s' % attribute.code].required = False
308
+                    self.fields["attr_%s" % attribute.code].required = False
286 309
 
287 310
     def get_attribute_field(self, attribute):
288 311
         """
@@ -295,7 +318,7 @@ class ProductForm(SEOFormMixin, forms.ModelForm):
295 318
         Deletes any fields not needed for child products. Override this if
296 319
         you want to e.g. keep the description field.
297 320
         """
298
-        for field_name in ['description', 'is_discountable']:
321
+        for field_name in ["description", "is_discountable"]:
299 322
             if field_name in self.fields:
300 323
                 del self.fields[field_name]
301 324
 
@@ -305,7 +328,7 @@ class ProductForm(SEOFormMixin, forms.ModelForm):
305 328
         (which it does in _post_clean), which in turn validates attributes.
306 329
         """
307 330
         for attribute in self.instance.attr.get_all_attributes():
308
-            field_name = 'attr_%s' % attribute.code
331
+            field_name = "attr_%s" % attribute.code
309 332
             # An empty text field won't show up in cleaned_data.
310 333
             if field_name in self.cleaned_data:
311 334
                 value = self.cleaned_data[field_name]
@@ -314,67 +337,64 @@ class ProductForm(SEOFormMixin, forms.ModelForm):
314 337
 
315 338
 
316 339
 class StockAlertSearchForm(forms.Form):
317
-    status = forms.CharField(label=_('Status'))
340
+    status = forms.CharField(label=_("Status"))
318 341
 
319 342
 
320 343
 class ProductCategoryForm(forms.ModelForm):
321
-
322 344
     class Meta:
323 345
         model = ProductCategory
324
-        fields = ('category', )
346
+        fields = ("category",)
325 347
 
326 348
 
327 349
 class ProductImageForm(forms.ModelForm):
328
-
329 350
     class Meta:
330 351
         model = ProductImage
331
-        fields = ['product', 'original', 'caption', 'display_order']
352
+        fields = ["product", "original", "caption", "display_order"]
332 353
         # use ImageInput widget to create HTML displaying the
333 354
         # actual uploaded image and providing the upload dialog
334 355
         # when clicking on the actual image.
335 356
         widgets = {
336
-            'original': ImageInput(),
337
-            'display_order': forms.HiddenInput(),
357
+            "original": ImageInput(),
358
+            "display_order": forms.HiddenInput(),
338 359
         }
339 360
 
340
-    def __init__(self, data=None, *args, **kwargs):
341
-        self.prefix = kwargs.get('prefix', None)
342
-        instance = kwargs.get('instance', None)
361
+    def __init__(self, *args, data=None, **kwargs):
362
+        self.prefix = kwargs.get("prefix", None)
363
+        instance = kwargs.get("instance", None)
343 364
         if not instance:
344
-            initial = {'display_order': self.get_display_order()}
345
-            initial.update(kwargs.get('initial', {}))
346
-            kwargs['initial'] = initial
365
+            initial = {"display_order": self.get_display_order()}
366
+            initial.update(kwargs.get("initial", {}))
367
+            kwargs["initial"] = initial
347 368
         super().__init__(data, *args, **kwargs)
348 369
 
349 370
     def get_display_order(self):
350
-        return int(self.prefix.split('-').pop())
371
+        return int(self.prefix.split("-").pop())
351 372
 
352 373
 
353 374
 class ProductRecommendationForm(forms.ModelForm):
354
-
355 375
     class Meta:
356 376
         model = ProductRecommendation
357
-        fields = ['primary', 'recommendation', 'ranking']
377
+        fields = ["primary", "recommendation", "ranking"]
358 378
         widgets = {
359
-            'recommendation': ProductSelect,
379
+            "recommendation": ProductSelect,
360 380
         }
361 381
 
362 382
 
363 383
 class ProductClassForm(forms.ModelForm):
364
-
365 384
     def __init__(self, *args, **kwargs):
366 385
         super().__init__(*args, **kwargs)
367
-        remote_field = self._meta.model._meta.get_field('options').remote_field
386
+        # pylint: disable=no-member
387
+        remote_field = self._meta.model._meta.get_field("options").remote_field
368 388
         self.fields["options"].widget = RelatedMultipleFieldWidgetWrapper(
369
-            self.fields["options"].widget, remote_field)
389
+            self.fields["options"].widget, remote_field
390
+        )
370 391
 
371 392
     class Meta:
372 393
         model = ProductClass
373
-        fields = ['name', 'requires_shipping', 'track_stock', 'options']
394
+        fields = ["name", "requires_shipping", "track_stock", "options"]
374 395
 
375 396
 
376 397
 class ProductAttributesForm(forms.ModelForm):
377
-
378 398
     def __init__(self, *args, **kwargs):
379 399
         super().__init__(*args, **kwargs)
380 400
 
@@ -384,9 +404,11 @@ class ProductAttributesForm(forms.ModelForm):
384 404
 
385 405
         self.fields["option_group"].help_text = _("Select an option group")
386 406
 
387
-        remote_field = self._meta.model._meta.get_field('option_group').remote_field
407
+        # pylint: disable=no-member
408
+        remote_field = self._meta.model._meta.get_field("option_group").remote_field
388 409
         self.fields["option_group"].widget = RelatedFieldWidgetWrapper(
389
-            self.fields["option_group"].widget, remote_field)
410
+            self.fields["option_group"].widget, remote_field
411
+        )
390 412
 
391 413
     def clean_code(self):
392 414
         code = self.cleaned_data.get("code")
@@ -398,10 +420,13 @@ class ProductAttributesForm(forms.ModelForm):
398 420
         return code
399 421
 
400 422
     def clean(self):
401
-        attr_type = self.cleaned_data.get('type')
402
-        option_group = self.cleaned_data.get('option_group')
403
-        if attr_type in [ProductAttribute.OPTION, ProductAttribute.MULTI_OPTION] and not option_group:
404
-            self.add_error('option_group', _('An option group is required'))
423
+        attr_type = self.cleaned_data.get("type")
424
+        option_group = self.cleaned_data.get("option_group")
425
+        if (
426
+            attr_type in [ProductAttribute.OPTION, ProductAttribute.MULTI_OPTION]
427
+            and not option_group
428
+        ):
429
+            self.add_error("option_group", _("An option group is required"))
405 430
 
406 431
     class Meta:
407 432
         model = ProductAttribute
@@ -409,21 +434,18 @@ class ProductAttributesForm(forms.ModelForm):
409 434
 
410 435
 
411 436
 class AttributeOptionGroupForm(forms.ModelForm):
412
-
413 437
     class Meta:
414 438
         model = AttributeOptionGroup
415
-        fields = ['name']
439
+        fields = ["name"]
416 440
 
417 441
 
418 442
 class AttributeOptionForm(forms.ModelForm):
419
-
420 443
     class Meta:
421 444
         model = AttributeOption
422
-        fields = ['option']
445
+        fields = ["option"]
423 446
 
424 447
 
425 448
 class OptionForm(forms.ModelForm):
426
-
427 449
     class Meta:
428 450
         model = Option
429
-        fields = ['name', 'type', 'required', 'order', 'help_text', 'option_group']
451
+        fields = ["name", "type", "required", "order", "help_text", "option_group"]

+ 78
- 66
src/oscar/apps/dashboard/catalogue/formsets.py View File

@@ -5,49 +5,55 @@ from django.utils.translation import gettext_lazy as _
5 5
 
6 6
 from oscar.core.loading import get_classes, get_model
7 7
 
8
-Product = get_model('catalogue', 'Product')
9
-ProductClass = get_model('catalogue', 'ProductClass')
10
-ProductAttribute = get_model('catalogue', 'ProductAttribute')
11
-StockRecord = get_model('partner', 'StockRecord')
12
-ProductCategory = get_model('catalogue', 'ProductCategory')
13
-ProductImage = get_model('catalogue', 'ProductImage')
14
-ProductRecommendation = get_model('catalogue', 'ProductRecommendation')
15
-AttributeOptionGroup = get_model('catalogue', 'AttributeOptionGroup')
16
-AttributeOption = get_model('catalogue', 'AttributeOption')
17
-
18
-(StockRecordForm,
19
- ProductCategoryForm,
20
- ProductImageForm,
21
- ProductRecommendationForm,
22
- ProductAttributesForm,
23
- AttributeOptionForm) = \
24
-    get_classes('dashboard.catalogue.forms',
25
-                ('StockRecordForm',
26
-                 'ProductCategoryForm',
27
-                 'ProductImageForm',
28
-                 'ProductRecommendationForm',
29
-                 'ProductAttributesForm',
30
-                 'AttributeOptionForm'))
8
+Product = get_model("catalogue", "Product")
9
+ProductClass = get_model("catalogue", "ProductClass")
10
+ProductAttribute = get_model("catalogue", "ProductAttribute")
11
+StockRecord = get_model("partner", "StockRecord")
12
+ProductCategory = get_model("catalogue", "ProductCategory")
13
+ProductImage = get_model("catalogue", "ProductImage")
14
+ProductRecommendation = get_model("catalogue", "ProductRecommendation")
15
+AttributeOptionGroup = get_model("catalogue", "AttributeOptionGroup")
16
+AttributeOption = get_model("catalogue", "AttributeOption")
17
+
18
+(
19
+    StockRecordForm,
20
+    ProductCategoryForm,
21
+    ProductImageForm,
22
+    ProductRecommendationForm,
23
+    ProductAttributesForm,
24
+    AttributeOptionForm,
25
+) = get_classes(
26
+    "dashboard.catalogue.forms",
27
+    (
28
+        "StockRecordForm",
29
+        "ProductCategoryForm",
30
+        "ProductImageForm",
31
+        "ProductRecommendationForm",
32
+        "ProductAttributesForm",
33
+        "AttributeOptionForm",
34
+    ),
35
+)
31 36
 
32 37
 
33 38
 BaseStockRecordFormSet = inlineformset_factory(
34
-    Product, StockRecord, form=StockRecordForm, extra=1)
39
+    Product, StockRecord, form=StockRecordForm, extra=1
40
+)
35 41
 
36 42
 
37 43
 class StockRecordFormSet(BaseStockRecordFormSet):
38
-
39 44
     def __init__(self, product_class, user, *args, **kwargs):
40 45
         self.user = user
41 46
         self.require_user_stockrecord = not user.is_staff
42 47
         self.product_class = product_class
43 48
 
44
-        if not user.is_staff and \
45
-           'instance' in kwargs and \
46
-           'queryset' not in kwargs:
47
-            kwargs.update({
48
-                'queryset': StockRecord.objects.filter(product=kwargs['instance'],
49
-                                                       partner__in=user.partners.all())
50
-            })
49
+        if not user.is_staff and "instance" in kwargs and "queryset" not in kwargs:
50
+            kwargs.update(
51
+                {
52
+                    "queryset": StockRecord.objects.filter(
53
+                        product=kwargs["instance"], partner__in=user.partners.all()
54
+                    )
55
+                }
56
+            )
51 57
 
52 58
         super().__init__(*args, **kwargs)
53 59
         self.set_initial_data()
@@ -66,19 +72,17 @@ class StockRecordFormSet(BaseStockRecordFormSet):
66 72
         if self.require_user_stockrecord:
67 73
             try:
68 74
                 user_partner = self.user.partners.get()
69
-            except (exceptions.ObjectDoesNotExist,
70
-                    exceptions.MultipleObjectsReturned):
75
+            except (exceptions.ObjectDoesNotExist, exceptions.MultipleObjectsReturned):
71 76
                 pass
72 77
             else:
73
-                partner_field = self.forms[0].fields.get('partner', None)
78
+                partner_field = self.forms[0].fields.get("partner", None)
74 79
                 if partner_field and partner_field.initial is None:
75 80
                     partner_field.initial = user_partner
76 81
 
77 82
     def _construct_form(self, i, **kwargs):
78
-        kwargs['product_class'] = self.product_class
79
-        kwargs['user'] = self.user
80
-        return super()._construct_form(
81
-            i, **kwargs)
83
+        kwargs["product_class"] = self.product_class
84
+        kwargs["user"] = self.user
85
+        return super()._construct_form(i, **kwargs)
82 86
 
83 87
     def clean(self):
84 88
         """
@@ -88,22 +92,26 @@ class StockRecordFormSet(BaseStockRecordFormSet):
88 92
         if any(self.errors):
89 93
             return
90 94
         if self.require_user_stockrecord:
91
-            stockrecord_partners = set([form.cleaned_data.get('partner', None)
92
-                                        for form in self.forms])
95
+            stockrecord_partners = set(
96
+                [form.cleaned_data.get("partner", None) for form in self.forms]
97
+            )
93 98
             user_partners = set(self.user.partners.all())
94 99
             if not user_partners & stockrecord_partners:
95 100
                 raise exceptions.ValidationError(
96
-                    _("At least one stock record must be set to a partner that"
97
-                      " you're associated with."))
101
+                    _(
102
+                        "At least one stock record must be set to a partner that"
103
+                        " you're associated with."
104
+                    )
105
+                )
98 106
 
99 107
 
100 108
 BaseProductCategoryFormSet = inlineformset_factory(
101
-    Product, ProductCategory, form=ProductCategoryForm, extra=1,
102
-    can_delete=True)
109
+    Product, ProductCategory, form=ProductCategoryForm, extra=1, can_delete=True
110
+)
103 111
 
104 112
 
105 113
 class ProductCategoryFormSet(BaseProductCategoryFormSet):
106
-
114
+    # pylint: disable=unused-argument
107 115
     def __init__(self, product_class, user, *args, **kwargs):
108 116
         # This function just exists to drop the extra arguments
109 117
         super().__init__(*args, **kwargs)
@@ -111,51 +119,55 @@ class ProductCategoryFormSet(BaseProductCategoryFormSet):
111 119
     def clean(self):
112 120
         if not self.instance.is_child and self.get_num_categories() == 0:
113 121
             raise forms.ValidationError(
114
-                _("Stand-alone and parent products "
115
-                  "must have at least one category"))
122
+                _("Stand-alone and parent products must have at least one category")
123
+            )
116 124
         if self.instance.is_child and self.get_num_categories() > 0:
117
-            raise forms.ValidationError(
118
-                _("A child product should not have categories"))
125
+            raise forms.ValidationError(_("A child product should not have categories"))
119 126
 
120 127
     def get_num_categories(self):
121 128
         num_categories = 0
122 129
         for i in range(0, self.total_form_count()):
123 130
             form = self.forms[i]
124
-            if (hasattr(form, 'cleaned_data')
125
-                    and form.cleaned_data.get('category', None)
126
-                    and not form.cleaned_data.get('DELETE', False)):
131
+            if (
132
+                hasattr(form, "cleaned_data")
133
+                and form.cleaned_data.get("category", None)
134
+                and not form.cleaned_data.get("DELETE", False)
135
+            ):
127 136
                 num_categories += 1
128 137
         return num_categories
129 138
 
130 139
 
131 140
 BaseProductImageFormSet = inlineformset_factory(
132
-    Product, ProductImage, form=ProductImageForm, extra=2)
141
+    Product, ProductImage, form=ProductImageForm, extra=2
142
+)
133 143
 
134 144
 
135 145
 class ProductImageFormSet(BaseProductImageFormSet):
136
-
146
+    # pylint: disable=unused-argument
137 147
     def __init__(self, product_class, user, *args, **kwargs):
138 148
         super().__init__(*args, **kwargs)
139 149
 
140 150
 
141 151
 BaseProductRecommendationFormSet = inlineformset_factory(
142
-    Product, ProductRecommendation, form=ProductRecommendationForm,
143
-    extra=5, fk_name="primary")
152
+    Product,
153
+    ProductRecommendation,
154
+    form=ProductRecommendationForm,
155
+    extra=5,
156
+    fk_name="primary",
157
+)
144 158
 
145 159
 
146 160
 class ProductRecommendationFormSet(BaseProductRecommendationFormSet):
147
-
161
+    # pylint: disable=unused-argument
148 162
     def __init__(self, product_class, user, *args, **kwargs):
149 163
         super().__init__(*args, **kwargs)
150 164
 
151 165
 
152
-ProductAttributesFormSet = inlineformset_factory(ProductClass,
153
-                                                 ProductAttribute,
154
-                                                 form=ProductAttributesForm,
155
-                                                 extra=3)
166
+ProductAttributesFormSet = inlineformset_factory(
167
+    ProductClass, ProductAttribute, form=ProductAttributesForm, extra=3
168
+)
156 169
 
157 170
 
158
-AttributeOptionFormSet = inlineformset_factory(AttributeOptionGroup,
159
-                                               AttributeOption,
160
-                                               form=AttributeOptionForm,
161
-                                               extra=3)
171
+AttributeOptionFormSet = inlineformset_factory(
172
+    AttributeOptionGroup, AttributeOption, form=AttributeOptionForm, extra=3
173
+)

+ 2
- 1
src/oscar/apps/dashboard/catalogue/mixins.py View File

@@ -14,5 +14,6 @@ class PartnerProductFilterMixin:
14 14
             return queryset
15 15
 
16 16
         return queryset.filter(
17
-            Q(children__stockrecords__partner__users__pk=user.pk) | Q(stockrecords__partner__users__pk=user.pk)
17
+            Q(children__stockrecords__partner__users__pk=user.pk)
18
+            | Q(stockrecords__partner__users__pk=user.pk)
18 19
         ).distinct()

+ 81
- 56
src/oscar/apps/dashboard/catalogue/tables.py View File

@@ -6,114 +6,139 @@ from django_tables2 import A, Column, LinkColumn, TemplateColumn
6 6
 
7 7
 from oscar.core.loading import get_class, get_model
8 8
 
9
-DashboardTable = get_class('dashboard.tables', 'DashboardTable')
10
-Product = get_model('catalogue', 'Product')
11
-Category = get_model('catalogue', 'Category')
12
-AttributeOptionGroup = get_model('catalogue', 'AttributeOptionGroup')
13
-Option = get_model('catalogue', 'Option')
9
+DashboardTable = get_class("dashboard.tables", "DashboardTable")
10
+Product = get_model("catalogue", "Product")
11
+Category = get_model("catalogue", "Category")
12
+AttributeOptionGroup = get_model("catalogue", "AttributeOptionGroup")
13
+Option = get_model("catalogue", "Option")
14 14
 
15 15
 
16 16
 class ProductTable(DashboardTable):
17 17
     title = TemplateColumn(
18
-        verbose_name=_('Title'),
19
-        template_name='oscar/dashboard/catalogue/product_row_title.html',
20
-        order_by='title', accessor=A('title'))
18
+        verbose_name=_("Title"),
19
+        template_name="oscar/dashboard/catalogue/product_row_title.html",
20
+        order_by="title",
21
+        accessor=A("title"),
22
+    )
21 23
     image = TemplateColumn(
22
-        verbose_name=_('Image'),
23
-        template_name='oscar/dashboard/catalogue/product_row_image.html',
24
-        orderable=False)
24
+        verbose_name=_("Image"),
25
+        template_name="oscar/dashboard/catalogue/product_row_image.html",
26
+        orderable=False,
27
+    )
25 28
     product_class = Column(
26
-        verbose_name=_('Product type'),
27
-        accessor=A('product_class'),
28
-        order_by='product_class__name')
29
+        verbose_name=_("Product type"),
30
+        accessor=A("product_class"),
31
+        order_by="product_class__name",
32
+    )
29 33
     variants = TemplateColumn(
30 34
         verbose_name=_("Variants"),
31
-        template_name='oscar/dashboard/catalogue/product_row_variants.html',
32
-        orderable=False
35
+        template_name="oscar/dashboard/catalogue/product_row_variants.html",
36
+        orderable=False,
33 37
     )
34 38
     stock_records = TemplateColumn(
35
-        verbose_name=_('Stock records'),
36
-        template_name='oscar/dashboard/catalogue/product_row_stockrecords.html',
37
-        orderable=False)
39
+        verbose_name=_("Stock records"),
40
+        template_name="oscar/dashboard/catalogue/product_row_stockrecords.html",
41
+        orderable=False,
42
+    )
38 43
     actions = TemplateColumn(
39
-        verbose_name=_('Actions'),
40
-        template_name='oscar/dashboard/catalogue/product_row_actions.html',
41
-        orderable=False)
44
+        verbose_name=_("Actions"),
45
+        template_name="oscar/dashboard/catalogue/product_row_actions.html",
46
+        orderable=False,
47
+    )
42 48
 
43
-    icon = 'fas fa-sitemap'
49
+    icon = "fas fa-sitemap"
44 50
 
45 51
     class Meta(DashboardTable.Meta):
46 52
         model = Product
47
-        fields = ('upc', 'is_public', 'date_updated')
48
-        sequence = ('title', 'upc', 'image', 'product_class', 'variants',
49
-                    'stock_records', '...', 'is_public', 'date_updated', 'actions')
50
-        order_by = '-date_updated'
53
+        fields = ("upc", "is_public", "date_updated")
54
+        sequence = (
55
+            "title",
56
+            "upc",
57
+            "image",
58
+            "product_class",
59
+            "variants",
60
+            "stock_records",
61
+            "...",
62
+            "is_public",
63
+            "date_updated",
64
+            "actions",
65
+        )
66
+        order_by = "-date_updated"
51 67
 
52 68
 
53 69
 class CategoryTable(DashboardTable):
54
-    name = LinkColumn('dashboard:catalogue-category-update', args=[A('pk')])
70
+    name = LinkColumn("dashboard:catalogue-category-update", args=[A("pk")])
55 71
     description = TemplateColumn(
56 72
         template_code='{{ record.description|default:""|striptags'
57
-                      '|cut:"&nbsp;"|truncatewords:6 }}')
73
+        '|cut:"&nbsp;"|truncatewords:6 }}'
74
+    )
58 75
     # mark_safe is needed because of
59 76
     # https://github.com/bradleyayers/django-tables2/issues/187
60 77
     num_children = LinkColumn(
61
-        'dashboard:catalogue-category-detail-list', args=[A('pk')],
62
-        verbose_name=mark_safe(_('Number of child categories')),
63
-        accessor='get_num_children',
64
-        orderable=False)
78
+        "dashboard:catalogue-category-detail-list",
79
+        args=[A("pk")],
80
+        verbose_name=mark_safe(_("Number of child categories")),
81
+        accessor="get_num_children",
82
+        orderable=False,
83
+    )
65 84
     actions = TemplateColumn(
66
-        template_name='oscar/dashboard/catalogue/category_row_actions.html',
67
-        orderable=False)
85
+        template_name="oscar/dashboard/catalogue/category_row_actions.html",
86
+        orderable=False,
87
+    )
68 88
 
69 89
     icon = "sitemap"
70 90
     caption = ngettext_lazy("%s Category", "%s Categories")
71 91
 
72 92
     class Meta(DashboardTable.Meta):
73 93
         model = Category
74
-        fields = ('name', 'description', 'is_public')
75
-        sequence = ('name', 'description', '...', 'is_public', 'actions')
94
+        fields = ("name", "description", "is_public")
95
+        sequence = ("name", "description", "...", "is_public", "actions")
76 96
 
77 97
 
78 98
 class AttributeOptionGroupTable(DashboardTable):
79 99
     name = TemplateColumn(
80
-        verbose_name=_('Name'),
81
-        template_name='oscar/dashboard/catalogue/attribute_option_group_row_name.html',
82
-        order_by='name')
100
+        verbose_name=_("Name"),
101
+        template_name="oscar/dashboard/catalogue/attribute_option_group_row_name.html",
102
+        order_by="name",
103
+    )
83 104
     option_summary = TemplateColumn(
84
-        verbose_name=_('Option summary'),
85
-        template_name='oscar/dashboard/catalogue/attribute_option_group_row_option_summary.html',
86
-        orderable=False)
105
+        verbose_name=_("Option summary"),
106
+        template_name="oscar/dashboard/catalogue/attribute_option_group_row_option_summary.html",
107
+        orderable=False,
108
+    )
87 109
     actions = TemplateColumn(
88
-        verbose_name=_('Actions'),
89
-        template_name='oscar/dashboard/catalogue/attribute_option_group_row_actions.html',
90
-        orderable=False)
110
+        verbose_name=_("Actions"),
111
+        template_name="oscar/dashboard/catalogue/attribute_option_group_row_actions.html",
112
+        orderable=False,
113
+    )
91 114
 
92 115
     icon = "sitemap"
93 116
     caption = ngettext_lazy("%s Attribute Option Group", "%s Attribute Option Groups")
94 117
 
95 118
     class Meta(DashboardTable.Meta):
96 119
         model = AttributeOptionGroup
97
-        fields = ('name',)
98
-        sequence = ('name', 'option_summary', 'actions')
120
+        fields = ("name",)
121
+        sequence = ("name", "option_summary", "actions")
99 122
         per_page = settings.OSCAR_DASHBOARD_ITEMS_PER_PAGE
100 123
 
101 124
 
102 125
 class OptionTable(DashboardTable):
103 126
     name = TemplateColumn(
104
-        verbose_name=_('Name'),
105
-        template_name='oscar/dashboard/catalogue/option_row_name.html',
106
-        order_by='name')
127
+        verbose_name=_("Name"),
128
+        template_name="oscar/dashboard/catalogue/option_row_name.html",
129
+        order_by="name",
130
+    )
107 131
     actions = TemplateColumn(
108
-        verbose_name=_('Actions'),
109
-        template_name='oscar/dashboard/catalogue/option_row_actions.html',
110
-        orderable=False)
132
+        verbose_name=_("Actions"),
133
+        template_name="oscar/dashboard/catalogue/option_row_actions.html",
134
+        orderable=False,
135
+    )
111 136
 
112 137
     icon = "reorder"
113 138
     caption = ngettext_lazy("%s Option", "%s Options")
114 139
 
115 140
     class Meta(DashboardTable.Meta):
116 141
         model = Option
117
-        fields = ('name', 'type', 'required')
118
-        sequence = ('name', 'type', 'required', 'actions')
142
+        fields = ("name", "type", "required")
143
+        sequence = ("name", "type", "required", "actions")
119 144
         per_page = settings.OSCAR_DASHBOARD_ITEMS_PER_PAGE

+ 0
- 0
src/oscar/apps/dashboard/catalogue/views.py View File


Some files were not shown because too many files changed in this diff

Loading…
Cancel
Save