├── .gitignore ├── LICENSE ├── README.md ├── __init__.py ├── admin.py ├── commands.py ├── fixtures.py ├── functions.py ├── info.py ├── main.py ├── models.py ├── pipelines ├── __init__.py ├── base.py └── shipping.py ├── processors ├── __init__.py ├── base.py └── pagseguro_processor.py ├── tasks.py ├── template_filters.py ├── templates └── cart │ ├── cart.html │ ├── cart │ └── complete_information.html │ ├── cart_base.html │ ├── confirmation.html │ ├── dummy.html │ ├── empty_cart.html │ ├── history.html │ ├── pipeline_error.html │ ├── simple_confirmation.html │ └── test.html └── views.py /.gitignore: -------------------------------------------------------------------------------- 1 | *.py[cod] 2 | 3 | # C extensions 4 | *.so 5 | 6 | # Packages 7 | *.egg 8 | *.egg-info 9 | dist 10 | build 11 | eggs 12 | parts 13 | bin 14 | var 15 | sdist 16 | develop-eggs 17 | .installed.cfg 18 | lib 19 | lib64 20 | __pycache__ 21 | 22 | # Installer logs 23 | pip-log.txt 24 | 25 | # Unit test / coverage reports 26 | .coverage 27 | .tox 28 | nosetests.xml 29 | 30 | # Translations 31 | *.mo 32 | 33 | # Mr Developer 34 | .mr.developer.cfg 35 | .project 36 | .pydevproject 37 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2013 pythonic open source network 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy of 6 | this software and associated documentation files (the "Software"), to deal in 7 | the Software without restriction, including without limitation the rights to 8 | use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of 9 | the Software, and to permit persons to whom the Software is furnished to do so, 10 | subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS 17 | FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR 18 | COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER 19 | IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN 20 | CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 21 | 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Quokka Cart | Version 0.1.0dev 2 | 3 | A Shopping cart for [Quokka CMS](http://www.quokkaproject.org) 4 | 5 |

6 | quokka cart 7 |

