Time to open the sauce : )

Dead man switch activated. Good luck everyone, I wish you the best.

 - BDFL

	new file:   .gitignore
	new file:   MANIFEST.in
	new file:   Makefile
	new file:   README.rst
	new file:   development.ini
	new file:   journal.rst
	new file:   make_post_sell/__init__.py
	new file:   make_post_sell/config_jinja2.py
	new file:   make_post_sell/lib/__init__.py
	new file:   make_post_sell/lib/mail.py
	new file:   make_post_sell/lib/mail_messages.py
	new file:   make_post_sell/lib/render.py
	new file:   make_post_sell/models/__init__.py
	new file:   make_post_sell/models/cart.py
	new file:   make_post_sell/models/cart_coupon.py
	new file:   make_post_sell/models/coupon.py
	new file:   make_post_sell/models/coupon_redemption.py
	new file:   make_post_sell/models/invoice.py
	new file:   make_post_sell/models/market.py
	new file:   make_post_sell/models/meta.py
	new file:   make_post_sell/models/price.py
	new file:   make_post_sell/models/product.py
	new file:   make_post_sell/models/shop.py
	new file:   make_post_sell/models/shop_search_request.py
	new file:   make_post_sell/models/stripe_user_shop.py
	new file:   make_post_sell/models/user.py
	new file:   make_post_sell/models/user_product.py
	new file:   make_post_sell/models/user_shop.py
	new file:   make_post_sell/request_methods.py
	new file:   make_post_sell/routes.py
	new file:   make_post_sell/scripts/__init__.py
	new file:   make_post_sell/scripts/alembic/README
	new file:   make_post_sell/scripts/alembic/env.py
	new file:   make_post_sell/scripts/alembic/script.py.mako
	new file:   make_post_sell/scripts/alembic/versions/15751f5d7a03_add_active_cart_total_in_cents_to_user_.py
	new file:   make_post_sell/scripts/alembic/versions/24662905655f_create_new_active_shop_id_column_on_.py
	new file:   make_post_sell/scripts/alembic/versions/3e1e70bfe89d_google_analytics_id.py
	new file:   make_post_sell/scripts/alembic/versions/4d9da43b0e86_product_visibility_column.py
	new file:   make_post_sell/scripts/alembic/versions/5c40dfb56179_shop_ribbon_columns.py
	new file:   make_post_sell/scripts/alembic/versions/6da97f2f913f_add_updated_timestamp_to_cart.py
	new file:   make_post_sell/scripts/alembic/versions/96f851cbab01_terms_and_privacy.py
	new file:   make_post_sell/scripts/alembic/versions/ab7338eaedf1_add_price_in_cents_column.py
	new file:   make_post_sell/scripts/alembic/versions/af28cc35ed6f_product_bundles.py
	new file:   make_post_sell/scripts/alembic/versions/b0d5604d9255_add_active_cart_count_to_user_table.py
	new file:   make_post_sell/scripts/alembic/versions/bfc4195a7f56_product_is_sellable_coluumn.py
	new file:   make_post_sell/scripts/alembic/versions/c4d5566ec87d_coupon_stackable_and_couponredemption.py
	new file:   make_post_sell/scripts/alembic/versions/e12bbe39d87e_cart_coupon_table.py
	new file:   make_post_sell/scripts/alembic/versions/eadd0d69e133_shop_ribbon_text_color.py
	new file:   make_post_sell/scripts/alembic/versions/f3aa57777025_coupon_table.py
	new file:   make_post_sell/scripts/alembic/versions/f882f2255cd1_add_stripe_secret_and_public_key_to_shop.py
	new file:   make_post_sell/scripts/alembic/versions/fd9f7e2f2b78_user_full_name.py
	new file:   make_post_sell/scripts/initialize_db.py
	new file:   make_post_sell/scripts/pshell.py
	new file:   make_post_sell/static/css/alerts.css
	new file:   make_post_sell/static/css/common.css
	new file:   make_post_sell/static/css/lib.css
	new file:   make_post_sell/static/css/mps.css
	new file:   make_post_sell/static/favicon.ico
	new file:   make_post_sell/static/js/custom.js
	new file:   make_post_sell/static/js/jquery-3.4.1.min.js
	new file:   make_post_sell/static/js/jquery.min.js
	new file:   make_post_sell/static/makepostsell-3.png
	new file:   make_post_sell/static/makepostsell.png
	new file:   make_post_sell/static/mps.png
	new file:   make_post_sell/templates/actions_new.j2
	new file:   make_post_sell/templates/actions_view.j2
	new file:   make_post_sell/templates/base.j2
	new file:   make_post_sell/templates/billing.j2
	new file:   make_post_sell/templates/cart.j2
	new file:   make_post_sell/templates/cart_checkout.j2
	new file:   make_post_sell/templates/carts.j2
	new file:   make_post_sell/templates/content.j2
	new file:   make_post_sell/templates/content_new.j2
	new file:   make_post_sell/templates/coupon.j2
	new file:   make_post_sell/templates/coupon_new.j2
	new file:   make_post_sell/templates/coupons.j2
	new file:   make_post_sell/templates/home.j2
	new file:   make_post_sell/templates/join-or-log-in.j2
	new file:   make_post_sell/templates/markup_editor.j2
	new file:   make_post_sell/templates/markup_renderer.j2
	new file:   make_post_sell/templates/product.j2
	new file:   make_post_sell/templates/product_edit.j2
	new file:   make_post_sell/templates/product_new.j2
	new file:   make_post_sell/templates/products.j2
	new file:   make_post_sell/templates/shop.j2
	new file:   make_post_sell/templates/shop_about.j2
	new file:   make_post_sell/templates/shop_new.j2
	new file:   make_post_sell/templates/shop_products.j2
	new file:   make_post_sell/templates/shop_settings.j2
	new file:   make_post_sell/templates/shops.j2
	new file:   make_post_sell/templates/snippets/csrf.j2
	new file:   make_post_sell/templates/snippets/flash-alerts.j2
	new file:   make_post_sell/templates/snippets/google-analytics.j2
	new file:   make_post_sell/templates/snippets/optional-javascript.j2
	new file:   make_post_sell/templates/snippets/ribbon.j2
	new file:   make_post_sell/templates/snippets/search.j2
	new file:   make_post_sell/templates/snippets/stripe.j2
	new file:   make_post_sell/templates/update-card.j2
	new file:   make_post_sell/templates/user_settings.j2
	new file:   make_post_sell/tests/__init__.py
	new file:   make_post_sell/tests/test_functional.py
	new file:   make_post_sell/tests/test_models.py
	new file:   make_post_sell/views/__init__.py
	new file:   make_post_sell/views/actions.py
	new file:   make_post_sell/views/authentication.py
	new file:   make_post_sell/views/billing.py
	new file:   make_post_sell/views/cart.py
	new file:   make_post_sell/views/content.py
	new file:   make_post_sell/views/coupon.py
	new file:   make_post_sell/views/misc.py
	new file:   make_post_sell/views/product.py
	new file:   make_post_sell/views/shop.py
	new file:   make_post_sell/views/user.py
	new file:   pytest.ini
	new file:   requirements-dev.txt
	new file:   requirements-prod.txt
	new file:   requirements-test.txt
	new file:   requirements.py3.txt
	new file:   requirements.txt
	new file:   setup.py
	new file:   test.ini
This commit is contained in:
Russell Ballestrini 2021-12-31 21:39:40 -05:00
commit c211df66a8
121 changed files with 10542 additions and 0 deletions

27
.gitignore vendored Normal file
View file

@ -0,0 +1,27 @@
# my ignores.
*.sqlite*
vars.fish
vars.sh
*.egg
*.egg-info
*.pyc
*$py.class
*~
.coverage
coverage.xml
build/
dist/
src/
.tox/
nosetests.xml
env*/
tmp/
Data.fs*
*.sublime-project
*.sublime-workspace
.*.sw?
.sw?
.DS_Store
coverage
test

2
MANIFEST.in Normal file
View file

@ -0,0 +1,2 @@
include *.txt *.ini *.cfg *.rst
recursive-include make_post_sell *.ico *.png *.css *.gif *.jpg *.pt *.txt *.mak *.mako *.js *.html *.xml *.jinja2 *.j2

33
Makefile Normal file
View file

@ -0,0 +1,33 @@
env:
python3 -m venv env
. env/bin/activate
env/bin/pip install --upgrade pip
env/bin/pip install --editable .
env/bin/pip install --upgrade -r requirements.py3.txt
cp -rp env env.vanilla
dev: env
env/bin/pip install --upgrade -r requirements-dev.txt
env/bin/pip install --upgrade -r requirements-test.txt
prod: env
env/bin/pip install --upgrade -r requirements-prod.txt
init_db: env
env/bin/initialize_make_post_sell_db ${config}
clean:
rm -rf env
rm -rf env.vanilla
test: dev
#env/bin/py.test --lf
env/bin/py.test
serve: dev
# In the first shell, run a copy of make_post_sell using:
env/bin/pserve development.ini --reload
smtp: env
# In the third shell, run a "mock" SMTP server to catch log in emails:
sudo env/bin/python -m smtpd -n -c DebuggingServer localhost:25

100
README.rst Normal file
View file

@ -0,0 +1,100 @@
Make Post Sell
==============
The `Make Post Sell <https://www.makepostsell.com>`_ monolith platform service.
Getting Started
---------------
Clone this repo and change your working directory to the checkout.
Create a Python virtual environment and install project dependencies:
.. code-block:: bash
make dev
Load schema and default data into the database using a script,
we currently use sqlite3 as a development and test database:
.. code-block:: bash
make init_db config=development.ini
Run a web application server with the project loaded:
.. code-block:: bash
make serve
Open a 2nd shell and run a test SMTP server for intercepting OTP login uri's:
.. code-block:: bash
make smtp
In a web browser navagate to http://127.0.0.1:6501/ and you should see the app.
Running Tests
-------------
We currently use `pytest`:
.. code-block:: bash
make test
SQL Migrations
===============
If your deployment is brand new, you don't need to run any migrations.
You should however run this to stamp the database as ready:
.. code-block:: bash
alembic -c development.ini stamp head
Otherwise, it should be safe to run this at anytime to catch your database up:
.. code-block:: bash
alembic -c development.ini upgrade head
To look at the current revision and the history run these:
.. code-block:: bash
alembic -c development.ini history
alembic -c development.ini current
If you ever want to cut a new migration script, you can run this:
.. code-block:: bash
alembic -c development.ini revision -m "Added email_id column to User table."
Then you can edit / modify the generated ``.py`` file with your changes.
You can also autogenerate a new migration script using `--autogenerate`.
Alembic will prepare a migration script by comparing the state of the
database with the state of the model:
.. code-block:: bash
alembic -c development.ini revision --autogenerate -m "autogenerated indicies."
You should review the recommended migration script before `upgrade`.
misc
====
You may source the new Python virtual environment during development:
.. code-block:: bash
# source env/bin/activate.fish
. env/bin/activate

108
development.ini Normal file
View file

@ -0,0 +1,108 @@
###
# app configuration
# https://docs.pylonsproject.org/projects/pyramid/en/latest/narr/environment.html
###
[app:main]
use = egg:make_post_sell
pyramid.reload_templates = true
pyramid.debug_authorization = true
pyramid.debug_notfound = true
pyramid.debug_routematch = true
pyramid.default_locale_name = en
pyramid.includes = pyramid_debugtoolbar pyramid_tm
# By default, the toolbar only appears for clients from IP addresses
# '127.0.0.1' and '::1'.
#debugtoolbar.hosts = 127.0.0.1 ::1
debugtoolbar.hosts = 0.0.0.0/0
sqlalchemy.url = sqlite:///%(here)s/make_post_sell.sqlite
retry.attempts = 3
###
# session / cookie configuration.
# http://docs.pylonsproject.org/docs/pyramid/en/latest/api/session.html
###
session.hashalg = sha512
session.secret = test-secret
session.timeout = 31104000
session.max_age = 31104000
session.reissue_time = 15552000
session.samesite = None
###
# app "business logic" settings
###
app.root_domain = blanka.foxhop.net
app.root_url = http://blanka.foxhop.net:6501
app.bucket.secure_uploads = plan-period-files
app.bucket.secure_uploads.region = nyc3
app.bucket.secure_uploads.post_endpoint = https://nyc3.digitaloceanspaces.com
#app.bucket.secure_uploads.get_endpoint = https://plan-period-files.nyc3.digitaloceanspaces.com
# testing cdn in development.
app.bucket.secure_uploads.get_endpoint = https://plan-period-files.nyc3.cdn.digitaloceanspaces.com
app.bucket.secure_uploads.access_key = ${MPS_APP_SECURE_UPLOADS_ACCESS_KEY}
app.bucket.secure_uploads.secret_key = ${MPS_APP_SECURE_UPLOADS_SECRET_KEY}
[pshell]
setup = make_post_sell.scripts.pshell.setup
###
# wsgi server configuration
###
[alembic]
# path to migration scripts
script_location = make_post_sell:scripts/alembic
sqlalchemy.url = sqlite:///%(here)s/make_post_sell.sqlite
[server:main]
use = egg:waitress#main
#listen = localhost:6501
#listen = 192.168.1.216:6501
listen = 0.0.0.0:6501
###
# logging configuration
# https://docs.pylonsproject.org/projects/pyramid/en/latest/narr/logging.html
###
[loggers]
keys = root, make_post_sell, sqlalchemy
[handlers]
keys = console
[formatters]
keys = generic
[logger_root]
level = INFO
handlers = console
[logger_make_post_sell]
level = DEBUG
handlers =
qualname = make_post_sell
[logger_sqlalchemy]
level = WARN
handlers =
qualname = sqlalchemy.engine
# "level = INFO" logs SQL queries.
# "level = DEBUG" logs SQL queries and results.
# "level = WARN" logs neither. (Recommended for production systems.)
[handler_console]
class = StreamHandler
args = (sys.stderr,)
level = NOTSET
formatter = generic
[formatter_generic]
format = %(asctime)s %(levelname)-5.5s [%(name)s:%(lineno)s][%(threadName)s] %(message)s

248
journal.rst Normal file
View file

@ -0,0 +1,248 @@
Tue 23 Jun 2020 07:16:03 PM EDT
=================================
Ok so I'm on day 4 of my stay-cation at home and I've finally gotten around to working on one of my two important goals during my break.
1. implement coupon codes on makepostsell.com and
2. utilize all cardboard into my landscape and out of my garage and house
I've made great progress on 2, but just started getting my toes wet on 1...
Gosh let me tell you, I spent the last 2 hours or so just getting parts of my release pipeline working again, end to end.
* I had to hard reboot the Jenkins server (`jenkins.foxhop.net`) which lives on my SmartOS host (`mbison.foxhop.net`)::
ssh root@mbison.foxhop.net
vmadm list
vmadm stop --force <jenkins-uuid>
vmadm start <jenkins-uuid>
* I had to fix tests in this repo (`make_post_sell`):
See the previous commit in github for details, but basically I broke jenkins builds because I busted my `setup.py` when I broke some magic that I forgot I implemented to load `README.txt`, which I recently moved to `README.rst`...
* I had to release new TLS certificates, which had expired, again!
Didn't I fix the TLS certificate expiring issue?
Let's check...
yup ... doh!
`akuma-crontab/init.sls`::
deploy-new-letsencrypt-certs:
cron.present:
- comment: 'Deploy new certs every day at 4:25am'
- name: salt -C 'remarkbox.* or sakura.* or ryu.*' state.sls letsencrypt.client
- identifier: 'deploy-new-letsencrypt-certs'
- minute: 25
- hour: 19
- user: root
So let's update that state to also include `mps-web*` so that it automatically get's it's certificates each day, if any new ones are present. In this way the cronjob that requests the new certs controls the speed of which the new certs are staged and then submitted each day.
Akuma (`akuma.foxhop.net`) is the `salt-master` (SaltStack).
Anyways, new cronjob:
::
name: salt -C 'remarkbox.* or sakura.* or ryu.* or mps-web*' state.sls letsencrypt.client
Sat 01 Aug 2020 03:10:13 PM EDT
###################################
ok we are camping today and I've been messing with the make post sell codebase building out coupon code workflows all this week. We rented an RV for a week.
Place is real chill, and the kids get to socialize with family.
Anyways so I've been able to work a bit again on the coupon code system.
Previously I had all the models done but no real way to create coupons.
In my latest commit I created a new coupon form and the views/handlers to accept user data and generate Coupon instances.
I also created a cute coupon page which shop keepers could link to when sending out a marketing communications like emails or social media posts.
The coupon page makes it really easy for a customer to click a button labelled "apply coupon to active cart".
I still need to build the "apply coupon to active cart" button logic.
What does "apply" mean for a cart, whether single shop or marketplace?
Seems like there are two "states" a Coupon/Cart "attachment" could be in:
* `unqualified`
* `qualified`
a Coupon has certain qualification conditions which must be met by the cart before proceeding with checkout.
For example:
* a $20 minimum cart total
* coupon expired
* limit 1 per customer and the customer already used the coupon
* total redemption limit reached
These checks are verified when the user tries to click checkout.
If any fail, flash a message to the user explaining why and send them back to the cart.
These checks are verified once again, after the user clicks the "Yes, Complete Checkout" button. Failure at this stage bumps user back to cart with the error message.
The cart has a link to the attached coupon so the user may easily click it to reread the terms of the coupon.
The link also had an `X` which may be clicked to remove the coupon from the cart.
I was planning to day dream about this away from the keyboard but I instead day dreamed about it in this journal file.
This is an important next step. Finishing this means we work on integration/functional tests for a complete checkout process including adding a coupon to a cart (and all the numerous edgecases that could have).
Once we have those tests, we may safely ship coupon codes to production!
Sun 23 Aug 2020 11:53:13 AM EDT
====================================
So I ran into a strange case where I deleted or in retrospect, renamed a field model field which happened to get into my alembic migrations.
This manifested quite a few commits later when I needed to migrate the database
again for a new table (in this case the table to relate many Coupons to Carts) I noticed that the migration was trying to remove the renamed field.
Now at first glance this wouldn't be a big deal, EXCEPT for the fact that my
development environment is sqlite3 and as of today, it still doesn't support
ALTER TABLE and DROP COLUMN so I new this migration, just by looking at the code that it would fail to apply.
Now I use master for all my work, even features because I don't have the memory to remember feature branches for all my different codebases. If I ever get a team around this codebase `make_post_sell` I would definately switch to a feature branch model.
Anyways I have a lot of changes in master, and I have not shipped to production in a long time. I have all the coupon code dashboard stuff queued up and I want to ship the whole feature MVP together as a unit; but I've been a bit scatter-brained and only working occasionally on `make_post_sell` over the last few months.
That said, in my defence the ONLY priority for `make_post_sell` is shipping coupon codes. So using master as the feature branch for coupon codes actually makes sense in some wierd way.
Anyways, shipping coupon codes is my #1 priority is because we want it ready for Jenn's back-to-school curriculum sales on https://shop.printableprompts.com.
Ok so back to the problem at hand, I needed to figure out when a line was added and when it was removed and I knew what string I needed to search for (`stripe_id`).
So the search when for when I added the line, because I shifted around how we store our stripe setup a few times in this app, I decided to see if the first commit to the `User` class had the field.
Sure enough, the first commit of the `User` class had a reference to `stripe_id`.
So master does not have `User.stripe_id` but the first commit to this file does have `User.stripe_id`. This means we need to find the commit when we deleted the line containing `stripe.id`.
Searched duckduckgo (DDG) with the query "determine the commit when a line was deleted" and came upon a stackoverflow forum post which helped me form this query::
git log -c -S'stripe_id' /path/to/file.py
Unfortnately this didn't work, but this did!::
git log -c -S'stripe_id'
Display all logs with commits for all files (`git log -c`) including only the commits that have the search string (`-S'stripe_id'`)
This gave me a handful of commits to scroll through looking for my match and sure enough I found the commit deleting the `stripe_id` field.
Then I figured out the missing context, my past self decided to rename `stripe_id` to `cus_id` but I didn't notice or didn't care that the column had existed in sqlite3 database since the start.
Here is the commit ::
commit b70e46bfef7f7ef3442d23e6d66ebd7aea5fd767
Author: russellballestrini <russell.ballestrini@gmail.com>
Date: Sun Sep 1 20:58:52 2019 -0400
/billing functions as expected.
diff --git a/make_post_sell/models/stripe_user_shop.py b/make_post_sell/models/stripe_user_shop.py
index 912e6c3..4a4bb66 100644
--- a/make_post_sell/models/stripe_user_shop.py
+++ b/make_post_sell/models/stripe_user_shop.py
@@ -17,7 +17,7 @@ class StripeUserShop(RBase, Base):
shop_id = Column(UUIDType, foreign_key("Shop", "id"), nullable=False)
# example: "cus_12345678AbCdEF".
- stripe_id = Column(Unicode(18), unique=True, nullable=False)
+ cus_id = Column(Unicode(32), nullable=False)
user = relationship(
argument="User", backref=backref("stripe_user", cascade="all, delete-orphan")
@@ -27,8 +27,6 @@ class StripeUserShop(RBase, Base):
argument="Shop", backref=backref("stripe_shop", cascade="all, delete-orphan")
)
- # def set_stripe_id
-
def __init__(self, user=None, shop=None):
self.id = uuid.uuid1()
self.user = user
.. image:: one-cannot-simply-meme
One cannot simply ALTER TABLE to rename a column in sqlite3
Ok so at this point, I'm not sure I care enough about the naming of this column and I don't want
to do the scary work of migrating the production sqlite3 database manually by hand. Maybe I automate an offline rename sqlite3 column script but for now I'm just not that interested in "operations" work.
So this commit will be renaming the `cus_id` column back to it's original name `stripe_id` on the `User` class.
Fri Jul 16 12:11:20 PM EDT 2021
=================================================
could there be slack bot workflows for interacting with a make_post_sell shop as an owner?
for example creating new products via bot commands, editing products via bot commands, etc.
and if this could be useful how does matrix fit into making chat bots? could a matrix bot be
a one ring to rule them all sort of play where a bridge could be build between any chat service?
Ask if this is a good idea or not.
How about transaction data or daily/weekly/monthly reports notified via email, slack/chat?
anyways food for thought! think errbot UAC (user access control) plugin gitlab pipeline setup. but instead it could interact with make_post_sell API (the API is build and bot which speaks html/http/form and hack away). It's the web scraper's API. how will you prevent people from spamming the system? make it just as easy to delete spam.
imagine now a bot which changes prices for a group of products or a bot which changes all prices on a shop based on a metric multiplier or some other formula.
The chat bot allows others the ability to harness authenticated and canned API calls which they may give inputs, proper process leads to desirable outputs. It's the trivium. A proper bot unlocks a safe trivium for end user creators, operators, marketing people. The salesman position is removed, your work packaged and posted to your shop sells itself 24/7 - you don't even need to pay a sales clerk. You will however have to drive "traffic" to your content and to your shop and so the marketers job is never ending. Where will you take your business next?
Wed Nov 10 07:09:49 PM EST 2021
===================================
this release creates the concept of product visibility. seems to be working as I expect at this point so I'm happy. Let's Go!
Tue Nov 16 08:40:38 PM EST 2021
==============================================
[ ] we need to implement shop defined object stores.
engineering wants to test this feature with digital ocean spaces and s3 and gcp.
sales wants this feature to be an upsale for the $99/yr plan.
marketing likes this and could start to work on a pricing section on the homepage.
wants to discuss contrants on the free account, like 500M capacity what is the cost-of that across various cloud providers?
we don't want to kill the company trying to service free content creators.
Tue Nov 23 12:11:54 PM EST 2021
================================
Eureka, I need to built a streaming content system into make post sell.
I'll build my own BoobTube.
I will try to overload the mps_product objects to support free "streaming" (broadcast) type "content" products.
A major difference in "content" versus "product" is that there is no way to purchase it,
and the content page may autoplay and similar to a tube site.
Leads the way to building in comments & community.
main difference is the metadata on the object store needs to allow "inline" streaming instead of "attachment".

View file

@ -0,0 +1,42 @@
from pyramid.config import Configurator
from pyramid.session import SignedCookieSessionFactory
from miscutils import get_children_settings
def session_serializer(serializer_name):
if serializer_name == "json":
from pyramid.session import JSONSerializer
return JSONSerializer()
from pyramid.session import PickleSerializer
return PickleSerializer()
def main(global_config, **settings):
"""This function returns a Pyramid WSGI application."""
# Setup session factory signed cookies prevent tampering, not encrypted.
session_settings = get_children_settings(settings, "session")
# Setup session serializer.
session_settings["serializer"] = session_serializer(
session_settings.get("serializer")
)
session_factory = SignedCookieSessionFactory(**session_settings)
with Configurator(settings=settings, session_factory=session_factory) as config:
# all of the models for persisting data into our database.
config.include(".models")
# all of the web application routes.
config.include(".routes")
# all of the data we attach to each inbound request.
config.include(".request_methods")
# enable jinja2 templating engine.
config.include(".config_jinja2")
# scan each of these includes for additional configuration.
config.scan()
return config.make_wsgi_app()

View file

@ -0,0 +1,5 @@
def includeme(config):
JINJA2_EXTENSION = ".j2"
config.include("pyramid_jinja2")
config.add_jinja2_renderer(JINJA2_EXTENSION)
config.add_jinja2_search_path("templates", name=JINJA2_EXTENSION)

