Git Product home page Git Product logo

django-salesman's Introduction

Salesman logo

Headless e-commerce framework for Django and Wagtail.

PyPI GitHub - Test status Codecov branch PyPI - Python Version PyPI - Django Version Code style: black

Salesman provides a configurable system for building an online store. It includes a RESTful API with endpoints for manipulating the basket, processing the checkout and payment operations as well as managing customer orders.

Features

  • API endpoints for Basket, Checkout and Order
  • Support for as many Product types needed using generic relations
  • Pluggable Modifier system for basket processing
  • Payment methods interface to support any gateway necessary
  • Customizable Order implementation
  • Fully swappable Order and Basket models
  • Wagtail and Django admin implementation

Documentation

Documentation is available on Read the Docs.

django-salesman's People

Contributors

chriswedgwood avatar dinoperovic avatar laricko avatar

Stargazers

 avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar

Watchers

 avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar

django-salesman's Issues

Ability to return the full basket after other API operations

When adding/updating/deleting/etc. items, additional GET request on /api/basket/ is needed to return a fresh basket.

When ?basket is apended to the url (/api/basket/?basket, /api/basket/ref-1/?basket) the response should return a new basket after the desiered operation.

Inherited PaymentMethod not shown as choice

I was refactoring a payment method and noticed that when I used a base class to hold common method that the payment method choices were no longer presented.

Here is a simple example using the documented payment methods:

Payment Methods:

from django.core.exceptions import ValidationError
from django.urls import reverse

from salesman.checkout.payment import PaymentMethod
from salesman.core.utils import get_salesman_model

Order = get_salesman_model('Order')


class PayOnDelivery(PaymentMethod):
    """
    Payment method that expects payment on delivery.
    
    Copied from https://github.com/dinoperovic/django-salesman/blob/8105e84c0ccca00f483122b91a74175131808399/example/shop/payment/on_delivery.py#L9
    """

    identifier = "pay-on-delivery"
    label = "Pay on delivery"

    def validate_basket(self, basket, request):
        """
        Payment only available when purchasing 10 items or less.
        """
        super().validate_basket(basket, request)
        if basket.quantity > 10:
            raise ValidationError("Can't pay for more than 10 items on delivery.")

    def basket_payment(self, basket, request):
        """
        Create order and mark it as shipped. Order status should be changed
        to `COMPLETED` and a new payment should be added manually by the merchant
        when the order items are received and paid for by the customer.
        """
        order = Order.objects.create_from_basket(basket, request, status="SHIPPED")
        basket.delete()
        url = reverse("salesman-order-last") + f"?token={order.token}"
        return request.build_absolute_uri(url)


class PayOnDelivery2(PayOnDelivery):
    identifier = "pay-on-delivery-2"
    label = "Pay on delivery 2"


class PayOnDelivery3(PayOnDelivery):
    identifier = "pay-on-delivery-3"
    label = "Pay on delivery 3"

    def basket_payment(self, *args, **kwargs):
        return super().basket_payment(*args, **kwargs)

Setting:

SALESMAN_PAYMENT_METHODS = [
    'path.to.PayOnDelivery',
    'path.to.PayOnDelivery2',
    'path.to.PayOnDelivery3',
]

When selected the payment method, PayOnDelivery2 is not presented as a choice.

Documentation - Wagtail Admin

It is not very clear how to get an overview of Salesman's components, how to manage products, ... within Wagtail.

The SALESMAN_ADMIN_REGISTER setting seems to imply that it is set to True by default and gives us all kinds of Admin views, however only Orders is added to the Wagtail admin menu.

Is the idea that we should build our own Admin views using ModelAdmin?
Or are there default views for all components (Products, Baskets, ...) shipped and need they be enabled somehow?

Store custom data on Order