8 | 9 | 10 | Features 11 | ============= 12 | 13 | ### What Quokka-cart does: 14 | 15 | - Generic shopping cart management (manages itenms, prices, quantities) 16 | - Executes **pipelines** (functions to dinamycally configure the checkout) 17 | - Executes a decoupled **processor** for checkout 18 | - Expose urls for checkout, history and receive carrier notifications 19 | - Expose simple API to manage the cart (/additem, /setitem, /removeitem etc..) 20 | 21 | ### What Quokka-cart does not: 22 | 23 | - A complete e-commerce solution (for that take a look at Quokka-commerce which uses quokka-cart) 24 | 25 | 26 | How to install 27 | =============== 28 | 29 | Go to your quokka modules folder and clone it. 30 | 31 | ```bash 32 | $ cd quokka/quokka/modules 33 | $ git clone https://github.com/pythonhub/quokka-cart.git cart 34 | $ ls 35 | __init__.py accounts media posts cart ... 36 | ``` 37 | 38 | Now you have **cart** folder as a Quokka module, restart your server and it will be available in yout application and in admin under "Cart" menu. 39 | 40 | 41 | Product 42 | ======= 43 | 44 | Cart 45 | ==== 46 | 47 | Checkout Pipeline 48 | ======== 49 | 50 | Processor 51 | ========= 52 | 53 | 54 | - http://github.com/pythonhub/quokka-cart 55 | - by Bruno Rocha 56 | 57 | 58 | [![Bitdeli Badge](https://d2weczhvl823v0.cloudfront.net/pythonhub/quokka-cart/trend.png)](https://bitdeli.com/free "Bitdeli Badge") 59 | 60 | -------------------------------------------------------------------------------- /__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/quokkaproject/quokka-cart/d119e98c05c688e4da24861ceef6b2c57d59cc6f/__init__.py -------------------------------------------------------------------------------- /admin.py: -------------------------------------------------------------------------------- 1 | # coding : utf -8 2 | # from flask.ext.htmlbuilder import html 3 | # from flask.ext.admin.babel import lazy_gettext 4 | from quokka import admin 5 | from quokka.modules.posts.admin import PostAdmin 6 | from quokka.core.admin.models import ModelAdmin 7 | from quokka.utils.translation import _, _l 8 | from quokka.core.widgets import TextEditor, PrepopulatedText 9 | from .models import Cart, Processor 10 | 11 | 12 | class ProductAdmin(PostAdmin): 13 | column_list = ('title', 'slug', 'channel', 14 | 'unity_value', 'weight', 15 | 'published', 'created_at', 16 | 'available_at', 'view_on_site') 17 | column_searchable_list = ['title', 'summary', 'description'] 18 | form_columns = [ 19 | 'title', 'slug', 'channel', 'related_channels', 'summary', 20 | 'description', 'unity_value', 'weight', 'dimensions', 'extra_value', 21 | 'published', 'show_on_channel', 22 | 'available_at', 'available_until', 23 | 'tags', 'contents', 'values', 'template_type' 24 | ] 25 | form_widget_args = { 26 | 'description': { 27 | 'rows': 20, 28 | 'cols': 20, 29 | 'class': 'text_editor', 30 | 'style': "margin: 0px; width: 725px; height: 360px;" 31 | }, 32 | 'summary': { 33 | 'style': 'width: 400px; height: 100px;' 34 | }, 35 | 'title': {'style': 'width: 400px'}, 36 | 'slug': {'style': 'width: 400px'}, 37 | } 38 | 39 | 40 | class CartAdmin(ModelAdmin): 41 | roles_accepted = ('admin', 'editor') 42 | column_filters = ('status', 'created_at', 'total', 'tax', 43 | 'reference_code', 'transaction_code') 44 | column_searchable_list = ('transaction_code', 'checkout_code', 45 | 'reference_code', 'search_helper') 46 | column_list = ("belongs_to", 'total', 'tax', 'status', 'created_at', 47 | 'processor', 48 | "reference_code", 'items', 'published') 49 | form_columns = ('created_at', 'belongs_to', 'processor', 'status', 50 | 'total', 'extra_costs', 'reference_code', 'checkout_code', 51 | 'sender_data', 'shipping_data', 'tax', 'shipping_cost', 52 | 'transaction_code', 53 | # 'requires_login', 54 | # 'continue_shopping_url', 'pipeline', 'config', 55 | # 'items', 56 | 'payment', 'published') 57 | 58 | form_subdocuments = { 59 | 'items': { 60 | 'form_subdocuments': { 61 | None: { 62 | 'form_columns': ['uid', 'title', 'description', 63 | 'link', 'quantity', 'unity_value', 64 | 'total_value', 'weight', 'dimensions', 65 | 'extra_value'] 66 | } 67 | } 68 | } 69 | } 70 | 71 | column_formatters = { 72 | 'created_at': ModelAdmin.formatters.get('datetime'), 73 | 'available_at': ModelAdmin.formatters.get('datetime'), 74 | 'items': ModelAdmin.formatters.get('ul'), 75 | 'status': ModelAdmin.formatters.get('status'), 76 | 'reference_code': ModelAdmin.formatters.get('get_url') 77 | } 78 | 79 | column_formatters_args = { 80 | 'ul': { 81 | 'items': { 82 | 'placeholder': u"{item.title} - {item.total_value}", 83 | 'style': "min-width:200px;max-width:300px;" 84 | } 85 | }, 86 | 'status': { 87 | 'status': { 88 | 'labels': { 89 | 'confirmed': 'success', 90 | 'checked_out': 'warning', 91 | 'cancelled': 'important', 92 | 'completed': 'success' 93 | }, 94 | 'style': 'min-height:18px;' 95 | } 96 | }, 97 | 'get_url': { 98 | 'reference_code': { 99 | 'attribute': 'reference', 100 | 'method': 'get_admin_url' 101 | } 102 | } 103 | } 104 | 105 | def after_model_change(self, form, model, is_created): 106 | if not is_created and model.reference: 107 | model.reference.published = model.published 108 | if model.tax: 109 | model.set_reference_tax(float(model.tax)) 110 | model.reference.save() 111 | 112 | 113 | class ProcessorAdmin(ModelAdmin): 114 | roles_accepted = ('admin', 'developer') 115 | column_list = ('identifier', 'title', 'module', 'published') 116 | form_args = { 117 | "description": {"widget": TextEditor()}, 118 | "identifier": {"widget": PrepopulatedText(master='title')} 119 | } 120 | form_columns = ('title', 'identifier', 'description', 'module', 121 | 'requires', 'image', 'link', 'config', 'pipeline', 122 | 'published') 123 | form_ajax_refs = { 124 | 'image': {'fields': ['title', 'long_slug', 'summary']} 125 | } 126 | 127 | form_widget_args = { 128 | 'config': {'cols': 40, 'rows': 10, 'style': 'width:500px;'} 129 | } 130 | 131 | admin.register(Cart, CartAdmin, category=_("Cart"), name=_l("Cart")) 132 | admin.register(Processor, ProcessorAdmin, category=_("Cart"), 133 | name=_l("Processor")) 134 | -------------------------------------------------------------------------------- /commands.py: -------------------------------------------------------------------------------- 1 | # coding: utf-8 2 | 3 | import logging 4 | 5 | from flask.ext.script import Command, Option 6 | from .models import Cart 7 | 8 | 9 | logger = logging.getLogger(__name__) 10 | 11 | 12 | class ListCart(Command): 13 | "prints a list of carts" 14 | 15 | command_name = 'list_carts' 16 | 17 | option_list = ( 18 | Option('--title', '-t', dest='title'), 19 | ) 20 | 21 | def run(self, title=None): 22 | 23 | carts = Cart.objects 24 | if title: 25 | carts = carts(title=title) 26 | 27 | for cart in carts: 28 | logger.info('Cart: {}'.format(cart)) 29 | -------------------------------------------------------------------------------- /fixtures.py: -------------------------------------------------------------------------------- 1 | # coding: utf-8 2 | -------------------------------------------------------------------------------- /functions.py: -------------------------------------------------------------------------------- 1 | # coding: utf-8 2 | 3 | from flask import session 4 | from .models import Cart 5 | 6 | 7 | def get_current_cart(*args, **kwargs): 8 | if session.get('cart_id'): 9 | return Cart.get_cart(*args, **kwargs) 10 | -------------------------------------------------------------------------------- /info.py: -------------------------------------------------------------------------------- 1 | author = "Bruno Rocha " 2 | link = "http://github.com/pythonhub/quokka-cart" 3 | version = "0.1.0" 4 | image = "" 5 | requirements_apt = [] 6 | requirements = ["pagseguro"] 7 | -------------------------------------------------------------------------------- /main.py: -------------------------------------------------------------------------------- 1 | # coding: utf-8 2 | 3 | from quokka.core.app import QuokkaModule 4 | from .views import CartView, SetItemView, RemoveItemView, SetProcessorView, \ 5 | CheckoutView, HistoryView, ConfirmationView, NotificationView 6 | from .functions import get_current_cart 7 | 8 | module = QuokkaModule("cart", __name__, 9 | template_folder="templates", static_folder="static") 10 | 11 | # template globals 12 | module.add_app_template_global(get_current_cart) 13 | 14 | 15 | # urls 16 | module.add_url_rule('/cart/', view_func=CartView.as_view('cart')) 17 | module.add_url_rule('/cart/setitem/', view_func=SetItemView.as_view('setitem')) 18 | module.add_url_rule('/cart/removeitem/', 19 | view_func=RemoveItemView.as_view('removeitem')) 20 | module.add_url_rule('/cart/setprocessor/', 21 | view_func=SetProcessorView.as_view('setprocessor')) 22 | module.add_url_rule('/cart/checkout/', 23 | view_func=CheckoutView.as_view('checkout')) 24 | module.add_url_rule('/cart/history/', view_func=HistoryView.as_view('history')) 25 | module.add_url_rule('/cart/confirmation//', 26 | view_func=ConfirmationView.as_view('confirmation')) 27 | module.add_url_rule('/cart/notification//', 28 | view_func=NotificationView.as_view('notification')) 29 | 30 | """ 31 | Every url accepts ajax requests, and so do not redirect anything. 32 | in ajax request it will return JSON as response 33 | /cart 34 | - if there is a cart, but it is checked_out, create a new one (if has item) 35 | - show a link to cart history if any 36 | - show the cart to the user 37 | - context is "cart" 38 | - renders cart/cart.html template 39 | - list items and has form for quantity and extra info 40 | - different things can be done via api ex: config shipping 41 | /cart/setitem 42 | - receives a POST with item information 43 | - if "uid" is present ans item exists it will be updated else created 44 | - "product" reference is passed as an "id" and converted to a reference 45 | - receive quantity, weight etc.. 46 | - if "next" is present redirect to there else redirect to "/cart" 47 | /cart/removeitem 48 | - receives a POST with item_id or product_id 49 | - use 'next' to redirect or '/cart' 50 | /cart/setprocessor 51 | - receives a POST with processor identifier or id 52 | /cart/setstatus 53 | - user can set only from abandoned to pending 54 | - if there is a current 'pending' cart it will be set to 'abandoned' 55 | - admin can set to any status 56 | /cart/configure 57 | - requires logged in user as cart owner 58 | - receive a POST 59 | - "property" can be shipping_data, sender_data, extra_costs, shipping_cost 60 | /cart/checkout 61 | - if cart.requires_login: 62 | if user is not logged in it will redirect user to login page 63 | - Do the checkout process if the cart is "pending" 64 | else clean cart from session and redirect to history 65 | - if the processor has a pipeline in record or in class it will executed 66 | - record: pipeline 67 | - class: pre_pipeline, pos_pipeline 68 | - every method in pipeline must act like a view 69 | and it will have self.cart, self.config and flask request and session 70 | to deal 71 | - they should return a redirect, a rendered template, or self.continue() 72 | which will continue to the next method in the pipeline and set state 73 | to jump to a specific method in pipeline you can do continue('method') 74 | - if None is returned or pipeline is empty/iterated, 75 | so the processor.process() method will be executed 76 | /cart/history 77 | - show the cart history for the current user 78 | - context is carts 79 | - user must be logged in 80 | """ 81 | -------------------------------------------------------------------------------- /models.py: -------------------------------------------------------------------------------- 1 | # coding: utf-8 2 | 3 | import datetime 4 | import logging 5 | import sys 6 | 7 | from werkzeug.utils import import_string 8 | from flask import session, current_app 9 | 10 | from quokka.utils.translation import _l 11 | from quokka.utils import get_current_user, lazy_str_setting 12 | from quokka.core.templates import render_template 13 | from quokka.core.db import db 14 | from quokka.core.models.signature import ( 15 | Publishable, Ordered, Dated 16 | ) 17 | from quokka.core.models.content import Content 18 | from quokka.modules.media.models import Image 19 | 20 | 21 | if sys.version_info.major == 3: 22 | from functools import reduce 23 | 24 | logger = logging.getLogger() 25 | 26 | 27 | class BaseProductReference(object): 28 | def get_title(self): 29 | return getattr(self, 'title', None) 30 | 31 | def get_description(self): 32 | return getattr(self, 'description', None) 33 | 34 | def get_unity_value(self): 35 | return getattr(self, 'unity_value', None) 36 | 37 | def get_weight(self): 38 | return getattr(self, 'weight', None) 39 | 40 | def get_dimensions(self): 41 | return getattr(self, 'dimensions', None) 42 | 43 | def get_summary(self): 44 | summary = getattr(self, 'summary', None) 45 | if not summary: 46 | try: 47 | return self.get_description()[:255] 48 | except: 49 | pass 50 | return summary 51 | 52 | def get_extra_value(self): 53 | return getattr(self, 'extra_value', None) 54 | 55 | def get_uid(self): 56 | return str(self.id) 57 | 58 | def set_status(self, *args, **kwargs): 59 | pass 60 | 61 | def remove_item(self, *args, **kwargs): 62 | pass 63 | 64 | 65 | class BaseProduct(BaseProductReference, Content): 66 | description = db.StringField(required=True) 67 | unity_value = db.FloatField() 68 | weight = db.FloatField() 69 | dimensions = db.StringField() 70 | extra_value = db.FloatField() 71 | 72 | meta = { 73 | 'allow_inheritance': True 74 | } 75 | 76 | 77 | class Item(Ordered, Dated, db.EmbeddedDocument): 78 | product = db.ReferenceField(Content) 79 | reference = db.GenericReferenceField() # customized product 80 | """ 81 | Must implement all the BaseProduct methods/ its optional 82 | if None, "product" will be considered 83 | """ 84 | uid = db.StringField() 85 | title = db.StringField(required=True, max_length=255) 86 | description = db.StringField(required=True) 87 | link = db.StringField() 88 | quantity = db.FloatField(default=1) 89 | unity_value = db.FloatField(required=True) 90 | total_value = db.FloatField() 91 | weight = db.FloatField() 92 | dimensions = db.StringField() 93 | extra_value = db.FloatField() 94 | allowed_to_set = db.ListField(db.StringField(), default=['quantity']) 95 | pipeline = db.ListField(db.StringField(), default=[]) 96 | 97 | def set_status(self, status, *args, **kwargs): 98 | kwargs['item'] = self 99 | if self.reference and hasattr(self.reference, 'set_status'): 100 | self.reference.set_status(status, *args, **kwargs) 101 | if self.product and hasattr(self.product, 'set_status'): 102 | self.product.set_status(status, *args, **kwargs) 103 | 104 | def get_main_image_url(self, thumb=False, default=None): 105 | try: 106 | return self.product.get_main_image_url(thumb, default) 107 | except: 108 | return None 109 | 110 | @classmethod 111 | def normalize(cls, kwargs): 112 | new = {} 113 | for k, v in kwargs.items(): 114 | field = cls._fields.get(k) 115 | if not field: 116 | continue 117 | new[k] = field.to_python(v) 118 | return new 119 | 120 | def __unicode__(self): 121 | return u"{i.title} - {i.total_value}".format(i=self) 122 | 123 | def get_uid(self): 124 | try: 125 | return self.product.get_uid() 126 | except: 127 | return self.uid 128 | 129 | @property 130 | def unity_plus_extra(self): 131 | return float(self.unity_value or 0) + float(self.extra_value or 0) 132 | 133 | @property 134 | def total(self): 135 | self.clean() 136 | self.total_value = self.unity_plus_extra * float(self.quantity or 1) 137 | return self.total_value 138 | 139 | def clean(self): 140 | mapping = [ 141 | ('title', 'get_title'), 142 | ('description', 'get_description'), 143 | ('link', 'get_absolute_url'), 144 | ('unity_value', 'get_unity_value'), 145 | ('weight', 'get_weight'), 146 | ('dimensions', 'get_dimensions'), 147 | ('extra_value', 'get_extra_value'), 148 | ('uid', 'get_uid'), 149 | ] 150 | 151 | references = [self.reference, self.product] 152 | 153 | for ref in references: 154 | if not ref: 155 | continue 156 | for attr, method in mapping: 157 | current = getattr(self, attr, None) 158 | if current is not None: 159 | continue 160 | setattr(self, attr, getattr(ref, method, lambda: None)()) 161 | 162 | 163 | class Payment(db.EmbeddedDocument): 164 | uid = db.StringField() 165 | payment_system = db.StringField() 166 | method = db.StringField() 167 | value = db.FloatField() 168 | extra_value = db.FloatField() 169 | date = db.DateTimeField() 170 | confirmed_at = db.DateTimeField() 171 | status = db.StringField() 172 | 173 | 174 | class Processor(Publishable, db.DynamicDocument): 175 | identifier = db.StringField(max_length=100, unique=True) 176 | module = db.StringField(max_length=255) 177 | requires = db.ListField(db.StringField(max_length=255)) 178 | description = db.StringField() 179 | title = db.StringField() 180 | image = db.ReferenceField(Image, reverse_delete_rule=db.NULLIFY) 181 | link = db.StringField(max_length=255) 182 | config = db.DictField(default=lambda: {}) 183 | pipeline = db.ListField(db.StringField(max_length=255), default=[]) 184 | 185 | def import_processor(self): 186 | return import_string(self.module) 187 | 188 | def get_instance(self, *args, **kwargs): 189 | if 'config' not in kwargs: 190 | kwargs['config'] = self.config 191 | kwargs['_record'] = self 192 | return self.import_processor()(*args, **kwargs) 193 | 194 | def clean(self, *args, **kwargs): 195 | for item in (self.requires or []): 196 | import_string(item) 197 | super(Processor, self).clean(*args, **kwargs) 198 | 199 | def __unicode__(self): 200 | return self.identifier 201 | 202 | @classmethod 203 | def get_instance_by_identifier(cls, identifier, cart=None): 204 | processor = cls.objects.get(identifier=identifier) 205 | return processor.get_instance(cart=cart) 206 | 207 | @classmethod 208 | def get_default_processor(cls): 209 | default = lazy_str_setting( 210 | 'CART_DEFAULT_PROCESSOR', 211 | default={ 212 | 'module': 'quokka.modules.cart.processors.Dummy', 213 | 'identifier': 'dummy', 214 | 'published': True, 215 | 'title': "Test" 216 | } 217 | ) 218 | 219 | try: 220 | return cls.objects.get(identifier=default['identifier']) 221 | except: 222 | return cls.objects.create(**default) 223 | 224 | def save(self, *args, **kwargs): 225 | self.import_processor() 226 | super(Processor, self).save(*args, **kwargs) 227 | 228 | 229 | class Cart(Publishable, db.DynamicDocument): 230 | STATUS = ( 231 | ("pending", _l("Pending")), # not checked out 232 | ("checked_out", _l("Checked out")), # not confirmed (payment) 233 | ("analysing", _l("Analysing")), # Analysing payment 234 | ("confirmed", _l("Confirmed")), # Payment confirmed 235 | ("completed", _l("Completed")), # Payment completed (money released) 236 | ("refunding", _l("Refunding")), # Buyer asks refund 237 | ("refunded", _l("Refunded")), # Money refunded to buyer 238 | ("cancelled", _l("Cancelled")), # Cancelled without processing 239 | ("abandoned", _l("Abandoned")), # Long time no update 240 | ) 241 | reference = db.GenericReferenceField() 242 | """reference must implement set_status(**kwargs) method 243 | arguments: status(str), value(float), date, uid(str), msg(str) 244 | and extra(dict). 245 | Also reference must implement get_uid() which will return 246 | the unique identifier for this transaction""" 247 | 248 | belongs_to = db.ReferenceField('User', 249 | # default=get_current_user, 250 | reverse_delete_rule=db.NULLIFY) 251 | items = db.ListField(db.EmbeddedDocumentField(Item)) 252 | payment = db.ListField(db.EmbeddedDocumentField(Payment)) 253 | status = db.StringField(choices=STATUS, default='pending') 254 | total = db.FloatField(default=0) 255 | extra_costs = db.DictField(default=lambda: {}) 256 | sender_data = db.DictField(default=lambda: {}) 257 | shipping_data = db.DictField(default=lambda: {}) 258 | shipping_cost = db.FloatField(default=0) 259 | tax = db.FloatField(default=0) 260 | processor = db.ReferenceField(Processor, 261 | default=Processor.get_default_processor, 262 | reverse_delete_rule=db.NULLIFY) 263 | reference_code = db.StringField() # Reference code for filtering 264 | checkout_code = db.StringField() # The UID for transaction checkout 265 | transaction_code = db.StringField() # The UID for transaction 266 | requires_login = db.BooleanField(default=True) 267 | continue_shopping_url = db.StringField( 268 | default=lambda: current_app.config.get( 269 | 'CART_CONTINUE_SHOPPING_URL', '/' 270 | ) 271 | ) 272 | pipeline = db.ListField(db.StringField(), default=[]) 273 | log = db.ListField(db.StringField(), default=[]) 274 | config = db.DictField(default=lambda: {}) 275 | 276 | search_helper = db.StringField() 277 | 278 | meta = { 279 | 'ordering': ['-created_at'] 280 | } 281 | 282 | def send_response(self, response, identifier): 283 | if self.reference and hasattr(self.reference, 'get_response'): 284 | self.reference.get_response(response, identifier) 285 | 286 | for item in self.items: 287 | if hasattr(item, 'get_response'): 288 | item.get_response(response, identifier) 289 | 290 | def set_tax(self, tax, save=False): 291 | """ 292 | set tax and send to references 293 | """ 294 | try: 295 | tax = float(tax) 296 | self.tax = tax 297 | self.set_reference_tax(tax) 298 | except Exception as e: 299 | self.addlog("impossible to set tax: %s" % str(e)) 300 | 301 | def set_status(self, status, save=False): 302 | """ 303 | THis method will be called by the processor 304 | which will pass a valid status as in STATUS 305 | so, this method will dispatch the STATUS to 306 | all the items and also the 'reference' if set 307 | """ 308 | if self.status != status: 309 | self.status = status 310 | 311 | self.set_reference_statuses(status) 312 | 313 | if save: 314 | self.save() 315 | 316 | def set_reference_statuses(self, status): 317 | if self.reference and hasattr(self.reference, 'set_status'): 318 | self.reference.set_status(status, cart=self) 319 | 320 | for item in self.items: 321 | item.set_status(status, cart=self) 322 | 323 | def set_reference_tax(self, tax): 324 | if self.reference and hasattr(self.reference, 'set_tax'): 325 | self.reference.set_tax(tax) 326 | 327 | for item in self.items: 328 | if hasattr(item, 'set_tax'): 329 | item.set_tax(tax) 330 | 331 | def addlog(self, msg, save=True): 332 | try: 333 | self.log.append(u"{0},{1}".format(datetime.datetime.now(), msg)) 334 | logger.debug(msg) 335 | save and self.save() 336 | except UnicodeDecodeError as e: 337 | logger.info(msg) 338 | logger.error(str(e)) 339 | 340 | @property 341 | def uid(self): 342 | return self.get_uid() 343 | 344 | def get_uid(self): 345 | try: 346 | return self.reference.get_uid() or str(self.id) 347 | except Exception: 348 | self.addlog("Using self.id as reference", save=False) 349 | return str(self.id) 350 | 351 | def __unicode__(self): 352 | return u"{o.uid} - {o.processor.identifier}".format(o=self) 353 | 354 | def get_extra_costs(self): 355 | if self.extra_costs: 356 | return sum(self.extra_costs.values()) 357 | 358 | @classmethod 359 | def get_cart(cls, no_dereference=False, save=True): 360 | """ 361 | get or create a new cart related to the session 362 | if there is a current logged in user it will be set 363 | else it will be set during the checkout. 364 | """ 365 | session.permanent = current_app.config.get( 366 | "CART_PERMANENT_SESSION", True) 367 | try: 368 | cart = cls.objects(id=session.get('cart_id'), status='pending') 369 | 370 | if not cart: 371 | raise cls.DoesNotExist('A pending cart not found') 372 | 373 | if no_dereference: 374 | cart = cart.no_dereference() 375 | 376 | cart = cart.first() 377 | 378 | save and cart.save() 379 | 380 | except (cls.DoesNotExist, db.ValidationError): 381 | cart = cls(status="pending") 382 | cart.save() 383 | session['cart_id'] = str(cart.id) 384 | session.pop('cart_pipeline_index', None) 385 | session.pop('cart_pipeline_args', None) 386 | 387 | return cart 388 | 389 | def assign(self): 390 | self.belongs_to = self.belongs_to or get_current_user() 391 | 392 | def save(self, *args, **kwargs): 393 | self.total = sum([item.total for item in self.items]) 394 | self.assign() 395 | self.reference_code = self.get_uid() 396 | self.search_helper = self.get_search_helper() 397 | if not self.id: 398 | self.published = True 399 | super(Cart, self).save(*args, **kwargs) 400 | self.set_reference_statuses(self.status) 401 | 402 | def get_search_helper(self): 403 | if not self.belongs_to: 404 | return "" 405 | user = self.belongs_to 406 | return " ".join([ 407 | user.name or "", 408 | user.email or "" 409 | ]) 410 | 411 | def get_item(self, uid): 412 | # MongoEngine/mongoengine#503 413 | return self.items.get(uid=uid) 414 | 415 | def set_item(self, **kwargs): 416 | if 'product' in kwargs: 417 | if not isinstance(kwargs['product'], Content): 418 | try: 419 | kwargs['product'] = Content.objects.get( 420 | id=kwargs['product']) 421 | except Content.DoesNotExist: 422 | kwargs['product'] = None 423 | 424 | uid = kwargs.get( 425 | 'uid', 426 | kwargs['product'].get_uid() if kwargs.get('product') else None 427 | ) 428 | 429 | if not uid: 430 | self.addlog("Cannot add item without an uid %s" % kwargs) 431 | return 432 | 433 | item = self.get_item(uid) 434 | 435 | kwargs = Item.normalize(kwargs) 436 | 437 | if not item: 438 | # items should only be added if there is a product (for safety) 439 | if not kwargs.get('product'): 440 | self.addlog("there is no product to add item") 441 | return 442 | allowed = ['product', 'quantity'] 443 | item = self.items.create( 444 | **{k: v for k, v in kwargs.items() if k in allowed} 445 | ) 446 | self.addlog("New item created %s" % item, save=False) 447 | else: 448 | # update only allowed attributes 449 | item = self.items.update( 450 | {k: v for k, v in kwargs.items() if k in item.allowed_to_set}, 451 | uid=item.uid 452 | ) 453 | self.addlog("Item updated %s" % item, save=False) 454 | 455 | if int(kwargs.get('quantity', "1")) == 0: 456 | self.addlog("quantity is 0, removed %s" % kwargs, save=False) 457 | self.remove_item(**kwargs) 458 | 459 | self.save() 460 | self.reload() 461 | return item 462 | 463 | def remove_item(self, **kwargs): 464 | deleted = self.items.delete(**kwargs) 465 | if self.reference and hasattr(self.reference, 'remove_item'): 466 | self.reference.remove_item(**kwargs) 467 | return deleted 468 | 469 | def checkout(self, processor=None, *args, **kwargs): 470 | self.set_processor(processor) 471 | processor_instance = self.processor.get_instance(self, *args, **kwargs) 472 | if processor_instance.validate(): 473 | response = processor_instance.process() 474 | self.status = 'checked_out' 475 | self.save() 476 | session.pop('cart_id', None) 477 | return response 478 | else: 479 | self.addlog("Cart did not validate") 480 | raise Exception("Cart did not validate") # todo: specialize this 481 | 482 | def get_items_pipeline(self): 483 | if not self.items: 484 | return [] 485 | 486 | return reduce( 487 | lambda x, y: x + y, [item.pipeline for item in self.items] 488 | ) 489 | 490 | def build_pipeline(self): 491 | items = ['quokka.modules.cart.pipelines:StartPipeline'] 492 | items.extend(current_app.config.get('CART_PIPELINE', [])) 493 | items.extend(self.get_items_pipeline()) 494 | items.extend(self.pipeline) 495 | items.extend(self.processor and self.processor.pipeline or []) 496 | return items 497 | 498 | def process_pipeline(self): 499 | if not self.items: 500 | return render_template('cart/empty_cart.html', 501 | url=self.continue_shopping_url) 502 | 503 | pipelines = self.build_pipeline() 504 | index = session.get('cart_pipeline_index', 0) 505 | pipeline = import_string(pipelines[index]) 506 | return pipeline(self, pipelines, index)._preprocess() 507 | 508 | def set_processor(self, processor=None): 509 | if not self.processor: 510 | self.processor = Processor.get_default_processor() 511 | self.save() 512 | 513 | if not processor: 514 | return 515 | 516 | if isinstance(processor, Processor): 517 | self.processor = processor 518 | self.save() 519 | return 520 | 521 | try: 522 | self.processor = Processor.objects.get(id=processor) 523 | except: 524 | self.processor = Processor.objects.get(identifier=processor) 525 | 526 | self.save() 527 | 528 | def get_available_processors(self): 529 | return Processor.objects(published=True) 530 | -------------------------------------------------------------------------------- /pipelines/__init__.py: -------------------------------------------------------------------------------- 1 | # coding: utf-8 2 | 3 | from .base import * 4 | -------------------------------------------------------------------------------- /pipelines/base.py: -------------------------------------------------------------------------------- 1 | # coding: utf-8 2 | from flask import session, request 3 | from werkzeug.utils import import_string 4 | from quokka.core.templates import render_template 5 | from quokka.utils import get_current_user 6 | 7 | 8 | class PipelineOverflow(Exception): 9 | pass 10 | 11 | 12 | class CartPipeline(object): 13 | 14 | def __init__(self, cart, pipeline, index=0): 15 | self.cart = cart # Cart object 16 | self.pipeline = pipeline # pipeline ordered list [] 17 | self.index = index # current index in pipeline index 18 | self.update_args() 19 | 20 | def update_args(self): 21 | self.args = session.get('cart_pipeline_args', {}) 22 | self.args.update(request.form.copy()) 23 | session['cart_pipeline_args'] = self.args.copy() 24 | 25 | def del_sessions(self): 26 | if session.get('cart_pipeline_index'): 27 | del session['cart_pipeline_index'] 28 | if session.get('cart_pipeline_args'): 29 | del session['cart_pipeline_args'] 30 | 31 | def render(self, *args, **kwargs): 32 | return render_template(*args, **kwargs) 33 | 34 | def _preprocess(self): 35 | try: 36 | ret = self.process() # the only overridable method 37 | if not ret: 38 | ret = self.go() 39 | if isinstance(ret, CartPipeline): 40 | session['cart_pipeline_index'] = ret.index 41 | return ret._preprocess() 42 | else: 43 | session['cart_pipeline_index'] = self.index 44 | return ret 45 | except PipelineOverflow as e: 46 | ret = self.cart.checkout() 47 | self.del_sessions() 48 | return ret 49 | except Exception as e: 50 | self.del_sessions() 51 | self.cart.addlog( 52 | u"{e} {p.index} {p} cart: {p.cart.id}".format(p=self, e=e) 53 | ) 54 | return render_template('cart/pipeline_error.html', 55 | pipeline=self, 56 | error=e) 57 | 58 | def process(self): 59 | return NotImplementedError("Should be implemented") 60 | 61 | def go(self, index=None, name=None): 62 | index = index or self.index + 1 63 | try: 64 | pipeline = import_string(self.pipeline[index]) 65 | except IndexError: 66 | raise PipelineOverflow("pipeline overflow at %s" % index) 67 | 68 | if not issubclass(pipeline, CartPipeline): 69 | raise ValueError("Pipelines should be subclass of CartPipeline") 70 | 71 | return pipeline(self.cart, self.pipeline, index) 72 | 73 | 74 | class CartItemPipeline(CartPipeline): 75 | pass 76 | 77 | 78 | class CartProcessorPipeline(CartPipeline): 79 | pass 80 | 81 | 82 | class StartPipeline(CartPipeline): 83 | """ 84 | This is the first pipeline executed upon cart checkout 85 | it only checks if user has email and name 86 | """ 87 | def process(self): 88 | self.cart.addlog("StartPipeline") 89 | user = get_current_user() 90 | if not all([user.name, user.email]): 91 | confirm = request.form.get('cart_complete_information') 92 | 93 | name = request.form.get("name") or user.name or "" 94 | email = request.form.get("email") or user.email 95 | 96 | valid_name = len(name.split()) > 1 97 | 98 | if not confirm or not valid_name: 99 | return self.render('cart/complete_information.html', 100 | valid_name=valid_name, 101 | name=name) 102 | 103 | user.name = name 104 | user.email = email 105 | user.save() 106 | 107 | return self.go() 108 | 109 | 110 | class TestPipeline(CartPipeline): 111 | def process(self): 112 | self.cart.addlog("TestPipeline") 113 | if not session.get('completed') == 3: 114 | session['completed'] = 3 115 | return render_template('cart/test.html', cart=self.cart) 116 | return self.go() 117 | -------------------------------------------------------------------------------- /pipelines/shipping.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/quokkaproject/quokka-cart/d119e98c05c688e4da24861ceef6b2c57d59cc6f/pipelines/shipping.py -------------------------------------------------------------------------------- /processors/__init__.py: -------------------------------------------------------------------------------- 1 | # coding: utf-8 2 | import logging 3 | from .base import BaseProcessor 4 | from quokka.core.templates import render_template 5 | 6 | logger = logging.getLogger() 7 | 8 | 9 | class Dummy(BaseProcessor): 10 | def validate(self, *args, **kwargs): 11 | items = self.cart.items 12 | logger.info(items) 13 | return True 14 | 15 | def process(self, *args, **kwargs): 16 | logger.info("Cheking out %s" % self.cart.id) 17 | self.cart.addlog("Dummy processor %s" % self.cart.id) 18 | return render_template('cart/dummy.html', cart=self.cart) 19 | -------------------------------------------------------------------------------- /processors/base.py: -------------------------------------------------------------------------------- 1 | # coding: utf-8 2 | 3 | 4 | class BaseProcessor(object): 5 | def __init__(self, cart, *args, **kwargs): 6 | self.cart = cart 7 | self.config = kwargs.get('config', {}) 8 | self._record = kwargs.get('_record') 9 | 10 | def validate(self, *args, **kwargs): 11 | raise NotImplementedError() 12 | 13 | def process(self, *args, **kwargs): 14 | raise NotImplementedError() 15 | 16 | def notification(self): 17 | return "notification" 18 | 19 | def confirmation(self): 20 | return "confirmation" 21 | -------------------------------------------------------------------------------- /processors/pagseguro_processor.py: -------------------------------------------------------------------------------- 1 | # coding: utf-8 2 | import logging 3 | from flask import redirect, request 4 | from pagseguro import PagSeguro 5 | from quokka.core.templates import render_template 6 | from .base import BaseProcessor 7 | from ..models import Cart 8 | 9 | logger = logging.getLogger() 10 | 11 | 12 | class PagSeguroProcessor(BaseProcessor): 13 | 14 | STATUS_MAP = { 15 | "1": "checked_out", 16 | "2": "analysing", 17 | "3": "confirmed", 18 | "4": "completed", 19 | "5": "refunding", 20 | "6": "refunded", 21 | "7": "cancelled" 22 | } 23 | 24 | def __init__(self, cart, *args, **kwargs): 25 | self.cart = cart 26 | self.config = kwargs.get('config') 27 | self._record = kwargs.get('_record') 28 | if not isinstance(self.config, dict): 29 | raise ValueError("Config must be a dict") 30 | email = self.config.get('email') 31 | token = self.config.get('token') 32 | self.pg = PagSeguro(email=email, token=token) 33 | self.cart and self.cart.addlog( 34 | "PagSeguro initialized {}".format(self.__dict__) 35 | ) 36 | 37 | def validate(self, *args, **kwargs): 38 | self.pg.sender = self.cart.sender_data 39 | self.pg.shipping = self.cart.shipping_data 40 | self.pg.reference = self.cart.get_uid() 41 | extra_costs = self.cart.get_extra_costs() 42 | if extra_costs: 43 | self.pg.extra_amount = "%.2f" % extra_costs 44 | 45 | self.pg.items = [ 46 | { 47 | "id": item.get_uid(), 48 | "description": item.title[:100], 49 | "amount": "%.2f" % item.unity_plus_extra, 50 | "weight": item.weight, 51 | "quantity": int(item.quantity or 1) 52 | } 53 | for item in self.cart.items if item.total >= 0 54 | ] 55 | 56 | if hasattr(self.cart, 'redirect_url'): 57 | self.pg.redirect_url = self.cart.redirect_url 58 | else: 59 | self.pg.redirect_url = self.config.get('redirect_url') 60 | 61 | if hasattr(self.cart, 'notification_url'): 62 | self.pg.notification_url = self.cart.notification_url 63 | else: 64 | self.pg.notification_url = self.config.get('notification_url') 65 | 66 | self.cart.addlog("pagSeguro validated {}".format(self.pg.data)) 67 | return True # all data is valid 68 | 69 | def process(self, *args, **kwargs): 70 | kwargs.update(self._record.config) 71 | kwargs.update(self.cart.config) 72 | response = self.pg.checkout(**kwargs) 73 | self.cart.addlog( 74 | ( 75 | "lib checkout data:{pg.data}\n" 76 | " code:{r.code} url:{r.payment_url}\n" 77 | " errors: {r.errors}\n" 78 | " xml: {r.xml}\n" 79 | ).format( 80 | pg=self.pg, r=response 81 | ) 82 | ) 83 | if not response.errors: 84 | self.cart.checkout_code = response.code 85 | self.cart.addlog("PagSeguro processed! {}".format(response.code)) 86 | return redirect(response.payment_url) 87 | else: 88 | self.cart.addlog( 89 | 'PagSeguro error processing {}'.format( 90 | response.errors 91 | ) 92 | ) 93 | return render_template("cart/checkout_error.html", 94 | response=response, cart=self.cart) 95 | 96 | def notification(self): 97 | code = request.form.get('notificationCode') 98 | if not code: 99 | return "notification code not found" 100 | 101 | response = self.pg.check_notification(code) 102 | reference = getattr(response, 'reference', None) 103 | if not reference: 104 | return "reference not found" 105 | 106 | prefix = self.pg.config.get('REFERENCE_PREFIX', '') or '' 107 | prefix = prefix.replace('%s', '') 108 | 109 | status = getattr(response, 'status', None) 110 | transaction_code = getattr(response, 'code', None) 111 | 112 | # get grossAmount to populate a payment with methods 113 | try: 114 | ref = reference.replace(prefix, '') 115 | qs = Cart.objects.filter( 116 | reference_code=ref 117 | ) or Cart.objects.filter(id=ref) 118 | 119 | if not qs: 120 | return "Cart not found" 121 | 122 | self.cart = qs[0] 123 | 124 | self.cart.set_status( 125 | self.STATUS_MAP.get(str(status), self.cart.status) 126 | ) 127 | 128 | if transaction_code: 129 | self.cart.transaction_code = transaction_code 130 | 131 | msg = "Status changed to: %s" % self.cart.status 132 | self.cart.addlog(msg) 133 | 134 | fee_amount = getattr(response, 'feeAmount', None) 135 | if fee_amount: 136 | self.cart.set_tax(fee_amount) 137 | msg = "Tax set to: %s" % fee_amount 138 | self.cart.addlog(msg) 139 | 140 | # send response to reference and products 141 | self.cart.send_response(response, 'pagseguro') 142 | 143 | return msg 144 | except Exception as e: 145 | msg = "Error in notification: {} - {}".format(reference, e) 146 | logger.error(msg) 147 | return msg 148 | 149 | def confirmation(self): # redirect_url 150 | context = {} 151 | transaction_param = self.config.get( 152 | 'transaction_param', 153 | self.pg.config.get('TRANSACTION_PARAM', 'transaction_id') 154 | ) 155 | transaction_code = request.args.get(transaction_param) 156 | if transaction_code: 157 | context['transaction_code'] = transaction_code 158 | response = self.pg.check_transaction(transaction_code) 159 | logger.debug(response.xml) 160 | reference = getattr(response, 'reference', None) 161 | if not reference: 162 | logger.error("no reference found") 163 | return render_template('cart/simple_confirmation.html', 164 | **context) 165 | prefix = self.pg.config.get('REFERENCE_PREFIX', '') or '' 166 | prefix = prefix.replace('%s', '') 167 | 168 | status = getattr(response, 'status', None) 169 | 170 | # get grossAmount to populate a payment with methods 171 | try: 172 | # self.cart = Cart.objects.get( 173 | # reference_code=reference.replace(PREFIX, '') 174 | # ) 175 | ref = reference.replace(prefix, '') 176 | qs = Cart.objects.filter( 177 | reference_code=ref 178 | ) or Cart.objects.filter(id=ref) 179 | 180 | if not qs: 181 | return "Cart not found" 182 | 183 | self.cart = qs[0] 184 | 185 | self.cart.set_status( 186 | self.STATUS_MAP.get(str(status), self.cart.status) 187 | ) 188 | 189 | self.cart.transaction_code = transaction_code 190 | msg = "Status changed to: %s" % self.cart.status 191 | self.cart.addlog(msg) 192 | context['cart'] = self.cart 193 | logger.info("Cart updated") 194 | 195 | fee_amount = getattr(response, 'feeAmount', None) 196 | if fee_amount: 197 | self.cart.set_tax(fee_amount) 198 | msg = "Tax set to: %s" % fee_amount 199 | self.cart.addlog(msg) 200 | 201 | # send response to reference and products 202 | self.cart.send_response(response, 'pagseguro') 203 | 204 | return render_template('cart/confirmation.html', **context) 205 | except Exception as e: 206 | msg = "Error in confirmation: {} - {}".format(reference, e) 207 | logger.error(msg) 208 | return render_template('cart/simple_confirmation.html', **context) 209 | -------------------------------------------------------------------------------- /tasks.py: -------------------------------------------------------------------------------- 1 | # coding: utf-8 2 | 3 | import logging 4 | 5 | from quokka import create_celery_app 6 | 7 | celery = create_celery_app() 8 | 9 | 10 | logger = logging.getLogger(__name__) 11 | 12 | 13 | @celery.task 14 | def cart_task(): 15 | logger.info("Doing something async...") 16 | -------------------------------------------------------------------------------- /template_filters.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/quokkaproject/quokka-cart/d119e98c05c688e4da24861ceef6b2c57d59cc6f/template_filters.py -------------------------------------------------------------------------------- /templates/cart/cart.html: -------------------------------------------------------------------------------- 1 | {% extends theme('cart/cart_base.html') %} 2 | {% block js_footer %} 3 | 11 | {% endblock %} 12 | 13 | {% block content %} 14 |
15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | {% for item in cart.items %} 28 | 29 | 33 | 34 | 35 | 41 | 42 | 48 | 49 | {% endfor %} 50 | 51 | 52 | 53 | 54 | 55 | 56 | 57 |
Product Unity value Extra value Quantity Total Actions
30 |
{{ item.title }}
31 |

