├── tests ├── __init__.py ├── _site │ ├── __init__.py │ └── apps │ │ ├── __init__.py │ │ └── dashboard │ │ ├── __init__.py │ │ └── apps.py ├── factories │ ├── __init__.py │ ├── catalogue.py │ └── oscar_promotions.py ├── functional │ ├── __init__.py │ ├── test_promotions.py │ └── test_dashboard.py ├── integration │ ├── __init__.py │ ├── test_dashboard_forms.py │ └── test_models.py ├── conftest.py ├── urls.py └── settings.py ├── oscar_promotions ├── migrations │ ├── __init__.py │ └── 0001_initial.py ├── templatetags │ ├── __init__.py │ └── promotion_tags.py ├── __init__.py ├── dashboard │ ├── __init__.py │ ├── formsets.py │ ├── forms.py │ ├── apps.py │ └── views.py ├── templates │ └── oscar_promotions │ │ ├── automaticproductlist.html │ │ ├── handpickedproductlist.html │ │ ├── rawhtml.html │ │ ├── default.html │ │ ├── image.html │ │ ├── home.html │ │ ├── dashboard │ │ ├── handpickedproductlist_form.html │ │ ├── delete_pagepromotion.html │ │ ├── delete.html │ │ ├── pagepromotion_list.html │ │ ├── promotion_list.html │ │ ├── page_detail.html │ │ └── form.html │ │ ├── baseproductlist.html │ │ ├── singleproduct.html │ │ └── multiimage.html ├── app_settings.py ├── layout.py ├── conf.py ├── views.py ├── static │ └── styles.css ├── apps.py ├── admin.py ├── context_processors.py └── models.py ├── .coveragerc ├── MANIFEST.in ├── .gitignore ├── Makefile ├── sandbox ├── wsgi.py ├── templates │ └── oscar │ │ ├── layout.html │ │ ├── layout_2_col.html │ │ └── layout_3_col.html ├── manage.py ├── urls.py ├── fixtures │ └── promotions.json └── settings.py ├── requirements.txt ├── setup.cfg ├── .github ├── dependabot.yml └── workflows │ └── test.yml ├── tox.ini ├── setup.py └── README.rst /tests/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tests/_site/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tests/_site/apps/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tests/factories/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tests/functional/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tests/integration/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /oscar_promotions/migrations/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /oscar_promotions/templatetags/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /.coveragerc: -------------------------------------------------------------------------------- 1 | [run] 2 | source = oscar_promotions 3 | omit = *migrations* 4 | -------------------------------------------------------------------------------- /tests/_site/apps/dashboard/__init__.py: -------------------------------------------------------------------------------- 1 | default_app_config = "tests._site.apps.dashboard.apps.DashboardConfig" 2 | -------------------------------------------------------------------------------- /oscar_promotions/__init__.py: -------------------------------------------------------------------------------- 1 | default_app_config = 'oscar_promotions.apps.PromotionsConfig' 2 | VERSION = '1.0.0b1' 3 | -------------------------------------------------------------------------------- /oscar_promotions/dashboard/__init__.py: -------------------------------------------------------------------------------- 1 | default_app_config = 'oscar_promotions.dashboard.apps.PromotionsDashboardConfig' 2 | -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | include *.rst 2 | recursive-include oscar_promotions/templates *.html 3 | recursive-include oscar_promotions/static *.css 4 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *.pyc 2 | db.sqlite3 3 | 4 | media/ 5 | 6 | # PyCharm files 7 | .idea/ 8 | 9 | .pytest_cache/ 10 | build/ 11 | dist/ 12 | *.egg-info 13 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | test: lint run-tests 2 | 3 | isort: 4 | isort -q -c --recursive --diff oscar_promotions tests setup.py 5 | flake8 6 | 7 | run-tests: 8 | pytest 9 | -------------------------------------------------------------------------------- /oscar_promotions/templates/oscar_promotions/automaticproductlist.html: -------------------------------------------------------------------------------- 1 | {% extends 'oscar_promotions/baseproductlist.html' %} 2 | 3 | {# just exists to allow overriding #} 4 | -------------------------------------------------------------------------------- /oscar_promotions/templates/oscar_promotions/handpickedproductlist.html: -------------------------------------------------------------------------------- 1 | {% extends 'oscar_promotions/baseproductlist.html' %} 2 | 3 | {# just exists to allow overriding #} 4 | -------------------------------------------------------------------------------- /tests/_site/apps/dashboard/apps.py: -------------------------------------------------------------------------------- 1 | from oscar.apps.dashboard.apps import DashboardConfig as OscarDashboardConfig 2 | 3 | 4 | class DashboardConfig(OscarDashboardConfig): 5 | pass 6 | -------------------------------------------------------------------------------- /tests/conftest.py: -------------------------------------------------------------------------------- 1 | import os 2 | 3 | import django 4 | 5 | 6 | def pytest_configure(config): 7 | os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'tests.settings') 8 | django.setup() 9 | -------------------------------------------------------------------------------- /sandbox/wsgi.py: -------------------------------------------------------------------------------- 1 | import os 2 | 3 | from django.core.wsgi import get_wsgi_application 4 | 5 | os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'settings') 6 | 7 | application = get_wsgi_application() 8 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | django-oscar>=2.0,<2.3 2 | coverage 3 | django-webtest==1.9.10 4 | pytest-django>=3.7,<4.5 5 | tox>=2.9,<3.26 6 | sorl-thumbnail>=12.4.1,<12.10 7 | 8 | # Development 9 | flake8 10 | isort -------------------------------------------------------------------------------- /oscar_promotions/templates/oscar_promotions/rawhtml.html: -------------------------------------------------------------------------------- 1 |
2 |
3 |

{{ promotion.name }}

4 |
5 | {{ promotion.body|safe }} 6 |
7 | -------------------------------------------------------------------------------- /tests/functional/test_promotions.py: -------------------------------------------------------------------------------- 1 | from django.urls import reverse 2 | from oscar.test.testcases import WebTestCase 3 | 4 | 5 | class PromotionViewsTests(WebTestCase): 6 | def test_pages_exist(self): 7 | urls = [reverse('oscar_promotions:home')] 8 | for url in urls: 9 | self.assertIsOk(self.get(url)) 10 | -------------------------------------------------------------------------------- /setup.cfg: -------------------------------------------------------------------------------- 1 | [tool:pytest] 2 | testpaths = tests/ 3 | addopts = --nomigrations 4 | 5 | [flake8] 6 | exclude = migrations 7 | max-complexity = 6 8 | max-line-length=119 9 | 10 | [isort] 11 | line_length = 100 12 | multi_line_output = 3 13 | include_trailing_comma = True 14 | force_grid_wrap = 0 15 | combine_as_imports = True 16 | skip = migrations 17 | -------------------------------------------------------------------------------- /oscar_promotions/templates/oscar_promotions/default.html: -------------------------------------------------------------------------------- 1 | {% load currency_filters %} 2 | {% load product_tags %} 3 | {% load i18n %} 4 | 5 |

{{ block.title }}

6 | 7 | {{ block.description|safe }} 8 | 9 |
    10 | {% for product in block.products.all %} 11 |
  1. {% render_product product %}
  2. 12 | {% endfor %} 13 |
14 | -------------------------------------------------------------------------------- /oscar_promotions/templates/oscar_promotions/image.html: -------------------------------------------------------------------------------- 1 | {% if promotion.image %} 2 | {% if promotion.link_url %} 3 | 4 | {% else %} 5 | 6 | {% endif %} 7 | {% endif %} 8 | 9 | -------------------------------------------------------------------------------- /oscar_promotions/templates/oscar_promotions/home.html: -------------------------------------------------------------------------------- 1 | {% extends "oscar/layout_2_col.html" %} 2 | {% load i18n %} 3 | {% load promotion_tags %} 4 | 5 | {% block navigation %} 6 | {% include "oscar/partials/nav_primary.html" with expand_dropdown=1 %} 7 | {% endblock %} 8 | 9 | {% block header %}{% endblock %} 10 | 11 | {% block column_left %} 12 | {% endblock %} 13 | 14 | {% block content %} 15 | {% endblock content %} 16 | -------------------------------------------------------------------------------- /oscar_promotions/templates/oscar_promotions/dashboard/handpickedproductlist_form.html: -------------------------------------------------------------------------------- 1 | {% extends 'oscar_promotions/dashboard/form.html' %} 2 | {% load i18n %} 3 | 4 | {% block inlines %} 5 |

{% trans "Products" %}

6 | {{ product_formset.management_form }} 7 | {% for form in product_formset %} 8 | {% include "oscar/dashboard/partials/form_fields.html" with form=form %} 9 | {% endfor %} 10 | {% endblock %} 11 | -------------------------------------------------------------------------------- /oscar_promotions/app_settings.py: -------------------------------------------------------------------------------- 1 | from django.conf import settings 2 | 3 | OSCAR_PROMOTIONS_FOLDER = getattr(settings, 'OSCAR_PROMOTIONS_FOLDER', 'images/promotions/') 4 | 5 | OSCAR_PROMOTIONS_POSITIONS = getattr(settings, 'OSCAR_PROMOTIONS_POSITIONS', (('page', 'Page'), 6 | ('right', 'Right-hand sidebar'), 7 | ('left', 'Left-hand sidebar'))) 8 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | - package-ecosystem: pip 4 | directory: "/" 5 | schedule: 6 | interval: monthly 7 | time: "11:00" 8 | open-pull-requests-limit: 10 9 | ignore: 10 | - dependency-name: django 11 | versions: 12 | - ">= 3.a, < 4" 13 | - dependency-name: tox 14 | versions: 15 | - 3.21.3 16 | - dependency-name: coverage 17 | versions: 18 | - 5.3.1 19 | - "5.4" 20 | - dependency-name: django-oscar 21 | versions: 22 | - "2.1" 23 | -------------------------------------------------------------------------------- /oscar_promotions/layout.py: -------------------------------------------------------------------------------- 1 | def split_by_position(linked_promotions, context): 2 | """ 3 | Split the list of promotions into separate lists, grouping 4 | by position, and write these lists to the passed context. 5 | """ 6 | for linked_promotion in linked_promotions: 7 | promotion = linked_promotion.content_object 8 | if not promotion: 9 | continue 10 | key = 'promotions_%s' % linked_promotion.position.lower() 11 | if key not in context: 12 | context[key] = [] 13 | context[key].append(promotion) 14 | -------------------------------------------------------------------------------- /oscar_promotions/dashboard/formsets.py: -------------------------------------------------------------------------------- 1 | from django.forms.models import inlineformset_factory 2 | from oscar.core.loading import get_class, get_model 3 | 4 | HandPickedProductList = get_model('oscar_promotions', 'HandPickedProductList') 5 | OrderedProduct = get_model('oscar_promotions', 'OrderedProduct') 6 | OrderedProductForm = get_class( 7 | 'oscar_promotions.dashboard.forms', 'OrderedProductForm', module_prefix='oscar_promotions' 8 | ) 9 | 10 | OrderedProductFormSet = inlineformset_factory( 11 | HandPickedProductList, OrderedProduct, form=OrderedProductForm, extra=2) 12 | -------------------------------------------------------------------------------- /sandbox/templates/oscar/layout.html: -------------------------------------------------------------------------------- 1 | {% extends "oscar/layout.html" %} 2 | {% load promotion_tags %} 3 | 4 | {% block content_wrapper %} 5 |
6 |
7 |
8 |
9 | {% for promotion in promotions_page %} 10 | {% render_promotion promotion %} 11 | {% endfor %} 12 |
13 |
14 |
15 |
16 | {{ block.super }} 17 | {% endblock content_wrapper %} -------------------------------------------------------------------------------- /sandbox/manage.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | import os 3 | import sys 4 | 5 | if __name__ == "__main__": 6 | os.environ.setdefault("DJANGO_SETTINGS_MODULE", "settings") 7 | try: 8 | from django.core.management import execute_from_command_line 9 | except ImportError as exc: 10 | raise ImportError( 11 | "Couldn't import Django. Are you sure it's installed and " 12 | "available on your PYTHONPATH environment variable? Did you " 13 | "forget to activate a virtual environment?" 14 | ) from exc 15 | execute_from_command_line(sys.argv) 16 | -------------------------------------------------------------------------------- /sandbox/templates/oscar/layout_2_col.html: -------------------------------------------------------------------------------- 1 | {% extends "oscar/layout_2_col.html" %} 2 | {% load promotion_tags %} 3 | 4 | {% block content_wrapper %} 5 |
6 |
7 |
8 |
9 | {% for promotion in promotions_page %} 10 | {% render_promotion promotion %} 11 | {% endfor %} 12 |
13 |
14 |
15 |
16 | {{ block.super }} 17 | {% endblock content_wrapper %} 18 | -------------------------------------------------------------------------------- /sandbox/templates/oscar/layout_3_col.html: -------------------------------------------------------------------------------- 1 | {% extends "oscar/layout_3_col.html" %} 2 | {% load promotion_tags %} 3 | 4 | {% block content_wrapper %} 5 |
6 |
7 |
8 |
9 | {% for promotion in promotions_page %} 10 | {% render_promotion promotion %} 11 | {% endfor %} 12 |
13 |
14 |
15 |
16 | {{ block.super }} 17 | {% endblock content_wrapper %} 18 | -------------------------------------------------------------------------------- /tox.ini: -------------------------------------------------------------------------------- 1 | [tox] 2 | envlist = py{36,37}-django{22} 3 | 4 | [testenv] 5 | commands = coverage run --parallel -m pytest {posargs} 6 | deps = 7 | -r{toxinidir}/requirements.txt 8 | django22: django>=2.2,<2.3 9 | 10 | [testenv:lint] 11 | basepython = python3.6 12 | deps = 13 | flake8 14 | isort 15 | commands = 16 | flake8 oscar_promotions tests setup.py 17 | isort -q -c --recursive --diff oscar_promotions tests setup.py 18 | 19 | [testenv:coverage-report] 20 | basepython = python3.6 21 | deps = coverage 22 | skip_install = true 23 | commands = 24 | coverage combine 25 | coverage report 26 | -------------------------------------------------------------------------------- /oscar_promotions/templatetags/promotion_tags.py: -------------------------------------------------------------------------------- 1 | from django.template import Library 2 | from django.template.loader import select_template 3 | 4 | register = Library() 5 | 6 | 7 | @register.simple_tag(takes_context=True) 8 | def render_promotion(context, promotion): 9 | template = select_template([ 10 | promotion.template_name(), 'oscar_promotions/default.html']) 11 | request = context['request'] 12 | 13 | ctx = { 14 | 'request': request, 15 | 'promotion': promotion 16 | } 17 | ctx.update(**promotion.template_context(request=request)) 18 | 19 | return template.render(ctx, request) 20 | -------------------------------------------------------------------------------- /oscar_promotions/templates/oscar_promotions/baseproductlist.html: -------------------------------------------------------------------------------- 1 | {% load i18n %} 2 | {% load product_tags %} 3 | 4 | {% block content %} 5 |
6 |
7 |

