commit c211df66a84a9870ed66eebf697ee23df6a60d73 Author: Russell Ballestrini Date: Fri Dec 31 21:39:40 2021 -0500 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 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 0000000..e18461f Binary files /dev/null and b/make_post_sell/static/favicon.ico differ diff --git a/make_post_sell/static/js/custom.js b/make_post_sell/static/js/custom.js new file mode 100644 index 0000000..c225e91 --- /dev/null +++ b/make_post_sell/static/js/custom.js @@ -0,0 +1,30 @@ +// post_preview +// previewTimer must live outside the functions. +var previewTimer = null; + +function previewAjax(textarea, div, show_raw = false){ + // set div to raw textarea while waiting for remote Markdown rendering. + if (show_raw) { + // bust HTML tags like + +{% 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