{{ item.description|truncate(140) }}

32 |
$ {{"%.2f" % item.unity_value}} $ {{"%.2f" % item.extra_value}} 36 |
37 | 38 | 39 |
40 |
$ {{"%.2f" % item.total}} 43 |
44 | 45 | 46 |
47 |
$ {{"%.2f" % cart.items|sum(attribute="extra_value")}} {{ cart.items|sum(attribute="quantity")|int }} $ {{ "%.2f" % cart.total }}
58 | 59 |
60 |
61 | Payment 62 | {%for processor in cart.get_available_processors() %} 63 | 67 | {% endfor %} 68 |
69 | 70 |
71 | Continue shopping 72 | 73 | {%if current_user.is_authenticated() or not cart.requires_login %} 74 | 75 | {% else %} 76 | Login and checkout 77 | {% endif %} 78 | 79 |
80 | 81 | {% if current_user.is_authenticated() %} 82 | Purchase history 83 | {% endif %} 84 |
85 | {% endblock %} 86 | -------------------------------------------------------------------------------- /templates/cart/cart/complete_information.html: -------------------------------------------------------------------------------- 1 | {% extends theme('cart/cart_base.html') %} 2 | {% block content %} 3 | Implements a form 4 | 5 | confirm, name and email 6 | {% endblock %} 7 | -------------------------------------------------------------------------------- /templates/cart/cart_base.html: -------------------------------------------------------------------------------- 1 | {% extends theme("base.html") %} 2 | 3 | {% block page_header %} 4 |
5 | 8 |
9 | {% endblock %} 10 | -------------------------------------------------------------------------------- /templates/cart/confirmation.html: -------------------------------------------------------------------------------- 1 | {% extends theme('cart/cart_base.html') %} 2 | {% block content %} 3 |
4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | {% for item in cart.items %} 16 | 17 | 21 | 22 | 23 | 26 | 27 | 28 | 29 | {% endfor %} 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 |
Product Unity value Extra value Quantity Total
18 |
{{ item.title }}
19 |