Currently, when overriding and expanding your Order model with custom fields (let's say I want to store first_name and last_name on my order besides the default email), everything has to go through the extras object during checkout. I was wondering if there is (or could be in the future) a more robust way of doing this, as it currently feels like twisting the frameworks arm a bit. Let me clarify.

Say I expand my order model with these fields:

from salesman.orders.models import BaseOrder

class Order(BaseOrder):
    first_name = TextField(_("First name"), null=True, blank=True)
    last_name = TextField(_("Last name"), null=True, blank=True)

And, after creating a basket I initiate a checkout with this JSON:

{
  "email": "[email protected]",
  "first_name": "Tim",
  "last_name": "van der Linden",
  "payment_method": "pay-later",
}

To get the extra fields first_name and last_name stored on my order I need to override the populate_from_basket() method on the Order and add in these two fields:

@transaction.atomic
    def populate_from_basket(
        self,
        basket: BaseBasket,
        request: HttpRequest,
        **kwargs: Any,
    ) -> None:
        """
        Populate order with items from basket.

        Args:
            basket (Basket): Basket instance
            request (HttpRequest): Django request
        """
        from salesman.basket.serializers import ExtraRowsField

        if not hasattr(basket, "total"):
            basket.update(request)

        self.user = basket.user
        self.email = basket.extra.pop("email", "")

        self.first_name = basket.extra.pop("first_name", "") # <- added in
        self.last_name = basket.extra.pop("last_name", "")  # <- added in
     
        ...

As you can see I need to pop them from the extras object of the basket to store them on the final Order.

I understand that the checkout itself is not a model and is more of a process, your Basket simply gets "transformed" into an Order with the above method. But still it feels strange to pull all of our custom data we wish to capture from the extras object of the basket.

Is this the intended way? Or should the Basket model be overridden as well and contain mirror fields of the data we want to store on the final Order (eg. a custom Basket model which would also contain a first_name and last_name field)?

Add a custom validator for "extra" field

Is your feature request related to a problem? Please describe.
User can populate extra field with any amount of JSON data using the API. It would be useful to allow a custom validator for extra field on basket and basket item serializer, similar to how address validation works on the checkout serializer.

Describe the solution you'd like
Validate extra field before it gets saved on basket or basket item using a custom validator function configured in settings.py.

Describe alternatives you've considered
-

Additional context
-

Django 4.2 support

Describe the bug
ERROR: Cannot install -r requirements.in (line 24), django-salesman==1.1.6 and django>=4.2 because these package versions have conflicting dependencies.

Django 4.2 was released and salesman I think would run just fine on the newer version as well but because of the Django version limitations it's not installable from pip.

Could we expand the Django versions it supports?

iSort dependency prevents Poetry resolution in example project

Describe the bug

iSort dependency prevents poetry resolution

To Reproduce

➜  django-salesman (master) ✔ poetry install -E example
The currently activated Python version 2.7.16 is not supported by the project (^3.6).
Trying to find and use a compatible version. 
Using python3 (3.8.5)
Creating virtualenv django-salesman-J2JSvch3-py3.8 in /Users/raymaun/Library/Caches/pypoetry/virtualenvs
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
NameError: name 'PosixPath' is not defined
Updating dependencies
Resolving dependencies... (468.6s)

Writing lock file

[SolverProblemError]
The current project's Python requirement (2.7.16) is not compatible with some of the required packages Python requirement:
  - isort requires Python >=3.6,<4.0

Because isort (5.5.1) requires Python >=3.6,<4.0
 and no versions of isort match >=5.4,<5.5.1 || >5.5.1,<6.0, isort is forbidden.
So, because django-salesman depends on isort (^5.4), version solving failed.
➜  django-salesman (master) ✔ 

Expected behavior
That poetry install -E example would resolve the dependencies.

Technical details:

  • Python version: [e.g. 3.8.5]

TemplateDoesNotExist at /cms/salesman/order/ in Wagtail 2.14

Describe the bug
On Wagtail 2.14 the orders admin page returns the following error:

TemplateDoesNotExist at /cms/salesman/order/
modeladmin/salesman/order/index.html, modeladmin/salesman/index.html, modeladmin/index.html

Request Method: | GET
-- | --
http://localhost:8000/cms/salesman/order/
3.2.6
TemplateDoesNotExist
modeladmin/salesman/order/index.html, modeladmin/salesman/index.html, modeladmin/index.html
/usr/local/lib/python3.8/site-packages/django/template/loader.py, line 47, in select_template
/usr/local/bin/python
3.8.1
['/app',  '/usr/local/bin',  '/usr/local/lib/python38.zip',  '/usr/local/lib/python3.8',  '/usr/local/lib/python3.8/lib-dynload',  '/usr/local/lib/python3.8/site-packages']
Tue, 17 Aug 2021 19:53:46 +0000

To Reproduce
Steps to reproduce the behavior:

  1. Install Wagtail and django-salesman
  2. Click on 'Orders' menu link
  3. See error

Technical details:

  • Python version: 3.8
  • Django version: 3.2
  • Wagtail version: 2.14

Multiple checkout steps - A checkout model

Currently, a checkout is a transition from a Basket to an Order via a single API call and returns either a redirect URL to a payment method processing page or a custom URL (such as a thank you page).

However, I think there are use cases where a checkout can be multiple steps. For example, during checkout you define the shipping address, yet the shipping cost (which you define as a modifier on your Basket) could be variable depending on your address - it is not always a flat fee. Currently we would have to add the shipping address to the basket to be able to add a variable shipping cost modifier ... and then again to the checkout to store the shipping address on the order. This feels hacky.

Having multiple steps during checkout would allow to set Basket modifiers (or Checkout modifiers in that case?) which would add extra lines to the Basket/Order depending on data that is given during the checkout.

This would probably require a checkout to become its own model which sits between the basket and the final order and to which different data is applied during various steps. I imagine API routes such as:

  • POST to /shop/checkout/ to start a checkout, returning a checkout ref (giving your Basket ref).
  • PUT to /shop/checkout/<checkout-ref>/ to update the checkout with various data
  • POST to /shop/checkout/<checkout-ref>/finalize to transition the checkout to an order

Via various calls to the PUT route one could implement multiple steps, adding more data to the checkout as they go along. The final POST would act similar to the current checkout, as it would return a URL to redirect to (external payment method, thank you page, ...). Checkout models would also contain a configurable status object depicting the various steps such as "define shipping", "define contact data", "define gift wrapping", ... .

Having a checkout being a separate model and giving it its own status could also provide some more statistical insight in the backend, as we would have an admin showing the various checkouts and where the user might have jumped off, aka. on which status a checkout is left.

I think that currently the idea is that the basket is used for all of the above, as in: we patch the basket during "checkout" until we are ready to commit this to an Order. But on the other hand only the checkout route contains crucial data such as the shipping address which is needed to calculate the shipping basket modifier.

Semantically I feel as if a basket is simply your shopping cart, the checkout is its own thing where your patch in various items such as addresses, contact info, gift wrapping, ... and an order is all of that finalized.

Addresses as strings assumption

This is somewhat related to #29. Currently it is assumed that addresses (billing and shipping) are given as strings. However, in our case (and I boldly assume in many) addresses are stored as address objects with separate components. To store proper address objects on an Order I override the Order model first:

from salesman.orders.models import BaseOrder

class Order(BaseOrder):
    shipping_address = ForeignKey(
        "webshop.OrderAddress",
        null=True,
        blank=True,
        on_delete=SET_NULL,
        related_name="order_shipping_address",
    )
    billing_address = ForeignKey(
        "webshop.OrderAddress",
        null=True,
        blank=True,
        on_delete=SET_NULL,
        related_name="order_billing_address",
    )

Making shipping_address and billing_address foreign keys to the addresses table. Then on checkout I submit them like this:

{
   "email": "[email protected]",
   "shipping_address": {
	"street": "Shippingstreet",
        "house_number": 1,
	"postal_code": "1234",
	"locality": "Some Locality",
	"country": "BE"
   },
   "billing_address": {
	"street": "Billingstreet",
        "house_number": 1,
	"postal_code": "1234",
	"locality": "Some Locality",
	"country": "BE"
   },
  "payment_method": "pay-later",
}

Now to be able for Salesman to handle this I first need to override the Checkout serializer:

from salesman.checkout.serializers import CheckoutSerializer

class CustomCheckoutSerializer(CheckoutSerializer):
    shipping_address = OrderAddressSerializer()
    billing_address = OrderAddressSerializer()
  
    def validate_shipping_address(self, value: OrderAddressSerializer) -> OrderAddressSerializer:
        context = self.context.copy()
        context["address"] = "shipping"
        return validate_address(value, context=context)

    def validate_billing_address(self, value: OrderAddressSerializer) -> OrderAddressSerializer:
        context = self.context.copy()
        context["address"] = "billing"
        return validate_address(value, context=context)

    def save(self, **kwargs: Any) -> None:
        basket, request = self.context["basket"], self.context["request"]
       
       ....

        # Add in address fields.
        basket.extra["shipping_address"] = self.validated_data["shipping_address"]
        basket.extra["billing_address"] =  self.validated_data["billing_address"]

The OrderAddressSerializer is a simple ModelSerializer which serializes the address objects. I have overridden both the validate_shipping_address() and validate_billing_address() functions to accept the object data instead of simple string data. The actual validator function needs to be written as well:

from webshop.serializers.orders.order_address import OrderAddressSerializer

def validate_address(value: OrderAddressSerializer, context: dict[str, Any] = {}) -> str:
    if not value:
        raise ValidationError(_("Address is required."))
    return value

And set this in the config:

SALESMAN_ADDRESS_VALIDATOR = "webshop.utils.validate_address"

And finally I assign the validated data to the extra object on the basket.

Next the populate_from_basket() method on the custom Order model needs to be modified:

  @transaction.atomic
    def populate_from_basket(
        self,
        basket: BaseBasket,
        request: HttpRequest,
        **kwargs: Any,
    ) -> None:
        from webshop.models import OrderAddress
        from salesman.basket.serializers import ExtraRowsField

        if not hasattr(basket, "total"):
            basket.update(request)

        self.user = basket.user
        self.email = basket.extra.pop("email", "")

        self.shipping_address = OrderAddress.objects.create(**basket.extra["shipping_address"])
        self.billing_address = OrderAddress.objects.create(**basket.extra["billing_address"])

Here the data found on the extra object is expanded into actual Address objects and assigned to the Order.

To round things up I need to make sure that Salesman will use my CustomCheckoutSerializer instead of the default. As this is not configurable I am forced to override the routing by defining a new View:

from salesman.checkout.views import CheckoutViewSet
from webshop.serializers.checkout.checkout import CustomCheckoutSerializer

class CustomCheckoutViewSet(SalesmanCheckoutViewSet):
    serializer_class = CustomCheckoutSerializer

And then wire this view into my route:

router.register("checkout", CustomCheckoutViewSet, basename="salesman-checkout")

This feels a bit convoluted, or is this the intended way?

I think making the checkout serializer configurable (eg. SALESMAN_CHECKOUT_SERIALIZER) would at least take care of not having to override the view and routing. But we are still left with the assumption of addresses being strings and a "hacky" way of trying to change this via the extra object on a basket.

Stock Handling

I'm looking to implement stock handling for order s/ basket items - handling the stock is fine the problem I'm having is catching basket quantity changes to adjust stock levels, any ideas or suggestions on doing this?

Add label to order payment

It would be helpful to the customer if there was a way to display a short label with the order payment. For example, VISA ending in 1111 instead of just the identifier credit-card or whatever the payment method identifier is.

The payment_method is useful for the api but doesn't really give the customer enough information to remember which statement to check.

This seems generally useful and would probably be a good thing to include. If not, it would be helpful to allow adding additional fields here

Add ability to specify extra during checkout

Is your feature request related to a problem? Please describe.
Saving extra data on order (eg. "phone number") during the checkout requires an additional POST request to /api/basket/extra/.

Describe the solution you'd like
When sending a POST request to /api/checkout/ add an ability to send extra data in a payload directly.

Describe alternatives you've considered
-

Additional context
Validation for extra data should be enforced here as well (#1).

Add PATCH endpoint for basket item "extra" data

It would be helpful if there was an endpoint one could use to patch extra key values like:

<form 
    hx-patch="{% endverbatim %}{% url 'salesman-basket-list' %}{% verbatim %}{{ ref }}/"
    hx-swap="none"
>
    <input type="number" name="unit_price_override" value="{{ unit_price }}" min="0" step="0.01">
    <button type="submit" class="btn btn-primary" data-bs-dismiss="modal">Submit</button>
</form>

The result of submitting the form should be that the unit_price_override key in the basket item extra dict is set to the submitted value without modifying the other keys of the extra data.

Checkout serializer missing `allow_blank=True`

Probably related to #29 and #30 but mentioning issue so it is tracked.

It would be helpful for situations where a customer genuinely does not have an email address to allow checkout without one.

The Order model does not require an email address but the Checkout serializer does.

Simplest workaround I've come up with for the moment is to submit with an email placeholder and then remove it with a pre_save signal.

Swappable models incorrectly documented to work with Django<4

Swappable models release indicates it should work for Django 3.1+.

Should it work with Django 3.x? Was hoping to add this to a project that hasn't completed the update to 4+ yet.

Migrations fail for me on Django<4:

ValueError: The field salesmanbasket.BasketItem.basket was declared with a lazy reference to 'shop.basket', but app 'shop' isn't installed.
The field salesmanorders.OrderItem.order was declared with a lazy reference to 'shop.order', but app 'shop' isn't installed.
The field salesmanorders.OrderNote.order was declared with a lazy reference to 'shop.order', but app 'shop' isn't installed.
The field salesmanorders.OrderPayment.order was declared with a lazy reference to 'shop.order', but app 'shop' isn't installed.

If I install Django 4+ then the migration is successful.

-- edit --

It does appear to work on 3.x once the migration is created on 4.x

Modify payment_methods_pool

We are implementing a multi site Wagtail based store. The store should have the payment methods limited based on the site.

For example for country Germany you should have Stripe but for US we should only allow the PayPal payment method. Currently the payment_methods_pool does not have a setting to override or modify the Pool in any way.

We'd be happy to help build such a feature if an agreement on how this should be done could be described and agreed upon.

Recommend Projects

  • React photo React

    A declarative, efficient, and flexible JavaScript library for building user interfaces.

  • Vue.js photo Vue.js

    🖖 Vue.js is a progressive, incrementally-adoptable JavaScript framework for building UI on the web.

  • Typescript photo Typescript

    TypeScript is a superset of JavaScript that compiles to clean JavaScript output.

  • TensorFlow photo TensorFlow

    An Open Source Machine Learning Framework for Everyone

  • Django photo Django

    The Web framework for perfectionists with deadlines.

  • D3 photo D3

    Bring data to life with SVG, Canvas and HTML. 📊📈🎉

Recommend Topics

  • javascript

    JavaScript (JS) is a lightweight interpreted programming language with first-class functions.

  • web

    Some thing interesting about web. New door for the world.

  • server

    A server is a program made to process requests and deliver data to clients.

  • Machine learning

    Machine learning is a way of modeling and interpreting data that allows a piece of software to respond intelligently.

  • Game

    Some thing interesting about game, make everyone happy.

Recommend Org

  • Facebook photo Facebook

    We are working to build community through open source technology. NB: members must have two-factor auth.

  • Microsoft photo Microsoft

    Open source projects and samples from Microsoft.

  • Google photo Google

    Google ❤️ Open Source for everyone.

  • D3 photo D3

    Data-Driven Documents codes.