{{ promotion.name }}

8 |
9 | 10 | {{ promotion.description|safe }} 11 | 12 | 17 | 18 | {% if block.link_url %} 19 | {% trans "See more" %} 20 | {% endif %} 21 |
22 | {% endblock %} 23 | -------------------------------------------------------------------------------- /tests/factories/catalogue.py: -------------------------------------------------------------------------------- 1 | import factory 2 | from oscar.core.loading import get_model 3 | 4 | Product = get_model('catalogue', 'Product') 5 | ProductClass = get_model('catalogue', 'ProductClass') 6 | 7 | 8 | class ProductClassFactory(factory.django.DjangoModelFactory): 9 | class Meta: 10 | model = ProductClass 11 | 12 | name = factory.Faker('sentence') 13 | 14 | 15 | class ProductFactory(factory.django.DjangoModelFactory): 16 | class Meta: 17 | model = Product 18 | 19 | upc = factory.Faker('isbn13') 20 | title = factory.Faker('sentence') 21 | description = factory.Faker('text') 22 | product_class = factory.SubFactory(ProductClassFactory) 23 | -------------------------------------------------------------------------------- /tests/urls.py: -------------------------------------------------------------------------------- 1 | from django.apps import apps 2 | from django.conf import settings 3 | from django.conf.urls import i18n 4 | from django.conf.urls.static import static 5 | from django.contrib import admin 6 | from django.urls import include, path 7 | 8 | urlpatterns = [ 9 | path("admin/", admin.site.urls), 10 | path("i18n/", include(i18n)), 11 | path("", apps.get_app_config("oscar_promotions").urls), 12 | path("dashboard/promotions/", apps.get_app_config("oscar_promotions_dashboard").urls), 13 | path("", include(apps.get_app_config("oscar").urls[0])), 14 | ] 15 | 16 | if settings.DEBUG: 17 | urlpatterns += static(settings.MEDIA_URL, document_root=settings.MEDIA_ROOT) 18 | -------------------------------------------------------------------------------- /sandbox/urls.py: -------------------------------------------------------------------------------- 1 | from django.apps import apps 2 | from django.conf import settings 3 | from django.conf.urls import i18n 4 | from django.conf.urls.static import static 5 | from django.contrib import admin 6 | from django.urls import include, path 7 | 8 | urlpatterns = [ 9 | path("admin/", admin.site.urls), 10 | path("i18n/", include(i18n)), 11 | path("", apps.get_app_config("oscar_promotions").urls), 12 | path("dashboard/promotions/", apps.get_app_config("oscar_promotions_dashboard").urls), 13 | path("", include(apps.get_app_config("oscar").urls[0])), 14 | ] 15 | 16 | if settings.DEBUG: 17 | urlpatterns += static(settings.MEDIA_URL, document_root=settings.MEDIA_ROOT) 18 | -------------------------------------------------------------------------------- /oscar_promotions/conf.py: -------------------------------------------------------------------------------- 1 | from oscar.core.loading import get_model 2 | 3 | SingleProduct = get_model('oscar_promotions', 'SingleProduct') 4 | RawHTML = get_model('oscar_promotions', 'RawHTML') 5 | Image = get_model('oscar_promotions', 'Image') 6 | PagePromotion = get_model('oscar_promotions', 'PagePromotion') 7 | AutomaticProductList = get_model('oscar_promotions', 'AutomaticProductList') 8 | HandPickedProductList = get_model('oscar_promotions', 'HandPickedProductList') 9 | MultiImage = get_model('oscar_promotions', 'MultiImage') 10 | 11 | 12 | def get_promotion_classes(): 13 | return (RawHTML, Image, SingleProduct, AutomaticProductList, 14 | HandPickedProductList, MultiImage) 15 | 16 | 17 | PROMOTION_CLASSES = get_promotion_classes() 18 | -------------------------------------------------------------------------------- /tests/integration/test_dashboard_forms.py: -------------------------------------------------------------------------------- 1 | from django.test import TestCase 2 | from oscar.core.loading import get_model 3 | 4 | from oscar_promotions.dashboard import forms 5 | 6 | RawHTML = get_model('oscar_promotions', 'RawHTML') 7 | PagePromotion = get_model('oscar_promotions', 'PagePromotion') 8 | 9 | 10 | class PagePromotionFormTests(TestCase): 11 | def test_page_promotion_has_fields(self): 12 | promotion = RawHTML() 13 | promotion.save() 14 | instance = PagePromotion(content_object=promotion) 15 | data = {'position': 'page', 'page_url': '/'} 16 | form = forms.PagePromotionForm(data=data, instance=instance) 17 | self.assertTrue(form.is_valid()) 18 | page_promotion = form.save() 19 | self.assertEqual(page_promotion.page_url, '/') 20 | -------------------------------------------------------------------------------- /oscar_promotions/views.py: -------------------------------------------------------------------------------- 1 | from django.urls import reverse 2 | from django.views.generic import RedirectView, TemplateView 3 | 4 | 5 | class HomeView(TemplateView): 6 | """ 7 | This is the home page and will typically live at / 8 | """ 9 | template_name = 'oscar_promotions/home.html' 10 | 11 | 12 | class RecordClickView(RedirectView): 13 | """ 14 | Simple RedirectView that helps recording clicks made on promotions 15 | """ 16 | permanent = False 17 | model = None 18 | 19 | def get_redirect_url(self, **kwargs): 20 | try: 21 | prom = self.model.objects.get(pk=kwargs['pk']) 22 | except self.model.DoesNotExist: 23 | return reverse('promotions:home') 24 | 25 | if prom.promotion.has_link: 26 | prom.record_click() 27 | return prom.link_url 28 | return reverse('promotions:home') 29 | -------------------------------------------------------------------------------- /tests/functional/test_dashboard.py: -------------------------------------------------------------------------------- 1 | from django.urls import reverse 2 | from oscar.test.testcases import WebTestCase 3 | 4 | 5 | class DashboardViewsTests(WebTestCase): 6 | 7 | is_staff = True 8 | 9 | def test_pages_exist(self): 10 | urls = [ 11 | reverse('oscar_promotions_dashboard:promotion-list'), 12 | reverse('oscar_promotions_dashboard:promotion-create-rawhtml'), 13 | reverse('oscar_promotions_dashboard:promotion-create-singleproduct'), 14 | reverse('oscar_promotions_dashboard:promotion-create-image'), 15 | ] 16 | for url in urls: 17 | self.assertIsOk(self.get(url)) 18 | 19 | def test_create_redirects(self): 20 | base_url = reverse('oscar_promotions_dashboard:promotion-create-redirect') 21 | p_types = ['rawhtml', 'singleproduct', 'image'] 22 | for p_type in p_types: 23 | url = '{base_url}?promotion_type={p_type}'.format( 24 | base_url=base_url, p_type=p_type 25 | ) 26 | self.assertIsRedirect(self.get(url)) 27 | -------------------------------------------------------------------------------- /oscar_promotions/templates/oscar_promotions/singleproduct.html: -------------------------------------------------------------------------------- 1 | {% load currency_filters %} 2 | {% load basket_tags %} 3 | {% load image_tags %} 4 | {% load i18n %} 5 | 6 |
7 |
8 |

{{ promotion.name }}

9 |
10 |
11 |
12 |
13 | {% with image=product.primary_image %} 14 | {% oscar_thumbnail image.original "x155" upscale=False as thumb %} 15 | {{ product.get_title }} 16 | {% endwith %} 17 |
18 |
19 |
20 |

{{ product.title }}