{{ item.description|truncate(140) }}

20 |
$ {{"%.2f" % item.unity_value}} $ {{"%.2f" % item.extra_value}} 24 | {{item.quantity|int}} 25 | $ {{"%.2f" % item.total}}
$ {{"%.2f" % cart.items|sum(attribute="extra_value")}} {{ cart.items|sum(attribute="quantity")|int }} $ {{ "%.2f" % cart.total }}
38 | 39 | {{ transaction_code }} 40 | 41 | Continue shopping 42 | {% if current_user.is_authenticated() %} 43 | Purchase history 44 | {% endif %} 45 |
46 | {% endblock %} 47 | -------------------------------------------------------------------------------- /templates/cart/dummy.html: -------------------------------------------------------------------------------- 1 | {% extends theme('cart/cart_base.html') %} 2 | 3 | {% block content %} 4 | 5 |

Dummy processed

6 | 7 | {% for item in cart.log %} 8 | {{item}}
9 | {% endfor %} 10 | 11 |
12 | {% endblock %} -------------------------------------------------------------------------------- /templates/cart/empty_cart.html: -------------------------------------------------------------------------------- 1 | {% extends theme('cart/cart_base.html') %} 2 | 3 | {% block content %} 4 | Your cart is empty
5 | Continue shopping 6 | {% if current_user.is_authenticated() %} 7 | Purchase history 8 | {% endif %} 9 | {% endblock %} 10 | -------------------------------------------------------------------------------- /templates/cart/history.html: -------------------------------------------------------------------------------- 1 | 2 | {% extends theme("base.html") %} 3 | 4 | {% block page_header %} 5 |
6 | 9 |
10 | {% endblock %} 11 | 12 | {% block content %} 13 |
14 | 15 | Cart 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | {% for cart in carts %} 29 | 30 | 31 | 32 | 40 | 41 | 42 | 43 | 44 | {% endfor %} 45 | 46 |
Reference Status Items Amount Checkout code Created
{{cart.get_uid()}}{{cart.status}} 33 | Items: {{cart.items.count()}}
34 |
    35 | {% for item in cart.items %} 36 |
  • {{ item }}
  • 37 | {% endfor %} 38 |