View file

142
make_post_sell/lib/mail.py Normal file
View file

@ -0,0 +1,142 @@
from miscutils.mail import send_pyramid_email
# quote email address in OTP so that a plus address
# is not decoded as a space during authentication.
try:
# Python 2.
from urllib import quote_plus
except ImportError:
# Python 3.
from urllib.parse import quote_plus
from make_post_sell.lib.mail_messages import (
WELCOME_1_TEXT,
WELCOME_1_HTML,
PURCHASE_1_TEXT,
PURCHASE_1_HTML,
SALE_1_TEXT,
SALE_1_HTML,
)
def send_otp_email(request, to_email, raw_otp, return_to):
"""
Send email with OTP (one time password) link.
request
the request (of the successful log in attempt)
to_email
the email address to send the OTP link
raw_otp
the raw (unencrypted) one time password
return_to
the URI to return the user on successful authentication
"""
query_params = [
"email={}".format(quote_plus(to_email)),
"raw-otp={}".format(raw_otp),
]
if return_to:
query_params.append("return-to={}".format(return_to))
link = "{0}/join-or-log-in?{1}".format(request.app_url, "&".join(query_params))
subject = "Your log in link"
message_text = WELCOME_1_TEXT.format(link)
message_html = WELCOME_1_HTML.format(subject, link)
send_pyramid_email(request, to_email, subject, message_text, message_html)
def send_purchase_email(request, to_email, products, total_cost):
"""
Send purchase email to customer.
request
the request (of the successful log in attempt)
to_email
the email address to send the email to.
products
the list of products that the customer purchased.
total_cost
the total transaction price.
"""
subject = "Your purchase was successful!"
product_text_list = []
for p in products:
thumbnail = ""
if "thumbnail1" in p.extensions:
thumbnail = '<img src="{}/{}/thumbnail1?ts={}" style="border: 1px solid #ddd; border-radius: 4px; max-width: 184px; max-height: 184px; width: auto; height: auto;" />'.format(
request.app["bucket.secure_uploads.get_endpoint"],
p.s3_path,
p.updated_timestamp,
)
product_text_list.append(
'<p><a href="{}">{}<br/>{}</a></p>'.format(
p.absolute_url(request), p.title, thumbnail
)
)
product_text = "<br/>".join(product_text_list)
message_text = PURCHASE_1_TEXT.format(
"%.2f" % total_cost, request.app_url
)
message_html = PURCHASE_1_HTML.format(
subject, request.app_url, product_text, "%.2f" % total_cost
)
send_pyramid_email(request, to_email, subject, message_text, message_html)
def send_sale_email(request, shop, products, total_cost):
"""
Send an email to all shop owners regarding the sale.
request
the request (of the successful log in attempt)
shop
the shop that made the sale
products
the list of products that the customer purchased.
total_cost
the total transaction price.
"""
subject = "You made a sale!"
product_text_list = []
for p in products:
thumbnail = ""
if "thumbnail1" in p.extensions:
thumbnail = '<img src="{}/{}/thumbnail1?ts={}" style="border: 1px solid #ddd; border-radius: 4px; max-width: 184px; max-height: 184px; width: auto; height: auto;" />'.format(
request.app["bucket.secure_uploads.get_endpoint"],
p.s3_path,
p.updated_timestamp,
)
product_text_list.append(
'<p><a href="{}">{}<br/>{}</a></p>'.format(
p.absolute_url(request), p.title, thumbnail
)
)
product_text = "<br/>".join(product_text_list)
message_text = SALE_1_TEXT.format(
"%.2f" % total_cost, request.app_url
)
message_html = SALE_1_HTML.format(
subject, request.app_url, shop.id, product_text, "%.2f" % total_cost
)
# send a separate email for each shop owner.
for user in shop.users:
send_pyramid_email(request, user.email, subject, message_text, message_html)

View file

@ -0,0 +1,158 @@
WELCOME_1_TEXT = """
Hey there!
Here is the log in link that you requested.
You may paste this link into your browser to log in:
\n{0}\n
"""
WELCOME_1_HTML = """
<!DOCTYPE html>
<html>
<head>
<title>{0}</title>
</head>
<body>
<h2>Hey there!</h2>
<p>
Here is the log in link that you requested:
</p>
<p>
<a href="{1}" style="font-weight: bold;" target="_blank">Click here to log in!</a>
</p>
<p style="font-size: .8em;">
<span style="color: #aaaaaa;">
You may paste this link into your browser to log in:
</span>
<br>
<br>
<a href="{1}" style="color: #439fe0; font-weight: normal; text-decoration: none; word-break: break-word;" target="_blank">
{1}
</a>
</p>
</body>
</html>
"""
PURCHASE_1_TEXT = """
You made a purchase!
{0}
You may paste this link into your browser to view and download your purchases:
\n{1}\n
Thank you very much!
"""
# 0: subject,
# 1: request.app_url,
# 2: product_text,
# 3: total_cost,
PURCHASE_1_HTML = """
<!DOCTYPE html>
<html>
<head>
<title>{0}</title>
</head>
<body>
<h2>
You made a purchase!
</h2>
{2}
<b>${3}</b>
<p>
You may <a href="{1}/u/purchases" style="font-weight: bold;" target="_blank">download your purchases</a> at any time.
</p>
<p>
</p>
<p style="font-size: .8em;">
<span style="color: #aaaaaa;">
You may paste this link into your browser to view or download your purchases:
</span>
<br>
<br>
<a href="{1}/u/purchases" style="color: #439fe0; font-weight: normal; text-decoration: none; word-break: break-word;" target="_blank">
{1}/u/purchases
</a>
</p>
<p>
Thank you very much!
</p>
</body>
</html>
"""
SALE_1_TEXT = """
You made a sale!
{0}
You may paste this link into your browser to view your sales:
\n{1}\n
Great job!
"""
# 0: subject,
# 1: request.app_url,
# 2: shop.id,
# 3: product_text,
# 4: total_cost,
SALE_1_HTML = """
<!DOCTYPE html>
<html>
<head>
<title>{0}</title>
</head>
<body>
<h2>
You made a <u>sale</u>!
</h2>
<b>${4}</b>
{3}
<p>
You may view <a href="{1}/s/{2}/sales" style="font-weight: bold;" target="_blank">all your sales</a> at any time.
</p>
<p style="font-size: .8em;">
<span style="color: #aaaaaa;">
You may paste this link into your browser to view your sales:
</span>
<br>
<br>
<a href="{1}/s/{2}/sales" style="color: #439fe0; font-weight: normal; text-decoration: none; word-break: break-word;" target="_blank">
{2}/u/purchases
</a>
</p>
<p>
Great job!
</p>
</body>
</html>
"""

View file

@ -0,0 +1,29 @@
from miscutils.sanitize_html import (
default_cleaner,
markdown_to_raw_html,
clean_raw_html,
)
import logging
log = logging.getLogger(__name__)
def make_cleaner_from_shop(shop):
"""Given a Shop return a bleach Cleaner object."""
cleaner = default_cleaner()
cleaner.link_protection = True
apex_domain_name = shop.domain_name.split(".")[-2:]
cleaner.whitelist_domains.append(shop.domain_name)
cleaner.whitelist_domains.append(apex_domain_name)
cleaner.absolute_domain = shop.domain_name
return cleaner
def markdown_to_html(data, shop=None):
raw_html = markdown_to_raw_html(data)
if shop:
cleaner = make_cleaner_from_shop(shop)
else:
cleaner = default_cleaner()
return clean_raw_html(raw_html, cleaner)

View file

@ -0,0 +1,91 @@
from sqlalchemy import engine_from_config
from sqlalchemy.orm import sessionmaker
from sqlalchemy.orm import configure_mappers
import zope.sqlalchemy
# import or define all models here to ensure they are attached to the
# Base.metadata prior to any initialization routines.
from .product import *
from .shop import *
from .user import *
from .user_shop import *
from .user_product import *
from .price import *
from .cart import *
from .cart_coupon import *
from .market import *
from .coupon import *
from .coupon_redemption import *
from .invoice import *
from .stripe_user_shop import *
from .shop_search_request import *
# run configure_mappers after defining all of the models
# to ensure all relationships can be setup.
configure_mappers()
def get_engine(settings, prefix="sqlalchemy."):
return engine_from_config(settings, prefix)
def get_session_factory(engine):
factory = sessionmaker()
factory.configure(bind=engine)
return factory
def get_tm_session(session_factory, transaction_manager):
"""
Get a ``sqlalchemy.orm.Session`` instance backed by a transaction.
This function will hook the session to the transaction manager which
will take care of committing any changes.
- When using pyramid_tm it will automatically be committed or aborted
depending on whether an exception is raised.
- When using scripts you should wrap the session in a manager yourself.
For example::
import transaction
engine = get_engine(settings)
session_factory = get_session_factory(engine)
with transaction.manager:
dbsession = get_tm_session(session_factory, transaction.manager)
"""
dbsession = session_factory()
zope.sqlalchemy.register(dbsession, transaction_manager=transaction_manager)
return dbsession
def includeme(config):
"""
Initialize the model for a Pyramid app.
Activate this setup using ``config.include('make_post_sell.models')``.
"""
settings = config.get_settings()
settings["tm.manager_hook"] = "pyramid_tm.explicit_manager"
# use pyramid_tm to hook the transaction lifecycle to the request
config.include("pyramid_tm")
# use pyramid_retry to retry a request when transient exceptions occur
config.include("pyramid_retry")
session_factory = get_session_factory(get_engine(settings))
config.registry["dbsession_factory"] = session_factory
# make request.dbsession available for use in Pyramid
config.add_request_method(
# r.tm is the transaction manager used by pyramid_tm
lambda r: get_tm_session(session_factory, r.tm),
"dbsession",
reify=True,
)

View file

@ -0,0 +1,358 @@
import uuid
import json
from sqlalchemy.orm import relationship
from sqlalchemy.ext.associationproxy import association_proxy
from sqlalchemy import (
Column,
Boolean,
BigInteger,
Unicode,
UnicodeText
)
from .meta import (
Base,
RBase,
UUIDType,
foreign_key,
now_timestamp,
get_object_by_id,
bucket_objects_by_id,
)
from .product import get_products_by_ids
from .shop import get_shops_by_ids
from .cart_coupon import CartCoupon
from miscutils import (
timestamp_to_ago_string,
dollars_to_cents,
cents_to_dollars,
)
try:
unicode("")
except:
from six import u as unicode
class Cart(RBase, Base):
"""This class tracks a User's shopping carts."""
# only a cart whose id is in the session is considered active.
active = False
id = Column(UUIDType, primary_key=True, index=True)
user_id = Column(UUIDType, foreign_key("User", "id"), nullable=True)
name = Column(Unicode(64))
public = Column(Boolean, default=False)
json_cart = Column(UnicodeText, default=unicode("{}"))
created_timestamp = Column(BigInteger, nullable=False)
updated_timestamp = Column(BigInteger, nullable=False)
user = relationship(argument="User", uselist=False, lazy="joined")
# many to many uses association_proxy.
coupons = association_proxy("cart_coupons", "coupon", creator=lambda c: CartCoupon(coupon=c))
def __init__(self, user=None):
# since shopping carts are a private thing which may optionally
# be granted public access, we use random uuid4 UUIDs to prevent
# interative discovery/enumeration of public carts.
self.id = uuid.uuid4()
self.user = user
self.json_cart = unicode("{}")
self.created_timestamp = now_timestamp()
self.updated_timestamp = now_timestamp()
def _bust_memoized_attributes(self):
if hasattr(self, "_count"):
del self._count
if hasattr(self, "_line_totals_in_cents"):
del self._line_totals_in_cents
if hasattr(self, "_products"):
del self._products
if hasattr(self, "_shops"):
del self._shops
if hasattr(self, "_shop_product_dict"):
del self._shop_product_dict
if hasattr(self, "_shop_totals_in_cents"):
del self._shop_totals_in_cents
if hasattr(self, "_shop_totals"):
del self._shop_totals
if hasattr(self, "_discounted_shop_totals_in_cents"):
del self._discounted_shop_totals_in_cents
if hasattr(self, "_discounted_shop_totals"):
del self._discounted_shop_totals
if hasattr(self, "_line_totals"):
del self._line_totals
def set_cart(self, cart_dict):
"""Save cart_dict as JSON into json_cart."""
self._cart = cart_dict
self.json_cart = json.dumps(self._cart)
self.updated_timestamp = now_timestamp()
self._bust_memoized_attributes()
def get_cart(self):
"""memoized dictionary of user's shopping cart."""
if hasattr(self, "_cart") == False:
self._cart = json.loads(self.json_cart)
return self._cart
cart = property(get_cart, set_cart)
def empty(self):
"""removes all items from cart."""
self.cart = {}
self._bust_memoized_attributes()
def add_product(self, product):
quantity = 1
if str(product.id) in self.cart:
quantity = self.cart[str(product.id)] + 1
self.set_product_quantity(product, quantity)
def remove_product(self, product):
tmp_cart = self.cart
del tmp_cart[str(product.id)]
self.cart = tmp_cart
def set_product_quantity(self, product, quantity):
quantity = int(quantity)
if quantity >= 999:
quantity = 999
tmp_cart = self.cart
tmp_cart[str(product.id)] = quantity
self.cart = tmp_cart
def get_product_quantity(self, product):
return self.cart.get(product.uuid_str, 0)
def merge_in_cart(self, other):
tmp_cart = self.cart
for product_id, count in other.cart.items():
if product_id in tmp_cart:
tmp_cart[product_id] += count
else:
tmp_cart[product_id] = count
self.cart = tmp_cart
@property
def count(self):
if hasattr(self, "_count") == False:
self._count = sum(self.cart.values())
return self._count
@property
def products(self):
"""
Return a dictionary of unique Product objects in cart.
The key is the product UUID, and the value is the Product object.
"""
if hasattr(self, "_products") == False:
product_ids = self.cart.keys()
products = get_products_by_ids(self.dbsession, product_ids)
self._products = bucket_objects_by_id(products)
return self._products
@property
def shops(self):
"""
Return a dictionary of unique Shop objects in cart.
The key is the Shop UUID, and the value is the Shop object.
"""
if hasattr(self, "_shops") == False:
self._shops = {}
# loop over Products in cart, aggregate a unique set of shop_ids.
shop_ids = set()
for product in self.products.values():
shop_ids.add(product.id_to_uuid_str(product.shop_id))
shops = get_shops_by_ids(self.dbsession, shop_ids)
self._shops = bucket_objects_by_id(shops)
return self._shops
@property
def shop_product_dict(self):
"""
Return a dict where keys are shop_ids and values are a list of product_ids.
"""
if hasattr(self, "_shop_product_dict") == False:
self._shop_product_dict = {}
for shop_id in self.shops.keys():
self._shop_product_dict[shop_id] = []
for product in self.products.values():
shop_id = product.id_to_uuid_str(product.shop_id)
self._shop_product_dict[shop_id].append(product.uuid_str)
return self._shop_product_dict
@property
def line_totals_in_cents(self):
if hasattr(self, "_line_totals_in_cents") == False:
self._line_totals_in_cents = {}
for product in self.products.values():
quantity = self.get_product_quantity(product)
self._line_totals_in_cents[product.uuid_str] = (
product.price_in_cents * quantity
)
return self._line_totals_in_cents
@property
def line_totals(self):
if hasattr(self, "_line_totals") == False:
self._line_totals = {}
for product_id, line_total_in_cents in self.line_totals_in_cents.items():
self._line_totals[product_id] = cents_to_dollars(line_total_in_cents)
return self._line_totals
@property
def shop_totals_in_cents(self):
if hasattr(self, "_shop_totals_in_cents") == False:
self._shop_totals_in_cents = {}
for shop_id, product_ids in self.shop_product_dict.items():
self._shop_totals_in_cents[shop_id] = 0
for product_id in product_ids:
self._shop_totals_in_cents[shop_id] += self.line_totals_in_cents[product_id]
return self._shop_totals_in_cents
@property
def shop_totals(self):
if hasattr(self, "_shop_totals") == False:
self._shop_totals = {}
for shop_id, shop_total_in_cents in self.shop_totals_in_cents.items():
self._shop_totals[shop_id] = cents_to_dollars(shop_total_in_cents)
return self._shop_totals
@property
def discounted_shop_totals_in_cents(self):
"""Discounted shop totals in cents after applying coupons."""
if hasattr(self, "_discounted_shop_totals_in_cents") == False:
from copy import deepcopy
self._discounted_shop_totals_in_cents = deepcopy(self.shop_totals_in_cents)
if len(self.coupons) > 0:
for coupon in sorted(self.coupons):
for shop_uuid, shop_total_in_cents in self.shop_totals_in_cents.items():
if coupon.shop_uuid_str == shop_uuid:
self._discounted_shop_totals_in_cents[shop_uuid] = coupon.compute_discount(shop_total_in_cents)
return self._discounted_shop_totals_in_cents
@property
def discounted_shop_totals(self):
if hasattr(self, "_discounted_shop_totals") == False:
self._discounted_shop_totals = {}
for shop_id, discounted_shop_total_in_cents in self.discounted_shop_totals_in_cents.items():
self._discounted_shop_totals[shop_id] = cents_to_dollars(discounted_shop_total_in_cents)
return self._discounted_shop_totals
@property
def total_price_in_cents(self):
# TODO: memoize?
return sum(self.line_totals_in_cents.values())
@property
def total_price(self):
return cents_to_dollars(self.total_price_in_cents)
@property
def total_discounted_price_in_cents(self):
# TODO: memoize?
return sum(self.discounted_shop_totals_in_cents.values())
@property
def total_discounted_price(self):
return cents_to_dollars(self.total_discounted_price_in_cents)
@property
def is_discounted(self):
if self.total_price != self.total_discounted_price:
return True
return False
@property
def total(self):
"""The total (after discounts) to be charged to user via an Invoice."""
if self.is_discounted:
return self.total_discounted_price
return self.total_price
@property
def total_in_cents(self):
"""The total in cents (after discounts)."""
if self.is_discounted:
return self.total_discounted_price_in_cents
return self.total_price_in_cents
@property
def requires_payment(self):
"""Return True if we should charge a card, else False."""
# Only charge a credit card if cart total is greater than $0.64!
# Credit card "gas" payment costs $0.30 + 2.9%
# We gift the customer up to $0.32 when we bypass credit card gas.
if self.total_in_cents > 64:
return True
return False
@property
def is_not_public(self):
return not self.public
@property
def is_empty(self):
return self.count <= 0
@property
def human_updated_timestamp(self):
return timestamp_to_ago_string(self.updated_timestamp)
@property
def human_created_timestamp(self):
return timestamp_to_ago_string(self.created_timestamp)
def validate_attached_coupons(self):
"""This routine makes sure all attached coupon have terms met."""
error_messages = []
if self.coupons:
for coupon in self.coupons:
# test if the shop's cart total qualifies for this coupon.
if coupon.is_not_valid:
error_messages.append("Sorry that coupon is not valid (expired or disabled).")
elif self.shop_totals_in_cents[coupon.shop_uuid_str] < coupon.cart_qualifier:
error_messages.append("Please review coupon terms: shop total not met.")
elif coupon.max_redemptions and coupon.redemptions.count() >= coupon.max_redemptions:
error_messages.append("That coupon was used too many times. Please remove it.")
elif coupon.max_redemptions_per_user and coupon.redemptions.filter_by(user_id=self.user_id).count() >= coupon.max_redemptions_per_user:
error_messages.append("You have already used this coupon. Please remove it.")
return error_messages
def get_all_carts(dbsession):
"""
Return all Cart objects in descending order by
created timestamp (newest first).
"""
return (
dbsession.query(Cart)
.order_by(Cart.created_timestamp.desc())
)
def get_cart_by_id(dbsession, cart_id):
"""Try to get Cart object by id or return None."""
return get_object_by_id(dbsession, cart_id, Cart)

View file

@ -0,0 +1,33 @@
import uuid
from sqlalchemy import Column, BigInteger
from .meta import Base, RBase, UUIDType, foreign_key, now_timestamp
from sqlalchemy.orm import relationship, backref
class CartCoupon(RBase, Base):
"""
Many to many, Carts to Coupons.
A relationship signifies the application of a coupon to a cart.
"""
id = Column(UUIDType, primary_key=True, index=True)
cart_id = Column(UUIDType, foreign_key("Cart", "id"), nullable=False)
coupon_id = Column(UUIDType, foreign_key("Coupon", "id"), nullable=False)
created_timestamp = Column(BigInteger, nullable=False)
cart = relationship(
argument="Cart", backref=backref("cart_coupons", cascade="all, delete-orphan")
)
coupon = relationship(
argument="Coupon", backref=backref("coupon_carts", cascade="all, delete-orphan")
)
def __init__(self, cart=None, coupon=None):
self.id = uuid.uuid1()
self.cart = cart
self.coupon = coupon
self.created_timestamp = now_timestamp()

View file

@ -0,0 +1,218 @@
import uuid
from sqlalchemy import Column, BigInteger, Boolean, Unicode, Enum
from sqlalchemy.ext.associationproxy import association_proxy
from sqlalchemy.orm import relationship
from .meta import (
Base,
RBase,
UUIDType,
now_timestamp,
foreign_key,
get_object_by_id,
COUPON_ACTION_TYPES,
)
from .cart_coupon import CartCoupon
from miscutils import (
datetime_to_timestamp,
timestamp_to_datetime,
dollars_to_cents,
cents_to_dollars,
)
from datetime import datetime
class Coupon(RBase, Base):
"""This class represents a coupon."""
id = Column(UUIDType, primary_key=True, index=True)
shop_id = Column(UUIDType, foreign_key("Shop", "id"), nullable=False)
# a code must be unique for a shop, but not unique acrossed all shops.
code = Column(Unicode(256), nullable=False)
# the coupon description / display name.
description = Column(Unicode(256), nullable=False)
# the type of action that this coupon applies to a cart.
action_type = Column(
Enum(*COUPON_ACTION_TYPES, name="action_type"), nullable=False
)
action_value = Column(BigInteger, nullable=False)
# The amount of times this code may be used.
max_redemptions = Column(BigInteger, nullable=True)
max_redemptions_per_user = Column(BigInteger, nullable=True)
created_timestamp = Column(BigInteger, nullable=False)
expired_timestamp = Column(BigInteger, nullable=True)
# Optional. The minimum cart total needed for this coupon to apply.
cart_qualifier = Column(BigInteger, nullable=True)
# stackable means coupon may be combined or stacked with
# other stackable coupons.
stackable = Column(Boolean, default=False, nullable=False)
disabled = Column(Boolean, default=False)
# lazy='"joined" performs a left join to reduce queries, it's magic.
# set uselist=False creates one-to-one relationship.
shop = relationship(argument="Shop", uselist=False, lazy="joined")
# many to many uses association_proxy.
carts = association_proxy("coupon_carts", "cart", creator=lambda c: CartCoupon(cart=c))
# one to many.
# returns this coupon's redemptions.
# lazy="dynamic" returns a query object instread of an InstrumentedList.
# this is ideal when we want to further filter the results.
redemptions = relationship(argument="CouponRedemption", lazy="dynamic", back_populates="coupon")
def __init__(
self,
shop,
code,
description,
action_type,
action_value,
max_redemptions=None,
max_redemptions_per_user=None,
expiration_date = None,
cart_qualifier = None,
):
self.id = uuid.uuid1()
self.shop = shop
self.created_timestamp = now_timestamp()
self.code = code
self.description = description
self.action_type = action_type
self.action_value = action_value
if self.action_type == "dollar-off":
self.action_value = dollars_to_cents(action_value)
if max_redemptions:
self.max_redemptions = max_redemptions
if max_redemptions_per_user:
self.max_redemptions_per_user = max_redemptions_per_user
self.cart_qualifier = None
if cart_qualifier:
self.cart_qualifier = dollars_to_cents(cart_qualifier)
self.expired_timestamp = None
if expiration_date:
# convert date string into datetime.
expiration_datetime = datetime.strptime(
expiration_date,
'%Y-%m-%d'
)
# convert datetime into timestamp.
# adding 86399000 to get the end of day.
self.expired_timestamp = datetime_to_timestamp(
expiration_datetime
) + 86399000
@property
def expired_datetime(self):
if self.expired_timestamp:
return timestamp_to_datetime(self.expired_timestamp)
@property
def is_expired(self):
if self.expired_timestamp == None:
return False
return now_timestamp() >= self.expired_timestamp
@property
def is_not_expired(self):
return not self.is_expired
@property
def is_not_valid(self):
return self.disabled or self.is_expired
@property
def is_valid(self):
return not self.is_not_valid
@property
def human_discount(self):
if self.action_type == "percent-off":
return "{}% off".format(self.action_value)
return "${0:.2f} off".format(
cents_to_dollars(self.action_value)
)
@property
def human_max_redemptions(self):
if self.max_redemptions:
return "{}".format(self.max_redemptions)
return "&#8734;"
@property
def human_limit_per_customer(self):
if self.max_redemptions_per_user:
return "Limit {} per customer.".format(self.max_redemptions_per_user)
return ""
@property
def human_valid_thru(self):
if self.expired_timestamp:
x = self.expired_datetime.strftime("%A, %b %d, %Y")
return "Valid thru {}.".format(x)
return ""
@property
def human_minimum_cart(self):
if self.cart_qualifier:
return "any ${0:.0f} cart.".format(cents_to_dollars(self.cart_qualifier))
return "any cart."
def compute_discount(self, cents):
"""
Given an integer representing total cents of a transaction,
Return a new integer representing the total cents after applying
This coupon's discount terms.
"""
if self.action_type == "dollar-off":
cents_after_discount = cents - self.action_value
if self.action_type == "percent-off":
cents_after_discount = cents - (cents * self.action_value)
if cents_after_discount < 0:
# the transaction total may never be negative.
cents_after_discount = 0
return cents_after_discount
@property
def shop_uuid_str(self):
return self.id_to_uuid_str(self.shop_id)
def get_coupon_by_id(dbsession, coupon_id):
"""Try to get Coupon object by id or return None."""
return get_object_by_id(dbsession, coupon_id, Coupon)
def get_coupons_by_code(dbsession, code, shop=None):
"""
Given a coupon code and optional shop, return a list of Coupon objects.
Remember a coupon code may not be unique across all shops.
"""
if shop is not None:
return shop.coupons.filter(
Coupon.code == code
).all()
return dbsession.query(Coupon).filter(Coupon.code == code).all()

