Add sales channels (#1103)

- [x] Data model
- [x] Enforce constraint
- [x] Filter order list
- [x] Set channel on created order
- [x] Products API
- [x] Order API
- [x] Tests
- [x] Filter reports
- [x] Resellers
- [ ] deploy plugins
  - [ ] posbackend
  - [ ] resellers
  - [ ] reports
- [x] Ticketlayouts
- [x] Support in pretixPOS
This commit is contained in:
Raphael Michel
2018-11-23 15:35:09 +01:00
committed by GitHub
parent 0f76779fb1
commit b4290384e1
39 changed files with 472 additions and 57 deletions

View File

@@ -0,0 +1,66 @@
import logging
from collections import OrderedDict
from django.dispatch import receiver
from django.utils.translation import ugettext_lazy as _
from pretix.base.signals import register_sales_channels
logger = logging.getLogger(__name__)
_ALL_CHANNELS = None
class SalesChannel:
def __repr__(self):
return '<SalesChannel: {}>'.format(self.identifier)
@property
def identifier(self) -> str:
"""
The internal identifier of this sales channel.
"""
raise NotImplementedError() # NOQA
@property
def verbose_name(self) -> str:
"""
A human-readable name of this sales channel.
"""
raise NotImplementedError() # NOQA
@property
def icon(self) -> str:
"""
The name of a Font Awesome icon to represent this channel
"""
return "circle"
def get_all_sales_channels():
global _ALL_CHANNELS
if _ALL_CHANNELS:
return _ALL_CHANNELS
types = OrderedDict()
for recv, ret in register_sales_channels.send(None):
if isinstance(ret, (list, tuple)):
for r in ret:
types[r.identifier] = r
else:
types[ret.identifier] = ret
_ALL_CHANNELS = types
return types
class WebshopSalesChannel(SalesChannel):
identifier = "web"
verbose_name = _('Online shop')
icon = "globe"
@receiver(register_sales_channels, dispatch_uid="base_register_default_sales_channels")
def base_sales_channels(sender, **kwargs):
return (
WebshopSalesChannel(),
)

View File

@@ -0,0 +1,29 @@
# Generated by Django 2.1.1 on 2018-11-21 12:24
import django.db.models.deletion
import jsonfallback.fields
from django.db import migrations, models
import pretix.base.models.fields
class Migration(migrations.Migration):
dependencies = [
('pretixbase', '0102_auto_20181017_0024'),
]
operations = [
migrations.AddField(
model_name='item',
name='sales_channels',
field=pretix.base.models.fields.MultiStringField(default=['web'], verbose_name='Sales channels'),
preserve_default=False,
),
migrations.AddField(
model_name='order',
name='sales_channel',
field=models.CharField(default='web', max_length=190),
preserve_default=False,
),
]

View File

@@ -0,0 +1,94 @@
from django.core import exceptions
from django.db.models import TextField, lookups as builtin_lookups
from django.utils.translation import gettext_lazy as _
from rest_framework import serializers
from rest_framework.exceptions import ValidationError
DELIMITER = "\x1F"
class MultiStringField(TextField):
default_error_messages = {
'delimiter_found': _('No value can contain the delimiter character.')
}
def __init__(self, verbose_name=None, name=None, **kwargs):
super().__init__(verbose_name, name, **kwargs)
def deconstruct(self):
name, path, args, kwargs = super().deconstruct()
return name, path, args, kwargs
def to_python(self, value):
if isinstance(value, (list, tuple)):
return value
elif value:
return [v for v in value.split(DELIMITER) if v]
else:
return []
def get_prep_value(self, value):
if isinstance(value, (list, tuple)):
return DELIMITER + DELIMITER.join(value) + DELIMITER
elif value is None:
return ""
raise TypeError("Invalid data type passed.")
def get_prep_lookup(self, lookup_type, value): # NOQA
raise TypeError('Lookups on multi strings are currently not supported.')
def from_db_value(self, value, expression, connection, context):
if value:
return [v for v in value.split(DELIMITER) if v]
else:
return []
def validate(self, value, model_instance):
super().validate(value, model_instance)
for l in value:
if DELIMITER in l:
raise exceptions.ValidationError(
self.error_messages['delimiter_found'],
code='delimiter_found',
)
def get_lookup(self, lookup_name):
if lookup_name == 'contains':
return MultiStringContains
elif lookup_name == 'icontains':
return MultiStringIContains
raise NotImplementedError(
"Lookup '{}' doesn't work with MultiStringField".format(lookup_name),
)
class MultiStringContains(builtin_lookups.Contains):
def process_rhs(self, qn, connection):
sql, params = super().process_rhs(qn, connection)
params[0] = "%" + DELIMITER + params[0][1:-1] + DELIMITER + "%"
return sql, params
class MultiStringIContains(builtin_lookups.IContains):
def process_rhs(self, qn, connection):
sql, params = super().process_rhs(qn, connection)
params[0] = "%" + DELIMITER + params[0][1:-1] + DELIMITER + "%"
return sql, params
class MultiStringSerializer(serializers.Field):
def __init__(self, **kwargs):
self.allow_blank = kwargs.pop('allow_blank', False)
super().__init__(**kwargs)
def to_representation(self, value):
return value
def to_internal_value(self, data):
if isinstance(data, list):
return data
else:
raise ValidationError('Invalid data type.')
serializers.ModelSerializer.serializer_field_mapping[MultiStringField] = MultiStringSerializer

View File

@@ -17,6 +17,7 @@ from django.utils.timezone import is_naive, make_aware, now
from django.utils.translation import pgettext_lazy, ugettext_lazy as _
from i18nfield.fields import I18nCharField, I18nTextField
from pretix.base.models import fields
from pretix.base.models.base import LoggedModel
from pretix.base.models.tax import TaxedPrice
@@ -195,6 +196,8 @@ class Item(LoggedModel):
:type original_price: decimal.Decimal
:param require_approval: If set to ``True``, orders containing this product can only be processed and paid after approved by an administrator
:type require_approval: bool
:param sales_channels: Sales channels this item is available on.
:type sales_channels: bool
"""
event = models.ForeignKey(
@@ -329,6 +332,10 @@ class Item(LoggedModel):
help_text=_('If set, this will be displayed next to the current price to show that the current price is a '
'discounted one. This is just a cosmetic setting and will not actually impact pricing.')
)
sales_channels = fields.MultiStringField(
verbose_name=_('Sales channels'),
default=['web']
)
# !!! Attention: If you add new fields here, also add them to the copying code in
# pretix/control/forms/item.py if applicable.

View File

@@ -94,6 +94,8 @@ class Order(LockModel, LoggedModel):
:type require_approval: bool
:param meta_info: Additional meta information on the order, JSON-encoded.
:type meta_info: str
:param sales_channel: Identifier of the sales channel this order was created through.
:type sales_channel: str
"""
STATUS_PENDING = "n"
@@ -174,6 +176,7 @@ class Order(LockModel, LoggedModel):
require_approval = models.BooleanField(
default=False
)
sales_channel = models.CharField(max_length=190, default="web")
class Meta:
verbose_name = _("Order")

View File

@@ -102,7 +102,8 @@ class CartManager:
AddOperation: 30
}
def __init__(self, event: Event, cart_id: str, invoice_address: InvoiceAddress=None, widget_data=None):
def __init__(self, event: Event, cart_id: str, invoice_address: InvoiceAddress=None, widget_data=None,
sales_channel='web'):
self.event = event
self.cart_id = cart_id
self.now_dt = now()
@@ -115,6 +116,7 @@ class CartManager:
self._expiry = None
self.invoice_address = invoice_address
self._widget_data = widget_data or {}
self._sales_channel = sales_channel
@property
def positions(self):
@@ -192,6 +194,9 @@ class CartManager:
if not op.item.is_available() or (op.variation and not op.variation.active):
raise CartError(error_messages['unavailable'])
if self._sales_channel not in op.item.sales_channels:
raise CartError(error_messages['unavailable'])
if op.voucher and not op.voucher.applies_to(op.item, op.variation):
raise CartError(error_messages['voucher_invalid_item'])
@@ -735,7 +740,7 @@ def get_fees(event, request, total, invoice_address, provider):
@app.task(base=ProfiledTask, bind=True, max_retries=5, default_retry_delay=1, throws=(CartError,))
def add_items_to_cart(self, event: int, items: List[dict], cart_id: str=None, locale='en',
invoice_address: int=None, widget_data=None) -> None:
invoice_address: int=None, widget_data=None, sales_channel='web') -> None:
"""
Adds a list of items to a user's cart.
:param event: The event ID in question
@@ -755,7 +760,8 @@ def add_items_to_cart(self, event: int, items: List[dict], cart_id: str=None, lo
try:
try:
cm = CartManager(event=event, cart_id=cart_id, invoice_address=ia, widget_data=widget_data)
cm = CartManager(event=event, cart_id=cart_id, invoice_address=ia, widget_data=widget_data,
sales_channel=sales_channel)
cm.add_new_items(items)
cm.commit()
except LockTimeoutException:
@@ -807,7 +813,7 @@ def clear_cart(self, event: int, cart_id: str=None, locale='en') -> None:
@app.task(base=ProfiledTask, bind=True, max_retries=5, default_retry_delay=1, throws=(CartError,))
def set_cart_addons(self, event: int, addons: List[dict], cart_id: str=None, locale='en',
invoice_address: int=None) -> None:
invoice_address: int=None, sales_channel='web') -> None:
"""
Removes a list of items from a user's cart.
:param event: The event ID in question
@@ -825,7 +831,7 @@ def set_cart_addons(self, event: int, addons: List[dict], cart_id: str=None, loc
pass
try:
try:
cm = CartManager(event=event, cart_id=cart_id, invoice_address=ia)
cm = CartManager(event=event, cart_id=cart_id, invoice_address=ia, sales_channel=sales_channel)
cm.set_addons(addons)
cm.commit()
except LockTimeoutException:

View File

@@ -498,7 +498,7 @@ def _get_fees(positions: List[CartPosition], payment_provider: BasePaymentProvid
def _create_order(event: Event, email: str, positions: List[CartPosition], now_dt: datetime,
payment_provider: BasePaymentProvider, locale: str=None, address: InvoiceAddress=None,
meta_info: dict=None):
meta_info: dict=None, sales_channel: str='web'):
fees, pf = _get_fees(positions, payment_provider, address, meta_info, event)
total = sum([c.price for c in positions]) + sum([c.value for c in fees])
@@ -511,7 +511,8 @@ def _create_order(event: Event, email: str, positions: List[CartPosition], now_d
locale=locale,
total=total,
meta_info=json.dumps(meta_info or {}),
require_approval=any(p.item.require_approval for p in positions)
require_approval=any(p.item.require_approval for p in positions),
sales_channel=sales_channel
)
order.set_expires(now_dt, event.subevents.filter(id__in=[p.subevent_id for p in positions]))
order.save()
@@ -550,7 +551,7 @@ def _create_order(event: Event, email: str, positions: List[CartPosition], now_d
def _perform_order(event: str, payment_provider: str, position_ids: List[str],
email: str, locale: str, address: int, meta_info: dict=None):
email: str, locale: str, address: int, meta_info: dict=None, sales_channel: str='web'):
event = Event.objects.get(id=event)
if payment_provider:
@@ -579,7 +580,7 @@ def _perform_order(event: str, payment_provider: str, position_ids: List[str],
raise OrderError(error_messages['internal'])
_check_positions(event, now_dt, positions, address=addr)
order = _create_order(event, email, positions, now_dt, pprov,
locale=locale, address=addr, meta_info=meta_info)
locale=locale, address=addr, meta_info=meta_info, sales_channel=sales_channel)
invoice = order.invoices.last() # Might be generated by plugin already
if event.settings.get('invoice_generate') == 'True' and invoice_qualified(order):
@@ -1318,11 +1319,13 @@ class OrderChangeManager:
@app.task(base=ProfiledTask, bind=True, max_retries=5, default_retry_delay=1, throws=(OrderError,))
def perform_order(self, event: str, payment_provider: str, positions: List[str],
email: str=None, locale: str=None, address: int=None, meta_info: dict=None):
email: str=None, locale: str=None, address: int=None, meta_info: dict=None,
sales_channel: str='web'):
with language(locale):
try:
try:
return _perform_order(event, payment_provider, positions, email, locale, address, meta_info)
return _perform_order(event, payment_provider, positions, email, locale, address, meta_info,
sales_channel)
except LockTimeoutException:
self.retry()
except (MaxRetriesExceededError, LockTimeoutException):

View File

@@ -178,6 +178,15 @@ however for this signal, the ``sender`` **may also be None** to allow creating t
notification settings!
"""
register_sales_channels = django.dispatch.Signal(
providing_args=[]
)
"""
This signal is sent out to get all known sales channels types. Receivers should return an
instance of a subclass of pretix.base.channels.SalesChannel or a list of such
instances.
"""
register_data_exporters = EventPluginSignal(
providing_args=[]
)