39 |
$ {{ "%.2f" % cart.total}}{{cart.checkout_code}}{{cart.created_at}}
47 |
48 | {% endblock %} 49 | -------------------------------------------------------------------------------- /templates/cart/pipeline_error.html: -------------------------------------------------------------------------------- 1 | {% extends theme('cart/cart_base.html') %} 2 | 3 | {% block content %} 4 | 5 | There was an error processing your cart
6 | Developers have been informed about this error
7 | 8 | erro: {{ e }}
9 | 10 | cart: {{ pipeline.cart }} 11 | 12 | 13 | 14 | {% endblock %} -------------------------------------------------------------------------------- /templates/cart/simple_confirmation.html: -------------------------------------------------------------------------------- 1 | {% extends theme('cart/cart_base.html') %} 2 | {% block content %} 3 | Thanks for your purchase!
4 | {{ transaction_code }} 5 | 6 | {% endblock %} -------------------------------------------------------------------------------- /templates/cart/test.html: -------------------------------------------------------------------------------- 1 | {% extends theme('cart/cart_base.html') %} 2 | 3 | {% block content %} 4 | 5 |
6 |
7 | Payment 8 | {%for processor in cart.get_available_processors() %} 9 | 13 | {% endfor %} 14 |
15 | 16 |
17 | Continue shopping 18 | 19 |
20 | 21 | {% endblock %} 22 | -------------------------------------------------------------------------------- /views.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # -*- coding: utf-8 -*- 3 | import json 4 | from flask import request, jsonify, redirect, url_for, session, current_app 5 | from flask.views import View, MethodView 6 | from quokka.core.templates import render_template 7 | from quokka.utils import get_current_user 8 | from flask.ext.security import current_user 9 | from flask.ext.security.utils import url_for_security 10 | from .models import Cart, Processor 11 | 12 | import logging 13 | logger = logging.getLogger() 14 | 15 | 16 | class BaseView(MethodView): 17 | 18 | requires_login = False 19 | 20 | def needs_login(self, **kwargs): 21 | if not current_user.is_authenticated(): 22 | next = kwargs.get('next', request.values.get('next', '/cart')) 23 | return redirect(url_for_security('login', next=next)) 24 | 25 | def get(self): 26 | # by default redirects to /cart on get 27 | return self.redirect() 28 | 29 | def as_json(self, **kwargs): 30 | format = request.args.get('format') 31 | if request.is_xhr or format == 'json': 32 | for k, v in kwargs.items(): 33 | if hasattr(v, 'to_json'): 34 | kwargs[k] = json.loads(v.to_json()) 35 | try: 36 | return jsonify(kwargs) 37 | except: 38 | return jsonify({"result": str(kwargs)}) 39 | 40 | def render(self, *args, **kwargs): 41 | return self.as_json(**kwargs) or render_template(*args, **kwargs) 42 | 43 | def redirect(self, *args, **kwargs): 44 | next = request.values.get('next', '/cart') 45 | return self.as_json(**kwargs) or redirect(next) 46 | 47 | 48 | class CartView(BaseView): 49 | 50 | def get(self): 51 | if not session.get('cart_id'): 52 | return self.render( 53 | 'cart/empty_cart.html', 54 | url=current_app.config.get('CART_CONTINUE_URL', '/') 55 | ) 56 | 57 | cart = Cart.get_cart() 58 | context = {"cart": cart} 59 | 60 | if cart.items: 61 | template = 'cart/cart.html' 62 | else: 63 | template = 'cart/empty_cart.html' 64 | context['url'] = cart.continue_shopping_url 65 | 66 | return self.render(template, **context) 67 | 68 | 69 | class SetItemView(BaseView): 70 | def post(self): 71 | cart = Cart.get_cart() 72 | params = {k: v for k, v in request.form.items() if not k == "next"} 73 | item = cart.set_item(**params) 74 | return self.redirect(item=item) 75 | 76 | 77 | class RemoveItemView(BaseView): 78 | def post(self): 79 | cart = Cart.get_cart() 80 | params = {k: v for k, v in request.form.items() if not k == "next"} 81 | item = cart.remove_item(**params) 82 | return self.redirect(item=item) 83 | 84 | 85 | class SetProcessorView(BaseView): 86 | def post(self): 87 | cart = Cart.get_cart() 88 | processor = request.form.get('processor') 89 | cart.set_processor(processor) 90 | return self.redirect(processor=cart.processor.identifier) 91 | 92 | 93 | class CheckoutView(BaseView): 94 | def post(self): 95 | cart = Cart.get_cart() 96 | return (cart.requires_login and self.needs_login()) \ 97 | or cart.process_pipeline() 98 | 99 | 100 | class HistoryView(BaseView): 101 | def get(self): 102 | context = { 103 | "carts": Cart.objects(belongs_to=get_current_user()) 104 | } 105 | return self.needs_login( 106 | next=url_for('quokka.modules.cart.history') 107 | ) or self.render( 108 | 'cart/history.html', **context 109 | ) 110 | 111 | 112 | class ProcessorView(View): 113 | methods = ['GET', 'POST'] 114 | 115 | def get_processor(self, identifier): 116 | return Processor.get_instance_by_identifier(identifier) 117 | 118 | 119 | class NotificationView(ProcessorView): 120 | def dispatch_request(self, identifier): 121 | processor = self.get_processor(identifier) 122 | return processor.notification() 123 | 124 | 125 | class ConfirmationView(ProcessorView): 126 | def dispatch_request(self, identifier): 127 | processor = self.get_processor(identifier) 128 | return processor.confirmation() 129 | --------------------------------------------------------------------------------