View file

@ -0,0 +1,34 @@
import uuid
from sqlalchemy import Column, BigInteger
from .meta import Base, RBase, UUIDType, now_timestamp, foreign_key
from sqlalchemy.orm import relationship
class CouponRedemption(RBase, Base):
"""This class represents a redemption of a Coupon."""
# The records in this table should be treated as immutable.
# Once created we should not modify records in this class.
id = Column(UUIDType, primary_key=True, index=True, unique=True)
coupon_id = Column(UUIDType, foreign_key("Coupon", "id"), nullable=False)
invoice_id = Column(UUIDType, foreign_key("Invoice", "id"), nullable=False)
shop_id = Column(UUIDType, foreign_key("Shop", "id"), nullable=False)
user_id = Column(UUIDType, foreign_key("User", "id"), nullable=False)
created_timestamp = Column(BigInteger, nullable=False)
coupon = relationship(argument="Coupon", lazy="joined", uselist=False)
invoice = relationship(argument="Invoice", lazy="joined", uselist=False)
shop = relationship(argument="Shop", lazy="joined", uselist=False)
user = relationship(argument="User", lazy="joined", uselist=False)
def __init__(self, coupon, invoice, shop, user):
self.id = uuid.uuid1()
self.coupon = coupon
self.invoice = invoice
self.shop = shop
self.user = user
self.created_timestamp = now_timestamp()

View file

@ -0,0 +1,187 @@
import uuid
from sqlalchemy import Column, BigInteger, Boolean, Integer
from .meta import Base, RBase, UUIDType, foreign_key, now_timestamp
from sqlalchemy.orm import relationship, backref
from .coupon_redemption import CouponRedemption
class InvoiceLineItem(RBase, Base):
"""
An invoice may have zero or many line items
We should do our best to treat these line items as immutable.
This will let us rely on them as a proof of transactional record.
"""
id = Column(UUIDType, primary_key=True, index=True)
# a way to group multiple transactions into a single reference.
invoice_id = Column(UUIDType, foreign_key("Invoice", "id"), nullable=False)
# the product of this transaction.
product_id = Column(UUIDType, foreign_key("Product", "id"), nullable=False)
# the price object which was active during this transaction.
price_id = Column(UUIDType, foreign_key("Price", "id"), nullable=False)
# the shop who owns this product.
shop_id = Column(UUIDType, foreign_key("Shop", "id"), nullable=False)
# optional, used to keep track of market place sales.
market_id = Column(UUIDType, foreign_key("Market", "id"), nullable=True)
# the amount of this product which was purchased (or credited).
quantity = Column(Integer, nullable=False, default=1)
# a credit basically represents a refund, we don't change the original
# invoice item, but instead append a new line item flagged as a credit.
credit = Column(Boolean, default=False)
# the timestamp when this line_item was created.
created_timestamp = Column(BigInteger, nullable=False)
# one to one.
invoice = relationship(argument="Invoice", uselist=False, lazy="joined")
# one to one.
product = relationship(argument="Product", uselist=False, lazy="joined")
# one to one.
price = relationship(argument="Price", uselist=False, lazy="joined")
# one to one.
shop = relationship(argument="Shop", uselist=False, lazy="joined")
# one to one.
market = relationship(argument="Market", uselist=False, lazy="joined")
def __init__(
self,
invoice,
product,
price=None,
shop=None,
market=None,
quantity=1,
credit=False
):
if price is None:
price = product.current_price
if shop is None:
shop = product.shop
self.id = uuid.uuid1()
self.invoice = invoice
self.product = product
self.price = price
self.shop = shop
self.market = market
self.quantity = quantity
self.credit = credit
self.created_timestamp = now_timestamp()
class Invoice(RBase, Base):
"""
A user may have zero or many invoices
which relate to a purchase transaction.
"""
id = Column(UUIDType, primary_key=True, index=True)
# the user making the purchase.
user_id = Column(UUIDType, foreign_key("User", "id"), nullable=False)
# the shop for this invoice, null if a marketplace sale.
shop_id = Column(UUIDType, foreign_key("Shop", "id"), nullable=True)
# optional, opt-in, a marketplace may sell products on behalf of a shop.
# the marketplace is responsible for charging the customer
# and paying out the shop owner, while taking a 15-35% cut.
market_id = Column(UUIDType, foreign_key("Market", "id"), nullable=True)
# the timestamp when the invoice was created.
created_timestamp = Column(BigInteger, nullable=False)
# one to one.
user = relationship(argument="User", uselist=False, lazy="joined")
# one to one.
shop = relationship(argument="Shop", uselist=False, lazy="joined")
# one to one.
market = relationship(argument="Market", uselist=False, lazy="joined")
# one to many.
# returns all the InvoiceLineItems.
# lazy="dynamic" returns a query object instead of an InstrumentedList.
# this is ideal when we want to further filter the results.
line_items = relationship(argument="InvoiceLineItem", lazy="dynamic", back_populates="invoice")
# one to many.
# returns all the CouponRedemptions for this Invoice.
# lazy="dynamic" returns a query object instead of an InstrumentedList.
# this is ideal when we want to further filter the results.
coupon_redemptions = relationship(argument="CouponRedemption", lazy="dynamic", back_populates="invoice")
# TODO: We need a method that returns InvoiceLineItem grouped by shop.
# we will need this when we start supporting market places.
#line_items_grouped_by_shop
# TODO: We need a way to relate none or many CouponRedemption
# to an Invoice. This will allow us to keep track of where the
# coupons are utilized and how they affect the invoice total.
# When we have a concept of market places, we will need to make
# sure a shop coupon for say $5 will only affect products in the
# invoice related to that shop. Spending a $5 coupon from shop X
# on an market invoice total of $6 but only has a $2 product
# from shop X would bring the invoice total to $4.
# Shop coupon codes should only affect the "sub total" of that
# shop on the Invoice.
def __init__(self, user):
self.id = uuid.uuid1()
self.user = user
self.created_timestamp = now_timestamp()
def new_line_item(self, product, quantity=1, market=None):
"""
given a a product, and optional arguments attach a line item to this invoice.
"""
self.line_items.append(
InvoiceLineItem(
invoice=self,
product=product,
quantity=quantity,
market=market,
)
)
def credit_line_item(self, line_item, quantity=None):
"""
given a line_item, attach a credit/refund line item to this invoice.
"""
if quantity is None:
# By default we use the quantity from the `line_item`.
# Lets say the given `line_item` has `quantity` of 3 and you want
# to credit or refund the user only 1 of the 3. To do so, you
# would pass `quantity=1` to `credit_line_item`.
quantity = line_item.quantity
self.line_items.append(
InvoiceLineItem(
invoice=self,
product=line_item.product,
price=line_item.price,
shop=line_item.shop,
market=line_item.market,
quantity=quantity,
credit=True,
)
)
def new_coupon_redemption(self, coupon):
"""
given a Coupon, create a coupon redemption for this invoice.
"""
# TODO: currently this routine is naive to "market" invoices, an Invoice
# which has InvoiceLineItems for Products derived from more than one Shop.
if coupon.shop == self.shop:
self.coupon_redemptions.append(
CouponRedemption(
coupon=coupon,
invoice=self,
shop=coupon.shop,
user=self.user,
)
)

View file

@ -0,0 +1,11 @@
import uuid
from sqlalchemy import Column, Unicode
from .meta import Base, RBase, UUIDType
class Market(RBase, Base):
"""This class represents a Market place."""
id = Column(UUIDType, primary_key=True, index=True)
name = Column(Unicode(64), unique=True, nullable=False)

View file

@ -0,0 +1,194 @@
from time import time
from sqlalchemy import ForeignKey
from sqlalchemy.ext.declarative import declarative_base, declared_attr
from sqlalchemy.schema import MetaData
from sqlalchemy.orm.session import object_session
import uuid
from sqlalchemy_utils import UUIDType as TempUUIDType
UUIDType = TempUUIDType(binary=False)
CLASS_TO_TABLE = {
"Shop": "mps_shop",
"Product": "mps_product",
"Coupon": "mps_coupon",
"CouponRedemption": "mps_coupon_redemption",
"User": "mps_user",
"UserShop": "mps_user_shop",
"UserProduct": "mps_user_product",
"Price": "mps_price",
"License": "mps_license",
"Cart": "mps_cart",
"CartCoupon": "mps_cart_coupon",
"Invoice": "mps_invoice",
"InvoiceLineItem": "mps_invoice_line_item",
"ShopSearchRequest": "mps_shop_search_request",
"StripeUserShop": "mps_stripe_user_shop",
"Market": "mps_market",
}
# Recommended naming convention used by Alembic, as various different database
# providers will autogenerate vastly different names making migrations more
# difficult. See: http://alembic.zzzcomputing.com/en/latest/naming.html
NAMING_CONVENTION = {
"ix": "ix_%(column_0_label)s",
"uq": "uq_%(table_name)s_%(column_0_name)s",
"ck": "ck_%(table_name)s_%(constraint_name)s",
"fk": "fk_%(table_name)s_%(column_0_name)s_%(referred_table_name)s",
"pk": "pk_%(table_name)s",
}
metadata = MetaData(naming_convention=NAMING_CONVENTION)
# Base = declarative_base(metadata=metadata)
Base = declarative_base()
COUPON_ACTION_TYPES = {
"dollar-off",
"percent-off",
}
VISIBILITY_INT_TO_HUMAN ={
0: "private",
1: "public",
2: "unlisted",
}
def short_id_to_bytes(short_id):
"""Accept a short_id (sanitized url safe base64 string) and return a byte string.
>>> short_id_to_bytes('dbHeSEFLEeeuz5xONpxxWA')
'u\xb1\xdeHAK\x11\xe7\xae\xcf\x9cN6\x9cqX'
"""
return (short_id + "==").replace("_", "/").replace("-", "+").decode("base64")
def id_to_uuid(the_id):
"""
Accept an id string, return a UUID object.
>>> id_to_uuid('dbHeSEFLEeeuz5xONpxxWA')
UUID('75b1de48-414b-11e7-aecf-9c4e369c7158')
>>> id_to_uuid('75b1de48414b11e7aecf9c4e369c7158')
UUID('75b1de48-414b-11e7-aecf-9c4e369c7158')
>>> id_to_uuid('75b1de48-414b-11e7-aecf-9c4e369c7158')
UUID('75b1de48-414b-11e7-aecf-9c4e369c7158')
"""
if isinstance(the_id, uuid.UUID):
# if the_id is already a UUID object, return it.
return the_id
try:
# assume the_id is already a hex uuid string.
return uuid.UUID(hex=the_id)
except ValueError:
try:
# assume the_id is a base64 "short_id" string.
return uuid.UUID(bytes=short_id_to_bytes(the_id))
except ValueError:
pass
return None
def get_object_by_id(dbsession, object_id, cls):
"""Try to get object from database by id or return None"""
object_uuid = id_to_uuid(object_id)
if object_uuid:
return dbsession.query(cls).filter(cls.id == object_uuid).one_or_none()
def get_objects_by_ids(dbsession, object_ids, cls):
"""Try to get a list of objects from database by ids or return None"""
# TODO: if the list of object_ids is _very_ long consider chunking up
# the sequence and querying the database more than once before
# aggregating and returning the results.
# make object_ids unique by converting to a set and the back to a list.
object_ids = list(set(object_ids))
# sort the object_ids list.
object_ids.sort()
# transform uuid with dashes to uuid without dashes.
object_uuids = [id_to_uuid(object_id) for object_id in object_ids]
if object_uuids:
# reference: https://stackoverflow.com/q/444475
return dbsession.query(cls).filter(cls.id.in_(object_uuids)).all()
def bucket_objects_by_id(objects):
"""
Given a list of model Objects, return a dict where the key
is a str representation of the id/uuid and the value is the Object.
"""
bucket = {}
if objects:
for obj in objects:
bucket[obj.uuid_str] = obj
return bucket
def now_timestamp():
return int(time() * 1000)
def foreign_key(class_name, column_name):
return ForeignKey("{}.{}".format(CLASS_TO_TABLE[class_name], column_name))
class RBase(object):
"""Mixin for models."""
def __eq__(self, other):
"""Determine if equal by id."""
if other:
return self.id == other.id
return False
def __ne__(self, other):
"""Determine if not equal by user id."""
return not self.__eq__(other)
def __hash__(self):
return hash(self.id)
@declared_attr
def __tablename__(cls):
return CLASS_TO_TABLE[cls.__name__]
@property
def dbsession(self):
return object_session(self)
@property
def uuid(self):
return id_to_uuid(self.id)
@property
def uuid_str(self):
return self.uuid.__str__()
def id_to_uuid(self, _id):
"""Method for converting an id to UUID object."""
return id_to_uuid(_id)
def id_to_uuid_str(self, _id):
"""Method for converting an id to UUID str."""
return self.id_to_uuid(_id).__str__()

View file

@ -0,0 +1,34 @@
import uuid
from sqlalchemy import Column, BigInteger
from .meta import Base, RBase, UUIDType, foreign_key, now_timestamp
from sqlalchemy.orm import relationship
from miscutils import (
dollars_to_cents,
cents_to_dollars,
)
class Price(RBase, Base):
"""This class tracks a product's price history."""
id = Column(UUIDType, primary_key=True, index=True)
product_id = Column(UUIDType, foreign_key("Product", "id"), nullable=False)
price_in_cents = Column(BigInteger, nullable=False)
created_timestamp = Column(BigInteger, nullable=False)
product = relationship(argument="Product", uselist=False, lazy="joined")
def __init__(self, product, price_in_cents):
self.id = uuid.uuid1()
self.product = product
self.price_in_cents = price_in_cents
self.created_timestamp = now_timestamp()
@property
def price(self):
return cents_to_dollars(self.price_in_cents)

View file

@ -0,0 +1,492 @@
import uuid
import json
import mimetypes
from sqlalchemy import Column, Integer, BigInteger, Boolean, Unicode, UnicodeText
from sqlalchemy.orm import relationship
from .meta import (
Base,
RBase,
UUIDType,
now_timestamp,
foreign_key,
get_object_by_id,
get_objects_by_ids,
VISIBILITY_INT_TO_HUMAN,
)
from sqlalchemy.ext.associationproxy import association_proxy
from .user_product import UserProduct
from .price import Price
from make_post_sell.lib.render import markdown_to_html
from slugify import slugify
from miscutils import (
dollars_to_cents,
cents_to_dollars,
)
try:
unicode("")
except:
from six import u as unicode
DEFAULT_FILE_METADATA = unicode(
"""
{
"originals":{},
"extensions":{}
}
"""
)
DEFAULT_BUNDLE_METADATA = unicode(
"""
{
"bundle-ids":[],
"product-ids":[]
}
"""
)
class Product(RBase, Base):
"""This class represents a product."""
error_message = None
id = Column(UUIDType, primary_key=True, index=True)
shop_id = Column(UUIDType, foreign_key("Shop", "id"), nullable=False)
title = Column(Unicode(256), nullable=False)
# TODO: rename this column to description_raw.
description = Column(UnicodeText, nullable=False)
description_html = Column(UnicodeText, nullable=False)
json_file_metadata = Column(UnicodeText, default=DEFAULT_FILE_METADATA)
# a denormalized json field to relate products to bundles, and vice versa.
json_bundle_metadata = Column(
UnicodeText, default=DEFAULT_BUNDLE_METADATA, nullable=False
)
# 0=private, 1=public, 2=unlisted
visibility = Column(Integer, nullable=False, default=1)
price_in_cents = Column(BigInteger, nullable=False, default=0)
created_timestamp = Column(BigInteger, nullable=False)
updated_timestamp = Column(BigInteger, nullable=False)
is_bundle = Column(Boolean, default=False, nullable=False)
is_sellable = Column(Boolean, default=False, nullable=False)
# lazy="joined" performs a left join to reduce queries, it's magic.
# set uselist=False creates one-to-one relationship.
shop = relationship(argument="Shop", uselist=False, lazy="joined")
# lazy="joined" performs a left join to reduce queries, it's magic.
price_history = relationship(
argument="Price",
lazy="dynamic",
order_by="desc(Price.created_timestamp)",
back_populates="product",
)
# many to many uses association_proxy.
users = association_proxy(
"product_users", "user", creator=lambda u: UserProduct(user=u)
)
"""
_users = relationship(UserProduct, order_by=[UserProduct.created_timestamp.desc()])
users = association_proxy(
"_users", "user", creator=lambda u: UserProduct(user=u)
)
"""
file_keys = [
"product",
"preview",
"thumbnail1",
"thumbnail2",
"thumbnail3",
"thumbnail4",
]
file_public_keys = [
"preview",
"thumbnail1",
"thumbnail2",
"thumbnail3",
"thumbnail4",
]
file_thumbnail_keys = ["thumbnail1", "thumbnail2", "thumbnail3", "thumbnail4"]
def __init__(self, title, description):
self.id = uuid.uuid1()
self.title = title
self.set_description(description)
self.created_timestamp = now_timestamp()
self.updated_timestamp = now_timestamp()
def get_userproduct_for_user(self, user):
"""Given a User object, return the UserProduct object or None."""
for up in self.product_users:
if up.user == user:
return up
def unlock_for_user(self, user):
"""
Unlock this product for user so they may download.
Adds User object to UserProduct.
"""
up = self.get_userproduct_for_user(user)
if up is None:
up = UserProduct(user=user, product=self)
self.product_users.append(up)
return up
def set_description(self, description):
self.updated_timestamp = now_timestamp()
# TODO: rename this column to description_raw.
self.description = description
self.description_html = markdown_to_html(description, self.shop)
def set_price(self, price):
self.updated_timestamp = now_timestamp()
try:
self.price_in_cents = dollars_to_cents(price)
except ValueError:
self.error_message = "Please use only numbers for price."
return None
# create and return a price history object.
return Price(self, self.price_in_cents)
@property
def price(self):
return cents_to_dollars(self.price_in_cents)
@property
def current_price(self):
"""return the most current Price object for this Product from history."""
return self.price_history.limit(1).one()
@property
def is_ready(self):
if self.is_bundle:
return True if len(self.bundle_metadata["product-ids"]) >= 1 else False
return True if self.extensions.get("product") else False
@property
def is_not_ready(self):
return not self.is_ready
@property
def is_not_sellable(self):
return not self.is_sellable
@property
def is_public(self):
return True if self.visibility == 1 else False
@property
def is_unlisted(self):
return True if self.visibility == 2 else False
@property
def is_private(self):
return True if self.visibility == 0 else False
@property
def human_visibility(self):
return VISIBILITY_INT_TO_HUMAN[self.visibility]
@property
def slug(self):
"""return slug from title"""
return slugify(self.title)
@property
def s3_path(self):
"""return the S3 path shop.id/product.id"""
return "{}/{}".format(self.shop.id, self.id)
@property
def s3_key(self):
"""return the product S3 key"""
return "{}/product".format(self.s3_path)
@property
def s3_key_preview(self):
"""return the product preview S3 key"""
return "{}/preview".format(self.s3_path)
def s3_key_thumbnail(self, file_key):
"""return the thumbail S3 key"""
return "{}/{}".format(self.s3_path, file_key)
@property
def s3_key_thumbnails(self):
thumbnails = {}
for thumbnail_key in self.file_thumbnail_keys:
if thumbnail_key in self.extensions:
thumbnails[thumbnail_key] = self.s3_key_thumbnail(thumbnail_key)
return thumbnails
@property
def file_metadata(self):
"""memoized dictionary of file_metadata"""
if self.json_file_metadata:
if hasattr(self, "_file_metadata") == False:
self._file_metadata = json.loads(self.json_file_metadata)
return self._file_metadata
return {}
@property
def extensions(self):
return self.file_metadata.get("extensions", {})
@property
def originals(self):
return self.file_metadata.get("originals", {})
def set_file_metadata(self, file_key, extension, original_filename):
self.updated_timestamp = now_timestamp()
tmp = self.file_metadata
tmp["extensions"][file_key] = extension
tmp["originals"][file_key] = original_filename
self._file_metadata = tmp
self.json_file_metadata = json.dumps(tmp)
def store_file_metadata(self, s3_key):
# example s3_key: "uuid/uuid/product.MyTaco.Beans.pdf"
# example parts: ["product", "MyTaco", "Beans", "pdf"]
parts = s3_key.split("/")[-1].split(".")
# product
file_key = parts[0]
# pdf
file_ext = parts[-1]
# MyTaco.Beans.pdf
original_filename = ".".join(parts[1:])
self.set_file_metadata(file_key, file_ext, original_filename)
return file_key
def get_content_type(self, file_key):
"""return the content type string for the S3 object, or None."""
extension = self.extensions.get(file_key)
if extension:
return mimetypes.types_map.get("." + extension)
def get_content_disposition(self, file_key):
"""
Return the content disposition string for the S3 object, or None.
The content disposition string determines whether this is:
* an inline image (thumbnails)
* an attachment (file download)
"""
file_prefix = "" if file_key == "product" else "{}-".format(file_key)
extension = self.extensions.get(file_key, "")
filename = "{}{}.{}".format(file_prefix, self.slug, extension)
content_disposition = "attachment; filename={}".format(filename)
if file_key in self.file_thumbnail_keys:
content_disposition = "inline; filename={}".format(filename)
elif self.is_not_sellable:
content_disposition = "inline; filename={}".format(filename)
return content_disposition
@property
def content_disposition(self):
"""return the content_disposition for the product."""
return self.get_content_disposition("product")
@property
def preview_content_disposition(self):
"""return the content_disposition for the preview."""
return self.get_content_disposition("preview")
def absolute_url(self, request, slug=True):
"""
The absolute URI to the product page.
For example:
https://makepostsell.com/p/product-uuid
"""
if slug:
return "{}/p/{}/{}".format(request.app_url, self.id, self.slug)
return "{}/p/{}".format(request.app_url, self.id)
def absolute_edit_url(self, request):
"""
The absolute URI to the product edit page.
For example:
https://makepostsell.com/p/product-uuid/edit
"""
return "{}/edit".format(self.absolute_url(request, slug=False))
@property
def shop_uuid_str(self):
return self.id_to_uuid_str(self.shop_id)
@property
def bundle_metadata(self):
"""memoized dictionary of bundle_metadata"""
if hasattr(self, "_bundle_metadata") == False:
self._bundle_metadata = json.loads(self.json_bundle_metadata)
return self._bundle_metadata
def add_product_to_bundle(self, product):
if self.is_bundle == False:
self.error_message = "This product is not a bundle."
if self.shop_id != product.shop_id:
self.error_message = "The product and bundle must share the same shop."
if self.error_message:
return False
# denormalized bundle relationship metadata for bundle.
self.bundle_metadata["product-ids"].append(str(product.id))
self.json_bundle_metadata = json.dumps(self.bundle_metadata)
# denormalized bundle relationship metadata for product in bundle.
product.bundle_metadata["bundle-ids"].append(str(self.id))
product.json_bundle_metadata = json.dumps(product.bundle_metadata)
return True
def remove_product_from_bundle(self, product):
if self.is_bundle == False:
self.error_message = "This product is not a bundle."
if self.error_message:
return False
# denormalized bundle relationship metadata for bundle.
self.bundle_metadata["product-ids"].remove(str(product.id))
self.json_bundle_metadata = json.dumps(self.bundle_metadata)
# denormalized bundle relationship metadata for product in bundle.
product.bundle_metadata["bundle-ids"].remove(str(self.id))
product.json_bundle_metadata = json.dumps(product.bundle_metadata)
return True
@property
def products_in_this_bundle(self):
"""if this product is a bundle return a list of Product objects in this bundle."""
if self.is_bundle == False:
return []
if hasattr(self, "_products_in_bundle") == False:
self._products_in_this_bundle = (
get_products_by_ids(self.dbsession, self.bundle_metadata["product-ids"])
or []
)
return self._products_in_this_bundle
@property
def bundles_with_this_product(self):
"""
Return a list of zero or many bundle Product objects.
All the bundles out there which if purchased unlock this product.
"""
if self.is_bundle:
return []
if hasattr(self, "_bundles_with_this_product") == False:
self._bundles_with_this_product = (
get_products_by_ids(self.dbsession, self.bundle_metadata["bundle-ids"])
or []
)
return self._bundles_with_this_product
def get_all_products(dbsession):
"""
Return all Product objects descending order by created timestamp (newest first).
"""
return dbsession.query(Product).order_by(Product.created_timestamp.desc())
def get_all_products_by_visibility(dbsession, visibility=1):
"""
Return all Product objects with the given visibility integer in descending order by created timestamp (newest first).
"""
# 0=private, 1=public, 2=unlisted
return (
dbsession.query(Product)
.filter(Product.visibility == visibility)
.order_by(Product.created_timestamp.desc())
)
def get_product_by_id(dbsession, product_id):
"""Try to get Product object by id or return None."""
return get_object_by_id(dbsession, product_id, Product)
def get_products_by_ids(dbsession, product_ids):
"""Try to get Product objects by ids or return None."""
return get_objects_by_ids(dbsession, product_ids, Product)
def get_all_products_from_a_shop(shop):
return shop.products.order_by(Product.updated_timestamp.desc())
def get_products_from_a_shop(shop, visibility=1):
"""
given a Shop object, return all products,
ordered by descending updated_timestamp.
"""
return shop.products.filter(Product.visibility == visibility).order_by(
Product.updated_timestamp.desc()
)
def get_products_by_keywords(dbsession, keywords, shop=None):
scores = {}
hits = {}
if shop:
product_query = shop.products
else:
product_query = dbsession.query(Product)
for keyword in keywords:
keyword_filter = Product.title.ilike("%{}%".format(keyword))
products = (
product_query.filter(keyword_filter).filter(Product.visibility == 1).all()
)
for product in products:
if product.id not in scores:
scores[product.id] = 0
scores[product.id] += 1
if product.id not in hits:
hits[product.id] = product
# sort the scores dictionary by value (score),
# but get the key (object id),
# reversed (highest score first)
score_sorted_ids = sorted(scores, key=scores.get, reverse=True)
# return a list of objects in sorted order by score.
return [hits[_id] for _id in score_sorted_ids]

