From c211df66a84a9870ed66eebf697ee23df6a60d73 Mon Sep 17 00:00:00 2001 From: Russell Ballestrini Date: Fri, 31 Dec 2021 21:39:40 -0500 Subject: [PATCH] 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 --- .gitignore | 27 + MANIFEST.in | 2 + Makefile | 33 + README.rst | 100 +++ development.ini | 108 +++ journal.rst | 248 ++++++ make_post_sell/__init__.py | 42 + make_post_sell/config_jinja2.py | 5 + make_post_sell/lib/__init__.py | 0 make_post_sell/lib/mail.py | 142 ++++ make_post_sell/lib/mail_messages.py | 158 ++++ make_post_sell/lib/render.py | 29 + make_post_sell/models/__init__.py | 91 +++ make_post_sell/models/cart.py | 358 +++++++++ make_post_sell/models/cart_coupon.py | 33 + make_post_sell/models/coupon.py | 218 ++++++ make_post_sell/models/coupon_redemption.py | 34 + make_post_sell/models/invoice.py | 187 +++++ make_post_sell/models/market.py | 11 + make_post_sell/models/meta.py | 194 +++++ make_post_sell/models/price.py | 34 + make_post_sell/models/product.py | 492 ++++++++++++ make_post_sell/models/shop.py | 315 ++++++++ make_post_sell/models/shop_search_request.py | 35 + make_post_sell/models/stripe_user_shop.py | 48 ++ make_post_sell/models/user.py | 268 +++++++ make_post_sell/models/user_product.py | 35 + make_post_sell/models/user_shop.py | 35 + make_post_sell/request_methods.py | 219 ++++++ make_post_sell/routes.py | 101 +++ make_post_sell/scripts/__init__.py | 1 + make_post_sell/scripts/alembic/README | 5 + make_post_sell/scripts/alembic/env.py | 77 ++ make_post_sell/scripts/alembic/script.py.mako | 26 + ...add_active_cart_total_in_cents_to_user_.py | 28 + ...5f_create_new_active_shop_id_column_on_.py | 26 + .../3e1e70bfe89d_google_analytics_id.py | 24 + .../4d9da43b0e86_product_visibility_column.py | 27 + .../5c40dfb56179_shop_ribbon_columns.py | 28 + ...97f2f913f_add_updated_timestamp_to_cart.py | 28 + .../96f851cbab01_terms_and_privacy.py | 30 + .../ab7338eaedf1_add_price_in_cents_column.py | 24 + .../versions/af28cc35ed6f_product_bundles.py | 34 + ...255_add_active_cart_count_to_user_table.py | 28 + ...fc4195a7f56_product_is_sellable_coluumn.py | 26 + ...d_coupon_stackable_and_couponredemption.py | 42 + .../e12bbe39d87e_cart_coupon_table.py | 40 + .../eadd0d69e133_shop_ribbon_text_color.py | 24 + .../versions/f3aa57777025_coupon_table.py | 45 ++ ...dd_stripe_secret_and_public_key_to_shop.py | 27 + .../versions/fd9f7e2f2b78_user_full_name.py | 28 + make_post_sell/scripts/initialize_db.py | 52 ++ make_post_sell/scripts/pshell.py | 13 + make_post_sell/static/css/alerts.css | 66 ++ make_post_sell/static/css/common.css | 738 ++++++++++++++++++ make_post_sell/static/css/lib.css | 25 + make_post_sell/static/css/mps.css | 13 + make_post_sell/static/favicon.ico | Bin 0 -> 9662 bytes make_post_sell/static/js/custom.js | 30 + make_post_sell/static/js/jquery-3.4.1.min.js | 2 + make_post_sell/static/js/jquery.min.js | 1 + make_post_sell/static/makepostsell-3.png | Bin 0 -> 121801 bytes make_post_sell/static/makepostsell.png | Bin 0 -> 55344 bytes make_post_sell/static/mps.png | Bin 0 -> 27936 bytes make_post_sell/templates/actions_new.j2 | 30 + make_post_sell/templates/actions_view.j2 | 35 + make_post_sell/templates/base.j2 | 123 +++ make_post_sell/templates/billing.j2 | 49 ++ make_post_sell/templates/cart.j2 | 234 ++++++ make_post_sell/templates/cart_checkout.j2 | 42 + make_post_sell/templates/carts.j2 | 20 + make_post_sell/templates/content.j2 | 92 +++ make_post_sell/templates/content_new.j2 | 59 ++ make_post_sell/templates/coupon.j2 | 88 +++ make_post_sell/templates/coupon_new.j2 | 171 ++++ make_post_sell/templates/coupons.j2 | 66 ++ make_post_sell/templates/home.j2 | 66 ++ make_post_sell/templates/join-or-log-in.j2 | 51 ++ make_post_sell/templates/markup_editor.j2 | 38 + make_post_sell/templates/markup_renderer.j2 | 19 + make_post_sell/templates/product.j2 | 115 +++ make_post_sell/templates/product_edit.j2 | 194 +++++ make_post_sell/templates/product_new.j2 | 72 ++ make_post_sell/templates/products.j2 | 36 + make_post_sell/templates/shop.j2 | 30 + make_post_sell/templates/shop_about.j2 | 44 ++ make_post_sell/templates/shop_new.j2 | 68 ++ make_post_sell/templates/shop_products.j2 | 33 + make_post_sell/templates/shop_settings.j2 | 211 +++++ make_post_sell/templates/shops.j2 | 28 + make_post_sell/templates/snippets/csrf.j2 | 3 + .../templates/snippets/flash-alerts.j2 | 16 + .../templates/snippets/google-analytics.j2 | 11 + .../templates/snippets/optional-javascript.j2 | 3 + make_post_sell/templates/snippets/ribbon.j2 | 30 + make_post_sell/templates/snippets/search.j2 | 24 + make_post_sell/templates/snippets/stripe.j2 | 141 ++++ make_post_sell/templates/update-card.j2 | 32 + make_post_sell/templates/user_settings.j2 | 64 ++ make_post_sell/tests/__init__.py | 0 make_post_sell/tests/test_functional.py | 635 +++++++++++++++ make_post_sell/tests/test_models.py | 159 ++++ make_post_sell/views/__init__.py | 84 ++ make_post_sell/views/actions.py | 15 + make_post_sell/views/authentication.py | 118 +++ make_post_sell/views/billing.py | 76 ++ make_post_sell/views/cart.py | 467 +++++++++++ make_post_sell/views/content.py | 75 ++ make_post_sell/views/coupon.py | 162 ++++ make_post_sell/views/misc.py | 56 ++ make_post_sell/views/product.py | 383 +++++++++ make_post_sell/views/shop.py | 494 ++++++++++++ make_post_sell/views/user.py | 57 ++ pytest.ini | 3 + requirements-dev.txt | 6 + requirements-prod.txt | 5 + requirements-test.txt | 7 + requirements.py3.txt | 55 ++ requirements.txt | 59 ++ setup.py | 56 ++ test.ini | 72 ++ 121 files changed, 10542 insertions(+) create mode 100644 .gitignore create mode 100644 MANIFEST.in create mode 100644 Makefile create mode 100644 README.rst create mode 100644 development.ini create mode 100644 journal.rst create mode 100644 make_post_sell/__init__.py create mode 100644 make_post_sell/config_jinja2.py create mode 100644 make_post_sell/lib/__init__.py create mode 100644 make_post_sell/lib/mail.py create mode 100644 make_post_sell/lib/mail_messages.py create mode 100644 make_post_sell/lib/render.py create mode 100644 make_post_sell/models/__init__.py create mode 100644 make_post_sell/models/cart.py create mode 100644 make_post_sell/models/cart_coupon.py create mode 100644 make_post_sell/models/coupon.py create mode 100644 make_post_sell/models/coupon_redemption.py create mode 100644 make_post_sell/models/invoice.py create mode 100644 make_post_sell/models/market.py create mode 100644 make_post_sell/models/meta.py create mode 100644 make_post_sell/models/price.py create mode 100644 make_post_sell/models/product.py create mode 100644 make_post_sell/models/shop.py create mode 100644 make_post_sell/models/shop_search_request.py create mode 100644 make_post_sell/models/stripe_user_shop.py create mode 100644 make_post_sell/models/user.py create mode 100644 make_post_sell/models/user_product.py create mode 100644 make_post_sell/models/user_shop.py create mode 100644 make_post_sell/request_methods.py create mode 100644 make_post_sell/routes.py create mode 100644 make_post_sell/scripts/__init__.py create mode 100644 make_post_sell/scripts/alembic/README create mode 100644 make_post_sell/scripts/alembic/env.py create mode 100644 make_post_sell/scripts/alembic/script.py.mako create mode 100644 make_post_sell/scripts/alembic/versions/15751f5d7a03_add_active_cart_total_in_cents_to_user_.py create mode 100644 make_post_sell/scripts/alembic/versions/24662905655f_create_new_active_shop_id_column_on_.py create mode 100644 make_post_sell/scripts/alembic/versions/3e1e70bfe89d_google_analytics_id.py create mode 100644 make_post_sell/scripts/alembic/versions/4d9da43b0e86_product_visibility_column.py create mode 100644 make_post_sell/scripts/alembic/versions/5c40dfb56179_shop_ribbon_columns.py create mode 100644 make_post_sell/scripts/alembic/versions/6da97f2f913f_add_updated_timestamp_to_cart.py create mode 100644 make_post_sell/scripts/alembic/versions/96f851cbab01_terms_and_privacy.py create mode 100644 make_post_sell/scripts/alembic/versions/ab7338eaedf1_add_price_in_cents_column.py create mode 100644 make_post_sell/scripts/alembic/versions/af28cc35ed6f_product_bundles.py create mode 100644 make_post_sell/scripts/alembic/versions/b0d5604d9255_add_active_cart_count_to_user_table.py create mode 100644 make_post_sell/scripts/alembic/versions/bfc4195a7f56_product_is_sellable_coluumn.py create mode 100644 make_post_sell/scripts/alembic/versions/c4d5566ec87d_coupon_stackable_and_couponredemption.py create mode 100644 make_post_sell/scripts/alembic/versions/e12bbe39d87e_cart_coupon_table.py create mode 100644 make_post_sell/scripts/alembic/versions/eadd0d69e133_shop_ribbon_text_color.py create mode 100644 make_post_sell/scripts/alembic/versions/f3aa57777025_coupon_table.py create mode 100644 make_post_sell/scripts/alembic/versions/f882f2255cd1_add_stripe_secret_and_public_key_to_shop.py create mode 100644 make_post_sell/scripts/alembic/versions/fd9f7e2f2b78_user_full_name.py create mode 100644 make_post_sell/scripts/initialize_db.py create mode 100644 make_post_sell/scripts/pshell.py create mode 100644 make_post_sell/static/css/alerts.css create mode 100644 make_post_sell/static/css/common.css create mode 100644 make_post_sell/static/css/lib.css create mode 100644 make_post_sell/static/css/mps.css create mode 100644 make_post_sell/static/favicon.ico create mode 100644 make_post_sell/static/js/custom.js create mode 100644 make_post_sell/static/js/jquery-3.4.1.min.js create mode 120000 make_post_sell/static/js/jquery.min.js create mode 100644 make_post_sell/static/makepostsell-3.png create mode 100644 make_post_sell/static/makepostsell.png create mode 100644 make_post_sell/static/mps.png create mode 100644 make_post_sell/templates/actions_new.j2 create mode 100644 make_post_sell/templates/actions_view.j2 create mode 100644 make_post_sell/templates/base.j2 create mode 100644 make_post_sell/templates/billing.j2 create mode 100644 make_post_sell/templates/cart.j2 create mode 100644 make_post_sell/templates/cart_checkout.j2 create mode 100644 make_post_sell/templates/carts.j2 create mode 100644 make_post_sell/templates/content.j2 create mode 100644 make_post_sell/templates/content_new.j2 create mode 100644 make_post_sell/templates/coupon.j2 create mode 100644 make_post_sell/templates/coupon_new.j2 create mode 100644 make_post_sell/templates/coupons.j2 create mode 100644 make_post_sell/templates/home.j2 create mode 100644 make_post_sell/templates/join-or-log-in.j2 create mode 100644 make_post_sell/templates/markup_editor.j2 create mode 100644 make_post_sell/templates/markup_renderer.j2 create mode 100644 make_post_sell/templates/product.j2 create mode 100644 make_post_sell/templates/product_edit.j2 create mode 100644 make_post_sell/templates/product_new.j2 create mode 100644 make_post_sell/templates/products.j2 create mode 100644 make_post_sell/templates/shop.j2 create mode 100644 make_post_sell/templates/shop_about.j2 create mode 100644 make_post_sell/templates/shop_new.j2 create mode 100644 make_post_sell/templates/shop_products.j2 create mode 100644 make_post_sell/templates/shop_settings.j2 create mode 100644 make_post_sell/templates/shops.j2 create mode 100644 make_post_sell/templates/snippets/csrf.j2 create mode 100644 make_post_sell/templates/snippets/flash-alerts.j2 create mode 100644 make_post_sell/templates/snippets/google-analytics.j2 create mode 100644 make_post_sell/templates/snippets/optional-javascript.j2 create mode 100644 make_post_sell/templates/snippets/ribbon.j2 create mode 100644 make_post_sell/templates/snippets/search.j2 create mode 100644 make_post_sell/templates/snippets/stripe.j2 create mode 100644 make_post_sell/templates/update-card.j2 create mode 100644 make_post_sell/templates/user_settings.j2 create mode 100644 make_post_sell/tests/__init__.py create mode 100644 make_post_sell/tests/test_functional.py create mode 100644 make_post_sell/tests/test_models.py create mode 100644 make_post_sell/views/__init__.py create mode 100644 make_post_sell/views/actions.py create mode 100644 make_post_sell/views/authentication.py create mode 100644 make_post_sell/views/billing.py create mode 100644 make_post_sell/views/cart.py create mode 100644 make_post_sell/views/content.py create mode 100644 make_post_sell/views/coupon.py create mode 100644 make_post_sell/views/misc.py create mode 100644 make_post_sell/views/product.py create mode 100644 make_post_sell/views/shop.py create mode 100644 make_post_sell/views/user.py create mode 100644 pytest.ini create mode 100644 requirements-dev.txt create mode 100644 requirements-prod.txt create mode 100644 requirements-test.txt create mode 100644 requirements.py3.txt create mode 100644 requirements.txt create mode 100644 setup.py create mode 100644 test.ini diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..c7b4dfd --- /dev/null +++ b/.gitignore @@ -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 diff --git a/MANIFEST.in b/MANIFEST.in new file mode 100644 index 0000000..3575645 --- /dev/null +++ b/MANIFEST.in @@ -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 diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..e1050ed --- /dev/null +++ b/Makefile @@ -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 diff --git a/README.rst b/README.rst new file mode 100644 index 0000000..fe407be --- /dev/null +++ b/README.rst @@ -0,0 +1,100 @@ +Make Post Sell +============== + +The `Make Post Sell `_ 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 diff --git a/development.ini b/development.ini new file mode 100644 index 0000000..8d53486 --- /dev/null +++ b/development.ini @@ -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 diff --git a/journal.rst b/journal.rst new file mode 100644 index 0000000..0a18059 --- /dev/null +++ b/journal.rst @@ -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 + vmadm start + +* 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 + 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". diff --git a/make_post_sell/__init__.py b/make_post_sell/__init__.py new file mode 100644 index 0000000..1375482 --- /dev/null +++ b/make_post_sell/__init__.py @@ -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() diff --git a/make_post_sell/config_jinja2.py b/make_post_sell/config_jinja2.py new file mode 100644 index 0000000..b26afa5 --- /dev/null +++ b/make_post_sell/config_jinja2.py @@ -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) diff --git a/make_post_sell/lib/__init__.py b/make_post_sell/lib/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/make_post_sell/lib/mail.py b/make_post_sell/lib/mail.py new file mode 100644 index 0000000..891a9ff --- /dev/null +++ b/make_post_sell/lib/mail.py @@ -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 = ''.format( + request.app["bucket.secure_uploads.get_endpoint"], + p.s3_path, + p.updated_timestamp, + ) + + product_text_list.append( + '

{}
{}

'.format( + p.absolute_url(request), p.title, thumbnail + ) + ) + product_text = "
".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 = ''.format( + request.app["bucket.secure_uploads.get_endpoint"], + p.s3_path, + p.updated_timestamp, + ) + + product_text_list.append( + '

{}
{}

'.format( + p.absolute_url(request), p.title, thumbnail + ) + ) + product_text = "
".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) diff --git a/make_post_sell/lib/mail_messages.py b/make_post_sell/lib/mail_messages.py new file mode 100644 index 0000000..69fd649 --- /dev/null +++ b/make_post_sell/lib/mail_messages.py @@ -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 = """ + + + +{0} + + +

Hey there!

+ +

+ Here is the log in link that you requested: +

+ +

+ Click here to log in! +

+ +

+ + You may paste this link into your browser to log in: + +
+
+ + {1} + +

+ + + +""" + +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 = """ + + + +{0} + + + +

+ You made a purchase! +

+ + {2} + + ${3} + +

+ You may download your purchases at any time. +

+ +

+

+ +

+ + You may paste this link into your browser to view or download your purchases: + +
+
+ + {1}/u/purchases + +

+ +

+ Thank you very much! +

+ + + +""" + +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 = """ + + + +{0} + + + +

+ You made a sale! +

+ + ${4} + + {3} + +

+ You may view all your sales at any time. +

+ +

+ + You may paste this link into your browser to view your sales: + +
+
+ + {2}/u/purchases + +

+ +

+ Great job! +