21 |
22 | {% include "oscar/catalogue/partials/stock_record.html" %} 23 |
24 | {{ promotion.description|safe }} 25 |
26 |
27 |
28 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | from setuptools import find_packages, setup 3 | 4 | from oscar_promotions import VERSION 5 | 6 | setup( 7 | name='django-oscar-promotions', 8 | version=VERSION, 9 | url='https://github.com/django-oscar/django-oscar-promotions', 10 | author='Oscar Team', 11 | author_email='sasha@metaclass.co', 12 | description='Promotions for Django Oscar', 13 | long_description=open('README.rst').read(), 14 | license='BSD', 15 | packages=find_packages(exclude=['sandbox*', 'tests*']), 16 | include_package_data=True, 17 | zip_safe=False, 18 | classifiers=[ 19 | 'Development Status :: 4 - Beta', 20 | 'Environment :: Web Environment', 21 | 'Framework :: Django', 22 | 'Framework :: Django :: 2.0', 23 | 'Intended Audience :: Developers', 24 | 'License :: OSI Approved :: BSD License', 25 | 'Operating System :: Unix', 26 | 'Programming Language :: Python :: 3', 27 | 'Programming Language :: Python :: 3.6', 28 | 'Programming Language :: Python :: 3.7', 29 | ], 30 | install_requires=['django>=2.2,<3.3', 'django-oscar>=2.0,<2.3'], 31 | ) 32 | -------------------------------------------------------------------------------- /oscar_promotions/templates/oscar_promotions/multiimage.html: -------------------------------------------------------------------------------- 1 | {% if promotion.images %} 2 | 24 | {% endif %} 25 | -------------------------------------------------------------------------------- /oscar_promotions/static/styles.css: -------------------------------------------------------------------------------- 1 | .promotion_single .image_container { 2 | min-height: 210px; 3 | margin-bottom: 20px; 4 | } 5 | .promotion_single .image_container img { 6 | max-height: 200px; 7 | } 8 | .sidebar .promotion_single h2 { 9 | font-family: inherit; 10 | font-weight: 500; 11 | line-height: 1.1; 12 | color: inherit; 13 | margin-top: 20px; 14 | margin-bottom: 10px; 15 | font-size: 24px; 16 | font-size: 17.5px; 17 | line-height: 1.4; 18 | margin-top: 0; 19 | margin-bottom: 0; 20 | } 21 | .sidebar .promotion_single h2 small, 22 | .sidebar .promotion_single h2 .small { 23 | font-weight: normal; 24 | line-height: 1; 25 | color: #777777; 26 | } 27 | .sidebar .promotion_single h2 small, 28 | .sidebar .promotion_single h2 .small { 29 | font-size: 65%; 30 | } 31 | .sidebar .promotion_single h3 { 32 | font-family: inherit; 33 | font-weight: 500; 34 | line-height: 1.1; 35 | color: inherit; 36 | margin-top: 10px; 37 | margin-bottom: 10px; 38 | font-size: 14px; 39 | line-height: 1.42857143; 40 | } 41 | .sidebar .promotion_single h3 small, 42 | .sidebar .promotion_single h3 .small { 43 | font-weight: normal; 44 | line-height: 1; 45 | color: #777777; 46 | } 47 | .sidebar .promotion_single h3 small, 48 | .sidebar .promotion_single h3 .small { 49 | font-size: 75%; 50 | } 51 | .sidebar .promotion_single .row > [class*="col-"] { 52 | float: none; 53 | width: auto; 54 | } 55 | -------------------------------------------------------------------------------- /oscar_promotions/apps.py: -------------------------------------------------------------------------------- 1 | from django.conf.urls import url 2 | from django.utils.translation import gettext_lazy as _ 3 | from oscar.core.application import OscarConfig 4 | from oscar.core.loading import get_class, get_model 5 | 6 | 7 | class PromotionsConfig(OscarConfig): 8 | 9 | label = 'oscar_promotions' 10 | name = 'oscar_promotions' 11 | verbose_name = _("Promotions") 12 | 13 | namespace = 'promotions' 14 | 15 | def ready(self): 16 | super().ready() 17 | self.home_view = get_class('oscar_promotions.views', 'HomeView', module_prefix='oscar_promotions') 18 | self.record_click_view = get_class( 19 | 'oscar_promotions.views', 'RecordClickView', module_prefix='oscar_promotions' 20 | ) 21 | 22 | def get_urls(self): 23 | PagePromotion = get_model('oscar_promotions', 'PagePromotion') 24 | KeywordPromotion = get_model('oscar_promotions', 'KeywordPromotion') 25 | urls = [ 26 | url( 27 | r'page-redirect/(?P\d+)/$', 28 | self.record_click_view.as_view(model=PagePromotion), 29 | name='page-click', 30 | ), 31 | url( 32 | r'keyword-redirect/(?P\d+)/$', 33 | self.record_click_view.as_view(model=KeywordPromotion), 34 | name='keyword-click', 35 | ), 36 | url(r'^$', self.home_view.as_view(), name='home'), 37 | ] 38 | return self.post_process_urls(urls) 39 | -------------------------------------------------------------------------------- /oscar_promotions/templates/oscar_promotions/dashboard/delete_pagepromotion.html: -------------------------------------------------------------------------------- 1 | {% extends 'oscar/dashboard/layout.html' %} 2 | {% load i18n %} 3 | 4 | {% block body_class %}{{ block.super }} create-page{% endblock %} 5 | 6 | {% block breadcrumbs %} 7 | 16 | {% endblock %} 17 | 18 | {% block headertext %} 19 | {% trans "Remove promotion from page?" %} 20 | {% endblock %} 21 | 22 | {% block dashboard_content %} 23 |
24 | {% trans "Remove promotion" %} 25 |
26 |
27 | {% csrf_token %} 28 |

{% blocktrans %}Remove {{ object.content_object.type }} content block {{ object.name }} from page {{ object.page_url }} - are you sure?{% endblocktrans %}

29 | 30 |
31 | 32 | {% trans "or" %} {% trans "cancel" %} 33 |
34 |
35 | {% endblock %} 36 | -------------------------------------------------------------------------------- /oscar_promotions/admin.py: -------------------------------------------------------------------------------- 1 | from django.contrib import admin 2 | from oscar.core.loading import get_model 3 | 4 | AutomaticProductList = get_model('oscar_promotions', 'AutomaticProductList') 5 | HandPickedProductList = get_model('oscar_promotions', 'HandPickedProductList') 6 | Image = get_model('oscar_promotions', 'Image') 7 | KeywordPromotion = get_model('oscar_promotions', 'KeywordPromotion') 8 | MultiImage = get_model('oscar_promotions', 'MultiImage') 9 | OrderedProduct = get_model('oscar_promotions', 'OrderedProduct') 10 | PagePromotion = get_model('oscar_promotions', 'PagePromotion') 11 | RawHTML = get_model('oscar_promotions', 'RawHTML') 12 | SingleProduct = get_model('oscar_promotions', 'SingleProduct') 13 | TabbedBlock = get_model('oscar_promotions', 'TabbedBlock') 14 | 15 | 16 | class OrderProductInline(admin.TabularInline): 17 | model = OrderedProduct 18 | 19 | 20 | class HandPickedProductListAdmin(admin.ModelAdmin): 21 | inlines = [OrderProductInline] 22 | 23 | 24 | class PagePromotionAdmin(admin.ModelAdmin): 25 | list_display = ['page_url', 'content_object', 'position'] 26 | exclude = ['clicks'] 27 | 28 | 29 | class KeywordPromotionAdmin(admin.ModelAdmin): 30 | list_display = ['keyword', 'position', 'clicks'] 31 | readonly_fields = ['clicks'] 32 | 33 | 34 | admin.site.register(Image) 35 | admin.site.register(MultiImage) 36 | admin.site.register(RawHTML) 37 | admin.site.register(HandPickedProductList, HandPickedProductListAdmin) 38 | admin.site.register(AutomaticProductList) 39 | admin.site.register(TabbedBlock) 40 | admin.site.register(PagePromotion, PagePromotionAdmin) 41 | admin.site.register(KeywordPromotion, KeywordPromotionAdmin) 42 | admin.site.register(SingleProduct) 43 | -------------------------------------------------------------------------------- /oscar_promotions/templates/oscar_promotions/dashboard/delete.html: -------------------------------------------------------------------------------- 1 | {% extends 'oscar/dashboard/layout.html' %} 2 | {% load i18n %} 3 | 4 | {% block body_class %}{{ block.super }} create-page{% endblock %} 5 | 6 | {% block title %} 7 | {% blocktrans with name=object.name %}Delete content block '{{ name }}'?"{% endblocktrans %} 8 | | {{ block.super }} 9 | {% endblock %} 10 | 11 | {% block breadcrumbs %} 12 | 21 | {% endblock %} 22 | 23 | {% block headertext %} 24 | {% blocktrans with name=object.name %}Delete content block '{{ name }}'?"{% endblocktrans %} 25 | {% endblock %} 26 | 27 | {% block dashboard_content %} 28 |
29 | {% trans "Delete content block" %} 30 |
31 |
32 | {% csrf_token %} 33 | {{ form }} 34 |

{% blocktrans %}Delete {{ object.type }} content block {{ object.name }} - are you sure?{% endblocktrans %}

35 |
36 | 37 | {% trans "or" %} {% trans "cancel" %} 38 |
39 |
40 | {% endblock %} 41 | -------------------------------------------------------------------------------- /oscar_promotions/context_processors.py: -------------------------------------------------------------------------------- 1 | from itertools import chain 2 | 3 | from oscar.core.loading import get_model 4 | 5 | KeywordPromotion = get_model('oscar_promotions', 'KeywordPromotion') 6 | PagePromotion = get_model('oscar_promotions', 'PagePromotion') 7 | 8 | 9 | def promotions(request): 10 | """ 11 | For adding bindings for banners and pods to the template 12 | context. 13 | """ 14 | promotions = get_request_promotions(request) 15 | 16 | # Split the promotions into separate lists for each position, and add them 17 | # to the template bindings 18 | context = { 19 | 'url_path': request.path 20 | } 21 | split_by_position(promotions, context) 22 | 23 | return context 24 | 25 | 26 | def get_request_promotions(request): 27 | """ 28 | Return promotions relevant to this request 29 | """ 30 | promotions = PagePromotion._default_manager.select_related() \ 31 | .prefetch_related('content_object') \ 32 | .filter(page_url=request.path) \ 33 | .order_by('display_order') 34 | 35 | if 'q' in request.GET: 36 | keyword_promotions \ 37 | = KeywordPromotion._default_manager.select_related()\ 38 | .filter(keyword=request.GET['q']) 39 | if keyword_promotions.exists(): 40 | promotions = list(chain(promotions, keyword_promotions)) 41 | return promotions 42 | 43 | 44 | def split_by_position(linked_promotions, context): 45 | """ 46 | Split the list of promotions into separate lists, grouping 47 | by position, and write these lists to the context dict. 48 | """ 49 | for linked_promotion in linked_promotions: 50 | promotion = linked_promotion.content_object 51 | if not promotion: 52 | continue 53 | key = 'promotions_%s' % linked_promotion.position.lower() 54 | if key not in context: 55 | context[key] = [] 56 | context[key].append(promotion) 57 | -------------------------------------------------------------------------------- /README.rst: -------------------------------------------------------------------------------- 1 | ======================= 2 | Django Oscar Promotions 3 | ======================= 4 | 5 | Django Oscar Promotions is an app for Dashboard-editable promotional content 6 | in Oscar. It was formerly a part of Oscar core, but has now been separated into 7 | a standalone app. 8 | 9 | Installation 10 | ~~~~~~~~~~~~ 11 | 12 | Install the package with ``pip install django-oscar-promotions``. 13 | 14 | Add the following entries to ``INSTALLED_APPS``: 15 | 16 | .. code-block:: python 17 | 18 | INSTALLED_APPS = [ 19 | ..., 20 | 'oscar_promotions.apps.PromotionsConfig', 21 | 'oscar_promotions.dashboard.apps.PromotionsDashboardConfig', 22 | ] 23 | 24 | 25 | And the following URL patterns to your project's URL configuration: 26 | 27 | .. code-block:: python 28 | 29 | urlpatterns = [ 30 | ..., 31 | path("", apps.get_app_config("oscar_promotions").urls), 32 | path("dashboard/promotions/", apps.get_app_config("oscar_promotions_dashboard").urls), 33 | ] 34 | 35 | 36 | You can, if you prefer, include the dashboard URLs inside the URL configuration 37 | of your forked dashboard app. 38 | 39 | If you want the dashboard views to be accessible from the dashboard menu, 40 | add them to ``OSCAR_DASHBOARD_NAVIGATION``. The snippet below will add two 41 | menu items to the Content menu. 42 | 43 | .. code-block:: python 44 | 45 | OSCAR_DASHBOARD_NAVIGATION[5]['children'] += [ 46 | { 47 | 'label': 'Content blocks', 48 | 'url_name': 'oscar_promotions_dashboard:promotion-list', 49 | }, 50 | { 51 | 'label': 'Content blocks by page', 52 | 'url_name': 'oscar_promotions_dashboard:promotion-list-by-page', 53 | }, 54 | ] 55 | 56 | Add the promotions context processor to your ``TEMPLATES`` setting: 57 | 58 | .. code-block:: python 59 | 60 | TEMPLATES = { 61 | 'context_processors': [ 62 | ... 63 | 'oscar_promotions.context_processors.promotions', 64 | ] 65 | } 66 | -------------------------------------------------------------------------------- /.github/workflows/test.yml: -------------------------------------------------------------------------------- 1 | name: Tests 2 | 3 | on: 4 | push: 5 | branches: 6 | - master 7 | pull_request: 8 | branches: 9 | - master 10 | 11 | jobs: 12 | test: 13 | runs-on: ubuntu-latest 14 | strategy: 15 | fail-fast: true 16 | matrix: 17 | python-version: [3.6, 3.7, 3.8, 3.9] 18 | django-version: [2.2, 3.1] 19 | services: 20 | postgres: 21 | image: postgres:10 22 | ports: 23 | - 5432:5432 24 | options: --health-cmd pg_isready --health-interval 10s --health-timeout 5s --health-retries 5 25 | env: 26 | POSTGRES_USER: postgres 27 | POSTGRES_PASSWORD: postgres 28 | POSTGRES_DB: postgres 29 | steps: 30 | - uses: actions/checkout@v2 31 | - name: Set up Python ${{ matrix.python-version }} 32 | uses: actions/setup-python@v2 33 | with: 34 | python-version: ${{ matrix.python-version }} 35 | - name: Install dependencies 36 | run: | 37 | python -m pip install --upgrade pip 38 | pip install -e .[test] 39 | pip install -r requirements.txt 40 | pip install "django~=${{ matrix.django-version }}.0" 41 | - name: Run tests 42 | env: 43 | DATABASE_USER: postgres 44 | DATABASE_PASSWORD: postgres 45 | DATABASE_HOST: localhost 46 | DATABASE_NAME: postgres 47 | run: | 48 | coverage run --parallel -m pytest -x 49 | - name: Upload coverage to Codecov 50 | uses: codecov/codecov-action@v1 51 | with: 52 | fail_ci_if_error: true 53 | lint_python: 54 | runs-on: ubuntu-latest 55 | steps: 56 | - uses: actions/checkout@v2 57 | - name: Set up Python ${{ matrix.python-version }} 58 | uses: actions/setup-python@v2 59 | with: 60 | python-version: 3.7 61 | - name: Install dependencies 62 | run: | 63 | python -m pip install --upgrade pip 64 | pip install -e .[test] 65 | pip install -r requirements.txt 66 | - name: Run linters 67 | run: | 68 | flake8 oscar_promotions tests setup.py 69 | isort -q -c --recursive --diff oscar_promotions tests setup.py -------------------------------------------------------------------------------- /sandbox/fixtures/promotions.json: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "model": "oscar_promotions.pagepromotion", 4 | "pk": 4, 5 | "fields": { 6 | "content_type": [ 7 | "oscar_promotions", 8 | "rawhtml" 9 | ], 10 | "object_id": 1, 11 | "position": "page", 12 | "display_order": 0, 13 | "clicks": 0, 14 | "date_created": "2014-05-06T10:31:06.444Z", 15 | "page_url": "/" 16 | } 17 | }, 18 | { 19 | "model": "oscar_promotions.pagepromotion", 20 | "pk": 6, 21 | "fields": { 22 | "content_type": [ 23 | "oscar_promotions", 24 | "handpickedproductlist" 25 | ], 26 | "object_id": 1, 27 | "position": "page", 28 | "display_order": 0, 29 | "clicks": 0, 30 | "date_created": "2014-05-06T10:31:55.034Z", 31 | "page_url": "/" 32 | } 33 | }, 34 | { 35 | "model": "oscar_promotions.rawhtml", 36 | "pk": 1, 37 | "fields": { 38 | "name": "Welcome!", 39 | "display_type": "", 40 | "body": "Welcome to Oscar's sandbox site.  This is a example install of Oscar, making very few customisations to the core.  It is intended as a playground for experimenting with Oscar's features.

You can get access to the dashboard using this form.", 41 | "date_created": "2013-01-08T17:12:09.526Z" 42 | } 43 | }, 44 | { 45 | "model": "oscar_promotions.handpickedproductlist", 46 | "pk": 1, 47 | "fields": { 48 | "name": "Other good books", 49 | "description": "Commodo sed artisan before they sold out veniam aute sint you probably haven't heard of them pour-over, +1 delectus street art direct trade. Enim craft beer odd future single-origin coffee gluten-free artisan, salvia consectetur master cleanse vegan eiusmod carles. Yr proident ennui VHS, art party direct trade veniam est try-hard voluptate. Authentic irure aliqua, pariatur ad whatever sunt banjo esse letterpress plaid bicycle rights mollit quinoa you probably haven't heard of them. Farm-to-table keytar duis, craft beer carles lomo est accusamus single-origin coffee. Swag twee magna, literally meh cred cray. Aute deserunt ullamco, you probably haven't heard of them nisi synth DIY food truck.

", 50 | "link_url": "", 51 | "link_text": "", 52 | "date_created": "2013-01-08T17:19:57.759Z" 53 | } 54 | } 55 | ] -------------------------------------------------------------------------------- /oscar_promotions/templates/oscar_promotions/dashboard/pagepromotion_list.html: -------------------------------------------------------------------------------- 1 | {% extends 'oscar/dashboard/layout.html' %} 2 | {% load i18n %} 3 | 4 | {% block body_class %}{{ block.super }} content-blocks{% endblock %} 5 | 6 | {% block headertext %} 7 | {% trans "Content blocks" %} 8 | {% endblock %} 9 | 10 | {% block title %} 11 | {% trans "Pages with content blocks" %} | {{ block.super }} 12 | {% endblock %} 13 | 14 | {% block breadcrumbs %} 15 | 24 | {% endblock %} 25 | 26 | {% block dashboard_content %} 27 |
28 |

{% trans "View by page" %}

29 |
30 | 34 | 35 |
36 |

{% trans "Pages with content blocks on them" %}

37 |
38 | 39 | 40 | {% if pages %} 41 | 42 | 43 | 44 | 45 | 46 | 47 | 48 | 49 | {% for page in pages %} 50 | 51 | 52 | 53 | 56 | 57 | 58 | {% endfor %} 59 | 60 | {% else %} 61 | 62 | {% endif %} 63 |
{% trans "URL" %}{% trans "Number of content blocks" %}{% trans "Actions" %}
{{ page.page_url }}{{ page.freq }} 54 | {% trans "Edit" %} 55 |
{% trans "No content blocks found." %}
64 | {% endblock dashboard_content %} 65 | -------------------------------------------------------------------------------- /tests/factories/oscar_promotions.py: -------------------------------------------------------------------------------- 1 | import factory.fuzzy 2 | from oscar.core.loading import get_model 3 | 4 | from tests.factories.catalogue import ProductFactory 5 | 6 | PagePromotion = get_model('oscar_promotions', 'PagePromotion') 7 | KeywordPromotion = get_model('oscar_promotions', 'KeywordPromotion') 8 | 9 | 10 | class RawHTMLFactory(factory.django.DjangoModelFactory): 11 | class Meta: 12 | model = get_model('oscar_promotions', 'RawHTML') 13 | 14 | name = factory.Faker('sentence') 15 | body = factory.Faker('text') 16 | 17 | 18 | class ImageFactory(factory.django.DjangoModelFactory): 19 | class Meta: 20 | model = get_model('oscar_promotions', 'Image') 21 | 22 | name = factory.Faker('sentence') 23 | link_url = factory.Faker('uri_path') 24 | image = 'image.jpg' 25 | 26 | 27 | class MultiImageFactory(factory.django.DjangoModelFactory): 28 | class Meta: 29 | model = get_model('oscar_promotions', 'MultiImage') 30 | 31 | name = factory.Faker('sentence') 32 | 33 | @factory.post_generation 34 | def images(self, *args, **kwargs): 35 | for _ in range(4): 36 | self.images.add(ImageFactory()) 37 | 38 | 39 | class SingleProductFactory(factory.django.DjangoModelFactory): 40 | class Meta: 41 | model = get_model('oscar_promotions', 'SingleProduct') 42 | 43 | name = factory.Faker('sentence') 44 | product = factory.SubFactory(ProductFactory) 45 | description = factory.Faker('text') 46 | 47 | 48 | class AutomaticProductListFactory(factory.django.DjangoModelFactory): 49 | class Meta: 50 | model = get_model('oscar_promotions', 'AutomaticProductList') 51 | 52 | name = factory.Faker('sentence') 53 | description = factory.Faker('text') 54 | link_url = factory.Faker('uri_path') 55 | link_text = factory.Faker('sentence') 56 | method = factory.fuzzy.FuzzyChoice( 57 | [choice for choice, label in Meta.model.METHOD_CHOICES] 58 | ) 59 | 60 | @factory.post_generation 61 | def products(self, *args, **kwargs): 62 | for _ in range(4): 63 | ProductFactory() 64 | 65 | 66 | class HandPickedProductListFactory(factory.django.DjangoModelFactory): 67 | class Meta: 68 | model = get_model('oscar_promotions', 'HandPickedProductList') 69 | 70 | name = factory.Faker('sentence') 71 | description = factory.Faker('text') 72 | link_url = factory.Faker('uri_path') 73 | link_text = factory.Faker('sentence') 74 | 75 | @factory.post_generation 76 | def products(self, *args, **kwargs): 77 | for _ in range(4): 78 | self.products.add(ProductFactory()) 79 | 80 | 81 | class TabbedBlockFactory(factory.django.DjangoModelFactory): 82 | class Meta: 83 | model = get_model('oscar_promotions', 'TabbedBlock') 84 | 85 | name = factory.Faker('sentence') 86 | -------------------------------------------------------------------------------- /oscar_promotions/dashboard/forms.py: -------------------------------------------------------------------------------- 1 | from django import forms 2 | from django.utils.translation import gettext_lazy as _ 3 | from oscar.core.loading import get_class, get_model 4 | from oscar.forms.fields import ExtendedURLField 5 | 6 | from oscar_promotions import app_settings 7 | from oscar_promotions.conf import PROMOTION_CLASSES 8 | 9 | HandPickedProductList = get_model('oscar_promotions', 'HandPickedProductList') 10 | OrderedProduct = get_model('oscar_promotions', 'OrderedProduct') 11 | PagePromotion = get_model('oscar_promotions', 'PagePromotion') 12 | RawHTML = get_model('oscar_promotions', 'RawHTML') 13 | SingleProduct = get_model('oscar_promotions', 'SingleProduct') 14 | 15 | ProductSelect = get_class('dashboard.catalogue.widgets', 'ProductSelect') 16 | 17 | 18 | class PromotionTypeSelectForm(forms.Form): 19 | choices = [] 20 | for klass in PROMOTION_CLASSES: 21 | choices.append((klass.classname(), klass._meta.verbose_name)) 22 | promotion_type = forms.ChoiceField(choices=tuple(choices), 23 | label=_("Promotion type")) 24 | 25 | 26 | class RawHTMLForm(forms.ModelForm): 27 | class Meta: 28 | model = RawHTML 29 | fields = ['name', 'body'] 30 | 31 | def __init__(self, *args, **kwargs): 32 | super().__init__(*args, **kwargs) 33 | self.fields['body'].widget.attrs['class'] = "no-widget-init" 34 | 35 | 36 | class SingleProductForm(forms.ModelForm): 37 | class Meta: 38 | model = SingleProduct 39 | fields = ['name', 'product', 'description'] 40 | widgets = {'product': ProductSelect} 41 | 42 | 43 | class HandPickedProductListForm(forms.ModelForm): 44 | class Meta: 45 | model = HandPickedProductList 46 | fields = ['name', 'description', 'link_url', 'link_text'] 47 | 48 | 49 | class OrderedProductForm(forms.ModelForm): 50 | class Meta: 51 | model = OrderedProduct 52 | fields = ['list', 'product', 'display_order'] 53 | widgets = { 54 | 'product': ProductSelect, 55 | } 56 | 57 | 58 | class PagePromotionForm(forms.ModelForm): 59 | page_url = ExtendedURLField(label=_("URL")) 60 | position = forms.CharField( 61 | widget=forms.Select(choices=app_settings.OSCAR_PROMOTIONS_POSITIONS), 62 | label=_("Position"), 63 | help_text=_("Where in the page this content block will appear")) 64 | 65 | class Meta: 66 | model = PagePromotion 67 | fields = ['position', 'page_url'] 68 | 69 | def clean_page_url(self): 70 | page_url = self.cleaned_data.get('page_url') 71 | if not page_url: 72 | return page_url 73 | 74 | if page_url.startswith('http'): 75 | raise forms.ValidationError( 76 | _("Content blocks can only be linked to internal URLs")) 77 | 78 | if page_url.startswith('/') and not page_url.endswith('/'): 79 | page_url += '/' 80 | 81 | return page_url 82 | -------------------------------------------------------------------------------- /tests/integration/test_models.py: -------------------------------------------------------------------------------- 1 | from django.test import RequestFactory, TestCase 2 | from oscar.core.loading import get_class 3 | 4 | from oscar_promotions.templatetags.promotion_tags import render_promotion 5 | from tests.factories.oscar_promotions import ( 6 | AutomaticProductListFactory, 7 | HandPickedProductListFactory, 8 | ImageFactory, 9 | MultiImageFactory, 10 | RawHTMLFactory, 11 | SingleProductFactory, 12 | TabbedBlockFactory, 13 | ) 14 | 15 | DefaultStrategy = get_class('partner.strategy', 'Default') 16 | 17 | 18 | class RawHTMLPromotionsTests(TestCase): 19 | def test_render_promotion(self): 20 | promotion = RawHTMLFactory() 21 | render_promotion({'request': RequestFactory().get('/')}, promotion) 22 | self.assertEqual('oscar_promotions/rawhtml.html', promotion.template_name()) 23 | 24 | 25 | class ImagePromotionsTests(TestCase): 26 | def test_render_promotion(self): 27 | promotion = ImageFactory() 28 | render_promotion({'request': RequestFactory().get('/')}, promotion) 29 | self.assertEqual('oscar_promotions/image.html', promotion.template_name()) 30 | 31 | 32 | class MultiImagePromotionsTests(TestCase): 33 | def test_render_promotion(self): 34 | promotion = MultiImageFactory() 35 | render_promotion({'request': RequestFactory().get('/')}, promotion) 36 | self.assertEqual('oscar_promotions/multiimage.html', promotion.template_name()) 37 | 38 | 39 | class SingleProductFactoryPromotionsTests(TestCase): 40 | def test_render_promotion(self): 41 | promotion = SingleProductFactory() 42 | request = RequestFactory().get('/') 43 | request.strategy = DefaultStrategy() 44 | render_promotion({'request': request}, promotion) 45 | self.assertEqual( 46 | 'oscar_promotions/singleproduct.html', promotion.template_name() 47 | ) 48 | 49 | 50 | class AutomaticProductListPromotionsTests(TestCase): 51 | def test_render_promotion(self): 52 | promotion = AutomaticProductListFactory() 53 | request = RequestFactory().get('/') 54 | request.strategy = DefaultStrategy() 55 | render_promotion({'request': request}, promotion) 56 | self.assertEqual( 57 | 'oscar_promotions/automaticproductlist.html', promotion.template_name() 58 | ) 59 | 60 | 61 | class HandPickedProductListPromotionsTests(TestCase): 62 | def test_render_promotion(self): 63 | promotion = HandPickedProductListFactory() 64 | request = RequestFactory().get('/') 65 | request.strategy = DefaultStrategy() 66 | render_promotion({'request': request}, promotion) 67 | self.assertEqual( 68 | 'oscar_promotions/handpickedproductlist.html', promotion.template_name() 69 | ) 70 | 71 | 72 | class TabbedBlockPromotionsTests(TestCase): 73 | def test_render_promotion(self): 74 | promotion = TabbedBlockFactory() 75 | render_promotion({'request': RequestFactory().get('/')}, promotion) 76 | self.assertEqual('oscar_promotions/tabbedblock.html', promotion.template_name()) 77 | -------------------------------------------------------------------------------- /oscar_promotions/templates/oscar_promotions/dashboard/promotion_list.html: -------------------------------------------------------------------------------- 1 | {% extends 'oscar/dashboard/layout.html' %} 2 | {% load i18n %} 3 | 4 | {% block body_class %}{{ block.super }} content-blocks{% endblock %} 5 | 6 | {% block title %} 7 | {% trans "Content blocks" %} | {{ block.super }} 8 | {% endblock %} 9 | 10 | {% block header %} 11 | 14 | {% endblock header %} 15 | 16 | {% block breadcrumbs %} 17 | 23 | {% endblock %} 24 | 25 | {% block dashboard_content %} 26 |
27 |

{% trans "Create a new content block" %}

28 |
29 |
30 |
31 | {% include "oscar/dashboard/partials/form_fields_inline.html" with form=select_form %} 32 | 33 |
34 |
35 | 36 | 37 | 38 | {% if num_promotions %} 39 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | 47 | 48 | 49 | {% for promotion in promotions %} 50 | 51 | 52 | 53 | 54 | 55 | 69 | 70 | {% endfor %} 71 | 72 | {% else %} 73 | 74 | {% endif %} 75 |
{% trans "Content blocks" %}
{% trans "Name" %}{% trans "Type" %}{% trans "Number of times used" %}{% trans "Date created" %}{% trans "Actions" %}
{{ promotion.name }}{{ promotion.type }}{{ promotion.num_times_used }}{{ promotion.date_created }} 56 |
57 |
58 | 62 | 66 |
67 |
68 |
{% trans "No content blocks found." %}
76 | {% endblock dashboard_content %} 77 | -------------------------------------------------------------------------------- /oscar_promotions/templates/oscar_promotions/dashboard/page_detail.html: -------------------------------------------------------------------------------- 1 | {% extends 'oscar/dashboard/layout.html' %} 2 | {% load i18n %} 3 | 4 | {% block body_class %}{{ block.super }} promotions{% endblock %} 5 | 6 | {% block title %} 7 | {% blocktrans %}Content blocks for page {{ page_url }}{% endblocktrans %} | {{ block.super }} 8 | {% endblock %} 9 | 10 | {% block breadcrumbs %} 11 | 20 | {% endblock %} 21 | 22 | {% block headertext %} 23 | {% blocktrans %}Content blocks for page {{ page_url }}{% endblocktrans %} 24 | {% endblock %} 25 | 26 | {% block dashboard_content %} 27 | 28 | {% for position in positions %} 29 | 30 | 31 | {% if position.promotions %} 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | {% for promotion in position.promotions %} 41 | 42 | 43 | 44 | 59 | 60 | {% endfor %} 61 | 62 | {% else %} 63 | 64 | {% endif %} 65 |
{% blocktrans with name=position.name %}Edit promotions in position '{{ name }}'{% endblocktrans %}
{% trans "Promotion Name" %}{% trans "Type" %}{% trans "Actions" %}
{{ promotion.content_object.name }}{{ promotion.content_object.type }} 45 |
46 |
47 | 51 | 55 |
56 | {% trans "Change display order" %} 57 |
58 |
{% trans "No promotions in this position." %}
66 | {% endfor %} 67 | 68 | {% endblock dashboard_content %} 69 | 70 | {% block onbodyload %} 71 | {{ block.super }} 72 | oscar.dashboard.reordering.init({ 73 | wrapper: '.promotion_list' 74 | }); 75 | {% endblock %} 76 | -------------------------------------------------------------------------------- /oscar_promotions/templates/oscar_promotions/dashboard/form.html: -------------------------------------------------------------------------------- 1 | {% extends 'oscar/dashboard/layout.html' %} 2 | {% load i18n %} 3 | 4 | {% block body_class %}{{ block.super }} create-page promotions{% endblock %} 5 | 6 | {% block breadcrumbs %} 7 | 16 | {% endblock %} 17 | 18 | {% block headertext %} 19 | {{ heading }} 20 | {% endblock %} 21 | 22 | {% block dashboard_content %} 23 | 24 | {% block promotion_form %} 25 |
26 |

{% trans "Content block" %}

27 |
28 | 29 |
30 | {% csrf_token %} 31 | {% include "oscar/dashboard/partials/form_fields.html" with form=form %} 32 | 33 | {% block inlines %} {% endblock %} 34 | 35 |
36 | 37 | {% trans "or" %} {% trans "cancel" %} 38 |
39 |
40 | 41 | {% endblock %} 42 | 43 | {% if promotion %} 44 | 45 | 46 | {% if links %} 47 | 48 | 49 | 50 | 51 | 52 | 53 | 54 | 55 | {% for link in links %} 56 | 57 | 58 | 59 | 68 | 69 | {% endfor %} 70 | 71 | {% else %} 72 | 73 | {% endif %} 74 |
{% trans "Pages displaying this content blocks" %}
{% trans "Page URL" %}{% trans "Position on page" %}{% trans "Actions" %}
{{ link.page_url }}{{ link.position }} 60 |
61 | {% csrf_token %} 62 | 63 | 64 | {% trans "View all blocks on this page" %} 65 | 66 |
67 |
{% trans "This promotion is not displayed anywhere at the moment." %}
75 |
76 |

{% trans "Add to a page" %}

77 |
78 |
79 |
80 | {% csrf_token %} 81 | 82 | {% include "oscar/dashboard/partials/form_fields.html" with form=link_form %} 83 | 84 |
85 |
86 | {% endif %} 87 | 88 | {% endblock %} 89 | -------------------------------------------------------------------------------- /oscar_promotions/dashboard/apps.py: -------------------------------------------------------------------------------- 1 | from django.conf.urls import url 2 | from django.utils.translation import gettext_lazy as _ 3 | from oscar.core.application import OscarDashboardConfig 4 | from oscar.core.loading import get_class 5 | 6 | 7 | class PromotionsDashboardConfig(OscarDashboardConfig): 8 | 9 | label = 'oscar_promotions_dashboard' 10 | name = 'oscar_promotions.dashboard' 11 | namespace = 'oscar_promotions_dashboard' 12 | verbose_name = _("Promotions dashboard") 13 | default_permissions = ['is_staff'] 14 | 15 | # Dynamically set the CRUD views for all promotion classes 16 | view_names = ( 17 | ('create_%s_view', 'Create%sView'), 18 | ('update_%s_view', 'Update%sView'), 19 | ('delete_%s_view', 'Delete%sView'), 20 | ) 21 | 22 | def get_promotion_classes(self): 23 | from oscar_promotions.conf import PROMOTION_CLASSES 24 | 25 | return PROMOTION_CLASSES 26 | 27 | def ready(self): 28 | super().ready() 29 | self.list_view = get_class( 30 | 'oscar_promotions.dashboard.views', 'ListView', module_prefix='oscar_promotions' 31 | ) 32 | self.page_list = get_class( 33 | 'oscar_promotions.dashboard.views', 'PageListView', module_prefix='oscar_promotions' 34 | ) 35 | self.page_detail = get_class( 36 | 'oscar_promotions.dashboard.views', 'PageDetailView', module_prefix='oscar_promotions' 37 | ) 38 | self.create_redirect_view = get_class( 39 | 'oscar_promotions.dashboard.views', 'CreateRedirectView', module_prefix='oscar_promotions' 40 | ) 41 | self.delete_page_promotion_view = get_class( 42 | 'oscar_promotions.dashboard.views', 'DeletePagePromotionView', module_prefix='oscar_promotions' 43 | ) 44 | for klass in self.get_promotion_classes(): 45 | for attr_name, view_name in self.view_names: 46 | full_attr_name = attr_name % klass.classname() 47 | full_view_name = view_name % klass.__name__ 48 | view = get_class( 49 | 'oscar_promotions.dashboard.views', full_view_name, module_prefix='oscar_promotions' 50 | ) 51 | setattr(self, full_attr_name, view) 52 | 53 | def get_urls(self): 54 | urls = [ 55 | url(r'^$', self.list_view.as_view(), name='promotion-list'), 56 | url(r'^pages/$', self.page_list.as_view(), name='promotion-list-by-page'), 57 | url( 58 | r'^page/-(?P/([\w-]+(/[\w-]+)*/)?)$', 59 | self.page_detail.as_view(), 60 | name='promotion-list-by-url', 61 | ), 62 | url( 63 | r'^create/$', 64 | self.create_redirect_view.as_view(), 65 | name='promotion-create-redirect', 66 | ), 67 | url( 68 | r'^page-promotion/(?P\d+)/$', 69 | self.delete_page_promotion_view.as_view(), 70 | name='pagepromotion-delete', 71 | ), 72 | ] 73 | for klass in self.get_promotion_classes(): 74 | code = klass.classname() 75 | urls.extend( 76 | [ 77 | url( 78 | r'create/%s/' % code, 79 | getattr(self, 'create_%s_view' % code).as_view(), 80 | name='promotion-create-%s' % code, 81 | ), 82 | url( 83 | r'^update/(?P%s)/(?P\d+)/$' % code, 84 | getattr(self, 'update_%s_view' % code).as_view(), 85 | name='promotion-update', 86 | ), 87 | url( 88 | r'^delete/(?P%s)/(?P\d+)/$' % code, 89 | getattr(self, 'delete_%s_view' % code).as_view(), 90 | name='promotion-delete', 91 | ), 92 | ] 93 | ) 94 | return self.post_process_urls(urls) 95 | -------------------------------------------------------------------------------- /tests/settings.py: -------------------------------------------------------------------------------- 1 | from oscar.defaults import * # noqa 2 | 3 | DATABASES = { 4 | 'default': { 5 | 'ENGINE': 'django.db.backends.sqlite3', 6 | 'NAME': 'db.sqlite3', 7 | } 8 | } 9 | 10 | INSTALLED_APPS = [ 11 | # Django apps 12 | 'django.contrib.admin', 13 | 'django.contrib.auth', 14 | 'django.contrib.contenttypes', 15 | 'django.contrib.sessions', 16 | 'django.contrib.staticfiles', 17 | 'django.contrib.sites', 18 | 'django.contrib.flatpages', 19 | 'django.contrib.messages', 20 | # Oscar apps 21 | 'oscar.config.Shop', 22 | 'oscar.apps.analytics.apps.AnalyticsConfig', 23 | 'oscar.apps.checkout.apps.CheckoutConfig', 24 | 'oscar.apps.address.apps.AddressConfig', 25 | 'oscar.apps.shipping.apps.ShippingConfig', 26 | 'oscar.apps.catalogue.apps.CatalogueConfig', 27 | 'oscar.apps.catalogue.reviews.apps.CatalogueReviewsConfig', 28 | 'oscar.apps.communication.apps.CommunicationConfig', 29 | 'oscar.apps.partner.apps.PartnerConfig', 30 | 'oscar.apps.basket.apps.BasketConfig', 31 | 'oscar.apps.payment.apps.PaymentConfig', 32 | 'oscar.apps.offer.apps.OfferConfig', 33 | 'oscar.apps.order.apps.OrderConfig', 34 | 'oscar.apps.customer.apps.CustomerConfig', 35 | 'oscar.apps.search.apps.SearchConfig', 36 | 'oscar.apps.voucher.apps.VoucherConfig', 37 | 'oscar.apps.wishlists.apps.WishlistsConfig', 38 | 'oscar.apps.dashboard.apps.DashboardConfig', 39 | 'oscar.apps.dashboard.reports.apps.ReportsDashboardConfig', 40 | 'oscar.apps.dashboard.users.apps.UsersDashboardConfig', 41 | 'oscar.apps.dashboard.orders.apps.OrdersDashboardConfig', 42 | 'oscar.apps.dashboard.catalogue.apps.CatalogueDashboardConfig', 43 | 'oscar.apps.dashboard.offers.apps.OffersDashboardConfig', 44 | 'oscar.apps.dashboard.partners.apps.PartnersDashboardConfig', 45 | 'oscar.apps.dashboard.pages.apps.PagesDashboardConfig', 46 | 'oscar.apps.dashboard.ranges.apps.RangesDashboardConfig', 47 | 'oscar.apps.dashboard.reviews.apps.ReviewsDashboardConfig', 48 | 'oscar.apps.dashboard.vouchers.apps.VouchersDashboardConfig', 49 | 'oscar.apps.dashboard.communications.apps.CommunicationsDashboardConfig', 50 | 'oscar.apps.dashboard.shipping.apps.ShippingDashboardConfig', 51 | # Oscar promotions apps 52 | 'oscar_promotions', 53 | 'oscar_promotions.dashboard', 54 | # 3rd-party apps that oscar depends on 55 | 'widget_tweaks', 56 | 'haystack', 57 | 'treebeard', 58 | 'sorl.thumbnail', 59 | 'django_tables2', 60 | ] 61 | 62 | TEMPLATES = [ 63 | { 64 | 'BACKEND': 'django.template.backends.django.DjangoTemplates', 65 | 'OPTIONS': { 66 | 'loaders': [ 67 | 'django.template.loaders.filesystem.Loader', 68 | 'django.template.loaders.app_directories.Loader', 69 | ], 70 | 'context_processors': [ 71 | 'django.contrib.auth.context_processors.auth', 72 | 'django.template.context_processors.request', 73 | 'django.template.context_processors.debug', 74 | 'django.template.context_processors.i18n', 75 | 'django.template.context_processors.media', 76 | 'django.template.context_processors.static', 77 | 'django.contrib.messages.context_processors.messages', 78 | 'oscar.apps.search.context_processors.search_form', 79 | 'oscar.apps.communication.notifications.context_processors.notifications', 80 | 'oscar.apps.checkout.context_processors.checkout', 81 | 'oscar.core.context_processors.metadata', 82 | 'oscar_promotions.context_processors.promotions', 83 | ], 84 | }, 85 | } 86 | ] 87 | 88 | MIDDLEWARE = [ 89 | 'django.middleware.common.CommonMiddleware', 90 | 'django.contrib.sessions.middleware.SessionMiddleware', 91 | 'django.middleware.csrf.CsrfViewMiddleware', 92 | 'django.contrib.auth.middleware.AuthenticationMiddleware', 93 | 'django.contrib.messages.middleware.MessageMiddleware', 94 | 'oscar.apps.basket.middleware.BasketMiddleware', 95 | ] 96 | 97 | AUTHENTICATION_BACKENDS = ( 98 | 'oscar.apps.customer.auth_backends.EmailBackend', 99 | 'django.contrib.auth.backends.ModelBackend', 100 | ) 101 | 102 | HAYSTACK_CONNECTIONS = { 103 | 'default': {'ENGINE': 'haystack.backends.simple_backend.SimpleEngine'} 104 | } 105 | 106 | ROOT_URLCONF = 'tests.urls' 107 | LOGIN_REDIRECT_URL = '/accounts/' 108 | 109 | STATIC_URL = '/static/' 110 | MEDIA_URL = '/media/' 111 | 112 | SITE_ID = 1 113 | USE_TZ = 1 114 | APPEND_SLASH = True 115 | 116 | SECRET_KEY = 'notverysecret' 117 | -------------------------------------------------------------------------------- /sandbox/settings.py: -------------------------------------------------------------------------------- 1 | import os 2 | 3 | from oscar.defaults import * # noqa 4 | 5 | 6 | # Path helper 7 | def base_location(location): 8 | return os.path.join(os.path.dirname(os.path.realpath(__file__)), location) 9 | 10 | 11 | ALLOWED_HOSTS = [ 12 | 'localhost', 13 | '127.0.0.1', 14 | ] 15 | 16 | DATABASES = { 17 | 'default': { 18 | 'ENGINE': 'django.db.backends.sqlite3', 19 | 'NAME': 'db.sqlite3', 20 | } 21 | } 22 | 23 | INSTALLED_APPS = [ 24 | # Django apps 25 | 'django.contrib.admin', 26 | 'django.contrib.auth', 27 | 'django.contrib.contenttypes', 28 | 'django.contrib.sessions', 29 | 'django.contrib.staticfiles', 30 | 'django.contrib.sites', 31 | 'django.contrib.flatpages', 32 | 'django.contrib.messages', 33 | # Oscar apps 34 | 'oscar.config.Shop', 35 | 'oscar.apps.analytics.apps.AnalyticsConfig', 36 | 'oscar.apps.checkout.apps.CheckoutConfig', 37 | 'oscar.apps.address.apps.AddressConfig', 38 | 'oscar.apps.shipping.apps.ShippingConfig', 39 | 'oscar.apps.catalogue.apps.CatalogueConfig', 40 | 'oscar.apps.catalogue.reviews.apps.CatalogueReviewsConfig', 41 | 'oscar.apps.communication.apps.CommunicationConfig', 42 | 'oscar.apps.partner.apps.PartnerConfig', 43 | 'oscar.apps.basket.apps.BasketConfig', 44 | 'oscar.apps.payment.apps.PaymentConfig', 45 | 'oscar.apps.offer.apps.OfferConfig', 46 | 'oscar.apps.order.apps.OrderConfig', 47 | 'oscar.apps.customer.apps.CustomerConfig', 48 | 'oscar.apps.search.apps.SearchConfig', 49 | 'oscar.apps.voucher.apps.VoucherConfig', 50 | 'oscar.apps.wishlists.apps.WishlistsConfig', 51 | 'oscar.apps.dashboard.apps.DashboardConfig', 52 | 'oscar.apps.dashboard.reports.apps.ReportsDashboardConfig', 53 | 'oscar.apps.dashboard.users.apps.UsersDashboardConfig', 54 | 'oscar.apps.dashboard.orders.apps.OrdersDashboardConfig', 55 | 'oscar.apps.dashboard.catalogue.apps.CatalogueDashboardConfig', 56 | 'oscar.apps.dashboard.offers.apps.OffersDashboardConfig', 57 | 'oscar.apps.dashboard.partners.apps.PartnersDashboardConfig', 58 | 'oscar.apps.dashboard.pages.apps.PagesDashboardConfig', 59 | 'oscar.apps.dashboard.ranges.apps.RangesDashboardConfig', 60 | 'oscar.apps.dashboard.reviews.apps.ReviewsDashboardConfig', 61 | 'oscar.apps.dashboard.vouchers.apps.VouchersDashboardConfig', 62 | 'oscar.apps.dashboard.communications.apps.CommunicationsDashboardConfig', 63 | 'oscar.apps.dashboard.shipping.apps.ShippingDashboardConfig', 64 | # Oscar promotions apps 65 | 'oscar_promotions', 66 | 'oscar_promotions.dashboard', 67 | # 3rd-party apps that oscar depends on 68 | 'widget_tweaks', 69 | 'haystack', 70 | 'treebeard', 71 | 'sorl.thumbnail', 72 | 'django_tables2', 73 | ] 74 | 75 | TEMPLATES = [ 76 | { 77 | 'BACKEND': 'django.template.backends.django.DjangoTemplates', 78 | 'DIRS': [base_location('templates')], 79 | 'OPTIONS': { 80 | 'loaders': [ 81 | 'django.template.loaders.filesystem.Loader', 82 | 'django.template.loaders.app_directories.Loader', 83 | ], 84 | 'context_processors': [ 85 | 'django.contrib.auth.context_processors.auth', 86 | 'django.template.context_processors.request', 87 | 'django.template.context_processors.debug', 88 | 'django.template.context_processors.i18n', 89 | 'django.template.context_processors.media', 90 | 'django.template.context_processors.static', 91 | 'django.contrib.messages.context_processors.messages', 92 | 'oscar.apps.search.context_processors.search_form', 93 | 'oscar.apps.communication.notifications.context_processors.notifications', 94 | 'oscar.apps.checkout.context_processors.checkout', 95 | 'oscar.core.context_processors.metadata', 96 | 'oscar_promotions.context_processors.promotions', 97 | ], 98 | }, 99 | } 100 | ] 101 | 102 | 103 | MIDDLEWARE = [ 104 | 'django.middleware.common.CommonMiddleware', 105 | 'django.contrib.sessions.middleware.SessionMiddleware', 106 | 'django.middleware.csrf.CsrfViewMiddleware', 107 | 'django.contrib.auth.middleware.AuthenticationMiddleware', 108 | 'django.contrib.messages.middleware.MessageMiddleware', 109 | 'oscar.apps.basket.middleware.BasketMiddleware', 110 | ] 111 | 112 | 113 | AUTHENTICATION_BACKENDS = ( 114 | 'oscar.apps.customer.auth_backends.EmailBackend', 115 | 'django.contrib.auth.backends.ModelBackend', 116 | ) 117 | 118 | HAYSTACK_CONNECTIONS = { 119 | 'default': {'ENGINE': 'haystack.backends.simple_backend.SimpleEngine'} 120 | } 121 | 122 | ROOT_URLCONF = 'urls' 123 | LOGIN_REDIRECT_URL = '/accounts/' 124 | STATIC_URL = '/static/' 125 | MEDIA_URL = '/media/' 126 | PUBLIC_ROOT = base_location('public') 127 | MEDIA_ROOT = os.path.join(PUBLIC_ROOT, 'media') 128 | 129 | DEBUG = True 130 | SITE_ID = 1 131 | USE_TZ = 1 132 | APPEND_SLASH = True 133 | 134 | LANGUAGE_CODE = 'en-gb' 135 | 136 | SECRET_KEY = 'notverysecret' 137 | 138 | OSCAR_DASHBOARD_NAVIGATION[5]['children'] += [ # noqa F405 139 | { 140 | 'label': 'Content blocks', 141 | 'url_name': 'oscar_promotions_dashboard:promotion-list', 142 | }, 143 | { 144 | 'label': 'Content blocks by page', 145 | 'url_name': 'oscar_promotions_dashboard:promotion-list-by-page', 146 | }, 147 | ] 148 | -------------------------------------------------------------------------------- /oscar_promotions/migrations/0001_initial.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 2.1.1 on 2018-09-02 20:02 2 | 3 | from django.db import migrations, models 4 | import django.db.models.deletion 5 | import oscar.models.fields 6 | 7 | 8 | class Migration(migrations.Migration): 9 | 10 | initial = True 11 | 12 | dependencies = [ 13 | ('contenttypes', '0002_remove_content_type_name'), 14 | ('catalogue', '0013_auto_20170821_1548'), 15 | ] 16 | 17 | operations = [ 18 | migrations.CreateModel( 19 | name='AutomaticProductList', 20 | fields=[ 21 | ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), 22 | ('name', models.CharField(max_length=255, verbose_name='Title')), 23 | ('description', models.TextField(blank=True, verbose_name='Description')), 24 | ('link_url', oscar.models.fields.ExtendedURLField(blank=True, verbose_name='Link URL')), 25 | ('link_text', models.CharField(blank=True, max_length=255, verbose_name='Link text')), 26 | ('date_created', models.DateTimeField(auto_now_add=True)), 27 | ('method', models.CharField(choices=[('Bestselling', 'Bestselling products'), ('RecentlyAdded', 'Recently added products')], max_length=128, verbose_name='Method')), 28 | ('num_products', models.PositiveSmallIntegerField(default=4, verbose_name='Number of Products')), 29 | ], 30 | options={ 31 | 'verbose_name': 'Automatic product list', 32 | 'verbose_name_plural': 'Automatic product lists', 33 | }, 34 | ), 35 | migrations.CreateModel( 36 | name='HandPickedProductList', 37 | fields=[ 38 | ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), 39 | ('name', models.CharField(max_length=255, verbose_name='Title')), 40 | ('description', models.TextField(blank=True, verbose_name='Description')), 41 | ('link_url', oscar.models.fields.ExtendedURLField(blank=True, verbose_name='Link URL')), 42 | ('link_text', models.CharField(blank=True, max_length=255, verbose_name='Link text')), 43 | ('date_created', models.DateTimeField(auto_now_add=True)), 44 | ], 45 | options={ 46 | 'verbose_name': 'Hand Picked Product List', 47 | 'verbose_name_plural': 'Hand Picked Product Lists', 48 | }, 49 | ), 50 | migrations.CreateModel( 51 | name='Image', 52 | fields=[ 53 | ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), 54 | ('name', models.CharField(max_length=128, verbose_name='Name')), 55 | ('link_url', oscar.models.fields.ExtendedURLField(blank=True, help_text='This is where this promotion links to', verbose_name='Link URL')), 56 | ('image', models.ImageField(max_length=255, upload_to='images/promotions/', verbose_name='Image')), 57 | ('date_created', models.DateTimeField(auto_now_add=True)), 58 | ], 59 | options={ 60 | 'verbose_name': 'Image', 61 | 'verbose_name_plural': 'Image', 62 | }, 63 | ), 64 | migrations.CreateModel( 65 | name='KeywordPromotion', 66 | fields=[ 67 | ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), 68 | ('object_id', models.PositiveIntegerField()), 69 | ('position', models.CharField(help_text='Position on page', max_length=100, verbose_name='Position')), 70 | ('display_order', models.PositiveIntegerField(default=0, verbose_name='Display Order')), 71 | ('clicks', models.PositiveIntegerField(default=0, verbose_name='Clicks')), 72 | ('date_created', models.DateTimeField(auto_now_add=True, verbose_name='Date Created')), 73 | ('keyword', models.CharField(max_length=200, verbose_name='Keyword')), 74 | ('filter', models.CharField(blank=True, max_length=200, verbose_name='Filter')), 75 | ('content_type', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='contenttypes.ContentType')), 76 | ], 77 | options={ 78 | 'verbose_name': 'Keyword Promotion', 79 | 'verbose_name_plural': 'Keyword Promotions', 80 | 'ordering': ['-clicks'], 81 | 'abstract': False, 82 | }, 83 | ), 84 | migrations.CreateModel( 85 | name='MultiImage', 86 | fields=[ 87 | ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), 88 | ('name', models.CharField(max_length=128, verbose_name='Name')), 89 | ('date_created', models.DateTimeField(auto_now_add=True)), 90 | ('images', models.ManyToManyField(blank=True, help_text='Choose the Image content blocks that this block will use. (You may need to create some first).', to='oscar_promotions.Image')), 91 | ], 92 | options={ 93 | 'verbose_name': 'Multi Image', 94 | 'verbose_name_plural': 'Multi Images', 95 | }, 96 | ), 97 | migrations.CreateModel( 98 | name='OrderedProduct', 99 | fields=[ 100 | ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), 101 | ('display_order', models.PositiveIntegerField(default=0, verbose_name='Display Order')), 102 | ], 103 | options={ 104 | 'verbose_name': 'Ordered product', 105 | 'verbose_name_plural': 'Ordered product', 106 | 'ordering': ('display_order',), 107 | }, 108 | ), 109 | migrations.CreateModel( 110 | name='PagePromotion', 111 | fields=[ 112 | ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), 113 | ('object_id', models.PositiveIntegerField()), 114 | ('position', models.CharField(help_text='Position on page', max_length=100, verbose_name='Position')), 115 | ('display_order', models.PositiveIntegerField(default=0, verbose_name='Display Order')), 116 | ('clicks', models.PositiveIntegerField(default=0, verbose_name='Clicks')), 117 | ('date_created', models.DateTimeField(auto_now_add=True, verbose_name='Date Created')), 118 | ('page_url', oscar.models.fields.ExtendedURLField(db_index=True, max_length=128, verbose_name='Page URL')), 119 | ('content_type', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='contenttypes.ContentType')), 120 | ], 121 | options={ 122 | 'verbose_name': 'Page Promotion', 123 | 'verbose_name_plural': 'Page Promotions', 124 | 'ordering': ['-clicks'], 125 | 'abstract': False, 126 | }, 127 | ), 128 | migrations.CreateModel( 129 | name='RawHTML', 130 | fields=[ 131 | ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), 132 | ('name', models.CharField(max_length=128, verbose_name='Name')), 133 | ('display_type', models.CharField(blank=True, help_text='This can be used to have different types of HTML blocks (eg different widths)', max_length=128, verbose_name='Display type')), 134 | ('body', models.TextField(verbose_name='HTML')), 135 | ('date_created', models.DateTimeField(auto_now_add=True)), 136 | ], 137 | options={ 138 | 'verbose_name': 'Raw HTML', 139 | 'verbose_name_plural': 'Raw HTML', 140 | }, 141 | ), 142 | migrations.CreateModel( 143 | name='SingleProduct', 144 | fields=[ 145 | ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), 146 | ('name', models.CharField(max_length=128, verbose_name='Name')), 147 | ('description', models.TextField(blank=True, verbose_name='Description')), 148 | ('date_created', models.DateTimeField(auto_now_add=True)), 149 | ('product', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='catalogue.Product')), 150 | ], 151 | options={ 152 | 'verbose_name': 'Single product', 153 | 'verbose_name_plural': 'Single product', 154 | }, 155 | ), 156 | migrations.CreateModel( 157 | name='TabbedBlock', 158 | fields=[ 159 | ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), 160 | ('name', models.CharField(max_length=255, verbose_name='Title')), 161 | ('date_created', models.DateTimeField(auto_now_add=True, verbose_name='Date Created')), 162 | ], 163 | options={ 164 | 'verbose_name': 'Tabbed Block', 165 | 'verbose_name_plural': 'Tabbed Blocks', 166 | }, 167 | ), 168 | migrations.CreateModel( 169 | name='OrderedProductList', 170 | fields=[ 171 | ('handpickedproductlist_ptr', models.OneToOneField(auto_created=True, on_delete=django.db.models.deletion.CASCADE, parent_link=True, primary_key=True, serialize=False, to='oscar_promotions.HandPickedProductList')), 172 | ('display_order', models.PositiveIntegerField(default=0, verbose_name='Display Order')), 173 | ('tabbed_block', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='tabs', to='oscar_promotions.TabbedBlock', verbose_name='Tabbed Block')), 174 | ], 175 | options={ 176 | 'verbose_name': 'Ordered Product List', 177 | 'verbose_name_plural': 'Ordered Product Lists', 178 | 'ordering': ('display_order',), 179 | }, 180 | bases=('oscar_promotions.handpickedproductlist',), 181 | ), 182 | migrations.AddField( 183 | model_name='orderedproduct', 184 | name='list', 185 | field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='oscar_promotions.HandPickedProductList', verbose_name='List'), 186 | ), 187 | migrations.AddField( 188 | model_name='orderedproduct', 189 | name='product', 190 | field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='catalogue.Product', verbose_name='Product'), 191 | ), 192 | migrations.AddField( 193 | model_name='handpickedproductlist', 194 | name='products', 195 | field=models.ManyToManyField(blank=True, through='oscar_promotions.OrderedProduct', to='catalogue.Product', verbose_name='Products'), 196 | ), 197 | migrations.AlterUniqueTogether( 198 | name='orderedproduct', 199 | unique_together={('list', 'product')}, 200 | ), 201 | ] 202 | -------------------------------------------------------------------------------- /oscar_promotions/models.py: -------------------------------------------------------------------------------- 1 | from django.contrib.contenttypes import fields 2 | from django.contrib.contenttypes.models import ContentType 3 | from django.db import models 4 | from django.urls import reverse 5 | from django.utils.translation import gettext_lazy as _, pgettext_lazy 6 | from oscar.core.loading import get_model 7 | from oscar.models.fields import ExtendedURLField 8 | 9 | from oscar_promotions import app_settings 10 | 11 | # Linking models - these link promotions to content (eg pages, or keywords) 12 | 13 | 14 | class LinkedPromotion(models.Model): 15 | 16 | # We use generic foreign key to link to a promotion model 17 | content_type = models.ForeignKey(ContentType, on_delete=models.CASCADE) 18 | object_id = models.PositiveIntegerField() 19 | content_object = fields.GenericForeignKey('content_type', 'object_id') 20 | 21 | position = models.CharField(_("Position"), max_length=100, 22 | help_text=_("Position on page")) 23 | display_order = models.PositiveIntegerField(_("Display Order"), default=0) 24 | clicks = models.PositiveIntegerField(_("Clicks"), default=0) 25 | date_created = models.DateTimeField(_("Date Created"), auto_now_add=True) 26 | 27 | class Meta: 28 | abstract = True 29 | app_label = 'oscar_promotions' 30 | ordering = ['-clicks'] 31 | verbose_name = _("Linked Promotion") 32 | verbose_name_plural = _("Linked Promotions") 33 | 34 | def record_click(self): 35 | self.clicks += 1 36 | self.save() 37 | record_click.alters_data = True 38 | 39 | 40 | class PagePromotion(LinkedPromotion): 41 | """ 42 | A promotion embedded on a particular page. 43 | """ 44 | page_url = ExtendedURLField( 45 | _('Page URL'), max_length=128, db_index=True) 46 | 47 | def __str__(self): 48 | return "%s on %s" % (self.content_object, self.page_url) 49 | 50 | def get_link(self): 51 | return reverse('promotions:page-click', 52 | kwargs={'page_promotion_id': self.id}) 53 | 54 | class Meta(LinkedPromotion.Meta): 55 | verbose_name = _("Page Promotion") 56 | verbose_name_plural = _("Page Promotions") 57 | 58 | 59 | class KeywordPromotion(LinkedPromotion): 60 | """ 61 | A promotion linked to a specific keyword. 62 | 63 | This can be used on a search results page to show promotions 64 | linked to a particular keyword. 65 | """ 66 | 67 | keyword = models.CharField(_("Keyword"), max_length=200) 68 | 69 | # We allow an additional filter which will let search query matches 70 | # be restricted to different parts of the site. 71 | filter = models.CharField(_("Filter"), max_length=200, blank=True) 72 | 73 | def get_link(self): 74 | return reverse('promotions:keyword-click', 75 | kwargs={'keyword_promotion_id': self.id}) 76 | 77 | class Meta(LinkedPromotion.Meta): 78 | verbose_name = _("Keyword Promotion") 79 | verbose_name_plural = _("Keyword Promotions") 80 | 81 | # Different model types for each type of promotion 82 | 83 | 84 | class AbstractPromotion(models.Model): 85 | """ 86 | Abstract base promotion that defines the interface 87 | that subclasses must implement. 88 | """ 89 | _type = 'Promotion' 90 | keywords = fields.GenericRelation(KeywordPromotion, 91 | verbose_name=_('Keywords')) 92 | pages = fields.GenericRelation(PagePromotion, verbose_name=_('Pages')) 93 | 94 | class Meta: 95 | abstract = True 96 | app_label = 'oscar_promotions' 97 | verbose_name = _("Promotion") 98 | verbose_name_plural = _("Promotions") 99 | 100 | @property 101 | def type(self): 102 | return _(self._type) 103 | 104 | @classmethod 105 | def classname(cls): 106 | return cls.__name__.lower() 107 | 108 | @property 109 | def code(self): 110 | return self.__class__.__name__.lower() 111 | 112 | def template_name(self): 113 | """ 114 | Returns the template to use to render this promotion. 115 | """ 116 | return 'oscar_promotions/%s.html' % self.code 117 | 118 | def template_context(self, request): 119 | return {} 120 | 121 | @property 122 | def content_type(self): 123 | return ContentType.objects.get_for_model(self) 124 | 125 | @property 126 | def num_times_used(self): 127 | ctype = self.content_type 128 | page_count = PagePromotion.objects.filter(content_type=ctype, 129 | object_id=self.id).count() 130 | keyword_count \ 131 | = KeywordPromotion.objects.filter(content_type=ctype, 132 | object_id=self.id).count() 133 | return page_count + keyword_count 134 | 135 | 136 | class RawHTML(AbstractPromotion): 137 | """ 138 | Simple promotion - just raw HTML 139 | """ 140 | _type = 'Raw HTML' 141 | name = models.CharField(_("Name"), max_length=128) 142 | 143 | # Used to determine how to render the promotion (eg 144 | # if a different width container is required). This isn't always 145 | # required. 146 | display_type = models.CharField( 147 | _("Display type"), max_length=128, blank=True, 148 | help_text=_("This can be used to have different types of HTML blocks" 149 | " (eg different widths)")) 150 | body = models.TextField(_("HTML")) 151 | date_created = models.DateTimeField(auto_now_add=True) 152 | 153 | class Meta: 154 | verbose_name = _('Raw HTML') 155 | verbose_name_plural = _('Raw HTML') 156 | 157 | def __str__(self): 158 | return self.name 159 | 160 | 161 | class Image(AbstractPromotion): 162 | """ 163 | An image promotion is simply a named image which has an optional 164 | link to another part of the site (or another site). 165 | 166 | This can be used to model both banners and pods. 167 | """ 168 | _type = 'Image' 169 | name = models.CharField(_("Name"), max_length=128) 170 | link_url = ExtendedURLField( 171 | _('Link URL'), blank=True, 172 | help_text=_('This is where this promotion links to')) 173 | image = models.ImageField( 174 | _('Image'), upload_to=app_settings.OSCAR_PROMOTIONS_FOLDER, 175 | max_length=255) 176 | date_created = models.DateTimeField(auto_now_add=True) 177 | 178 | def __str__(self): 179 | return self.name 180 | 181 | class Meta: 182 | verbose_name = _("Image") 183 | verbose_name_plural = _("Image") 184 | 185 | 186 | class MultiImage(AbstractPromotion): 187 | """ 188 | A multi-image promotion is simply a collection of image promotions 189 | that are rendered in a specific way. This models things like 190 | rotating banners. 191 | """ 192 | _type = 'Multi-image' 193 | name = models.CharField(_("Name"), max_length=128) 194 | images = models.ManyToManyField( 195 | 'oscar_promotions.Image', blank=True, 196 | help_text=_( 197 | "Choose the Image content blocks that this block will use. " 198 | "(You may need to create some first).")) 199 | date_created = models.DateTimeField(auto_now_add=True) 200 | 201 | def __str__(self): 202 | return self.name 203 | 204 | class Meta: 205 | verbose_name = _("Multi Image") 206 | verbose_name_plural = _("Multi Images") 207 | 208 | 209 | class SingleProduct(AbstractPromotion): 210 | _type = 'Single product' 211 | name = models.CharField(_("Name"), max_length=128) 212 | product = models.ForeignKey('catalogue.Product', on_delete=models.CASCADE) 213 | description = models.TextField(_("Description"), blank=True) 214 | date_created = models.DateTimeField(auto_now_add=True) 215 | 216 | def __str__(self): 217 | return self.name 218 | 219 | def template_context(self, request): 220 | return {'product': self.product} 221 | 222 | class Meta: 223 | verbose_name = _("Single product") 224 | verbose_name_plural = _("Single product") 225 | 226 | 227 | class AbstractProductList(AbstractPromotion): 228 | """ 229 | Abstract superclass for promotions which are essentially a list 230 | of products. 231 | """ 232 | name = models.CharField( 233 | pgettext_lazy("Promotion product list title", "Title"), 234 | max_length=255) 235 | description = models.TextField(_("Description"), blank=True) 236 | link_url = ExtendedURLField(_('Link URL'), blank=True) 237 | link_text = models.CharField(_("Link text"), max_length=255, blank=True) 238 | date_created = models.DateTimeField(auto_now_add=True) 239 | 240 | class Meta: 241 | abstract = True 242 | verbose_name = _("Product list") 243 | verbose_name_plural = _("Product lists") 244 | 245 | def __str__(self): 246 | return self.name 247 | 248 | def template_context(self, request): 249 | return {'products': self.get_products()} 250 | 251 | 252 | class HandPickedProductList(AbstractProductList): 253 | """ 254 | A hand-picked product list is a list of manually selected 255 | products. 256 | """ 257 | _type = 'Product list' 258 | products = models.ManyToManyField('catalogue.Product', 259 | through='OrderedProduct', blank=True, 260 | verbose_name=_("Products")) 261 | 262 | def get_queryset(self): 263 | return self.products.base_queryset()\ 264 | .order_by('%s.display_order' % OrderedProduct._meta.db_table) 265 | 266 | def get_products(self): 267 | return self.get_queryset() 268 | 269 | class Meta: 270 | verbose_name = _("Hand Picked Product List") 271 | verbose_name_plural = _("Hand Picked Product Lists") 272 | 273 | 274 | class OrderedProduct(models.Model): 275 | 276 | list = models.ForeignKey( 277 | 'oscar_promotions.HandPickedProductList', 278 | on_delete=models.CASCADE, 279 | verbose_name=_("List")) 280 | product = models.ForeignKey( 281 | 'catalogue.Product', 282 | on_delete=models.CASCADE, 283 | verbose_name=_("Product")) 284 | display_order = models.PositiveIntegerField(_('Display Order'), default=0) 285 | 286 | class Meta: 287 | app_label = 'oscar_promotions' 288 | ordering = ('display_order',) 289 | unique_together = ('list', 'product') 290 | verbose_name = _("Ordered product") 291 | verbose_name_plural = _("Ordered product") 292 | 293 | 294 | class AutomaticProductList(AbstractProductList): 295 | 296 | _type = 'Auto-product list' 297 | BESTSELLING, RECENTLY_ADDED = ('Bestselling', 'RecentlyAdded') 298 | METHOD_CHOICES = ( 299 | (BESTSELLING, _("Bestselling products")), 300 | (RECENTLY_ADDED, _("Recently added products")), 301 | ) 302 | method = models.CharField(_('Method'), max_length=128, 303 | choices=METHOD_CHOICES) 304 | num_products = models.PositiveSmallIntegerField(_('Number of Products'), 305 | default=4) 306 | 307 | def get_queryset(self): 308 | Product = get_model('catalogue', 'Product') 309 | qs = Product.objects.browsable().base_queryset().select_related('stats') 310 | if self.method == self.BESTSELLING: 311 | return qs.order_by('-stats__score') 312 | return qs.order_by('-date_created') 313 | 314 | def get_products(self): 315 | return self.get_queryset()[:self.num_products] 316 | 317 | class Meta: 318 | verbose_name = _("Automatic product list") 319 | verbose_name_plural = _("Automatic product lists") 320 | 321 | 322 | class OrderedProductList(HandPickedProductList): 323 | tabbed_block = models.ForeignKey( 324 | 'oscar_promotions.TabbedBlock', 325 | on_delete=models.CASCADE, 326 | related_name='tabs', 327 | verbose_name=_("Tabbed Block")) 328 | display_order = models.PositiveIntegerField(_('Display Order'), default=0) 329 | 330 | class Meta: 331 | ordering = ('display_order',) 332 | verbose_name = _("Ordered Product List") 333 | verbose_name_plural = _("Ordered Product Lists") 334 | 335 | 336 | class TabbedBlock(AbstractPromotion): 337 | 338 | _type = 'Tabbed block' 339 | name = models.CharField( 340 | pgettext_lazy("Tabbed block title", "Title"), max_length=255) 341 | date_created = models.DateTimeField(_("Date Created"), auto_now_add=True) 342 | 343 | class Meta: 344 | verbose_name = _("Tabbed Block") 345 | verbose_name_plural = _("Tabbed Blocks") 346 | -------------------------------------------------------------------------------- /oscar_promotions/dashboard/views.py: -------------------------------------------------------------------------------- 1 | import itertools 2 | 3 | from django.contrib import messages 4 | from django.contrib.contenttypes.models import ContentType 5 | from django.db.models import Count 6 | from django.http import HttpResponseRedirect 7 | from django.shortcuts import HttpResponse 8 | from django.urls import reverse 9 | from django.utils.translation import gettext_lazy as _ 10 | from django.views import generic 11 | from oscar.core.loading import get_class, get_classes, get_model 12 | 13 | from oscar_promotions import app_settings 14 | from oscar_promotions.conf import PROMOTION_CLASSES 15 | 16 | AutomaticProductList = get_model('oscar_promotions', 'AutomaticProductList') 17 | HandPickedProductList = get_model('oscar_promotions', 'HandPickedProductList') 18 | Image = get_model('oscar_promotions', 'Image') 19 | MultiImage = get_model('oscar_promotions', 'MultiImage') 20 | PagePromotion = get_model('oscar_promotions', 'PagePromotion') 21 | RawHTML = get_model('oscar_promotions', 'RawHTML') 22 | SingleProduct = get_model('oscar_promotions', 'SingleProduct') 23 | 24 | 25 | SelectForm, RawHTMLForm, PagePromotionForm, HandPickedProductListForm, \ 26 | SingleProductForm \ 27 | = get_classes('oscar_promotions.dashboard.forms', 28 | ['PromotionTypeSelectForm', 'RawHTMLForm', 29 | 'PagePromotionForm', 'HandPickedProductListForm', 30 | 'SingleProductForm'], module_prefix='oscar_promotions') 31 | 32 | OrderedProductFormSet = get_class( 33 | 'oscar_promotions.dashboard.formsets', 'OrderedProductFormSet', module_prefix='oscar_promotions' 34 | ) 35 | 36 | 37 | class ListView(generic.TemplateView): 38 | template_name = 'oscar_promotions/dashboard/promotion_list.html' 39 | 40 | def get_context_data(self): 41 | # Need to load all promotions of all types and chain them together 42 | # no pagination required for now. 43 | data = [] 44 | num_promotions = 0 45 | for klass in PROMOTION_CLASSES: 46 | objects = klass.objects.all() 47 | num_promotions += objects.count() 48 | data.append(objects) 49 | promotions = itertools.chain(*data) 50 | ctx = { 51 | 'num_promotions': num_promotions, 52 | 'promotions': promotions, 53 | 'select_form': SelectForm(), 54 | } 55 | return ctx 56 | 57 | 58 | class CreateRedirectView(generic.RedirectView): 59 | permanent = True 60 | 61 | def get_redirect_url(self, **kwargs): 62 | code = self.request.GET.get('promotion_type', None) 63 | urls = {} 64 | for klass in PROMOTION_CLASSES: 65 | urls[klass.classname()] = reverse( 66 | 'oscar_promotions_dashboard:promotion-create-%s' % 67 | klass.classname()) 68 | return urls.get(code, None) 69 | 70 | 71 | class PageListView(generic.TemplateView): 72 | template_name = 'oscar_promotions/dashboard/pagepromotion_list.html' 73 | 74 | def get_context_data(self, *args, **kwargs): 75 | pages = PagePromotion.objects.all().values( 76 | 'page_url').distinct().annotate(freq=Count('id')) 77 | return {'pages': pages} 78 | 79 | 80 | class PageDetailView(generic.TemplateView): 81 | template_name = 'oscar_promotions/dashboard/page_detail.html' 82 | 83 | def get_context_data(self, *args, **kwargs): 84 | path = self.kwargs['path'] 85 | return {'page_url': path, 86 | 'positions': self.get_positions_context_data(path), } 87 | 88 | def get_positions_context_data(self, path): 89 | ctx = [] 90 | for code, name in app_settings.OSCAR_PROMOTIONS_POSITIONS: 91 | promotions = PagePromotion._default_manager.select_related() \ 92 | .filter(page_url=path, 93 | position=code) 94 | ctx.append({ 95 | 'code': code, 96 | 'name': name, 97 | 'promotions': promotions.order_by('display_order'), 98 | }) 99 | return ctx 100 | 101 | def post(self, request, **kwargs): 102 | """ 103 | When called with a post request, try and get 'promo' from 104 | the post data and use it to reorder the page content blocks. 105 | """ 106 | data = dict(request.POST).get('promo') 107 | self._save_page_order(data) 108 | return HttpResponse(status=200) 109 | 110 | def _save_page_order(self, data): 111 | """ 112 | Save the order of the pages. This gets used when an ajax request 113 | posts backa new order for promotions within page regions. 114 | """ 115 | for index, item in enumerate(data): 116 | page = PagePromotion.objects.get(pk=item) 117 | if page.display_order != index: 118 | page.display_order = index 119 | page.save() 120 | 121 | 122 | class PromotionMixin(object): 123 | def get_template_names(self): 124 | return ['oscar_promotions/dashboard/%s_form.html' % self.model.classname(), 125 | 'oscar_promotions/dashboard/form.html'] 126 | 127 | 128 | class DeletePagePromotionView(generic.DeleteView): 129 | template_name = 'oscar_promotions/dashboard/delete_pagepromotion.html' 130 | model = PagePromotion 131 | 132 | def get_success_url(self): 133 | messages.info(self.request, _("Content block removed successfully")) 134 | return reverse('oscar_promotions_dashboard:promotion-list-by-url', 135 | kwargs={'path': self.object.page_url}) 136 | 137 | 138 | # ============ 139 | # CREATE VIEWS 140 | # ============ 141 | 142 | 143 | class CreateView(PromotionMixin, generic.CreateView): 144 | 145 | def get_success_url(self): 146 | messages.success(self.request, _("Content block created successfully")) 147 | return reverse('oscar_promotions_dashboard:promotion-update', 148 | kwargs={'ptype': self.model.classname(), 149 | 'pk': self.object.id}) 150 | 151 | def get_context_data(self, *args, **kwargs): 152 | ctx = super().get_context_data(*args, **kwargs) 153 | ctx['heading'] = self.get_heading() 154 | return ctx 155 | 156 | def get_heading(self): 157 | if hasattr(self, 'heading'): 158 | return getattr(self, 'heading') 159 | return _('Create a new %s content block') % self.model._type 160 | 161 | 162 | class CreateRawHTMLView(CreateView): 163 | model = RawHTML 164 | form_class = RawHTMLForm 165 | 166 | 167 | class CreateSingleProductView(CreateView): 168 | model = SingleProduct 169 | form_class = SingleProductForm 170 | 171 | 172 | class CreateImageView(CreateView): 173 | model = Image 174 | fields = ['name', 'link_url', 'image'] 175 | 176 | 177 | class CreateMultiImageView(CreateView): 178 | model = MultiImage 179 | fields = ['name'] 180 | 181 | 182 | class CreateAutomaticProductListView(CreateView): 183 | model = AutomaticProductList 184 | fields = ['name', 'description', 'link_url', 'link_text', 'method', 185 | 'num_products'] 186 | 187 | 188 | class CreateHandPickedProductListView(CreateView): 189 | model = HandPickedProductList 190 | form_class = HandPickedProductListForm 191 | 192 | def get_context_data(self, **kwargs): 193 | ctx = super(CreateHandPickedProductListView, 194 | self).get_context_data(**kwargs) 195 | if 'product_formset' not in kwargs: 196 | ctx['product_formset'] \ 197 | = OrderedProductFormSet(instance=self.object) 198 | return ctx 199 | 200 | def form_valid(self, form): 201 | promotion = form.save(commit=False) 202 | product_formset = OrderedProductFormSet(self.request.POST, 203 | instance=promotion) 204 | if product_formset.is_valid(): 205 | promotion.save() 206 | product_formset.save() 207 | self.object = promotion 208 | messages.success(self.request, 209 | _('Product list content block created')) 210 | return HttpResponseRedirect(self.get_success_url()) 211 | 212 | ctx = self.get_context_data(product_formset=product_formset) 213 | return self.render_to_response(ctx) 214 | 215 | 216 | # ============ 217 | # UPDATE VIEWS 218 | # ============ 219 | 220 | 221 | class UpdateView(PromotionMixin, generic.UpdateView): 222 | actions = ('add_to_page', 'remove_from_page') 223 | link_form_class = PagePromotionForm 224 | 225 | def get_context_data(self, *args, **kwargs): 226 | ctx = super().get_context_data(*args, **kwargs) 227 | ctx['heading'] = _("Update content block") 228 | ctx['promotion'] = self.get_object() 229 | ctx['link_form'] = self.link_form_class() 230 | content_type = ContentType.objects.get_for_model(self.model) 231 | ctx['links'] = PagePromotion.objects.filter(content_type=content_type, 232 | object_id=self.object.id) 233 | return ctx 234 | 235 | def post(self, request, *args, **kwargs): 236 | action = request.POST.get('action', None) 237 | if action in self.actions: 238 | self.object = self.get_object() 239 | return getattr(self, action)(self.object, request, *args, **kwargs) 240 | return super().post(request, *args, **kwargs) 241 | 242 | def get_success_url(self): 243 | messages.info(self.request, _("Content block updated successfully")) 244 | return reverse('oscar_promotions_dashboard:promotion-list') 245 | 246 | def add_to_page(self, promotion, request, *args, **kwargs): 247 | instance = PagePromotion(content_object=self.get_object()) 248 | form = self.link_form_class(request.POST, instance=instance) 249 | if form.is_valid(): 250 | form.save() 251 | page_url = form.cleaned_data['page_url'] 252 | messages.success(request, _("Content block '%(block)s' added to" 253 | " page '%(page)s'") 254 | % {'block': promotion.name, 255 | 'page': page_url}) 256 | return HttpResponseRedirect( 257 | reverse('oscar_promotions_dashboard:promotion-update', 258 | kwargs=kwargs)) 259 | 260 | main_form = self.get_form_class()(instance=self.object) 261 | ctx = self.get_context_data(form=main_form) 262 | ctx['link_form'] = form 263 | return self.render_to_response(ctx) 264 | 265 | def remove_from_page(self, promotion, request, *args, **kwargs): 266 | link_id = request.POST['pagepromotion_id'] 267 | try: 268 | link = PagePromotion.objects.get(id=link_id) 269 | except PagePromotion.DoesNotExist: 270 | messages.error(request, _("No link found to delete")) 271 | else: 272 | page_url = link.page_url 273 | link.delete() 274 | messages.success(request, _("Content block removed from page '%s'") 275 | % page_url) 276 | return HttpResponseRedirect( 277 | reverse('oscar_promotions_dashboard:promotion-update', 278 | kwargs=kwargs)) 279 | 280 | 281 | class UpdateRawHTMLView(UpdateView): 282 | model = RawHTML 283 | form_class = RawHTMLForm 284 | 285 | 286 | class UpdateSingleProductView(UpdateView): 287 | model = SingleProduct 288 | form_class = SingleProductForm 289 | 290 | 291 | class UpdateImageView(UpdateView): 292 | model = Image 293 | fields = ['name', 'link_url', 'image'] 294 | 295 | 296 | class UpdateMultiImageView(UpdateView): 297 | model = MultiImage 298 | fields = ['name', 'images'] 299 | 300 | 301 | class UpdateAutomaticProductListView(UpdateView): 302 | model = AutomaticProductList 303 | fields = ['name', 'description', 'link_url', 'link_text', 'method', 304 | 'num_products'] 305 | 306 | 307 | class UpdateHandPickedProductListView(UpdateView): 308 | model = HandPickedProductList 309 | form_class = HandPickedProductListForm 310 | 311 | def get_context_data(self, **kwargs): 312 | ctx = super(UpdateHandPickedProductListView, 313 | self).get_context_data(**kwargs) 314 | if 'product_formset' not in kwargs: 315 | ctx['product_formset'] \ 316 | = OrderedProductFormSet(instance=self.object) 317 | return ctx 318 | 319 | def form_valid(self, form): 320 | promotion = form.save(commit=False) 321 | product_formset = OrderedProductFormSet(self.request.POST, 322 | instance=promotion) 323 | if product_formset.is_valid(): 324 | promotion.save() 325 | product_formset.save() 326 | self.object = promotion 327 | messages.success(self.request, _('Product list promotion updated')) 328 | return HttpResponseRedirect(self.get_success_url()) 329 | 330 | ctx = self.get_context_data(product_formset=product_formset) 331 | return self.render_to_response(ctx) 332 | 333 | # ============ 334 | # DELETE VIEWS 335 | # ============ 336 | 337 | 338 | class DeleteView(generic.DeleteView): 339 | template_name = 'oscar_promotions/dashboard/delete.html' 340 | 341 | def get_success_url(self): 342 | messages.info(self.request, _("Content block deleted successfully")) 343 | return reverse('oscar_promotions_dashboard:promotion-list') 344 | 345 | 346 | class DeleteRawHTMLView(DeleteView): 347 | model = RawHTML 348 | 349 | 350 | class DeleteSingleProductView(DeleteView): 351 | model = SingleProduct 352 | 353 | 354 | class DeleteImageView(DeleteView): 355 | model = Image 356 | 357 | 358 | class DeleteMultiImageView(DeleteView): 359 | model = MultiImage 360 | 361 | 362 | class DeleteAutomaticProductListView(DeleteView): 363 | model = AutomaticProductList 364 | 365 | 366 | class DeleteHandPickedProductListView(DeleteView): 367 | model = HandPickedProductList 368 | --------------------------------------------------------------------------------