View file

@ -0,0 +1,315 @@
import uuid
from sqlalchemy import Column, BigInteger, Boolean, Unicode, UnicodeText, func
from .meta import Base, RBase, UUIDType, now_timestamp, foreign_key, get_object_by_id, get_objects_by_ids
from sqlalchemy.ext.associationproxy import association_proxy
from sqlalchemy.orm import relationship
from .user_shop import UserShop
from .stripe_user_shop import StripeUserShop
from slugify import slugify
from miscutils.hex_color import color_scale
from make_post_sell.lib.render import markdown_to_html
try:
unicode("")
except:
from six import u as unicode
class Shop(RBase, Base):
"""This class represents a shop."""
id = Column(UUIDType, primary_key=True, index=True)
name = Column(Unicode(64), unique=True, nullable=False)
domain_name = Column(Unicode(256), unique=True)
# todo: rename to description_raw.
description = Column(UnicodeText, nullable=True)
description_html = Column(UnicodeText, nullable=True)
terms_of_service_raw = Column(UnicodeText, nullable=True)
terms_of_service_html = Column(UnicodeText, nullable=True)
privacy_policy_raw = Column(UnicodeText, nullable=True)
privacy_policy_html = Column(UnicodeText, nullable=True)
billing_address = Column(UnicodeText, nullable=False)
phone_number = Column(Unicode(32), nullable=False)
# The text at the very top of the shop's pages.
ribbon_text = Column(UnicodeText, nullable=True, default="")
ribbon_text_color = Column(Unicode(32), nullable=True, default="")
ribbon_color_1 = Column(Unicode(32), nullable=True, default="")
ribbon_color_2 = Column(Unicode(32), nullable=True, default="")
# example: "UA-XX31XX60-3".
google_analytics_id = Column(Unicode(32), nullable=True, default="")
# example: "cus_12345678AbCdEF" but may be null.
# this is how the shop pays for make_post_sell.
stripe_id = Column(Unicode(32), unique=True, nullable=True)
# these keys allow make_post_sell to charge
# cards on behalf of the shop's stripe account.
stripe_secret_api_key = Column(Unicode(64), nullable=True)
stripe_public_api_key = Column(Unicode(64), nullable=True)
created_timestamp = Column(BigInteger, nullable=False)
disabled = Column(Boolean, default=False)
# many to many uses association_proxy.
users = association_proxy("shop_users", "user", creator=lambda u: UserShop(user=u))
# many to many uses association_proxy.
stripe_users = association_proxy(
"stripe_users", "user", creator=lambda u: StripeUserShop(user=u)
)
# lazy='dynamic' returns a query object instead of collection.
# Reference: http://docs.sqlalchemy.org/en/latest/orm/collections.html
products = relationship(argument="Product", lazy="dynamic", back_populates="shop")
# lazy='dynamic' returns a query object instead of collection.
# Reference: http://docs.sqlalchemy.org/en/latest/orm/collections.html
coupons = relationship(
argument="Coupon",
back_populates="shop",
lazy="dynamic",
order_by="desc(Coupon.created_timestamp)",
)
# lazy='dynamic' returns a query object instead of collection.
# Reference: http://docs.sqlalchemy.org/en/latest/orm/collections.html
search_requests = relationship(
argument="ShopSearchRequest", lazy="dynamic", back_populates="shop"
)
def __init__(self, name, phone_number, billing_address, description):
self.id = uuid.uuid1()
self.name = name
self.phone_number = phone_number
self.billing_address = billing_address
self.description = description
self.created_timestamp = now_timestamp()
def get_usershop_for_user(self, user):
"""Given a User object, return the UserShop object or None."""
if self.dbsession is not None:
return self.dbsession.query(UserShop).filter(
UserShop.shop == self,
UserShop.user == user,
).one_or_none()
def add_user_to_shop(self, user, default=None):
"""Add User object to UserShop."""
us = self.get_usershop_for_user(user)
if us is None:
us = UserShop(user=user, shop=self)
self.shop_users.append(us)
if default != None:
us.default = default
return us
@property
def slug(self):
"""return slug from name"""
return slugify(self.name)
@property
def is_ready(self):
if self.stripe_secret_api_key and self.stripe_public_api_key:
return True
return False
@property
def is_not_ready(self):
return not self.is_ready
@property
def stripe(self):
"""Return a stripe object using this shop's secret api key."""
if hasattr(self, "_stripe") == False:
if self.stripe_secret_api_key:
import stripe
stripe.api_key = self.stripe_secret_api_key
self._stripe = stripe
else:
self._stripe = None
return self._stripe
def stripe_customer_id(self, user):
"""Return the stripe_customer id from our database for this user, or None."""
stripe_user_shop = self.dbsession.query(StripeUserShop).filter(
StripeUserShop.user == user,
StripeUserShop.shop == self,
).one_or_none()
if stripe_user_shop:
return stripe_user_shop.cus_id
def stripe_customer(self, user, create=False):
"""
Return the stripe customer object for this shop for a given user.
If missing, create one and return, if the `create` parameter is True
else return None.
"""
stripe_customer_id = self.stripe_customer_id(user)
if stripe_customer_id is None:
if create == False:
return None
# create a new stripe customer.
customer = self.stripe.Customer.create(email=user.email)
# create a new many-to-many user to shop stripe customer.
stripe_user_shop = StripeUserShop(user, self)
stripe_user_shop.cus_id = customer.id
stripe_customer_id = customer.id
# flush relationship to database.
self.dbsession.add(stripe_user_shop)
self.dbsession.flush()
return self.stripe.Customer.retrieve(stripe_customer_id)
def list_stripe_charges(self, stripe_customer=None):
"""
Return a list of all charges for this shop.
Optionally pass a stripe Customer object to filter.
"""
if stripe_customer is not None:
return self.stripe.Charge.list(customer=stripe_customer)
return self.stripe.Charge.list()
@property
def theme_base_color(self):
# return the user defined base for shop or default.
return self.ribbon_color_1 or "#5871ad"
@property
def theme_link_color(self):
return color_scale(self.theme_base_color, .75)
def absolute_url(self, request, slug=True):
"""
The absolute URI to the shop's page.
For example:
https://makepostsell.com/s/<shop-uuid>
"""
if slug:
return "{}/s/{}/{}".format(request.app_url, self.id, self.slug)
return "{}/s/{}".format(request.app_url, self.id)
def absolute_settings_url(self, request):
"""
The absolute URI to the shop's settings page.
For example:
https://makepostsell.com/s/<shop-uuid>/edit
"""
return "{}/settings".format(self.absolute_url(request, slug=False))
def absolute_about_url(self, request):
"""
The absolute URI to the shop's about page.
For example:
https://makepostsell.com/s/<shop-uuid>/about
"""
return "{}/about".format(self.absolute_url(request))
def absolute_terms_url(self, request):
return "{}/terms".format(self.absolute_url(request, slug=False))
def absolute_privacy_policy_url(self, request):
return "{}/privacy-policy".format(self.absolute_url(request, slug=False))
def set_description(self, new_description):
self.description = new_description
self.description_html = markdown_to_html(self.description)
@property
def privacy_policy(self):
return self.privacy_policy_html
@privacy_policy.setter
def privacy_policy(self, new_privacy_policy_raw):
self.privacy_policy_raw = new_privacy_policy_raw
self.privacy_policy_html = markdown_to_html(self.privacy_policy_raw, self)
@property
def terms_of_service(self):
return self.terms_of_service_html
@terms_of_service.setter
def terms_of_service(self, new_terms_of_service_raw):
self.terms_of_service_raw = new_terms_of_service_raw
self.terms_of_service_html = markdown_to_html(self.terms_of_service_raw, self)
def is_shop_name_available(dbsession, name):
return not _shop_by_name_query(dbsession, unicode(name)).count()
def is_shop_name_valid(name):
"""Test if shop name meets our criteria for validity."""
# Choices here are subject to change.
# allow dashes.
name = name.replace("-", "")
# allow period.
name = name.replace(".", "")
# allow spaces.
name = name.replace(" ", "")
# allow ' single quote.
name = name.replace("'", "")
return name.isalnum()
def _shop_by_name_query(dbsession, name):
"""query Shop by case insensitive name."""
return dbsession.query(Shop).filter(func.lower(Shop.name) == unicode(name.lower()))
def _shop_by_domain_name_query(dbsession, domain_name):
"""query Shop by case insensitive domain name."""
return dbsession.query(Shop).filter(
func.lower(Shop.domain_name) == unicode(domain_name.lower())
)
def get_shop_by_name(dbsession, name):
"""Try to get Shop object by name or return None"""
if name:
return _shop_by_name_query(dbsession, name).one_or_none()
def get_shop_by_domain_name(dbsession, domain_name):
"""Try to get Shop object by name or return None"""
if domain_name:
return _shop_by_domain_name_query(dbsession, domain_name).one_or_none()
def get_shop_by_id(dbsession, shop_id):
"""Try to get Shop object by id or return None."""
return get_object_by_id(dbsession, shop_id, Shop)
def get_shops_by_ids(dbsession, shop_ids):
"""Try to get Shop objects by ids or return None."""
return get_objects_by_ids(dbsession, shop_ids, Shop)

View file

@ -0,0 +1,35 @@
import uuid
from sqlalchemy import Column, BigInteger, Unicode
from .meta import Base, RBase, UUIDType, now_timestamp, foreign_key, get_object_by_id
from sqlalchemy.orm import relationship
class ShopSearchRequest(RBase, Base):
"""This class represents a shop search request result and hits count."""
id = Column(UUIDType, primary_key=True, index=True)
shop_id = Column(UUIDType, foreign_key("Shop", "id"))
user_id = Column(UUIDType, foreign_key("User", "id"))
keywords = Column(Unicode(128), nullable=False)
hit_count = Column(BigInteger, nullable=False)
created_timestamp = Column(BigInteger, nullable=False)
user = relationship(argument="User", lazy="joined", uselist=False)
shop = relationship(argument="Shop", lazy="joined", uselist=False)
def __init__(self, keywords, hit_count, shop=None, user=None):
self.id = uuid.uuid1()
# truncate to 128 chars to fit in VarChar.
self.keywords = keywords[:128]
self.hit_count = hit_count
self.shop = shop
self.user = user
self.created_timestamp = now_timestamp()
def get_shop_search_request_by_id(dbsession, _id):
"""Try to get ShopSearchRequest object by id or return None."""
return get_object_by_id(dbsession, _id, ShopSearchRequest)

View file

@ -0,0 +1,48 @@
import uuid
from sqlalchemy import Column, Unicode
from .meta import Base, RBase, UUIDType, foreign_key
from sqlalchemy.orm import relationship, backref
class StripeUserShop(RBase, Base):
"""
A user may have zero or many unique stripe_id for each shop it makes purchases on.
"""
id = Column(UUIDType, primary_key=True, index=True)
user_id = Column(UUIDType, foreign_key("User", "id"), nullable=False)
shop_id = Column(UUIDType, foreign_key("Shop", "id"), nullable=False)
# example: "cus_12345678AbCdEF".
cus_id = Column(Unicode(32), nullable=False)
user = relationship(
argument="User", backref=backref("stripe_user", cascade="all, delete-orphan")
)
shop = relationship(
argument="Shop", backref=backref("stripe_shop", cascade="all, delete-orphan")
)
def __init__(self, user=None, shop=None):
self.id = uuid.uuid1()
self.user = user
self.shop = shop
@property
def stripe_customer(self):
"""Return this object's remote API stripe Customer object."""
return self.shop.stripe_customer(self.user)
def get_all_stripe_customer_objects(dbsession):
"""
Return all Stripe customer objects.
created timestamp (newest first).
"""
return [
i.stripe_customer for i in dbsession.query(StripeUserShop).all()
]

View file

@ -0,0 +1,268 @@
import uuid
import bcrypt
from sqlalchemy import Column, BigInteger, Integer, Boolean, Unicode, func
from .meta import Base, RBase, UUIDType, now_timestamp, foreign_key, get_object_by_id
from sqlalchemy.ext.associationproxy import association_proxy
from sqlalchemy.orm import relationship
from .user_shop import UserShop
from .stripe_user_shop import StripeUserShop
from .user_product import UserProduct
from .shop import Shop
from .cart import Cart
from .cart import get_cart_by_id
from miscutils import generate_password
import logging
log = logging.getLogger(__name__)
try:
unicode("")
except:
from six import u as unicode
class User(RBase, Base):
"""This class represents a user account."""
# was this User object authenticated?
authenticated = False
id = Column(UUIDType, primary_key=True, index=True)
active_cart_id = Column(UUIDType, nullable=False)
active_shop_id = Column(UUIDType, default=None)
name = Column(Unicode(64), unique=True, nullable=False)
full_name = Column(Unicode(64))
email = Column(Unicode(64), unique=True, nullable=False)
password = Column(Unicode(64))
password_timestamp = Column(BigInteger)
created_timestamp = Column(BigInteger, nullable=False)
gravatar = Column(Boolean, default=False)
verified = Column(Boolean, default=False)
disabled = Column(Boolean, default=False)
active_cart_count = Column(Integer, default=0, nullable=False)
active_cart_total_in_cents = Column(BigInteger, default=0, nullable=False)
# many to many uses association_proxy.
shops = association_proxy("user_shops", "shop", creator=lambda s: UserShop(shop=s))
# many to many uses association_proxy.
stripe_shops = association_proxy("stripe_shops", "shop", creator=lambda s: StripeUserShop(shop=s))
# many to many uses association_proxy.
# returns all the products that a user has purchased.
"""
_products = relationship(UserProduct, order_by=[UserProduct.created_timestamp.desc()])
products = association_proxy(
"_products", "product", creator=lambda p: UserProduct(product=p)
)
"""
products = association_proxy(
"user_products", "product", creator=lambda p: UserProduct(product=p)
)
# one to many.
# returns this user's carts.
# lazy="dynamic" returns a query object instread of an InstrumentedList.
# this is ideal when we want to further filter the results.
carts = relationship(argument="Cart", lazy="dynamic", back_populates="user")
def __init__(self, email):
self.name = unicode("user-{}".format(generate_password(size=8)))
self.created_timestamp = now_timestamp()
self.id = uuid.uuid1()
self.email = unicode(email)
self.new_password()
# don't password throttle new User objects.
self.password_timestamp = 0
def _generate_raw_password(self, size=32):
"""Return a system generated password"""
return generate_password(size)
def new_password(self):
"""Generate and return raw password, store password hash into DB."""
raw_password = self._generate_raw_password()
# bcrypt works with bytes so we encode to utf-8.
self.password = bcrypt.hashpw(
raw_password.encode("utf-8"),
bcrypt.gensalt()
).decode("utf-8")
self.password_timestamp = now_timestamp()
return raw_password
def check_password(self, password):
"""Accept plain-text raw password, create hash, compare with DB."""
stored_hash = self.password
# bcrypt works with bytes so we encode to utf-8.
new_hash = bcrypt.hashpw(
password.encode("utf-8"),
stored_hash.encode("utf-8"),
).decode("utf-8")
# log.info("new_hash={} stored_hash={}".format(new_hash, stored_hash))
if new_hash == stored_hash:
return True
return False
def throttle_password(self, needed_delta=90000):
"""Return True when throttled, else False"""
if self.password_timestamp_delta <= needed_delta:
return True
return False
@property
def password_timestamp_delta(self):
return now_timestamp() - self.password_timestamp
def set_active_shop(self, shop):
self.active_shop_id = shop.id
self.dbsession.add(self)
self.dbsession.flush()
@property
def active_shop(self):
"""Return the user's active shop, or None."""
if self.active_shop_id:
return self.dbsession.query(Shop).filter(
Shop.id == self.active_shop_id
).one_or_none()
elif self.shops:
# if a user doesn't have an active shop but it has
# at least one shop, set them an active shop.
shop = self.shops[0]
self.set_active_shop(shop)
return shop
def can_edit_product(self, product):
"""Return True or False"""
if product and self in product.shop.users:
return True
return False
def can_not_edit_product(self, product):
"""Return True or False"""
return not self.can_edit_product(product)
def can_download_product(self, product):
"""Return True or False"""
if product:
if self.can_edit_product(product) or self in product.users:
return True
for bundle in product.bundles_with_this_product:
if self in bundle.users:
return True
return False
def can_not_download_product(self, product):
"""Return True or False"""
return not self.can_download_product(product)
def can_edit_shop(self, shop):
if shop and self in shop.users:
return True
return False
def can_not_edit_shop(self, shop):
return not self.can_edit_shop(shop)
@property
def active_cart(self):
if hasattr(self, "_active_cart") == False:
self._active_cart = get_cart_by_id(self.dbsession, self.active_cart_id)
return self._active_cart
def new_active_cart(self):
self._active_cart = Cart(user=self)
self.active_cart_id = self._active_cart.id
return self._active_cart
def owns_cart(self, cart):
return self == cart.user
def does_not_own_cart(self, cart):
return not self.owns_cart(cart)
def _user_by_name_query(dbsession, name):
"""query User by case insensitive name."""
return dbsession.query(User).filter(func.lower(User.name) == unicode(name.lower()))
def is_user_name_available(dbsession, name):
return not _user_by_name_query(dbsession, unicode(name)).count()
def generate_user_name(dbsession, desired_name=""):
size = 8
sep = ""
if desired_name:
size = 3
sep = "-"
name = sep.join([desired_name, generate_password(size)])
while is_user_name_available(dbsession, name) == False:
name = sep.join([desired_name, generate_password(size)])
return name
def is_user_name_valid(name):
"""Test if user name meets our criteria for validity."""
# Choices here are subject to change.
# allow dashes.
name = name.replace("-", "")
# allow period.
name = name.replace(".", "")
# allow spaces.
name = name.replace(" ", "")
return name.isalnum()
def get_user_by_name(dbsession, name):
"""Try to get User object by name or return None"""
if name:
return _user_by_name_query(dbsession, name).one_or_none()
def get_user_by_id(dbsession, user_id):
"""Try to get User object by id or return None."""
return get_object_by_id(dbsession, user_id, User)
def get_user_by_email(dbsession, email):
"""Try to get User object by email or return None"""
if email:
return dbsession.query(User).filter(User.email == unicode(email)).one_or_none()
def get_or_create_user_by_email(dbsession, email):
"""Try to get User object by email or return new User"""
user = get_user_by_email(dbsession, email)
if user is None:
# create new User for unverified user.
user = User(email)
cart = user.new_active_cart()
if not is_user_name_available(dbsession, user.name):
user.name = generate_user_name(dbsession)
# make sure the new user's default active cart exists.
dbsession.add(cart)
dbsession.flush()
return user

View file

@ -0,0 +1,35 @@
import uuid
from sqlalchemy import Column, Boolean, BigInteger
from .meta import Base, RBase, UUIDType, foreign_key, now_timestamp
from sqlalchemy.orm import relationship, backref
class UserProduct(RBase, Base):
"""
Many users to many products.
A relationship signifies the ability to _download_ a product.
"""
id = Column(UUIDType, primary_key=True, index=True)
user_id = Column(UUIDType, foreign_key("User", "id"), nullable=False)
product_id = Column(UUIDType, foreign_key("Product", "id"), nullable=False)
created_timestamp = Column(BigInteger, nullable=False)
user = relationship(
argument="User", backref=backref("user_products", cascade="all, delete-orphan"),
)
product = relationship(
argument="Product",
backref=backref("product_users", cascade="all, delete-orphan"),
order_by="UserProduct.created_timestamp",
)
def __init__(self, user=None, product=None):
self.id = uuid.uuid1()
self.user = user
self.product = product
self.created_timestamp = now_timestamp()

View file

@ -0,0 +1,35 @@
import uuid
from sqlalchemy import Column, Boolean, BigInteger
from .meta import Base, RBase, UUIDType, foreign_key, now_timestamp
from sqlalchemy.orm import relationship, backref
class UserShop(RBase, Base):
"""
Many shops to many users.
A relationship signifies the ability to edit a shop and it's products.
"""
id = Column(UUIDType, primary_key=True, index=True)
user_id = Column(UUIDType, foreign_key("User", "id"), nullable=False)
shop_id = Column(UUIDType, foreign_key("Shop", "id"), nullable=False)
created_timestamp = Column(BigInteger, nullable=False)
# a user may set a default shop if they have more than 1.
default = Column(Boolean, default=False)
user = relationship(
argument="User", backref=backref("user_shops", cascade="all, delete-orphan")
)
shop = relationship(
argument="Shop", backref=backref("shop_users", cascade="all, delete-orphan")
)
def __init__(self, user=None, shop=None):
self.id = uuid.uuid1()
self.user = user
self.shop = shop
self.created_timestamp = now_timestamp()

View file