+ + + +""" diff --git a/make_post_sell/lib/render.py b/make_post_sell/lib/render.py new file mode 100644 index 0000000..a44ea31 --- /dev/null +++ b/make_post_sell/lib/render.py @@ -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) diff --git a/make_post_sell/models/__init__.py b/make_post_sell/models/__init__.py new file mode 100644 index 0000000..5de95c3 --- /dev/null +++ b/make_post_sell/models/__init__.py @@ -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, + ) diff --git a/make_post_sell/models/cart.py b/make_post_sell/models/cart.py new file mode 100644 index 0000000..4b7b254 --- /dev/null +++ b/make_post_sell/models/cart.py @@ -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) diff --git a/make_post_sell/models/cart_coupon.py b/make_post_sell/models/cart_coupon.py new file mode 100644 index 0000000..0055a98 --- /dev/null +++ b/make_post_sell/models/cart_coupon.py @@ -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() diff --git a/make_post_sell/models/coupon.py b/make_post_sell/models/coupon.py new file mode 100644 index 0000000..bf6ea96 --- /dev/null +++ b/make_post_sell/models/coupon.py @@ -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 "∞" + + @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() diff --git a/make_post_sell/models/coupon_redemption.py b/make_post_sell/models/coupon_redemption.py new file mode 100644 index 0000000..8f2ae43 --- /dev/null +++ b/make_post_sell/models/coupon_redemption.py @@ -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() diff --git a/make_post_sell/models/invoice.py b/make_post_sell/models/invoice.py new file mode 100644 index 0000000..4cdf9d6 --- /dev/null +++ b/make_post_sell/models/invoice.py @@ -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, + ) + ) diff --git a/make_post_sell/models/market.py b/make_post_sell/models/market.py new file mode 100644 index 0000000..03278cd --- /dev/null +++ b/make_post_sell/models/market.py @@ -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) diff --git a/make_post_sell/models/meta.py b/make_post_sell/models/meta.py new file mode 100644 index 0000000..23b93d0 --- /dev/null +++ b/make_post_sell/models/meta.py @@ -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__() diff --git a/make_post_sell/models/price.py b/make_post_sell/models/price.py new file mode 100644 index 0000000..7344660 --- /dev/null +++ b/make_post_sell/models/price.py @@ -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) diff --git a/make_post_sell/models/product.py b/make_post_sell/models/product.py new file mode 100644 index 0000000..883b894 --- /dev/null +++ b/make_post_sell/models/product.py @@ -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] diff --git a/make_post_sell/models/shop.py b/make_post_sell/models/shop.py new file mode 100644 index 0000000..8fbda50 --- /dev/null +++ b/make_post_sell/models/shop.py @@ -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/ + """ + 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//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//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) + diff --git a/make_post_sell/models/shop_search_request.py b/make_post_sell/models/shop_search_request.py new file mode 100644 index 0000000..86ed638 --- /dev/null +++ b/make_post_sell/models/shop_search_request.py @@ -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) diff --git a/make_post_sell/models/stripe_user_shop.py b/make_post_sell/models/stripe_user_shop.py new file mode 100644 index 0000000..e73c6a6 --- /dev/null +++ b/make_post_sell/models/stripe_user_shop.py @@ -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() + ] diff --git a/make_post_sell/models/user.py b/make_post_sell/models/user.py new file mode 100644 index 0000000..0638d7c --- /dev/null +++ b/make_post_sell/models/user.py @@ -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 diff --git a/make_post_sell/models/user_product.py b/make_post_sell/models/user_product.py new file mode 100644 index 0000000..3389fde --- /dev/null +++ b/make_post_sell/models/user_product.py @@ -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() diff --git a/make_post_sell/models/user_shop.py b/make_post_sell/models/user_shop.py new file mode 100644 index 0000000..e7aeaab --- /dev/null +++ b/make_post_sell/models/user_shop.py @@ -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() diff --git a/make_post_sell/request_methods.py b/make_post_sell/request_methods.py new file mode 100644 index 0000000..dd6c2ed --- /dev/null +++ b/make_post_sell/request_methods.py @@ -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 + ) diff --git a/make_post_sell/routes.py b/make_post_sell/routes.py new file mode 100644 index 0000000..24add89 --- /dev/null +++ b/make_post_sell/routes.py @@ -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:.*}") diff --git a/make_post_sell/scripts/__init__.py b/make_post_sell/scripts/__init__.py new file mode 100644 index 0000000..5bb534f --- /dev/null +++ b/make_post_sell/scripts/__init__.py @@ -0,0 +1 @@ +# package diff --git a/make_post_sell/scripts/alembic/README b/make_post_sell/scripts/alembic/README new file mode 100644 index 0000000..aedc862 --- /dev/null +++ b/make_post_sell/scripts/alembic/README @@ -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 diff --git a/make_post_sell/scripts/alembic/env.py b/make_post_sell/scripts/alembic/env.py new file mode 100644 index 0000000..d34a6fb --- /dev/null +++ b/make_post_sell/scripts/alembic/env.py @@ -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() diff --git a/make_post_sell/scripts/alembic/script.py.mako b/make_post_sell/scripts/alembic/script.py.mako new file mode 100644 index 0000000..a78d4f2 --- /dev/null +++ b/make_post_sell/scripts/alembic/script.py.mako @@ -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"} diff --git a/make_post_sell/scripts/alembic/versions/15751f5d7a03_add_active_cart_total_in_cents_to_user_.py b/make_post_sell/scripts/alembic/versions/15751f5d7a03_add_active_cart_total_in_cents_to_user_.py new file mode 100644 index 0000000..2bec7c3 --- /dev/null +++ b/make_post_sell/scripts/alembic/versions/15751f5d7a03_add_active_cart_total_in_cents_to_user_.py @@ -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 ### diff --git a/make_post_sell/scripts/alembic/versions/24662905655f_create_new_active_shop_id_column_on_.py b/make_post_sell/scripts/alembic/versions/24662905655f_create_new_active_shop_id_column_on_.py new file mode 100644 index 0000000..2dcde1c --- /dev/null +++ b/make_post_sell/scripts/alembic/versions/24662905655f_create_new_active_shop_id_column_on_.py @@ -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') diff --git a/make_post_sell/scripts/alembic/versions/3e1e70bfe89d_google_analytics_id.py b/make_post_sell/scripts/alembic/versions/3e1e70bfe89d_google_analytics_id.py new file mode 100644 index 0000000..8cda36c --- /dev/null +++ b/make_post_sell/scripts/alembic/versions/3e1e70bfe89d_google_analytics_id.py @@ -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') diff --git a/make_post_sell/scripts/alembic/versions/4d9da43b0e86_product_visibility_column.py b/make_post_sell/scripts/alembic/versions/4d9da43b0e86_product_visibility_column.py new file mode 100644 index 0000000..4bbef0b --- /dev/null +++ b/make_post_sell/scripts/alembic/versions/4d9da43b0e86_product_visibility_column.py @@ -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') diff --git a/make_post_sell/scripts/alembic/versions/5c40dfb56179_shop_ribbon_columns.py b/make_post_sell/scripts/alembic/versions/5c40dfb56179_shop_ribbon_columns.py new file mode 100644 index 0000000..1154c48 --- /dev/null +++ b/make_post_sell/scripts/alembic/versions/5c40dfb56179_shop_ribbon_columns.py @@ -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') diff --git a/make_post_sell/scripts/alembic/versions/6da97f2f913f_add_updated_timestamp_to_cart.py b/make_post_sell/scripts/alembic/versions/6da97f2f913f_add_updated_timestamp_to_cart.py new file mode 100644 index 0000000..c3d0cfe --- /dev/null +++ b/make_post_sell/scripts/alembic/versions/6da97f2f913f_add_updated_timestamp_to_cart.py @@ -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 ### diff --git a/make_post_sell/scripts/alembic/versions/96f851cbab01_terms_and_privacy.py b/make_post_sell/scripts/alembic/versions/96f851cbab01_terms_and_privacy.py new file mode 100644 index 0000000..6cfc4a4 --- /dev/null +++ b/make_post_sell/scripts/alembic/versions/96f851cbab01_terms_and_privacy.py @@ -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') diff --git a/make_post_sell/scripts/alembic/versions/ab7338eaedf1_add_price_in_cents_column.py b/make_post_sell/scripts/alembic/versions/ab7338eaedf1_add_price_in_cents_column.py new file mode 100644 index 0000000..f903eb9 --- /dev/null +++ b/make_post_sell/scripts/alembic/versions/ab7338eaedf1_add_price_in_cents_column.py @@ -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 diff --git a/make_post_sell/scripts/alembic/versions/af28cc35ed6f_product_bundles.py b/make_post_sell/scripts/alembic/versions/af28cc35ed6f_product_bundles.py new file mode 100644 index 0000000..3a79d41 --- /dev/null +++ b/make_post_sell/scripts/alembic/versions/af28cc35ed6f_product_bundles.py @@ -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 ### diff --git a/make_post_sell/scripts/alembic/versions/b0d5604d9255_add_active_cart_count_to_user_table.py b/make_post_sell/scripts/alembic/versions/b0d5604d9255_add_active_cart_count_to_user_table.py new file mode 100644 index 0000000..5e3b12d --- /dev/null +++ b/make_post_sell/scripts/alembic/versions/b0d5604d9255_add_active_cart_count_to_user_table.py @@ -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 ### diff --git a/make_post_sell/scripts/alembic/versions/bfc4195a7f56_product_is_sellable_coluumn.py b/make_post_sell/scripts/alembic/versions/bfc4195a7f56_product_is_sellable_coluumn.py new file mode 100644 index 0000000..e19982e --- /dev/null +++ b/make_post_sell/scripts/alembic/versions/bfc4195a7f56_product_is_sellable_coluumn.py @@ -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') diff --git a/make_post_sell/scripts/alembic/versions/c4d5566ec87d_coupon_stackable_and_couponredemption.py b/make_post_sell/scripts/alembic/versions/c4d5566ec87d_coupon_stackable_and_couponredemption.py new file mode 100644 index 0000000..0a123e4 --- /dev/null +++ b/make_post_sell/scripts/alembic/versions/c4d5566ec87d_coupon_stackable_and_couponredemption.py @@ -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') diff --git a/make_post_sell/scripts/alembic/versions/e12bbe39d87e_cart_coupon_table.py b/make_post_sell/scripts/alembic/versions/e12bbe39d87e_cart_coupon_table.py new file mode 100644 index 0000000..5ab9631 --- /dev/null +++ b/make_post_sell/scripts/alembic/versions/e12bbe39d87e_cart_coupon_table.py @@ -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 ### diff --git a/make_post_sell/scripts/alembic/versions/eadd0d69e133_shop_ribbon_text_color.py b/make_post_sell/scripts/alembic/versions/eadd0d69e133_shop_ribbon_text_color.py new file mode 100644 index 0000000..944f136 --- /dev/null +++ b/make_post_sell/scripts/alembic/versions/eadd0d69e133_shop_ribbon_text_color.py @@ -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') diff --git a/make_post_sell/scripts/alembic/versions/f3aa57777025_coupon_table.py b/make_post_sell/scripts/alembic/versions/f3aa57777025_coupon_table.py new file mode 100644 index 0000000..d73da4c --- /dev/null +++ b/make_post_sell/scripts/alembic/versions/f3aa57777025_coupon_table.py @@ -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') diff --git a/make_post_sell/scripts/alembic/versions/f882f2255cd1_add_stripe_secret_and_public_key_to_shop.py b/make_post_sell/scripts/alembic/versions/f882f2255cd1_add_stripe_secret_and_public_key_to_shop.py new file mode 100644 index 0000000..0a52ecc --- /dev/null +++ b/make_post_sell/scripts/alembic/versions/f882f2255cd1_add_stripe_secret_and_public_key_to_shop.py @@ -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 diff --git a/make_post_sell/scripts/alembic/versions/fd9f7e2f2b78_user_full_name.py b/make_post_sell/scripts/alembic/versions/fd9f7e2f2b78_user_full_name.py new file mode 100644 index 0000000..76a7815 --- /dev/null +++ b/make_post_sell/scripts/alembic/versions/fd9f7e2f2b78_user_full_name.py @@ -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 ### diff --git a/make_post_sell/scripts/initialize_db.py b/make_post_sell/scripts/initialize_db.py new file mode 100644 index 0000000..1bc3a90 --- /dev/null +++ b/make_post_sell/scripts/initialize_db.py @@ -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. + ''') diff --git a/make_post_sell/scripts/pshell.py b/make_post_sell/scripts/pshell.py new file mode 100644 index 0000000..0f432ab --- /dev/null +++ b/make_post_sell/scripts/pshell.py @@ -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 diff --git a/make_post_sell/static/css/alerts.css b/make_post_sell/static/css/alerts.css new file mode 100644 index 0000000..d744b3f --- /dev/null +++ b/make_post_sell/static/css/alerts.css @@ -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 */ diff --git a/make_post_sell/static/css/common.css b/make_post_sell/static/css/common.css new file mode 100644 index 0000000..1359d3c --- /dev/null +++ b/make_post_sell/static/css/common.css @@ -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; + } +} diff --git a/make_post_sell/static/css/lib.css b/make_post_sell/static/css/lib.css new file mode 100644 index 0000000..d6ac6c5 --- /dev/null +++ b/make_post_sell/static/css/lib.css @@ -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} +} diff --git a/make_post_sell/static/css/mps.css b/make_post_sell/static/css/mps.css new file mode 100644 index 0000000..efb85e9 --- /dev/null +++ b/make_post_sell/static/css/mps.css @@ -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); +} diff --git a/make_post_sell/static/favicon.ico b/make_post_sell/static/favicon.ico new file mode 100644 index 0000000000000000000000000000000000000000..e18461ffd16b5b80aeabf7be5b0d4aa4fa351706 GIT binary patch literal 9662 zcmeI1+iMd+6vih_ZH$-hgU!aO*rH9TiWg83L9CZ-sv=kvM7*GgU~_p;kXl8ls0b=T z1s{sEw2edxfLo8;5XE@!gIK?_%}TO#W+%x;s_vFAXJ=>5>~GG@ z?#yf%bK!5%B8J~;)*E0bn_{ly;+op`=21I;XIXE-pp2<(=En}Kr z)fR&B8)W7-(pSeee{it{|22odcoB%GPW+1(UF({i_*=)Qm?dQ6eo7oj9%%nOF4x#JBK$6UQ) zTg4X3dzuThwY3pr4Q8V=g?~vd(^U0)Npbd++PY?2L`};bWtDzK!o!UhGG5@!N>7+>f)W+8kZO4TN z*bWKUjXpWet0V`08b@a#dUh8;YoHU*GlE$3s1WHY8$|4OxDf39uiN;J=XRlX)(|KNwL%6Ec0w=MjI9+AzRr!xvm% zZuT+KU|#^y`^Fe6quG^4d9#(^ms)><@gRqv##RFR4D+q^bRvc1kLpVo zZRDQ^PToG^eHeRd9z=V4y9@mE4oyPe(|QTpj5)1@=Vvef+6mzQEa-!J5l3HNAN4~i zR0Gv^baa$i&0Ti#N7Y~%*xw5Hn?(Hi=GirVqCU-v|9HNPBELV#e@euWZ=P-Zs%88S z=gTPavyVUM#n}HS82>v(9Qo$i#SiWP^qs>`@6~zvGK%~p_^*rnjXdy0IP5fkJ;i<8 zMp-+p1pob*#oh|}rC@v{;6Hs}I)4pRNP8ssY5l_O`-ne8Yq+BV_IJpuEQC?yE5T3w z{}7sG-s8s9kPFwtkZ9gLw0GJ@5kf(ElKj*L`o`M}(K?OBI<5WhLA*5&`P>Q@Y1|u) z&ViqJa?#zWU$g#zV1VRli^P8eZ7t6eDJM7Z&$k!@50FpeUo=jPeo3O-zL>v3xaQ3X z(4sK*R&Lyr_J_o07Jb)Q74d6SW*u}HdJNI`6e>&4?GxxKv;*>X)a|yoqy5>tW^7>L z3eD5x3wpEU54x9lv;TdB{WxoyqPd!U+W7rbSC>$FPU%$pp`03>2sjb=CnGSdABrg|6lQAJ`lLs to prevent running evil code. + var busted_textarea = $('#'+textarea).val().replace(/&/g, '&').replace(/'+busted_textarea+''); + } + 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 ); +} diff --git a/make_post_sell/static/js/jquery-3.4.1.min.js b/make_post_sell/static/js/jquery-3.4.1.min.js new file mode 100644 index 0000000..a1c07fd --- /dev/null +++ b/make_post_sell/static/js/jquery-3.4.1.min.js @@ -0,0 +1,2 @@ +/*! jQuery v3.4.1 | (c) JS Foundation and other contributors | jquery.org/license */ +!function(e,t){"use strict";"object"==typeof module&&"object"==typeof module.exports?module.exports=e.document?t(e,!0):function(e){if(!e.document)throw new Error("jQuery requires a window with a document");return t(e)}:t(e)}("undefined"!=typeof window?window:this,function(C,e){"use strict";var t=[],E=C.document,r=Object.getPrototypeOf,s=t.slice,g=t.concat,u=t.push,i=t.indexOf,n={},o=n.toString,v=n.hasOwnProperty,a=v.toString,l=a.call(Object),y={},m=function(e){return"function"==typeof e&&"number"!=typeof e.nodeType},x=function(e){return null!=e&&e===e.window},c={type:!0,src:!0,nonce:!0,noModule:!0};function b(e,t,n){var r,i,o=(n=n||E).createElement("script");if(o.text=e,t)for(r in c)(i=t[r]||t.getAttribute&&t.getAttribute(r))&&o.setAttribute(r,i);n.head.appendChild(o).parentNode.removeChild(o)}function w(e){return null==e?e+"":"object"==typeof e||"function"==typeof e?n[o.call(e)]||"object":typeof e}var f="3.4.1",k=function(e,t){return new k.fn.init(e,t)},p=/^[\s\uFEFF\xA0]+|[\s\uFEFF\xA0]+$/g;function d(e){var t=!!e&&"length"in e&&e.length,n=w(e);return!m(e)&&!x(e)&&("array"===n||0===t||"number"==typeof t&&0+~]|"+M+")"+M+"*"),U=new RegExp(M+"|>"),X=new RegExp($),V=new RegExp("^"+I+"$"),G={ID:new RegExp("^#("+I+")"),CLASS:new RegExp("^\\.("+I+")"),TAG:new RegExp("^("+I+"|[*])"),ATTR:new RegExp("^"+W),PSEUDO:new RegExp("^"+$),CHILD:new RegExp("^:(only|first|last|nth|nth-last)-(child|of-type)(?:\\("+M+"*(even|odd|(([+-]|)(\\d*)n|)"+M+"*(?:([+-]|)"+M+"*(\\d+)|))"+M+"*\\)|)","i"),bool:new RegExp("^(?:"+R+")$","i"),needsContext:new RegExp("^"+M+"*[>+~]|:(even|odd|eq|gt|lt|nth|first|last)(?:\\("+M+"*((?:-\\d)?\\d*)"+M+"*\\)|)(?=[^-]|$)","i")},Y=/HTML$/i,Q=/^(?:input|select|textarea|button)$/i,J=/^h\d$/i,K=/^[^{]+\{\s*\[native \w/,Z=/^(?:#([\w-]+)|(\w+)|\.([\w-]+))$/,ee=/[+~]/,te=new RegExp("\\\\([\\da-f]{1,6}"+M+"?|("+M+")|.)","ig"),ne=function(e,t,n){var r="0x"+t-65536;return r!=r||n?t:r<0?String.fromCharCode(r+65536):String.fromCharCode(r>>10|55296,1023&r|56320)},re=/([\0-\x1f\x7f]|^-?\d)|^-$|[^\0-\x1f\x7f-\uFFFF\w-]/g,ie=function(e,t){return t?"\0"===e?"\ufffd":e.slice(0,-1)+"\\"+e.charCodeAt(e.length-1).toString(16)+" ":"\\"+e},oe=function(){T()},ae=be(function(e){return!0===e.disabled&&"fieldset"===e.nodeName.toLowerCase()},{dir:"parentNode",next:"legend"});try{H.apply(t=O.call(m.childNodes),m.childNodes),t[m.childNodes.length].nodeType}catch(e){H={apply:t.length?function(e,t){L.apply(e,O.call(t))}:function(e,t){var n=e.length,r=0;while(e[n++]=t[r++]);e.length=n-1}}}function se(t,e,n,r){var i,o,a,s,u,l,c,f=e&&e.ownerDocument,p=e?e.nodeType:9;if(n=n||[],"string"!=typeof t||!t||1!==p&&9!==p&&11!==p)return n;if(!r&&((e?e.ownerDocument||e:m)!==C&&T(e),e=e||C,E)){if(11!==p&&(u=Z.exec(t)))if(i=u[1]){if(9===p){if(!(a=e.getElementById(i)))return n;if(a.id===i)return n.push(a),n}else if(f&&(a=f.getElementById(i))&&y(e,a)&&a.id===i)return n.push(a),n}else{if(u[2])return H.apply(n,e.getElementsByTagName(t)),n;if((i=u[3])&&d.getElementsByClassName&&e.getElementsByClassName)return H.apply(n,e.getElementsByClassName(i)),n}if(d.qsa&&!A[t+" "]&&(!v||!v.test(t))&&(1!==p||"object"!==e.nodeName.toLowerCase())){if(c=t,f=e,1===p&&U.test(t)){(s=e.getAttribute("id"))?s=s.replace(re,ie):e.setAttribute("id",s=k),o=(l=h(t)).length;while(o--)l[o]="#"+s+" "+xe(l[o]);c=l.join(","),f=ee.test(t)&&ye(e.parentNode)||e}try{return H.apply(n,f.querySelectorAll(c)),n}catch(e){A(t,!0)}finally{s===k&&e.removeAttribute("id")}}}return g(t.replace(B,"$1"),e,n,r)}function ue(){var r=[];return function e(t,n){return r.push(t+" ")>b.cacheLength&&delete e[r.shift()],e[t+" "]=n}}function le(e){return e[k]=!0,e}function ce(e){var t=C.createElement("fieldset");try{return!!e(t)}catch(e){return!1}finally{t.parentNode&&t.parentNode.removeChild(t),t=null}}function fe(e,t){var n=e.split("|"),r=n.length;while(r--)b.attrHandle[n[r]]=t}function pe(e,t){var n=t&&e,r=n&&1===e.nodeType&&1===t.nodeType&&e.sourceIndex-t.sourceIndex;if(r)return r;if(n)while(n=n.nextSibling)if(n===t)return-1;return e?1:-1}function de(t){return function(e){return"input"===e.nodeName.toLowerCase()&&e.type===t}}function he(n){return function(e){var t=e.nodeName.toLowerCase();return("input"===t||"button"===t)&&e.type===n}}function ge(t){return function(e){return"form"in e?e.parentNode&&!1===e.disabled?"label"in e?"label"in e.parentNode?e.parentNode.disabled===t:e.disabled===t:e.isDisabled===t||e.isDisabled!==!t&&ae(e)===t:e.disabled===t:"label"in e&&e.disabled===t}}function ve(a){return le(function(o){return o=+o,le(function(e,t){var n,r=a([],e.length,o),i=r.length;while(i--)e[n=r[i]]&&(e[n]=!(t[n]=e[n]))})})}function ye(e){return e&&"undefined"!=typeof e.getElementsByTagName&&e}for(e in d=se.support={},i=se.isXML=function(e){var t=e.namespaceURI,n=(e.ownerDocument||e).documentElement;return!Y.test(t||n&&n.nodeName||"HTML")},T=se.setDocument=function(e){var t,n,r=e?e.ownerDocument||e:m;return r!==C&&9===r.nodeType&&r.documentElement&&(a=(C=r).documentElement,E=!i(C),m!==C&&(n=C.defaultView)&&n.top!==n&&(n.addEventListener?n.addEventListener("unload",oe,!1):n.attachEvent&&n.attachEvent("onunload",oe)),d.attributes=ce(function(e){return e.className="i",!e.getAttribute("className")}),d.getElementsByTagName=ce(function(e){return e.appendChild(C.createComment("")),!e.getElementsByTagName("*").length}),d.getElementsByClassName=K.test(C.getElementsByClassName),d.getById=ce(function(e){return a.appendChild(e).id=k,!C.getElementsByName||!C.getElementsByName(k).length}),d.getById?(b.filter.ID=function(e){var t=e.replace(te,ne);return function(e){return e.getAttribute("id")===t}},b.find.ID=function(e,t){if("undefined"!=typeof t.getElementById&&E){var n=t.getElementById(e);return n?[n]:[]}}):(b.filter.ID=function(e){var n=e.replace(te,ne);return function(e){var t="undefined"!=typeof e.getAttributeNode&&e.getAttributeNode("id");return t&&t.value===n}},b.find.ID=function(e,t){if("undefined"!=typeof t.getElementById&&E){var n,r,i,o=t.getElementById(e);if(o){if((n=o.getAttributeNode("id"))&&n.value===e)return[o];i=t.getElementsByName(e),r=0;while(o=i[r++])if((n=o.getAttributeNode("id"))&&n.value===e)return[o]}return[]}}),b.find.TAG=d.getElementsByTagName?function(e,t){return"undefined"!=typeof t.getElementsByTagName?t.getElementsByTagName(e):d.qsa?t.querySelectorAll(e):void 0}:function(e,t){var n,r=[],i=0,o=t.getElementsByTagName(e);if("*"===e){while(n=o[i++])1===n.nodeType&&r.push(n);return r}return o},b.find.CLASS=d.getElementsByClassName&&function(e,t){if("undefined"!=typeof t.getElementsByClassName&&E)return t.getElementsByClassName(e)},s=[],v=[],(d.qsa=K.test(C.querySelectorAll))&&(ce(function(e){a.appendChild(e).innerHTML="",e.querySelectorAll("[msallowcapture^='']").length&&v.push("[*^$]="+M+"*(?:''|\"\")"),e.querySelectorAll("[selected]").length||v.push("\\["+M+"*(?:value|"+R+")"),e.querySelectorAll("[id~="+k+"-]").length||v.push("~="),e.querySelectorAll(":checked").length||v.push(":checked"),e.querySelectorAll("a#"+k+"+*").length||v.push(".#.+[+~]")}),ce(function(e){e.innerHTML="";var t=C.createElement("input");t.setAttribute("type","hidden"),e.appendChild(t).setAttribute("name","D"),e.querySelectorAll("[name=d]").length&&v.push("name"+M+"*[*^$|!~]?="),2!==e.querySelectorAll(":enabled").length&&v.push(":enabled",":disabled"),a.appendChild(e).disabled=!0,2!==e.querySelectorAll(":disabled").length&&v.push(":enabled",":disabled"),e.querySelectorAll("*,:x"),v.push(",.*:")})),(d.matchesSelector=K.test(c=a.matches||a.webkitMatchesSelector||a.mozMatchesSelector||a.oMatchesSelector||a.msMatchesSelector))&&ce(function(e){d.disconnectedMatch=c.call(e,"*"),c.call(e,"[s!='']:x"),s.push("!=",$)}),v=v.length&&new RegExp(v.join("|")),s=s.length&&new RegExp(s.join("|")),t=K.test(a.compareDocumentPosition),y=t||K.test(a.contains)?function(e,t){var n=9===e.nodeType?e.documentElement:e,r=t&&t.parentNode;return e===r||!(!r||1!==r.nodeType||!(n.contains?n.contains(r):e.compareDocumentPosition&&16&e.compareDocumentPosition(r)))}:function(e,t){if(t)while(t=t.parentNode)if(t===e)return!0;return!1},D=t?function(e,t){if(e===t)return l=!0,0;var n=!e.compareDocumentPosition-!t.compareDocumentPosition;return n||(1&(n=(e.ownerDocument||e)===(t.ownerDocument||t)?e.compareDocumentPosition(t):1)||!d.sortDetached&&t.compareDocumentPosition(e)===n?e===C||e.ownerDocument===m&&y(m,e)?-1:t===C||t.ownerDocument===m&&y(m,t)?1:u?P(u,e)-P(u,t):0:4&n?-1:1)}:function(e,t){if(e===t)return l=!0,0;var n,r=0,i=e.parentNode,o=t.parentNode,a=[e],s=[t];if(!i||!o)return e===C?-1:t===C?1:i?-1:o?1:u?P(u,e)-P(u,t):0;if(i===o)return pe(e,t);n=e;while(n=n.parentNode)a.unshift(n);n=t;while(n=n.parentNode)s.unshift(n);while(a[r]===s[r])r++;return r?pe(a[r],s[r]):a[r]===m?-1:s[r]===m?1:0}),C},se.matches=function(e,t){return se(e,null,null,t)},se.matchesSelector=function(e,t){if((e.ownerDocument||e)!==C&&T(e),d.matchesSelector&&E&&!A[t+" "]&&(!s||!s.test(t))&&(!v||!v.test(t)))try{var n=c.call(e,t);if(n||d.disconnectedMatch||e.document&&11!==e.document.nodeType)return n}catch(e){A(t,!0)}return 0":{dir:"parentNode",first:!0}," ":{dir:"parentNode"},"+":{dir:"previousSibling",first:!0},"~":{dir:"previousSibling"}},preFilter:{ATTR:function(e){return e[1]=e[1].replace(te,ne),e[3]=(e[3]||e[4]||e[5]||"").replace(te,ne),"~="===e[2]&&(e[3]=" "+e[3]+" "),e.slice(0,4)},CHILD:function(e){return e[1]=e[1].toLowerCase(),"nth"===e[1].slice(0,3)?(e[3]||se.error(e[0]),e[4]=+(e[4]?e[5]+(e[6]||1):2*("even"===e[3]||"odd"===e[3])),e[5]=+(e[7]+e[8]||"odd"===e[3])):e[3]&&se.error(e[0]),e},PSEUDO:function(e){var t,n=!e[6]&&e[2];return G.CHILD.test(e[0])?null:(e[3]?e[2]=e[4]||e[5]||"":n&&X.test(n)&&(t=h(n,!0))&&(t=n.indexOf(")",n.length-t)-n.length)&&(e[0]=e[0].slice(0,t),e[2]=n.slice(0,t)),e.slice(0,3))}},filter:{TAG:function(e){var t=e.replace(te,ne).toLowerCase();return"*"===e?function(){return!0}:function(e){return e.nodeName&&e.nodeName.toLowerCase()===t}},CLASS:function(e){var t=p[e+" "];return t||(t=new RegExp("(^|"+M+")"+e+"("+M+"|$)"))&&p(e,function(e){return t.test("string"==typeof e.className&&e.className||"undefined"!=typeof e.getAttribute&&e.getAttribute("class")||"")})},ATTR:function(n,r,i){return function(e){var t=se.attr(e,n);return null==t?"!="===r:!r||(t+="","="===r?t===i:"!="===r?t!==i:"^="===r?i&&0===t.indexOf(i):"*="===r?i&&-1:\x20\t\r\n\f]*)[\x20\t\r\n\f]*\/?>(?:<\/\1>|)$/i;function j(e,n,r){return m(n)?k.grep(e,function(e,t){return!!n.call(e,t,e)!==r}):n.nodeType?k.grep(e,function(e){return e===n!==r}):"string"!=typeof n?k.grep(e,function(e){return-1)[^>]*|#([\w-]+))$/;(k.fn.init=function(e,t,n){var r,i;if(!e)return this;if(n=n||q,"string"==typeof e){if(!(r="<"===e[0]&&">"===e[e.length-1]&&3<=e.length?[null,e,null]:L.exec(e))||!r[1]&&t)return!t||t.jquery?(t||n).find(e):this.constructor(t).find(e);if(r[1]){if(t=t instanceof k?t[0]:t,k.merge(this,k.parseHTML(r[1],t&&t.nodeType?t.ownerDocument||t:E,!0)),D.test(r[1])&&k.isPlainObject(t))for(r in t)m(this[r])?this[r](t[r]):this.attr(r,t[r]);return this}return(i=E.getElementById(r[2]))&&(this[0]=i,this.length=1),this}return e.nodeType?(this[0]=e,this.length=1,this):m(e)?void 0!==n.ready?n.ready(e):e(k):k.makeArray(e,this)}).prototype=k.fn,q=k(E);var H=/^(?:parents|prev(?:Until|All))/,O={children:!0,contents:!0,next:!0,prev:!0};function P(e,t){while((e=e[t])&&1!==e.nodeType);return e}k.fn.extend({has:function(e){var t=k(e,this),n=t.length;return this.filter(function(){for(var e=0;e\x20\t\r\n\f]*)/i,he=/^$|^module$|\/(?:java|ecma)script/i,ge={option:[1,""],thead:[1,"","
"],col:[2,"","
"],tr:[2,"","
"],td:[3,"","
"],_default:[0,"",""]};function ve(e,t){var n;return n="undefined"!=typeof e.getElementsByTagName?e.getElementsByTagName(t||"*"):"undefined"!=typeof e.querySelectorAll?e.querySelectorAll(t||"*"):[],void 0===t||t&&A(e,t)?k.merge([e],n):n}function ye(e,t){for(var n=0,r=e.length;nx",y.noCloneChecked=!!me.cloneNode(!0).lastChild.defaultValue;var Te=/^key/,Ce=/^(?:mouse|pointer|contextmenu|drag|drop)|click/,Ee=/^([^.]*)(?:\.(.+)|)/;function ke(){return!0}function Se(){return!1}function Ne(e,t){return e===function(){try{return E.activeElement}catch(e){}}()==("focus"===t)}function Ae(e,t,n,r,i,o){var a,s;if("object"==typeof t){for(s in"string"!=typeof n&&(r=r||n,n=void 0),t)Ae(e,s,n,r,t[s],o);return e}if(null==r&&null==i?(i=n,r=n=void 0):null==i&&("string"==typeof n?(i=r,r=void 0):(i=r,r=n,n=void 0)),!1===i)i=Se;else if(!i)return e;return 1===o&&(a=i,(i=function(e){return k().off(e),a.apply(this,arguments)}).guid=a.guid||(a.guid=k.guid++)),e.each(function(){k.event.add(this,t,i,r,n)})}function De(e,i,o){o?(Q.set(e,i,!1),k.event.add(e,i,{namespace:!1,handler:function(e){var t,n,r=Q.get(this,i);if(1&e.isTrigger&&this[i]){if(r.length)(k.event.special[i]||{}).delegateType&&e.stopPropagation();else if(r=s.call(arguments),Q.set(this,i,r),t=o(this,i),this[i](),r!==(n=Q.get(this,i))||t?Q.set(this,i,!1):n={},r!==n)return e.stopImmediatePropagation(),e.preventDefault(),n.value}else r.length&&(Q.set(this,i,{value:k.event.trigger(k.extend(r[0],k.Event.prototype),r.slice(1),this)}),e.stopImmediatePropagation())}})):void 0===Q.get(e,i)&&k.event.add(e,i,ke)}k.event={global:{},add:function(t,e,n,r,i){var o,a,s,u,l,c,f,p,d,h,g,v=Q.get(t);if(v){n.handler&&(n=(o=n).handler,i=o.selector),i&&k.find.matchesSelector(ie,i),n.guid||(n.guid=k.guid++),(u=v.events)||(u=v.events={}),(a=v.handle)||(a=v.handle=function(e){return"undefined"!=typeof k&&k.event.triggered!==e.type?k.event.dispatch.apply(t,arguments):void 0}),l=(e=(e||"").match(R)||[""]).length;while(l--)d=g=(s=Ee.exec(e[l])||[])[1],h=(s[2]||"").split(".").sort(),d&&(f=k.event.special[d]||{},d=(i?f.delegateType:f.bindType)||d,f=k.event.special[d]||{},c=k.extend({type:d,origType:g,data:r,handler:n,guid:n.guid,selector:i,needsContext:i&&k.expr.match.needsContext.test(i),namespace:h.join(".")},o),(p=u[d])||((p=u[d]=[]).delegateCount=0,f.setup&&!1!==f.setup.call(t,r,h,a)||t.addEventListener&&t.addEventListener(d,a)),f.add&&(f.add.call(t,c),c.handler.guid||(c.handler.guid=n.guid)),i?p.splice(p.delegateCount++,0,c):p.push(c),k.event.global[d]=!0)}},remove:function(e,t,n,r,i){var o,a,s,u,l,c,f,p,d,h,g,v=Q.hasData(e)&&Q.get(e);if(v&&(u=v.events)){l=(t=(t||"").match(R)||[""]).length;while(l--)if(d=g=(s=Ee.exec(t[l])||[])[1],h=(s[2]||"").split(".").sort(),d){f=k.event.special[d]||{},p=u[d=(r?f.delegateType:f.bindType)||d]||[],s=s[2]&&new RegExp("(^|\\.)"+h.join("\\.(?:.*\\.|)")+"(\\.|$)"),a=o=p.length;while(o--)c=p[o],!i&&g!==c.origType||n&&n.guid!==c.guid||s&&!s.test(c.namespace)||r&&r!==c.selector&&("**"!==r||!c.selector)||(p.splice(o,1),c.selector&&p.delegateCount--,f.remove&&f.remove.call(e,c));a&&!p.length&&(f.teardown&&!1!==f.teardown.call(e,h,v.handle)||k.removeEvent(e,d,v.handle),delete u[d])}else for(d in u)k.event.remove(e,d+t[l],n,r,!0);k.isEmptyObject(u)&&Q.remove(e,"handle events")}},dispatch:function(e){var t,n,r,i,o,a,s=k.event.fix(e),u=new Array(arguments.length),l=(Q.get(this,"events")||{})[s.type]||[],c=k.event.special[s.type]||{};for(u[0]=s,t=1;t\x20\t\r\n\f]*)[^>]*)\/>/gi,qe=/\s*$/g;function Oe(e,t){return A(e,"table")&&A(11!==t.nodeType?t:t.firstChild,"tr")&&k(e).children("tbody")[0]||e}function Pe(e){return e.type=(null!==e.getAttribute("type"))+"/"+e.type,e}function Re(e){return"true/"===(e.type||"").slice(0,5)?e.type=e.type.slice(5):e.removeAttribute("type"),e}function Me(e,t){var n,r,i,o,a,s,u,l;if(1===t.nodeType){if(Q.hasData(e)&&(o=Q.access(e),a=Q.set(t,o),l=o.events))for(i in delete a.handle,a.events={},l)for(n=0,r=l[i].length;n")},clone:function(e,t,n){var r,i,o,a,s,u,l,c=e.cloneNode(!0),f=oe(e);if(!(y.noCloneChecked||1!==e.nodeType&&11!==e.nodeType||k.isXMLDoc(e)))for(a=ve(c),r=0,i=(o=ve(e)).length;r").attr(n.scriptAttrs||{}).prop({charset:n.scriptCharset,src:n.url}).on("load error",i=function(e){r.remove(),i=null,e&&t("error"===e.type?404:200,e.type)}),E.head.appendChild(r[0])},abort:function(){i&&i()}}});var Vt,Gt=[],Yt=/(=)\?(?=&|$)|\?\?/;k.ajaxSetup({jsonp:"callback",jsonpCallback:function(){var e=Gt.pop()||k.expando+"_"+kt++;return this[e]=!0,e}}),k.ajaxPrefilter("json jsonp",function(e,t,n){var r,i,o,a=!1!==e.jsonp&&(Yt.test(e.url)?"url":"string"==typeof e.data&&0===(e.contentType||"").indexOf("application/x-www-form-urlencoded")&&Yt.test(e.data)&&"data");if(a||"jsonp"===e.dataTypes[0])return r=e.jsonpCallback=m(e.jsonpCallback)?e.jsonpCallback():e.jsonpCallback,a?e[a]=e[a].replace(Yt,"$1"+r):!1!==e.jsonp&&(e.url+=(St.test(e.url)?"&":"?")+e.jsonp+"="+r),e.converters["script json"]=function(){return o||k.error(r+" was not called"),o[0]},e.dataTypes[0]="json",i=C[r],C[r]=function(){o=arguments},n.always(function(){void 0===i?k(C).removeProp(r):C[r]=i,e[r]&&(e.jsonpCallback=t.jsonpCallback,Gt.push(r)),o&&m(i)&&i(o[0]),o=i=void 0}),"script"}),y.createHTMLDocument=((Vt=E.implementation.createHTMLDocument("").body).innerHTML="
",2===Vt.childNodes.length),k.parseHTML=function(e,t,n){return"string"!=typeof e?[]:("boolean"==typeof t&&(n=t,t=!1),t||(y.createHTMLDocument?((r=(t=E.implementation.createHTMLDocument("")).createElement("base")).href=E.location.href,t.head.appendChild(r)):t=E),o=!n&&[],(i=D.exec(e))?[t.createElement(i[1])]:(i=we([e],t,o),o&&o.length&&k(o).remove(),k.merge([],i.childNodes)));var r,i,o},k.fn.load=function(e,t,n){var r,i,o,a=this,s=e.indexOf(" ");return-1").append(k.parseHTML(e)).find(r):e)}).always(n&&function(e,t){a.each(function(){n.apply(this,o||[e.responseText,t,e])})}),this},k.each(["ajaxStart","ajaxStop","ajaxComplete","ajaxError","ajaxSuccess","ajaxSend"],function(e,t){k.fn[t]=function(e){return this.on(t,e)}}),k.expr.pseudos.animated=function(t){return k.grep(k.timers,function(e){return t===e.elem}).length},k.offset={setOffset:function(e,t,n){var r,i,o,a,s,u,l=k.css(e,"position"),c=k(e),f={};"static"===l&&(e.style.position="relative"),s=c.offset(),o=k.css(e,"top"),u=k.css(e,"left"),("absolute"===l||"fixed"===l)&&-1<(o+u).indexOf("auto")?(a=(r=c.position()).top,i=r.left):(a=parseFloat(o)||0,i=parseFloat(u)||0),m(t)&&(t=t.call(e,n,k.extend({},s))),null!=t.top&&(f.top=t.top-s.top+a),null!=t.left&&(f.left=t.left-s.left+i),"using"in t?t.using.call(e,f):c.css(f)}},k.fn.extend({offset:function(t){if(arguments.length)return void 0===t?this:this.each(function(e){k.offset.setOffset(this,t,e)});var e,n,r=this[0];return r?r.getClientRects().length?(e=r.getBoundingClientRect(),n=r.ownerDocument.defaultView,{top:e.top+n.pageYOffset,left:e.left+n.pageXOffset}):{top:0,left:0}:void 0},position:function(){if(this[0]){var e,t,n,r=this[0],i={top:0,left:0};if("fixed"===k.css(r,"position"))t=r.getBoundingClientRect();else{t=this.offset(),n=r.ownerDocument,e=r.offsetParent||n.documentElement;while(e&&(e===n.body||e===n.documentElement)&&"static"===k.css(e,"position"))e=e.parentNode;e&&e!==r&&1===e.nodeType&&((i=k(e).offset()).top+=k.css(e,"borderTopWidth",!0),i.left+=k.css(e,"borderLeftWidth",!0))}return{top:t.top-i.top-k.css(r,"marginTop",!0),left:t.left-i.left-k.css(r,"marginLeft",!0)}}},offsetParent:function(){return this.map(function(){var e=this.offsetParent;while(e&&"static"===k.css(e,"position"))e=e.offsetParent;return e||ie})}}),k.each({scrollLeft:"pageXOffset",scrollTop:"pageYOffset"},function(t,i){var o="pageYOffset"===i;k.fn[t]=function(e){return _(this,function(e,t,n){var r;if(x(e)?r=e:9===e.nodeType&&(r=e.defaultView),void 0===n)return r?r[i]:e[t];r?r.scrollTo(o?r.pageXOffset:n,o?n:r.pageYOffset):e[t]=n},t,e,arguments.length)}}),k.each(["top","left"],function(e,n){k.cssHooks[n]=ze(y.pixelPosition,function(e,t){if(t)return t=_e(e,n),$e.test(t)?k(e).position()[n]+"px":t})}),k.each({Height:"height",Width:"width"},function(a,s){k.each({padding:"inner"+a,content:s,"":"outer"+a},function(r,o){k.fn[o]=function(e,t){var n=arguments.length&&(r||"boolean"!=typeof e),i=r||(!0===e||!0===t?"margin":"border");return _(this,function(e,t,n){var r;return x(e)?0===o.indexOf("outer")?e["inner"+a]:e.document.documentElement["client"+a]:9===e.nodeType?(r=e.documentElement,Math.max(e.body["scroll"+a],r["scroll"+a],e.body["offset"+a],r["offset"+a],r["client"+a])):void 0===n?k.css(e,t,i):k.style(e,t,n,i)},s,n?e:void 0,n)}})}),k.each("blur focus focusin focusout resize scroll click dblclick mousedown mouseup mousemove mouseover mouseout mouseenter mouseleave change select submit keydown keypress keyup contextmenu".split(" "),function(e,n){k.fn[n]=function(e,t){return 0)0zxR0yLi zLzXO)wXtviXMErH{r%^o2Czte#M={wD5TZ1RcH}Czt zK<0r$NL03%7_fRKD5+Zx!BRqXKnd~Yukw9XiM|RGBabSzQbA~>g{K(h#iMsT$|m4S z+(fy163bzAi( z1jC4L#a$AxFH!&fV3NBl$p3p0@}tR;x{2=qaUj2gpgi=SFJW|Scn*DMy6 z17r^VzrP_+y~)2eX`Oip5iKEKzB2qx_|}V!Tk{tJA?V5f8X_Pd9uR>5{pSk|_>D|l zobM&VWpqzi;w6};J2^q=d};ZYf3N+&N3bh<{O>(h(alUENi4AnpcscO59?fOEAP9P z;06D+0EH>^T>kI-VA70Du38wr(%38j&6#zyTyIxOk{9})dnV$0b1wepDTt4)nNm?# z&5hpiHC_3|8$b@CmHkqr(8T+{yU~%IMUekop&7GVWJ4cO)y&X&+DUJWgSD5oPyc5E zYWqs;|6Jk+H7PC~5eUOH5b``^9WCSMhi|G5$^xOHkcz-cj(0OocX zD~t?P+Sw`pQAc(n2(yA~SawSNOGz$PR_^mA~I`YUk(@2LAwohIPxa;Hw@( z`Vfktm#O^~5-V1_>!hV!R!jQdqcBFF|KFo*z6Ca|4HVHo0_~T3^&biBCmL~s`alN1 z7|`St%kQ8Y?lvRzP}&CTjf1b(EiP4%Z{DWZfNotXgljH_Yb}iVCI{ZST}V7zjlC;Q z&Hdkqh8a7^6Yu=zs*anXvg|(Ql&cd&$Rx`EZ*qWS66PowHqr8}%sRa`+1`n6wTb6p zfu1t2^~33<-j4+(GuAsv=1Wo{jb(+mHa}Qkz@5d@+UjDbE1%@+g7UAW(Z5%0#%tOS>6P7pNEEL^eBa%N+c4HEX*F!EwIc zYma?=bYEZj>D2h*dGIr&3h0-SmOqDHe!pYW$>JLJd zfZfP^Rrm|F`FQqtF2Y>;Yze z+{nfXwl?UlGO0G#3g1~b5|X)gajv4}e0Ab=0dsyf_#eDVPN|}CsE!ZQDm((oGRH?S z|E1%rTYrHfCt5-A;LQ@yhBy%+YE3T}qJk2!IT{%b?#N1}u*qR&VOhMw*v7&V`f*v} z6%$nW*8Ur*t)*9xms57rn&<9m9ve!qfkKQdf6 zzlp^qtfu5`==}Fs3|5fB8%Y+WwaE@TA{VV$9}v6;3a8r4CJSfF;9$YNx_|S~Zf%Hi z>ai{i=I~q-USMx$XZx#{FtEqI943yf0v(n0wa;CL!Vbmic#&a9OTP`>H&-iy<1nt$ zt#e&fsMUMO3zT&iJ53u-^bI4lVP2DMkfdi|7{kr;?IB7nznQtGYS?WKe&n2NcH2H6 zX}mJ(++TEB|H`x4(j3=f-^FM<7iy#WS&%$rd>+$}hS=ZdQN@=7G3nz&nM}! zorvP_GcQcclQYJUNpnOKvEAbP83ITCf0mVo{XiFcy^W>yB|~oER7Snstv(miEC>{VFukEas~Mrn&|Sqs^)N0|RkdLb_Y!9X zc)ndMyQuL)l{K!^zn}Yc1P>R8T6~Hh$%U%?^KV(NbwxLbdG2S($fuS_m1lq^h`30z$)aOdN|!tci#9X zLWKh(jzX2MjK65()c-FodPYe;ZyIC=L!;xsYA{?e{4oiH6<7Ygd$qHY@;#S&xf?Cd z4!zvHCccRb7WDc3qqu&G?>zMx^Zlv#fv;&;@WkYnC69o)9j0MsJ1i7Z`i%=!D)%i| zO;Q^e&w)06;-d-cz?|cjIOX??h2{~V6*-ts4X9Z!6Eoqt%%sZKXE6vM@(uXKe-<_V z{$PIpsHY+1*FDc}a`}mwYy0bMs=gv5I=heJBT7ANnQh4XqyDBEpj!>G}FxsKV;ZMK7E9}ps4{QHr zqAZFH6Ei0X)l(nWYbZKK>xVwl%A^z@=wWm}Y&*B9NHnJ>pF41V4TaJN~Zo&hZW60x8f8w5N6mUyfW9Z<}k$Am?0hD(MIrG0%sW z2c}g1EKl0hC-Msj^g9L0$?XpISk+@p6-RuYYkUWLiX*KmbNryU1Y@W7(hOuC$1<}= z-S`mhhly%=T=XVodOks|pdYf{7C6eJyPk7y5eO9boT`O4Pt}UTh&|{M(h#sHI0rPv zf;n=TM%A9xke($Va9Gzet~dRyY*fRyuH2X}X=bFj!hprSrUoVP$TOJ@sQ|GdzE5wAu-R~hi1%-+qPp$vp z7uitIh!|ohC8Md#5rx;q*@=C=l~`%H`Ih8HgZ93JqDC)Ml_po&d0~2otH_gzU@U)z zSC))STk-B7@W6IV*7TH&uq^{J!NiLT*@;~#Sm}8%EB@*fvnxcncpk4aw^ZMFg+c-u zDU1re6}Q)S!0gpCcDWlAWU;5;p2lHpMIKZ? zbK`WgJEzhWuvs#TvF&C1-_uWW$*!PWriY2VEPYaYQ*x``1V*&R?ZaBf7MEmI>drur z9+kdbZk?bEb#ZYoKJ=3rp+fWgtwZFTGIkjo-tmdYhNcd0)jK z*M2NnD@P}_b`P!aMzxxeU;NrF(_0&yO|!~g-aHhV22VISjW_A{UVTLs>$nDTF|rJc zyl7eNvPyH|z6Pd>`-EryrBk$sB(gb|Y4lTG6|G}Peo1z4bM_Q-@u(^|JY?Ob;wTQ) zhs({t24B7=6WxCS6j~XK382}GXAP-3E7`Z>3BFHSeS>CK_md~39#}F~%4Mf%P3!%~ zNF}R6waejukH^n(cUE@3Cm+huUtjL;w=jYt9a++3$Is7JJnV3!q6FrPEdes)h7(J^GTD0mg2{bmT zYTx+tFPzmxgoTAYX7W+pn#e^<;Yb%y(BnOQ7NcVWvXe(An?H(iVkA?Io7O{W+hs2h z#iR2R$c~28*k4>j9-s#Ndl_7UCKY2)qIC48x_>!k5=<4w4t^}Sih39IRft6*Z|EDZRx z;}Kl;LEC@HZFvnnzh)9+#l1Cg%C}?8T>o0a1pmxaz=rYFn4kKx-DTqR)T?3BUfd~Ii9}FB0S$zBZ zC+r3T+^}okZN^m9V7cPR13O!a-|m5%*TW^K@cGHlKLFo0kuT)xwD$J>i33WB8%URb z;pwSVo2_K;w4yQ*1f54#=Ao`F20Hl1_2MkSZ;0RQ`Mj|xB`cG}h-vB!8>8iH=;Cl8 zEG%p*xnwUwlBsR%d)VwK=rP%oMmPM!{GgMn#S<#EOw8Ofn7BEI&B2ht_vSc|G}X|c zyoeL5W;s7+u;2->9Y(EgeaQBwwg3GK`3N3dGdx)?^dQ_aY`oGQVm__L!lEUUGW2B6 zK5ePIzn+ZCeOhyrHpkfWM9FarNa!C4s=z+bTG+ibEhO|ME%$zgXgOcrRp)21tI;Fc z>Cyw{d|A&(%+uX|<_j{!s4&=UkJHaqL-sV;>meN z&#|siWC^h8hdm5doAHPT(zhVv&%daL#!6fV14IU0g&6U303BH(l9+_V)V^>?@SKZGUI49DSBkxgwAT z{_8S5qR#8&Xy;92-z%sp|HrcS3md&G3j*r?jlA^7?34B<)J|d!srQ4LFbmzt=ltNv zg8cNy2)0I*iv~rQw&Mufh$k13^o62>+ZwZa1e5l_1Oz#-cazHR+q-Uu@F7(AVju4@ zYt6cXHy!5t7g}HRROJL_R(v2aG_GxR($hseHK2AW;lVD7D-g~Xgc9jfd z1ma4P3>6rPQ*!#VSzF*V-yr6{ZmG1B3+j5QFVzIuEL$#S+ut*l*gX!@xcD6a_G8~A zqmgz2MjMVNWXL>#Qs}j-y{}!dsgOW7IILCVvAL%e?kgf?EcSdq>9D|qjA$3S6#63? zJLnrcf^&dv$CiP`_UxD7PX_&xT~fPT#LXX%v^T#E3jKELo4<5hjb9Xk4oI=yiyq~$HI zzi-QxTJd%;SaZF@w{+Y1S({l;su1}rhNJH)Pxq8>1U?bI@ZP{ZhoUeE@sI+itw_B6 z6c-iz6j?3`|1eZG`w^&MS`PCqz6bVVqcKX-$32=y)Gm2g7Dbl$>3@Wwmew(u2QjAL zeF(4%1wgL@4m3k2i%iRw9Z7V__oFrr41dbp=J51j&K$fxpmL9sQniU27W*NcM*G&%1^jePpC84eC%qLkmH z0?{pQmB3(u9_uo`<1rau6pHArBhi~~A$h60e>Ve)xO-gFOWHLJiF~Uu zbOhu_;hR0yr=2`N*=1}gogI3XtmYw46oW32{T#!VD!~F;#egE*A`Cd*ERJLK1f@o zc?L?#ObJmW{ywUgSI=(_%^l?PQ5RmIoi}O$VMcV51Rc<(BO#H@H1m?>Q*L!wx9!&o zAtfuhqtj-jGUXE#;vrkEHxNdmyth-_!pCq*9JQyE_@t`-$sS^6|c>q~w1n>fx{S=fCV&)KR^Ug}n9!RzeD>O+)i&nuK z8_N@S11U;zUp@ZJGP84iyOu<0^Ha+<2!O3c91CorarncA+RyGFRfaxa(69&b)-`65 zdbOC+ld%?iyY3pVmtHvSEFS>JWc&h6qis2di>;2>98#^8QgumkM3q6R3|?%tY65)^ z9vZ=}jL40qBgjK>R;~nUnZReO_TrEE0;^e)`{JR}VITfh9%b89h}C%TqmMr2``}vsG@zx5ZnZM~A z2R{eMN3gK&pmwQDw9pUN1xajlK@YUk9GU- z)gtiXR?=WSuqQD<*)K}GOkhmPk5kjULORp{<>plVm z{6{74lcD)wPe1`k(Z0{$6zJjVjelsK&YOHqKwvdefvGXHxkjk|L1B`(=WE16?Ti=| zU_Vk{s>*tl^C*xuIRyyZ1D#&!!1DDw1Ui9!-tt-;mOmD#UY9GpG_&zkn@)s8OM}u4 zZDpb`SI{Kb;6c3yV4_+`z)y8mCWF_W{gsztY-K-V0L_HFLaFw=ZIJc)!~I}LLkaKm z_$CBS+56t*s8(;$g6Z(J|8mWDjuA;E=SHt5ZwT3lj`@jf;0YCq=@jI zS?kk@i=aK)PSy&PA8dy+VSIG!#9e?4B+r1a@#rhst0c!_oOuMr7~bHCnP;wVD#bjv zyFc*$9K=Rp1S6pm6`Uy4a)Q``03oJ=_4g1bi$UTy*I%-txXhpn`W_M@{q6DB3|%=h z8yOl%e6-)+$HMTF>fgLW23QAq;a6;_1g?0#7OeRv6%l=Y~#0VObx+3V-Qkxp- zKE)-H`k$^zdXy|mD_Z%LtT*^2n&lV66ST^c3-_ z?U!sUEm`25;_C1d<|}a$k-;1)3ZbSz{;xF&-u!V5`3=Y2%$FRgg!h#=7{c;t-^MYy z+}FBId}KO)nYz=SSqWN{rgy(0-prpF0U~u@wr%1NP#mQpn@k~ap|0tABpRVghvcY@ z^Vah-jqL@(O7A?_`$b+3)H}_8DhoEb1%?6~?TZjeATfAw>3#O&28VB>7HmP($b4Xz zP`V{kWCa2ckWNjZwPkrni_Y6V8=5e;)z$FQFNLAaeMR93i0-WWjt33ZI(9wE2%w(r zeWWs*eiljq2=fHL6L&I#af;+5av+JM75%tSI0|?jwG*)%AA{Q+K3BF53P~d5^_{aq zOua|k-5-jDucx*grcY~lwrazy+Kwn840g$?>IBnFQr)CcZiket~qT zq!m6r#Ev#+OJZtJQB7f}qu`e(;uPLIdwP2h$iIim5j07+g#^SeTp=Lc(vYNX z%`dse;*%NnLru?MB?DDjEk`eV*Wi~38UQ9UVsOSn6!w)$0zJBLh3bH{(R%{>;UEq_ zW=HivVH$XKBA~TNAhqF{ z&uAx8r65+rfGY=I7{H10T-F5wONdTo1{<+FLeWnG9u0mxQ(L`CCT2SrX|B{+s{h6y^bZ>Hb5yJK|acKHS{nq?{9yf z@-XNe#E@b-TF{<5*-h>m9RW+^;^9re{uzBO>(}TmW(w}LwH?y%*^sJ)d5b3$xW`* zcDjU7#eo$HbL`bebMT$IaI0&|bLrctXWpU#qkv}K<>}cD5Bj$y$|``)#gfzxac4?> zE{iR%Cx`sHVtf-K&y~?12zoiB`fsD@pmhfbA^i*WdVB(Cy{*vYZ@mu@ zz#D&!bes^WEQ@9P2}mDUJ$^VX(F6)1@4AEWc<#2R0F{B>Z#W6PQmCReWXc{2CU)a& zs13qEsgkr$zguNlD%r!n*b&%(E$PNw%3FqjEy%fB>GuIy@izC6!YuV(+;%*fJ!p!A z0>3!!dR=IvU63NgNVBGY>JS+&*xl0&Nx9HAHYyi8OG#W8bK26i__5Ng%Xz zwpH?x6sgjH&fumiL*`kCpa;eQEID*~{yb377Wn0z`nb4eTJP6#u7Jk3=PSNHI+Kbt>h%o>5hR!eG)_@=YNE0nPfF`QbDFfCtY0%E~A zc;RbHK^dnfN$RWA0Yu!lmm5&(MU>4ex&XDWJnPMghba>;9T_I&jR~CuucT;Rr4Z~Y zg2>(ud88w@xn%%sSlB3S-u(M7kc z7!WfJ>xJvIv-p9<6L-$)V7o~h5VFVSS+np+hPVd?S`ISit0mlHqoi!lmDHUnD_@Fd z`&8@%6nU45o=tq(%Wqn#OaoY?A44JJu|M-LM_Q#esm$v00S}d4mR5e=sf1&&Vxhn9 z7dZpx^bic^4N6!@!)Co8Ce8W9TOJ(Z$pb2`Wf^AJ0!bJs!L51Wt6ZeiJ9^J2yr@NE zTEkPxv@4CzK1}YxiA$~RA!`5A&2pWNTW#2d{QzIla4zqZ)y{p>fTMt#@#a zwKwOEH`o$J1@ltsme%KXnTB@jf=D$NJJ;5soeSuf8vwt?&Yz2OK!1TcV)~V&*uY9j zW8!OIN+VmL_3zTon9zq3(^r4rb*7Xbx{KyFdPw-uo#3a~*J1(h06{54%NvUyB@pBd z%v*z2oxy*A9~;EpyO3o-qD=1Q7c@<WN^vt9QAiY~A!*CCTh2LY@@+|hLPrZXO_SueaB5^~Svl=q*?8|;DtXb^n&*mtov zxHoF^f(bWm-xbhYPhfY6F&IP~vZ74@H3G(vp@<$&qzkIyK;nEAQ6xEe z)enRZpQuGGOb?gj8K@cJdvWuaKVQv;!=7Az5~(3fU!8!+*?=u@0`-H$Q{&zO5;GN8 zO>7can>3gEUD7DyySIuZmz@ivDvU+wuz3GeoZ4*_4J%Eaq1+;FSo3TbkF%zF;=Dlm zYHwZWky0R_w)?x=euv$=cVZ>DH${P2fVvDz4pN}$i~#w4xAmx59l2SiP5#&&aON+x z)^+1z_{P5LcAAfd%=`l-*_P1??}(ck z@&d1b>g}I{hC?#Po7=+%9zjQz{;G(3XD@V4xwszmXC#4?nPzFDgKHmbIRHa8yyn`7 zS2S$S0oy))*%!Y8qK*ykhN z5YX+U$|l78%qf3)QDYd(G$UBs%C~!P7rWQHblR=>JpRS1K7j)8v$8>l8b4R(R)CQJ zn)`9}gQIIgCk1n7?4<&Nl>V(Ni4V$CZ-eN;lDU)Fiua5=XlLc-C2iI?!g7I$K*)LF zfGv;J3`#)2KBwp48{QpsXQ%Eme2L||>^41DBc-nlUKV6L>~qZsRw)WhlP`d+f338QB(D=yys&Dd79yK_qXRoR<+W_3*yPDWhnv+$+x<^gho!rN>vx-+I3VS(*5 zzWE&|CXygI%#r;8eFB9_e^@VEE?X8R#8|#oRc@#1>}!@Pvn4xWLE2a5C|`{F#ATvl z4GRhDxeJ5kukP>|uC-N42$&k@)GOKzueywbUq6}tO&h)XL7;0ecR-R;&8>?brylL_ zafosd9KZ;jdtbj3bu+t++97rG^M~)^9X>lm6_E?>0V4oyuKjp6!^%y!7hqIau*ZQU z7C_pxy!DzjsDVU6^cP8N_f`nk6LT{5iahTdy~G5C))6<0!t;Ma3e^urk@7)T^N9i_E2qS*lBI?HwW&AgEciB-+ZRj^5~k`QE$-ByRTMJd+dE z*@qGSst1G9c;fA??ShO?Z#cLPKE~w&HB)!d)2BXgkPT4U3ZUN`>>NFQ!FPpJeL5={ zn_L?@t6nd36K^kS?8q_}a=&>eU07Bx2|4FQ;9(+;^C6^QTfxn$ED(KvPGX2q82y!eud=+rNeb;J4>S2VS`2;g=u zjg3YW7;2^*;M!}GQDEGC)O(lzjJWR`yzKtr?f!E)pj_%{dUt83cAf96`@Vf|MASQ= zX40A65qWOE#XusP6Nx=Ihf49p+V#S}URuh;UC66klf&4<)%`f}*Nn(PTCC>vn)6EX z3eZQenw?C3JwEQGSZ>;X!rXZskjJ;3Sem8TrhVKNTSt1B+|A9^-ikSLn^g3eNc7}Ws*`1^weG-tdp1+=WPB|GDN zTr&Ez=pURhR|!VZQN8{}DbgE2=JeZ1dqjlzjB*=8`-D@GI8vudN-ZM;NJ07jukQ>s)F&sGbZ1Pl{)oZuX z4TZ-#!NZo~p)rH*_ww9;>IUGY)+fzXVc`K@v6&TliZswDgm%7;dUanMkLr7G>oK4K z4UAPuORd^K4&1(0Q=UN@?C@D+S(wgPoJt^|+JXe)t&u}VMG#bb+q_KwLx1JlHC$Sr zA8llL&PuyJdoZ;*GH(WqNP0znAgo@U$8j%IW&ir70=fV+a0SpFn@0ggJWNm+QLJ2i z_4*_>%DYnm$c*W*jJKtxudFx76s`c;FKF(BtoPlJ-MG+-JE*w1uBZB?eZnNENOzPeO=N zxrVn7qh~>cMCDx(0us|y4dgu4-mf?3lU#*wQgmJg_B^p(B~j1X;XDq!QfsAAuT23v zYaDc^g^;-}(_*S!NH97HIqYX~%NKP;Yk#ytS+WIPl zMuQ7Dyib-#af6pv6n{jdvIG=)6@@`8^0+v{89)PYAmk{NP3>MpGfpb{4*!h1$D=S%BA`}3530YStJT()_)qJS z+Kq#E%x%M1!NHIVv%RYyPkNd1!lxHlFSR#*+9+%_HOaV}^e)$5itGw)R}g_mv(1Db zp!jT3l*ej#w!;#Hx~Ul1nNuSLJmk;@5xo3O{lv!B`QD->^x!SBR&x<{PB(;;0Bq}f zN}v)zhj>rMH=KaDZ2_NHLy`7`*xoZE{$kt>gFq%{KmzlxuTRZ2u#z=GW{J+d`KYK{ zy%8H~I8W;AqZBn77^x04e^36wa+?DqFeBG3DGEywI{A0OO$=VY!YvnDC3=}$1I86m zXvq4{l8kJxUWo-6oYWmz0j-X;#6Im)rnVKQ*mAlUtew1u>S0kMG=7$&p;ARA|K!6P z00h)X!$4#~6#z7|Akum@;o>A>v78opy@`UQHE|C`)>K@leZ0Kll&Q=Fx>^|PWn}y# zPW(5sDfO7?tFH`XR~VnUyI?k$mZNdndO!ytdq=9?aOMfu5 za7`WCIv^Wvoo*TQSrnZ5E92{!MawH$`_%NfNy_kc#9_+{NeKmVz@I4m3M=^U)9DnE zQ#J~7GqqOVmC|r%cM{XCC?IL4Vp$x|1Z_hugW6Mmg69^@P1s4oI$A~;z*+X`5(t9_ zko*-t4N-xmrB41G^4rJWR(-~;dAsM)6xJZJ_5)i0(9Acbpj4LAfE85H#FW3Ss6>XJ z{bNVkW3g@5zT+2cjm?og$)~Z5{e*{ zs$Ajwb01amn`vS$ntZrUq*Cwjn4wxj@ntEFC`KN88QPgVD9j*HU^Xy2TqaxWt*4D8 zGm93c*-R50zxxgCZp=4R&dJlUpIR>Slj7=r%?gls37J=%`daknO+@n{>DULKLr1U? zq}Gz?>=;X6>M$BelKT&=9)cRp{2w#VBB*n3w|nFJYS{m&{p*orubSZ3lzdc4$;ip^c6N( z2$%aG&d1(rRE|8X>hNfpkS(+W$s34i&<`={LejVQuwOSYTCLB38nBX@7}EA{ zAu6yutOJ}Xu_L$`>LBYxwx58|QDu!SBS}H90&V7i`W2~16odaRsjB$+L#yi6`)bDJ zhKzbmz89ETAULa>hB>Yp-8eIgc+j}sSHkxdAas2_c%R_A&rHN^I4)*e2v60IB>kcH8jYk1^g|_hw0km-zS_%;}$mIhY#C!C5 z#r|qym?mhcy99zdgU9^2f6~Vjo~i$kdb0?j!_(sYU3|@*l(_0tR)1Xzcv-eL_-`(2 zbO#jwr#Twrf9+=0r(RyAvyKW2jOU9=H!N#%G>+SByXW;Ac=#Bw7U+z)?oeJ#B$WOz zz^L<|F1+@n=(7hyo1#4YPJvu~r87jfLfX*T8@%m%k9l*~9BawLw`9Xfag zQyLrgs#+k#lG&2RDaFl?aOJuV1O@x;Xk#-7(Ae`OLp8$V6Xr>CIQj~BMgHEfvG-eP z1nvGKtAnRo4$Hc~xAa?zu8aw(v1`2ncwM$T>?w zqNGvusq;{8l-?DK8{sY5ZY+qNpYaEKu+JL+}16dC-80#O>7$+ ze+7;**qg$kpOX@l(3^gk>51<-^RMD(%?OFD3)jCWlCWqbgfnRJL@+!SZ@f5D!CC9Z zicIJ+{oYYtF8+J0N~e~c=9C?2?hn%#GC!yDR4k4WL1Dha$k!4QQ}epPJH*p1+Uk>e zrxt%0peinMCCL}BUsior%r;bX9)0`(7s)^ZrvVMTEQs`Ll(TmM+S9 zLTEEq#FfrDy;Rf0cKkL1(z$;~kPl>I^rh$#uc$Q8z*oaM8R@KECP}O4hg&OwDUB9* z(1|d}CU&XdL9r3Ly~I_UT1NhG1&)w$Y|#<1D#yM{mNYBD2nM9&+ItaejK=Ko*Vp#u z$w6KjzykN{!Y}m)?L62HxW9TaCAq^7g$e;;ukkS_fZ&)WN}wK#sW4K`gB^brTk0!u z_83glg##!>C`l4EqnTADB!;{}FOedb<%CtMUy)zKnz3zqi&z&0-aq7;dygRW>t%&} z{J2-4*#~Ehx%D2bKO2?NwYH;~9df9WgHlx=-6$#t{alBd6bFYINLI_yVBC#-pSE-sn;W6vO76tjwi`xw-lB-5PcSFe(La zb>?1y0>-g|9ett%GLoANtMWO%^x`OgF+cU>#vX;)s~S$p0tHW#&O5k!qh1lZrcbeu zbHYB0$OX*B_4BZI#NvVDUGR4J9C@{EzgHuCPjh5C^djIYDhl}lYy{mPX*Kz0ZwLYB zy|z~8Hq`Dl;?2J$#KVjih|zr&4r4thD3QU>q`n|8~(h3%&Y`KIk*=qfOejSxB+{2LP-%K-#eI6f+!c(#>373Gt!IwH65Y_ zFC&sCh(5A&qv$gqy(XNMm3{z3TwUEWf|R|e)+_vYk;2fCB-ISF?)=mBB8|MTH#01{ z2a}-y>(nm_yji`Ieteq~@}61kc3a@#QgFLr(-lzbWa|$TMj%B4Q_aIO4xbCwoL7Gh zSqV#L?yniR;=Ywx5^)h~F<8F{5c>YYPnKE-CZg zAMFUiCm{lt-T%c{Pi;TFOF4#PF!m&MbQbtU!hp60DfatZ#2aD}nzu!-+aGT3zxjm? zjoAH)b6-k53=Rpj&c4ywOCUR`L1?!#&tfJYEq^EB4-CYo*Ve4cm(TEP?*Wz`bfIn? z6_2unwAaQ>miN6@b+8(`$6IVyvth2yY_Kc#d7}uzwhM?^GrR?}-v&=oq-WlKrt;kh z7Q6UK*!nbisl!uD{MNYKD{}dMQrTMDIRoi#YsTopM%lB5)}Br$Q#O9ZNH?nt?EUT@ z$^k0!T%A49X7EsBrP4Nxl-q!DEEX0p^7HYPAo9_7x^U(!mpo?nh-1Rf_!8g%DT*|6 zPj{*o?9U@?5?I1;9ELYCnbw62Ns4OYvA}|jBo8*0<@c*Tt13&C zNB&mV?vHzG+8QCghvriU;1P+ZBk;VlElwMsyhzlX$PoYgo1evKd~=G+S;EZ`uM*P`%m?A`eXd1+MLEeb&+%1SN-_|Pm^#YD9O|GD=Fz?=5E+{QnlRGXWAO> ztaaN)`H*~{Wg?vMcwB=mE7p>FxwI7G@Xg;UmJhwZcnc1?;Fl)|97PIqJER-ur-FSZ z?38pij1N8)4~0d%ps4%NVno3v3x<{wxY9%V$AG+#m#u!p|K9#{*BgoEI8?ugzz$F7 zLf8#X2a!L$_r$)7e38%MJSSu z6W7IN)r*Qbyt$%lG8ndGvs!3Qt;Ich8#DnbWGvTJag=j^{!H*mP7u9^ZZ#kiu@(;V zj9m03FQMbE5`2$x^1(IUG_G=FFht=s0###Jyi{FXHTYePPT|OoWqQo*+rWU$qL$$$`ira3ocd|_!X`WCg^gnUE{@6H%%nucR;ZpZ3!#mSZ zitH#f|MuJjRCE<&5j(_4x!JxKV)gav>#sUAksVzW1E<%bLscQ`ukG#H z#h+#1ho`?{kC*R_^P@0|k!~KSCGwigX2$EDb33kVKc{Yw=RC3uidtn1V`RU_1kN^v zF!EB*zIy*b-#gELrIm%!`I}9#9&ACvq$aFgWTgH*D>I7%ZcP!&2J>{8q(uxqns2V1 zRu{Y&Ze3W=gdfe?W%c&gv(OK@8cA;H4YX25*?!qjnNq#r){?E6MvO%Nj4w!OxW_1-lP4FoA$J=UMvNF~# zeG@qlhI(k~A42ZZ@J*er&Q7xHkHDfZigM`SJ);OZzMPX5V!n>aB-il9eMA{|WOcRL z$Na8#Uhw9`C}p$=xS7>feq+vPwZNY%g~sXS!Pd-EnXJ~UP(#zPlX|_J(-C0B%Xz;2 ze0op+i%L-KH!-`FqLmOIoEl+Jb*`kbVW+!aq%H1+eo{{4imfoR7&4J)m#0D}uA0Ot zBv9^-*9$Vz3lcJ2jHo()y01OD(bef*1w7$p%{&)GAVEM zsJZ34{cH;2b>NpSul#>BU3FMgU$h+>>68))B?VMM1f&O%?vfS(K@kK-xRlJW(4uO1-N8%{6AY!lGN`Yie@*Iy>X4HhfOO-@t5^DGCB z*m=%-&-xefum5{;a{6?f;U1f3qI=G)xvc4_w_aDT>W)nFreENXE!mOg(@lO{ zJSk|9WDwa*RT_5$VJO+hsrKSgD}0S&M%-t3S)U*uah34!K3ESQe84p>d9Y^kw`ivN zYtgF0+n^+dGo6{uqTwAFlD<_ECcl?T@Up!1MYDSog_6GrXFtuO;TK|2nvQHDcWQ|E z;f1b_8r4aw+1?JHw^f`&KL#W7pj+SKAAhc0faA<1<=lPD+Gb?lleNtE;UwO(xj7jK zH_!N>J3-c&y#Kw=*{&Yz(;w%R%yPv~$4ab5&@pz-E_-t~Utv}{Vq6eKZe?XOs2^Fo ztT_2igG*lsT_Ipc;qVu`e+e2Dak|2AUX~n}N)mr=Ec3OVnyvHvMr;Go>pQyjwj%JM zbk~mz;panAQ565J{ zx>(s<(?h-}XZ*E{qG@7cYejmHVo?Izh&7_I&FS@+Vl%9 z+USXTY+Np`@qx2Sgnm}G;-8dK8+`#3`mn*OS-{BZM&Pm0PFR$JR=ZO_8+1Hi$0%X2 zh>|Wq{IKOGBhtE8kEZ#L}4Qg?BZxul@U;-AUHqx1tUn<%IghWWo=fH}Pz&1M1J*!&Jr znlSo6@j;=*fBhG1JHk6celSDoMfX_i!dSkC)5NE8xB9znIOQT#)Th6nUpk*WNet?> z$zddIUn)BGxJH7M`I?q4oZm>`eM2+^Lh{6LgWr4`1b;)`mvRj{+vkZ%zQqeMkqh4` z1Gm0smK9rqa#rR zsCAHQzpul$U@Ef~YTK6u}f zb`*v50qKE;o?^=566=3Hp}K4RRg?wN7VLCXY4t_lS9mWR@fq18yuw z96i)^PmwPb&l7h+IJ_`(?aMOCItxVKqVRK>k=_`fQ{v2dIqKY?DF?-?y^kMRfI?m# z4r&fGP$;e&vE3wzzC#D`{a0;Mkl#Y40M*gXMB2Zt(@r3Kfv}LB_1m5*>aX(%iBNSe zZdA1O;(huwWLFkM` zU5o2$uHBnnkc_duOvc!CdYU+D-4m;yh7pG0-U~f$qL7C!S4V2~N#iUUmAg@m1lqFi zPXJi#5=YH;9cziQca^?t6gCR%A7quXiR~wY#@}6XTGz-{Gq-1auvZ^nxF11WE2UqU zGny(_j}i^nZ&->) zq1+9VZF=|O3z`Z7!E?^nDau)v#6xs!er{rDLzlZfzSEw8Mb1;rQkNN)SXm0;?k8S< zFU@c1%hQW6i$3-}V*>?q6ca^eCj5&%91Yofcw7AkU5q8$*1=&>A;vDN<6Z+>*Y=uT zt7t$4nrhji-ynSluL2avcm)SIkRaJH|4w`}Ae@ZsyCWQU=h5c&?3}8&--LT$AGdT7 z<$k`Gk>xg!KpoMBK`MIcdJ9{{R0p;@N;)9U!bDyiyR!ztdp)*z>=}v!X<_h-kvHRW zura?-Hgz9`>hcclGvjyeI!TM=jd>;cV4##!RX6}?UrdD7nD~qA!Cao{*Q4mnoKw^f z$@6ycksQ4Ub~2e2Nkuy7fG>o&^w16re!eCS?`1l4ciP|o$O<-&6Srcs@^?pI%Z6mn zqh`K_IdSta&#)7bb@IMZ)1PpRuS}T|?tBLf^w6GXRa|cYVxpu9bqQ_JxmY1TUgt$x z^V%v%{cNw6(X~$zx?vRJzcaA)=tQuZW?>Uin+OOwo#kj@U2oU3%g_*WqHGLR z?0m^Z{C&E}yfM=ri|We0AvNd?@vZP$0Z2e}gA+#_pCByVx@~+xH|NNP#!@LyJSeJk z*`H3AciHF_T6RXs3rzVr=V1n&<{pR9XzKyr5 zjFfo9^SR`H8vASW)u}WHs#Ki*kN4PJ-H9f1dzY75NE|rau?tP!kcsCx0;AxJP(l>W zJvwYzN+=J}-oN1YwI&A*pm|vQWIxi|Q0;Nffx9ffWb$y-f=N%a>???v( zAHD#B>ud2!#27Pogcb<&pl$Vc9rw+`;2}zDHsIWv?YoSoD*U&!FH3ev>POX8EXiz* z6qQ_u4x_0nxpjB>ZB~-nsNE} zVWb#)MdP_evPnwxA9YrAJd7nr6_`_qHa>dW2{DJxYh%(4=GkSsaO8p_txBq~Kq%(CVHkG&ng*~FV z4V#Q6I9*SW5|B3n|AfWCp2C(i)(4)osW_|TdG^Ba8K6<$dvL?yGzB#V3MDBXU_+O< zKGQ}fFLjLF9!@v6&wjZ+|*RxeNAjmp#C|8ALiP7p|5UQPDZY9ZWc@Vo-e zsBg}{n~ixC(4Wa0)eCDNrXeax1Ii$E|4AK+u0jC(yi-WA#ox^ag2)-I-plVLs|y+E z9c7BZ`ax=@$Q(5B4_H##)Gke6Lb;oMD!ny8-NHU0nTOSK^sVnf92Cnx4JT|(_SQm% znek*pAB&!ajr%;Zvz;@s$!}%4w4sefcFqcZrqa01t$SdD@beSMwdC3Y+ZujYi{buk zw4LnVM+gDuHayrkZ9@{zKuac591`sUa0wTGhYvCEtL`R5cJeqmFb)NJqN7_q$2}f@ zynz?giMdk7gCUT?{7Ic$z}k}6dC?#&kkoTq zd^Va|iE5FlxD?<@wDi`C;+55ue758Hq%!$*_s<4j`&44U_N^63LW3~Q%MuL&SLFMa zO{Miu*!^^6q67i{Cl@%qw>%LtQl^0n#b>z@nRT_QFCvX^vLI40`5eep?&Pn`0theP zC!gWG>$ve(ta3?MiY*o<`iMU6iEZlA%Lk8gls639y;g5Y{=E(lT+M?QzQ9GlF4`D4 z=$TJBJD#FBG4;kftMjh~0m~df=~}eK6mwk7rVgybw9$W|?JpzRf0Zk0!{GU?n41wT z_BHEvy4Oh**yBzYk`|UHUM|kXIa7Y|n2&Gwuvn%jcG|$N#kFX!7?b!Xw+<>I28tL? zMRR3#&SHbO9!hVMAQm%l@Hcc8gNu&o2LWUiYtd&$En1@jm-JRyg>4M+W0as`gWgi> zWdWhZxujwk%ps0~*j_veqH%uK=_H)uf9$=Btdv1cqYY zeGWWk8^U?=>SzErpeuS?cCdXO0k_4T4$2Ostux-PnOKHeAOkp40!7_!2K=O8rG_K| zYR)$%^QK$t2ethktxDoh>j;WiAI(B08wcf~26yZKbe}gJd$5KS7r6adO@;XSTE%b< z#Z&n%%~gurRHBK1J7(}c5j0rWMc&A<5@iHzOa0B?&-DFgZXNq-&W65%2>N5@qw#%> z>n|fw40+;m(b9)%2Y#0!nuw|PKo6$F}0rw=Apfl0J7ib@iL7&Z>-7;s|ILt@-N zqPeKps0sT36IS~FeqVr+i$Y;Sr||x59Cp+7OM{24REgag0c+z)Ici|bwf?LuDB@yD zv=cm+(C7Gm-(Z;%my$Bi{ns@r6sM0>!i)}1FJ|L(-!}dGA&Yw=8|Iaxi!|oHy()>{ z)gO`XUsKeE8tc($=0y;2rLM(=LgQuf8rxb&O|2%IqUE6Rgj;{BTj`!{GQ3H++bp=f z%2*g>ABW1Nm~CQ*fBCahj;Egx3^F%oPsEodFfoKJ{tWy)Lj?T?V8q1~2EN5Vn?45m zugodhr}_W+Bl}NZn=>NuL~?x~<}m*zsyx##Z)X<6+ltAbs@y!!?eMwMCe8g$qsd>G zG6txmC!emR_r0&WNIbu}{3d>tCA&pSP`#whAbg5E&>GaPpis~WNKijf>huZf6-mm_ zzE+5eVY@-l%#VN}gW^y$S{AP9St)eb&25+zanQe-#byTKE{3NSc1?p5CPbxkt9g#U&{5jNx!9sbh6)3k4KGJ$3#)eGNK74E`J0X36%WUm z?x#O_yk3;(Yp+Qc)-?E#ir?6C_Heay`s$N*v2L0%z0+FP_Yn9On!Dr7zTg9&)Au z_@RZY7iJV(fvNp({x)*>mir*bSOAk6xUmsJwdd-W&%Sniw-QRLg&dms%8jV`;Z6wo zn3T>-zv<&YX5n=Eh$^t|)`#$T$Ur-@#7`JSgtDE%KNt4-h2Nb=HpMw`2HVQ0vV$e0 zJoH!PsrP$Jx?o&z7NNGDPTK#PAQ=&VD>YtifBO6G8}|#qsNkQS*zN$9;~pGPAFZwN zU+i+VCzMrLkCM{7 zN&703XB2-!$wa_(31DqRsL>c|Bi3XG@FJa*Mp zy4h1t@jy=N8grg`3oqNom%iJ4g%|emF??-^ z)8^b4Ifd{OtP&!mD|h$VrCp$~tvM0!rFjs8dlQ8KXDRfileF#*CT{|PF=AT(AQQ*M zmreX0&dE+4s+h%X&|2j7cLf2?91UH>ySFTpmYydu$VLik0-e1o3}J}TdBB*k@wab% zT5%NFvB=t^1GM<^O~{n?%Hwxam!nw@IH!qeTy z1{@F`gpNT`jn0KfBJ)q#Bq%gog`ir`~Fw0VXBbP;)NSU z^4>4EZp~{OgFB#qYHaj;WtHw>)&fX-dWUX^qF-I2>*V}W3F+Kis$5MIqbv|&7kjbl zP9*~^xAY)RZtovxV!RyV_vd1n{#BNB^oKbDwUinEd-OPE%ES#yJ%9wkJHQ>oh7s1k z56QywX794(*7}V6reZ*@yDBOFfutNydqI^PCT8G`*H*Lwh29vO;U^HQr;=f%WzT65 zWj1YWgAqq)S{yv(5H!7Tr*8WMb3B&W8D^@BAlp5Vrq&4+h6N>s@>9ofBij2%P5=Ck z(bzfu7^*H2^M*hwA~T@uqo>oX(NCnqgI2XSIM0q7>4G*rCh0wV*9wq+|GWUBV#d9N zmh1?GqsTGGO%Z(Lm}Ya_52wDU0ta47p(NXC3(0E^=IPfPnR&-?O!GRj4lY*J&gU(t4{jZZ2z7a=Om;&D&k z>83PhQRNs8w(;{&(NJ?4P)~n0CfKS4Xv{O zZDle{z#d4)J)g+oeYzll%CW2YnnB8IFhc*a(L`^cC0%5=QH&{adoDWeny?q{|1$EE z<4rl~@fMG^YIf3zw6}AfXO*?P*#@Zrf^o6dsaMl874pyxgFJcsLkQ~1-e7iEO4?g{ zh(Ju3SWY^t145?P`(XWam9YL;OZ?(R$9B?f*;hqqc2O6{&E&O)tW%51lWvKy0nqZ{ z2Ee0`!2Q=|U4smiX8E?|nSv!4!ono927TkeM85Mv_gZ?+m#a~hm$=5>2MSifKd!yZ zGFhsQ#4lTFyem=TVU~E*Tya2m{V#T&{iszv>99nbBrEL6L(UU@0`6EBt2%>s8gItS z&cq}07ug!O3xzJX(f`f!nfdM>oTw@rv=RR7w3``YMuu!MGzIN!aw`vrNmw!=6x!;t zcQWS&n3v<^ms;`cjUSjvci8g(djYt0@wGqTzplH2$)%)}J3Y4FjJggN=@BHYX&L@x z?MT`|XU7O`uzdd&(m^jt!YLKGrl~zm-xiIKtTH z{aL2@D!AM6%Z;Dhh2jj!Es6~&(UCr1zdiwkb(pYoCJC+y3h7j3xXpjVpSVq*LpREO zv%zR!_TA+1;9B*X@(mtWQBlNzBmKq3 zSnu4k`;D~u`DRZXA~Umaeq;Ce*Iy#UEt?;`d>_ynzd~pD5S39X(D+wdJ$;Ul0@!=U z3g5MEbK$(K=?Esyi1rtoi-OM11}UH3=yd9kjb^AAQH^|p)Ou|i8W=@l)Jc4H7^5t#&AK2L9j~W; z?VOAbmS^f_)jh7IyV3|$J$w+H=4AXAKq7JC9RlZCrDZb6HBfZ_?f=ciUra!;&5GKz zZ09yCy&|1A*MEAif(VKtnuvjTvekZde`TQDosC#GUZg9-?@GT zrSkN6rK;5D?bpYPD1sck7ZBH$>_uNTCikUNY50It0hMB@BuibTKQ4VIb z5?;a06pJuOjPq#?ww-B)y7afPb*8UQ3fc}8)*_?@f{cQGitm%{>}2oz+=!J85e?q6 z!y!3XTU37Nd7rBgD<)T7T*kjuBt4of_3?+W}Kt`?hGMS^$|^!f^K$L%{? zUW*)j6ful`FAxYI~bnnTi#nex@?i;wuveL$t-By4Z#PQ z^WR#(&nF5ax~|J9uhf4s2>4GLZcjaU#$q=!WtR`w@-I@B$IReAVzpd76f-2~wN<&v zNbN-59%tJ0i8-}V#hHI}QBu+U@w%Y2=MT6EZ{|km+Xkb_^pgQ`p=hHncO#Q~Xe4@I9`=7L1mG*yhs|EU_q{5&cqRuNWFLw@ z4GO_OW5u}&%57r0 zjZQu)v!fV1yKBs4gCp8F_z+_I{Sg6Vf{wBxKzRfP0mHL$Po6uIV9WU89JTJ%3`aK0 zs7KasD^i|r+vH78@Zss7WS9Apg*EqxWX9gq_I%NpQn4)bym z?bZqPb@CSr*cKDqF`Dusw+H2T+*`{@hT<41pyLL7Z+`2N$+ zyA%vScaR&aK|txX15}+k`&69Qb-=4q`cL&;){X$OVQvd1F{wGUSFPMFUJ-u*EA_&8 z4?9~&Q;p(K?6)<&MQmGTF0Zn(CTCTg%{>C_f6lmWTP^lP=*el~jfW+~ihWz@Pglf= zdYAr9m;XeAt3(6Aft3nhWG{`}Rp|$TL~S7VgddUjD&naidUG6Nd&@{oigs$|Qa}p| z9dwAe9;Bz7OvQzi<~w%r1$NjAHN0vDje97RZ`Hg=^%Lm3D7yO!AQG<6FxMf$?V2kx zo~rRRls6&%-UALXcqcw&CdE#ePQC*Qch=yuAv$4YoZk9D9X_4$O-DUQU=?`&Ju0|t z9LfsW1o}VF^>1<^XtI%q0*Jh}elgJ%a)_1dZSxx%P^iNlJ|P6ox~22XJtq(hVn{8Y zoPpFx8!#JnYqwlKulP3Wg7&pVCduT`Q2?Uo%*0#o6@gK4L`2`*+L(SO^`D3_I_g)A zMyWS*_b1voi!xPtgL8oSW?2vb6?bpESP-}mfEd#G4TK@!&bWsmKx$W{FMBKd=(1!Q z?E8eSsW&a7EH@(j$zMr@Y3Ra&@#y!)XlyjRK$VG;LldzrOM`0*Hhpc?&1)h8EZt}; zE#58nfHHSdhi3ef#7p3+$*T)0e?)(1MgN@>8)?le19w%Fda;C4|AsU7W8(NYlrH;& z`3}H^Z|>R5;!soPnL4UDYin^Mn7rm-A5;sMij!k%W3U7W&YRbqde^WDFgA?`I<$Yi z_1X(v*`AQ|r(y^%HfP3?AScS5@64vIOQRchJirT>935%67l<%lEsQtby48DWFxoT; zqYcHX8eLtPD$WEZt5{=YUO%X-Zac_OpILwx5eP3z9%W&_kL{>K#qQVa9FCvpc+Wa@ z3=ZK=tw|~qRK3{Rs!lT7o>#F3uI)n z4io7&+0|2EUlMm$x-x})@j{5x#6Id+G-KC!GM%!rXn)C0&GpG!WPK~|pB)xL3I$B6N2((+0LI8mje(&JYZ)59 z&0<(%n^~_`!DB|m7&ti{Ioq;?#aA82ufZOn{E*vaj|jkVQQ?lVM-)*5oCsMnO+X!f z8wfBXTTCDxdz>B7@my$Ut{p}J?)@~lwI!*P7scu+um7jrTMq{bh9k^;lQEN*R{pn0 ze}affd?0F};Y|x(KS9vA`nrbo^)~kXO*D@uqEwS}#P9u}^BjeJ4ANc`w-d~`umvIo z96p*w2lpru!HEamzY#hV;_2a^MNd`d2$up-3_b0NL+zh76S%*Px4v~FxGfE=-)RJ?u(@)Dg1JemT%i=pE6X$N(v>JB+3Tq)bdaOl zXPbAlDZAN8qNR_ElRuT)3_p72t>#RE5J~A#xy}`T{kSp+zQj@NukNk)UA?JOCp z$^qy?Bh^Hq&80RZYi`(p4bGa9cc4mi&i_g4p^^X)e=z{`dP?}WM$f|hOclS7^8ApXlG%cph%Ls4D!Dfz1(+36g~8uhkY!J|NVT&B2yJP zbX1M`x92@Zb&v3jF~?4m&Lm9_bh>a6M%*^t-xjXRxCgIM*iuXhV_YO%Ul|Vn4|)_9 zf8~n*SKsRpD14txP0RwY)A0(4Q>?HTg(k*YgGpd4x=-43s=x->IsHp27lHELiOBr@ zd@j+06??Iq!+6hOzTs$Vd!><1XA_c0cN#tR{g(>vW?{k;Z<295Jc|)+?*>ehRUN2@ z*M*2kG#wq*$^QLzAU9Xe_Tc^P$zF3!fTI6nljlF08hZ9HJRJU|H>S1805m!a3Df*e zH#w|pukc8!ODZK*gUmT3RXLiNv*ZDa9U^3zA#&Mhf-3pI8j@0i3eD{Mj9mwYE1`rB zhZelI*3DXtzD30nYHTUzACo}6D%F3kFgOyjL^gp$NY4KgK!3bvF~8ol4?-=;p?y9PjCjx=|fVlUp}tZeAKZ% zBAGIEI*o*W+G2AP#!Pun-_u8?3%HBlc{N@abE(2gC(R&A@`%?x1ZT3)idBD7erus~ z@w?%`fn(Ku-y>RaGS5Ql!vQ%WANP8YrmM2W8SWDQrfRFM6MQ}$zwDpzjBcv(3z1fk z%h109BpF5?yV*P%$x@WTYB|I!!xYIQ$%;4^gD|R%k;zAbP0ZeA&t=C*In6IqL6?u$ zb3X$9g44gbn%-s9H26o2Mt`{jbLD+@wvSyXs!iN3ZQ_)d)wD_^@%YBJK?yng;&Rs? z%>EV4wu-c;mgE--3ks&oS2Rtt-6Zzdv}8gGKHz`RK%YLL{%gKY4$)su%Gpp`cZ%zm z0rWZD+2o|bZII35brSjksDi#An@<;)Y|nW&gE*^V_>Y1XzfA>78tV?s$NzSERn5vOb>*(?iBeBO4_VxkO?ji z?UBCReIbAOZrdyzQ2pGrZoeSAGH@~$_d2tGL3g31aovjkG`H}UP@8oz zWApkm1Ns(?E&N_4ZRtBuxC-k5ite}hD~`J9?7Z{UWm7Ml-~#=;_5y#KSAd+Y3-@lu zE`@KdR2*`jr-5=+zsdjPHES%NdyGLsC8z6yz>qA)$peG3ZjU8z2+^U;j@JwPZ!#9X zuQ!6Xm7%{C9btcV52p(rlM7h{C5+!@sI5S$(|R$KP|7{%f<-aA#Gz&(p-#}@?dVYWQ(le}F2Fqj2EK8{yNs3z9pZa%*FKivrWyVLIxI8nQhZ|K49J9Ln%K%wX9I!Ex$C7X2J#Mpgf~7>x3$+l_X+% z@E2`o|1S&8WPL5~gDOnzL!MS9{c&*|${|*g=;18PZduF^jgvX+F-v#WFgP!t{~2`l znQ5mAL~|nNHs!ZZpW^TlRcTy8tq zh8B0yFnxjF*`+4=QFT1%mx#KmIEa%D`D~vW z-)y(<60)x@Bhdo*kjKAZff3Wsj8bu>jSTadv9*|Q=h^N*!^i7oj)P}OK4GEr=Juq^ zxTHQ-zj?MBrlkPu0yh8U>|BiJPG=jAZeXs-H&B*!?gXd^wzY#_a5vs08 z(g2%F7x;a_e}Kv3-DMF^6zj4c4j<&iPxuf1KRmt`xw2a^O`yK=?_ZsWlC1aq9C7RZ zFm0pG6a3F)pNqBX6>+2q+W4$%Jr99LpR(|U;bqp`{AvuXCR2Ez&a&=|R|vH%527X} z*&+?QL4mme=Oc!NR+(~`z3E8qOpn1SrsKvzMd)&$)=l%~l2{kcK)sHf+Z60B=|yZG zab2kegi;VM_cQGTyT#Fo<&EiRt$S7vI}7BUNjwi*e)k+!C!49qp;V{pZ9YfrSgz=W zza2H*`8&_?%mV;g)I$)k<>*^A+gKV>Hyi^R>e)tbuiFdmWMpNNJFsOJV~tvSdrNq! z%(m--hWur$2hkLPq)r~k4!A|G*=8{;DeEbOi*6Hs77+?+%Jv~6znM6_aTIP5u#+p* zIZ05pH3LYv$`amTVPo14Yd8g_%+(QMh`z2Yy|>#utl5}*UBB`>n#>^EcoUR1r>i}r z?qP4~qG@}><@SsrQr@D0ckp_DaXxPFv3a*LPSC|R4zn+px^_2Nku)w2{SvCOni;FW zy*lx0!sFoylR&co;*dE%xXB(A)#<=%+VZmspL!x<(B9P5{Po9M&Yfu`%o)-C7!{h$ zI}kgM9n{64iJH*a(OVXe^F2ULzM!>>I2bo16+rd^6#55NeW9Kmmb5$DP~Dzd4Ub@t z|M49?$E{PeChla4VwAj)hxC8*RcC;%=p&hkwKewQ#YY(P?exz(eaWyc_@$*^-g-gS z>w<*JD-3qrn#mqVxzS#&;%(pQzT(@{>K`_TTYmMaNL|fW{SOyXL~XY}5w;6cr;{)0 zJ@feE{oA1v0k+8GmFuxys#z*DJ{17bI@d06^8PH>7)ti)+HO;jj zI|HorA>kFG?R^8W$J;%c!wdWqcxCTlzQ-m$Z*}?p-&itJzl@-~L|WfA(;Lt2QXP}JL&9iA8q$)7MrKpZczvZYAU5A!iZ{waM>gDJh>e@oj5 z>HCp{wmcmxRh}L7xk8*Z^BeGumqb!SJ4=v6g}#?>GI{Pnf$6SjErpkDr~RUyCd&=% z77u<{tr@Bz1kG5ZN$?jCYO^0D8m7-B@z`sK#8DgoB%ySqq23R(pOu=gN``1@IyBi2 zcScJIvDA3493X7-F3s(6*(SIh^kI;pO!#-uEk8VQyf?CD^vWc5u#jymh`pEajf9l@wt)zc zX;&nbLk(LVyFVFeJ|K~3`f`@HD@JAV$T)z+&M$bMKPJ`v&bWd8`Mf2*YT zZV&V!i5e;5xJioK(zAtA&dTKcDj!`Wqsb;Xhvx~-7E6_!w#uuisLA#1?bROm{8GwH z2HWv1=gehCzb=LEAE`@~!qwx~I;Kthov;l21II643q1kXTS9v4JuEyqELvA_epfm? zn)gRhZsq>77rgaL2pngCG`GnZ%X1L?qm+7ib!prY^HR_4kb(tuLBW z(g!p$>*{=4s(-;4H+U-jeJ$47hsbU);EGkw&<%G18@G(S6le+ovW#+w9)quJR z(yUhmpGR|aoADnT2lfEh%!$PgMtdXVp*4`v612NWkW5QQ)0N_~d2Kcza&)Aj1%qZ! zPBSuv8sPUL!+Bx)93Vb0$=f3x83qXwxW1rIXiqmll`iS?YuQJN!Mt~X(sa$YmMd)h zJ$Q|V43)`WL^=~r$YWB%@B@PSae_|k8_gBDA&LuW`!X`2-?`AqkR@J|@w z#>4f#hJq`v34g77%Z3o2`oTEH;Ba~Kirf{_bgZfMl0$~|!{6Mx4@|MVx!_mp+@0r@Q$e zQmpj-fuLV=!^O35zb85JzGMgNCTGsohPlmIMe*+-{umF0jc?u80y|mZQ!AwWmzVPq z+^vT8xD|O{EYTX$rn00D_YzQ8=;2#R=H`6r51g!qCN^2^1!iG|8vKy%CQY5}!<%`Q zPcO4V)z^Ad1hC}$D{*HeuyC1p-|GS8K_M1Y7)T}jER??Ef6h4c2Zj{sf#(IkrQhiG z%q_@2WD8E=bh4caq>sJb4#jZ7zj|$lnGT96+GBJb@wA)v_wz|09%gPYB ze}m73PkGX{ew@Nali|(6uiwqeSKrjS$}5y1TYP}@qH+AeeDVHhsdlK5;tL&j@0pkq znOxB;OOJGp=SvgklqJjy-g<4Tqp46Ckn@k2FT#`{7SVqzOH{$R39ZhbgFyo4%~lIP z;8Ol>$cNdC$D}F>B@jKT%?0!dD@c^Gp*230x}|dn4mR#b?Iprx zDC1O502phI1>1tBZFuIFoQja&SnKx!@Zvhr9u{M|No{FLfzAX0Qvo^zL$#UgEoGPN zIXI2LTMA|l&4e9o_U`k$vfvZd4=#sG>X=Dy1ubo*r>D5Fa)(FdS7Qh#Qh5KXlIVKD z5Z2>wkH0q+2Gl#L%sxiyz~PUM$9QI*g45$AnH7WgcoLRD_ey)Mtoo+LRXM-o)eHQ% z*zt6?wT^D!0&VPr!CyI4qw!-$`@p#P**g|zHD{VJk}_WzXCBRDpD^P5&b<=@cU76p z*1MTQKJPfC1r=yB?m8eUw7B*s@*&z))(A{NPO1ymTwUq-%i~{FNyt21RTKRt@9Ty;J1o(5a!u#4#cvY_IL+#ME@ppmkNvi9&tWv^xPWqE z#88y}IEk*5I;QQr8PT&U^vzf1tRy@NEBN4sVk-t-5-LA=tD7ZDd+MLP_QAw=9=a5& zQx7(rIaM{&hz?wyly0`VmzV>y)yqEp7<#s@*0UE}F;Jwc$h;67`Ah>iP_wiJG>7a& zEJKxd`ujy^im$x?eW^v0ZCw3LQ!J0MT_gk^LihJVI!eL+?**vEH*X?GJQmnMa)mOe zN%7PJ$G|VegGO|hSJ8Yln9NHXt^GM!l*Dl4W#Fy%?Oh_uoyYUJT@CY)H|ROE4`mnK zM{HfJ)*VSRwtXuP8K=>a_yzk&LeI>8+_l%noW&Q~BZg80K8E78xe~iaH@C7VBU92# ztttvBLab`YVF}B@4E|U1fRd!YW5*T^+GANu9Q=|u+i)NhZH~TexP-$V;ET8(S9z%f`Xa(DZcX<_Y~~ zGo1^{P|;SjOA#b+vurqyL+;njhX4o`*geT2h^)7+&3sia4}@HYfM>OQY*ao+@~MCf z@d%}yEhU61wzYV}`KP)ADzAs~`35H&7nm~qAr!gvQvS~ZT~#&uNsW9o>7$zgm%#6# zlEkVf6Zi-3caBuwSJOUH?-nhwDVjLwS}I)dxyKWM9|;=kSRuU@hxbXtq8p%y$IUb53*DaSbW0tN-E!W)UUOhn-uEO{|c%dQmjJq?G zHB$_rJJHFheqE=wU72akACf9WfQ9@EHEu%6|Ur5P(28gf2qH05b1f`bnS2Ug*dLv0FfVdtz zp9Tb0(Z!LO=xW4;d)hf?O4a&S>UNEB8 z*_32}iMl5rUgs4XL3XV?3~_k}Z|VQgs1vv_L!bVAyXDGSFcbBvnoW7eo7@U!M`J5a zmm0e@q&qaud-WGC&EI16vZ<98z2G?aaQ9^P#n(^oMp>W)2a@z>LwnN7^t_q`yY$G1 zj-ekzX*r0o2?Xbbi4zHgWbcKa`cq@?Z)?j3UI%k2m(ijTD#nQYF@8pl)b!@HI4cG@ zp^sCfL!j0?YiCKk?#HZeg`bN^uJ=h%=-*onUmVlkynR{u-Y;N0X6*yJJF9hDJleF@ zZb^3G6o+qGIm@Dq+B-q7YDv7(X7C-yZ!*X?bNjvF2C47Qi%xy7e6LJ234=gK=o4Ba zPLm>xhoFDUbGi9?2$kNWY+!MXcb&F6c-NYm>bSUUqVcu4BfZ)4)7l99cQ`b-xWQk5 zn`!Z_Q~LM!Zg+UA?y_6a;M3fsepWG)W2jPka!FriS^{piDXS{{Z2J#&aNd!}dpG&z zcDh@pea5k+-;IW2Jv(8y9k(Gj&48+Tk5+y20Ta{4nu{;tZ=oBMdz1tthbIqEcx|3+ zOMah{9kyqnmPUKe9HS`lvOS2JI3=uFrtpMP^jIwOrw}!-tOge@f4^F{)-=DcH*G#a zSk1CRryMr#-V{D~z}X~01~qrTIODqSCW*jlD->b#{Z`zvD}Qt(-RD!B)8g`p z_8pJq(cjbSc#4-MiOI#x-G>fKLkv`Z8P^5=Wy_BUjVKpe4mR;#pQJ=R|8AXC=T0d5 zkjW5}-&=|#`XP}((cp9wA)&gpFpY<1kCbT35fA)hfS2nr!t&<>HCI1nK)pL0!LEhJ zx$Nk9O#!j^`Ob*I(^q81ZAM2ak&-08^avFad;y}?OhekU{%JxYqw73uN&c=g0WLSJanjzN#$fT z$NY0&xIN{m2Qk)_bic#sOz0KuZuL`Xlu5X~q4JQLvR+P7nP&nym(DhoL=eX$Qj|P5 z3>&G!P(^l)SMc2_1Lbj&!Xja9Ue)(jO*I&7y7ZL z?!P<1r!rKU#Aj&{!y;>LxgkVa=bFw@R^oToB2-v;)7zb)6?dv}gPPrBJ1Ym7-FGJ@0%ZiG4H;Z_KX%{q zIX_Wh`M*+$*MIfWyH8r%zTQ}Tm09J-g=^KUD|kO?;*sUjL1h_Lk!Ms>!o;J6B4Y&p zF8lhE$vUdb7n)87+UtxQ!v>Oa_M(jvOh%YT7n`R$v=0;|m5Em#{QdWD^JL*3OE-O) zC7X!j?POb%_2Xf;P-P|89CCIuBGcWQPr7&6M4OML))^hTrc5P+zmuUc{}hrB&)92_ z1p_(alrLRJOnt52ypjBO->Bfvt@anDm_!O9rv>kuI4`=mi?Sffz=q*v7bSkU>8V!| z^3k%%d%>EkzdXR{Jp7t{-w~&gYRlK7VC}?i-*CCBxEl9K&)fa-?=lrxFQ@&ij@igE z$h2eJ(^IEEYknb&66eB~cyD3T*-ejVuU2xF=jAndh`RIrc3bSIiSP|tf1?Mm281OI z7FURm&UoiHr4^r5=8)!z;Kzsu3{zc}xRa<*mM(4bT2_sHYWX?PF6?|r|2_2QcQ?77e0 zYp=ET>=L9q?=VttBhtM)Jt_gpsG6ruxcFMvTwDdL+FVom^6~d$hN4&$MXhnQPONf_ zGe4m){YCO#JM!2fosv(h-#4_ULxRoao%Oqh58FOHO-Nesa9p`6rgX@QqDgvRn-)b*B-s^EWqt?$bK4v%nqBOJDl<@(YmNU@JmPxXsHZav}Na&kVHCBgooUzVCYi5 zbwen0j>Ng~m;%j!ma{P`6YH2`;`F&kQJ6!d{HW-eR7pP zAsh)=&Ac^Zw}pjuA3D=E+HKM=ILUP)cC#J{yAVq+phu134>VK{=jhYV3GdXv;oOm0 z#_motK2~g4IKWM%Kg$pQ1?eb$7qhk(PwhGK+>fnpU8}p?*M99iXsOr(J0Utl!#=rV zM5LvvM2Xu;4lG&v^Ni|#D;qC}m$A!m zgaa9a@l1ubU8_Nv-{FiM4_Q*)(f z!17swcNcYzz-sD9{2NsO9ncum?S3rzKm^T28du(19eT1=nO?G?8L}fOvfrMn{M_bC zJKY<}6~SamI4cT=Btri8N)rg&VMh0unX67TIp6vkn|d%s&tPuDa+TC2V`Fkhw-I`D z+!c=8tX^i=hr(!HgXb438EV6PNbc|thNq=+cG56 zjjaVH*GHm1w}~7W3K|+A+rt(&e5PBEanD{aESN>tgOgZ(b$ou(mlJnDe`R&Z`9_5T z-jp)tG?9Y?qA{ym!`!misvgmgIizga3&kbOY5uRAS(VP~c8HB;M>l-ml4!2*SIyzJ z2O8gUlE-oD93$(9={7V|&F624;`Z+1?A1Wcrww5O5iB_+a8g4~^xvkF; zn8i*F)iqz7A>}KjbIgaY)2I3jg^CD&w&ajSm)kttNu&fa0kk(AN&962hH9%5j&s2} zjD!%6+JrQ6t%|$qPgA2zj&^-awylh5qiOYZW%p+Y-w_hUwF`r4qvjq@CkcJj+A$fK za~f?m28S+F(2538T?nSWwFZn z$45%CXa-ne=)v(n9N~B8-j_cnWIBf2GSj90D|~#wgqugA)n*ZM2?o97X69U7!V5oU z9f|J3wi{zJMd^RHSU@|AF;$Qg@G^Fb5eC)TsoVt@du)GvJ+V_|`pfm}Qx-$823-Tl zhoWO$>4pF+umeJ=&^oJ{pUM^mUI(SA+YeGGrCE=wvRg>PBZ&QJO3vRJy!m;4H{z=R zyyR(9y{=Z9%a{d?P}Dj{xrArTd%J4P)pm2;kpM$u=aV*NtU4D~)>N642GJINRC|O8 z6$p|H$pAUw9D0MQg-@=PKGKJIgJ!=*%UW-8unt)!HDf^pJ5)(m_3x~l+h81^f=n&9 zDlE4K;Logx;!twH#RJ9a@GLi#7nBcJ-?4Qa!7EbvL8YJmi{lZE&*{|uC!LumG}%_o z%Y&8X%o}SU-A_U$_#Sx5CeZYgRiPwbVfkD9l?sg0Wa8=PYu!Z>#xs}~2@s0*w@!3Z zM3R;WY&nHjMZ(?D5@};5lEVwY!bA#qLJZ%4!@SFH+38>9Kd9hMy9Sbf7IP6ESBVa! z9jTxe=th}K%*7?)PzGXxHdi&N7_H9{K;`c1tBc8%fv_Z?k@20v$W6Uy7~VGuzc1+h z1&G+A{P-P~ z^)ewngMS|thChPrHSE<>RX9f2#JvTwy#^&)Zq7G+XAA_~r0j*D%&#IGNrd+YT5A2& zM`4!>CG|F#|8e&ho9JUM6#gRpT4iVd_?-&PeSAhOST-*{_&`WFeX6%i=!62z+amrc z+hNA4*4R@^H4GkAFaduQ=E7B-a30_5W5uhYl8`W==e69u=xb4d2x6}qp6d+ zRwh+Soks5q_j$e5r-A@dziA?g6^UV}TjH|(WTboVZvVwUf?G5jn#-YhIr4*!?Tyc5 z_510vfWVE&u50-$UYN4pQuH>*@w=*Ti-InGg%U!}PVEs3#fruml<{>5%BC%SO}N?h z=mwmXM>Bfu%pL}pIlxm>QjR6$s4__t<|pYl{u8_??HzkxYjH7mqDMDMBidSIBEWhZ z^*Asg-Cfz$0<{poAPws1?=4F&6XC0#f@x_j_+?AYQ=s=x4Lr>6{(_K#xG!EW^JO*P z*q}-Kj<#9R_^=)sSRn$v_He!9k*Excbz3&s+vn=ljNpjt~FEqJi4*QS(vlF%3FctGK zJi@>e>B|FIKR^9G?THZ-kDn^NmKuwvbj~a*2rl-Ko4zIguV~EcCvc8FQ#q4ZJR50| zAd6|8j#E9{+;d?cI!|2}t29V7tYzEVrYiX3y+oB;~_C^ zZyO2@4Ifh8YIcmRamzz1SzYBGMELNyKH;X||MZuABbJ~0V1eRgtWhR&i?_ibDV}70 z<0bM$k9r7xJ6ZpE24aN=OA;P3Q2ZB~W}asdhcfb2p@x%{3+-{*@@ia5+?KkA7;rs+ zLroekSdoX1Coh%8%)TkPOygd61)<*Sx?Pbi(z1|{?BYxREa>B?2mH5{lz+M;|DC{U zds!>wun7`g^HlWfc0%sgCeC|4G(|J_1AsD!u?Ky524i=N7gL|O)OdxL^sPxU*ux~) zbM;i?mog1C2>-x8R>-zT%W>o#%Omc{3fr08X;NhuI$ z?h<|j7mK@(fp1_*)?MyjoP5FvqpaZ7X_RT9edvWZG$?SAHlPU-3Arry{vf9NbMPdM z&u^i3Ht2DV)GdEQ?k?a z^&!#BOxzJZM^4yh-nV>j&kDA4D0-!YOSZlNiTE4d)f-IfUk<@Hmq?r*6Y=iGrGh)$ zEnen`|9@b9r?Wd=0uDogpz>G1R|Ayyq-{xrG3Z|kVuD+<0_px)Ub&)0p?fJ{xlitu zvjksYe0E*k%tP(Q@FVVUK>7wKyq0P>kwsYY`)R1*9fsMdL4|8{0NPz zPulr84@?f8Z!j(ZMR_q!vfMH?_G97>7;k*)Wc6PL4&Z^d*ji_dq~WETuc-{PRD`mp z(4#IH1PRxUO?dd3YFqf9Lk-jOfZa(pj#fej0K9yG$)RTNkbvhZyAoH(4oU?y!FU38 z;6E0MJ#0SJHSn!d98z@PY1pIlVLe>zQAvz>IcMu@i*A^)Z#Wv_mO1aZZE%urH#N%xY~%A`j()Y4%9V9h0XQtG`05skGBlA z^j&zt1Gav2o34JgPE=Mfvu@z60t$Y+BdM8YUo+Wl1s3Pf>BIc#SrTr z{x~?@6aq?x>FdR*2F31Nk&4}2Iy5O5=yHz_d^_?$46HEr*YW2u=+=_S$VDT)&&#XY ziyE(@T(I5zL4!B)Sy|K-(?KUHv%qru2hy$WUn z;@i)={cMwUgjK%@MH_yi1uK7zrwPp1;NpuzCA&#V$IjSfp257Cq3_TPk!alfX8=Zf z>&#VY^!s%{F@$Bf?vvO(y7pT4u`KZl7n(PO)ycZlvgittBVIQauOoT2UWCZK2V)iR z@mi11?j$Hnmv~%6WW_)Cvo1kpB38US^B{ z9k51?Pr~13PJvvR3!SeFy@4~gj8Gz(h~tG2gs6QM1b@;Os;lfeRG(wp1}wdOh^_UC>wuB~oHuQS+L~&2QKZ#QDnpXl@Z8y87NvMvjJ_ebF|JPS9B&_O)Wbiot+t4Y-J-uak_Q>1&DOR$*!9n(S)v2N+Jxk=uKav1y(XjkPe)- z>-`3jQvk;m6cWaVthc@~HgEh<*tSiGmdmi+a{*Wyc-qV{9!NF~@q{Il=9g`I{hmGB z^@zXNR2W$Z`JeYhO?@iPtTjT8fK@Tg{sh#K_ift_O&YWoO{;g@w%L6#H!TS#qcuG( z6~A2vd>iiQ@l1l#*q)|ag?JBv9Q`RpaMZu|$Oa$5)lWG;U-{W0G(TP+4hiCjRbFY! z4-R=^=aO`!C5G=w`1qgS@}mbxNK^UzG_VYcZ+#{=sb;+VuHTrcpOJt9%mHW6-Ra?} z#{F|f1w3E&DL5?r1UpO%m2mrX;5x6QMDYMC`WC#sb{*MV>sRhGl6;RKEe;Kf7uRQ@q1@B7deks4wQjuVdW<*~N3{`nZYgXq(x)!4a$`gRG zFHv|zseE}+3j`SNRub;}vi{ghEeiiJ9vNSM-T|5QX9zZ9JkZRz&l=;XtMDBZf=@p3eaSbPK0y{o8m!xFap!peti*}skNzku`r|xt`#q*v z8X`rc&jCq>x#cj6PB^N4ILxl(0TU@x3L@nxf_40ujSh9~|2A;7c;}4#>>6Ijt5|8y z9A{yVcmfJ6uE&nt_Dpi?+ZNuEq3T(c&a`7KCTGS6fL9nZK#kiis(Biyz-98)pvtb$ zIb~NS?i6iTP<}o{zz<7fo_+WKc>!vxa7~Ww6q(K`s;CXX&jZ#

d@NPb39gSKRgZQ-9v|+?l#JwrI)r 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≈f%L9iQR3{h5CB%*E5ep2ABYhT@xo% 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 literal 0 HcmV?d00001 diff --git a/make_post_sell/static/makepostsell.png b/make_post_sell/static/makepostsell.png new file mode 100644 index 0000000000000000000000000000000000000000..e7705531f5ad028d68e9a38fe12851575cb9ec49 GIT binary patch literal 55344 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

!Uu+-! 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
    ~()BLoqC?fV=w^SOwZa^(?f3m}W*i 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 literal 0 HcmV?d00001 diff --git a/make_post_sell/static/mps.png b/make_post_sell/static/mps.png new file mode 100644 index 0000000000000000000000000000000000000000..85a0619c3c0c45dafbb3f9e05cc954a5a02240b5 GIT binary patch literal 27936 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 literal 0 HcmV?d00001 diff --git a/make_post_sell/templates/actions_new.j2 b/make_post_sell/templates/actions_new.j2 new file mode 100644 index 0000000..eb12c87 --- /dev/null +++ b/make_post_sell/templates/actions_new.j2 @@ -0,0 +1,30 @@ +{% extends "base.j2" -%} + +{% block content -%} + +
    + + {% if request.user.active_shop %} + + {{ request.user.active_shop.name }} + +
    +
    +
    + New Content + +
    +
    + + New Coupon + +
    +
    + + New Product + +
    +
    + + New Shop + {% endif %} + +
    + +{%- endblock -%} diff --git a/make_post_sell/templates/actions_view.j2 b/make_post_sell/templates/actions_view.j2 new file mode 100644 index 0000000..b17e687 --- /dev/null +++ b/make_post_sell/templates/actions_view.j2 @@ -0,0 +1,35 @@ +{% extends "base.j2" -%} + +{% block content -%} + +
    + + {% if request.user.active_shop %} + + {{ request.user.active_shop.name }} + +
    +
    +   View Shop + +
    +
    +   View Products + +
    +
    +   View Coupons + +
    +
    + ⚙   Shop Settings + +
    +
    + ⛶   Switch Shop + + {% endif %} + +
    + +{%- endblock -%} diff --git a/make_post_sell/templates/base.j2 b/make_post_sell/templates/base.j2 new file mode 100644 index 0000000..5e0df7b --- /dev/null +++ b/make_post_sell/templates/base.j2 @@ -0,0 +1,123 @@ + +{% if request.product -%} + +{%- endif %} +{% if request.product -%} + +{%- endif %} + + + {% block title %}{{ request.app_domain }}{% endblock %} + + + {% set theme_color = request.shop.ribbon_color_1 or "#5871ad" %} + + + + + + + + + + + {% include 'snippets/google-analytics.j2' %} + + {%- block append_to_head_tag_section %} + {% endblock append_to_head_tag_section %} + + + + +{% include 'snippets/ribbon.j2' %} +
    + + + +{% include 'snippets/flash-alerts.j2' %} + + {% if request.user and request.user.authenticated %} + +
    + +
    + {% if request.is_saas_domain or request.user.can_edit_shop(request.shop) %} + {% if request.user.active_shop %} + ☰   {{ request.user.active_shop.name }} + {% endif %} + {% endif %} +
    + +
    + {% block call_to_action %} + {% if request.is_saas_domain or request.user.can_edit_shop(request.shop) %} + {% if request.user.shops %} + + New + {% else %} + + New Shop + {% endif %} + {% endif %} + + {% endblock call_to_action %} +
    + {% endif %} + +
    + +
    + {% block content %} + {% endblock %} +
    + + +
    + + + diff --git a/make_post_sell/templates/billing.j2 b/make_post_sell/templates/billing.j2 new file mode 100644 index 0000000..44926a4 --- /dev/null +++ b/make_post_sell/templates/billing.j2 @@ -0,0 +1,49 @@ +{% extends "base.j2" -%} +{%- import 'snippets/stripe.j2' as stripe with context -%} + +{% block content -%} + +{% if request.stripe_active_card %} + +
    + +
    + +

    Active Card

    + + The active card will be charged during checkout. + +
    + + {{ stripe.active_card() }} + + {{ stripe.saved_cards() }} + + +
    + +{% else %} + +
    + +{% endif %} + +
    +

    Save a new credit or debit card

    +
    +
    + {{ stripe.new_card() }} +
    +
    + +
    + +
    +Review Order +  +  +  +Checkout +
    + +{%- endblock -%} diff --git a/make_post_sell/templates/cart.j2 b/make_post_sell/templates/cart.j2 new file mode 100644 index 0000000..b1f23c1 --- /dev/null +++ b/make_post_sell/templates/cart.j2 @@ -0,0 +1,234 @@ +{% extends "base.j2" -%} + +{%- block call_to_action -%} +{% if request.user and request.user.authenticated and request.user.carts.count() > 1 %} +View Saved Carts +{% endif %} +{%- endblock call_to_action -%} + + +{% block content -%} + +
    + +
    + + {% for coupon in cart.coupons %} +
    + {{ coupon.code }}
    + {{ coupon.description }}
    + + + {{ coupon.human_discount }} + {{ coupon.human_minimum_cart }} + + + + {{ coupon.human_valid_thru }} + + + + {{ coupon.human_limit_per_customer }} + + +
    +
    + + + + + + + +
    +
    + + +
    +
    +
    + {% endfor %} + + {% if cart.is_empty %} + +
    +

    This cart is empty.

    + Nothing to see here. + {% if request.user and request.user.authenticated and request.user.carts.count() > 1 %} +
    +
    + View Saved Carts +
    +
    or + {% endif %} +
    +
    + + Let's go shopping! +
    + + {% else %} + + + {% for shop_id, product_ids in shop_product_dict.items() %} + + {% set shop = shops[shop_id] %} + +
    + +
    + {{ shop.name }} +
    + + {% 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] %} + +
    + {% if "thumbnail1" in product.extensions %} + + + + {% endif %} +
    + +
    + + {{ product.title }} sold by + {{ shop.name }} + +
    + +
    + + quantity: + +
    +
    + + + remove +
    + +
    + {{ product_quantity }} x ${{ '%0.2f' % product.price }} +
    + ${{ '%0.2f' % line_total }} +
    +
    + + {% endfor %} + +
    + + {% if cart.is_discounted %} + ${{ '%0.2f' % cart.shop_totals[shop.uuid_str] }} +
    + ${{ '%0.2f' % discounted_shop_totals[shop.uuid_str] }} + {% else %} + ${{ '%0.2f' % cart.shop_totals[shop.uuid_str] }} + {% endif %} +
    + +
    + +

    + + {% endfor %} + + +
    +
    + +
    + Continue shopping +
    + +
    +
    + + {% endif %} + +
    + +
    + + {% if cart.is_discounted %} +

    Total: ${{ '%0.2f' % cart.total_price }} + ${{ '%0.2f' % total_discounted_price }} +

    + {% else %} +

    Total: ${{ '%0.2f' % cart.total }}

    + {% endif %} + + {% if not cart.is_empty %} + + Checkout + +
    +
    +
    + +
    + +
    +
    + + {% endif %} + + {% if request.active_cart == cart and cart.count > 0 %} + + Save Cart +
    + + {% elif request.user and request.active_cart != cart %} + + {% if request.user == cart.user %} + Make Cart Active +
    + {% else %} + Copy & Make Cart Active +
    + {% endif %} + + {% endif %} + + {% if request.user and request.user.owns_cart(cart) %} + + {% if cart.is_not_public and cart.count > 0 %} + 🔓 Make Cart Public +
    + {% elif cart.public %} + 🔒 Make Cart Private + +
    +
    + Link to public cart +
    +
    + {% endif %} + + {% if request.active_cart != cart %} + Delete Cart +
    + {% endif %} + + {% endif %} + +
    +
    + + +{%- endblock -%} diff --git a/make_post_sell/templates/cart_checkout.j2 b/make_post_sell/templates/cart_checkout.j2 new file mode 100644 index 0000000..0b51e39 --- /dev/null +++ b/make_post_sell/templates/cart_checkout.j2 @@ -0,0 +1,42 @@ +{% extends "base.j2" -%} +{%- import 'snippets/stripe.j2' as stripe with context -%} + +{% block content -%} + +
    + +
    + +

    Active Card

    + + {{ stripe.active_card() }} + +
    + + {% if request.shop and request.shop.is_ready %} + Use a different card + {% endif %} + + +
    + +
    + +

    Cart Total: ${{ '%0.2f' % cart.total }}

    + +
    + Are you sure you want to charge
    + ${{ '%0.2f' % cart.total }} to your active credit card? + +
    +
    +
    +
    + + Yes, Complete Checkout + + +
    +
    + +{%- endblock -%} diff --git a/make_post_sell/templates/carts.j2 b/make_post_sell/templates/carts.j2 new file mode 100644 index 0000000..1dd585e --- /dev/null +++ b/make_post_sell/templates/carts.j2 @@ -0,0 +1,20 @@ +{% extends "base.j2" -%} + +{% block content -%} + +
    + +

    Your saved carts:

    + +{% for cart in request.user.carts %} + +
    + 🛒 Cart ${{ '%0.2f' % cart.total }} ({{ cart.count }} items) {{ cart.human_updated_timestamp }} {% if cart.id == request.user.active_cart_id %}(active){% endif %} +
    +
    + +{% endfor %} + +
    + +{%- endblock -%} diff --git a/make_post_sell/templates/content.j2 b/make_post_sell/templates/content.j2 new file mode 100644 index 0000000..dc2b2d9 --- /dev/null +++ b/make_post_sell/templates/content.j2 @@ -0,0 +1,92 @@ +{% extends "base.j2" -%} + +{%- block call_to_action -%} +{% if request.user.can_edit_shop(request.shop) %} +✎ Edit +{% endif %} +{%- endblock call_to_action -%} + + +{% block content -%} + +
    + +
    + + {% if "thumbnail1" in product.extensions %} + {% if product.extensions["product"] == "mp4" %} + + +
    click ▶ to play!
    +
    + + {% else %} + + {% endif %} +
    + {% endif %} + +

    {{ product.title }}

    +

    + uploaded to {{ product.shop.name }} +

    + +
    + + Description +
    {{ product.description_html | safe }}
    + +
    +
    + + {% if product.is_bundle %} + + {% if product.products_in_this_bundle %} +
      + {% for p in product.products_in_this_bundle %} +
    • + {{p.title}} +
    • + {% endfor %} +
    + {% endif %} + + {% else %} + File Type +
    + {{ product.get_content_type("product") }} +
    + {% endif %} + + +
    + +
    + +
    + {% if product.is_ready %} + + {% if signed_get_object_url is not none %} + ⭳ Download + {% endif %} + + {% else %} + +
    + Not Ready. + + {% endif %} +
    + +
    + +
    + +
    + +
    + +{%- endblock -%} diff --git a/make_post_sell/templates/content_new.j2 b/make_post_sell/templates/content_new.j2 new file mode 100644 index 0000000..14d792e --- /dev/null +++ b/make_post_sell/templates/content_new.j2 @@ -0,0 +1,59 @@ +{% extends "base.j2" -%} + +{%- block call_to_action -%} +{%- endblock call_to_action -%} + +{% block content -%} + +
    +
    +

    New Content

    +
    + + Creating a new content in the shop named "{{ shop.name }}". + +
    + +
    + + + +
    + + + +
    +
    + + + + +
    +
    + + + If checked, this content will bundle other existing content.
    (playlist, gallery, album, collection, series, etc.) + +
    +
    + + + + +
    +
    +
    + +{%- endblock -%} diff --git a/make_post_sell/templates/coupon.j2 b/make_post_sell/templates/coupon.j2 new file mode 100644 index 0000000..8130d29 --- /dev/null +++ b/make_post_sell/templates/coupon.j2 @@ -0,0 +1,88 @@ +{% extends "base.j2" -%} + +{%- block call_to_action -%} +{%- endblock call_to_action -%} + + +{% block content -%} + +
    + +
    + + +



    Coupon:

    {{ request.shop.name }}

    +Coupon: {{ request.shop.name }} + +
    + +

    +{{ coupon.description }} +

    + + +Enter coupon code — + +
    +

    +{{ coupon.code }} +

    +
    + +

    + +For {{ coupon.human_discount }} +{{ coupon.human_minimum_cart }} + +

    + + +{{ coupon.human_valid_thru }} + + + +{{ coupon.human_limit_per_customer }} + + + + +
    +
    + +{% if coupon.is_expired %} + +This coupon has expired. + +{% else %} + +
    + + + + + + + +
    + +{% endif %} + +
    + +
    +
    + +
    +
    + +{%- endblock -%} diff --git a/make_post_sell/templates/coupon_new.j2 b/make_post_sell/templates/coupon_new.j2 new file mode 100644 index 0000000..d147997 --- /dev/null +++ b/make_post_sell/templates/coupon_new.j2 @@ -0,0 +1,171 @@ +{% extends "base.j2" -%} + +{%- block call_to_action -%} +{%- endblock call_to_action -%} + + +{% block content -%} + + + +
    + +
    +

    New Coupon

    + + A new coupon in the shop named "{{ request.shop.name }}". +
    +
    + + * required + +
    +
    + +
    + + + The coupon code customers type during cart checkout. +
    + + +
    +
    +
    + + + This will appear on customer receipts and invoices. +
    + + +
    +
    +
    + + + Dollar Off +
    + Percent Off + +
    +
    +
    + + + + +
    +
    +
    + + + Optional. The amount of times this code may be used. +
    + +
    +
    + Per customer. +
    + + +
    +
    +
    + + + Optional. Coupon valid thru date. +
    + + +
    +
    +
    + + + Optional. The minimum cart total needed for this coupon to apply. +
    + + +
    +
    +
    + + + +
    +
    + +
    + +
    + +
    + +{%- endblock -%} diff --git a/make_post_sell/templates/coupons.j2 b/make_post_sell/templates/coupons.j2 new file mode 100644 index 0000000..733d4f0 --- /dev/null +++ b/make_post_sell/templates/coupons.j2 @@ -0,0 +1,66 @@ +{% extends "base.j2" -%} + +{% block content -%} + +
    + +

    Coupons

    + +Coupons in the shop named "{{ request.shop.name }}". + +
    +
    + +{% if coupons %} + +{% for coupon in coupons %} + + + +{{ coupon.code }} +— +{{ coupon.description }} +— +{{ coupon.redemptions.count() }}/{{ coupon.human_max_redemptions | safe }} + + +
    + + +{{ coupon.human_discount }} +{{ coupon.human_minimum_cart }} + + + +{{ coupon.human_valid_thru }} + + + +{{ coupon.human_limit_per_customer }} + + + + +
    +
    + +{% endfor %} + +
    +
    + +{% else %} + +This shop has no coupons. You may create one. + +
    +
    + +{% endif %} + +
    + New Coupon + +
    + +{%- endblock -%} diff --git a/make_post_sell/templates/home.j2 b/make_post_sell/templates/home.j2 new file mode 100644 index 0000000..20abb72 --- /dev/null +++ b/make_post_sell/templates/home.j2 @@ -0,0 +1,66 @@ +{% extends "base.j2" -%} + +{% block content -%} + +{% if not request.shop %} + +
    + + {% if request.user and request.user.authenticated %} + + {% if request.user.active_shop %} + + {{ request.user.active_shop.name }} + + + + {% else %} + You don't have any shops, yet. You should create one. + +
    +
    + + + New Shop + {% endif %} + + {% else %} + +
    + You should join or log in to see this dashboard. +
    + + {% endif %} + +
    + +{% else %} + + {% if products %} +
    + {% for product in products %} + + {% if product.is_ready %} +
    + {% if "thumbnail1" in product.extensions %} + + + + {% endif %} + {{ product.title }} + {% if product.is_sellable %} +
    + ${{ '%0.2f' % product.price }} + {% endif %} +
    + {{ product.shop.name }} +
    + {% endif %} + + {% endfor %} + +
    + {% endif %} + +{% endif %} + +{%- endblock -%} diff --git a/make_post_sell/templates/join-or-log-in.j2 b/make_post_sell/templates/join-or-log-in.j2 new file mode 100644 index 0000000..36bea3b --- /dev/null +++ b/make_post_sell/templates/join-or-log-in.j2 @@ -0,0 +1,51 @@ +{% extends "base.j2" -%} + +{% block content -%} + +
    + + + +
    + +{%- endblock -%} diff --git a/make_post_sell/templates/markup_editor.j2 b/make_post_sell/templates/markup_editor.j2 new file mode 100644 index 0000000..7d62d1f --- /dev/null +++ b/make_post_sell/templates/markup_editor.j2 @@ -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 -%} + +
    + +
    +
    + + {{ markup_subject }} + +
    + +
    +
    + +
    + {{ markup_subject }} +
    +
    +
    {{ markup_rendered | safe }}
    +
    + +
    + +{%- endblock -%} diff --git a/make_post_sell/templates/markup_renderer.j2 b/make_post_sell/templates/markup_renderer.j2 new file mode 100644 index 0000000..fb964fd --- /dev/null +++ b/make_post_sell/templates/markup_renderer.j2 @@ -0,0 +1,19 @@ +{% extends "base.j2" -%} + +{%- block call_to_action -%} +{% if request.user.can_edit_shop(request.shop) %} +✎ Edit +{% endif %} +{%- endblock call_to_action -%} + +{% block content -%} + +
    + +
    +{{ markup_rendered | safe }} +
    + +
    + +{%- endblock -%} diff --git a/make_post_sell/templates/product.j2 b/make_post_sell/templates/product.j2 new file mode 100644 index 0000000..fd4c800 --- /dev/null +++ b/make_post_sell/templates/product.j2 @@ -0,0 +1,115 @@ +{% extends "base.j2" -%} + +{%- block call_to_action -%} +{% if request.user.can_edit_shop(request.shop) %} +✎ Edit +{% endif %} +{%- endblock call_to_action -%} + +{% block content -%} + +
    + +
    + +

    {{ product.title }}

    +

    + sold by + {{ product.shop.name }} +

    + + + {% if "thumbnail1" in product.extensions %} + +
    + {% 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] %} + + {% endif %} + {% endfor %} + + +
    + +
    + +
    +
    +
    + +
    +

    ${{ '%0.2f' % product.price }}

    + + {% if product.is_ready %} + + {% if signed_get_object_url is not none %} + ⭳ Download +
    +
    +
    +
    + {% endif %} + + Add To Cart +
    + {% if (not request.user or not request.user.authenticated) and signed_get_object_url is none %} +
    + Already purchased? log in +
    +
    +
    + {% endif %} + + {% if "preview" in product.extensions %} + View Preview +
    + {% endif %} + + {% else %} + +
    + This product is not ready for sale yet. + + {% endif %} +
    + +
    + +
    + +
    + +
    + + Description +
    {{ product.description_html | safe }}
    + +
    +
    + + {% if product.is_bundle %} + + {% if product.products_in_this_bundle %} +
      + {% for p in product.products_in_this_bundle %} +
    • + {{p.title}} +
    • + {% endfor %} +
    + {% endif %} + + {% else %} + File Type +
    + {{ product.get_content_type("product") }} +
    + Please make sure you have an application to open this file type before you purchase. + {% endif %} + +
    + +{%- endblock -%} diff --git a/make_post_sell/templates/product_edit.j2 b/make_post_sell/templates/product_edit.j2 new file mode 100644 index 0000000..7bf387b --- /dev/null +++ b/make_post_sell/templates/product_edit.j2 @@ -0,0 +1,194 @@ +{% extends "base.j2" -%} + +{%- block call_to_action -%} +{%- endblock call_to_action -%} + +{% block content -%} + +
    + +
    + + {% if product.is_bundle %} + +

    Product IDs or URLs to include in this bundle

    + +
    + + +
    + +
    + + {% if product.products_in_this_bundle %} +
      + {% for p in product.products_in_this_bundle %} +
    • +
      + {{p.title}} + +
      +
    • +
      + {% endfor %} +
    + {% endif %} + + {% else %} + +

    Upload or Replace Product File

    + + People may download this file after making a purchase. +
    +
    + + {% set signed_post = signed_posts["product"] %} +
    + {% for field_name, field_value in signed_post["fields"].items() %} + + {% endfor %} +
    + +
    + + {% if "product" in product.originals %}

    Current File: {{ product.originals["product"] }}

    {% endif %} + + {% endif %} + + {% if product.is_sellable %} +
    +
    + +

    Upload or Replace Preview File

    + + People may download this file before making a purchase. +
    +
    + {% set signed_post = signed_posts["preview"] %} +
    + {% for field_name, field_value in signed_post["fields"].items() %} + + {% endfor %} +
    +
    + +
    + {% if "preview" in product.originals %}

    Current File: {{ product.originals["preview"] }}

    {% endif %} + + {% endif %} + +
    + + +
    +

    Upload or Replace Thumbnail Files

    + +
    +Your product cover (thumbnail1) will show up on search pages. +
    +
    +
    + + +{% set s3_key_thumbnails = product.s3_key_thumbnails %} + +{% for thumbnail_key in product.file_thumbnail_keys %} +
    + {{ thumbnail_key }} +
    + + {% set signed_post = signed_posts[thumbnail_key] %} +
    + {% for field_name, field_value in signed_post["fields"].items() %} + + {% endfor %} +
    + +
    +
    + + {% if thumbnail_key in s3_key_thumbnails %} + {% set get_endpoint = request.app["bucket.secure_uploads.get_endpoint"] + "/" + s3_key_thumbnails[thumbnail_key] %} + + {% endif %} + +
    +{% endfor %} +
    + +
    +
    +

    Edit Title, Description, or Price

    + + + + +
    +
    + + Supports Markdown and some HTML + + +
    +
    + + + + +
    +
    + + + +
    + + Visibility + + + + + + + + + + +
    + +
    +
    + + +
    +
    + +
    + +{%- endblock -%} diff --git a/make_post_sell/templates/product_new.j2 b/make_post_sell/templates/product_new.j2 new file mode 100644 index 0000000..0196159 --- /dev/null +++ b/make_post_sell/templates/product_new.j2 @@ -0,0 +1,72 @@ +{% extends "base.j2" -%} + +{%- block call_to_action -%} +{%- endblock call_to_action -%} + +{% block content -%} + +
    +
    +

    New Product

    +
    + + Creating a new product in the shop named "{{ shop.name }}". + +
    + +
    + + + +
    + + + +
    +
    + + + + +
    +
    + + + + +
    +
    + + + If checked, this product will bundle other existing products. +
    (playlist, gallery, album, collection, series, etc.) + +
    +
    + + + +
    +
    +
    + +{%- endblock -%} diff --git a/make_post_sell/templates/products.j2 b/make_post_sell/templates/products.j2 new file mode 100644 index 0000000..1902a42 --- /dev/null +++ b/make_post_sell/templates/products.j2 @@ -0,0 +1,36 @@ +{% extends "base.j2" -%} + +{% block content -%} + +{% if the_title %} +
    +

    +{{ the_title }} +

    +
    +{% endif %} + +
    +{% if products %} +{% for product in products %} + +
    + {% if "thumbnail1" in product.extensions %} + + + + {% endif %} + {{ product.title }} + {% if product.is_sellable %} +
    + ${{ '%0.2f' % product.price }} + {% endif %} +
    + {{ product.shop.name }} +
    + +{% endfor %} +{% endif %} +
    + +{%- endblock -%} diff --git a/make_post_sell/templates/shop.j2 b/make_post_sell/templates/shop.j2 new file mode 100644 index 0000000..1eb7edb --- /dev/null +++ b/make_post_sell/templates/shop.j2 @@ -0,0 +1,30 @@ +{% extends "base.j2" -%} + +{% block content -%} + +
    +{% if products %} +{% for product in products %} + + {% if product.is_ready %} +
    + {% if "thumbnail1" in product.extensions %} + + + + {% endif %} + {{ product.title }} + {% if product.is_sellable %} +
    + ${{ '%0.2f' % product.price }} + {% endif %} +
    + {{ product.shop.name }} +
    + {% endif %} + +{% endfor %} +{% endif %} +
    + +{%- endblock -%} diff --git a/make_post_sell/templates/shop_about.j2 b/make_post_sell/templates/shop_about.j2 new file mode 100644 index 0000000..16ad652 --- /dev/null +++ b/make_post_sell/templates/shop_about.j2 @@ -0,0 +1,44 @@ +{% extends "base.j2" -%} + +{% block content -%} + +
    + + + +
    +
    + +

    {{ request.shop.name }}

    + +

    + {{ request.shop.description }} +

    + + + +

    +

    + View Products +
    +

    + +
    + +{%- endblock -%} diff --git a/make_post_sell/templates/shop_new.j2 b/make_post_sell/templates/shop_new.j2 new file mode 100644 index 0000000..6b3f155 --- /dev/null +++ b/make_post_sell/templates/shop_new.j2 @@ -0,0 +1,68 @@ +{% extends "base.j2" -%} + +{%- block call_to_action -%} +{%- endblock call_to_action -%} + +{% block content -%} + +
    +
    +

    New Shop

    +
    + + + + +
    +
    + + + + + + +
    +
    + + + + +
    +
    + + + + +
    +
    + + +
    +
    +
    + +{%- endblock -%} diff --git a/make_post_sell/templates/shop_products.j2 b/make_post_sell/templates/shop_products.j2 new file mode 100644 index 0000000..47c804a --- /dev/null +++ b/make_post_sell/templates/shop_products.j2 @@ -0,0 +1,33 @@ +{% extends "base.j2" -%} + +{% block content -%} + + + +{% if products %} + + + + + +{% for product in products %} + + + + + + + + + + +{% endfor %} +
    Sellable Price Visibility Bundle Ready Title +
    {{ product.is_sellable }}${{ '%0.2f' % product.price }}{{ product.human_visibility }}{{ product.is_bundle }}{{ product.is_ready }}{{ product.title }}
    + +{% endif %} +
    + +{%- endblock -%} diff --git a/make_post_sell/templates/shop_settings.j2 b/make_post_sell/templates/shop_settings.j2 new file mode 100644 index 0000000..b6c789e --- /dev/null +++ b/make_post_sell/templates/shop_settings.j2 @@ -0,0 +1,211 @@ +{% extends "base.j2" -%} + +{% block content -%} + +
    + +
    +

    Shop Settings

    +
    + + + + +
    +
    + + + + + + +
    +
    + + + + +
    +
    + + + + +
    +
    + + + + +
    +
    + + + + +
    +
    + + + + +
    +
    + + + + +
    +
    + + + +
    +
    +
    + +
    + +
    + +

    Announcement Ribbon Settings

    + + + + +
    +
    + + + + +
    +
    + + + + +
    +
    + + + + +
    +
    + + + +
    +
    + +
    + +
    +
    + + +
    + + +

    Branding

    + + + {% set signed_post = signed_posts["shop-logo-banner"] %} +
    + {% for field_name, field_value in signed_post["fields"].items() %} + + {% endfor %} +
    + +
    + +
    +
    + + + + +
    + +
    + +{%- endblock -%} diff --git a/make_post_sell/templates/shops.j2 b/make_post_sell/templates/shops.j2 new file mode 100644 index 0000000..f2fe4c5 --- /dev/null +++ b/make_post_sell/templates/shops.j2 @@ -0,0 +1,28 @@ +{% extends "base.j2" -%} + +{% block content -%} + +
    + +{% if request.user.shops %} +Choose and switch shop +
    +
    + +{% for shop in request.user.shops %} + +{{ shop.name }} +
    +{% endfor %} +{% else %} +You don't have any shops, yet. You should create one. +{% endif %} + +
    +
    + ++ New Shop + +
    + +{%- endblock -%} diff --git a/make_post_sell/templates/snippets/csrf.j2 b/make_post_sell/templates/snippets/csrf.j2 new file mode 100644 index 0000000..f28926c --- /dev/null +++ b/make_post_sell/templates/snippets/csrf.j2 @@ -0,0 +1,3 @@ +{% if request.csrf_token %} + +{% endif %} diff --git a/make_post_sell/templates/snippets/flash-alerts.j2 b/make_post_sell/templates/snippets/flash-alerts.j2 new file mode 100644 index 0000000..b141dec --- /dev/null +++ b/make_post_sell/templates/snippets/flash-alerts.j2 @@ -0,0 +1,16 @@ + + +
    +{%- if request.session.peek_flash() -%} +{%- for message, level in request.session.pop_flash() %} +
    +

    {{message|safe}}

    + +
    +{%- endfor %} +{%- endif %} +
    diff --git a/make_post_sell/templates/snippets/google-analytics.j2 b/make_post_sell/templates/snippets/google-analytics.j2 new file mode 100644 index 0000000..2394424 --- /dev/null +++ b/make_post_sell/templates/snippets/google-analytics.j2 @@ -0,0 +1,11 @@ +{% if request.shop and request.shop.google_analytics_id %} + + + +{% endif %} diff --git a/make_post_sell/templates/snippets/optional-javascript.j2 b/make_post_sell/templates/snippets/optional-javascript.j2 new file mode 100644 index 0000000..8e9e437 --- /dev/null +++ b/make_post_sell/templates/snippets/optional-javascript.j2 @@ -0,0 +1,3 @@ + + + diff --git a/make_post_sell/templates/snippets/ribbon.j2 b/make_post_sell/templates/snippets/ribbon.j2 new file mode 100644 index 0000000..731cf53 --- /dev/null +++ b/make_post_sell/templates/snippets/ribbon.j2 @@ -0,0 +1,30 @@ +{% if request.is_saas_domain %} + +
    +Sell all the digital things you make—commission free +
    + +{% else %} + + + +{% if request.shop.ribbon_text %} +
    +{{ request.shop.ribbon_text }} +
    +{% endif %} + +{% endif %} diff --git a/make_post_sell/templates/snippets/search.j2 b/make_post_sell/templates/snippets/search.j2 new file mode 100644 index 0000000..1d571bc --- /dev/null +++ b/make_post_sell/templates/snippets/search.j2 @@ -0,0 +1,24 @@ +{% if request.shop and not search_disabled %} + + +{% if keywords and not products %} +
    +No matches. +{% endif %} + +{% endif %} diff --git a/make_post_sell/templates/snippets/stripe.j2 b/make_post_sell/templates/snippets/stripe.j2 new file mode 100644 index 0000000..d263a91 --- /dev/null +++ b/make_post_sell/templates/snippets/stripe.j2 @@ -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", + } + %} + +
    + +
    + +
    + {{ card.brand }} +
    + ending in {{ card.last4 }} +
    + + expiring {{ card.exp_month }}/{{ card.exp_year }} + +
    + +
    +
    +
    + {% if actions %} + + + {% endif %} + {% if change_card %} + + {% endif %} +
    +
    +{% 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 %} + +
    +
    +
    + +

    Saved Cards

    + {% for card in request.stripe_saved_cards %} + {% if card != request.stripe_active_card %} + {{ display_card(card) }} +
    + {% endif %} + {% endfor %} +
    + {% endif %} +{% endmacro %} + +{% macro new_card() %} + + +
    + {% include 'snippets/csrf.j2' %} + +
    + +
    + +
    + + + +
    +
    + + + + + +
    +
    + + +{% endmacro %} diff --git a/make_post_sell/templates/update-card.j2 b/make_post_sell/templates/update-card.j2 new file mode 100644 index 0000000..411584b --- /dev/null +++ b/make_post_sell/templates/update-card.j2 @@ -0,0 +1,32 @@ +{% extends "base.j2" -%} +{%- import 'snippets/stripe.j2' as stripe with context -%} + +{% block content -%} + +
    + +
    + +

    Are you sure?

    + + {{ stripe.display_card(card, actions=False) }} + +
    + +
    +
    + {% include 'snippets/csrf.j2' %} + + + + +
    +
    + +
    + +
    + +
    + +{%- endblock -%} diff --git a/make_post_sell/templates/user_settings.j2 b/make_post_sell/templates/user_settings.j2 new file mode 100644 index 0000000..026fcd8 --- /dev/null +++ b/make_post_sell/templates/user_settings.j2 @@ -0,0 +1,64 @@ +{% extends "base.j2" -%} + +{% block call_to_action %} +log out +{% endblock call_to_action %} + +{% block content -%} + +
    + + + +
    + + {% if request.is_saas_domain %} + My Shops + {% endif %} + + {% if request.shop and request.shop.is_ready %} + My Purchases +

    + Payment Preferences + {% endif %} + +
    + +
    + +{%- endblock -%} diff --git a/make_post_sell/tests/__init__.py b/make_post_sell/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/make_post_sell/tests/test_functional.py b/make_post_sell/tests/test_functional.py new file mode 100644 index 0000000..cbd84ee --- /dev/null +++ b/make_post_sell/tests/test_functional.py @@ -0,0 +1,635 @@ +# needed to grab test stripe keys from environment vars. +# MPS_TEST_STRIPE_PUBLIC_API_KEY & MPS_TEST_STRIPE_SECRET_API_KEY +from os import environ + +import transaction +import unittest +import webtest +import stripe + +from ..models import get_tm_session + +from ..models.meta import Base + +from ..models.shop import Shop, get_shop_by_name + +from ..models.user import get_or_create_user_by_email + +from ..models.cart import get_cart_by_id, get_all_carts + +from ..models.stripe_user_shop import get_all_stripe_customer_objects + +from ..models.product import get_product_by_id, get_all_products + +from ..models.coupon import get_coupons_by_code + +from pyramid.paster import get_appsettings + +from miscutils import dollars_to_cents, cents_to_dollars + +import mock +from mock import patch + + +# todo we should pick a new file to put test helpers. +mock_always_true = mock.Mock(return_value=True) + + +# todo we should pick a new file to put test helpers. +class FunctionalTests(unittest.TestCase, object): + def setUp(self): + from make_post_sell import main + + self.settings = get_appsettings("test.ini") + + self.app = main({}, **self.settings) + + self.testapp = webtest.TestApp(self.app) + + self.session_factory = self.app.registry["dbsession_factory"] + self.engine = self.session_factory.kw["bind"] + Base.metadata.create_all(bind=self.engine) + + self.dbsession = get_tm_session(self.session_factory, transaction.manager) + + def tearDown(self): + # log out current user. + self.testapp.get("/log-out") + # drop all tables in database. + transaction.abort() + Base.metadata.drop_all(bind=self.engine) + + +class UnauthenticatedFunctionalTests(FunctionalTests): + def test_root_home_page(self): + res = self.testapp.get("/", status=200) + self.assertIn(b"log in", res.body) + self.assertIn(b"Cart $0.00 (0)", res.body) + + def test_new_product_redirects(self): + redirect_res = self.testapp.get("/p/new", status=302) + res = redirect_res.follow() + self.assertIn(b"You must log in to access that area.", res.body) + + def test_new_shop_redirects(self): + redirect_res = self.testapp.get("/s/new", status=302) + res = redirect_res.follow() + self.assertIn(b"You must log in to access that area.", res.body) + + def test_user_settings_redirects(self): + redirect_res = self.testapp.get("/u/settings", status=302) + res = redirect_res.follow() + self.assertIn(b"You must log in to access that area.", res.body) + + @patch("smtplib.SMTP") + def test_user_log_in(self, mock_smtp): + redirect_res1 = self.testapp.post( + "/join-or-log-in", {"email": "test@example.com"}, status=302 + ) + + self.assertIn(b"The resource was found at", redirect_res1.body) + + res = redirect_res1.follow() + + self.assertIn( + b"We just sent a link to test@example.com. Check email to log in.", res.body + ) + + +class AuthenticatedFunctionalTests(FunctionalTests): + + def setUp(self): + + super(AuthenticatedFunctionalTests, self).setUp() + + self.shop1_params = { + "name": "russell's shop", + "phone_number": "555-555-8688", + "billing_address": "555 example way\nnorth pole\n555555\n", + "description": "russell's shop sells some great digital downloads.", + "stripe_public_api_key": environ["MPS_TEST_STRIPE_PUBLIC_API_KEY"], + "stripe_secret_api_key": environ["MPS_TEST_STRIPE_SECRET_API_KEY"], + "domain_name": "localhost", + } + + self.shop2_params = { + "name": "joe's shop", + "phone_number": "555-555-9998", + "billing_address": "55 example st\nnorth pole\n555555\n", + "description": "joe's shop sells some bad digital downloads.", + "stripe_public_api_key": environ["MPS_TEST_STRIPE_PUBLIC_API_KEY"], + "stripe_secret_api_key": environ["MPS_TEST_STRIPE_SECRET_API_KEY"], + "domain_name": "localhost", + } + + self.product1_params = { + "title": "russell's product", + "description": "russell's product", + "price": "3.50", + "is_sellable": "on", + } + + self.coupon1_params = { + "code": "camp31", + "description": "Slippery Halloween Party", + "action_type": "dollar-off", + "action_value": "3.50", + "max_redemptions": "100", + "cart_qualifier": "10", + "max_redemptions_per_user": "1", + "expiration_date": "2040-10-31", + } + + # create test user1 and user2. + self.user1 = get_or_create_user_by_email( + self.dbsession, "test1@example.com" + ) + self.user2 = get_or_create_user_by_email( + self.dbsession, "test2@example.com" + ) + + # capture user credentials needed for authentication. + self.user1_creds = ( + "test1@example.com", + # returns the raw auto-generated password used + # in one-time-password links OTP + self.user1.new_password() + ) + self.user2_creds = ( + "test2@example.com", + # returns the raw auto-generated password used + # in one-time-password links OTP + self.user2.new_password() + ) + + # flush new users to database. + self.dbsession.add(self.user1) + self.dbsession.add(self.user2) + self.dbsession.flush() + + # commit the transaction. + transaction.manager.commit() + + # requery User objects to avoid detached instance error. + self.user1 = get_or_create_user_by_email( + self.dbsession, "test1@example.com" + ) + self.user2 = get_or_create_user_by_email( + self.dbsession, "test2@example.com" + ) + + def _clean_up_user(self, user): + # print("deleteing user {} from dbsession.".format(user.name)) + self.dbsession.delete(user) + + def _clean_up_shop(self, shop): + self.dbsession.delete(shop) + + def _clean_up_stripe(self): + """clean up remote Stripe API by removing Customer objects.""" + customers = get_all_stripe_customer_objects(self.dbsession) + # print("cleaning remote Stripe API by removing {} Customer objets.".format(len(customers))) + for customer in customers: + customer.delete() + + def tearDown(self): + """Delete test users and shop in between tests.""" + + # clean up remote Stripe API by removing Customer objects. + self._clean_up_stripe() + + # log out and delete the test_users in between tests. + self._clean_up_user(self.user1) + self._clean_up_user(self.user2) + + # self.dbsession.flush() + # transaction.manager.commit() + super(AuthenticatedFunctionalTests, self).tearDown() + + def log_in_user(self, user_creds): + # log in user. + res_login = self.testapp.get( + "/join-or-log-in?email={}&raw-otp={}".format(*user_creds) + ) + # attach csrf to class. + res_csrf = self.testapp.get("/") + # self.csrf = res_csrf.form.fields["csrf_token"][0].value + return res_login + + def test_log_in(self): + redirect_res1 = self.log_in_user(self.user1_creds) + redirect_res2 = redirect_res1.follow() + res = redirect_res2.follow() + + self.assertNotIn(self.user1.name, res.body.decode()) + + + def test_a_girl_has_no_name(self): + """A new user configures a display name.""" + self.log_in_user(self.user1_creds) + + res = self.testapp.post( + "/u/settings", {"name": "russell", "full_name": "russell ballestrini"} + ) + + res_body = res.body.decode() + + self.assertIn("russell", res_body) + self.assertIn("russell ballestrini", res_body) + self.assertIn("You set your public display name.", res_body) + self.assertIn("You set your private full name.", res_body) + + self.testapp.get("/log-out") + + redirect_res1 = self.log_in_user(self.user1_creds) + redirect_res2 = redirect_res1.follow() + res = redirect_res2.follow() + res_body = res.body.decode() + + self.dbsession.refresh(self.user1) + self.dbsession.refresh(self.user2) + + self.assertIn(self.user1.name, res_body) + self.assertNotIn(self.user2.name, res_body) + + def test_new_product_without_a_shop(self): + self.log_in_user(self.user2_creds) + redirect_res = self.testapp.get("/p/new", status=302) + res = redirect_res.follow() + self.assertIn(b"First we must create a shop.", res.body) + + def test_create_new_shop(self, user_creds=None, shop_params=None, log_out_user=False): + + if user_creds is None: + user_creds = self.user1_creds + + if shop_params is None: + shop_params = self.shop1_params + + # log in with given user credentials. + self.log_in_user(user_creds) + + # create a new shop. + redirect_res = self.testapp.post("/s/new", shop_params) + res = redirect_res.follow() + self.assertIn( + "Great work, you created a shop! You may continue to setup your shop or start posting products!", + res.body.decode(), + ) + + # query the new shop from database. + shop = get_shop_by_name(self.dbsession, shop_params["name"]) + + # prove that shop is not ready. + self.assertFalse(shop.is_ready) + self.assertTrue(shop.is_not_ready) + + # add stripe keys to the newly created shop to make it ready. + res = self.testapp.post("/s/{}/settings".format(shop.id), shop_params) + res_body = res.body.decode() + self.assertIn( + "You set the shop's stripe_public_api_key.", + res_body, + ) + self.assertIn( + "You set the shop's stripe_secret_api_key.", + res_body, + ) + + # refresh shop attributes from database. + self.dbsession.refresh(shop) + + # prove that shop is ready. + self.assertTrue(shop.is_ready) + self.assertFalse(shop.is_not_ready) + + if log_out_user: + self.testapp.get("/log-out") + + def test_new_shop_invalid_shop_name(self): + self.log_in_user(self.user1_creds) + params = self.shop1_params + params["name"] = "$$$ russell's shop" + res = self.testapp.post("/s/new", params) + self.assertIn( + "Invalid shop name, only use alpha numeric, spaces, dashes, or periods.", + res.body.decode(), + ) + + def test_new_shop_missing_required_field(self): + self.log_in_user(self.user2_creds) + params = self.shop2_params + del params["phone_number"] + res = self.testapp.post("/s/new", params) + self.assertIn("You must fill out all fields.", res.body.decode()) + + def test_new_shop_name_already_in_use(self): + self.log_in_user(self.user1_creds) + params = self.shop1_params + self.testapp.post("/s/new", params) + res = self.testapp.post("/s/new", params) + self.assertIn( + "That shop name is already in use. Please pick another.", res.body.decode() + ) + + def test_new_product_missing_required_fields(self): + + # log in and create a new shop. + self.test_create_new_shop( + user_creds=self.user1_creds, + shop_params=self.shop1_params, + log_out_user=False + ) + + product_params = self.product1_params + del product_params["description"] + res = self.testapp.post("/p/new", product_params) + self.assertIn("You must fill out all fields.", res.body.decode()) + + def test_new_product(self, user_creds=None, shop_params=None, product_params=None, log_out_user=False): + + if user_creds is None: + user_creds = self.user1_creds + + if shop_params is None: + shop_params = self.shop1_params + + if product_params is None: + product_params = self.product1_params + + # log in and create a new shop. + self.test_create_new_shop( + user_creds=user_creds, + shop_params=shop_params, + ) + + redirect_res = self.testapp.post("/p/new", product_params) + res = redirect_res.follow() + self.assertIn("Great, next you may upload files.", res.body.decode()) + + if log_out_user: + self.testapp.get("/log-out") + + def test_new_product_when_user_does_not_own_the_shop(self): + + # log in user1. + self.log_in_user(self.user1_creds) + + # have user1 create a new shop. + params = self.shop1_params + res = self.testapp.post("/s/new", params) + + # get the new shop_id from the response location attribute. + shop_id = res.location.split("/")[-2] + + # log out user1. + self.testapp.get("/log-out") + + # log in user2. + self.log_in_user(self.user2_creds) + + # make user2 create a new product on a shop she doesn't own. + params = self.product1_params + redirect_res = self.testapp.post("/p/new?shop_id={}".format(shop_id), params) + res = redirect_res.follow() + self.assertIn("You do not own that Shop.", res.body.decode()) + + def test_new_product_price_history(self): + self.test_new_product( + user_creds=self.user1_creds, + shop_params=self.shop1_params, + product_params=self.product1_params, + ) + + # get the Product object from database. + all_products = get_all_products(self.dbsession) + product = all_products.one() + + self.assertEqual(product.price, 3.50) + self.assertEqual(product.price, product.price_history[0].price) + self.assertEqual(product.price_history.count(), 1) + self.assertEqual(product.price_in_cents, 350) + + + # this patch requires an argument added to test method `mock_smtp`. + @patch("smtplib.SMTP") + @patch("make_post_sell.models.Product.is_ready", mock_always_true) + def test_cart_checkout_for_shop(self, mock_smtp): + + # 1. log in as user1. + # 2. create new shop. + # 3. make new shop ready for checkout. + # 4. create new product. + # 5. log out user1. + self.test_new_product( + user_creds=self.user1_creds, + shop_params=self.shop1_params, + product_params=self.product1_params, + log_out_user=True, + ) + + # get the Product object from database. + all_products = get_all_products(self.dbsession) + product = all_products.one() + + # print("{},{},{}".format(product.id, product.title, product.price)) + + # log in user2. + self.log_in_user(self.user2_creds) + + # add product to cart. + redirect_res1 = self.testapp.get("/cart/add?product_id={}".format(product.id)) + redirect_res2 = redirect_res1.follow() + res = redirect_res2.follow() + res_body = res.body.decode() + + self.assertIn("You added \"russell\'s product\" to your cart.", res_body) + self.assertIn("Cart $3.50 (1)", res_body) + + carts = get_all_carts(self.dbsession) + + # make sure we have 2 carts. + self.assertEqual(2, carts.count()) + + # make sure user2 has 1 item in active Cart. + self.assertEqual(1, self.user2.active_cart.count) + + # make sure user1 has 0 items in active Cart. + self.assertEqual(0, self.user1.active_cart.count) + + redirect_res = self.testapp.get( + "/u/cart/{}/checkout?shop_id={}".format( + self.user2.active_cart.id, + product.shop.id + ) + ) + redirect_res2 = redirect_res1.follow() + res = redirect_res2.follow() + self.assertIn("Please enter your payment information.", res.body.decode()) + + res = self.testapp.post( + "/billing/add-card?shop_id={}".format(product.shop.id), + { + "email": self.user2.email, + "stripeToken": "tok_visa", + }, + ) + + res = self.testapp.get( + "/u/cart/{}/checkout?shop_id={}".format( + self.user2.active_cart.id, + product.shop.id + ) + ) + res_body = res.body.decode() + + self.assertIn("Please confirm your order.", res_body) + self.assertIn("Visa", res_body) + self.assertIn("Are you sure you want to charge", res_body) + + redirect_res1 = self.testapp.get( + "/u/cart/{}/complete/checkout?shop_id={}".format( + self.user2.active_cart.id, + product.shop.id + ) + ) + res = redirect_res1.follow() + res_body = res.body.decode() + self.assertIn("Success, you have completed the purchase!", res_body) + + shop = get_shop_by_name(self.dbsession, self.shop1_params["name"]) + + stripe_customer = shop.stripe_customer(self.user2) + + stripe_charge = shop.list_stripe_charges(stripe_customer).data[0] + + self.assertEqual( + stripe_charge.amount, + dollars_to_cents(self.product1_params["price"]), + ) + + @patch("make_post_sell.models.Product.is_ready", mock_always_true) + def make_new_coupon_for_shop(self, coupon_params=None): + + # 1. log in as user1. + # 2. create new shop. + # 3. make new shop ready for checkout. + # 4. create new product. + self.test_new_product( + user_creds=self.user1_creds, + shop_params=self.shop1_params, + product_params=self.product1_params, + ) + + # query the new shop from database. + shop = get_shop_by_name(self.dbsession, self.shop1_params["name"]) + + if coupon_params is None: + coupon_params = self.coupon1_params + else: + # merge in any given params. + tmp = self.coupon1_params.copy() + tmp.update(coupon_params) + coupon_params = tmp + + # create a new shop coupon. + res = self.testapp.post( + "/s/{}/coupon/new".format(shop.id), + coupon_params, + ) + return res + + def test_new_coupon_for_shop(self): + res = self.make_new_coupon_for_shop() + res = res.follow() + self.assertIn("You created a new coupon!", res.body.decode()) + + def test_new_coupon_missing_params(self): + coupon_params = self.coupon1_params + del coupon_params["code"] + res = self.make_new_coupon_for_shop(coupon_params) + self.assertIn("Please submit all required fields.", res.body.decode()) + + # this patch requires an argument added to test method `mock_smtp`. + @patch("smtplib.SMTP") + @patch("make_post_sell.models.Product.is_ready", mock_always_true) + def test_checkout_with_coupon_code(self, mock_smtp): + res = self.make_new_coupon_for_shop() + res = res.follow() + self.assertIn("You created a new coupon!", res.body.decode()) + + # log out user1. + self.testapp.get("/log-out") + + # log in user2. + self.log_in_user(self.user2_creds) + + coupons = get_coupons_by_code(self.dbsession, self.coupon1_params["code"]) + coupon = coupons[0] + + # apply coupon to user2's active cart. + self.testapp.post( + "/coupon/apply?cart_id={}&coupon_id={}".format( + self.user2.active_cart.id, + coupon.id, + ) + ) + + # verify coupon was applied. + res_redirect1 = self.testapp.get("/cart") + res = res_redirect1.follow() + res_body = res.body.decode() + self.assertIn("Slippery Halloween Party", res_body) + self.assertIn("3.50", res_body) + self.assertIn("camp31", res_body) + + # get the Product object from database. + all_products = get_all_products(self.dbsession) + product = all_products.one() + + # add product to user2's active cart. + self.testapp.get("/cart/add?product_id={}".format(product.uuid_str)) + + # add billing details for user2 for this shop. + res = self.testapp.post( + "/billing/add-card?shop_id={}".format(product.shop.id), + { + "email": self.user2.email, + "stripeToken": "tok_visa", + }, + ) + + # attempt to checkout before coupon is properly qualified. + # shop total must be over cart_qualifier (test defaults to $10). + res_redirect1 = self.testapp.get( + "/u/cart/{}/checkout?shop_id={}".format( + self.user2.active_cart.id, + product.shop.id + ) + ) + res = res_redirect1.follow() + self.assertIn("Please review coupon terms: shop total not met.", res.body.decode()) + + # add more product to user2's active cart, to pass cart_qualifier. + self.testapp.get("/cart/add?product_id={}".format(product.uuid_str)) + self.testapp.get("/cart/add?product_id={}".format(product.uuid_str)) + + # checkout coupon confirmation unblocked. + res = self.testapp.get( + "/u/cart/{}/checkout?shop_id={}".format( + self.user2.active_cart.id, + product.shop.id + ) + ) + self.assertIn("Please confirm your order.", res.body.decode()) + + """ + def test_cart_checkout_for_market(self): + pass + + def test_cart_checkout_for_shop_with_coupon(self): + pass + + def test_cart_checkout_for_market_with_coupon(self): + pass + """ diff --git a/make_post_sell/tests/test_models.py b/make_post_sell/tests/test_models.py new file mode 100644 index 0000000..57b21db --- /dev/null +++ b/make_post_sell/tests/test_models.py @@ -0,0 +1,159 @@ +import unittest + +import mock + +from ..models import User, Coupon, Shop, is_user_name_valid +from ..models.meta import now_timestamp + +mock_always_none = mock.Mock(return_value=None) +mock_always_true = mock.Mock(return_value=True) +mock_false_then_true = mock.Mock(side_effect=[False, False, True]) + + +class TestUser(unittest.TestCase): + @mock.patch("make_post_sell.models.user.is_user_name_available", mock_always_true) + def setUp(self): + self.user = User("russell@ballestrini.net") + + def test_created_timestamp_set(self): + self.assertGreater(self.user.created_timestamp, 100000) + + def test_email_set(self): + self.assertEqual(self.user.email, "russell@ballestrini.net") + + def test_new_password(self): + raw_password = self.user.new_password() + self.assertGreater(len(raw_password), 30) + + def test_check_password_success(self): + raw_password = self.user.new_password() + self.assertTrue(self.user.check_password(raw_password)) + + def test_check_password_failure(self): + raw_password = self.user.new_password() + self.assertFalse(self.user.check_password("fake password")) + + def test_is_user_name_valid(self): + self.assertTrue(is_user_name_valid("validusername")) + self.assertTrue(is_user_name_valid("validusername2")) + self.assertTrue(is_user_name_valid("ValidUsername")) + self.assertTrue(is_user_name_valid("valid-username")) + self.assertTrue(is_user_name_valid("valid username")) + + self.assertFalse(is_user_name_valid("invalid!!!")) + self.assertFalse(is_user_name_valid("")) + + @mock.patch("make_post_sell.models.user.is_user_name_available", mock_always_true) + def test_generate_user_name_no_dash_prefix(self): + u = User("tim@example.com") + self.assertFalse(u.name.startswith("-")) + + +class TestCart(unittest.TestCase): + + # TODO: please test the fitness of the the Cart model. + # + # We have deliberately not written these tests but we should + # before going to much further. Unit tests are much faster and + # more grainular than functional tests. + # + # I'm moving forward without creating these tests because I have + # a functional test running as a safety net. + # + # That said unit tests are much better at capturing expected + # behavior and have better tooling for test and codepath coverage. + # + # Please help write unit tests soon! + + def setUp(self): + pass + + def validate_attached_coupon_codes(self): + """Make sure all coupons attached to the cart met the terms.""" + pass + +class TestCoupon(unittest.TestCase): + @mock.patch("make_post_sell.models.user.is_user_name_available", mock_always_true) + def setUp(self): + + # if the year is 2040 and this code is still in use and these tests + # break, it is safe to raise this date another +20 years. + future_date = "2040-01-15" + past_date = "2020-01-15" + + self.shop = Shop( + "my-shop", + "860-555-5555", + "1 wayward way", + "my shop description", + ) + self.coupon = Coupon( + shop=self.shop, + code="a-coupon-code", + description="a valid coupon", + action_type="dollar-off", + action_value=500, + max_redemptions=100, + max_redemptions_per_user=1, + expiration_date=future_date, + cart_qualifier = 20, + ) + self.old_coupon = Coupon( + shop=self.shop, + code="old-coupon-code", + description="a invalid coupon", + action_type="dollar-off", + action_value=500, + max_redemptions=100, + max_redemptions_per_user=1, + expiration_date=past_date, + cart_qualifier = 20, + ) + self.disabled_coupon = Coupon( + shop=self.shop, + code="disabled-coupon-code", + description="a disabled coupon", + action_type="dollar-off", + action_value=500, + max_redemptions=100, + max_redemptions_per_user=1, + expiration_date=future_date, + cart_qualifier = 20, + ) + self.disabled_coupon.disabled = True + + def test_coupon_code(self): + self.assertNotEqual(self.coupon.code, "invalid-code") + self.assertEqual(self.coupon.code, "a-coupon-code") + + # make sure coupon is not expired. + self.assertTrue(self.coupon.is_valid) + self.assertTrue(self.coupon.is_not_expired) + + # test inverse. + self.assertFalse(self.coupon.is_expired) + self.assertFalse(self.coupon.is_not_valid) + + + def test_expired_coupon_code(self): + self.assertEqual(self.old_coupon.code, "old-coupon-code") + + # make sure old_coupon is_expired and is_not_valid + self.assertTrue(self.old_coupon.is_expired) + self.assertTrue(self.old_coupon.is_not_valid) + + # test inverse. + self.assertFalse(self.old_coupon.is_valid) + self.assertFalse(self.old_coupon.is_not_expired) + + + def test_disabled_coupon_code(self): + self.assertEqual(self.disabled_coupon.code, "disabled-coupon-code") + + # make sure disabled_coupon is_not_expired and is_not_valid + self.assertTrue(self.disabled_coupon.is_not_expired) + self.assertTrue(self.disabled_coupon.is_not_valid) + + # test inverse. + self.assertFalse(self.disabled_coupon.is_valid) + self.assertFalse(self.disabled_coupon.is_expired) diff --git a/make_post_sell/views/__init__.py b/make_post_sell/views/__init__.py new file mode 100644 index 0000000..3cac3c2 --- /dev/null +++ b/make_post_sell/views/__init__.py @@ -0,0 +1,84 @@ +from pyramid.httpexceptions import HTTPFound + + +def get_referer_or_home(request): + """return referer or safe_redirect or '/'""" + return ( + request.referer + if request.referer is not None + # else request.app.get("safe_redirect", "/") + else "/" + ) + + +def get_join_or_log_in_route_uri(request, return_to=""): + query = None + if return_to: + query = {"return-to":return_to} + return request.route_url( + "join-or-log-in", + _query=query, + ) + + +# view decorator. +def user_required( + flash_msg="You must log in to access that area.", + flash_level="error", + return_to_route_name="", +): + """This view requires that the request has a user.""" + + def wrapped(fn): + + def inner(request): + if request.user and request.user.authenticated: + return fn(request) + request.session.flash((flash_msg, flash_level)) + return_to = "" + if return_to_route_name: + return_to = request.route_url(return_to_route_name) + return HTTPFound(get_join_or_log_in_route_uri(request, return_to)) + + return inner + + return wrapped + + +# view decorator. +def shop_is_ready_required( + flash_msg="Sorry, this shop is not ready to make sales yet. Please try again later.", + flash_level="error", +): + """This view requires that the request has a shop and that show is_ready.""" + + def wrapped(fn): + + def inner(request): + if request.shop and request.shop.is_ready: + return fn(request) + request.session.flash((flash_msg, flash_level)) + return HTTPFound(get_referer_or_home(request)) + + return inner + + return wrapped + + +# view decorator. +def super_fly_required(fn): + """This view requires that the request has a super fly admin.""" + # TODO: don't rely on this hack for super fly admin. + def wrapped(request): + if ( + request.user + and request.user.authenticated + and request.user.name == "Admin" + ): + return fn(request) + request.session.flash( + ("You must be a super fly admin to access that.", "error") + ) + return HTTPFound(get_referer_or_home(request)) + + return wrapped diff --git a/make_post_sell/views/actions.py b/make_post_sell/views/actions.py new file mode 100644 index 0000000..b0a03c9 --- /dev/null +++ b/make_post_sell/views/actions.py @@ -0,0 +1,15 @@ +from pyramid.view import view_config + +from . import user_required + + +@view_config(route_name="actions_view", renderer="actions_view.j2") +@user_required() +def view_actions(request): + return {} + + +@view_config(route_name="actions_new", renderer="actions_new.j2") +@user_required() +def new_actions(request): + return {} diff --git a/make_post_sell/views/authentication.py b/make_post_sell/views/authentication.py new file mode 100644 index 0000000..8d3725c --- /dev/null +++ b/make_post_sell/views/authentication.py @@ -0,0 +1,118 @@ +import re + +from pyramid.view import view_config + +from pyramid.httpexceptions import HTTPFound + +from ..models.user import get_or_create_user_by_email + +from ..lib.mail import send_otp_email + +from . import get_referer_or_home + + +@view_config(route_name="log-out") +def log_out(request): + """log out the user, redirect to back to referer.""" + request.session["authenticated_user_id"] = None + request.session["active_cart_id"] = None + return HTTPFound("/") + + +# disable CSRF checking for iframe embedded version of this view. +# If a client has 3rd party cookies disabled this security feature causes +# more trouble then it helps, essentially blocking unauthenticated users. +# disable CSRF checking for basic mode since we only set the +# CSRF token for logged in users. +@view_config(route_name="join-or-log-in", renderer="join-or-log-in.j2", require_csrf=False) +def join_or_log_in(request): + """ + This view handles user registration, verification, and log in. + It uses "password-less" authentication by sending OTP (one-time-password) + links to the user's email address. + """ + _email_regex = re.compile("^[^@]+@[^@]+\.[^.@]+$") + + # get the return_to uri from posted parameters. + return_to = request.params.get("return-to", "") + + # get the raw OTP (one-time-password) from posted parameters. + raw_otp = request.params.get("raw-otp", "") + + # get the email from posted parameters. + email = request.params.get("email", "") + + if email and _email_regex.match(email) is None: + # posted email does not pass regex, set it to None. + email = None + request.session.flash(("That email address is invalid." ,"error")) + + if request.spam: + return request.spam + + if request.user and request.user.authenticated: + # user already authenticated, return early. + if return_to: + return HTTPFound(return_to) + return HTTPFound(get_referer_or_home(request)) + + elif email: + # get or create a User object from the posted email. + user = get_or_create_user_by_email(request.dbsession, email) + + if raw_otp and user.check_password(raw_otp): + # success: the user was verified. + user.verified = True + name = "" + if user.full_name: + name = user.name + msg = ("Welcome {}".format(name), "success") + request.session["authenticated_user_id"] = str(user.id) + request.session.flash(msg) + + # merge any products in the session cart + # into user's main active cart. + user.active_cart.merge_in_cart(request.active_cart) + + # make session cart match user's active cart. + request.session["active_cart_id"] = str(user.active_cart.id) + + # delete old session only cart. + request.dbsession.delete(request.active_cart) + + # save user and user's active cart. + request.dbsession.add(user) + request.dbsession.add(user.active_cart) + request.dbsession.flush() + return HTTPFound(return_to) + + if user.throttle_password(): + msg = ( + "We already sent a link to {}. Check email to log in.".format(user.email), + "info", + ) + + else: + # generate a new one-time-password and save to database + raw_otp = user.new_password() + request.dbsession.add(user) + request.dbsession.flush() + + email_return_to = return_to + + # email user the one-time-password and flash message. + send_otp_email(request, user.email, raw_otp, email_return_to) + + msg = ( + "We just sent a link to {}. Check email to log in.".format(user.email), + "info", + ) + + request.session.flash(msg) + + return HTTPFound(return_to) + + return { + "title": "join or log in", + "return_to": return_to, + } diff --git a/make_post_sell/views/billing.py b/make_post_sell/views/billing.py new file mode 100644 index 0000000..e371a99 --- /dev/null +++ b/make_post_sell/views/billing.py @@ -0,0 +1,76 @@ +from pyramid.view import view_config + +from pyramid.httpexceptions import HTTPFound, HTTPBadRequest + +from . import ( + user_required, + shop_is_ready_required, +) + + +@view_config(route_name="billing", renderer="billing.j2") +@user_required(return_to_route_name="billing") +@shop_is_ready_required() +def billing(request): + return {} + + +@view_config(route_name="add-card") +@user_required() +@shop_is_ready_required() +def add_card(request): + # try to get the return_to uri from posted parameters. + return_to = request.params.get("return-to", "/billing") + + stripe_customer = request.shop.stripe_customer(request.user, create=True) + + try: + source = request.stripe_customer.sources.create( + source=request.params.get("stripeToken") + ) + request.stripe_customer.default_source = source + request.session.flash(("You saved a new card.", "success")) + request.stripe_customer.save() + except request.shop.stripe.error.CardError as e: + body = e.json_body + err = body.get("error", {}) + request.session.flash((err.get("message"), "error")) + + return HTTPFound(return_to) + + +@view_config(route_name="confirm-update-card", renderer="update-card.j2") +@user_required() +@shop_is_ready_required() +def confirm_update_card(request): + card_id = request.matchdict.get("card_id") + card = request.stripe_customer.sources.retrieve(card_id) + action = request.matchdict.get("action") + action_human = action.replace("-", " ") + button_class = "red-button" if action == "delete-card" else "blue-button" + return { + "card": card, + "card_id": card_id, + "action": action, + "action_human": action_human, + "button_class": button_class, + "the_title": action_human.title(), + } + + +@view_config(route_name="update-card") +@user_required() +@shop_is_ready_required() +def update_card(request): + action = request.params.get("action", None) + card_id = request.params.get("card_id") + if action not in ["make-card-active", "delete-card"]: + return HTTPBadRequest + if "delete-card" == action: + request.stripe_customer.sources.retrieve(card_id).delete() + request.session.flash(("You deleted that card.", "success")) + if "make-card-active" == action: + request.stripe_customer.default_source = card_id + request.session.flash(("You set the active card.", "success")) + request.stripe_customer.save() + return HTTPFound("/billing") diff --git a/make_post_sell/views/cart.py b/make_post_sell/views/cart.py new file mode 100644 index 0000000..e858178 --- /dev/null +++ b/make_post_sell/views/cart.py @@ -0,0 +1,467 @@ +from pyramid.view import view_config + +from . import ( + user_required, + get_referer_or_home, + shop_is_ready_required, +) + +from ..models.cart import get_cart_by_id +from ..models.product import get_product_by_id +from ..models.invoice import Invoice, InvoiceLineItem + +from pyramid.httpexceptions import HTTPFound + +from ..lib.mail import ( + send_purchase_email, + send_sale_email, +) + + +def get_cart_from_matchdict(request): + """ + This function uses the cart_id from the url path + and returns a Cart object from the database or None. + + If active_cart is the same cart as the url, we need not to query database. + """ + if str(request.active_cart.id) == request.matchdict["cart_id"]: + return request.active_cart + else: + return get_cart_by_id(request.dbsession, request.matchdict["cart_id"]) + + +@view_config(route_name="user_carts", renderer="carts.j2") +@user_required() +def carts(request): + if request.user.carts.count() == 1: + # since this user only has one cart, redirect to it. + return HTTPFound("/cart/{}".format(request.user.carts[0].id)) + return {} + + +def save_cart(request): + if request.active_cart.is_empty == False: + new_cart = request.user.new_active_cart() + request.dbsession.add(new_cart) + request.dbsession.add(request.user) + request.session["active_cart_id"] = str(new_cart.id) + msg = ("Cart saved for later.", "info") + request.session.flash(msg) + + +@view_config(route_name="user_cart_save") +@user_required( + flash_msg="To save your cart, please verify your email address below.", + flash_level="info", + return_to_route_name="cart" +) +def cart_save(request): + save_cart(request) + return HTTPFound("/cart") + + +@view_config(route_name="user_cart_public") +@user_required() +def cart_public(request): + cart = get_cart_from_matchdict(request) + + if cart is None: + msg = ("That cart_id does not exist.", "error") + + elif request.user.does_not_own_cart(cart): + msg = ("You do not own that cart.", "error") + + else: + msg = ("You made that cart public, you may now share the cart's link with others.", "success") + cart.public = True + request.dbsession.add(cart) + request.dbsession.flush() + + request.session.flash(msg) + return HTTPFound(get_referer_or_home(request)) + + +@view_config(route_name="user_cart_unpublic") +@user_required() +def cart_unpublic(request): + cart = get_cart_from_matchdict(request) + + if cart is None: + msg = ("That cart_id does not exist.", "error") + + elif request.user.does_not_own_cart(cart): + msg = ("You do not own that cart.", "error") + + else: + msg = ("You made that cart private again.", "success") + cart.public = False + request.dbsession.add(cart) + request.dbsession.flush() + + request.session.flash(msg) + return HTTPFound(get_referer_or_home(request)) + + +@view_config(route_name="user_cart_activate") +@user_required() +def cart_activate(request): + cart = get_cart_from_matchdict(request) + + if cart is None: + msg = ("That cart_id does not exist.", "error") + + # TODO: consider changing user.does_not_own_cart to + # user.can_not_edit_cart and user.can_edit_cart to match shops/products. + # this would also allow two different users the ability to edit a shared cart? + # is consistancy in the names/verbs/actions important in this case? + elif cart.is_not_public and request.user.does_not_own_cart(cart): + msg = ("That cart is not public and you do not own that cart.", "error") + + elif cart == request.active_cart: + msg = ("This cart is already your active cart.", "success") + + else: + if request.user.owns_cart(cart): + msg = ("You activated that cart.", "success") + request.session["active_cart_id"] = str(cart.id) + request.user.active_cart_id = cart.id + else: + msg = ("You made a copy and activated that public cart.", "success") + new_cart = request.user.new_active_cart() + new_cart.merge_in_cart(cart) + request.dbsession.add(new_cart) + request.dbsession.add(request.user) + request.session["active_cart_id"] = str(new_cart.id) + request.session.flash(msg) + return HTTPFound("/cart/{}".format(new_cart.id)) + + request.session.flash(msg) + return HTTPFound(get_referer_or_home(request)) + + +@view_config(route_name="user_cart_delete") +@user_required() +def cart_delete(request): + cart = get_cart_from_matchdict(request) + + if cart is None: + msg = ("That cart_id does not exist.", "error") + + elif request.user.does_not_own_cart(cart): + msg = ("You do not own that cart.", "error") + + elif cart == request.active_cart or cart == request.user.active_cart: + msg = ("You may not delete your active cart.", "error") + + else: + msg = ("You deleted that cart.", "success") + request.dbsession.delete(cart) + request.dbsession.flush() + request.session.flash(msg) + return HTTPFound("/u/carts") + + request.session.flash(msg) + return HTTPFound(get_referer_or_home(request)) + + +@view_config(route_name="cart") +def cart(request): + """Load active cart from session. Create cart if not in session.""" + return HTTPFound("/cart/{}".format(request.active_cart.id)) + + +@view_config(route_name="cart_by_id", renderer="cart.j2") +def cart_by_id(request): + + cart = get_cart_from_matchdict(request) + + if cart is None: + msg = ("That cart_id does not exist.", "error") + + elif cart.active or cart.public or (request.user and request.user.authenticated and request.user.owns_cart(cart)): + + # remove invalid coupons from cart, if any. + for coupon in cart.coupons: + if coupon.is_not_valid: + cart.coupons.remove(coupon) + request.dbsession.add(cart) + request.dbsession.flush() + msg = ("Invalid coupon ({}) removed from cart.".format(coupon.code), "info") + request.session.flash(msg) + + return { + "cart": cart, + "products": cart.products, + "shops": cart.shops, + "shop_product_dict": cart.shop_product_dict, + "discounted_shop_totals": cart.discounted_shop_totals, + "total_price": cart.total_price, + "total_discounted_price": cart.total_discounted_price, + } + else: + msg = ("You may not view that cart. It is not public and you do not own it.", "error") + + request.session.flash(msg) + return HTTPFound(get_referer_or_home(request)) + + +@view_config(route_name="cart_add_product") +def cart_add_product(request): + + product_id = request.params.get("product_id", None) + + if product_id is None: + msg = ("missing product_id.", "error") + request.session.flash(msg) + return HTTPFound(get_referer_or_home(request)) + + product = get_product_by_id(request.dbsession, product_id) + + if product is None: + msg = ("invalid product_id.", "error") + request.session.flash(msg) + return HTTPFound(get_referer_or_home(request)) + + elif product.is_not_ready: + msg = ("that product is not ready for purchase.", "error") + request.session.flash(msg) + return HTTPFound(get_referer_or_home(request)) + + elif product.is_not_sellable: + msg = ("that content is not for sale or purchase.", "error") + request.session.flash(msg) + return HTTPFound(get_referer_or_home(request)) + + elif not request.is_saas_domain and request.shop.uuid_str != product.shop_uuid_str: + request.session.flash(("Refusing to add a product from another shop to cart.", "error")) + return HTTPFound("/cart") + + msg = ('You added "{}" to your cart.'.format(product.title), "success") + request.active_cart.add_product(product) + request.dbsession.add(request.active_cart) + request.dbsession.flush() + request.session.flash(msg) + return HTTPFound("/cart") + + +@view_config(route_name="cart_remove_product") +def cart_remove_product(request): + + product_id = request.params.get("product_id", None) + + if product_id is None: + msg = ("missing product_id.", "error") + request.session.flash(msg) + return HTTPFound(get_referer_or_home(request)) + + product = get_product_by_id(request.dbsession, product_id) + cart = get_cart_from_matchdict(request) + + if cart is None: + msg = ("That cart_id does not exist.", "error") + + elif product is None: + msg = ("That product_id does not exist.", "error") + + elif cart.active or (request.user and request.user.authenticated and request.user.owns_cart(cart)): + msg = ("You removed \"{}\" from the cart.".format(product.title), "success") + cart.remove_product(product) + request.dbsession.add(cart) + request.dbsession.flush() + + else: + msg = ("You do not own that cart.", "error") + + request.session.flash(msg) + return HTTPFound(get_referer_or_home(request)) + + +@view_config(route_name="cart_quantity_product") +def cart_quantity_product(request): + + product_id = request.params.get("product_id", None) + quantity = request.params.get("quantity", None) + + if quantity: + try: + quantity = int(quantity) + except: + msg = ("Quantity must be a positive integer.", "error") + request.session.flash(msg) + return HTTPFound(get_referer_or_home(request)) + + + if product_id is None or quantity is None or quantity == "": + msg = ("missing product_id or quantity.", "error") + request.session.flash(msg) + return HTTPFound(get_referer_or_home(request)) + elif int(quantity) <= 0: + msg = ("Changed your mind? Click remove on the product.", "error") + request.session.flash(msg) + return HTTPFound(get_referer_or_home(request)) + + product = get_product_by_id(request.dbsession, product_id) + cart = get_cart_from_matchdict(request) + + if cart is None: + msg = ("That cart_id does not exist.", "error") + + elif product is None: + msg = ("That product_id does not exist.", "error") + request.session.flash(msg) + + # TODO: this should eventually switch to if not market request + # then refuse to add a product of another shop. + # we could keep a simple allow list of marketplace domains. + elif not request.is_saas_domain and request.shop.uuid_str != product.shop_uuid_str: + request.session.flash(("Refusing to add a product from another shop to cart.", "error")) + return HTTPFound("/cart") + + elif cart.active or (request.user and request.user.authenticated and request.user.owns_cart(cart)): + msg = ("You updated the quantity of \"{}\" in the cart.".format(product.title), "success") + cart.set_product_quantity(product, quantity) + request.dbsession.add(cart) + request.dbsession.flush() + + else: + msg = ("You do not own that cart.", "error") + + request.session.flash(msg) + return HTTPFound(get_referer_or_home(request)) + + +@view_config(route_name="cart_checkout", renderer="cart_checkout.j2") +@view_config(route_name="user_cart_checkout", renderer="cart_checkout.j2") +@user_required( + flash_msg="To checkout your cart, please verify your email address below.", + flash_level="info", + return_to_route_name="cart" +) +@shop_is_ready_required() +def cart_checkout(request): + + if "cart_id" not in request.matchdict: + return HTTPFound("/u/cart/{}/checkout".format(request.active_cart.id)) + + cart = get_cart_from_matchdict(request) + + if cart is None: + msg = ("That cart_id does not exist.", "error") + + elif cart.is_not_public and request.user.does_not_own_cart(cart): + msg = ("That cart is not public and you do not own that cart.", "error") + + elif cart.is_empty: + msg = ("That cart is empty, you cannot checkout.", "error") + + elif cart.requires_payment and request.shop.stripe_customer(request.user) is None: + msg = ("Please enter your payment information.", "info") + request.session.flash(msg) + return HTTPFound("/billing") + + else: + # make sure all coupon terms are met before proceeding. + error_messages = cart.validate_attached_coupons() + if error_messages: + for error_message in error_messages: + request.session.flash((error_message, "error")) + return HTTPFound(get_referer_or_home(request)) + + msg = ("Please confirm your order.", "info") + request.session.flash(msg) + return { + "cart": cart, + "products": cart.products, + } + + request.session.flash(msg) + return HTTPFound(get_referer_or_home(request)) + + +@view_config(route_name="user_cart_complete_checkout") +@user_required() +@shop_is_ready_required() +def cart_complete_checkout(request): + cart = get_cart_from_matchdict(request) + + if cart is None: + msg = ("That cart_id does not exist.", "error") + + elif cart.is_not_public and request.user.does_not_own_cart(cart): + msg = ("That cart is not public and you do not own that cart.", "error") + + elif cart.is_empty: + msg = ("That cart is empty, you cannot checkout.", "error") + + elif cart.requires_payment and request.shop.stripe_customer(request.user) is None: + msg = ("Please enter your payment information.", "info") + request.session.flash(msg) + return HTTPFound("/billing") + + else: + # make sure all coupon terms are met before proceeding. + error_messages = cart.validate_attached_coupons() + if error_messages: + for error_message in error_messages: + request.session.flash((error_message, "error")) + return HTTPFound(get_referer_or_home(request)) + + # TODO: currently this routine is naive to "market" invoices, an Invoice + # which has InvoiceLineItems for Products derived from more than one Shop. + invoice = Invoice(user=request.user) + + + invoice.shop = request.shop + invoice.market = request.market + + for product_id, quantity in cart.cart.items(): + product = cart.products[product_id] + product.unlock_for_user(request.user) + request.dbsession.add(product) + invoice.new_line_item( + product=product, + quantity=quantity, + market=invoice.market, + ) + + for coupon in cart.coupons: + invoice.new_coupon_redemption(coupon) + + request.dbsession.add(invoice) + + successful_checkout = True + + # only create a real charge if the cart has a positive price. + # this conditional allows us to support freebies. + if cart.requires_payment: + charge = request.shop.stripe.Charge.create( + customer=request.shop.stripe_customer_id(request.user), + amount=cart.total_in_cents, + currency='usd', + ) + successful_checkout = charge.paid + + if successful_checkout: + # flush changes to database because charge was successful! + request.dbsession.flush() + msg = ("Success, you have completed the purchase!", "success") + request.session.flash(msg) + # email the authenticated user regarding their purchase. + send_purchase_email(request, request.user.email, cart.products.values(), cart.total) + # email the shop owners regarding their sale. + send_sale_email(request, request.shop, cart.products.values(), cart.total) + # save the cart, so that we end up with a new empty cart. + save_cart(request) + return HTTPFound("/u/purchases") + + else: + msg = ( + "Oops! There was a error with your order: {}".format( + charge.failure_message + ), + "error" + ) + + request.session.flash(msg) + return HTTPFound(get_referer_or_home(request)) diff --git a/make_post_sell/views/content.py b/make_post_sell/views/content.py new file mode 100644 index 0000000..ea73810 --- /dev/null +++ b/make_post_sell/views/content.py @@ -0,0 +1,75 @@ +from pyramid.view import view_config + +from . import get_referer_or_home + +from pyramid.httpexceptions import HTTPFound + + +@view_config(route_name="content", renderer="content.j2") +@view_config(route_name="content_slug", renderer="content.j2") +def content(request): + + product = request.product + + if not request.is_saas_domain and request.domain != request.shop.domain_name: + # The content uuid in the URI matchdict is mismatched with request shop. + request.session.flash(("Refusing to display another shop's content.", "error")) + return HTTPFound(get_referer_or_home(request)) + + if product.is_private: + if request.user is None or request.user.can_not_edit_product(product): + request.session.flash(("That content is private.", "error")) + return HTTPFound(get_referer_or_home(request)) + + if product.is_sellable: + return HTTPFound("/p/{}/{}".format(product.id, product.slug)) + + if "slug" not in request.matchdict: + return HTTPFound("/c/{}/{}".format(product.id, product.slug)) + + signed_get_object_url = None + + if request.user and request.user.authenticated and request.user.can_download_product(product): + + # Params: Bucket, IfMatch, IfModifiedSince, IfNoneMatch, IfUnmodifiedSince, + # Key, Range, ResponseCacheControl, ResponseContentDisposition, ResponseProductEncoding, + # ResponseContentLanguage, ResponseContentType, ResponseExpires, VersionId, + # SSECustomerAlgorithm, SSECustomerKey, SSECustomerKeyMD5, RequestPayer, PartNumber + + bucket_name = request.app["bucket.secure_uploads"] + + params = { + "Bucket": bucket_name, + "Key": product.s3_key, + } + + # The content disposition string determines whether this is: + # * an inline image (thumbnails) + # * an attachment (file download) + content_disposition = product.get_content_disposition("content") + + # The content type string determines whether this is a pdf, zip, etc. + content_type = product.get_content_type("content") + + if content_disposition: + params["ResponseContentDisposition"] = content_disposition + + if content_type: + params["ResponseContentType"] = content_type + + signed_get_object_url = request.secure_uploads_client.generate_presigned_url( + ClientMethod='get_object', + Params=params, + # 15 minutes. + ExpiresIn=900 + ) + + #t = request.secure_uploads_client.get_object_torrent( + # Bucket=bucket_name, + # Key=product.s3_key, + #) + + return { + "product": product, + "signed_get_object_url": signed_get_object_url, + } diff --git a/make_post_sell/views/coupon.py b/make_post_sell/views/coupon.py new file mode 100644 index 0000000..58fa3f4 --- /dev/null +++ b/make_post_sell/views/coupon.py @@ -0,0 +1,162 @@ +from pyramid.view import view_config + +from . import ( + user_required, + get_referer_or_home, +) + +from ..models.coupon import ( + Coupon, + get_coupon_by_id, +) + +from ..models.cart import get_cart_by_id + +from pyramid.httpexceptions import HTTPFound + + +def get_coupon_from_matchdict(request): + """ + This function uses the coupon_id from the url path + and returns a Coupon object from the database or None. + """ + return get_coupon_by_id(request.dbsession, request.matchdict["coupon_id"]) + + +@view_config(route_name="coupons", renderer="coupons.j2") +def coupons(request): + + shop = request.user.active_shop + if request.shop: + shop = request.shop + + if request.user not in shop.users: + request.session.flash(("You do not own that Shop.", "error")) + return HTTPFound(get_referer_or_home(request)) + + return { + "coupons": shop.coupons.all() + } + + +@view_config(route_name="coupon1", renderer="coupon.j2") +@view_config(route_name="coupon2", renderer="coupon.j2") +def coupon(request): + + coupon = get_coupon_from_matchdict(request) + + return { + "coupon": coupon + } + + +@view_config(route_name="coupon_new1", renderer="coupon_new.j2") +@view_config(route_name="coupon_new2", renderer="coupon_new.j2") +@user_required() +def coupon_new(request): + + shop = request.user.active_shop + if request.shop: + shop = request.shop + + if request.user not in shop.users: + request.session.flash(("You do not own that Shop.", "error")) + return HTTPFound(get_referer_or_home(request)) + + code = request.params.get("code") + description = request.params.get("description") + action_type = request.params.get("action_type") + action_value = request.params.get("action_value") + max_redemptions = request.params.get("max_redemptions") + max_redemptions_per_user = request.params.get("max_redemptions_per_user") + expiration_date = request.params.get("expiration_date") + cart_qualifier = request.params.get("cart_qualifier") + + if request.params: + + if not code or not description or not action_type or not action_type: + request.session.flash(("Please submit all required fields.", "error")) + else: + request.session.flash(("You created a new coupon!", "success")) + coupon = Coupon( + shop = shop, + code = code, + description = description, + action_type = action_type, + action_value = action_value, + max_redemptions = max_redemptions, + max_redemptions_per_user = max_redemptions_per_user, + expiration_date = expiration_date, + cart_qualifier = cart_qualifier, + ) + request.dbsession.add(coupon) + request.dbsession.flush() + return HTTPFound("/s/{}/coupons".format(shop.id)) + + return request.params + + +@view_config(route_name="coupon_apply_to_cart", request_method="POST") +@user_required() +def coupon_apply_to_cart(request): + cart_id = request.params.get("cart_id") + coupon_id = request.params.get("coupon_id") + + cart = get_cart_by_id(request.dbsession, cart_id) + coupon = get_coupon_by_id(request.dbsession, coupon_id) + + if cart is None: + msg = ("That cart_id does not exist.", "error") + + elif coupon is None: + msg = ("That coupon_id does not exist.", "error") + + elif request.user.does_not_own_cart(cart): + msg = ("You do not own that cart.", "error") + + else: + if coupon not in cart.coupons: + msg = ("The coupon was applied to your cart.", "success") + cart.coupons.append(coupon) + request.dbsession.add(cart) + request.dbsession.flush() + else: + msg = ("The coupon was already applied to your cart.", "success") + request.session.flash(msg) + return HTTPFound("/cart/{}".format(cart_id)) + + request.session.flash(msg) + return HTTPFound(get_referer_or_home(request)) + + +@view_config(route_name="coupon_remove_from_cart", request_method="POST") +@user_required() +def coupon_remove_from_cart(request): + cart_id = request.params.get("cart_id") + coupon_id = request.params.get("coupon_id") + + cart = get_cart_by_id(request.dbsession, cart_id) + coupon = get_coupon_by_id(request.dbsession, coupon_id) + + if cart is None: + msg = ("That cart_id does not exist.", "error") + + elif coupon is None: + msg = ("That coupon_id does not exist.", "error") + + elif request.user.does_not_own_cart(cart): + msg = ("You do not own that cart.", "error") + + else: + if coupon in cart.coupons: + msg = ("The coupon was removed from your cart.", "success") + cart.coupons.remove(coupon) + request.dbsession.add(cart) + request.dbsession.flush() + else: + msg = ("The coupon was already removed from your cart.", "success") + request.session.flash(msg) + return HTTPFound("/cart/{}".format(cart_id)) + + request.session.flash(msg) + return HTTPFound(get_referer_or_home(request)) diff --git a/make_post_sell/views/misc.py b/make_post_sell/views/misc.py new file mode 100644 index 0000000..421ef0a --- /dev/null +++ b/make_post_sell/views/misc.py @@ -0,0 +1,56 @@ +from pyramid.view import view_config + +from ..models.product import get_all_products + +# needed for favicon +import os +from pyramid.response import ( + FileResponse, + Response, +) + +from make_post_sell.lib.render import markdown_to_html + + +DEFAULT_ROBOTS_DOT_TXT = """ +User-agent: * +Disallow: +""" + + +@view_config(route_name="favicon") +def favicon_view(request): + here = os.path.dirname(__file__) + icon = os.path.join(here, "..", "static", "favicon.ico") + return FileResponse(icon, request=request) + + +@view_config(route_name="robots") +def robots_view(request): + """Load and return either default robots.txt or version from .ini""" + response = Response( + body=request.app.get( + "robots_dot_txt", + DEFAULT_ROBOTS_DOT_TXT + ).lstrip() + ) + response.content_type = "text/plain" + return response + + +# I set require_csfr to false because new browsers +# don't save 3rd party cookies anymore. +# This ajax endpoint should work whether a user is authenticated or not. +@view_config( + route_name="markup-editor-preview", + renderer="string", + xhr=True, + require_csrf=False +) +def markup_editor_preview(request): + """AJAJ: Accept Markup data param, return HTML""" + # currently only supports markdown, but eventually we could support others. + try: + return markdown_to_html(request.params["data"], request.shop) + except: + return "we could not create markdown to html preview." diff --git a/make_post_sell/views/product.py b/make_post_sell/views/product.py new file mode 100644 index 0000000..2f89a1a --- /dev/null +++ b/make_post_sell/views/product.py @@ -0,0 +1,383 @@ +from pyramid.view import view_config + +from . import ( + user_required, + get_referer_or_home, +) + +from ..models.product import Product + +from pyramid.httpexceptions import HTTPFound + +from miscutils import ( + dollars_to_cents, + cents_to_dollars, +) + + +def checkbox_to_bool(checkbox): + return True if checkbox == "on" else False + + +@view_config(route_name="product", renderer="product.j2") +@view_config(route_name="product_slug", renderer="product.j2") +def product(request): + + product = request.product + + if not request.is_saas_domain and request.domain != request.shop.domain_name: + # The product uuid in the URI matchdict is mismatched with request shop. + request.session.flash(("Refusing to display another shop's product.", "error")) + return HTTPFound(get_referer_or_home(request)) + + if product.is_private: + if request.user is None or request.user.can_not_edit_product(product): + request.session.flash(("That product is private.", "error")) + return HTTPFound(get_referer_or_home(request)) + + if product.is_not_sellable: + return HTTPFound("/c/{}/{}".format(product.id, product.slug)) + + if "slug" not in request.matchdict: + return HTTPFound("/p/{}/{}".format(product.id, product.slug)) + + signed_get_object_url = None + + if request.user and request.user.authenticated and request.user.can_download_product(product): + + # Params: Bucket, IfMatch, IfModifiedSince, IfNoneMatch, IfUnmodifiedSince, + # Key, Range, ResponseCacheControl, ResponseContentDisposition, ResponseContentEncoding, + # ResponseContentLanguage, ResponseContentType, ResponseExpires, VersionId, + # SSECustomerAlgorithm, SSECustomerKey, SSECustomerKeyMD5, RequestPayer, PartNumber + + params = { + "Bucket": request.app["bucket.secure_uploads"], + "Key": product.s3_key, + } + + # The content disposition string determines whether this is: + # * an inline image (thumbnails) + # * an attachment (file download) + content_disposition = product.get_content_disposition("product") + + # The content type string determines whether this is a pdf, zip, etc. + content_type = product.get_content_type("product") + + if content_disposition: + params["ResponseContentDisposition"] = content_disposition + + if content_type: + params["ResponseContentType"] = content_type + + signed_get_object_url = request.secure_uploads_client.generate_presigned_url( + ClientMethod='get_object', + Params=params, + # 15 minutes. + ExpiresIn=900 + ) + + return { + "product": product, + "signed_get_object_url": signed_get_object_url, + } + + +@view_config(route_name="product_new", renderer="product_new.j2") +@view_config(route_name="content_new", renderer="content_new.j2") +@user_required() +def product_new(request): + + if request.shop is None and request.user.active_shop is None: + msg = ("First we must create a shop.", "info") + request.session.flash(msg) + return HTTPFound("/s/new") + + shop = request.user.active_shop + if request.shop: + shop = request.shop + + if request.user not in shop.users: + request.session.flash(("You do not own that Shop.", "error")) + return HTTPFound(get_referer_or_home(request)) + + title = request.params.get("title", "") + description = request.params.get("description", "") + price = request.params.get("price", "") + + is_bundle_checkbox = request.params.get("is_bundle", "off") + is_bundle = checkbox_to_bool(is_bundle_checkbox) + + is_sellable_checkbox = request.params.get("is_sellable", "off") + is_sellable = checkbox_to_bool(is_sellable_checkbox) + + if title and description: + + product = Product(title, description) + product.shop = shop + product.is_bundle = is_bundle + product.is_sellable = is_sellable + + if product.error_message: + request.session.flash((product.error_message, "error")) + + else: + if is_sellable and price: + product_price = product.set_price(price) + request.dbsession.add(product_price) + + request.dbsession.add(product) + request.dbsession.flush() + msg = ("Great, next you may upload files.", "success") + request.session.flash(msg) + return HTTPFound("/p/{}/edit".format(product.id)) + + elif title or description or price: + msg = ("You must fill out all fields.", "error") + request.session.flash(msg) + + return { + "shop": shop, + "title": title, + "description": description, + "price": price, + } + + +@view_config(route_name="product_description_edit", renderer="markup_editor.j2") +@user_required() +def product_edit_description(request): + product = request.product + + if request.user.can_not_edit_product(product): + request.session.flash(("You do not own that Product.", "error")) + return HTTPFound(get_referer_or_home(request)) + + raw_markup_data = request.params.get( + "markup-editor-textarea", + # TODO: rename this column to description_raw. + product.description + ) + + if raw_markup_data and raw_markup_data != product.description: + product.set_description(raw_markup_data) + msg = ("You set the Products's Description!", "success") + request.session.flash(msg) + request.dbsession.add(product) + request.dbsession.flush() + + return { + "markup_subject": "Product Description for {}".format(product.title), + "markup_rendered": product.description_html, + "markup_raw": product.description, + "markup_form_path": "{}/description".format( + product.absolute_url(request, slug=False) + ) + } + + +@view_config(route_name="product_edit", renderer="product_edit.j2") +@view_config(route_name="product_edit2", renderer="product_edit.j2") +@user_required() +def product_edit(request): + + product_modified = False + product = request.product + + if request.user.can_not_edit_product(product): + request.session.flash(("You do not own that Product.", "error")) + return HTTPFound(get_referer_or_home(request)) + + title = request.params.get("title", product.title) + description = request.params.get("description", product.description) + price = request.params.get("price", product.price) + visibility = int(request.params.get("visibility", product.visibility)) + uploaded_file_key = request.params.get("uploaded", "") + + if uploaded_file_key: + request.session.flash( + ( + "You successfully uploaded a new {} file!".format(uploaded_file_key), + "success" + ) + ) + + try: + price = float(price) + new_price_in_cents = dollars_to_cents(price) + except ValueError: + request.session.flash(("Please only pass numbers for price.", "error")) + price = product.price + new_price_in_cents = None + + s3_webhook_key = request.params.get("key") + s3_webhook_bucket = request.params.get("bucket") + s3_webhook_etag = request.params.get("etag") + + if title != product.title: + product_modified = True + product.title = title + request.session.flash(("You updated the product's title.", "success")) + + if description != product.description: + product_modified = True + product.set_description(description) + request.session.flash(("You updated the product's description.", "success")) + + if visibility != product.visibility: + product_modified = True + product.visibility = visibility + request.session.flash(("You updated the product's visibility.", "success")) + + if new_price_in_cents and new_price_in_cents != product.price_in_cents: + product_modified = True + request.dbsession.add(product.set_price(price)) + + # price is now a float. + price = product.price + request.session.flash(("You updated the product's price.", "success")) + + if s3_webhook_key and s3_webhook_bucket and s3_webhook_etag: + # Store the uploaded user's file_metadata in our database. + file_key = product.store_file_metadata(s3_webhook_key) + + # This is how we protect our digital downloads while using the + # same bucket for thumbnails and previews related to the product. + acl = "private" + if file_key in product.file_public_keys: + acl = "public-read" + if product.is_not_sellable: + acl = "public-read" + + # don't allow proxies to cache and set the max age to 2 days. + cache_control = "private, max-age=172800" + + # copy upload to our system defined s3 location. + # https://boto3.amazonaws.com/v1/documentation/api/latest/reference/services/s3.html#S3.Client.copy_object + request.secure_uploads_client.copy_object( + ACL = acl, + Bucket=request.app["bucket.secure_uploads"], + CopySource={ + "Bucket": s3_webhook_bucket, + "Key": s3_webhook_key, + }, + ContentDisposition=product.get_content_disposition(file_key), + ContentType=product.get_content_type(file_key), + CacheControl=cache_control, + Key="{}/{}".format(product.s3_path, file_key), + MetadataDirective="REPLACE", + ) + + # delete original upload key. + request.secure_uploads_client.delete_object( + Bucket=s3_webhook_bucket, + Key=s3_webhook_key, + ) + + request.dbsession.add(product) + request.dbsession.flush() + + # redirect back to this page to clear + # the params posted by the s3 webhooks. + return HTTPFound("/p/{}/{}/edit?uploaded={}".format(product.id, product.slug, file_key)) + + if product_modified and product.error_message is None: + request.dbsession.add(product) + request.dbsession.flush() + + signed_posts = {} + + for file_key in product.file_keys: + key_starts_with = "{}/{}.".format(product.s3_path, file_key) + conditions = [ + { "success_action_redirect": product.absolute_edit_url(request) }, + ["starts-with", "$key", key_starts_with], + ] + + signed_posts[file_key] = request.secure_uploads_client.generate_presigned_post( + Bucket=request.app["bucket.secure_uploads"], + Key=key_starts_with + "${filename}", + ExpiresIn=900, + Conditions=conditions, + ) + + # We must create a field key/value for each extra condition. + # unfortunately `conditions` is a list of single key/value dicts + # and/or 2/3 item lists so we flatten them into a single fields dict. + # [{1:2}, [3,4]] -> {1:2, 3:4} + for condition in conditions: + if isinstance(condition, list): + if len(condition) == 2: + # convert list into dict. + condition = {condition[0]: condition[1]} + else: + continue + signed_posts[file_key]["fields"].update(condition) + + return { + "product": product, + "title": title, + "description": description, + "price": price, + "visibility": visibility, + "signed_posts": signed_posts, + } + + +@view_config(route_name="product_bundle_edit") +@view_config(route_name="product_bundle_edit2") +@user_required() +def product_bundle_edit(request): + + from ..models.product import get_products_by_ids + + from miscutils.uuid_validation import UUID_ALL_PATTERN + + product = request.product + + if product.is_bundle == False: + request.session.flash(("You may only add products to a Product bundle.", "error")) + + if request.user.can_not_edit_product(product): + request.session.flash(("You do not own that Product bundle.", "error")) + return HTTPFound(get_referer_or_home(request)) + + bundle_data = request.params.get("bundle-data", None) + + if bundle_data: + products = get_products_by_ids( + request.dbsession, + UUID_ALL_PATTERN.findall(bundle_data) + ) + for product_to_bundle in products: + if product.add_product_to_bundle(product_to_bundle) == False: + request.session.flash((product.error_message, "error")) + continue + request.session.flash(("You added a product to this bundle!", "success")) + + return HTTPFound(get_referer_or_home(request)) + + +@view_config(route_name="product_remove_bundled_product") +@user_required() +def product_bundle_remove_product(request): + + from ..models.product import get_product_by_id + + product = request.product + + bundled_product_id = request.matchdict.get("bundled_product_id") + bundled_product = get_product_by_id(request.dbsession, bundled_product_id) + + if product.is_bundle == False: + request.session.flash(("You may only remove products from a Product bundle.", "error")) + + if request.user.can_not_edit_product(product): + request.session.flash(("You do not own that Product bundle.", "error")) + return HTTPFound(get_referer_or_home(request)) + + if product.remove_product_from_bundle(bundled_product) == False: + request.session.flash((product.error_message, "error")) + else: + request.session.flash(("You removed a product from this bundle!", "success")) + + return HTTPFound(get_referer_or_home(request)) diff --git a/make_post_sell/views/shop.py b/make_post_sell/views/shop.py new file mode 100644 index 0000000..6f08afd --- /dev/null +++ b/make_post_sell/views/shop.py @@ -0,0 +1,494 @@ +import mimetypes + +from pyramid.view import view_config + +from . import user_required + +from ..models.shop import ( + Shop, + get_shop_by_id, + is_shop_name_valid, + is_shop_name_available, +) + +from ..models.product import ( + get_products_from_a_shop, + get_all_products_from_a_shop, + get_products_by_keywords, +) + +from ..models.shop_search_request import ( + ShopSearchRequest, +) + +from pyramid.httpexceptions import HTTPFound + +from miscutils.phone_numbers import is_phone_number_valid + + +def get_shop_from_matchdict(request, prefetched_shop=None): + """ + This function uses the shop_id from the url path + and returns a Shop object from the database or None. + + This protects the database from querying the same shop object twice, + if the optional prefetched_shop Shop object has the same id as the + matchdict in the route's path. + """ + if prefetched_shop and str(prefetched_shop.id) == request.matchdict["shop_id"]: + return prefetched_shop + else: + # returns None if shop_id is an invalid uuid. + return get_shop_by_id(request.dbsession, request.matchdict["shop_id"]) + + +@view_config(route_name="home", renderer="home.j2") +def home(request): + products = None + if request.shop: + products = get_products_from_a_shop(request.shop) + return { + "products": products, + } + + +@view_config(route_name="shop", renderer="shop.j2") +@view_config(route_name="shop_slug", renderer="shop.j2") +def shop(request): + shop = get_shop_from_matchdict(request) + if "slug" not in request.matchdict: + return HTTPFound("/s/{}/{}".format(shop.id, shop.slug)) + + return { + "products": get_products_from_a_shop(shop), + } + + +@view_config(route_name="shop_products", renderer="shop_products.j2") +@user_required() +def shop_products(request): + msg = None + shop = get_shop_from_matchdict(request) + + if shop is None: + msg = ("That shop_id does not exist.", "error") + elif request.user.can_not_edit_shop(shop): + msg = ("That shop is not owned by you.", "error") + + if msg: + request.session.flash(msg) + return HTTPFound(get_referer_or_home(request)) + + return { + "products": get_all_products_from_a_shop(shop), + } + + +@view_config(route_name="shop_about", renderer="shop_about.j2") +@view_config(route_name="shop_about_slug", renderer="shop_about.j2") +def shop_about(request): + shop = get_shop_from_matchdict(request) + if "slug" not in request.matchdict: + return HTTPFound("/s/{}/{}/about".format(shop.id, shop.slug)) + return {} + + +@view_config(route_name="search", renderer="home.j2") +def search(request): + keywords = request.params.get("keywords", None) + + if keywords is None: + return HTTPFound(get_referer_or_home(request)) + + products = get_products_by_keywords( + request.dbsession, + keywords.split(" "), + request.shop + ) + + hit_count = len(products) + + # log search_request for later reporting. + search_request = ShopSearchRequest( + keywords, + hit_count, + shop = request.shop, + user = request.user, + ) + request.dbsession.add(search_request) + request.dbsession.flush() + + if hit_count == 1: + # redirect to only match, the title slugified node uri. + return HTTPFound(products[0].absolute_url(request)) + + return { + "products": products, + "keywords": keywords, + } + + +@view_config(route_name="shop_new", renderer="shop_new.j2") +@user_required() +def shop_new(request): + + name = request.params.get("name", "") + phone_number = request.params.get("phone_number", "") + billing_address = request.params.get("billing_address", "") + description = request.params.get("description", "") + + if name and description and billing_address and phone_number: + if is_shop_name_valid(name) == False: + msg = ("Invalid shop name, only use alpha numeric, spaces, dashes, or periods.", "error") + request.session.flash(msg) + elif is_shop_name_available(request.dbsession, name) == False: + msg = ("That shop name is already in use. Please pick another.", "error") + request.session.flash(msg) + elif is_phone_number_valid(phone_number) == False: + msg = ("Invalid phone number. We accept: -.() and numbers.", "error") + request.session.flash(msg) + + else: + shop = Shop(name, phone_number, billing_address, description) + if len(request.user.shops) == 0: + shop.add_user_to_shop(request.user, default=True) + else: + shop.add_user_to_shop(request.user) + request.dbsession.add(shop) + request.dbsession.flush() + msg = ("Great work, you created a shop! You may continue to setup your shop or start posting products!", "success") + request.session.flash(msg) + return HTTPFound("/s/{}/settings".format(shop.id)) + + elif name or description or billing_address or phone_number: + msg = ("You must fill out all fields.", "error") + request.session.flash(msg) + + return { + "name": name, + "description": description, + "billing_address": billing_address, + "phone_number": phone_number, + } + + +@view_config(route_name="user_shop_activate") +@user_required() +def shop_activate(request): + shop = get_shop_from_matchdict( + request, prefetched_shop=request.user.active_shop + ) + + if shop is None: + msg = ("That shop_id does not exist.", "error") + + elif request.user.can_not_edit_shop(shop): + msg = ("That shop is not owned by you.", "error") + + elif shop == request.user.active_shop: + msg = ("This shop is already your active shop.", "success") + + else: + msg = ("You activated that shop.", "success") + request.user.active_shop_id = shop.id + request.dbsession.add(request.user) + request.dbsession.flush() + + request.session.flash(msg) + return HTTPFound("/") + + +@view_config(route_name="shop_terms_of_service", renderer="markup_renderer.j2") +@view_config(route_name="shop_terms_of_service2", renderer="markup_renderer.j2") +def shop_terms_of_service(request): + return { + "markup_rendered": request.shop.terms_of_service_html, + "markup_path": "terms", + } + + +@view_config(route_name="shop_privacy_policy", renderer="markup_renderer.j2") +@view_config(route_name="shop_privacy_policy2", renderer="markup_renderer.j2") +def shop_privacy_policy(request): + return { + "markup_rendered": request.shop.privacy_policy_html, + "markup_path": "privacy-policy", + } + + +@view_config(route_name="shop_edit_terms_of_service", renderer="markup_editor.j2") +@user_required() +def shop_edit_terms_of_service(request): + shop = request.user.active_shop + if request.shop: + shop = request.shop + + if request.user.can_not_edit_shop(shop): + request.session.flash(("You do not own that Shop.", "error")) + return HTTPFound(get_referer_or_home(request)) + + raw_markup_data = request.params.get( + "markup-editor-textarea", + shop.terms_of_service_raw + ) + + if raw_markup_data and raw_markup_data != shop.terms_of_service_raw: + shop.terms_of_service = raw_markup_data + msg = ("You set the shop's Terms of Service!", "success") + request.session.flash(msg) + request.dbsession.add(shop) + request.dbsession.flush() + + return { + "markup_subject": "Terms of Service", + "markup_rendered": shop.terms_of_service_html, + "markup_raw": shop.terms_of_service_raw, + "markup_form_path": shop.absolute_terms_url(request) + } + + +@view_config(route_name="shop_edit_privacy_policy", renderer="markup_editor.j2") +@user_required() +def shop_edit_privacy_policy(request): + shop = request.user.active_shop + if request.shop: + shop = request.shop + + if request.user.can_not_edit_shop(shop): + request.session.flash(("You do not own that Shop.", "error")) + return HTTPFound(get_referer_or_home(request)) + + raw_markup_data = request.params.get( + "markup-editor-textarea", + shop.privacy_policy_raw + ) + + if raw_markup_data and raw_markup_data != shop.privacy_policy_raw: + shop.privacy_policy = raw_markup_data + msg = ("You set the shop's Privacy Policy!", "success") + request.session.flash(msg) + request.dbsession.add(shop) + request.dbsession.flush() + + return { + "markup_subject": "Privacy Policy", + "markup_rendered": shop.privacy_policy_html, + "markup_raw": shop.privacy_policy_raw, + "markup_form_path": shop.absolute_privacy_policy_url(request) + } + + +@view_config(route_name="shop_settings", renderer="shop_settings.j2") +@user_required() +def shop_settings(request): + + shop = request.user.active_shop + if request.shop: + shop = request.shop + + if request.user.can_not_edit_shop(shop): + request.session.flash(("You do not own that Shop.", "error")) + return HTTPFound(get_referer_or_home(request)) + + name = request.params.get("name", shop.name) + phone_number = request.params.get("phone_number", shop.phone_number) + billing_address = request.params.get("billing_address", shop.billing_address) + description = request.params.get("description", shop.description) + domain_name = request.params.get("domain_name", shop.domain_name) + + # announcement ribbon settings. + ribbon_text = request.params.get("ribbon_text", shop.ribbon_text) + ribbon_text_color = request.params.get("ribbon_text_color", shop.ribbon_text_color) + ribbon_color_1 = request.params.get("ribbon_color_1", shop.ribbon_color_1) + ribbon_color_2 = request.params.get("ribbon_color_2", shop.ribbon_color_2) + + google_analytics_id = request.params.get("google_analytics_id", shop.google_analytics_id) + + stripe_public_api_key = request.params.get("stripe_public_api_key", shop.stripe_public_api_key) + stripe_secret_api_key = request.params.get("stripe_secret_api_key", shop.stripe_secret_api_key) + + s3_webhook_key = request.params.get("key") + s3_webhook_bucket = request.params.get("bucket") + s3_webhook_etag = request.params.get("etag") + + uploaded_file_key = request.params.get("uploaded", "") + + if uploaded_file_key: + request.session.flash( + ( + "You successfully uploaded a new {} file!".format(uploaded_file_key), + "success" + ) + ) + + if name != shop.name: + shop.name = name + msg = ("You set the shop's name.", "success") + request.session.flash(msg) + + if phone_number != shop.phone_number: + if is_phone_number_valid(phone_number): + shop.phone_number = phone_number + msg = ("You set the shop's phone number.", "success") + else: + msg = ("Invalid phone number. We accept: -.() and numbers.", "error") + request.session.flash(msg) + + if billing_address != shop.billing_address: + shop.billing_address = billing_address + msg = ("You set the shop's billing address.", "success") + request.session.flash(msg) + + if description != shop.description: + shop.description = description + msg = ("You set the shop's description.", "success") + request.session.flash(msg) + + if domain_name and domain_name != shop.domain_name: + shop.domain_name = domain_name + msg = ("You set the shop's domain name.", "success") + request.session.flash(msg) + + if ribbon_text != shop.ribbon_text: + shop.ribbon_text = ribbon_text + msg = ("You set the shop's announcement ribbon text.", "success") + request.session.flash(msg) + + if ribbon_text_color != shop.ribbon_text_color: + shop.ribbon_text_color = ribbon_text_color + msg = ("You set the shop's announcement ribbon text color.", "success") + request.session.flash(msg) + + if ribbon_color_1 != shop.ribbon_color_1: + shop.ribbon_color_1 = ribbon_color_1 + msg = ("You set the shop's announcement ribbon background color 1.", "success") + request.session.flash(msg) + + if ribbon_color_2 != shop.ribbon_color_2: + shop.ribbon_color_2 = ribbon_color_2 + msg = ("You set the shop's announcement ribbon background color 2.", "success") + request.session.flash(msg) + + if google_analytics_id != shop.google_analytics_id: + shop.google_analytics_id = google_analytics_id + msg = ("You set the shop's Google Analytics Id.", "success") + request.session.flash(msg) + + if stripe_public_api_key and stripe_public_api_key != shop.stripe_public_api_key: + if stripe_public_api_key.startswith("pk_"): + shop.stripe_public_api_key = stripe_public_api_key + msg = ("You set the shop's stripe_public_api_key.", "success") + else: + msg = ("The shop's stripe_public_api_key must start with 'pk_'.", "error") + request.session.flash(msg) + + if stripe_secret_api_key and stripe_secret_api_key != shop.stripe_secret_api_key: + if stripe_secret_api_key.startswith("sk_"): + shop.stripe_secret_api_key = stripe_secret_api_key + msg = ("You set the shop's stripe_secret_api_key.", "success") + else: + msg = ("The shop's stripe_secret_api_key must start with 'sk_'.", "error") + request.session.flash(msg) + + # TODO: Dry out this block, it's a copy pasta from views/product.py + if s3_webhook_key and s3_webhook_bucket and s3_webhook_etag: + + # get file_from S3 service posted params. + file_key = s3_webhook_key.split("/")[-1].split(".")[0] + + acl = "public-read" + + # don't allow proxies to cache and set the max age to 2 days. + cache_control = "private, max-age=172800" + + # determine content type from extension. + file_ext = s3_webhook_key.split(".")[-1] + print(file_ext) + content_type = mimetypes.types_map.get("." + file_ext) + print(content_type) + + # copy upload to our system defined s3 location. + # https://boto3.amazonaws.com/v1/documentation/api/latest/reference/services/s3.html#S3.Client.copy_object + request.secure_uploads_client.copy_object( + ACL = acl, + Bucket=request.app["bucket.secure_uploads"], + CopySource={ + "Bucket": s3_webhook_bucket, + "Key": s3_webhook_key, + }, + ContentDisposition= "inline; filename={}".format(file_key), + ContentType=content_type, + CacheControl=cache_control, + Key="{}/meta/{}".format(shop.id, file_key), + MetadataDirective="REPLACE", + ) + + # delete original upload key. + request.secure_uploads_client.delete_object( + Bucket=s3_webhook_bucket, + Key=s3_webhook_key, + ) + + # redirect back to this page to clear + # the params posted by the s3 webhooks. + return HTTPFound("/s/{}/{}/settings?uploaded={}".format(shop.id, shop.slug, file_key)) + + + + signed_posts = {} + get_endpoints = {} + + for file_key in ["shop-logo-banner", "shop-logo-avatar"]: + # conditionally sign the ability to POST to the object path when the + # object path start's with the shop's s3 namespace (uuid) and only for + # particular file keys. This prevents a user from writing files into + # a shops namespace which is owned by a different user. + key_starts_with = "{}/meta/{}.{}".format(shop.id, file_key, "$filename") + conditions = [ + { "success_action_redirect": shop.absolute_settings_url(request) }, + ["starts-with", "$key", key_starts_with], + ] + + signed_posts[file_key] = request.secure_uploads_client.generate_presigned_post( + Bucket=request.app["bucket.secure_uploads"], + # uploads to //meta/shop-logo-banner.the-users-file.png + Key=key_starts_with + "${filename}", + ExpiresIn=900, + Conditions=conditions, + ) + get_endpoints[file_key] = "{}/{}/meta/{}".format( + request.app["bucket.secure_uploads.get_endpoint"], + shop.id, + file_key, + ) + + # We must create a field key/value for each extra condition. + # unfortunately `conditions` is a list of single key/value dicts + # and/or 2/3 item lists so we flatten them into a single fields dict. + # [{1:2}, [3,4]] -> {1:2, 3:4} + for condition in conditions: + if isinstance(condition, list): + if len(condition) == 2: + # convert list into dict. + condition = {condition[0]: condition[1]} + else: + continue + signed_posts[file_key]["fields"].update(condition) + + + return { + "name": name, + "description": description, + "billing_address": billing_address, + "phone_number": phone_number, + "domain_name": domain_name, + "ribbon_text": ribbon_text, + "ribbon_text_color": ribbon_text_color, + "ribbon_color_1": ribbon_color_1, + "ribbon_color_2": ribbon_color_2, + "google_analytics_id": google_analytics_id, + "stripe_public_api_key": stripe_public_api_key, + "stripe_secret_api_key": stripe_secret_api_key, + "signed_posts": signed_posts, + "get_endpoints": get_endpoints, + } diff --git a/make_post_sell/views/user.py b/make_post_sell/views/user.py new file mode 100644 index 0000000..a833f3d --- /dev/null +++ b/make_post_sell/views/user.py @@ -0,0 +1,57 @@ +from pyramid.view import view_config + +from . import user_required + +from ..models.user import ( + is_user_name_available, + is_user_name_valid, +) + + +@view_config(route_name="user_shops", renderer="shops.j2") +@user_required() +def user_shops(request): + return { + "the_title": "My Shops", + } + + +@view_config(route_name="user_purchases", renderer="products.j2") +def user_products(request): + return { + "products": request.user.products, + "the_title": "My Purchases", + } + + +@view_config(route_name="user_settings", renderer="user_settings.j2") +@user_required() +def user_settings(request): + + user = request.user + + name = request.params.get("name", user.name) + full_name = request.params.get("full_name", user.full_name) + + if name and name != user.name: + if not is_user_name_valid(name): + msg = ("Sorry your display name is invalid. Please try again with only letters, numbers, spaces, dashes, and periods.", "error") + name = user.name + elif not is_user_name_available(request.dbsession, name): + msg = ("Sorry that display name is already taken. Please try again.", "error") + name = user.name + else: + user.name = name + msg = ("You set your public display name.", "success") + request.session.flash(msg) + + if full_name and full_name != user.full_name: + user.full_name = full_name + msg = ("You set your private full name.", "success") + request.session.flash(msg) + + return { + "name": name, + "full_name": full_name, + } + diff --git a/pytest.ini b/pytest.ini new file mode 100644 index 0000000..4a67483 --- /dev/null +++ b/pytest.ini @@ -0,0 +1,3 @@ +[pytest] +testpaths = make_post_sell +python_files = test*.py diff --git a/requirements-dev.txt b/requirements-dev.txt new file mode 100644 index 0000000..7dca248 --- /dev/null +++ b/requirements-dev.txt @@ -0,0 +1,6 @@ +# bpython shell and teach pyramid about it. +bpython +pyramid_bpython + +# dev web application server. +waitress diff --git a/requirements-prod.txt b/requirements-prod.txt new file mode 100644 index 0000000..07418e7 --- /dev/null +++ b/requirements-prod.txt @@ -0,0 +1,5 @@ +# prod web application server. +uwsgi + +# prod db is PostgreSQL, psycopg2 is a database driver. +psycopg2 diff --git a/requirements-test.txt b/requirements-test.txt new file mode 100644 index 0000000..142b5b1 --- /dev/null +++ b/requirements-test.txt @@ -0,0 +1,7 @@ +# testing. +pytest +pytest-cov +nose +mock +WebTest +fakeredis diff --git a/requirements.py3.txt b/requirements.py3.txt new file mode 100644 index 0000000..6b751c4 --- /dev/null +++ b/requirements.py3.txt @@ -0,0 +1,55 @@ +# python 2 -> 3 compat. +six + +# Pyramid with SQLAlchemy. +plaster_pastedeploy +pyramid +pyramid_jinja2 +pyramid_debugtoolbar +waitress +pyramid_retry + +# Model. +pyramid_tm +SQLAlchemy +SQLAlchemy-Utils +transaction +zope.sqlalchemy +alembic + +# boto3 is needed for AWS s3 or Digital Ocean Spaces. +boto3 + +# Create URI slugs from titles. +python-slugify + +# Future proof, computationally complex password hashes. +bcrypt + +# credit card storage and processing. +stripe + +# DKIM Signed Email from Python. +dkimpy + +# MarkDown is a popular and familair WYSIWYM markup language. +# python 3. +markdown + +# my miscutils checked out via git as an editable package. +#-e git+git@github.com:russellballestrini/miscutils.git#egg=miscutils +git+https://github.com/russellballestrini/miscutils.git#egg=miscutils + +# human readable timestamps. +ago + +# miniuri: The Universal URI Parser +miniuri + +# my miscutils package can sanitize and make HTML safe. +# that said, it requires the following: +# Bleach sanitizes MarkDown (removes HTML/Javascript) to prevent XSS. +bleach>=2.1.4 +bleach-allowlist +beautifulsoup4 +html5lib diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..cfd96e0 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,59 @@ +# python 2 -> 3 compat. +six + +# Pyramid with SQLAlchemy. +plaster_pastedeploy +pyramid +pyramid_jinja2 +pyramid_debugtoolbar +waitress +pyramid_retry + +# Model. +pyramid_tm +SQLAlchemy==1.3.24 +# pinning because they broke Python 2.7 in version 0.36.7 ... +SQLAlchemy-Utils<=0.36.6 +transaction +zope.sqlalchemy +alembic + +# boto3 is needed for AWS s3 or Digital Ocean Spaces. +boto3 + +# Create URI slugs from titles. +python-slugify + +# Future proof, computationally complex password hashes. +bcrypt + +# credit card storage and processing. +stripe + +# DKIM Signed Email from Python. +dkimpy + +# MarkDown is a popular and familair WYSIWYM markup language. +# python 3. +#markdown +# python 2. +markdown==2.6.11 + +# my miscutils checked out via git as an editable package. +#-e git+git@github.com:russellballestrini/miscutils.git#egg=miscutils +git+https://github.com/russellballestrini/miscutils.git#egg=miscutils + +# human readable timestamps. +ago + +# miniuri is a tiny URI parser. +miniuri + +# my miscutils package can sanitize and make HTML safe. +# that said, it requires the following: +# Bleach sanitizes MarkDown (removes HTML/Javascript) to prevent XSS. +bleach>=2.1.4 +#bleach-whitelist +bleach-allowlist +beautifulsoup4 +html5lib diff --git a/setup.py b/setup.py new file mode 100644 index 0000000..cac6f3a --- /dev/null +++ b/setup.py @@ -0,0 +1,56 @@ +import os + +from setuptools import setup, find_packages + +here = os.path.abspath(os.path.dirname(__file__)) +with open(os.path.join(here, "README.rst")) as f: + README = f.read() + +tests_require = [ + "WebTest >= 1.3.1", # py3 compat + "pytest >= 3.7.4", + "pytest-cov", +] + +setup( + name="make_post_sell", + version="1.0.0", + description="Make Post Sell", + long_description=README, + classifiers=[ + "Programming Language :: Python", + "Framework :: Pyramid", + "Topic :: Internet :: WWW/HTTP", + "Topic :: Internet :: WWW/HTTP :: WSGI :: Application", + ], + author="Russell Ballestrini", + author_email="russell@ballestrini.net", + url="https://www.makepostsell.com", + keywords="make post sell web pyramid pylons ecommerse digital downloads content marketing youtube alternative", + packages=find_packages(exclude="tests"), + package_data={ + "make_post_sell": ["scripts/alembic/*.py", "scripts/alembic/versions/*.py"] + }, + include_package_data=True, + zip_safe=False, + extras_require={ + "testing": tests_require, + }, + entry_points={ + "paste.app_factory": [ + "main = make_post_sell:main", + ], + "console_scripts": [ + "initialize_make_post_sell_db=make_post_sell.scripts.initialize_db:main", + ], + }, + classifiers=[ + "Programming Language :: Python :: 2.7", + "Programming Language :: Python :: 3.5", + "Programming Language :: Python :: 3.6", + "Programming Language :: Python :: 3.7", + "Programming Language :: Python :: 3.8", + "Programming Language :: Python :: 3.9", + ], + +) diff --git a/test.ini b/test.ini new file mode 100644 index 0000000..6d06393 --- /dev/null +++ b/test.ini @@ -0,0 +1,72 @@ +[app:main] +use = egg:make_post_sell + +pyramid.reload_templates = true +pyramid.debug_authorization = false +pyramid.debug_notfound = false +pyramid.debug_routematch = false +pyramid.default_locale_name = en + +sqlalchemy.url = sqlite:///%(here)s/test_make_post_sell.sqlite + +retry.attempts = 3 + +session.hashalg = sha512 +session.secret = test-secret +session.timeout = 31104000 +session.max_age = 31104000 +session.reissue_time = 15552000 +session.samesite = None + +app.root_domain = blanka.foxhop.net + +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 +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 + +[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:6502 + +[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 = DEBUG +handlers = +qualname = sqlalchemy.engine + +[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