@ -0,0 +1,219 @@
from .models.cart import Cart
from .models.cart import get_cart_by_id
from .models.user import get_user_by_id
from .models.shop import get_shop_by_id
from .models.shop import get_shop_by_domain_name
from .models.product import get_product_by_id
from miscutils import get_children_settings
def includeme(config):
"""Enhance all inbound requests with extra attributes!"""
# Each inbound request will run these functions as-needed
# and attach the results as attributes.
# get the app_settings from the config file.
app_settings = get_children_settings(config.get_settings(), "app")
def add_user(request):
"""Return User object or None. User.authenticated may be True or False."""
user = None
authenticated_user_id = request.session.get("authenticated_user_id", None)
if authenticated_user_id:
# attach the user object from DB to the request.
user = get_user_by_id(request.dbsession, authenticated_user_id)
if user is not None:
user.authenticated = True
return user
def add_active_cart(request):
"""
Load active cart from session or
create cart and add to session.
Either way, always returns a Cart.
"""
cart_modified = False
cart_id = request.session.get("active_cart_id", None)
if cart_id is not None:
cart = get_cart_by_id(request.dbsession, cart_id)
else:
# if no active cart in session, create one.
cart = Cart()
# When the serializer is JSON instead of pickle and I encode to
# TypeError: Unicode-objects must be encoded before hashing
#request.session["active_cart_id"] = str(cart.id)
# Still using the JSON serializer when I encode to
# to utf-8 in order to work with bytes, I get this error:
# TypeError: Object of type bytes is not JSON serializable
#request.session["active_cart_id"] = str(cart.id).encode("utf-8")
# this appears to work fine with pickle.
request.session["active_cart_id"] = str(cart.id)
# The issue started when attempting to port from Python 2 to 3.
request.dbsession.add(cart)
request.dbsession.flush()
# only a cart whose id is in the session is considered active.
cart.active = True
return cart
def add_product(request):
"""Return Product object or None from route."""
product_id = request.matchdict.get("product_id")
if product_id:
return get_product_by_id(request.dbsession, product_id)
def add_shop(request):
"""return Shop object or None."""
# get shop_id first from query, then route, or None.
shop_id = request.params.get("shop_id", request.matchdict.get("shop_id"))
if shop_id:
return get_shop_by_id(request.dbsession, shop_id)
elif request.product:
return request.product.shop
else:
return get_shop_by_domain_name(request.dbsession, request.domain)
def add_shop_param(request):
"""return query param for shop_id or empty string."""
if request.shop is not None:
if request.user in request.shop.users:
return "?shop_id={}".format(request.shop.id)
return ""
def add_market(request):
"""Return Market object or None."""
return None
def add_stripe_customer(request):
if request.shop and request.user:
return request.shop.stripe_customer(request.user)
def add_stripe_saved_cards(request):
if request.stripe_customer:
return request.stripe_customer.sources
return []
def add_stripe_active_card(request):
if request.stripe_customer:
if request.stripe_customer.default_source:
return request.stripe_customer.sources.retrieve(
request.stripe_customer.default_source
)
return None
def add_spam(request):
"""Test if request looks spammy HTTP Error or False."""
if request.params.get("email2", "") != "":
# spam filter, hidden email2 field tricks bots.
from pyramid.httpexceptions import HTTPUnauthorized
return HTTPUnauthorized("you smell like a spammer")
def add_app(request):
"""Attach app settings dictionary."""
return app_settings
def add_app_url(request):
"""
Use the app_url from config if request.domain ends with the
configured root_domain. Otherwise just use the request's host_url
"""
config_app_url = request.app.get("app_url", request.host_url)
root_domain = request.app.get("root_domain")
if root_domain and request.domain.endswith(root_domain):
return config_app_url
elif request.domain == "localhost":
return config_app_url
return request.host_url
def add_app_domain(request):
return request.app_url.split("://")[-1].split(":")[0]
def add_secure_uploads_client(request):
"""returns an S3 compatible client."""
import boto3
# Initialize a session using DigitalOcean Spaces.
session = boto3.session.Session()
return session.client(
"s3",
region_name=request.app["bucket.secure_uploads.region"],
endpoint_url=request.app["bucket.secure_uploads.post_endpoint"],
aws_access_key_id=request.app["bucket.secure_uploads.access_key"],
aws_secret_access_key=request.app["bucket.secure_uploads.secret_key"],
)
def add_is_shop_domain(request):
"""
Returns True or False.
Returns True when request domain matches the shop domain.
"""
if request.shop is not None and request.domain == request.shop.domain_name:
return True
return False
def add_saas_domain(request):
return request.app.get("root_domain")
def add_saas_url(request):
return request.app.get("root_url")
def add_is_saas_domain(request):
"""
Returns True or False.
Only one domain should have this method return True per deployment.
This request method lets us:
* show two different "home" pages
* show only certain buttons on the SaaS domain.
"""
root_domain = request.app.get("root_domain")
return (
root_domain
and request.domain.endswith(root_domain)
and request.app_domain == root_domain
)
# Register functions to app config as request methods.
# To prevent multiple DB lookups, cache result with `reify=True`.
config.add_request_method(add_user, "user", reify=True)
config.add_request_method(add_active_cart, "active_cart", reify=True)
config.add_request_method(add_product, "product", reify=True)
config.add_request_method(add_shop, "shop", reify=True)
config.add_request_method(add_shop_param, "shop_param", reify=True)
config.add_request_method(add_market, "market", reify=True)
config.add_request_method(add_stripe_customer, "stripe_customer", reify=True)
config.add_request_method(add_stripe_saved_cards, "stripe_saved_cards", reify=True)
config.add_request_method(add_stripe_active_card, "stripe_active_card", reify=True)
config.add_request_method(add_spam, "spam", reify=True)
config.add_request_method(add_app, "app", reify=True)
config.add_request_method(add_app_url, "app_url", reify=True)
config.add_request_method(add_app_domain, "app_domain", reify=True)
config.add_request_method(add_is_shop_domain, "is_shop_domain", reify=True)
config.add_request_method(add_is_saas_domain, "is_saas_domain", reify=True)
config.add_request_method(add_saas_domain, "saas_domain", reify=True)
config.add_request_method(add_saas_url, "saas_url", reify=True)
config.add_request_method(
add_secure_uploads_client, "secure_uploads_client", reify=True
)

101
make_post_sell/routes.py Normal file
View file

@ -0,0 +1,101 @@
def includeme(config):
config.add_static_view("static", "static", cache_max_age=3600)
config.add_route("favicon", "/favicon.ico")
config.add_route("robots", "/robots.txt")
config.add_route("markup-editor-preview", "/markup-editor-preview")
config.add_route("home", "/")
config.add_route("search", "/search")
config.add_route("log-out", "/log-out")
config.add_route("join-or-log-in", "/join-or-log-in")
# views with just a bunch of buttons to other pages.
config.add_route("actions_view", "/actions/view")
config.add_route("actions_new", "/actions/new")
# billing routes.
config.add_route("billing", "/billing")
config.add_route("add-card", "/billing/add-card")
config.add_route(
"confirm-update-card", "/billing/confirm-update-card/{action}/{card_id}"
)
config.add_route("update-card", "/billing/update-card")
# user routes.
config.add_route("user_settings", "/u/settings")
config.add_route("user_purchases", "/u/purchases")
config.add_route("user_shops", "/u/shops")
config.add_route("user_shop_activate", "/u/shop/{shop_id}/activate")
config.add_route("user_cart_save", "/u/cart/save")
config.add_route("user_carts", "/u/carts")
config.add_route("user_cart_delete", "/u/cart/{cart_id}/delete")
config.add_route("user_cart_activate", "/u/cart/{cart_id}/activate")
config.add_route(
"user_cart_complete_checkout", "/u/cart/{cart_id}/complete/checkout"
)
config.add_route("user_cart_checkout", "/u/cart/{cart_id}/checkout")
config.add_route("user_cart_public", "/u/cart/{cart_id}/public")
config.add_route("user_cart_unpublic", "/u/cart/{cart_id}/unpublic")
# cart routes.
config.add_route("cart", "/cart")
config.add_route("cart_checkout", "/cart/checkout")
config.add_route("cart_add_product", "/cart/add")
config.add_route("cart_by_id", "/cart/{cart_id}")
config.add_route("cart_remove_product", "/cart/{cart_id}/remove")
config.add_route("cart_quantity_product", "/cart/{cart_id}/quantity")
# coupon routes.
config.add_route("coupon_new1", "/coupon/new")
config.add_route("coupon_new2", "/s/{shop_id}/coupon/new")
config.add_route("coupon_apply_to_cart", "/coupon/apply")
config.add_route("coupon_remove_from_cart", "/coupon/remove")
config.add_route("coupons", "/s/{shop_id}/coupons")
config.add_route("coupon1", "/s/{shop_id}/coupon/{coupon_id}")
config.add_route("coupon2", "/s/{shop_id}/coupon/{coupon_id}/{slug:.*}")
# shop routes.
config.add_route("shop_new", "/s/new")
config.add_route("shop", "/s/{shop_id}")
config.add_route("shop_products", "/s/{shop_id}/products")
config.add_route("shop_settings", "/s/{shop_id}/settings")
config.add_route("shop_terms_of_service", "/s/terms")
config.add_route("shop_terms_of_service2", "/s/{shop_id}/terms")
config.add_route("shop_edit_terms_of_service", "/s/{shop_id}/terms/edit")
config.add_route("shop_privacy_policy", "/s/privacy-policy")
config.add_route("shop_privacy_policy2", "/s/{shop_id}/privacy-policy")
config.add_route("shop_edit_privacy_policy", "/s/{shop_id}/privacy-policy/edit")
config.add_route("shop_about", "/s/{shop_id}/about")
config.add_route("shop_about_slug", "/s/{shop_id}/{slug:.*}/about")
config.add_route("shop_slug", "/s/{shop_id}/{slug:.*}")
# content routes.
config.add_route("content_new", "/c/new")
config.add_route("content", "/c/{content_id}")
config.add_route("content_slug", "/c/{product_id}/{slug:.*}")
# product routes.
config.add_route("product_new", "/p/new")
config.add_route("product", "/p/{product_id}")
config.add_route("product_description_edit", "/p/{product_id}/description/edit")
config.add_route("product_bundle_edit", "/p/{product_id}/bundle/edit")
config.add_route("product_bundle_edit2", "/p/{product_id}/{slug:.*}/bundle/edit")
config.add_route("product_remove_bundled_product", "/p/{product_id}/bundle/remove/{bundled_product_id}")
config.add_route("product_edit", "/p/{product_id}/edit")
config.add_route("product_edit2", "/p/{product_id}/{slug:.*}/edit")
config.add_route("product_slug", "/p/{product_id}/{slug:.*}")

View file

@ -0,0 +1 @@
# package

View file

@ -0,0 +1,5 @@
This directory was created when I initialized alembic like this:
.. code-block:: bash
alembic -c development.ini init make_post_sell/scripts/alembic

View file

@ -0,0 +1,77 @@
from logging.config import fileConfig
from sqlalchemy import engine_from_config
from sqlalchemy import pool
from alembic import context
# this is the Alembic Config object, which provides
# access to the values within the .ini file in use.
config = context.config
# Interpret the config file for Python logging.
# This line sets up loggers basically.
fileConfig(config.config_file_name)
# add your model's MetaData object here
# for 'autogenerate' support
# from myapp import mymodel
# target_metadata = mymodel.Base.metadata
from make_post_sell.models import Base
target_metadata = Base.metadata
# other values from the config, defined by the needs of env.py,
# can be acquired:
# my_important_option = config.get_main_option("my_important_option")
# ... etc.
def run_migrations_offline():
"""Run migrations in 'offline' mode.
This configures the context with just a URL
and not an Engine, though an Engine is acceptable
here as well. By skipping the Engine creation
we don't even need a DBAPI to be available.
Calls to context.execute() here emit the given string to the
script output.
"""
url = config.get_main_option("sqlalchemy.url")
context.configure(
url=url, target_metadata=target_metadata, literal_binds=True
)
with context.begin_transaction():
context.run_migrations()
def run_migrations_online():
"""Run migrations in 'online' mode.
In this scenario we need to create an Engine
and associate a connection with the context.
"""
connectable = engine_from_config(
config.get_section(config.config_ini_section),
prefix="sqlalchemy.",
poolclass=pool.NullPool,
)
with connectable.connect() as connection:
context.configure(
connection=connection, target_metadata=target_metadata
)
with context.begin_transaction():
context.run_migrations()
if context.is_offline_mode():
run_migrations_offline()
else:
run_migrations_online()

View file

@ -0,0 +1,26 @@
"""${message}
Revision ID: ${up_revision}
Revises: ${down_revision | comma,n}
Create Date: ${create_date}
"""
from alembic import op
import sqlalchemy as sa
${imports if imports else ""}
# revision identifiers, used by Alembic.
revision = ${repr(up_revision)}
down_revision = ${repr(down_revision)}
branch_labels = ${repr(branch_labels)}
depends_on = ${repr(depends_on)}
from make_post_sell.models.meta import UUIDType
def upgrade():
${upgrades if upgrades else "pass"}
def downgrade():
${downgrades if downgrades else "pass"}

View file

@ -0,0 +1,28 @@
"""add active_cart_total_in_cents to User class
Revision ID: 15751f5d7a03
Revises: b0d5604d9255
Create Date: 2019-08-11 15:53:30.385027
"""
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = '15751f5d7a03'
down_revision = 'b0d5604d9255'
branch_labels = None
depends_on = None
def upgrade():
# ### commands auto generated by Alembic - please adjust! ###
op.add_column('mps_user', sa.Column('active_cart_total_in_cents', sa.BigInteger(), nullable=False, server_default="0"))
# ### end Alembic commands ###
def downgrade():
# ### commands auto generated by Alembic - please adjust! ###
op.drop_column('mps_user', 'active_cart_total_in_cents')
# ### end Alembic commands ###

View file

@ -0,0 +1,26 @@
"""create new active_shop_id column on user table.
Revision ID: 24662905655f
Revises: 3e1e70bfe89d
Create Date: 2020-02-03 00:03:11.599838
"""
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = '24662905655f'
down_revision = '3e1e70bfe89d'
branch_labels = None
depends_on = None
from make_post_sell.models.meta import UUIDType
def upgrade():
op.add_column('mps_user', sa.Column('active_shop_id', UUIDType, nullable=True))
def downgrade():
op.drop_column('mps_user', 'active_shop_id')

View file

@ -0,0 +1,24 @@
"""google_analytics_id
Revision ID: 3e1e70bfe89d
Revises: eadd0d69e133
Create Date: 2019-11-20 11:06:28.092483
"""
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = '3e1e70bfe89d'
down_revision = 'eadd0d69e133'
branch_labels = None
depends_on = None
def upgrade():
op.add_column('mps_shop', sa.Column('google_analytics_id', sa.Unicode(length=32), nullable=True))
def downgrade():
op.drop_column('mps_shop', 'google_analytics_id')

View file

@ -0,0 +1,27 @@
"""product visibility column
Revision ID: 4d9da43b0e86
Revises: af28cc35ed6f
Create Date: 2021-10-27 09:00:32.238571
"""
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = '4d9da43b0e86'
down_revision = 'af28cc35ed6f'
branch_labels = None
depends_on = None
from make_post_sell.models.meta import UUIDType
def upgrade():
op.add_column('mps_product', sa.Column('visibility', sa.Integer(), nullable=False, server_default="1"))
def downgrade():
op.drop_column('mps_product', 'visibility')

View file

@ -0,0 +1,28 @@
"""shop ribbon columns
Revision ID: 5c40dfb56179
Revises: f882f2255cd1
Create Date: 2019-10-29 11:59:01.050032
"""
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = '5c40dfb56179'
down_revision = 'f882f2255cd1'
branch_labels = None
depends_on = None
def upgrade():
op.add_column('mps_shop', sa.Column('ribbon_color_1', sa.Unicode(length=32), nullable=True))
op.add_column('mps_shop', sa.Column('ribbon_color_2', sa.Unicode(length=32), nullable=True))
op.add_column('mps_shop', sa.Column('ribbon_text', sa.UnicodeText(), nullable=True))
def downgrade():
op.drop_column('mps_shop', 'ribbon_text')
op.drop_column('mps_shop', 'ribbon_color_2')
op.drop_column('mps_shop', 'ribbon_color_1')

View file

@ -0,0 +1,28 @@
"""add updated_timestamp to cart
Revision ID: 6da97f2f913f
Revises: 15751f5d7a03
Create Date: 2019-08-13 08:52:18.073705
"""
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = '6da97f2f913f'
down_revision = '15751f5d7a03'
branch_labels = None
depends_on = None
def upgrade():
# ### commands auto generated by Alembic - please adjust! ###
op.add_column('mps_cart', sa.Column('updated_timestamp', sa.BigInteger(), nullable=False, server_default="0"))
# ### end Alembic commands ###
def downgrade():
# ### commands auto generated by Alembic - please adjust! ###
op.drop_column('mps_cart', 'updated_timestamp')
# ### end Alembic commands ###

View file

@ -0,0 +1,30 @@
"""terms-and-privacy
Revision ID: 96f851cbab01
Revises: 24662905655f
Create Date: 2020-03-21 18:34:12.244626
"""
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = '96f851cbab01'
down_revision = '24662905655f'
branch_labels = None
depends_on = None
def upgrade():
op.add_column('mps_shop', sa.Column('privacy_policy_html', sa.UnicodeText(), nullable=True, server_default=""))
op.add_column('mps_shop', sa.Column('privacy_policy_raw', sa.UnicodeText(), nullable=True, server_default=""))
op.add_column('mps_shop', sa.Column('terms_of_service_html', sa.UnicodeText(), nullable=True, server_default=""))
op.add_column('mps_shop', sa.Column('terms_of_service_raw', sa.UnicodeText(), nullable=True, server_default=""))
def downgrade():
op.drop_column('mps_shop', 'terms_of_service_raw')
op.drop_column('mps_shop', 'terms_of_service_html')
op.drop_column('mps_shop', 'privacy_policy_raw')
op.drop_column('mps_shop', 'privacy_policy_html')

View file

@ -0,0 +1,24 @@
"""add price_in_cents_column
Revision ID: ab7338eaedf1
Revises: fd9f7e2f2b78
Create Date: 2019-08-10 11:20:43.838087
"""
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = 'ab7338eaedf1'
down_revision = 'fd9f7e2f2b78'
branch_labels = None
depends_on = None
def upgrade():
op.add_column('mps_product', sa.Column('price_in_cents', sa.BigInteger(), nullable=False, server_default="3.50"))
def downgrade():
pass

View file

@ -0,0 +1,34 @@
"""product bundles
Revision ID: af28cc35ed6f
Revises: c4d5566ec87d
Create Date: 2021-04-15 09:30:53.107239
"""
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = 'af28cc35ed6f'
down_revision = 'c4d5566ec87d'
branch_labels = None
depends_on = None
from make_post_sell.models.meta import UUIDType
from make_post_sell.models.product import DEFAULT_BUNDLE_METADATA
def upgrade():
# ### commands auto generated by Alembic - please adjust! ###
op.add_column('mps_product', sa.Column('is_bundle', sa.Boolean(), nullable=False, server_default="0"))
op.add_column('mps_product', sa.Column('json_bundle_metadata', sa.UnicodeText(), nullable=False, server_default=DEFAULT_BUNDLE_METADATA))
# ### end Alembic commands ###
def downgrade():
# ### commands auto generated by Alembic - please adjust! ###
op.drop_column('mps_product', 'json_bundle_metadata')
op.drop_column('mps_product', 'is_bundle')
# ### end Alembic commands ###

View file

@ -0,0 +1,28 @@
"""add active_cart_count to user table
Revision ID: b0d5604d9255
Revises: ab7338eaedf1
Create Date: 2019-08-10 20:15:45.595633
"""
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = 'b0d5604d9255'
down_revision = 'ab7338eaedf1'
branch_labels = None
depends_on = None
def upgrade():
# ### commands auto generated by Alembic - please adjust! ###
op.add_column('mps_user', sa.Column('active_cart_count', sa.Integer(), server_default="0", nullable=False))
# ### end Alembic commands ###
def downgrade():
# ### commands auto generated by Alembic - please adjust! ###
op.drop_column('mps_user', 'active_cart_count')
# ### end Alembic commands ###

View file

@ -0,0 +1,26 @@
"""product.is_sellable coluumn
Revision ID: bfc4195a7f56
Revises: 4d9da43b0e86
Create Date: 2021-12-02 17:32:08.498999
"""
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = 'bfc4195a7f56'
down_revision = '4d9da43b0e86'
branch_labels = None
depends_on = None
from make_post_sell.models.meta import UUIDType
def upgrade():
op.add_column('mps_product', sa.Column('is_sellable', sa.Boolean(), nullable=False, server_default="1"))
def downgrade():
op.drop_column('mps_product', 'is_sellable')

View file

@ -0,0 +1,42 @@
"""Coupon.stackable and CouponRedemption
Revision ID: c4d5566ec87d
Revises: e12bbe39d87e
Create Date: 2021-01-16 11:18:10.302974
"""
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = 'c4d5566ec87d'
down_revision = 'e12bbe39d87e'
branch_labels = None
depends_on = None
from make_post_sell.models.meta import UUIDType
def upgrade():
op.create_table('mps_coupon_redemption',
sa.Column('id', UUIDType, nullable=False),
sa.Column('coupon_id', UUIDType, nullable=False),
sa.Column('invoice_id', UUIDType, nullable=False),
sa.Column('shop_id', UUIDType, nullable=False),
sa.Column('user_id', UUIDType, nullable=False),
sa.Column('created_timestamp', sa.BigInteger(), nullable=False),
sa.ForeignKeyConstraint(['coupon_id'], ['mps_coupon.id'], ),
sa.ForeignKeyConstraint(['invoice_id'], ['mps_invoice.id'], ),
sa.ForeignKeyConstraint(['shop_id'], ['mps_shop.id'], ),
sa.ForeignKeyConstraint(['user_id'], ['mps_user.id'], ),
sa.PrimaryKeyConstraint('id')
)
op.create_index(op.f('ix_mps_coupon_redemption_id'), 'mps_coupon_redemption', ['id'], unique=True)
op.add_column(u'mps_coupon', sa.Column('stackable', sa.Boolean(), nullable=False, server_default="0"))
def downgrade():
op.drop_column(u'mps_coupon', 'stackable')
op.drop_index(op.f('ix_mps_coupon_redemption_id'), table_name='mps_coupon_redemption')
op.drop_table('mps_coupon_redemption')

View file

@ -0,0 +1,40 @@
"""cart_coupon table
Revision ID: e12bbe39d87e
Revises: f3aa57777025
Create Date: 2020-08-23 12:48:47.259833
"""
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = 'e12bbe39d87e'
down_revision = 'f3aa57777025'
branch_labels = None
depends_on = None
from make_post_sell.models.meta import UUIDType
def upgrade():
# ### commands auto generated by Alembic - please adjust! ###
op.create_table('mps_cart_coupon',
sa.Column('id', UUIDType, nullable=False),
sa.Column('cart_id', UUIDType, nullable=False),
sa.Column('coupon_id', UUIDType, nullable=False),
sa.Column('created_timestamp', sa.BigInteger(), nullable=False),
sa.ForeignKeyConstraint(['cart_id'], ['mps_cart.id'], ),
sa.ForeignKeyConstraint(['coupon_id'], ['mps_coupon.id'], ),
sa.PrimaryKeyConstraint('id')
)
op.create_index(op.f('ix_mps_cart_coupon_id'), 'mps_cart_coupon', ['id'], unique=False)
# ### end Alembic commands ###
def downgrade():
# ### commands auto generated by Alembic - please adjust! ###
op.drop_index(op.f('ix_mps_cart_coupon_id'), table_name='mps_cart_coupon')
op.drop_table('mps_cart_coupon')
# ### end Alembic commands ###

View file

@ -0,0 +1,24 @@
"""shop ribbon text color
Revision ID: eadd0d69e133
Revises: 5c40dfb56179
Create Date: 2019-10-29 12:14:10.391983
"""
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = 'eadd0d69e133'
down_revision = '5c40dfb56179'
branch_labels = None
depends_on = None
def upgrade():
op.add_column('mps_shop', sa.Column('ribbon_text_color', sa.Unicode(length=32), nullable=True))
def downgrade():
op.drop_column('mps_shop', 'ribbon_text_color')

View file

@ -0,0 +1,45 @@
"""coupon table
Revision ID: f3aa57777025
Revises: 96f851cbab01
Create Date: 2020-07-27 10:11:58.760190
"""
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = 'f3aa57777025'
down_revision = '96f851cbab01'
branch_labels = None
depends_on = None
from make_post_sell.models.meta import UUIDType
def upgrade():
# ### commands auto generated by Alembic - please adjust! ###
op.create_table('mps_coupon',
sa.Column('id', UUIDType, nullable=False),
sa.Column('shop_id', UUIDType, nullable=False),
sa.Column('code', sa.Unicode(length=256), nullable=False),
sa.Column('description', sa.Unicode(length=256), nullable=False),
sa.Column('action_type', sa.Enum('percent-off', 'dollar-off', name='action_type'), nullable=False),
sa.Column('action_value', sa.BigInteger(), nullable=False),
sa.Column('redemptions', sa.BigInteger(), nullable=False),
sa.Column('max_redemptions', sa.BigInteger(), nullable=True),
sa.Column('max_redemptions_per_user', sa.BigInteger(), nullable=True),
sa.Column('cart_qualifier', sa.BigInteger(), nullable=True),
sa.Column('created_timestamp', sa.BigInteger(), nullable=False),
sa.Column('expired_timestamp', sa.BigInteger(), nullable=True),
sa.Column('disabled', sa.Boolean(), nullable=True),
sa.ForeignKeyConstraint(['shop_id'], ['mps_shop.id'], ),
sa.PrimaryKeyConstraint('id')
)
op.create_index(op.f('ix_mps_coupon_id'), 'mps_coupon', ['id'], unique=False)
def downgrade():
op.drop_index(op.f('ix_mps_coupon_id'), table_name='mps_coupon')
op.drop_table('mps_coupon')

View file

@ -0,0 +1,27 @@
"""add stripe secret and public key to shop
Revision ID: f882f2255cd1
Revises: 6da97f2f913f
Create Date: 2019-08-25 13:46:12.004425
"""
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = 'f882f2255cd1'
down_revision = '6da97f2f913f'
branch_labels = None
depends_on = None
def upgrade():
# ### commands auto generated by Alembic - please adjust! ###
op.add_column('mps_shop', sa.Column('stripe_public_api_key', sa.Unicode(length=32), nullable=True))
op.add_column('mps_shop', sa.Column('stripe_secret_api_key', sa.Unicode(length=32), nullable=True))
# ### end Alembic commands ###
def downgrade():
pass

View file

@ -0,0 +1,28 @@
"""user full_name
Revision ID: fd9f7e2f2b78
Revises:
Create Date: 2019-08-05 21:46:20.974126
"""
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = 'fd9f7e2f2b78'
down_revision = None
branch_labels = None
depends_on = None
def upgrade():
# ### commands auto generated by Alembic - please adjust! ###
op.add_column('mps_user', sa.Column('full_name', sa.Unicode(length=64), nullable=True))
# ### end Alembic commands ###
def downgrade():
# ### commands auto generated by Alembic - please adjust! ###
op.drop_column('mps_user', 'full_name')
# ### end Alembic commands ###

View file

@ -0,0 +1,52 @@
import argparse
import sys
from pyramid.paster import bootstrap, get_appsettings, setup_logging
from sqlalchemy.exc import OperationalError
from .. import models
def setup_models(dbsession):
"""
Add or update models / fixtures in the database.
"""
# model = models.mymodel.MyModel(name='one', value=1)
# dbsession.add(model)
def parse_args(argv):
parser = argparse.ArgumentParser()
parser.add_argument(
'config_uri',
help='Configuration file, e.g., development.ini',
)
return parser.parse_args(argv[1:])
def main(argv=sys.argv):
args = parse_args(argv)
setup_logging(args.config_uri)
env = bootstrap(args.config_uri)
settings = get_appsettings(args.config_uri)
engine = models.get_engine(settings)
models.meta.Base.metadata.create_all(engine)
try:
with env['request'].tm:
dbsession = env['request'].dbsession
setup_models(dbsession)
except OperationalError:
print('''
Pyramid is having a problem using your SQL database. The problem
might be caused by one of the following things:
1. You may need to initialize your database tables with `alembic`.
Check your README.txt for description and try to run it.
2. Your database server may not be running. Check that the
database server referred to by the "sqlalchemy.url" setting in
your "development.ini" file is running.
''')

View file

@ -0,0 +1,13 @@
from .. import models
def setup(env):
request = env['request']
# start a transaction
request.tm.begin()
# inject some vars into the shell builtins
env['tm'] = request.tm
env['dbsession'] = request.dbsession
env['models'] = models

View file

@ -0,0 +1,66 @@
div.message-ribbon {
color: #ffffff;
background-color: #5871ad;
background: linear-gradient(to right, #5871ad, #82A8FF);
}
#alerts {
animation: fadein 2s;
}
@keyframes fadein {
from { opacity: 0; }
to { opacity: 1; }
}
/* ALERT START */
#alerts {
z-index: 98;
}
.alert {
opacity: .85;
display: grid;
grid-template-columns: 1fr 50px;
grid-gap: 10px;
}
.alert-message {
text-align: center;
margin-top: 10px;
margin-bottom: 10px;
}
.alert .close {
text-align: right;
margin-top: 10px;
margin-bottom: 10px;
margin-right: 15px;
}
.alert-danger, .alert-error {
color: #721c24;
background-color: #f8d7da;
border-color: #f5c6cb;
}
.alert-warning {
color: #856404;
background-color: #fff3cd;
border-color: #ffeeba;
}
.alert-info, .alert-notice {
color: #0c5460;
background-color: #98b6fa;
border-color: #bee5eb;
}
.alert-success {
color: #155724;
background-color: #a3c765;
border-color: #c3e6cb;
}
/* ALERT END */

View file

@ -0,0 +1,738 @@
/* * * * * *
*
* Make Post Sell (mps) is a mobile first web application. We make the pages
* look great on mobile & only use media queries as needed for bigger displays.
*
* We use CSS Grid. It's awesome.
*
* * * * * */
body {
margin: 0px;
font-family: helvetica;
}
h1 {
font-size: 1.75em;
margin-top: .4em;
margin-bottom: .4em;
}
label {
display: block;
}
a {
text-decoration: none;
color: #5f6368
}
input {
/* keep the input inside the parents box! */
box-sizing: border-box;
}
input[type="text"],
input[type="tel"],
input[type="password"],
input[type="number"],
input[type="date"] {
padding: 8px;
border-radius: 4px;
border-style: solid;
border-width: 1px;
border-color: #CCCCCC;
}
textarea {
/* keep the input inside the parents box! */
box-sizing: border-box;
padding: 8px;
border-radius: 4px;
border-style: solid;
border-width: 1px;
border-color: #CCCCCC;
}
section.shop-name-and-description {
display: grid;
}
input.mps-quantity {
max-width: 60px;
}
input.mps-submit {
float: right;
}
textarea.mps-billing-address,
textarea.mps-shop-description,
textarea.mps-bundle-data,
textarea.mps-shop-ribbon-text
{
max-width: 600px;
width: 100%;
height: 120px;
padding: 10px;
}
textarea.mps-shop-ribbon-text {
height: 60px;
}
input.mps-shop-name,
input.mps-phone-number,
input.mps-google-analytics-id,
input.mps-stripe-public-api-key,
input.mps-stripe-secret-api-key,
input.mps-shop-ribbon-text-color,
input.mps-shop-ribbon-color
{
max-width: 600px;
width: 100%;
}
section.product-title-and-description,
section.content-title-and-description {
display: grid;
}
section.product-left,
section.content-left {
justify-self: right;
text-align: left;
/*max-width: 480px;*/
}
section.product-right,
section.content-right {
justify-self: center;
text-align: center;
}
section.log-in-form {
justify-self: right;
text-align: center;
}
section.join-form {
justify-self: left;
text-align: center;
}
input.mps-product-title,
input.mps-product-price,
input.mps-content-title {
max-width: 600px;
width: 100%;
}
textarea.mps-product-description,
textarea.mps-content-description {
max-width: 600px;
width: 100%;
height: 120px;
padding: 10px;
}
input.file-input {
width: 100%;
}
div.message-ribbon {
padding: 8px;
text-align: center;
font-weight: bold;
font-size: 18px;
}
section.main {
/*padding: 14px;*/
}
/*
section.grid-nav {
grid-column: 1/-1;
}
*/
/* mobile first */
/* the navbar is stacked by default. */
section.nav {
background-color: #F9F9FA;
display: grid;
grid-template-columns: 1fr;
grid-auto-columns: max-content;
grid-auto-flow: dense;
grid-gap: 4px;
padding: 4px;
}
div.message-ribbon {
display: none;
}
section.log-in {
text-align: center;
align-self: center;
}
section.search {
align-self: center;
}
form.search {
width: 100%;
}
input.mps-keywords {
width: 100%;
text-align: center;
}
section.content {
background-color: #ffffff;
margin: 4px;
padding: 4px;
}
section.logo{
align-self: center;
}
img.logo {
width: 90%;
padding: 5%;
}
img.product-cart-thumbnail {
border: 1px solid #ddd;
border-radius: 4px;
max-width:44px;
max-height:44px;
width: auto;
height: auto;
}
img.product-thumbnail {
border: 1px solid #ddd;
border-radius: 4px;
max-width:58px;
max-height:58px;
width: auto;
height: auto;
}
img.product-main {
/*
border: 1px solid #ddd;
border-radius: 4px;
*/
width: 100%;
}
.mps-button {
border: none;
border-radius: 4px;
color: white;
display: inline-block;
font-weight: bold;
min-width: 100%;
margin-top: 4px;
margin-bottom: 4px;
padding-top: 14px;
padding-bottom: 14px;
text-align: center;
text-decoration: none;
cursor: pointer;
}
a.mps-button-blue {
background-color: #98b6fa;
}
a.mps-button-green {
background-color: #a3c765;
}
a.product-edit-button {
background-color: #98b6fa;
}
a.product-preview-button {
background-color: #98b6fa;
}
a.product-download-button {
background-color: #a3c765;
}
a.product-new-button {
background-color: #98b6fa;
}
a.shop-new-button {
background-color: #a3c765;
}
a.done-button {
background-color: #a3c765;
}
a.log-out-button {
background-color: #5871ad;
}
a.cart-checkout-button {
background-color: #a3c765;
}
a.cart-save-button {
background-color: #98b6fa;
}
a.cart-activate-button {
background-color: #5871ad;
}
a.cart-public-button span.lock {
font-size: 24px;
}
a.cart-public-button {
color: #666666;
border-color: #666666;
border-style: solid;
border-width: 1px;
padding-top: 10px;
padding-bottom: 10px;
}
a.cart-delete-button {
background-color: #CC6958;
}
a.shop-switch-button {
color: #666666;
border-color: #666666;
border-style: solid;
border-width: 1px;
padding-top: 10px;
padding-bottom: 10px;
}
a.user-display-name {
margin: 14px;
display: inline-block;
}
a.cart-and-count {
margin: 14px;
display: inline-block;
}
.coupon-apply-button {
background-color: #a3c765;
}
section.serp {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(160px, 1.00fr));
grid-auto-columns: max-content;
grid-auto-flow: dense;
grid-gap: 4px;
}
div.serp-item {
border-radius: 4px;
padding: 4px;
grid-column: span 1;
grid-row: span 1;
/* The new CSS animations are off the hook. */
transition: background-color 800ms ease;
-webkit-transition: 600ms -webkit-filter ease;
-moz-transition: 600ms -moz-filter ease;
-moz-transition: 600ms filter ease;
-ms-transition: 600ms -ms-filter ease;
-o-transition: 600ms -o-filter ease;
transition: 600ms filter ease, 600ms -webkit-filter ease;
}
div.serp-item:hover {
background-color: #FBFBF3;
-webkit-filter: brightness(88%);
-moz-filter: brightness(88%);
filter: brightness(88%);
}
img.serp-thumbnail {
border-radius: 4px;
width: 100%;
}
div.edit-page {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(240px, 1fr));
grid-gap: 20px;
}
hr {
color: #EEEEEE;
}
section.upload-product-and-preview {
}
section.upload-thumbnails {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(240px, 1fr));
grid-auto-columns: max-content;
grid-auto-flow: dense;
grid-gap: 14px;
}
section.windows-95-task-bar {
display: grid;
grid-template-columns: 1fr;
grid-gap: 4px;
padding-top: 10px;
padding-bottom: 10px;
}
section.windows-95-start-button {
padding: 14px;
padding-top: 2px;
padding-bottom: 2px;
}
section.call-to-action {
padding: 14px;
padding-top: 2px;
padding-bottom: 2px;
}
section.one-column, section.one-column-thin {
max-width: 600px;
margin-left: auto;
margin-right: auto;
}
section.one-column-thin {
max-width: 400px;
}
section.user-settings {
justify-self: right;
}
section.button-panel {
justify-self: left;
text-align: center;
}
section.existing-cards {
justify-self: right;
text-align: left;
margin-bottom: 40px;
}
section.new-card {
justify-self: left;
text-align: right;
margin-bottom: 40px;
}
div.card-details {
max-width: 240px;
margin:auto;
}
div.card-are-you-sure-buttons {
max-width: 300px;
margin:auto;
}
section.cart-left {
margin-bottom: 10px;
grid-column: 1 / span 3;
}
section.cart-left-grid {
display: grid;
grid-gap: 20px;
grid-template-columns: 1fr 4fr 1fr;
}
section.cart-right {
text-align: center;
margin-bottom: 10px;
grid-column: 4;
}
span.cart-left-item-title {
font-weight: bold;
font-size: 18px;
}
section.checkout-left {
justify-self: right;
text-align: left;
text-align: center;
margin-bottom: 40px;
}
section.checkout-right {
justify-self: left;
text-align: center;
margin-bottom: 40px;
}
.coupon {
/* Dotted border */
border-radius: 4px;
border: 3px dotted #bbb;
padding: 20px;
}
#email2_input {
display: None;
}
.StripeElement {
min-width: 246px;
width: 100%;
border-color: #bbbbbb;
border-style: solid;
border-width: 1px;
border-radius: 2px;
background-color: white;
padding-top: 12px;
padding-bottom: 12px;
padding-left: 3px;
padding-right: 3px;
}
.opacity-60 {
opacity: .60;
}
.well, .well2 {
background-color: #F9F9FA;
border-radius: 10px;
padding: 10px;
}
/* markup editor css */
section.markup-settings {
width: 100%;
min-height: 280px;
}
textarea.markup-editor-textarea {
-webkit-box-sizing: border-box;
-moz-box-sizing: border-box;
box-sizing: border-box;
width: 100%;
height: 400px;
}
.markup-editor-submit {
float: right;
}
.markup-preview-div {
width: 100%;
}
.markup-preview-div {
border-top: 1px dotted #bbbbbb;
border-bottom: 1px dotted #bbbbbb;
padding-top: 16px;
padding-bottom: 16px;
margin-top: 16px;
margin-bottom: 16px;
margin-right: 5px;
margin-left: 5px;
line-height: 1.6;
}
.markup-preview-div p {
margin-top: 15px;
margin-bottom: 15px;
line-height: 1.75;
}
.preview-raw-markup {
display: block;
white-space: pre-wrap;
background-color: unset;
border-radius: unset;
border: unset;
border-width: 0px;
margin-top: 19px;
margin-bottom: 10px;
}
.discounted {
color: #a3c765;
}
@media (max-width: 800px) {
/* the two-column is stacked by default. */
section.two-column {
max-width: 600px;
margin-left: auto;
margin-right: auto;
}
/* the cart-grid is stacked by default. */
section.cart-grid {
max-width: 600px;
margin-left: auto;
margin-right: auto;
}
/* markup-editor is stacked by default. */
div.markup-editor {
display: grid;
margin-left: auto;
margin-right: auto;
}
section.markup-rendered {
display: none;
}
}
/*************************************************************
*
* for displays bigger than 800px which are "desktop-ish".
*
*************************************************************/
@media (min-width: 960px) {
/* The SERP page grid should have bigger items since it has more room.
* mobile -> desktop
* 140px -> 240px */
section.serp {
grid-template-columns: repeat(auto-fit, minmax(240px, .25fr));
}
/* We have the room to really break into 2 columns */
section.two-column {
display: grid;
grid-template-columns: 1fr 1fr;
grid-auto-columns: max-content;
grid-auto-flow: dense;
gap: 0px 60px;
padding-left: 60px;
padding-right: 60px;
justify-items: center;
}
/* if we have room, break markup-editor into 2 columns */
div.markup-editor {
display: grid;
grid-template-columns: 1fr 1fr;
grid-auto-columns: max-content;
grid-auto-flow: dense;
grid-gap: 30px;
padding: 14px;
justify-items: center;
}
section.markup-settings {
min-height: 400px;
}
/* the minimum width for "desktop-ish" screens. */
a.mps-button {
min-width: 280px;
}
/* really make the button small now that we have room. */
a.mps-button-small {
min-width: 140px;
padding-left: 10px;
padding-right: 10px;
}
section.windows-95-task-bar {
grid-template-columns: 1fr 1fr;
}
section.call-to-action {
text-align: right;
}
/* if we have room, break into 2 columns */
section.cart-grid {
display: grid;
grid-template-columns: 1fr 1fr 1fr 1fr;
grid-gap: 30px;
padding: 14px;
justify-items: center;
}
img.product-cart-thumbnail {
max-width:104px;
max-height:104px;
}
section.content {
background-color: #ffffff;
margin: 4px;
padding: 14px;
}
input.mps-keywords {
text-align: left;
}
section.nav {
/* if we have room, break navbar into 3 columns */
grid-template-columns: minmax(200px, 300px) 3fr 1fr;
grid-gap: 30px;
padding: 2px;
}
div.message-ribbon {
display: block;
}
section.log-in {
text-align: right;
}
.well, .well2 {
padding: 10px;
padding-right: 20px;
padding-left: 20px;
}
.well2 {
padding: 10px;
}
}
@media (min-width: 1200px) {
img.product-cart-thumbnail {
max-width:184px;
max-height:184px;
}
.well, .well2 {
padding: 20px;
padding-right: 40px;
padding-left: 40px;
}
.well2 {
padding: 20px;
}
}

View file

@ -0,0 +1,25 @@
.avoid-wrap {
display:inline-block;
}
.vertical-text {
display: inline-block;
-webkit-transform: rotate(-90deg);
-moz-transform: rotate(-90deg);
-ms-transform: rotate(-90deg);
-o-transform: rotate(-90deg);
transform: rotate(-90deg);
}
.shown-on-desktop {
display: none;
}
.not-shown-on-desktop {
display: block;
}
@media screen and (min-width:800px) {
.shown-on-desktop {display: block}
.not-shown-on-desktop {display: none}
}

View file

@ -0,0 +1,13 @@
div.message-ribbon {
color: #ffffff;
background-color: #5871ad;
background: linear-gradient(to right, #5871ad, #82A8FF);
}
.highlight {
text-shadow:
-6px 0px 6px rgba(255, 231, 13, 0.6),
6px 0px 6px rgba(255, 231, 13, 0.6),
12px 0px 12px rgba(255, 231, 13, 0.6),
-12px 0px 12px rgba(255, 231, 13, 0.6);
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.4 KiB

View file

@ -0,0 +1,30 @@
// post_preview
// previewTimer must live outside the functions.
var previewTimer = null;
function previewAjax(textarea, div, show_raw = false){
// set div to raw textarea while waiting for remote Markdown rendering.
if (show_raw) {
// bust HTML tags like <script> to prevent running evil code.
var busted_textarea = $('#'+textarea).val().replace(/&/g, '&amp;').replace(/</g,'&lt;');
$('#' + div).html('<span class="preview-raw-markup">'+busted_textarea+'</span>');
}
if (previewTimer) {
clearTimeout(previewTimer);
}
previewTimer = setTimeout(
function() { sendPreview( textarea, div); },
800
);
}
function sendPreview(textarea, div){
$.ajaxSetup ({
cache: false
});
var url = '/markup-editor-preview';
var params = { 'data' : $('#'+textarea).val() };
$( '#' + div ).load( url, params );
}

File diff suppressed because one or more lines are too long

1
make_post_sell/static/js/jquery.min.js vendored Symbolic link
View file

@ -0,0 +1 @@
jquery-3.4.1.min.js

Binary file not shown.

After

Width:  |  Height:  |  Size: 119 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 54 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 27 KiB

View file

@ -0,0 +1,30 @@
{% extends "base.j2" -%}
{% block content -%}
<section class="one-column well">
{% if request.user.active_shop %}
<b>{{ request.user.active_shop.name }}</b>
<br/>
<br/>
<a href="/c/new" class="shop-new-button mps-button">&#65291; New Content</a>
<br/>
<br/>
<a href="/coupon/new" class="shop-new-button mps-button">&#65291; New Coupon</a>
<br/>
<br/>
<a href="/p/new" class="shop-new-button mps-button">&#65291; New Product</a>
<br />
<br />
<a href="/s/new" class="shop-new-button mps-button mps-button">&#65291; New Shop</a>
{% endif %}
</section>
{%- endblock -%}

View file

@ -0,0 +1,35 @@
{% extends "base.j2" -%}
{% block content -%}
<section class="one-column well">
{% if request.user.active_shop %}
<b>{{ request.user.active_shop.name }}</b>
<br/>
<br/>
<a href="/s/{{ request.user.active_shop.id }}/{{ request.user.active_shop.slug }}" class="product-edit-button mps-button">&nbsp View Shop</a>
<br/>
<br/>
<a href="/s/{{ request.user.active_shop.id }}/products" class="product-edit-button mps-button">&nbsp View Products</a>
<br/>
<br/>
<a href="/s/{{ request.user.active_shop.id }}/coupons" class="product-edit-button mps-button">&nbsp View Coupons</a>
<br/>
<br/>
<a href="/s/{{ request.user.active_shop.id }}/settings" class="product-edit-button mps-button">&#9881; &nbsp Shop Settings</a>
<br/>
<br/>
<a href="/u/shops" class="product-edit-button mps-button">&#9974; &nbsp; Switch Shop</a>
{% endif %}
</section>
{%- endblock -%}

View file

@ -0,0 +1,123 @@
<!DOCTYPE html>
{% if request.product -%}
<!--shop_id={{ request.shop.id }}-->
{%- endif %}
{% if request.product -%}
<!--product_id={{ request.product.id }}-->
{%- endif %}
<html lang="en">
<head>
<title>{% block title %}{{ request.app_domain }}{% endblock %}</title>
<meta charset="utf-8" />
{% set theme_color = request.shop.ribbon_color_1 or "#5871ad" %}
<!-- this makes mobile pretty... -->
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="theme-color" content="{{ theme_color }}">
<!--
TODO: merge all these CSS files into a single file during builds.
In prod we use the single concatenated ("streamlined") all.css file.
In dev we use each CSS file busted out like so:
-->
<link rel="stylesheet" href="/static/css/common.css">
<link rel="stylesheet" href="/static/css/mps.css">
<link rel="stylesheet" href="/static/css/alerts.css">
<link rel="stylesheet" href="/static/css/lib.css">
{% include 'snippets/google-analytics.j2' %}
{%- block append_to_head_tag_section %}
{% endblock append_to_head_tag_section %}
</head>
<body>
{% include 'snippets/ribbon.j2' %}
<section class="main">
<section class="nav-grid">
<section class="nav">
<section class="logo">
{% if request.is_saas_domain %}
<a href="/"><img class="logo" src="/static/mps.png" /></a>
{% elif request.shop %}
<a href="/"><img class="logo" src="{{ request.app['bucket.secure_uploads.get_endpoint'] }}/{{ request.shop.uuid_str }}/meta/shop-logo-banner" /></a>
{% else%}
Upload a logo in shop settings.
{% endif %}
</section>
<section class="search">
{% include 'snippets/search.j2' %}
</section>
<section class="log-in">
{% if request.user and request.user.authenticated %}
{% if request.user.full_name %}
<a href="/u/settings" class="user-display-name">{{ request.user.name }}</a>
{% else %}
<a href="/u/settings" class="user-display-name">My Account</a>
{% endif %}
{% else %}
<a href="/join-or-log-in">My Account</a>
{% endif %}
<a href="/cart" class="cart-and-count">&#128722; Cart ${{ '%0.2f' % request.active_cart.total }} ({{ request.active_cart.count }})</a>
</section>
<!-- nav -->
</section>
<!-- nav-grid -->
</section>
{% include 'snippets/flash-alerts.j2' %}
{% if request.user and request.user.authenticated %}
<section class="windows-95-task-bar">
<section class="windows-95-start-button">
{% if request.is_saas_domain or request.user.can_edit_shop(request.shop) %}
{% if request.user.active_shop %}
<a href="/actions/view" class="windows-95-start-button mps-button mps-button-blue"><span style="font-size: 20px;">&#9776; &nbsp </span> {{ request.user.active_shop.name }}</a>
{% endif %}
{% endif %}
</section>
<section class="call-to-action">
{% block call_to_action %}
{% if request.is_saas_domain or request.user.can_edit_shop(request.shop) %}
{% if request.user.shops %}
<a href="/actions/new" class="mps-button mps-button-small mps-button-green">&#65291; New</a>
{% else %}
<a href="/s/new" class="mps-button mps-button-small mps-button-green">&#65291; New Shop</a>
{% endif %}
{% endif %}
{% endblock call_to_action %}
</section>
{% endif %}
</section >
<section class="content">
{% block content %}
{% endblock %}
</section>
<!-- main -->
</section>
</body>
</html>

View file

@ -0,0 +1,49 @@
{% extends "base.j2" -%}
{%- import 'snippets/stripe.j2' as stripe with context -%}
{% block content -%}
{% if request.stripe_active_card %}
<section class="two-column">
<section class="existing-cards well">
<h3>Active Card</h3>
The active card will be charged during checkout.
<br/>
{{ stripe.active_card() }}
{{ stripe.saved_cards() }}
</section>
{% else %}
<section class="one-column">
{% endif %}
<section class="new-card well">
<h3>Save a new <span class="avoid-wrap">credit or debit card</span></h3>
<br>
<div>
{{ stripe.new_card() }}
</div>
</section>
</section>
<center>
<a href="/cart" class="product-new-button mps-button">Review Order</a>
&nbsp;
&nbsp;
&nbsp;
<a href="/cart/checkout" class="cart-checkout-button mps-button">Checkout</a>
</center>
{%- endblock -%}

View file

@ -0,0 +1,234 @@
{% extends "base.j2" -%}
{%- block call_to_action -%}
{% if request.user and request.user.authenticated and request.user.carts.count() > 1 %}
<a href="/u/carts" class="product-edit-button mps-button mps-button-small">View Saved Carts</a>
{% endif %}
{%- endblock call_to_action -%}
{% block content -%}
<section class="cart-grid">
<section class="cart-left">
{% for coupon in cart.coupons %}
<section class="coupon">
<b>{{ coupon.code }}</b><br/>
{{ coupon.description }} <br/>
<span class="avoid-wrap">
{{ coupon.human_discount }}
{{ coupon.human_minimum_cart }}
</span>
<span class="avoid-wrap">
{{ coupon.human_valid_thru }}
</span>
<span class="avoid-wrap">
{{ coupon.human_limit_per_customer }}
</span>
<div style="float: right;">
<form method="post" action="/coupon/remove" onsubmit="submit.disabled = true; return true;">
<input
name = "cart_id"
type = "hidden"
id = "cart_id_input"
value = "{{ cart.id }}"
required />
<input
name = "coupon_id"
type = "hidden"
id = "coupon_id_input"
value = "{{ coupon.id }}"
required />
<input type="submit" name="submit" class="mps-submit" value="remove coupon" />
</form>
</div>
<!-- close coupon -->
</section>
<br/>
<br/>
{% endfor %}
{% if cart.is_empty %}
<center>
<h2>This cart is empty.</h2>
Nothing to see here.
{% if request.user and request.user.authenticated and request.user.carts.count() > 1 %}
<br/>
<br/>
<a href="/u/carts" class="product-edit-button mps-button mps-button-small">View Saved Carts</a>
<br/>
<br/> or
{% endif %}
<br/>
<br/>
<a href="/" class="cart-checkout-button mps-button mps-button-small">Let's go shopping!</a>
</center>
{% else %}
{% for shop_id, product_ids in shop_product_dict.items() %}
{% set shop = shops[shop_id] %}
<section class="cart-left-grid well">
<div style="grid-column: span 3">
<b style="font-size: 1.5em;">{{ shop.name }}</b>
</div>
{% for product_id in product_ids %}
{% set product = products[product_id] %}
{% set product_quantity = cart.cart[product_id] %}
{% set line_total = cart.line_totals[product_id] %}
<div class="cart-item">
{% if "thumbnail1" in product.extensions %}
<a href="/p/{{ product.id }}" rel="nofollow">
<img src="{{ request.app["bucket.secure_uploads.get_endpoint"] }}/{{ product.s3_path }}/thumbnail1?ts={{ product.updated_timestamp }}" class="product-cart-thumbnail" />
</a>
{% endif %}
</div>
<div class="cart-left-forms">
<span class="cart-left-item-title">
<a href="/p/{{ product.id }}">{{ product.title }}</a> sold by
<a href="{{ shop.absolute_about_url(request) }}" rel="nofollow">{{ shop.name }}</a>
</span>
<br/>
<form action="/cart/{{ cart.id }}/quantity" method="POST" onsubmit="submit.disabled = true; return true;" style="display: inline;">
<input type="hidden" name="product_id" value="{{ product.id }}">
quantity: <input type="text" name="quantity" class="mps-quantity" value="{{ product_quantity }}">
<input type="submit" id="submit" class="quantity button-small" value="update" />
</form>
<br/>
<!-- change this to a form to prevent CSRF -->
<a href="/cart/{{ cart.id }}/remove?product_id={{ product.id }}">remove</a>
</div>
<div style="text-align: right;">
<span class="opacity-60">{{ product_quantity }} x ${{ '%0.2f' % product.price }}</span>
<br/>
<b>${{ '%0.2f' % line_total }}</b>
<br/>
</div>
{% endfor %}
<div style="text-align: right; grid-column: span 3">
{% if cart.is_discounted %}
<b style="font-size: 1.5em;"><s>${{ '%0.2f' % cart.shop_totals[shop.uuid_str] }}</s></b>
<br/>
<b class="discounted" style="font-size: 1.5em;">${{ '%0.2f' % discounted_shop_totals[shop.uuid_str] }}</b>
{% else %}
<b style="font-size: 1.5em;">${{ '%0.2f' % cart.shop_totals[shop.uuid_str] }}</b>
{% endif %}
</div>
</section>
<br><br>
{% endfor %}
<br/>
<br/>
<center>
<a href="/" class="cart-checkout-button mps-button">Continue shopping</a>
</center>
<br/>
<br/>
{% endif %}
</section>
<section class="cart-right well">
{% if cart.is_discounted %}
<h2>Total: <s>${{ '%0.2f' % cart.total_price }}</s>
<span class="discounted">${{ '%0.2f' % total_discounted_price }}
</h2></span>
{% else %}
<h2>Total: ${{ '%0.2f' % cart.total }}</h2>
{% endif %}
{% if not cart.is_empty %}
<a href="/u/cart/{{ cart.id }}/checkout" class="cart-checkout-button mps-button">Checkout</a>
<br/>
<br/>
<br/>
<hr>
<br/>
<br/>
{% endif %}
{% if request.active_cart == cart and cart.count > 0 %}
<a href="/u/cart/save" class="cart-save-button mps-button">Save Cart</a>
<br/>
{% elif request.user and request.active_cart != cart %}
{% if request.user == cart.user %}
<a href="/u/cart/{{ cart.id }}/activate" class="cart-activate-button mps-button">Make Cart Active</a>
<br/>
{% else %}
<a href="/u/cart/{{ cart.id }}/activate" class="cart-activate-button mps-button">Copy & Make Cart Active</a>
<br/>
{% endif %}
{% endif %}
{% if request.user and request.user.owns_cart(cart) %}
{% if cart.is_not_public and cart.count > 0 %}
<a href="/u/cart/{{ cart.id }}/public" class="cart-public-button mps-button"><span class="lock">&#128275;</span> Make Cart Public</a>
<br/>
{% elif cart.public %}
<a href="/u/cart/{{ cart.id }}/unpublic" class="cart-public-button mps-button"><span class="lock">&#128274;</span> Make Cart Private</a>
<br/>
<br/>
<span style="font-size: .8em;"><a href="{{ request.current_route_url() }}" target="_blank" ref="nofollow">Link to public cart</a></span>
<br/>
<br/>
{% endif %}
{% if request.active_cart != cart %}
<a href="/u/cart/{{ cart.id }}/delete" class="cart-delete-button mps-button">Delete Cart</a>
<br/>
{% endif %}
{% endif %}
</section>
</section>
{%- endblock -%}

View file

@ -0,0 +1,42 @@
{% extends "base.j2" -%}
{%- import 'snippets/stripe.j2' as stripe with context -%}
{% block content -%}
<section class="two-column">
<section class="checkout-left well">
<h2><b>Active Card</b></h2>
{{ stripe.active_card() }}
<br>
{% if request.shop and request.shop.is_ready %}
<a href="/billing" class="product-edit-button mps-button">Use a different card</a>
{% endif %}
</section>
<section class="checkout-right well">
<h2>Cart Total: ${{ '%0.2f' % cart.total }}</h2>
<br/>
Are you sure you want to charge <br/>
<b>${{ '%0.2f' % cart.total }}</b> to your active credit card?
<br/>
<br/>
<br/>
<br/>
<a href="/u/cart/{{ cart.id }}/complete/checkout" class="cart-checkout-button mps-button">Yes, Complete Checkout</a>
</section>
</section>
{%- endblock -%}

View file

@ -0,0 +1,20 @@
{% extends "base.j2" -%}
{% block content -%}
<section class="one-column-thin well">
<h2>Your saved carts:</h2>
{% for cart in request.user.carts %}
<section class="cart">
<a class=""cart-and-count"" href="/cart/{{ cart.id }}">&#128722; Cart ${{ '%0.2f' % cart.total }} ({{ cart.count }} items)</a> {{ cart.human_updated_timestamp }} {% if cart.id == request.user.active_cart_id %}(active){% endif %}
</section>
<br>
{% endfor %}
</section>
{%- endblock -%}

View file

@ -0,0 +1,92 @@
{% extends "base.j2" -%}
{%- block call_to_action -%}
{% if request.user.can_edit_shop(request.shop) %}
<a href="/p/{{ product.id }}/edit" class="product-edit-button mps-button mps-button-small">&#9998; Edit</a>
{% endif %}
{%- endblock call_to_action -%}
{% block content -%}
<section class="two-column">
<section class="product-left">
{% if "thumbnail1" in product.extensions %}
{% if product.extensions["product"] == "mp4" %}
<div>
<a target="_blank" href="{{ request.app["bucket.secure_uploads.get_endpoint"] }}/{{ product.s3_path }}/product">
<br>
</div>
<img src="{{ request.app["bucket.secure_uploads.get_endpoint"] }}/{{ product.s3_path }}/thumbnail1?ts={{ product.updated_timestamp }}" class="product-main" />
<div class="play-button">click &#9654; to play!</div>
</a>
</div>
{% else %}
<img src="{{ request.app["bucket.secure_uploads.get_endpoint"] }}/{{ product.s3_path }}/thumbnail1?ts={{ product.updated_timestamp }}" class="product-main" />
{% endif %}
<br/>
{% endif %}
<h1>{{ product.title }}</h1>
<h3>
uploaded to <a href="{{ product.shop.absolute_about_url(request) }}" rel="nofollow">{{ product.shop.name }}</a>
</h3>
<br/>
<b>Description</b>
<section>{{ product.description_html | safe }}</section>
<br/>
<br/>
{% if product.is_bundle %}
{% if product.products_in_this_bundle %}
<ul>
{% for p in product.products_in_this_bundle %}
<li>
{{p.title}}
</li>
{% endfor %}
</ul>
{% endif %}
{% else %}
<b>File Type</b>
<br/>
{{ product.get_content_type("product") }}
<br/>
{% endif %}
</section>
<section class="product-right">
<div class="well">
{% if product.is_ready %}
{% if signed_get_object_url is not none %}
<a href="{{ signed_get_object_url }}" class="product-download-button mps-button" target="_blank" download>&#11123 Download</a>
{% endif %}
{% else %}
<br>
Not Ready.
{% endif %}
<div>
</section>
</section>
<section class="one-column" style="max-width: 960px;">
</section>
{%- endblock -%}

View file

@ -0,0 +1,59 @@
{% extends "base.j2" -%}
{%- block call_to_action -%}
{%- endblock call_to_action -%}
{% block content -%}
<section class="one-column">
<section class="content-title-and-description well">
<h3>New Content</h3>
<br/>
Creating a new content in the shop named "{{ shop.name }}".
<br/>
<form method="post" action="/p/new" onsubmit="submit.disabled = true; return true;">
<input type="hidden" name="shop_id" value="{{ shop.id }}" />
<br/>
<label for="title_input">Content Title</label>
<input
name = "title"
type = "text"
id = "title_input"
class = "mps-content-title"
value = "{{ title }}"
placeholder = "Content Title"
required />
<br />
<br />
<label for="description_input">Content Description</label>
<textarea
name = "description"
id = "description_input"
class = "mps-content-description"
placeholder = "Content Description"
required />{{ description }}</textarea>
<br />
<br />
<label for="is_bundle_input">Content Bundle?</label>
<input type="checkbox" name="is_bundle" id="is_bundle_input"> If checked, this content will bundle other existing content.<br>(playlist, gallery, album, collection, series, etc.)
<br />
<br />
<input type="hidden" name="is_sellable" id="is_sellable_input" value="off" required />
<input type="submit" name="submit" class="mps-submit" value="Continue" />
</form>
</section>
</section>
{%- endblock -%}

View file

@ -0,0 +1,88 @@
{% extends "base.j2" -%}
{%- block call_to_action -%}
{%- endblock call_to_action -%}
{% block content -%}
<section class="one-column well2">
<section class="coupon two-column">
<h3 class="vertical-text shown-on-desktop" style="margin: 0px;"><br><br>Coupon:<br><br>{{ request.shop.name }}</h3>
<b class="not-shown-on-desktop" style="margin: 0px;">Coupon: {{ request.shop.name }}</b>
<div>
<h1>
{{ coupon.description }}
</h1>
Enter coupon code &#8212;
<center>
<h2>
<span class="highlight">{{ coupon.code }}</span>
</h2>
</center>
<h3>
<span class="avoid-wrap">
For {{ coupon.human_discount }}
{{ coupon.human_minimum_cart }}
</span>
</h3>
<span class="avoid-wrap">
{{ coupon.human_valid_thru }}
</span>
<span class="avoid-wrap">
{{ coupon.human_limit_per_customer }}
</span>
</span>
<br />
<br />
{% if coupon.is_expired %}
<b>This coupon has expired.</b>
{% else %}
<form method="post" action="/coupon/apply" onsubmit="submit.disabled = true; return true;">
<input
name = "cart_id"
type = "hidden"
id = "cart_id_input"
value = "{{ request.active_cart.id }}"
required />
<input
name = "coupon_id"
type = "hidden"
id = "coupon_id_input"
value = "{{ coupon.id }}"
required />
<input type="submit" name="submit" class="mps-button coupon-apply-button" value="&#65291 Apply coupon to active cart" />
</form>
{% endif %}
</div>
</section>
</section>
<br />
<br />
{%- endblock -%}

View file

@ -0,0 +1,171 @@
{% extends "base.j2" -%}
{%- block call_to_action -%}
{%- endblock call_to_action -%}
{% block content -%}
<style>
label {
font-weight: bold;
}
.astrick {
color: red;
font-weight: bold;
}
</style>
<section class="one-column">
<section class="new-coupon well">
<h3>New Coupon</h3>
A new coupon in the shop named "{{ request.shop.name }}".
<br />
<br />
<span class="astrick">*</span> required
<br />
<br />
<form method="post" action="/s/{{ request.shop.id }}/coupon/new" onsubmit="submit.disabled = true; return true;">
<label for="coupon_code_input">Code <span class="astrick">*</span></label>
The coupon code customers type during cart checkout.
<br />
<input
name = "code"
type = "text"
id = "coupon_code_input"
class = "mps-coupon-code"
value = "{{ code }}"
placeholder = "ex: summer1337"
required />
<br />
<br />
<br />
<label for="coupon_name_input">Description <span class="astrick">*</span></label>
This will appear on customer receipts and invoices.
<br />
<input
name = "description"
type = "text"
id = "coupon_description_input"
class = "mps-coupon-description"
value = "{{ description }}"
placeholder = "ex: First purchase discount"
required />
<br />
<br />
<br />
<label for="action_type">Discount action <span class="astrick">*</span></label>
<input
type="radio"
id="dollar"
name="action_type"
value="dollar-off"
{% if action_type == "dollar-off" %}checked="checked"{% endif %} required /> Dollar Off
<br />
<input
type="radio"
id="percent"
name="action_type"
value="percent-off"
{% if action_type == "percent-off" %}checked="checked"{% endif %} /> Percent Off
<br />
<br />
<br />
<label for="action_value">Discount value <span class="astrick">*</span></label>
<input
name = "action_value"
type = "number"
step = "0.01"
id = "action_value_input"
class = "mps-coupon-action-value"
value = "{{ action_value }}"
placeholder = "ex: 3.50"
required />
<br />
<br />
<br />
<label for="max_redemptions">Redemption limits</label>
Optional. The amount of times this code may be used.
<br />
<input
name = "max_redemptions"
type = "number"
id = "max_redemptions_input"
class = "mps-coupon-max-redemptions"
value = "{{ max_redemptions }}"
placeholder = "ex: 100" />
<br />
<br />
Per customer.
<br />
<input
name = "max_redemptions_per_user"
type = "number"
id = "max_redemptions_input"
class = "mps-coupon-max-redemptions-per-user"
value = "{{ max_redemptions_per_user }}"
placeholder = "ex: 1" />
<br />
<br />
<br />
<label for="expiration_date">Expiration date</label>
Optional. Coupon valid thru date.
<br />
<input
name = "expiration_date"
type = "date"
id = "expiration_date_input"
class = "mps-coupon-expiration-date"
value = "{{ expiration_date }}"
/>
<br />
<br />
<br />
<label for="cart_qualifier">Cart Qualifier</label>
Optional. The minimum cart total needed for this coupon to apply.
<br />
<input
name = "cart_qualifier"
type = "number"
step = "1"
id = "coupon_cart_qualifier"
class = "mps-coupon-cart-qualifier"
value = "{{ cart_qualifier }}"
placeholder = "ex: 20"
/>
<br />
<br />
<br />
<input type="submit" name="submit" class="mps-submit" value="Create Coupon" />
<br />
<br />
</section>
</section>
<br />
{%- endblock -%}

View file

@ -0,0 +1,66 @@
{% extends "base.j2" -%}
{% block content -%}
<section class="one-column well">
<h3>Coupons</h3>
Coupons in the shop named "{{ request.shop.name }}".
<br />
<br />
{% if coupons %}
{% for coupon in coupons %}
<a href="coupon/{{ coupon.id }}/{{ coupon.code }}"
</span {% if coupon.is_not_valid %}style="opacity: .7;"{% endif %}>
<b>
{{ coupon.code }}
&#8212;
{{ coupon.description }}
&#8212;
<code>{{ coupon.redemptions.count() }}/{{ coupon.human_max_redemptions | safe }}</code>
</b>
<br />
<span class="avoid-wrap">
{{ coupon.human_discount }}
{{ coupon.human_minimum_cart }}
</span>
<span class="avoid-wrap">
{{ coupon.human_valid_thru }}
</span>
<span class="avoid-wrap">
{{ coupon.human_limit_per_customer }}
</span>
</span>
<br />
<br />
{% endfor %}
<br />
<br />
{% else %}
<b>This shop has no coupons. You may create one.</b>
<br />
<br />
{% endif %}
<a href="coupon/new" class="product-edit-button mps-button">&#65291 New Coupon</a>
</section>
{%- endblock -%}

View file

@ -0,0 +1,66 @@
{% extends "base.j2" -%}
{% block content -%}
{% if not request.shop %}
<section class="one-column well">
{% if request.user and request.user.authenticated %}
{% if request.user.active_shop %}
<b>{{ request.user.active_shop.name }}</b>
<!-- TODO: build the home page dashboard for the currently active shop. -->
{% else %}
You don't have any shops, yet. You should create one.
<br />
<br />
<a href="/s/new" class="shop-new-button mps-button mps-button-small">&#65291; New Shop</a>
{% endif %}
{% else %}
<center>
You should <a href="/join-or-log-in">join</a> or <a href="/join-or-log-in">log in</a> to see this dashboard.
</center>
{% endif %}
</section>
{% else %}
{% if products %}
<section class="serp">
{% for product in products %}
{% if product.is_ready %}
<div class="serp-item">
{% if "thumbnail1" in product.extensions %}
<a href="{{ product.absolute_url(request) }}" rel="nofollow">
<img src="{{ request.app["bucket.secure_uploads.get_endpoint"] }}/{{ product.s3_path }}/thumbnail1?ts={{ product.updated_timestamp }}" class="serp-thumbnail" />
</a>
{% endif %}
<b><a href="{{ product.absolute_url(request) }}" class="shop_theme_link_color">{{ product.title }}</a></b>
{% if product.is_sellable %}
<br>
<a href="{{ product.absolute_url(request) }}" rel="nofollow">${{ '%0.2f' % product.price }}</a>
{% endif %}
<br />
<a href="{{ product.shop.absolute_about_url(request) }}" rel="nofollow">{{ product.shop.name }}</a>
</div>
{% endif %}
{% endfor %}
</section>
{% endif %}
{% endif %}
{%- endblock -%}

View file

@ -0,0 +1,51 @@
{% extends "base.j2" -%}
{% block content -%}
<section class="one-column">
<section class="log-in-form well">
<h2>Log in</h2>
<b>New and Existing Users</b>
<br>
<br>
<form method="post" action="/join-or-log-in" onsubmit="submit.disabled = true; return true;">
<input
name = "email2"
type = "email"
id = "email2_input"
class = "common-text-input"
value = ""
placeholder = "Your Email Address" />
<input
name = "email"
type = "email"
id = "email_input"
class = "common-text-input"
tabindex = "1"
value = ""
placeholder = "Your Email Address"
required
autofocus />
<br/>
<br/>
<input type="submit" name="submit" value="send log in email" />
{% if return_to %}
<input type="hidden" name="return-to" value="{{ return_to }}" >
{% endif %}
</form>
<br/>
<p class="whats-next"><b>What's next?</b> check your email to log in!</p>
</section>
</section>
{%- endblock -%}

View file

@ -0,0 +1,38 @@
{% extends "base.j2" -%}
{%- block call_to_action -%}
{%- endblock call_to_action -%}
{%- block append_to_head_tag_section -%}
{%- include 'snippets/optional-javascript.j2' -%}
{%- endblock append_to_head_tag_section -%}
{% block content -%}
<div class="markup-editor">
<section class="markup-settings">
<form method="post" action="{{ markup_form_path }}/edit" onsubmit="submit.disabled = true; return true;">
<b>{{ markup_subject }}</b>
<input type="submit" name="submit" class="markup-editor-submit" value="Save" />
<br/>
<textarea
name = "markup-editor-textarea"
id = "markup-editor-textarea"
class = "markup-editor-textarea"
onkeyup = "previewAjax( 'markup-editor-textarea', 'markup-preview-div', show_raw=true)"
required>{{ markup_raw }}</textarea>
</form>
</section>
<section class="markup-rendered">
<b>{{ markup_subject }}</b>
<br/>
<br/>
<div id='markup-preview-div' name='preview' class='preview js-only'>{{ markup_rendered | safe }}</div>
</section>
</div>
{%- endblock -%}

View file

@ -0,0 +1,19 @@
{% extends "base.j2" -%}
{%- block call_to_action -%}
{% if request.user.can_edit_shop(request.shop) %}
<a href="/s/{{ request.shop.id }}/{{ markup_path }}/edit" class="product-edit-button mps-button mps-button-small">&#9998; Edit</a>
{% endif %}
{%- endblock call_to_action -%}
{% block content -%}
<div class="markup-rendered">
<section class="markup-rendered">
{{ markup_rendered | safe }}
</section>
</div>
{%- endblock -%}

View file

@ -0,0 +1,115 @@
{% extends "base.j2" -%}
{%- block call_to_action -%}
{% if request.user.can_edit_shop(request.shop) %}
<a href="/p/{{ product.id }}/edit" class="product-edit-button mps-button mps-button-small">&#9998; Edit</a>
{% endif %}
{%- endblock call_to_action -%}
{% block content -%}
<section class="two-column">
<section class="product-left">
<h1>{{ product.title }}</h1>
<h3>
sold by
<a href="{{ product.shop.absolute_about_url(request) }}" rel="nofollow">{{ product.shop.name }}</a>
</h3>
{% if "thumbnail1" in product.extensions %}
<img src="{{ request.app["bucket.secure_uploads.get_endpoint"] }}/{{ product.s3_path }}/thumbnail1?ts={{ product.updated_timestamp }}" class="product-main" />
<br/>
{% endif %}
{% for file_key in product.file_thumbnail_keys %}
{% if file_key in product.s3_key_thumbnails %}
{% set get_endpoint = request.app["bucket.secure_uploads.get_endpoint"] + "/" + product.s3_key_thumbnails[file_key] %}
<a href="{{ get_endpoint }}" target="_blank"><img src="{{ get_endpoint }}?ts={{ product.updated_timestamp }}" class="product-thumbnail" /></a>
{% endif %}
{% endfor %}
</section>
<section class="product-right">
<br>
<br>
<br>
<div class="well">
<h1>${{ '%0.2f' % product.price }}</h1>
{% if product.is_ready %}
{% if signed_get_object_url is not none %}
<a href="{{ signed_get_object_url }}" class="product-download-button mps-button" download>&#11123 Download</a>
<br/>
<br/>
<hr/>
<br/>
{% endif %}
<a href="/cart/add?product_id={{ product.id }}" class="product-download-button mps-button">Add To Cart</a>
<br/>
{% if (not request.user or not request.user.authenticated) and signed_get_object_url is none %}
<br/>
Already purchased? <a href="/join-or-log-in">log in</a>
<br/>
<br/>
<br/>
{% endif %}
{% if "preview" in product.extensions %}
<a href="{{ request.app["bucket.secure_uploads.get_endpoint"] }}/{{ product.s3_path }}/preview" class="product-preview-button mps-button" download>View Preview</a>
<br/>
{% endif %}
{% else %}
<br>
This product is not ready for sale yet.
{% endif %}
<div>
</section>
</section>
<section class="one-column" style="max-width: 960px;">
<br/>
<b>Description</b>
<section>{{ product.description_html | safe }}</section>
<br/>
<br/>
{% if product.is_bundle %}
{% if product.products_in_this_bundle %}
<ul>
{% for p in product.products_in_this_bundle %}
<li>
{{p.title}}
</li>
{% endfor %}
</ul>
{% endif %}
{% else %}
<b>File Type</b>
<br/>
{{ product.get_content_type("product") }}
<br/>
Please make sure you have an application to open this file type before you purchase.
{% endif %}
</section>
{%- endblock -%}

View file

@ -0,0 +1,194 @@
{% extends "base.j2" -%}
{%- block call_to_action -%}
{%- endblock call_to_action -%}
{% block content -%}
<div class="edit-page">
<section class="upload-product-and-preview well2">
{% if product.is_bundle %}
<h3>Product IDs or URLs to include in this bundle</h3>
<form method="post" action="/p/{{ product.id }}/bundle/edit" onsubmit="submit.disabled = true; return true;">
<textarea
name = "bundle-data"
class = "mps-bundle-data"
placeholder = "Product IDs or URLs to include in this bundle."
required /></textarea>
<input type="submit" name="submit" class="mps-submit" value="Add to bundle" />
</form>
<br>
{% if product.products_in_this_bundle %}
<ul>
{% for p in product.products_in_this_bundle %}
<li>
<form method="post" action="/p/{{ product.id }}/bundle/remove/{{ p.id }}" onsubmit="submit.disabled = true; return true;">
{{p.title}}
<input type="submit" name="submit" class="mps-submit" value="remove" />
</form>
</li>
<br>
{% endfor %}
</ul>
{% endif %}
{% else %}
<h3>Upload or Replace Product File</h3>
People may download this file after making a purchase.
<br>
<br>
{% set signed_post = signed_posts["product"] %}
<form method="post" action="{{ signed_post["url"] }}" enctype="multipart/form-data" onsubmit="submit.disabled = true; return true;">
{% for field_name, field_value in signed_post["fields"].items() %}
<input type="hidden" name="{{ field_name }}" value="{{ field_value }}" />
{% endfor %}
<input type="file" name="file" class="file-input" /> <br />
<input type="submit" name="submit" class="mps-upload" value="Upload Product" />
</form>
{% if "product" in product.originals %}<p style="font-weight: bold">Current File: {{ product.originals["product"] }}</p>{% endif %}
{% endif %}
{% if product.is_sellable %}
<br/>
<br/>
<h3>Upload or Replace Preview File</h3>
People may download this file before making a purchase.
<br>
<br>
{% set signed_post = signed_posts["preview"] %}
<form method="post" action="{{ signed_post["url"] }}" enctype="multipart/form-data" onsubmit="submit.disabled = true; return true;">
{% for field_name, field_value in signed_post["fields"].items() %}
<input type="hidden" name="{{ field_name }}" value="{{ field_value }}" />
{% endfor %}
<br/>
<input type="file" name="file" class="file-input" /> <br />
<input type="submit" name="submit" class="mps-upload" value="Upload Preview" />
</form>
{% if "preview" in product.originals %}<p style="font-weight: bold">Current File: {{ product.originals["preview"] }}</p>{% endif %}
{% endif %}
</section>
<section class="upload-thumbnails well2">
<h3>Upload or Replace Thumbnail Files</h3>
<section>
Your product cover (<code>thumbnail1</code>) will show up on search pages.
<br />
<br />
</section>
{% set s3_key_thumbnails = product.s3_key_thumbnails %}
{% for thumbnail_key in product.file_thumbnail_keys %}
<div class="upload-thumbnail-item">
<b>{{ thumbnail_key }}</b>
<br />
{% set signed_post = signed_posts[thumbnail_key] %}
<form method="post" action="{{ signed_post["url"] }}" enctype="multipart/form-data" onsubmit="submit.disabled = true; return true;">
{% for field_name, field_value in signed_post["fields"].items() %}
<input type="hidden" name="{{ field_name }}" value="{{ field_value }}" />
{% endfor %}
<input type="file" name="file" class="file-input" /> <br />
<input type="submit" name="submit" class="mps-upload" value="Upload {{ thumbnail_key }}" />
</form>
<br />
{% if thumbnail_key in s3_key_thumbnails %}
{% set get_endpoint = request.app["bucket.secure_uploads.get_endpoint"] + "/" + s3_key_thumbnails[thumbnail_key] %}
<a href="{{ get_endpoint }}?ts={{ product.updated_timestamp }}"><img src="{{ get_endpoint }}?ts={{ product.updated_timestamp }}" width="120px" /></a>
{% endif %}
</div>
{% endfor %}
</section>
<section class="product-title-and-description well2">
<form method="post" action="/p/{{ product.id }}/edit" onsubmit="submit.disabled = true; return true;">
<h3>Edit Title, Description, or Price</h3>
<label for="title_input">Product Title</label>
<input
name = "title"
type = "text"
id = "title_input"
class = "mps-product-title"
value = "{{ title }}"
placeholder = "Product Title"
required />
<br />
<br />
<label for="description_input">Product Description <a href="/p/{{ product.id }}/description/edit">(advanced editor)</a></label> Supports Markdown and some HTML
<textarea
name = "description"
id = "description_input"
class = "mps-product-description"
placeholder = "Product Description"
required />{{ description }}</textarea>
<br />
<br />
<label for="price_input">Product Price</label>
<input
name = "price"
type = "text"
id = "price_input"
class = "mps-product-price"
value = "{{ '%0.2f' % price }}"
placeholder = "Product Price"
required />
<br />
<br />
<style>
label.visibility_radio {
display: inline;
}
</style>
<fieldset>
<legend>Visibility</legend>
<input type="radio" name="visibility" id="visibility_public" value="1" {% if visibility == 1 %}checked{% endif %} />
<label class="visibility_radio" for="visibility_public">Public</label>
<input type="radio" name="visibility" id="visibility_unlisted" value="2" {% if visibility == 2 %}checked{% endif %} />
<label class="visibility_radio" for="visibility_unlisted">Unlisted</label>
<input type="radio" name="visibility" id="visibility_private" value="0" {% if visibility == 0 %}checked{% endif %} />
<label class="visibility_radio" for="visibility_private">Private</label>
</fieldset>
<br />
<br />
<input type="submit" name="submit" class="mps-submit" value="Save Settings" />
</form>
</section>
</div>
{%- endblock -%}

View file

@ -0,0 +1,72 @@
{% extends "base.j2" -%}
{%- block call_to_action -%}
{%- endblock call_to_action -%}
{% block content -%}
<section class="one-column">
<section class="product-title-and-description well">
<h3>New Product</h3>
<br/>
Creating a new product in the shop named "{{ shop.name }}".
<br/>
<form method="post" action="/p/new" onsubmit="submit.disabled = true; return true;">
<input type="hidden" name="shop_id" value="{{ shop.id }}" />
<br/>
<label for="title_input">Product Title</label>
<input
name = "title"
type = "text"
id = "title_input"
class = "mps-product-title"
value = "{{ title }}"
placeholder = "Product Title"
required />
<br />
<br />
<label for="description_input">Product Description</label>
<textarea
name = "description"
id = "description_input"
class = "mps-product-description"
placeholder = "Product Description"
required />{{ description }}</textarea>
<br />
<br />
<label for="price_input">Product Price</label>
<input
name = "price"
type = "text"
id = "price_input"
class = "mps-product-price"
value = "{{ price }}"
placeholder = "Product Price"
required />
<br />
<br />
<label for="is_bundle_input">Product Bundle?</label>
<input type="checkbox" name="is_bundle" id="is_bundle_input"> If checked, this product will bundle other existing products.
<br>(playlist, gallery, album, collection, series, etc.)
<br />
<br />
<input type="hidden" name="is_sellable" id="is_sellable_input" value="on" required />
<input type="submit" name="submit" class="mps-submit" value="Continue" />
</form>
</section>
</section>
{%- endblock -%}

View file

@ -0,0 +1,36 @@
{% extends "base.j2" -%}
{% block content -%}
{% if the_title %}
<center>
<h1>
{{ the_title }}
</h1>
</center>
{% endif %}
<section class="serp">
{% if products %}
{% for product in products %}
<div class="serp-item">
{% if "thumbnail1" in product.extensions %}
<a href="{{ product.absolute_url(request) }}" rel="nofollow">
<img src="{{ request.app["bucket.secure_uploads.get_endpoint"] }}/{{ product.s3_path }}/thumbnail1?ts={{ product.updated_timestamp }}" class="serp-thumbnail" />
</a>
{% endif %}
<b><a href="{{ product.absolute_url(request) }}" class="shop_theme_link_color">{{ product.title }}</a></b>
{% if product.is_sellable %}
<br />
<a href="{{ product.absolute_url(request) }}" rel="nofollow">${{ '%0.2f' % product.price }}</a>
{% endif %}
<br>
<a href="{{ product.absolute_url(request) }}" rel="nofollow">{{ product.shop.name }}</a>
</div>
{% endfor %}
{% endif %}
</section>
{%- endblock -%}

View file

@ -0,0 +1,30 @@
{% extends "base.j2" -%}
{% block content -%}
<section class="serp">
{% if products %}
{% for product in products %}
{% if product.is_ready %}
<div class="serp-item">
{% if "thumbnail1" in product.extensions %}
<a href="{{ product.absolute_url(request) }}" rel="nofollow">
<img src="{{ request.app["bucket.secure_uploads.get_endpoint"] }}/{{ product.s3_path }}/thumbnail1?ts={{ product.updated_timestamp }}" class="serp-thumbnail" />
</a>
{% endif %}
<b><a href="{{ product.absolute_url(request) }}">{{ product.title }}</a></b>
{% if product.is_sellable %}
<br />
<a href="{{ product.absolute_url(request) }}" rel="nofollow">${{ '%0.2f' % product.price }}</a>
{% endif %}
<br>
<a href="{{ product.absolute_url(request) }}" rel="nofollow">{{ product.shop.name }}</a>
</div>
{% endif %}
{% endfor %}
{% endif %}
</section>
{%- endblock -%}

View file

@ -0,0 +1,44 @@
{% extends "base.j2" -%}
{% block content -%}
<section class="one-column well">
<img class="logo" src="{{ request.app['bucket.secure_uploads.get_endpoint'] }}/{{ request.shop.uuid_str }}/meta/shop-logo-banner" />
<br/>
<br/>
<h1>{{ request.shop.name }}</h1>
<p>
{{ request.shop.description }}
</p>
<!--
{{ request.shop.phone_number }}
<pre>{{ request.shop.billing_address }}</pre>
<p>
<h2>Contact Us!</h2>
<ul>
{% for user in request.shop.users %}
<li>{{ user.email }}</li>
{% endfor %}
</ul>
</p>
-->
<p>
<center>
<a href="/s/{{ request.shop.id }}" class="product-download-button mps-button">View Products</a>
</center>
</p>
</section>
{%- endblock -%}

View file

@ -0,0 +1,68 @@
{% extends "base.j2" -%}
{%- block call_to_action -%}
{%- endblock call_to_action -%}
{% block content -%}
<section class="one-column">
<section class="shop-name-and-description well">
<h3>New Shop</h3>
<form method="post" action="/s/new" onsubmit="submit.disabled = true; return true;">
<label for="name_input">Shop Name</label>
<input
name = "name"
type = "text"
id = "name_input"
class = "mps-shop-name"
value = "{{ name }}"
placeholder = "Shop Name"
required />
<br />
<br />
<label for="phone_number_input">Phone Number</label>
<input
name = "phone_number"
type = "tel"
id = "phone_number_input"
class = "mps-phone-number"
value = "{{ phone_number }}"
placeholder = "Phone Number"
required />
<br />
<br />
<label for="billing_address_input">Billing Address</label>
<textarea
name = "billing_address"
id = "billing_address_input"
class = "mps-billing-address"
placeholder = "Billing Address"
required />{{ billing_address }}</textarea>
<br />
<br />
<label for="description_input">Shop Description</label>
<textarea
name = "description"
id = "description_input"
class = "mps-shop-description"
placeholder = "Shop Description"
required />{{ description }}</textarea>
<br />
<br />
<input type="submit" name="submit" class="mps-submit" value="Create Shop" />
</form>
</section>
</section>
{%- endblock -%}

View file

@ -0,0 +1,33 @@
{% extends "base.j2" -%}
{% block content -%}
<style>
tr { text-align: left;}
</style>
{% if products %}
<table style="width=100%; max-width: 100%; table-layout : fixed;">
<thead>
<tr> <th> Sellable <th> Price <th> Visibility <th> Bundle <th> Ready <th> Title
<tbody style="width=100%; max-width: 100%;">
{% for product in products %}
<tr>
<td>{{ product.is_sellable }}</td>
<td><a href="{{ product.absolute_url(request) }}" rel="nofollow">${{ '%0.2f' % product.price }}</a></td>
<td>{{ product.human_visibility }}</td>
<td>{{ product.is_bundle }}</td>
<td>{{ product.is_ready }}</td>
<td><b><a href="{{ product.absolute_url(request) }}">{{ product.title }}</a></b></td>
</tr>
{% endfor %}
</table>
{% endif %}
</section>
{%- endblock -%}

View file

@ -0,0 +1,211 @@
{% extends "base.j2" -%}
{% block content -%}
<section class="one-column">
<section class="shop-settings well">
<h3>Shop Settings</h3>
<form method="post" action="/s/{{ request.shop.id }}/settings" onsubmit="submit.disabled = true; return true;">
<label for="name_input">Shop Name</label>
<input
name = "name"
type = "text"
id = "name_input"
class = "mps-shop-name"
value = "{{ name }}"
placeholder = "Shop Name"
required />
<br />
<br />
<label for="phone_number_input">Phone Number</label>
<input
name = "phone_number"
type = "tel"
id = "phone_number_input"
class = "mps-phone-number"
value = "{{ phone_number }}"
placeholder = "Phone Number"
required />
<br />
<br />
<label for="billing_address_input">Billing Address</label>
<textarea
name = "billing_address"
id = "billing_address_input"
class = "mps-billing-address"
placeholder = "Billing Address"
required />{{ billing_address }}</textarea>
<br />
<br />
<label for="description_input">Shop Description</label>
<textarea
name = "description"
id = "description_input"
class = "mps-shop-description"
placeholder = "Shop Description"
required />{{ description }}</textarea>
<br />
<br />
<label for="domain_name_input">Custom Shop Domain Name</label>
<input
name = "domain_name"
type = "text"
id = "domain_name_input"
class = "mps-shop-name"
value = "{% if domain_name %}{{ domain_name }}{% endif %}"
placeholder = "Domain Name"
/>
<br />
<br />
<label for="google_analytics_id_input">Google Analytics Tracking Id</label>
<input
name = "google_analytics_id"
type = "text"
id = "google_analytics_id"
class = "mps-google-analytics-id"
value = "{% if google_analytics_id %}{{ google_analytics_id }}{% endif %}"
placeholder = "Google Analytics Id"
/>
<br />
<br />
<label for="stripe_public_api_key_input">Stripe Public API Key</label>
<input
name = "stripe_public_api_key"
type = "text"
id = "stripe_public_api_key"
class = "mps-stripe-public-api-key"
value = "{% if stripe_public_api_key %}{{ stripe_public_api_key }}{% endif %}"
placeholder = "Stripe Public API Key (pk)"
/>
<br />
<br />
<label for="stripe_secret_api_key_input">Stripe Secret API Key</label>
<input
name = "stripe_secret_api_key"
type = "text"
id = "stripe_secret_api_key"
class = "mps-stripe-secret-api-key"
value = "{% if stripe_secret_api_key %}{{ stripe_secret_api_key }}{% endif %}"
placeholder = "Stripe Secret API Key (sk)"
/>
<br />
<br />
<input type="submit" name="submit" class="mps-submit" value="Save Settings" />
<br />
<br />
<br />
<hr />
<br />
<h4>Announcement Ribbon Settings</h4>
<label for="ribbon_text_input">Ribbon Text</label>
<textarea
name = "ribbon_text"
id = "ribbon_text_input"
class = "mps-shop-ribbon-text"
placeholder = "The announcement ribbon is displayed on the top of all shop pages."
/>{{ ribbon_text }}</textarea>
<br />
<br />
<label for="ribbon_text_color_input">Ribbon Text Color</label>
<input
name = "ribbon_text_color"
type = "text"
id = "ribbon_text_color_input"
class = "mps-shop-ribbon-text-color"
value = "{{ ribbon_text_color }}"
placeholder = "#FFFFFF"
/>
<br />
<br />
<label for="ribbon_color_1_input">Ribbon Background Color 1</label>
<input
name = "ribbon_color_1"
type = "text"
id = "ribbon_color_1_input"
class = "mps-shop-ribbon-color"
value = "{{ ribbon_color_1 }}"
placeholder = "#5871AD"
/>
<br />
<br />
<label for="ribbon_color_2_input">Ribbon Background Color 2</label>
<input
name = "ribbon_color_2"
type = "text"
id = "ribbon_color_2_input"
class = "mps-shop-ribbon-color"
value = "{{ ribbon_color_2 }}"
placeholder = "#82A8FF"
/>
<br />
<br />
<input type="submit" name="submit" class="mps-submit" value="Save Settings" />
<br />
<br />
<hr>
<br />
<br />
</form>
<h4>Branding</h4>
<label for="logo-banner">Banner Logo</label>
{% set signed_post = signed_posts["shop-logo-banner"] %}
<form method="post" action="{{ signed_post["url"] }}" enctype="multipart/form-data" onsubmit="submit.disabled = true; return true;">
{% for field_name, field_value in signed_post["fields"].items() %}
<input type="hidden" name="{{ field_name }}" value="{{ field_value }}" />
{% endfor %}
<input type="file" name="file" class="file-input" /> <br />
<input type="submit" name="submit" class="mps-upload" value="Upload Shop Banner" />
</form>
<br />
<br />
<a href="{{ get_endpoints["shop-logo-banner"] }}"><img src="{{ get_endpoints["shop-logo-banner"] }}" width="420px" /></a>
</section>
</section>
{%- endblock -%}

View file

@ -0,0 +1,28 @@
{% extends "base.j2" -%}
{% block content -%}
<section class="one-column well">
{% if request.user.shops %}
Choose and switch shop
<br />
<br />
{% for shop in request.user.shops %}
<a href="{{ request.saas_url }}/u/shop/{{ shop.id }}/activate" class="mps-button shop-switch-button">{{ shop.name }}</a>
<br/>
{% endfor %}
{% else %}
You don't have any shops, yet. You should create one.
{% endif %}
<br />
<br />
<a href="/s/new" class="shop-new-button mps-button">&#65291; New Shop</a>
</section>
{%- endblock -%}

View file

@ -0,0 +1,3 @@
{% if request.csrf_token %}
<input type="hidden" name="csrf_token" value="{{ request.csrf_token }}">
{% endif %}

View file

@ -0,0 +1,16 @@
<noscript>
<style>
.js-only {display: none;}
</style>
</noscript>
<div id="alerts">
{%- if request.session.peek_flash() -%}
{%- for message, level in request.session.pop_flash() %}
<div class="alert alert-{{level}}" onclick="this.style.display='none'" name="alert">
<p class="alert-message">{{message|safe}}</p>
<label class="close js-only" alt="dismiss" title="Mark as read">X</label>
</div>
{%- endfor %}
{%- endif %}
</div>

View file

@ -0,0 +1,11 @@
{% if request.shop and request.shop.google_analytics_id %}
<!-- Global site tag (gtag.js) - Google Analytics -->
<script async src="https://www.googletagmanager.com/gtag/js?id={{ request.shop.google_analytics_id }}"></script>
<script>
window.dataLayer = window.dataLayer || [];
function gtag(){dataLayer.push(arguments);}
gtag('js', new Date());
gtag('config', '{{ request.shop.google_analytics_id }}');
</script>
{% endif %}

View file

@ -0,0 +1,3 @@
<script async src="/static/js/jquery.min.js"></script>
<script async src="/static/js/custom.js"></script>

View file

@ -0,0 +1,30 @@
{% if request.is_saas_domain %}
<div class="message-ribbon">
Sell all the digital things you make—commission free
</div>
{% else %}
<style>
{% set ribbon_text_color = request.shop.ribbon_text_color or "#FFFFFF" %}
{% set ribbon_color_1 = request.shop.ribbon_color_1 or "#5871AD" %}
{% set ribbon_color_2 = request.shop.ribbon_color_2 or "#82A8FF" %}
{% set link_color = request.shop.theme_link_color or "#425481" %}
div.message-ribbon {
color: {{ ribbon_text_color }};
background: linear-gradient(to right, {{ ribbon_color_1 }}, {{ ribbon_color_2 }});
}
a.shop_theme_link_color {
color: {{ link_color }};
}
</style>
{% if request.shop.ribbon_text %}
<div class="message-ribbon">
{{ request.shop.ribbon_text }}
</div>
{% endif %}
{% endif %}

View file

@ -0,0 +1,24 @@
{% if request.shop and not search_disabled %}
<form action="/search" id="search" name="search">
{% include 'csrf.j2' %}
{%- if request.shop -%}
<input type="hidden" name="shop_id" value="{{ request.shop.id }}">
{%- endif %}
<input type="text"
name="keywords"
id="keywords"
class="mps-keywords"
placeholder="Keyword search on {{ request.shop.name }}"
{% if keywords %}value="{{ keywords }}"{% endif %}
required/>
</form>
{% if keywords and not products %}
<br />
<b>No matches.</b>
{% endif %}
{% endif %}

View file

@ -0,0 +1,141 @@
{% macro display_card(card, actions=True, change_card=False) %}
{%
set brand_logos = {
"Visa" : "https://js.stripe.com/v3/fingerprinted/img/visa-d6c6e0a636f7373e06d5fb896ad49475.svg",
"MasterCard" : "https://js.stripe.com/v3/fingerprinted/img/mastercard-a96ee3841a5e1e28d05ed3f0f4da62b8.svg",
"American Express" : "https://js.stripe.com/v3/fingerprinted/img/amex-edf6011de255d8a4c22904795c9d8770.svg",
"Discover" : "https://js.stripe.com/v3/fingerprinted/img/discover-8f3d8fc6ef836da1fcac12c095ee6fb8.svg",
"Diners Club" : "https://js.stripe.com/v3/fingerprinted/img/diners-fced9e136fd8c25f40a3e7b37a51dc1d.svg",
"JCB" : "https://js.stripe.com/v3/fingerprinted/img/jcb-1b12d588a1e9465d4d9fb84a610f9136.svg",
"UnionPay" : "https://js.stripe.com/v3/fingerprinted/img/unionpay-en-099cb6671310a54f640ac16d5f2a825c.svg",
}
%}
<section class="payment-card">
<div class="card-details">
<img src="{{ brand_logos[card.brand] }}" style="width: 92px; float: left; margin-right: 10px; margin-top: 10px;">
<br/>
<b>{{ card.brand }}</b>
<br/>
ending in <b>{{ card.last4 }}</b>
<br/>
<span class="opacity-60">
expiring {{ card.exp_month }}/{{ card.exp_year }}
</span>
</div>
<br/>
<br/>
<div style="margin-top: -12px;">
{% if actions %}
<a href="/billing/confirm-update-card/delete-card/{{ card.id }}"><button type="button">delete</button></a>
<a href="/billing/confirm-update-card/make-card-active/{{ card.id }}" class="button button-small button-white"><button type="button">make active</button></a>
{% endif %}
{% if change_card %}
<a href="/billing"><button type="button">change card</button></a>
{% endif %}
</div>
</section>
{% endmacro %}
{% macro active_card(change_card=False) %}
{% if request.stripe_active_card %}
{{ display_card(request.stripe_active_card, actions=False, change_card=change_card) }}
{% endif %}
{% endmacro %}
{% macro saved_cards() %}
{% if request.stripe_saved_cards|length > 1 %}
<br>
<hr>
<br>
<h3>Saved Cards</h3>
{% for card in request.stripe_saved_cards %}
{% if card != request.stripe_active_card %}
{{ display_card(card) }}
<br/>
{% endif %}
{% endfor %}
<br/>
{% endif %}
{% endmacro %}
{% macro new_card() %}
<script src="/static/js/jquery.min.js"></script>
<script src="https://js.stripe.com/v3/"></script>
<form action="/billing/add-card" method="post" id="payment-form">
{% include 'snippets/csrf.j2' %}
<input type="hidden" name="return-to" value="{{ request.path_url }}">
<div class="form-row">
<div id="card-element">
<!-- A Stripe Element will be inserted here. -->
</div>
<button class="green-button button-max-width" style="margin-bottom: 0px; margin-top: 8px;">&#128274; Save Card</button>
<br>
<br>
<a href="https://stripe.com/docs/security/stripe" target="_blank"><img src="https://stripe.com/img/about/logos/badge/outline-dark.svg" style="margin-top: 8px;"></a>
<!-- Used to display Element errors. -->
<div id="card-errors" role="alert"></div>
</div>
</form>
<script>
function stripeTokenHandler(token) {
// Insert the token ID into the form so it gets submitted to the server
var form = document.getElementById('payment-form');
var hiddenInput = document.createElement('input');
hiddenInput.setAttribute('type', 'hidden');
hiddenInput.setAttribute('name', 'stripeToken');
hiddenInput.setAttribute('value', token.id);
form.appendChild(hiddenInput);
// Submit the form
form.submit();
}
var stripe;
var elements;
//$(document).ready(function() {
$(function () {
stripe = Stripe("{{ request.shop.stripe_public_api_key }}");
elements = stripe.elements();
var card = elements.create('card');
card.mount('#card-element');
card.addEventListener('change', function(event) {
var displayError = document.getElementById('card-errors');
if (event.error) {
displayError.textContent = event.error.message;
} else {
displayError.textContent = '';
}
});
var form = document.getElementById('payment-form');
form.addEventListener('submit', function(event) {
event.preventDefault();
stripe.createToken(card).then(function(result) {
if (result.error) {
// Inform the customer that there was an error.
var errorElement = document.getElementById('card-errors');
errorElement.textContent = result.error.message;
} else {
// Send the token to your server.
stripeTokenHandler(result.token);
}
});
});
});
</script>
{% endmacro %}

View file

@ -0,0 +1,32 @@
{% extends "base.j2" -%}
{%- import 'snippets/stripe.j2' as stripe with context -%}
{% block content -%}
<section class="one-column">
<section class="well">
<h3>Are you sure?</h3>
{{ stripe.display_card(card, actions=False) }}
<br/>
<div class="card-are-you-sure-buttons">
<form action="/billing/update-card" method="post">
{% include 'snippets/csrf.j2' %}
<input type="hidden" name="action" value="{{ action }}">
<input type="hidden" name="card_id" value="{{ card_id }}">
<a href="/billing"><button type="button">Cancel</button></a>
<button type="submit" class="{{ button_class }}" style="font-weight: bold;">Yes, {{ action_human }}</button>
</form>
</div>
<br>
</section>
</section>
{%- endblock -%}

View file

@ -0,0 +1,64 @@
{% extends "base.j2" -%}
{% block call_to_action %}
<a href="/log-out" class="log-out-button mps-button mps-button-small">log out</a>
{% endblock call_to_action %}
{% block content -%}
<section class="two-column">
<section class="user-settings well">
<h3>User Settings</h3>
<form method="post" action="/u/settings" onsubmit="submit.disabled = true; return true;">
<label for="name_input">Display Name (public)</label>
<input
name = "name"
type = "text"
id = "name_input"
class = "mps-user-name"
value = "{{ name }}"
placeholder = "Display Name"
required />
<br />
<br />
<label for="name_input">Full Name (private, used for billing)</label>
<input
name = "full_name"
type = "text"
id = "full_name_input"
class = "mps-full-name"
value = "{% if full_name %}{{ full_name }}{% endif %}"
placeholder = "Full Name"
required />
<br />
<br />
<input type="submit" name="submit" class="mps-submit" value="Save Settings" />
<br />
<br />
</form>
</section>
<section class="button-panel">
{% if request.is_saas_domain %}
<a href="/u/shops" class="product-edit-button mps-button">My Shops</a>
{% endif %}
{% if request.shop and request.shop.is_ready %}
<a href="/u/purchases" class="product-download-button mps-button">My Purchases</a>
<br/><br/>
<a href="/billing" class="product-edit-button mps-button">Payment Preferences</a>
{% endif %}
</section>
</section>
{%- endblock -%}

View file

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