diff --git a/.dockerignore b/.dockerignore index 4d919194a..34f4b2322 100644 --- a/.dockerignore +++ b/.dockerignore @@ -2,6 +2,9 @@ draft.toml charts/ NOTICE LICENSE -README.md data.db* venv +.venv +__pycache__ +dist +.git diff --git a/.gitignore b/.gitignore index ba7cd059c..0aaef4fa4 100644 --- a/.gitignore +++ b/.gitignore @@ -48,7 +48,6 @@ tests/browser-automated-tests-playwright/e2e/*-snapshots subscribie/static/* subscribie/custom_pages/* playwright-report -tests/browser-automated-tests-playwright .terraform *.pkl emails diff --git a/Dockerfile b/Dockerfile index d15ebd352..a53a4da33 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,17 +1,13 @@ # syntax = docker/dockerfile:experimental -FROM python:3.9-slim-bullseye +FROM python:3.12-slim-bullseye WORKDIR /usr/src/app -RUN pip install --upgrade pip RUN apt-get update && apt-get install -y \ - libffi-dev libcurl4-openssl-dev bash git gcc sqlite3 \ + libffi-dev libcurl4-openssl-dev bash gcc sqlite3 \ build-essential curl -# Rust is required for Building cryptography (TODO turn this into multistage build) -RUN curl --proto '=https' --tlsv1.2 https://sh.rustup.rs > rustup.sh && sh rustup.sh -y COPY . /usr/src/app/subscribie/ WORKDIR /usr/src/app/subscribie/ -RUN --mount=type=cache,target=/root/.cache/pip . $HOME/.cargo/env && pip install -r requirements.txt +RUN --mount=type=cache,target=/root/.cache/pip pip install -r requirements.lock RUN --mount=type=cache,target=/root/.cache/pip pip install uwsgi -RUN export FLASK_APP=subscribie; -EXPOSE 80 +EXPOSE 5000 ENTRYPOINT [ "./entrypoint.sh" ] diff --git a/docs/content/en/docs/Architecture/testing.md b/docs/content/en/docs/Architecture/testing.md index 1c8884279..895f202f1 100644 --- a/docs/content/en/docs/Architecture/testing.md +++ b/docs/content/en/docs/Architecture/testing.md @@ -109,6 +109,7 @@ locally. ``` export PLAYWRIGHT_HEADLESS=false export PLAYWRIGHT_HOST=http://127.0.0.1:5000/ +export SUBSCRIBER_EMAIL_USER=test@example.com ``` #### Run playwright tests: diff --git a/entrypoint.sh b/entrypoint.sh index 62917f50f..27099277e 100755 --- a/entrypoint.sh +++ b/entrypoint.sh @@ -4,32 +4,30 @@ set -euxo pipefail export FLASK_APP=subscribie export FLASK_DEBUG=1 -if [ -a .env ] +if [ -a settings.yaml ] then - echo ".env exists already so not copying from .env.example" + echo "settings.yaml.example exists already so not copying from settings.yaml.example" else - echo ".env not found, so copying from .env.example" - cp .env.example .env + echo "settings.yaml not found, so copying from settings.yaml.example" + cp settings.yaml.example settings.yaml + if [ ! -d "modules" ]; then + echo creating modules directory because it didn\'t exit + mkdir modules + fi + + # Set DB URI & PATH + sed -i 's#SQLALCHEMY_DATABASE_URI.*#SQLALCHEMY_DATABASE_URI: "sqlite:////usr/src/app/data.db"#g' settings.yaml + sed -i 's#DB_FULL_PATH.*#DB_FULL_PATH: "/usr/src/app/data.db"#g' settings.yaml + + # Set static dir + sed -i 's#TEMPLATE_BASE_DIR.*#TEMPLATE_BASE_DIR: "/usr/src/app/subscribie/subscribie/themes/"#g' settings.yaml + sed -i 's#UPLOADED_IMAGES_DEST.*#UPLOADED_IMAGES_DEST: "/usr/src/app/subscribie/subscribie/static/"#g' settings.yaml fi -# Set DB URI & PATH -sed -i 's#SQLALCHEMY_DATABASE_URI.*#SQLALCHEMY_DATABASE_URI="sqlite:////usr/src/app/data.db"#g' .env -sed -i 's#DB_FULL_PATH.*#DB_FULL_PATH=/usr/src/app/data.db#g' .env - -# Set cookie secure flag to false in development -sed -i 's#SESSION_COOKIE_SECURE.*##g' .env -sed -i 's#SESSION_COOKIE_SAMESITE.*#Lax#g' .env - -# Remove SERVER_NAME app config in docker environment -sed -i 's#SERVER_NAME.*##g' .env - -# Set static dir -sed -i 's#TEMPLATE_BASE_DIR.*#TEMPLATE_BASE_DIR=/usr/src/app/subscribie/subscribie/themes/#g' .env -sed -i 's#UPLOADED_IMAGES_DEST.*#UPLOADED_IMAGES_DEST=/usr/src/app/subscribie/subscribie/static/#g' .env flask db upgrade flask initdb -exec uwsgi --http :80 --workers 1 --threads 2 --wsgi-file subscribie.wsgi --touch-chain-reload subscribie.wsgi --chdir /usr/src/app/subscribie/ +exec uwsgi --http :5000 --workers 1 --threads 2 --wsgi-file subscribie.wsgi --touch-chain-reload subscribie.wsgi --chdir /usr/src/app/subscribie/ diff --git a/migrations/env.py b/migrations/env.py index 671b53983..74dca6fa6 100644 --- a/migrations/env.py +++ b/migrations/env.py @@ -78,7 +78,7 @@ def process_revision_directives(context, revision, directives): connection=connection, target_metadata=target_metadata, process_revision_directives=process_revision_directives, - **current_app.extensions["migrate"].configure_args + **current_app.extensions["migrate"].configure_args, ) with context.begin_transaction(): diff --git a/migrations/versions/00477315ded9_add_created_at_column_stripe_invoice_.py b/migrations/versions/00477315ded9_add_created_at_column_stripe_invoice_.py index 8809a0dca..fed5d236e 100644 --- a/migrations/versions/00477315ded9_add_created_at_column_stripe_invoice_.py +++ b/migrations/versions/00477315ded9_add_created_at_column_stripe_invoice_.py @@ -5,6 +5,7 @@ Create Date: 2022-04-07 17:49:50.146114 """ + from alembic import op import sqlalchemy as sa diff --git a/migrations/versions/041d569f7762_add_column_created_to_stripe_invoices_.py b/migrations/versions/041d569f7762_add_column_created_to_stripe_invoices_.py index b08432ade..e1e681c2a 100644 --- a/migrations/versions/041d569f7762_add_column_created_to_stripe_invoices_.py +++ b/migrations/versions/041d569f7762_add_column_created_to_stripe_invoices_.py @@ -5,6 +5,7 @@ Create Date: 2022-04-07 17:33:30.075127 """ + from alembic import op import sqlalchemy as sa diff --git a/migrations/versions/063ddc60bef1_association_table_plan_question.py b/migrations/versions/063ddc60bef1_association_table_plan_question.py new file mode 100644 index 000000000..02c1a949f --- /dev/null +++ b/migrations/versions/063ddc60bef1_association_table_plan_question.py @@ -0,0 +1,36 @@ +"""association_table_plan_question + +Revision ID: 063ddc60bef1 +Revises: c5bec71f1499 +Create Date: 2024-05-09 22:00:42.022150 + +""" +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision = "063ddc60bef1" +down_revision = "c5bec71f1499" +branch_labels = None +depends_on = None + + +def upgrade(): + op.create_table( + "plan_question_associations", + sa.Column("question_id", sa.Integer(), nullable=True), + sa.Column("plan_id", sa.Integer(), nullable=True), + sa.ForeignKeyConstraint( + ["plan_id"], + ["plan.id"], + ), + sa.ForeignKeyConstraint( + ["question_id"], + ["question.id"], + ), + ) + + +def downgrade(): + pass diff --git a/migrations/versions/07cc236f0a6d_remove_stripe_subscription_id_from_.py b/migrations/versions/07cc236f0a6d_remove_stripe_subscription_id_from_.py index 665861b92..b09db0f9e 100644 --- a/migrations/versions/07cc236f0a6d_remove_stripe_subscription_id_from_.py +++ b/migrations/versions/07cc236f0a6d_remove_stripe_subscription_id_from_.py @@ -5,6 +5,7 @@ Create Date: 2020-12-04 14:59:11.346386 """ + from alembic import op diff --git a/migrations/versions/084669093d74_add_stripe_cancel_at_to_subscription_.py b/migrations/versions/084669093d74_add_stripe_cancel_at_to_subscription_.py index d3e1b8515..ee2a7300e 100644 --- a/migrations/versions/084669093d74_add_stripe_cancel_at_to_subscription_.py +++ b/migrations/versions/084669093d74_add_stripe_cancel_at_to_subscription_.py @@ -5,6 +5,7 @@ Create Date: 2021-05-04 22:55:42.753614 """ + from alembic import op import sqlalchemy as sa diff --git a/migrations/versions/1653ed33cbd4_add_subscribie_checkout_session_id_to_.py b/migrations/versions/1653ed33cbd4_add_subscribie_checkout_session_id_to_.py index 38ef6654d..b7b8648ad 100644 --- a/migrations/versions/1653ed33cbd4_add_subscribie_checkout_session_id_to_.py +++ b/migrations/versions/1653ed33cbd4_add_subscribie_checkout_session_id_to_.py @@ -5,6 +5,7 @@ Create Date: 2020-11-11 12:08:45.878277 """ + from alembic import op import sqlalchemy as sa diff --git a/migrations/versions/1d4b6d333c16_.py b/migrations/versions/1d4b6d333c16_.py new file mode 100644 index 000000000..58c53d6d0 --- /dev/null +++ b/migrations/versions/1d4b6d333c16_.py @@ -0,0 +1,22 @@ +"""empty message + +Revision ID: 1d4b6d333c16 +Revises: c7a493cd99d4 +Create Date: 2024-05-18 20:05:40.299194 + +""" + + +# revision identifiers, used by Alembic. +revision = "1d4b6d333c16" +down_revision = "c7a493cd99d4" +branch_labels = None +depends_on = None + + +def upgrade(): + pass + + +def downgrade(): + pass diff --git a/migrations/versions/207556b3039b_add_custom_thank_you_url_column.py b/migrations/versions/207556b3039b_add_custom_thank_you_url_column.py index 1d290748b..b9f17c689 100644 --- a/migrations/versions/207556b3039b_add_custom_thank_you_url_column.py +++ b/migrations/versions/207556b3039b_add_custom_thank_you_url_column.py @@ -5,6 +5,7 @@ Create Date: 2023-09-01 19:35:12.241628 """ + from alembic import op import sqlalchemy as sa diff --git a/migrations/versions/21b64f9d73dd_add_trial_period_days_to_plan_model.py b/migrations/versions/21b64f9d73dd_add_trial_period_days_to_plan_model.py index 6e1c50b0f..407d1b5e6 100644 --- a/migrations/versions/21b64f9d73dd_add_trial_period_days_to_plan_model.py +++ b/migrations/versions/21b64f9d73dd_add_trial_period_days_to_plan_model.py @@ -5,6 +5,7 @@ Create Date: 2021-03-24 22:54:05.568960 """ + from alembic import op import sqlalchemy as sa diff --git a/migrations/versions/2378e3286d5b_correct_store_cancel_at_at_int.py b/migrations/versions/2378e3286d5b_correct_store_cancel_at_at_int.py index 4e10e81b5..08d799d98 100644 --- a/migrations/versions/2378e3286d5b_correct_store_cancel_at_at_int.py +++ b/migrations/versions/2378e3286d5b_correct_store_cancel_at_at_int.py @@ -5,6 +5,7 @@ Create Date: 2021-05-09 21:28:01.250318 """ + from alembic import op import sqlalchemy as sa diff --git a/migrations/versions/262c26af9630_add_has_min_sell_price_has_min_interval_.py b/migrations/versions/262c26af9630_add_has_min_sell_price_has_min_interval_.py index ba381f630..4a14de019 100644 --- a/migrations/versions/262c26af9630_add_has_min_sell_price_has_min_interval_.py +++ b/migrations/versions/262c26af9630_add_has_min_sell_price_has_min_interval_.py @@ -5,6 +5,7 @@ Create Date: 2022-12-23 14:50:18.143177 """ + from alembic import op import sqlalchemy as sa diff --git a/migrations/versions/2c7e021d9a69_remove_stripe_publishable_key_stripe_.py b/migrations/versions/2c7e021d9a69_remove_stripe_publishable_key_stripe_.py index 52b27fbbc..686e6dac9 100644 --- a/migrations/versions/2c7e021d9a69_remove_stripe_publishable_key_stripe_.py +++ b/migrations/versions/2c7e021d9a69_remove_stripe_publishable_key_stripe_.py @@ -5,6 +5,7 @@ Create Date: 2020-11-02 11:21:21.027943 """ + from alembic import op diff --git a/migrations/versions/2f3f0b5d2bde_add_archived_to_person_class.py b/migrations/versions/2f3f0b5d2bde_add_archived_to_person_class.py index 6d4a0821d..2a666c880 100644 --- a/migrations/versions/2f3f0b5d2bde_add_archived_to_person_class.py +++ b/migrations/versions/2f3f0b5d2bde_add_archived_to_person_class.py @@ -5,6 +5,7 @@ Create Date: 2021-02-28 23:13:51.486171 """ + from alembic import op import sqlalchemy as sa diff --git a/migrations/versions/3a54f4b1187d_add_order_to_association_table_plan_.py b/migrations/versions/3a54f4b1187d_add_order_to_association_table_plan_.py new file mode 100644 index 000000000..a69ecd12a --- /dev/null +++ b/migrations/versions/3a54f4b1187d_add_order_to_association_table_plan_.py @@ -0,0 +1,25 @@ +"""add order to association_table_plan_question + +Revision ID: 3a54f4b1187d +Revises: 1d4b6d333c16 +Create Date: 2024-05-19 18:13:11.397272 + +""" +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision = "3a54f4b1187d" +down_revision = "1d4b6d333c16" +branch_labels = None +depends_on = None + + +def upgrade(): + with op.batch_alter_table("plan_question_associations", schema=None) as batch_op: + batch_op.add_column(sa.Column("order", sa.Integer(), nullable=True)) + + +def downgrade(): + pass diff --git a/migrations/versions/3a8f3089d09d_create_upcoming_invoice_table.py b/migrations/versions/3a8f3089d09d_create_upcoming_invoice_table.py index 2b843ac60..af2021c9d 100644 --- a/migrations/versions/3a8f3089d09d_create_upcoming_invoice_table.py +++ b/migrations/versions/3a8f3089d09d_create_upcoming_invoice_table.py @@ -5,6 +5,7 @@ Create Date: 2021-05-31 21:24:34.681376 """ + from alembic import op import sqlalchemy as sa diff --git a/migrations/versions/3abcbb0428ef_add_stripe_connect_account_id_to_.py b/migrations/versions/3abcbb0428ef_add_stripe_connect_account_id_to_.py index 9ce3a7df1..18423dba3 100644 --- a/migrations/versions/3abcbb0428ef_add_stripe_connect_account_id_to_.py +++ b/migrations/versions/3abcbb0428ef_add_stripe_connect_account_id_to_.py @@ -5,6 +5,7 @@ Create Date: 2020-09-23 17:17:30.127947 """ + from alembic import op import sqlalchemy as sa diff --git a/migrations/versions/429b32b9ad20_add_plan_documents_association.py b/migrations/versions/429b32b9ad20_add_plan_documents_association.py index 10534a7e9..0b2e4bbe5 100644 --- a/migrations/versions/429b32b9ad20_add_plan_documents_association.py +++ b/migrations/versions/429b32b9ad20_add_plan_documents_association.py @@ -5,6 +5,7 @@ Create Date: 2022-11-17 01:07:18.115335 """ + from alembic import op import sqlalchemy as sa diff --git a/migrations/versions/48074e6225c6_add_subscription_stripe_ended_at.py b/migrations/versions/48074e6225c6_add_subscription_stripe_ended_at.py index 37868d5ca..5e99d862a 100644 --- a/migrations/versions/48074e6225c6_add_subscription_stripe_ended_at.py +++ b/migrations/versions/48074e6225c6_add_subscription_stripe_ended_at.py @@ -5,6 +5,7 @@ Create Date: 2024-02-11 17:21:19.287478 """ + from alembic import op import sqlalchemy as sa diff --git a/migrations/versions/4e7e3ee8972d_add_pricelistrule_model.py b/migrations/versions/4e7e3ee8972d_add_pricelistrule_model.py index 42ef13d38..cd088c733 100644 --- a/migrations/versions/4e7e3ee8972d_add_pricelistrule_model.py +++ b/migrations/versions/4e7e3ee8972d_add_pricelistrule_model.py @@ -5,6 +5,7 @@ Create Date: 2022-06-07 22:47:55.495926 """ + from alembic import op import sqlalchemy as sa diff --git a/migrations/versions/500f2d55c5d3_add_logintoken_model.py b/migrations/versions/500f2d55c5d3_add_logintoken_model.py index 32cd80fd7..363a974d2 100644 --- a/migrations/versions/500f2d55c5d3_add_logintoken_model.py +++ b/migrations/versions/500f2d55c5d3_add_logintoken_model.py @@ -5,6 +5,7 @@ Create Date: 2021-02-13 00:04:37.827539 """ + from alembic import op import sqlalchemy as sa diff --git a/migrations/versions/53840eddbb0f_add_taxrate_model.py b/migrations/versions/53840eddbb0f_add_taxrate_model.py index e7e8681a5..fd258e039 100644 --- a/migrations/versions/53840eddbb0f_add_taxrate_model.py +++ b/migrations/versions/53840eddbb0f_add_taxrate_model.py @@ -5,6 +5,7 @@ Create Date: 2021-03-06 17:26:15.092902 """ + from alembic import op import sqlalchemy as sa diff --git a/migrations/versions/54495b8316a1_add_currency_to_transactions_table.py b/migrations/versions/54495b8316a1_add_currency_to_transactions_table.py index 766f8f7c1..01b850482 100644 --- a/migrations/versions/54495b8316a1_add_currency_to_transactions_table.py +++ b/migrations/versions/54495b8316a1_add_currency_to_transactions_table.py @@ -5,6 +5,7 @@ Create Date: 2022-05-30 01:22:09.158426 """ + from alembic import op import sqlalchemy as sa diff --git a/migrations/versions/56e852d799d1_add_stripe_webhook_test_columns.py b/migrations/versions/56e852d799d1_add_stripe_webhook_test_columns.py index 0dda1c24c..d9e26387f 100644 --- a/migrations/versions/56e852d799d1_add_stripe_webhook_test_columns.py +++ b/migrations/versions/56e852d799d1_add_stripe_webhook_test_columns.py @@ -5,6 +5,7 @@ Create Date: 2020-11-02 11:54:29.263880 """ + from alembic import op import sqlalchemy as sa diff --git a/migrations/versions/57b068821280_add_primary_plan_question_associations.py b/migrations/versions/57b068821280_add_primary_plan_question_associations.py new file mode 100644 index 000000000..ed6886d90 --- /dev/null +++ b/migrations/versions/57b068821280_add_primary_plan_question_associations.py @@ -0,0 +1,26 @@ +"""add primary plan_question_associations + +Revision ID: 57b068821280 +Revises: 3a54f4b1187d +Create Date: 2024-05-19 19:58:41.527688 + +""" +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision = "57b068821280" +down_revision = "3a54f4b1187d" +branch_labels = None +depends_on = None + + +def upgrade(): + with op.batch_alter_table("plan_question_associations", schema=None) as batch_op: + batch_op.alter_column("question_id", existing_type=sa.INTEGER(), nullable=False) + batch_op.alter_column("plan_id", existing_type=sa.INTEGER(), nullable=False) + + +def downgrade(): + pass diff --git a/migrations/versions/5b308deca3d3_add_interval_unit_interval_amount_to_.py b/migrations/versions/5b308deca3d3_add_interval_unit_interval_amount_to_.py index 2b90d0d5e..3ea5ff1cc 100644 --- a/migrations/versions/5b308deca3d3_add_interval_unit_interval_amount_to_.py +++ b/migrations/versions/5b308deca3d3_add_interval_unit_interval_amount_to_.py @@ -5,6 +5,7 @@ Create Date: 2022-07-25 22:27:45.930134 """ + from alembic import op import sqlalchemy as sa diff --git a/migrations/versions/5d8da4e0a709_add_default_currency_to_setting.py b/migrations/versions/5d8da4e0a709_add_default_currency_to_setting.py index 0563166e2..ca24de048 100644 --- a/migrations/versions/5d8da4e0a709_add_default_currency_to_setting.py +++ b/migrations/versions/5d8da4e0a709_add_default_currency_to_setting.py @@ -5,6 +5,7 @@ Create Date: 2022-01-09 23:51:16.207317 """ + from alembic import op import sqlalchemy as sa diff --git a/migrations/versions/5e756a48f86d_add_stripeinvoice_model.py b/migrations/versions/5e756a48f86d_add_stripeinvoice_model.py index 739f3d83e..5c613492d 100644 --- a/migrations/versions/5e756a48f86d_add_stripeinvoice_model.py +++ b/migrations/versions/5e756a48f86d_add_stripeinvoice_model.py @@ -5,6 +5,7 @@ Create Date: 2022-02-26 23:05:31.976561 """ + from alembic import op import sqlalchemy as sa diff --git a/migrations/versions/6738e7241978_add_requires_discount_code_to_.py b/migrations/versions/6738e7241978_add_requires_discount_code_to_.py index e772940aa..aa623db18 100644 --- a/migrations/versions/6738e7241978_add_requires_discount_code_to_.py +++ b/migrations/versions/6738e7241978_add_requires_discount_code_to_.py @@ -5,6 +5,7 @@ Create Date: 2022-06-29 19:23:53.498096 """ + from alembic import op import sqlalchemy as sa diff --git a/migrations/versions/686d588c3c29_remove_stripe_test_webhooksecret_and_id.py b/migrations/versions/686d588c3c29_remove_stripe_test_webhooksecret_and_id.py index caab12616..dba1ed1e6 100644 --- a/migrations/versions/686d588c3c29_remove_stripe_test_webhooksecret_and_id.py +++ b/migrations/versions/686d588c3c29_remove_stripe_test_webhooksecret_and_id.py @@ -5,6 +5,7 @@ Create Date: 2020-11-02 15:22:00.449292 """ + from alembic import op diff --git a/migrations/versions/6ae2db9a982b_add_pricelist_model.py b/migrations/versions/6ae2db9a982b_add_pricelist_model.py index f16536370..cf97bd11f 100644 --- a/migrations/versions/6ae2db9a982b_add_pricelist_model.py +++ b/migrations/versions/6ae2db9a982b_add_pricelist_model.py @@ -5,6 +5,7 @@ Create Date: 2022-06-07 22:00:26.081464 """ + from alembic import op import sqlalchemy as sa diff --git a/migrations/versions/6cc0e87e8836_add_bg_primary_to_modulestyle_model.py b/migrations/versions/6cc0e87e8836_add_bg_primary_to_modulestyle_model.py index 0a134c31b..e3d4ffa79 100644 --- a/migrations/versions/6cc0e87e8836_add_bg_primary_to_modulestyle_model.py +++ b/migrations/versions/6cc0e87e8836_add_bg_primary_to_modulestyle_model.py @@ -5,6 +5,7 @@ Create Date: 2021-01-10 16:53:43.057712 """ + from alembic import op import sqlalchemy as sa diff --git a/migrations/versions/6d9febd1346e_add_currency_to_subscription_table.py b/migrations/versions/6d9febd1346e_add_currency_to_subscription_table.py index fe213b8ad..a530eb191 100644 --- a/migrations/versions/6d9febd1346e_add_currency_to_subscription_table.py +++ b/migrations/versions/6d9febd1346e_add_currency_to_subscription_table.py @@ -5,6 +5,7 @@ Create Date: 2022-07-25 22:01:11.062099 """ + from alembic import op import sqlalchemy as sa diff --git a/migrations/versions/702d6ee9b14f_compact_all_migrations.py b/migrations/versions/702d6ee9b14f_compact_all_migrations.py index 4bdbce12f..314bdb6c1 100644 --- a/migrations/versions/702d6ee9b14f_compact_all_migrations.py +++ b/migrations/versions/702d6ee9b14f_compact_all_migrations.py @@ -5,6 +5,7 @@ Create Date: 2020-09-21 21:11:08.854792 """ + from alembic import op import sqlalchemy as sa diff --git a/migrations/versions/746b4765a957_so_that_categories_may_be_ordered.py b/migrations/versions/746b4765a957_so_that_categories_may_be_ordered.py index a4b3b0401..28184f1da 100644 --- a/migrations/versions/746b4765a957_so_that_categories_may_be_ordered.py +++ b/migrations/versions/746b4765a957_so_that_categories_may_be_ordered.py @@ -5,6 +5,7 @@ Create Date: 2021-03-21 15:21:16.472897 """ + from alembic import op import sqlalchemy as sa diff --git a/migrations/versions/75a87d5ab587_add_back_stripe_subscription_id_to_.py b/migrations/versions/75a87d5ab587_add_back_stripe_subscription_id_to_.py index 7f966ff57..30ac38cba 100644 --- a/migrations/versions/75a87d5ab587_add_back_stripe_subscription_id_to_.py +++ b/migrations/versions/75a87d5ab587_add_back_stripe_subscription_id_to_.py @@ -5,6 +5,7 @@ Create Date: 2020-12-04 18:07:10.195939 """ + from alembic import op import sqlalchemy as sa diff --git a/migrations/versions/75dffc3851d8_addassociation_table_price_list_to_rule.py b/migrations/versions/75dffc3851d8_addassociation_table_price_list_to_rule.py index b74b1755c..773a9868a 100644 --- a/migrations/versions/75dffc3851d8_addassociation_table_price_list_to_rule.py +++ b/migrations/versions/75dffc3851d8_addassociation_table_price_list_to_rule.py @@ -5,6 +5,7 @@ Create Date: 2022-06-19 17:47:37.281829 """ + from alembic import op import sqlalchemy as sa diff --git a/migrations/versions/790aae5a7013_add_file_model.py b/migrations/versions/790aae5a7013_add_file_model.py index 63b1b3674..66be089bd 100644 --- a/migrations/versions/790aae5a7013_add_file_model.py +++ b/migrations/versions/790aae5a7013_add_file_model.py @@ -5,6 +5,7 @@ Create Date: 2020-09-22 14:45:14.686269 """ + from alembic import op import sqlalchemy as sa diff --git a/migrations/versions/796ff2e47e13_add_charge_vat_to_setting_model.py b/migrations/versions/796ff2e47e13_add_charge_vat_to_setting_model.py index 6f7093c02..62dda3424 100644 --- a/migrations/versions/796ff2e47e13_add_charge_vat_to_setting_model.py +++ b/migrations/versions/796ff2e47e13_add_charge_vat_to_setting_model.py @@ -5,6 +5,7 @@ Create Date: 2021-03-06 17:54:23.800916 """ + from alembic import op import sqlalchemy as sa diff --git a/migrations/versions/7d502ba7279c_add_stripe_subscription_id_to_.py b/migrations/versions/7d502ba7279c_add_stripe_subscription_id_to_.py index 532a7d6e8..f9ec4b11d 100644 --- a/migrations/versions/7d502ba7279c_add_stripe_subscription_id_to_.py +++ b/migrations/versions/7d502ba7279c_add_stripe_subscription_id_to_.py @@ -5,6 +5,7 @@ Create Date: 2020-11-13 12:02:40.496271 """ + from alembic import op import sqlalchemy as sa diff --git a/migrations/versions/7fba2877d034_add_api_key_secret_live_api_key_secret_.py b/migrations/versions/7fba2877d034_add_api_key_secret_live_api_key_secret_.py index 1e9d47f76..9776ff961 100644 --- a/migrations/versions/7fba2877d034_add_api_key_secret_live_api_key_secret_.py +++ b/migrations/versions/7fba2877d034_add_api_key_secret_live_api_key_secret_.py @@ -5,6 +5,7 @@ Create Date: 2022-03-08 01:43:18.744690 """ + from alembic import op import sqlalchemy as sa diff --git a/migrations/versions/89b4e5e02eac_add_balance_model.py b/migrations/versions/89b4e5e02eac_add_balance_model.py index cb4564221..2be60e708 100644 --- a/migrations/versions/89b4e5e02eac_add_balance_model.py +++ b/migrations/versions/89b4e5e02eac_add_balance_model.py @@ -5,6 +5,7 @@ Create Date: 2022-02-22 22:14:55.030280 """ + from alembic import op import sqlalchemy as sa diff --git a/migrations/versions/8d9ada7a21cd_add_description_to_plan_model.py b/migrations/versions/8d9ada7a21cd_add_description_to_plan_model.py index 84ea62a2a..1994f66a0 100644 --- a/migrations/versions/8d9ada7a21cd_add_description_to_plan_model.py +++ b/migrations/versions/8d9ada7a21cd_add_description_to_plan_model.py @@ -5,6 +5,7 @@ Create Date: 2021-01-02 21:00:51.067229 """ + from alembic import op import sqlalchemy as sa diff --git a/migrations/versions/9189f7033477_add_private_boolean_to_pages_model.py b/migrations/versions/9189f7033477_add_private_boolean_to_pages_model.py index 8bf744380..49ea3ba81 100644 --- a/migrations/versions/9189f7033477_add_private_boolean_to_pages_model.py +++ b/migrations/versions/9189f7033477_add_private_boolean_to_pages_model.py @@ -5,6 +5,7 @@ Create Date: 2020-11-19 11:40:21.304451 """ + from alembic import op import sqlalchemy as sa diff --git a/migrations/versions/938b171f97ec_subscription_to_document_association.py b/migrations/versions/938b171f97ec_subscription_to_document_association.py index 3383477a9..f2012a4f4 100644 --- a/migrations/versions/938b171f97ec_subscription_to_document_association.py +++ b/migrations/versions/938b171f97ec_subscription_to_document_association.py @@ -5,6 +5,7 @@ Create Date: 2022-11-19 22:56:16.153807 """ + from alembic import op import sqlalchemy as sa diff --git a/migrations/versions/94790e701430_adding_is_donation_in_transaction_table.py b/migrations/versions/94790e701430_adding_is_donation_in_transaction_table.py index f57aa71c1..1b55d76e2 100644 --- a/migrations/versions/94790e701430_adding_is_donation_in_transaction_table.py +++ b/migrations/versions/94790e701430_adding_is_donation_in_transaction_table.py @@ -5,6 +5,7 @@ Create Date: 2023-03-02 19:45:02.205558 """ + from alembic import op import sqlalchemy as sa diff --git a/migrations/versions/96430096c2c7_added_stripe_pause_collection_to_.py b/migrations/versions/96430096c2c7_added_stripe_pause_collection_to_.py index 6c0fa0e53..763f62c6d 100644 --- a/migrations/versions/96430096c2c7_added_stripe_pause_collection_to_.py +++ b/migrations/versions/96430096c2c7_added_stripe_pause_collection_to_.py @@ -5,6 +5,7 @@ Create Date: 2021-07-12 20:12:29.813558 """ + from alembic import op import sqlalchemy as sa diff --git a/migrations/versions/b3c13acc1013_store_cancel_at_as_int.py b/migrations/versions/b3c13acc1013_store_cancel_at_as_int.py index 26d587d8b..aed472517 100644 --- a/migrations/versions/b3c13acc1013_store_cancel_at_as_int.py +++ b/migrations/versions/b3c13acc1013_store_cancel_at_as_int.py @@ -5,6 +5,7 @@ Create Date: 2021-05-09 14:12:15.268759 """ + from alembic import op import sqlalchemy as sa diff --git a/migrations/versions/b3f47a3f53e2_add_hascreatedat_to_all_models.py b/migrations/versions/b3f47a3f53e2_add_hascreatedat_to_all_models.py index dbfb03734..7f7186160 100644 --- a/migrations/versions/b3f47a3f53e2_add_hascreatedat_to_all_models.py +++ b/migrations/versions/b3f47a3f53e2_add_hascreatedat_to_all_models.py @@ -5,6 +5,7 @@ Create Date: 2021-06-05 14:52:15.700299 """ + from alembic import op import sqlalchemy as sa diff --git a/migrations/versions/b767faeb4c0d_add_private_to_plan_model.py b/migrations/versions/b767faeb4c0d_add_private_to_plan_model.py index 3c76870d8..3389c341b 100644 --- a/migrations/versions/b767faeb4c0d_add_private_to_plan_model.py +++ b/migrations/versions/b767faeb4c0d_add_private_to_plan_model.py @@ -5,6 +5,7 @@ Create Date: 2021-03-27 00:26:17.579713 """ + from alembic import op import sqlalchemy as sa diff --git a/migrations/versions/b8e926d239f9_create_category_table.py b/migrations/versions/b8e926d239f9_create_category_table.py index 39dff2fb5..17c179180 100644 --- a/migrations/versions/b8e926d239f9_create_category_table.py +++ b/migrations/versions/b8e926d239f9_create_category_table.py @@ -5,6 +5,7 @@ Create Date: 2021-03-18 20:28:24.894968 """ + from alembic import op import sqlalchemy as sa diff --git a/migrations/versions/bb76d2149316_add_parent_plan_revision_uuid_to_plan.py b/migrations/versions/bb76d2149316_add_parent_plan_revision_uuid_to_plan.py index 7b6130e50..4e8c499cf 100644 --- a/migrations/versions/bb76d2149316_add_parent_plan_revision_uuid_to_plan.py +++ b/migrations/versions/bb76d2149316_add_parent_plan_revision_uuid_to_plan.py @@ -5,6 +5,7 @@ Create Date: 2024-02-16 23:22:02.230866 """ + from alembic import op import sqlalchemy as sa diff --git a/migrations/versions/bd63fd27d653_add_donations_columns_in_settings.py b/migrations/versions/bd63fd27d653_add_donations_columns_in_settings.py index a214f3eee..9c46b1012 100644 --- a/migrations/versions/bd63fd27d653_add_donations_columns_in_settings.py +++ b/migrations/versions/bd63fd27d653_add_donations_columns_in_settings.py @@ -5,6 +5,7 @@ Create Date: 2023-02-15 13:54:54.675597 """ + from alembic import op import sqlalchemy as sa diff --git a/migrations/versions/c39dd6d1961f_add_cancel_at_to_plan_model.py b/migrations/versions/c39dd6d1961f_add_cancel_at_to_plan_model.py index 1c2f730f0..540814e29 100644 --- a/migrations/versions/c39dd6d1961f_add_cancel_at_to_plan_model.py +++ b/migrations/versions/c39dd6d1961f_add_cancel_at_to_plan_model.py @@ -5,6 +5,7 @@ Create Date: 2021-04-25 15:29:11.034541 """ + from alembic import op import sqlalchemy as sa diff --git a/migrations/versions/c48dccfb0df5_add_password_expired_to_user_and_.py b/migrations/versions/c48dccfb0df5_add_password_expired_to_user_and_.py index 1e648c7cd..16a0d9848 100644 --- a/migrations/versions/c48dccfb0df5_add_password_expired_to_user_and_.py +++ b/migrations/versions/c48dccfb0df5_add_password_expired_to_user_and_.py @@ -5,6 +5,7 @@ Create Date: 2021-05-22 14:07:47.465185 """ + from alembic import op import sqlalchemy as sa diff --git a/migrations/versions/c5bec71f1499_add_question_table.py b/migrations/versions/c5bec71f1499_add_question_table.py new file mode 100644 index 000000000..07e1d6c62 --- /dev/null +++ b/migrations/versions/c5bec71f1499_add_question_table.py @@ -0,0 +1,31 @@ +"""add question table + +Revision ID: c5bec71f1499 +Revises: bb76d2149316 +Create Date: 2024-05-08 21:30:41.805887 + +""" +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision = "c5bec71f1499" +down_revision = "bb76d2149316" +branch_labels = None +depends_on = None + + +def upgrade(): + op.create_table( + "question", + sa.Column("id", sa.Integer(), nullable=False), + sa.Column("uuid", sa.String(), nullable=False), + sa.Column("created_at", sa.DateTime(), nullable=False), + sa.Column("title", sa.String(), nullable=False), + sa.PrimaryKeyConstraint("id"), + ) + + +def downgrade(): + pass diff --git a/migrations/versions/c5d444ee3ccd_rename_stripe_webhook_and_account_cols_.py b/migrations/versions/c5d444ee3ccd_rename_stripe_webhook_and_account_cols_.py index c26ce54cc..092081d75 100644 --- a/migrations/versions/c5d444ee3ccd_rename_stripe_webhook_and_account_cols_.py +++ b/migrations/versions/c5d444ee3ccd_rename_stripe_webhook_and_account_cols_.py @@ -5,6 +5,7 @@ Create Date: 2020-11-02 11:31:22.184012 """ + from alembic import op # revision identifiers, used by Alembic. diff --git a/migrations/versions/c70cd4900c96_add_answer_model.py b/migrations/versions/c70cd4900c96_add_answer_model.py new file mode 100644 index 000000000..78e9bbf5d --- /dev/null +++ b/migrations/versions/c70cd4900c96_add_answer_model.py @@ -0,0 +1,37 @@ +"""add answer model + +Revision ID: c70cd4900c96 +Revises: 063ddc60bef1 +Create Date: 2024-05-14 21:18:13.745275 + +""" +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision = "c70cd4900c96" +down_revision = "063ddc60bef1" +branch_labels = None +depends_on = None + + +def upgrade(): + op.create_table( + "answer", + sa.Column("id", sa.Integer(), nullable=False), + sa.Column("created_at", sa.DateTime(), nullable=True), + sa.Column("question_id", sa.Integer(), nullable=True), + sa.Column("question_title", sa.String(), nullable=True), + sa.Column("response", sa.String(), nullable=True), + sa.Column("subscription_id", sa.Integer(), nullable=True), + sa.ForeignKeyConstraint( + ["subscription_id"], + ["subscription.id"], + ), + sa.PrimaryKeyConstraint("id"), + ) + + +def downgrade(): + pass diff --git a/migrations/versions/c751fe53a042_add_external_refund_id_to_transaction_.py b/migrations/versions/c751fe53a042_add_external_refund_id_to_transaction_.py index 8a54f630a..7f02bb678 100644 --- a/migrations/versions/c751fe53a042_add_external_refund_id_to_transaction_.py +++ b/migrations/versions/c751fe53a042_add_external_refund_id_to_transaction_.py @@ -5,6 +5,7 @@ Create Date: 2021-04-08 15:19:14.355699 """ + from alembic import op import sqlalchemy as sa diff --git a/migrations/versions/c7a493cd99d4_create_questionoption_model.py b/migrations/versions/c7a493cd99d4_create_questionoption_model.py new file mode 100644 index 000000000..b192fb6b7 --- /dev/null +++ b/migrations/versions/c7a493cd99d4_create_questionoption_model.py @@ -0,0 +1,42 @@ +"""create QuestionOption model + +Revision ID: c7a493cd99d4 +Revises: c70cd4900c96 +Create Date: 2024-05-18 20:02:36.104426 + +""" +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision = "c7a493cd99d4" +down_revision = "c70cd4900c96" +branch_labels = None +depends_on = None + + +def upgrade(): + op.create_table( + "question_option", + sa.Column("id", sa.Integer(), nullable=False), + sa.Column("question_id", sa.Integer(), nullable=True), + sa.Column("created_at", sa.DateTime(), nullable=True), + sa.Column("title", sa.String(), nullable=True), + sa.Column("description", sa.Text(), nullable=True), + sa.Column("primary_icon", sa.String(), nullable=True), + sa.ForeignKeyConstraint( + ["question_id"], + ["question.id"], + ), + sa.PrimaryKeyConstraint("id"), + ) + + with op.batch_alter_table("question", schema=None) as batch_op: + batch_op.alter_column("uuid", existing_type=sa.VARCHAR(), nullable=True) + batch_op.alter_column("created_at", existing_type=sa.DATETIME(), nullable=True) + batch_op.alter_column("title", existing_type=sa.VARCHAR(), nullable=True) + + +def downgrade(): + pass diff --git a/migrations/versions/d04243b7bd47_add_custom_code_to_setting_model.py b/migrations/versions/d04243b7bd47_add_custom_code_to_setting_model.py index 8b07c7714..343937723 100644 --- a/migrations/versions/d04243b7bd47_add_custom_code_to_setting_model.py +++ b/migrations/versions/d04243b7bd47_add_custom_code_to_setting_model.py @@ -5,6 +5,7 @@ Create Date: 2021-03-24 18:16:54.944191 """ + from alembic import op import sqlalchemy as sa diff --git a/migrations/versions/d7b1aaee84ab_add_stripe_status_to_subscription_model.py b/migrations/versions/d7b1aaee84ab_add_stripe_status_to_subscription_model.py index b77d50a64..f00a9e58e 100644 --- a/migrations/versions/d7b1aaee84ab_add_stripe_status_to_subscription_model.py +++ b/migrations/versions/d7b1aaee84ab_add_stripe_status_to_subscription_model.py @@ -5,6 +5,7 @@ Create Date: 2021-05-01 23:05:25.344813 """ + from alembic import op import sqlalchemy as sa diff --git a/migrations/versions/da154873f3ab_add_created_at_uuid_to_.py b/migrations/versions/da154873f3ab_add_created_at_uuid_to_.py new file mode 100644 index 000000000..6c3a96979 --- /dev/null +++ b/migrations/versions/da154873f3ab_add_created_at_uuid_to_.py @@ -0,0 +1,26 @@ +"""add created_at uuid to PlanQuestionAssociation + +Revision ID: da154873f3ab +Revises: 57b068821280 +Create Date: 2024-05-19 21:59:10.555714 + +""" +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision = "da154873f3ab" +down_revision = "57b068821280" +branch_labels = None +depends_on = None + + +def upgrade(): + with op.batch_alter_table("plan_question_associations", schema=None) as batch_op: + batch_op.add_column(sa.Column("created_at", sa.DateTime(), nullable=True)) + batch_op.add_column(sa.Column("uuid", sa.String(), nullable=True)) + + +def downgrade(): + pass diff --git a/migrations/versions/dde6bed9a56f_add_shop_activated_to_setting_model.py b/migrations/versions/dde6bed9a56f_add_shop_activated_to_setting_model.py index 1dda98b44..aaf6f8b9a 100644 --- a/migrations/versions/dde6bed9a56f_add_shop_activated_to_setting_model.py +++ b/migrations/versions/dde6bed9a56f_add_shop_activated_to_setting_model.py @@ -5,6 +5,7 @@ Create Date: 2022-01-25 10:04:41.225725 """ + from alembic import op import sqlalchemy as sa diff --git a/migrations/versions/e0919a39645f_add_stripe_livemode_column_to_taxrate_.py b/migrations/versions/e0919a39645f_add_stripe_livemode_column_to_taxrate_.py index 0c933b223..ed69eb7c0 100644 --- a/migrations/versions/e0919a39645f_add_stripe_livemode_column_to_taxrate_.py +++ b/migrations/versions/e0919a39645f_add_stripe_livemode_column_to_taxrate_.py @@ -5,6 +5,7 @@ Create Date: 2021-03-06 18:23:31.252540 """ + from alembic import op import sqlalchemy as sa diff --git a/migrations/versions/e0a8901cde76_add_default_country_code_to_settings_.py b/migrations/versions/e0a8901cde76_add_default_country_code_to_settings_.py index 9ad95c999..d06ff35c6 100644 --- a/migrations/versions/e0a8901cde76_add_default_country_code_to_settings_.py +++ b/migrations/versions/e0a8901cde76_add_default_country_code_to_settings_.py @@ -5,6 +5,7 @@ Create Date: 2022-10-06 00:04:42.866952 """ + from alembic import op import sqlalchemy as sa diff --git a/migrations/versions/e9e1f148655e_add_association_table_plan_to_price_.py b/migrations/versions/e9e1f148655e_add_association_table_plan_to_price_.py index 613e469cf..2d537f688 100644 --- a/migrations/versions/e9e1f148655e_add_association_table_plan_to_price_.py +++ b/migrations/versions/e9e1f148655e_add_association_table_plan_to_price_.py @@ -5,6 +5,7 @@ Create Date: 2022-06-19 22:31:08.394131 """ + from alembic import op import sqlalchemy as sa diff --git a/migrations/versions/ee8e62e05b40_add_back_stripe_test_mode_colums.py b/migrations/versions/ee8e62e05b40_add_back_stripe_test_mode_colums.py index c281a03e8..aa548fa14 100644 --- a/migrations/versions/ee8e62e05b40_add_back_stripe_test_mode_colums.py +++ b/migrations/versions/ee8e62e05b40_add_back_stripe_test_mode_colums.py @@ -5,6 +5,7 @@ Create Date: 2020-11-02 16:38:32.337427 """ + from alembic import op import sqlalchemy as sa diff --git a/migrations/versions/f064a641532c_set_stripe_active_default_false_and_add_.py b/migrations/versions/f064a641532c_set_stripe_active_default_false_and_add_.py index ece13b146..ecceb613b 100644 --- a/migrations/versions/f064a641532c_set_stripe_active_default_false_and_add_.py +++ b/migrations/versions/f064a641532c_set_stripe_active_default_false_and_add_.py @@ -5,6 +5,7 @@ Create Date: 2022-09-13 12:18:31.520291 """ + from alembic import op import sqlalchemy as sa diff --git a/migrations/versions/f0e91df9fbf1_add_stripe_livemode_to_payment_provider.py b/migrations/versions/f0e91df9fbf1_add_stripe_livemode_to_payment_provider.py index f1713f60b..c9a9b1282 100644 --- a/migrations/versions/f0e91df9fbf1_add_stripe_livemode_to_payment_provider.py +++ b/migrations/versions/f0e91df9fbf1_add_stripe_livemode_to_payment_provider.py @@ -5,6 +5,7 @@ Create Date: 2020-10-01 16:30:38.155152 """ + from alembic import op import sqlalchemy as sa diff --git a/migrations/versions/f3579efd3331_add_stripe_external_id_to_subscription_.py b/migrations/versions/f3579efd3331_add_stripe_external_id_to_subscription_.py index e7d5b5aff..9458d482b 100644 --- a/migrations/versions/f3579efd3331_add_stripe_external_id_to_subscription_.py +++ b/migrations/versions/f3579efd3331_add_stripe_external_id_to_subscription_.py @@ -5,6 +5,7 @@ Create Date: 2020-12-04 14:51:09.699273 """ + from alembic import op import sqlalchemy as sa diff --git a/migrations/versions/fbc611c57b7a_add_hasreadonly_read_only_mixin_to_.py b/migrations/versions/fbc611c57b7a_add_hasreadonly_read_only_mixin_to_.py index 9f6b83058..5fba8a258 100644 --- a/migrations/versions/fbc611c57b7a_add_hasreadonly_read_only_mixin_to_.py +++ b/migrations/versions/fbc611c57b7a_add_hasreadonly_read_only_mixin_to_.py @@ -5,6 +5,7 @@ Create Date: 2023-05-11 22:44:20.776241 """ + from alembic import op import sqlalchemy as sa diff --git a/migrations/versions/fc7ac6f06521_add_document_table.py b/migrations/versions/fc7ac6f06521_add_document_table.py index ed89b56cf..4befa7b79 100644 --- a/migrations/versions/fc7ac6f06521_add_document_table.py +++ b/migrations/versions/fc7ac6f06521_add_document_table.py @@ -5,6 +5,7 @@ Create Date: 2022-11-16 22:56:12.898374 """ + from alembic import op import sqlalchemy as sa diff --git a/migrations/versions/fcd870ab34b8_set_stripe_active_default_false.py b/migrations/versions/fcd870ab34b8_set_stripe_active_default_false.py index 583b1fee7..87b0ff129 100644 --- a/migrations/versions/fcd870ab34b8_set_stripe_active_default_false.py +++ b/migrations/versions/fcd870ab34b8_set_stripe_active_default_false.py @@ -5,6 +5,7 @@ Create Date: 2022-09-12 14:37:30.182327 """ + from alembic import op from sqlalchemy import text diff --git a/migrations/versions/fd28aefcdb76_add_css_properties_json_remove_bg_.py b/migrations/versions/fd28aefcdb76_add_css_properties_json_remove_bg_.py index c091d7c1b..8358a3bc7 100644 --- a/migrations/versions/fd28aefcdb76_add_css_properties_json_remove_bg_.py +++ b/migrations/versions/fd28aefcdb76_add_css_properties_json_remove_bg_.py @@ -5,6 +5,7 @@ Create Date: 2021-01-16 13:12:55.795750 """ + from alembic import op import sqlalchemy as sa diff --git a/requirements-dev.lock b/requirements-dev.lock index 0f134b890..3d15330bc 100644 --- a/requirements-dev.lock +++ b/requirements-dev.lock @@ -5,74 +5,176 @@ # pre: false # features: [] # all-features: false +# with-sources: false +# generate-hashes: false -e file:. alembic==1.13.1 -annotated-types==0.6.0 -babel==2.14.0 + # via flask-migrate +annotated-types==0.7.0 + # via pydantic +babel==2.15.0 + # via flask-babel backoff==2.2.1 -black==23.12.1 -blinker==1.7.0 -certifi==2023.11.17 + # via subscribie +black==24.4.2 + # via subscribie +blinker==1.8.2 + # via flask + # via flask-mail + # via subscribie +certifi==2024.2.2 + # via requests cffi==1.16.0 + # via cryptography charset-normalizer==3.3.2 + # via requests click==8.1.7 + # via black + # via flask coloredlogs==15.0.1 -cryptography==41.0.7 + # via subscribie +cryptography==42.0.7 + # via pyjwt currency-symbols==2.0.3 -dnspython==2.4.2 -email-validator==2.1.0.post1 -flask==3.0.0 + # via subscribie +dnspython==2.6.1 + # via email-validator +email-validator==2.1.1 + # via subscribie +flask==3.0.3 + # via flask-babel + # via flask-cors + # via flask-mail + # via flask-migrate + # via flask-reuploaded + # via flask-sqlalchemy + # via flask-wtf + # via subscribie flask-babel==4.0.0 -flask-cors==4.0.0 -flask-mail==0.9.1 -flask-migrate==4.0.5 + # via subscribie +flask-cors==4.0.1 + # via subscribie +flask-mail==0.10.0 + # via subscribie +flask-migrate==4.0.7 + # via subscribie flask-reuploaded==1.4.0 + # via subscribie flask-sqlalchemy==3.1.1 + # via flask-migrate + # via subscribie flask-wtf==1.2.1 + # via subscribie gitdb==4.0.11 -gitpython==3.1.40 + # via gitpython +gitpython==3.1.43 + # via subscribie graphlib==0.9.5 -graphviz==0.20.1 + # via subscribie +graphviz==0.20.3 + # via subscribie greenlet==3.0.3 + # via sqlalchemy humanfriendly==10.0 -idna==3.6 + # via coloredlogs +idna==3.7 + # via email-validator + # via requests iniconfig==2.0.0 -itsdangerous==2.1.2 -jinja2==3.1.2 -joblib==1.3.2 -mako==1.3.0 -markupsafe==2.1.3 + # via pytest +itsdangerous==2.2.0 + # via flask + # via flask-wtf +jinja2==3.1.4 + # via flask + # via flask-babel +joblib==1.4.2 + # via scikit-learn +mako==1.3.5 + # via alembic +markupsafe==2.1.5 + # via jinja2 + # via mako + # via werkzeug + # via wtforms mypy-extensions==1.0.0 -numpy==1.26.2 -packaging==23.2 -pandas==2.1.4 + # via black +numpy==1.26.4 + # via pandas + # via scikit-learn + # via scipy +packaging==24.0 + # via black + # via pytest +pandas==2.2.2 + # via subscribie pathlib==1.0.1 + # via subscribie pathspec==0.12.1 -platformdirs==4.1.0 -pluggy==1.3.0 + # via black +platformdirs==4.2.2 + # via black +pluggy==1.5.0 + # via pytest py-auth-header-parser==1.0.2 + # via subscribie pycountry==23.12.11 -pycparser==2.21 -pycryptodome==3.19.0 -pydantic==2.5.3 -pydantic-core==2.14.6 + # via subscribie +pycparser==2.22 + # via cffi +pycryptodome==3.20.0 + # via subscribie +pydantic==2.7.1 + # via subscribie +pydantic-core==2.18.2 + # via pydantic pyjwt==2.8.0 -pytest==7.4.3 -python-dateutil==2.8.2 -pytz==2023.3.post1 -requests==2.31.0 -scikit-learn==1.3.2 -scipy==1.11.4 + # via subscribie +pytest==8.2.1 + # via subscribie +python-dateutil==2.9.0.post0 + # via pandas + # via strictyaml + # via subscribie +pytz==2024.1 + # via flask-babel + # via pandas +requests==2.32.2 + # via stripe + # via subscribie +scikit-learn==1.5.0 + # via subscribie +scipy==1.13.1 + # via scikit-learn + # via subscribie six==1.16.0 + # via python-dateutil smmap==5.0.1 -sqlalchemy==2.0.23 + # via gitdb +sqlalchemy==2.0.30 + # via alembic + # via flask-sqlalchemy + # via subscribie strictyaml==1.7.3 -stripe==7.10.0 -threadpoolctl==3.2.0 -typing-extensions==4.9.0 -tzdata==2023.3 -urllib3==2.1.0 -werkzeug==3.0.1 -wheel==0.42.0 -wtforms==3.1.1 + # via subscribie +stripe==9.8.0 + # via subscribie +threadpoolctl==3.5.0 + # via scikit-learn +typing-extensions==4.12.0 + # via alembic + # via pydantic + # via pydantic-core + # via sqlalchemy + # via stripe +tzdata==2024.1 + # via pandas +urllib3==2.2.1 + # via requests +werkzeug==3.0.3 + # via flask +wheel==0.43.0 + # via subscribie +wtforms==3.1.2 + # via flask-wtf diff --git a/requirements.lock b/requirements.lock index 0f134b890..3d15330bc 100644 --- a/requirements.lock +++ b/requirements.lock @@ -5,74 +5,176 @@ # pre: false # features: [] # all-features: false +# with-sources: false +# generate-hashes: false -e file:. alembic==1.13.1 -annotated-types==0.6.0 -babel==2.14.0 + # via flask-migrate +annotated-types==0.7.0 + # via pydantic +babel==2.15.0 + # via flask-babel backoff==2.2.1 -black==23.12.1 -blinker==1.7.0 -certifi==2023.11.17 + # via subscribie +black==24.4.2 + # via subscribie +blinker==1.8.2 + # via flask + # via flask-mail + # via subscribie +certifi==2024.2.2 + # via requests cffi==1.16.0 + # via cryptography charset-normalizer==3.3.2 + # via requests click==8.1.7 + # via black + # via flask coloredlogs==15.0.1 -cryptography==41.0.7 + # via subscribie +cryptography==42.0.7 + # via pyjwt currency-symbols==2.0.3 -dnspython==2.4.2 -email-validator==2.1.0.post1 -flask==3.0.0 + # via subscribie +dnspython==2.6.1 + # via email-validator +email-validator==2.1.1 + # via subscribie +flask==3.0.3 + # via flask-babel + # via flask-cors + # via flask-mail + # via flask-migrate + # via flask-reuploaded + # via flask-sqlalchemy + # via flask-wtf + # via subscribie flask-babel==4.0.0 -flask-cors==4.0.0 -flask-mail==0.9.1 -flask-migrate==4.0.5 + # via subscribie +flask-cors==4.0.1 + # via subscribie +flask-mail==0.10.0 + # via subscribie +flask-migrate==4.0.7 + # via subscribie flask-reuploaded==1.4.0 + # via subscribie flask-sqlalchemy==3.1.1 + # via flask-migrate + # via subscribie flask-wtf==1.2.1 + # via subscribie gitdb==4.0.11 -gitpython==3.1.40 + # via gitpython +gitpython==3.1.43 + # via subscribie graphlib==0.9.5 -graphviz==0.20.1 + # via subscribie +graphviz==0.20.3 + # via subscribie greenlet==3.0.3 + # via sqlalchemy humanfriendly==10.0 -idna==3.6 + # via coloredlogs +idna==3.7 + # via email-validator + # via requests iniconfig==2.0.0 -itsdangerous==2.1.2 -jinja2==3.1.2 -joblib==1.3.2 -mako==1.3.0 -markupsafe==2.1.3 + # via pytest +itsdangerous==2.2.0 + # via flask + # via flask-wtf +jinja2==3.1.4 + # via flask + # via flask-babel +joblib==1.4.2 + # via scikit-learn +mako==1.3.5 + # via alembic +markupsafe==2.1.5 + # via jinja2 + # via mako + # via werkzeug + # via wtforms mypy-extensions==1.0.0 -numpy==1.26.2 -packaging==23.2 -pandas==2.1.4 + # via black +numpy==1.26.4 + # via pandas + # via scikit-learn + # via scipy +packaging==24.0 + # via black + # via pytest +pandas==2.2.2 + # via subscribie pathlib==1.0.1 + # via subscribie pathspec==0.12.1 -platformdirs==4.1.0 -pluggy==1.3.0 + # via black +platformdirs==4.2.2 + # via black +pluggy==1.5.0 + # via pytest py-auth-header-parser==1.0.2 + # via subscribie pycountry==23.12.11 -pycparser==2.21 -pycryptodome==3.19.0 -pydantic==2.5.3 -pydantic-core==2.14.6 + # via subscribie +pycparser==2.22 + # via cffi +pycryptodome==3.20.0 + # via subscribie +pydantic==2.7.1 + # via subscribie +pydantic-core==2.18.2 + # via pydantic pyjwt==2.8.0 -pytest==7.4.3 -python-dateutil==2.8.2 -pytz==2023.3.post1 -requests==2.31.0 -scikit-learn==1.3.2 -scipy==1.11.4 + # via subscribie +pytest==8.2.1 + # via subscribie +python-dateutil==2.9.0.post0 + # via pandas + # via strictyaml + # via subscribie +pytz==2024.1 + # via flask-babel + # via pandas +requests==2.32.2 + # via stripe + # via subscribie +scikit-learn==1.5.0 + # via subscribie +scipy==1.13.1 + # via scikit-learn + # via subscribie six==1.16.0 + # via python-dateutil smmap==5.0.1 -sqlalchemy==2.0.23 + # via gitdb +sqlalchemy==2.0.30 + # via alembic + # via flask-sqlalchemy + # via subscribie strictyaml==1.7.3 -stripe==7.10.0 -threadpoolctl==3.2.0 -typing-extensions==4.9.0 -tzdata==2023.3 -urllib3==2.1.0 -werkzeug==3.0.1 -wheel==0.42.0 -wtforms==3.1.1 + # via subscribie +stripe==9.8.0 + # via subscribie +threadpoolctl==3.5.0 + # via scikit-learn +typing-extensions==4.12.0 + # via alembic + # via pydantic + # via pydantic-core + # via sqlalchemy + # via stripe +tzdata==2024.1 + # via pandas +urllib3==2.2.1 + # via requests +werkzeug==3.0.3 + # via flask +wheel==0.43.0 + # via subscribie +wtforms==3.1.2 + # via flask-wtf diff --git a/seed.sql b/seed.sql index 0733e4ea2..14401f514 100644 --- a/seed.sql +++ b/seed.sql @@ -51,9 +51,6 @@ tawk_active, tawk_property_id) VALUES (0, 'example'); -INSERT INTO module (name) -VALUES ('builder'); - CREATE TABLE IF NOT EXISTS builder_sites ( site_url text, diff --git a/subscribie/auth.py b/subscribie/auth.py index 301d8fffc..3439e0022 100644 --- a/subscribie/auth.py +++ b/subscribie/auth.py @@ -184,7 +184,8 @@ def jwt_login(): private_key = open(current_app.config["PRIVATE_KEY"]).read() jwt_payload = jwt.encode( { - "exp": datetime.datetime.utcnow() + datetime.timedelta(minutes=30), + "exp": datetime.datetime.now(datetime.UTC) + + datetime.timedelta(minutes=30), "user_id": user.id, }, private_key, diff --git a/subscribie/blueprints/admin/__init__.py b/subscribie/blueprints/admin/__init__.py index daeed49ad..f3618733d 100644 --- a/subscribie/blueprints/admin/__init__.py +++ b/subscribie/blueprints/admin/__init__.py @@ -98,6 +98,7 @@ from .ResetSite import remove_subscriptions # noqa: F401,E402 from .choice_group import list_choice_groups # noqa: F401, E402 from .option import list_options # noqa: F401, E402 +from .question_option import list_question_options # noqa: F401 from .subscriber import show_subscriber # noqa: F401, E402 from .export_subscribers import export_subscribers # noqa: F401, E402a from .export_transactions import export_transactions # noqa: F401, E402a diff --git a/subscribie/blueprints/admin/choice_group.py b/subscribie/blueprints/admin/choice_group.py index fbc595899..d4184b23d 100644 --- a/subscribie/blueprints/admin/choice_group.py +++ b/subscribie/blueprints/admin/choice_group.py @@ -1,7 +1,7 @@ from . import admin from subscribie.auth import login_required -from subscribie.forms import ChoiceGroupForm -from subscribie.models import ChoiceGroup, Plan +from subscribie.forms import ChoiceGroupForm, QuestionForm +from subscribie.models import ChoiceGroup, Plan, Question, PlanQuestionAssociation from flask import request, render_template, url_for, flash, redirect from subscribie.database import database @@ -25,8 +25,11 @@ def add_choice_group(): @login_required def list_choice_groups(): choice_groups = ChoiceGroup.query.all() + questions = Question.query.all() return render_template( - "admin/choice_group/list_choice_groups.html", choice_groups=choice_groups + "admin/choice_group/list_choice_groups.html", + choice_groups=choice_groups, + questions=questions, ) @@ -87,3 +90,143 @@ def delete_choice_group(id): database.session.commit() flash("Choice group deleted") return redirect(url_for("admin.list_choice_groups")) + + +@admin.route("/list-questions", methods=["GET", "POST"]) +@login_required +def list_questions(): + questions = Question.query.all() + plans = Plan.query.all() + + return render_template( + "admin/question/list_questions.html", questions=questions, plans=plans + ) + + +@admin.route("/add-question", methods=["GET", "POST"]) +@login_required +def add_question(): + form = QuestionForm() + if form.validate_on_submit(): + question = Question() + question.title = request.form["title"] + database.session.add(question) + database.session.commit() + flash("Added new question") + return redirect(url_for("admin.list_questions")) + + return render_template("admin/choice_group/add_question.html", form=form) + + +@admin.route("/edit-question/", methods=["GET", "POST"]) +@login_required +def edit_question(id): + question = Question.query.get(id) + if request.method == "POST": + question.title = request.form["title"] + database.session.commit() + flash("Question updated") + return render_template("admin/question/edit_question.html", question=question) + + +@admin.route("/question//assign-plan", methods=["GET", "POST"]) +@login_required +def question_assign_plan(question_id): + question = Question.query.get(question_id) + plans = Plan.query.filter_by(archived=0) + + if request.method == "POST": + # Remove question associations + for plan in plans: + database.session.query(PlanQuestionAssociation).filter( + PlanQuestionAssociation.plan_id == plan.id, + PlanQuestionAssociation.question_id == question.id, + ).delete() + + # Add back only selected question/plan associations + for plan_id in request.form.getlist("assign"): + plan = Plan.query.get(plan_id) + plan_question_assoc = PlanQuestionAssociation() + plan_question_assoc.question_id = question.id + plan_question_assoc.plan_id = int(plan_id) + database.session.add(plan_question_assoc) + + database.session.commit() + flash("The question assignments have been applied to selected plan(s)") + return redirect(url_for("admin.list_questions")) + + return render_template( + "admin/question/question_assign_plan.html", + question=question, + plans=plans, + ) + + +@admin.route("/delete-question/", methods=["GET"]) +@login_required +def delete_question(id): + if "confirm" in request.args: + questions = Question.query.all() + + return render_template( + "admin/question/list_questions.html", + questions=questions, + question=Question.query.get(id), + is_question_delete_request=True, + confirm=False, + ) + breakpoint() + question = Question.query.get(id) + + # Delete PlanQuestionAssociation with that question: + database.session.query(PlanQuestionAssociation).filter( + PlanQuestionAssociation.question_id == id + ).delete() + + # Delete the question itself: + database.session.delete(question) + database.session.commit() + + flash("Question deleted") + return redirect(url_for("admin.list_questions")) + + +@admin.route("/plan/questions/set-question-order/", methods=["GET", "POST"]) +@login_required +def set_question_order_by_plan(plan_id): + plan = Plan.query.get(plan_id) + + if request.method == "POST": + """ + For each question passed, get & and save the chosen order for + the appearance order + """ + for plan_question_assoc_id in request.form.getlist("plan_question_assoc_id"): + order_value = int( + request.form.get( + f"order-value-for-question_assoc-id-{plan_question_assoc_id}" + ) + ) + question_id = int( + request.form.get( + f"question_id-for-question_assoc-id-{plan_question_assoc_id}" + ) + ) + # Get by composite key (question_id, plan_id) + plan_question_association = PlanQuestionAssociation.query.get( + (question_id, plan.id) + ) + plan_question_association.order = order_value + database.session.add(plan_question_association) + + database.session.commit() + flash("The plans question's have been ordered") + return redirect(url_for("admin.set_question_order_by_plan", plan_id=plan.id)) + + # Sort questions by order + plan.questions.sort(key=lambda question: question.order or 0) + + return render_template( + "admin/question/set_question_order.html", + plan=plan, + ) diff --git a/subscribie/blueprints/admin/export_subscribers.py b/subscribie/blueprints/admin/export_subscribers.py index 248e2a349..b3d10d898 100644 --- a/subscribie/blueprints/admin/export_subscribers.py +++ b/subscribie/blueprints/admin/export_subscribers.py @@ -3,6 +3,7 @@ from subscribie.models import Subscription from flask import request, Response, jsonify import logging +import json @admin.route("/export-subscribers-email") @@ -15,15 +16,20 @@ def export_subscribers(): subscribers = [] for subscription in subscriptions: if subscription.person is not None: - subscribers.append( - { - "given_name": subscription.person.given_name, - "family_name": subscription.person.family_name, - "email": subscription.person.email, - "plan": subscription.plan.title, - "subscription_status": subscription.stripe_status, - } - ) + person = { + "given_name": subscription.person.given_name, + "family_name": subscription.person.family_name, + "email": subscription.person.email, + "plan": subscription.plan.title, + "subscription_status": subscription.stripe_status, + } + # Include subscription question answers in export, if any + if subscription.question_answers: + for answer in subscription.question_answers: + person[ + f"question_id-{answer.question_id}-{json.dumps(answer.question_title)}" # noqa: E501 + ] = json.dumps(answer.response) + subscribers.append(person) else: logging.info( f"Excluding subscription {subscription.id} as no person attached" @@ -34,7 +40,7 @@ def export_subscribers(): import io outfile = io.StringIO() - outcsv = csv.DictWriter(outfile, fieldnames=subscribers[0].keys()) + outcsv = csv.DictWriter(outfile, fieldnames=subscribers[0].keys(), extrasaction='ignore') outcsv.writeheader() for subscriber in subscribers: outcsv.writerow(subscriber) diff --git a/subscribie/blueprints/admin/option.py b/subscribie/blueprints/admin/option.py index 0c8e7d531..cac0c9db4 100644 --- a/subscribie/blueprints/admin/option.py +++ b/subscribie/blueprints/admin/option.py @@ -10,7 +10,7 @@ @login_required def add_option(choice_group_id): form = OptionForm() - choice_group = ChoiceGroup.query.get(choice_group_id) + choice_group = database.session.get(ChoiceGroup, choice_group_id) if form.validate_on_submit(): option = Option() option.title = request.form["title"] @@ -25,14 +25,14 @@ def add_option(choice_group_id): @admin.route("/list-options/", methods=["GET", "POST"]) @login_required def list_options(choice_group_id): - choice_group = ChoiceGroup.query.get(choice_group_id) + choice_group = database.session.get(ChoiceGroup, choice_group_id) return render_template("admin/option/list_options.html", choice_group=choice_group) @admin.route("/edit-option/", methods=["GET", "POST"]) @login_required def edit_option(id): - option = Option.query.get(id) + option = database.session.get(Option, id) if request.method == "POST": option.title = request.form["title"] database.session.commit() @@ -45,18 +45,18 @@ def edit_option(id): ) @login_required def delete_option(option_id, choice_group_id): - choice_group = ChoiceGroup.query.get(choice_group_id) + choice_group = database.session.get(ChoiceGroup, choice_group_id) if "confirm" in request.args: options = Option.query.all() return render_template( "admin/option/list_options.html", options=options, - option=Option.query.get(option_id), + option=database.session.get(Option, option_id), choice_group=choice_group, confirm=False, ) - option = Option.query.get(option_id) + option = database.session.get(Option, option_id) database.session.delete(option) database.session.commit() flash("Choice option deleted") diff --git a/subscribie/blueprints/admin/question_option.py b/subscribie/blueprints/admin/question_option.py new file mode 100644 index 000000000..b24601237 --- /dev/null +++ b/subscribie/blueprints/admin/question_option.py @@ -0,0 +1,69 @@ +from . import admin +from subscribie.auth import login_required +from subscribie.forms import QuestionOptionForm +from subscribie.models import Question, QuestionOption +from subscribie.database import database +from flask import request, render_template, url_for, flash, redirect + + +@admin.route("/add-question-option/question_id/", methods=["GET", "POST"]) +@login_required +def add_question_option(question_id): + form = QuestionOptionForm() + question = Question.query.get(question_id) + if form.validate_on_submit(): + question_option = QuestionOption() + question_option.title = request.form["title"] + question.options.append(question_option) + database.session.commit() + flash("Added new question option") + return redirect(url_for("admin.list_question_options", question_id=question_id)) + + return render_template("admin/question_option/add_question_option.html", form=form) + + +@admin.route("/list-question-options/", methods=["GET", "POST"]) +@login_required +def list_question_options(question_id): + question = Question.query.get(question_id) + return render_template( + "admin/question_option/list_question_options.html", question=question + ) + + +@admin.route("/edit-question-option/", methods=["GET", "POST"]) +@login_required +def edit_question_option(id): + question_option = QuestionOption.query.get(id) + if request.method == "POST": + question_option.title = request.form["title"] + database.session.commit() + flash("Question option updated") + return render_template( + "admin/question_option/edit_question_option.html", + question_option=question_option, + ) + + +@admin.route( + "/delete-question-option//question_id/", + methods=["GET"], +) +@login_required +def delete_question_option(question_option_id, question_id): + question = Question.query.get(question_id) + if "confirm" in request.args: + question_options = QuestionOption.query.all() + + return render_template( + "admin/question_option/list_question_options.html", + question_options=question_options, + question_option=QuestionOption.query.get(question_option_id), + question=question, + confirm=False, + ) + question_option = QuestionOption.query.get(question_option_id) + database.session.delete(question_option) + database.session.commit() + flash("Question option deleted") + return redirect(url_for("admin.list_question_options", question_id=question.id)) diff --git a/subscribie/blueprints/admin/stats.py b/subscribie/blueprints/admin/stats.py index a75be6b37..370399955 100644 --- a/subscribie/blueprints/admin/stats.py +++ b/subscribie/blueprints/admin/stats.py @@ -103,10 +103,14 @@ def get_number_of_recent_subscription_cancellations(): stripe.api_key = get_stripe_secret_key() connect_account_id = get_stripe_connect_account_id() - subscription_cancellations = stripe.Event.list( - stripe_account=connect_account_id, - limit=100, - types=["customer.subscription.deleted"], - ) + try: + subscription_cancellations = stripe.Event.list( + stripe_account=connect_account_id, + limit=100, + types=["customer.subscription.deleted"], + ) + except stripe._error.AuthenticationError as e: + log.error(f"stripe._error.AuthenticationError {e} ") + return "unknown" return len(subscription_cancellations) diff --git a/subscribie/blueprints/admin/templates/admin/choice_group/add_question.html b/subscribie/blueprints/admin/templates/admin/choice_group/add_question.html new file mode 100644 index 000000000..c1cd8f787 --- /dev/null +++ b/subscribie/blueprints/admin/templates/admin/choice_group/add_question.html @@ -0,0 +1,44 @@ +{% extends "admin/layout.html" %} +{% block title %} Add Shop Admin{% endblock %} + +{% block body %} + +

Add Question

+ +
+ +
+ +
+
+
+ +

Add Question

+

Questions may be attached to plans.

+ +
+ +
+ {{ form.csrf_token }} +
+ +
+ +
+
+ + +
+ +
+
+
+ + + +{% endblock %} diff --git a/subscribie/blueprints/admin/templates/admin/choice_group/list_choice_groups.html b/subscribie/blueprints/admin/templates/admin/choice_group/list_choice_groups.html index 3d5f04380..f395f3967 100644 --- a/subscribie/blueprints/admin/templates/admin/choice_group/list_choice_groups.html +++ b/subscribie/blueprints/admin/templates/admin/choice_group/list_choice_groups.html @@ -3,7 +3,7 @@ {% block body %} -

Choice Group

+

Choice Groups

+ + +{% endblock body %} \ No newline at end of file diff --git a/subscribie/blueprints/admin/templates/admin/question/question_assign_plan.html b/subscribie/blueprints/admin/templates/admin/question/question_assign_plan.html new file mode 100644 index 000000000..d9a386af2 --- /dev/null +++ b/subscribie/blueprints/admin/templates/admin/question/question_assign_plan.html @@ -0,0 +1,51 @@ +{% extends "admin/layout.html" %} +{% block title %} {{ title }} {% endblock %} + +{% block body %} + +

Question - Assign Plan

+ +
+ +
+ +
+
+
+ +

Assign Question {{ question.title }} To Plan(s)

+

Check which plan you want to assign the question to.

+

Doing so will make the question be asked of your customer when chosing a plan.

+ +
+ +
+ {% for plan in plans %} +
+
+
+ +
+
+ +
+ {% endfor %} + + +
+ +
+
+
+ +{% endblock body %} \ No newline at end of file diff --git a/subscribie/blueprints/admin/templates/admin/question/set_question_order.html b/subscribie/blueprints/admin/templates/admin/question/set_question_order.html new file mode 100644 index 000000000..ae9797b16 --- /dev/null +++ b/subscribie/blueprints/admin/templates/admin/question/set_question_order.html @@ -0,0 +1,59 @@ +{% extends "admin/layout.html" %} +{% block title %} {{ title }} {% endblock %} + +{% block body %} + +

Set Question Order

+ +
+ +
+ +
+
+
+ +

Set question order

+

Set the question order for plan: {{ plan.title }}.

+

(Questions are orderd lowest number to highest)

+ +
+
+ + + + + + + + + {% for plan_question_assoc in plan.questions %} + + + + + {% endfor %} + +
QuestionOrder
+ {{ plan_question_assoc.question.title|truncate(30) }} + + + + +
+ + +
+ +
+
+
+ + + +{% endblock body %} \ No newline at end of file diff --git a/subscribie/blueprints/admin/templates/admin/subscriber/show_subscriber.html b/subscribie/blueprints/admin/templates/admin/subscriber/show_subscriber.html index b34c2c569..5722dc25f 100644 --- a/subscribie/blueprints/admin/templates/admin/subscriber/show_subscriber.html +++ b/subscribie/blueprints/admin/templates/admin/subscriber/show_subscriber.html @@ -213,6 +213,19 @@

Subscriptions

Next date: {{ subscription.next_date().strftime('%d-%m-%Y') }}
{% endif %} {% endif %} + {% if subscription.question_answers %} + Question Answers: + {% for answer in subscription.question_answers %} +
+ + {{ answer.question_title }} + + {{ answer.response }} +
+ {% endfor %} + + {% endif %} + {% if subscription.chosen_options %} Choices: diff --git a/subscribie/blueprints/admin/templates/admin/subscribers.html b/subscribie/blueprints/admin/templates/admin/subscribers.html index 56c9db116..098fc5cab 100644 --- a/subscribie/blueprints/admin/templates/admin/subscribers.html +++ b/subscribie/blueprints/admin/templates/admin/subscribers.html @@ -95,6 +95,23 @@

Search...

{{ subscription.plan.interval_unit }} {% endif %} + {% if subscription.question_answers %} +
  • +
    + Question Answers +
      + {% for answer in subscription.question_answers %} +
    • + + {{ answer.question_title|truncate(40) }}:
      + {{ answer.response }} +
    • + {% endfor %} +
    +
    +
  • + {% endif %} + {% if subscription.chosen_options %}
  • diff --git a/subscribie/blueprints/checkout/__init__.py b/subscribie/blueprints/checkout/__init__.py index 2b116e745..4f30c6c77 100644 --- a/subscribie/blueprints/checkout/__init__.py +++ b/subscribie/blueprints/checkout/__init__.py @@ -15,6 +15,8 @@ Plan, Option, ChosenOption, + Question, + Answer, Person, PaymentProvider, Company, @@ -205,11 +207,14 @@ def order_summary(): if plan.is_free(): log.info("Plan is free, so skipping Stripe checkout") chosen_option_ids = session.get("chosen_option_ids", None) - create_subscription( email=session["email"], package=session["package"], chosen_option_ids=chosen_option_ids, + chosen_question_ids_answers=session.get("chosen_question_ids_answers"), + subscribie_checkout_session_id=session.get( + "subscribie_checkout_session_id" + ), # noqa: E501 ) return redirect(url_for("checkout.thankyou")) @@ -310,6 +315,7 @@ def thankyou(): email = session.get("email", current_app.config["MAIL_DEFAULT_SENDER"]) if is_donation is False: + # TODO if checkout_session_id is None (because free plan) subscription = ( database.session.query(Subscription) .filter_by(subscribie_checkout_session_id=checkout_session_id) @@ -508,6 +514,9 @@ def stripe_create_checkout_session(): "person_uuid": person.uuid, "plan_uuid": plan_uuid, "chosen_option_ids": json.dumps(session.get("chosen_option_ids", None)), + "chosen_question_ids_answers": json.dumps( + session.get("chosen_question_ids_answers", None) + ), # noqa "package": session.get("package", None), "subscribie_checkout_session_id": session.get( "subscribie_checkout_session_id", None @@ -564,6 +573,7 @@ def create_subscription( email=None, package=None, chosen_option_ids=None, + chosen_question_ids_answers=None, subscribie_checkout_session_id=None, stripe_external_id=None, stripe_subscription_id=None, @@ -638,10 +648,28 @@ def create_subscription( sell_price=sell_price, currency=currency, ) - # Add chosen options (if any) - if chosen_option_ids is None: - chosen_option_ids = session.get("chosen_option_ids", None) + database.session.add(subscription) + # Add question answers (if any) + answers = [] + if chosen_question_ids_answers: + for answer in chosen_question_ids_answers: + question_id = answer["question_id"] + answer_response = answer["answer"] + question = Question.query.get(question_id) + if question is not None: + # We will store Question responses as Answer + # because Question.title may change after the order + # has processed. This preserves integrity of the actual + # answered questions + answer = Answer() + answer.question_id = question_id + answer.question_title = question.title + answer.response = answer_response + database.session.add(answer) + answers.append(answer) + subscription.question_answers = answers + # Add chosen options (if any) if chosen_option_ids and chosen_option_ids != "null": log.info( f"Applying chosen_option_ids to subscription: {chosen_option_ids}" @@ -669,7 +697,6 @@ def create_subscription( else: log.info("No chosen_option_ids were found or applied.") - database.session.add(subscription) database.session.commit() session["subscription_uuid"] = subscription.uuid @@ -688,7 +715,13 @@ def create_subscription( subscription.stripe_cancel_at = cancel_at database.session.commit() except Exception as e: # noqa - log.error("Could not set cancel_at: {e}") + # OK to fail if plan is free since + # there may not be a Stripe object for + # free plans (but there could be for plans + # which started paid then became free, which + # is why we still attempt the lookup) + if subscription.plan.is_free() is False: + log.error("Could not set cancel_at: {e}") newSubscriberEmailNotification() return subscription @@ -1004,6 +1037,18 @@ def stripe_webhook(): chosen_option_ids = json.loads(chosen_option_ids) except KeyError: chosen_option_ids = None + + chosen_question_ids_answers = None + try: + chosen_question_ids_answers = json.loads( + session["metadata"]["chosen_question_ids_answers"] + ) # noqa + except KeyError: + msg = "KeyError on subscription metadata chosen_question_ids_answers (maybe none for this plan)" # noqa + log.warning(msg) + except json.decoder.JSONDecodeError: + log.warning("Unable to decode chosen_question_ids_answers") + try: package = session["metadata"]["package"] except KeyError: @@ -1024,6 +1069,7 @@ def stripe_webhook(): email=session["customer_email"], package=package, chosen_option_ids=chosen_option_ids, + chosen_question_ids_answers=chosen_question_ids_answers, subscribie_checkout_session_id=subscribie_checkout_session_id, stripe_subscription_id=stripe_subscription_id, stripe_external_id=session["id"], diff --git a/subscribie/email.py b/subscribie/email.py index e0d139b35..004754888 100644 --- a/subscribie/email.py +++ b/subscribie/email.py @@ -45,9 +45,9 @@ def send_email(to_email=None, subject=None, body_html=None, body_plaintext=None) if setting is not None: msg["Reply-To"] = setting.reply_to_email_address else: - msg[ - "Reply-To" - ] = User.query.first().email # Fallback to first shop admin email + msg["Reply-To"] = ( + User.query.first().email + ) # Fallback to first shop admin email msg.queue() except Exception as e: log.error(f"Failed to send email. {e}") diff --git a/subscribie/forms.py b/subscribie/forms.py index d96c8ad4c..5e9eb7536 100644 --- a/subscribie/forms.py +++ b/subscribie/forms.py @@ -85,10 +85,18 @@ class ChoiceGroupForm(FlaskForm): title = StringField("title", validators=[DataRequired()]) +class QuestionForm(FlaskForm): + title = StringField("title", validators=[DataRequired()]) + + class OptionForm(FlaskForm): title = StringField("title", validators=[DataRequired()]) +class QuestionOptionForm(FlaskForm): + title = StringField("title", validators=[DataRequired()]) + + class LoginForm(FlaskForm): email = StringField("email", validators=[DataRequired(), EmailValid()]) diff --git a/subscribie/models.py b/subscribie/models.py index f1d558d38..ffabbd566 100644 --- a/subscribie/models.py +++ b/subscribie/models.py @@ -6,9 +6,12 @@ from sqlalchemy import event from sqlalchemy import Column from sqlalchemy import Boolean +from sqlalchemy import PrimaryKeyConstraint +from sqlalchemy import desc from typing import Optional -from datetime import datetime +import datetime +import pytz from uuid import uuid4 from werkzeug.security import generate_password_hash, check_password_hash from dateutil.relativedelta import relativedelta @@ -99,7 +102,9 @@ class HasArchived(object): class CreatedAt(object): """Mixin that identifies a class as having created_at entities""" - created_at = database.Column(database.DateTime, default=datetime.utcnow) + created_at = database.Column( + database.DateTime, default=datetime.datetime.now(datetime.UTC) + ) class HasReadOnly(object): @@ -113,7 +118,9 @@ class User(database.Model): id = database.Column(database.Integer(), primary_key=True) email = database.Column(database.String()) password = database.Column(database.String()) - created_at = database.Column(database.DateTime, default=datetime.utcnow) + created_at = database.Column( + database.DateTime, default=datetime.datetime.now(datetime.UTC) + ) active = database.Column(database.String) login_token = database.Column(database.String) password_reset_string = database.Column(database.String()) @@ -132,10 +139,12 @@ def __repr__(self): class Person(database.Model, HasArchived): __tablename__ = "person" id = database.Column(database.Integer(), primary_key=True) - created_at = database.Column(database.DateTime, default=datetime.utcnow) + created_at = database.Column( + database.DateTime, default=datetime.datetime.now(datetime.UTC) + ) uuid = database.Column(database.String(), default=uuid_string) sid = database.Column(database.String()) - ts = database.Column(database.DateTime, default=datetime.utcnow) + ts = database.Column(database.DateTime, default=datetime.datetime.now(datetime.UTC)) given_name = database.Column(database.String()) family_name = database.Column(database.String()) full_name = database.column_property(given_name + " " + family_name) @@ -172,6 +181,7 @@ def balance(self, skipFetchDeclineCode=False): total_charged = 0 total_collected = 0 customer_balance = 0 + invoices = self.invoices(skipFetchDeclineCode=skipFetchDeclineCode) for invoice in invoices: total_charged += invoice.amount_due @@ -377,11 +387,14 @@ class Subscription(database.Model): # List of associated Stripe Invoices (may not be live synced) stripe_invoices = relationship("StripeInvoice") - created_at = database.Column(database.DateTime, default=datetime.utcnow) + created_at = database.Column( + database.DateTime, default=datetime.datetime.now(datetime.UTC) + ) transactions = relationship("Transaction", back_populates="subscription") chosen_options = relationship( "ChosenOption", back_populates="subscription" ) # noqa: E501 + question_answers = relationship("Answer", back_populates="subscription") currency = database.Column(database.String(), default="USD") subscribie_checkout_session_id = database.Column(database.String()) stripe_subscription_id = database.Column(database.String()) @@ -407,7 +420,7 @@ def next_date(self): Based on the created_at date, divided by number of intervals since + days remaining. """ - from datetime import datetime + import datetime from dateutil import rrule if self.plan.interval_unit == "yearly": @@ -415,8 +428,8 @@ def next_date(self): rrule.rrule( rrule.YEARLY, interval=1, - until=datetime.utcnow() + relativedelta(years=+1), - dtstart=self.created_at, + until=datetime.datetime.now(datetime.UTC) + relativedelta(years=+1), + dtstart=pytz.utc.localize(self.created_at), ) )[-1] elif self.plan.interval_unit == "weekly": @@ -424,8 +437,8 @@ def next_date(self): rrule.rrule( rrule.WEEKLY, interval=1, - until=datetime.utcnow() + relativedelta(weeks=+1), - dtstart=self.created_at, + until=datetime.datetime.now(datetime.UTC) + relativedelta(weeks=+1), + dtstart=pytz.utc.localize(self.created_at), ) )[-1] else: @@ -433,7 +446,8 @@ def next_date(self): rrule.rrule( rrule.MONTHLY, interval=1, - until=datetime.utcnow() + relativedelta(months=+1), + until=datetime.datetime.now(datetime.UTC) + + relativedelta(months=+1), dtstart=self.created_at, ) )[-1] @@ -486,7 +500,9 @@ def showIntervalAmount(self) -> str: class SubscriptionNote(database.Model): __tablename__ = "subscription_note" id = database.Column(database.Integer(), primary_key=True) - created_at = database.Column(database.DateTime, default=datetime.utcnow) + created_at = database.Column( + database.DateTime, default=datetime.datetime.now(datetime.UTC) + ) note = database.Column(database.String()) subscription_id = database.Column( database.Integer(), ForeignKey("subscription.id") @@ -508,7 +524,9 @@ class UpcomingInvoice(database.Model): __tablename__ = "upcoming_invoice" id = database.Column(database.Integer(), primary_key=True) - created_at = database.Column(database.DateTime, default=datetime.utcnow) + created_at = database.Column( + database.DateTime, default=datetime.datetime.now(datetime.UTC) + ) # Note, upcoming invoices do not have an id https://stripe.com/docs/api/invoices/upcoming # noqa stripe_subscription_id = database.Column(database.String()) stripe_invoice_status = database.Column(database.String()) @@ -569,7 +587,9 @@ class StripeInvoice(database.Model, CreatedAt): class Company(database.Model): __tablename__ = "company" id = database.Column(database.Integer(), primary_key=True) - created_at = database.Column(database.DateTime, default=datetime.utcnow) + created_at = database.Column( + database.DateTime, default=datetime.datetime.now(datetime.UTC) + ) name = database.Column(database.String()) slogan = database.Column(database.String()) logo_src = database.Column(database.String()) @@ -600,6 +620,22 @@ class Company(database.Model): ) +# Enables: As a shop owner I can order the order in which questions are presented +# to subscribers during sign up +# See: +# https://github.com/sqlalchemy/sqlalchemy/discussions/8556#discussioncomment-3700971 +class PlanQuestionAssociation(database.Model): + __tablename__ = "plan_question_associations" + created_at = database.Column(database.DateTime, default=datetime.datetime.now(datetime.UTC)) + uuid = database.Column(database.String(), default=uuid_string) + question_id = database.Column(database.Integer, ForeignKey("question.id")) + question = relationship("Question") + plan_id = database.Column(database.Integer, ForeignKey("plan.id")) + plan = relationship("Plan") + order = database.Column(database.Integer(), nullable=True) + __table_args__ = (PrimaryKeyConstraint("question_id", "plan_id"),) + + class INTERVAL_UNITS(Enum): DAILY = _("daily") WEEKLY = _("weekly") @@ -626,7 +662,9 @@ class INTERVAL_UNITS(Enum): class Plan(database.Model, HasArchived): __tablename__ = "plan" id = database.Column(database.Integer(), primary_key=True) - created_at = database.Column(database.DateTime, default=datetime.utcnow) + created_at = database.Column( + database.DateTime, default=datetime.datetime.now(datetime.UTC) + ) uuid = database.Column(database.String(), default=uuid_string) parent_plan_revision_uuid = database.Column(database.String(), default=uuid_string) title = database.Column(database.String()) @@ -649,6 +687,8 @@ class Plan(database.Model, HasArchived): secondary=association_table_plan_choice_group, backref=database.backref("plans", lazy="dynamic"), ) + # TODO associationproxy + questions = relationship(PlanQuestionAssociation, backref="plans") documents = relationship("Document", secondary=association_table_plan_to_document) position = database.Column(database.Integer(), default=0) @@ -1006,7 +1046,9 @@ class Category(database.Model): __tablename__ = "category" id = database.Column(database.Integer(), primary_key=True) uuid = database.Column(database.String(), default=uuid_string) - created_at = database.Column(database.DateTime, default=datetime.utcnow) + created_at = database.Column( + database.DateTime, default=datetime.datetime.now(datetime.UTC) + ) name = database.Column(database.String()) plans = relationship("Plan", back_populates="category") position = database.Column(database.Integer(), default=0) @@ -1015,7 +1057,9 @@ class Category(database.Model): class PlanRequirements(database.Model): __tablename__ = "plan_requirements" id = database.Column(database.Integer(), primary_key=True) - created_at = database.Column(database.DateTime, default=datetime.utcnow) + created_at = database.Column( + database.DateTime, default=datetime.datetime.now(datetime.UTC) + ) plan_id = database.Column(database.Integer(), ForeignKey("plan.id")) plan = relationship("Plan", back_populates="requirements") instant_payment = database.Column(database.Boolean(), default=False) @@ -1027,7 +1071,9 @@ class PlanRequirements(database.Model): class PlanSellingPoints(database.Model): __tablename__ = "plan_selling_points" id = database.Column(database.Integer(), primary_key=True) - created_at = database.Column(database.DateTime, default=datetime.utcnow) + created_at = database.Column( + database.DateTime, default=datetime.datetime.now(datetime.UTC) + ) point = database.Column(database.String()) plan_id = database.Column(database.Integer(), ForeignKey("plan.id")) plan = relationship("Plan", back_populates="selling_points") @@ -1036,7 +1082,9 @@ class PlanSellingPoints(database.Model): class Integration(database.Model): __tablename__ = "integration" id = database.Column(database.Integer(), primary_key=True) - created_at = database.Column(database.DateTime, default=datetime.utcnow) + created_at = database.Column( + database.DateTime, default=datetime.datetime.now(datetime.UTC) + ) google_tag_manager_active = database.Column(database.Boolean()) google_tag_manager_container_id = database.Column(database.String()) tawk_active = database.Column(database.Boolean()) @@ -1054,7 +1102,9 @@ class Integration(database.Model): class PaymentProvider(database.Model): __tablename__ = "payment_provider" id = database.Column(database.Integer(), primary_key=True) - created_at = database.Column(database.DateTime, default=datetime.utcnow) + created_at = database.Column( + database.DateTime, default=datetime.datetime.now(datetime.UTC) + ) gocardless_active = database.Column(database.Boolean()) gocardless_access_token = database.Column(database.String()) gocardless_environment = database.Column(database.String()) @@ -1071,7 +1121,9 @@ class PaymentProvider(database.Model): class Page(database.Model): __tablename__ = "page" id = database.Column(database.Integer(), primary_key=True) - created_at = database.Column(database.DateTime, default=datetime.utcnow) + created_at = database.Column( + database.DateTime, default=datetime.datetime.now(datetime.UTC) + ) page_name = database.Column(database.String()) path = database.Column(database.String()) template_file = database.Column(database.String()) @@ -1081,7 +1133,9 @@ class Page(database.Model): class Module(database.Model): __tablename__ = "module" id = database.Column(database.Integer(), primary_key=True) - created_at = database.Column(database.DateTime, default=datetime.utcnow) + created_at = database.Column( + database.DateTime, default=datetime.datetime.now(datetime.UTC) + ) name = database.Column(database.String()) src = database.Column(database.String()) @@ -1089,7 +1143,9 @@ class Module(database.Model): class Transaction(database.Model): __tablename__ = "transactions" id = database.Column(database.Integer(), primary_key=True) - created_at = database.Column(database.DateTime, default=datetime.utcnow) + created_at = database.Column( + database.DateTime, default=datetime.datetime.now(datetime.UTC) + ) uuid = database.Column(database.String(), default=uuid_string) currency = database.Column(database.String(), nullable=False) amount = database.Column(database.Integer()) @@ -1125,11 +1181,48 @@ class SeoPageTitle(database.Model): class ChoiceGroup(database.Model): __tablename__ = "choice_group" id = database.Column(database.Integer(), primary_key=True) - created_at = database.Column(database.DateTime, default=datetime.utcnow) + created_at = database.Column( + database.DateTime, default=datetime.datetime.now(datetime.UTC) + ) title = database.Column(database.String()) options = relationship("Option", back_populates="choice_group") +class Question(database.Model): + __tablename__ = "question" + id = database.Column(database.Integer(), primary_key=True) + uuid = database.Column(database.String(), default=uuid_string) + created_at = database.Column(database.DateTime, default=datetime.datetime.now(datetime.UTC)) + options = relationship("QuestionOption", back_populates="question") + title = database.Column(database.String()) + + +class QuestionOption(database.Model): + __tablename__ = "question_option" + id = database.Column(database.Integer(), primary_key=True) + question_id = database.Column(database.Integer(), ForeignKey("question.id")) # noqa + question = relationship("Question", back_populates="options") + created_at = database.Column(database.DateTime, default=datetime.datetime.now(datetime.UTC)) + title = database.Column(database.String()) + description = database.Column(database.Text()) + primary_icon = database.Column(database.String()) + + +class Answer(database.Model): + __tablename__ = "answer" + id = database.Column(database.Integer(), primary_key=True) + created_at = database.Column(database.DateTime, default=datetime.datetime.now(datetime.UTC)) + question_id = database.Column(database.Integer()) + question_title = database.Column(database.String()) + response = database.Column(database.String()) + subscription_id = database.Column( + database.Integer(), ForeignKey("subscription.id") + ) # noqa + subscription = relationship( + "Subscription", back_populates="question_answers" + ) # noqa + + class Option(database.Model): __tablename__ = "option" id = database.Column(database.Integer(), primary_key=True) @@ -1137,7 +1230,9 @@ class Option(database.Model): database.Integer(), ForeignKey("choice_group.id") ) # noqa choice_group = relationship("ChoiceGroup", back_populates="options") - created_at = database.Column(database.DateTime, default=datetime.utcnow) + created_at = database.Column( + database.DateTime, default=datetime.datetime.now(datetime.UTC) + ) title = database.Column(database.String()) description = database.Column(database.Text()) primary_icon = database.Column(database.String()) @@ -1146,7 +1241,9 @@ class Option(database.Model): class ChosenOption(database.Model): __tablename__ = "chosen_option" id = database.Column(database.Integer(), primary_key=True) - created_at = database.Column(database.DateTime, default=datetime.utcnow) + created_at = database.Column( + database.DateTime, default=datetime.datetime.now(datetime.UTC) + ) choice_group_id = database.Column(database.Integer()) choice_group_title = database.Column(database.String()) option_title = database.Column(database.String()) @@ -1161,7 +1258,9 @@ class ModuleStyle(database.Model): __tablename__ = "module_style" id = database.Column(database.Integer(), primary_key=True) - created_at = database.Column(database.DateTime, default=datetime.utcnow) + created_at = database.Column( + database.DateTime, default=datetime.datetime.now(datetime.UTC) + ) css_properties_json = database.Column(database.String()) css = database.Column(database.String()) @@ -1199,7 +1298,9 @@ class File(database.Model): __tablename__ = "file" id = database.Column(database.Integer(), primary_key=True) - created_at = database.Column(database.DateTime, default=datetime.utcnow) + created_at = database.Column( + database.DateTime, default=datetime.datetime.now(datetime.UTC) + ) uuid = database.Column(database.String(), default=uuid_string) file_name = database.Column(database.String()) @@ -1209,7 +1310,9 @@ class Document(database.Model, HasArchived, HasReadOnly): __tablename__ = "document" id = database.Column(database.Integer(), primary_key=True) - created_at = database.Column(database.DateTime, default=datetime.utcnow) + created_at = database.Column( + database.DateTime, default=datetime.datetime.now(datetime.UTC) + ) uuid = database.Column(database.String(), default=uuid_string) name = database.Column(database.String(), default=None) type = database.Column(database.String(), default=None) @@ -1233,7 +1336,9 @@ class TaxRate(database.Model): id = database.Column(database.Integer(), primary_key=True) stripe_tax_rate_id = database.Column(database.String()) stripe_livemode = database.Column(database.Boolean()) - created_at = database.Column(database.DateTime, default=datetime.utcnow) + created_at = database.Column( + database.DateTime, default=datetime.datetime.now(datetime.UTC) + ) association_table_price_list_to_rule = database.Table( @@ -1283,10 +1388,14 @@ class PriceList(database.Model): __tablename__ = "price_list" id = database.Column(database.Integer(), primary_key=True) - created_at = database.Column(database.DateTime, default=datetime.utcnow) + created_at = database.Column( + database.DateTime, default=datetime.datetime.now(datetime.UTC) + ) uuid = database.Column(database.String(), default=uuid_string) name = database.Column(database.String()) - start_date = database.Column(database.DateTime, default=datetime.utcnow) + start_date = database.Column( + database.DateTime, default=datetime.datetime.now(datetime.UTC) + ) expire_date = database.Column(database.DateTime, default=None) currency = database.Column(database.String()) rules = relationship( @@ -1325,10 +1434,14 @@ class PriceListRule(database.Model): __tablename__ = "price_list_rule" id = database.Column(database.Integer(), primary_key=True) - created_at = database.Column(database.DateTime, default=datetime.utcnow) + created_at = database.Column( + database.DateTime, default=datetime.datetime.now(datetime.UTC) + ) uuid = database.Column(database.String(), default=uuid_string) name = database.Column(database.String()) - start_date = database.Column(database.DateTime, default=datetime.utcnow) + start_date = database.Column( + database.DateTime, default=datetime.datetime.now(datetime.UTC) + ) expire_date = database.Column(database.DateTime, default=None) active = database.Column(database.Boolean(), default=1) position = database.Column(database.Integer(), default=0) diff --git a/subscribie/notifications.py b/subscribie/notifications.py index e20731083..4cd969ac4 100644 --- a/subscribie/notifications.py +++ b/subscribie/notifications.py @@ -56,9 +56,9 @@ def subscriberPaymentFailedNotification( kwargs["subscriber_first_name"] = subscriber_name.split(" ")[0] kwargs["failure_message"] = failure_message kwargs["failure_code"] = failure_code - kwargs[ - "subscriber_login_url" - ] = f"https://{app.config['SERVER_NAME']}/account/login" + kwargs["subscriber_login_url"] = ( + f"https://{app.config['SERVER_NAME']}/account/login" + ) msg = EmailMessageQueue() msg["subject"] = f"{company.name} - A payment collection failed" msg["from"] = current_app.config["EMAIL_LOGIN_FROM"] diff --git a/subscribie/schemas/schemas.py b/subscribie/schemas/schemas.py index 0ee7be291..8da04fc6f 100644 --- a/subscribie/schemas/schemas.py +++ b/subscribie/schemas/schemas.py @@ -1,6 +1,6 @@ -from pydantic import BaseModel, validator +from pydantic import field_validator, ConfigDict, BaseModel from enum import Enum -from datetime import datetime +import datetime from typing import List, Optional from sqlalchemy.orm import Query import uuid as _uuid @@ -10,22 +10,22 @@ class OrmBase(BaseModel): # Common properties across orm models # Credit: https://github.com/samuelcolvin/pydantic/issues/1334#issuecomment-679207580 # noqa - @validator("*", pre=True) + @field_validator("*", mode="before") + @classmethod def evaluate_lazy_columns(cls, v): if isinstance(v, Query): return v.all() return v - class Config: - orm_mode = True + model_config = ConfigDict(from_attributes=True) class PlanRequirementsBase(OrmBase): - created_at: datetime = datetime.utcnow() + created_at: datetime.datetime instant_payment: bool subscription: bool - note_to_seller_required: Optional[bool] - note_to_buyer_message: Optional[str] + note_to_seller_required: Optional[bool] = None + note_to_buyer_message: Optional[str] = None class PlanRequirements(PlanRequirementsBase): @@ -38,7 +38,7 @@ class PlanRequirementsCreate(PlanRequirementsBase): class PlanSellingPointBase(OrmBase): - created_at: datetime = datetime.utcnow() + created_at: datetime.datetime point: str @@ -72,14 +72,12 @@ class PlanBase(OrmBase): class PlanInDBBase(PlanBase): - created_at: datetime = datetime.utcnow() + created_at: datetime.datetime class Plan(PlanBase): - id: Optional[int] - - class Config: - orm_mode = True + id: Optional[int] = None + model_config = ConfigDict(from_attributes=True) class PlanUpdate(PlanBase): @@ -88,7 +86,7 @@ class PlanUpdate(PlanBase): class PlanCreate(PlanBase): title: str - created_at: datetime = datetime.utcnow() + created_at: datetime.datetime class Company(OrmBase): diff --git a/subscribie/themes/theme-jesmond/jesmond/macros/plan_card.html b/subscribie/themes/theme-jesmond/jesmond/macros/plan_card.html index 4a76f87ce..05a5b4112 100644 --- a/subscribie/themes/theme-jesmond/jesmond/macros/plan_card.html +++ b/subscribie/themes/theme-jesmond/jesmond/macros/plan_card.html @@ -75,10 +75,21 @@
    {{ plan.title|safe }}
    {% endif %} + + {# + If a plan has both choice groups and questions, collect choice groups first, + then `views.set_options` view will url-redirect to collect question answers + afterwards. + If plan does not have choice groups, ask questions right away. + #} {% if plan.choice_groups %} {{ _('Choose') }} + {% elif plan.questions %} + + {{ _('Choose') }} + {% else %} {{ _('Choose') }} @@ -88,4 +99,4 @@
    {{ plan.title|safe }}
    -{%- endmacro %} \ No newline at end of file +{%- endmacro %} diff --git a/subscribie/themes/theme-jesmond/jesmond/set_questions.html b/subscribie/themes/theme-jesmond/jesmond/set_questions.html new file mode 100644 index 000000000..b7ef30faa --- /dev/null +++ b/subscribie/themes/theme-jesmond/jesmond/set_questions.html @@ -0,0 +1,51 @@ +{% extends "layout.html" %} +{% block title %} Questions - {{ title }} {% endblock title %} + +{% block hero %} +
    +
    + +
    +
    +

    {{ _('Questions') }}

    +
    +
    + +
    +
    + +{% endblock %} + +{% block body %} + +
    +
    +
    +
    + + {% if plan.questions %} +

    {{ _('The') }} {{ plan.title }} {{ _('plan has') }} {{ plan.questions|count }} {{ _('question(s)') }}

    + + {% for plan_question_assoc in plan.questions %} +
    + + {% if plan_question_assoc.question.options %} + + {% else %} + + {% endif %} +
    + {% endfor %} + {% endif %} + + +
    +
    +
    +
    +{% endblock %} diff --git a/subscribie/views.py b/subscribie/views.py index db2a44121..1652543eb 100644 --- a/subscribie/views.py +++ b/subscribie/views.py @@ -16,7 +16,17 @@ send_from_directory, ) from markupsafe import Markup -from .models import Company, Plan, Integration, Page, Category, Setting, PaymentProvider +from .models import ( + Company, + Plan, + Integration, + Page, + Category, + Setting, + PaymentProvider, + Question, + QuestionOption, +) from subscribie.blueprints.style import inject_custom_style from subscribie.database import database from subscribie.signals import register_signal_handlers @@ -223,15 +233,47 @@ def set_options(plan_uuid): if request.method == "POST": # Store chosen options in session session["chosen_option_ids"] = [] - for choice_group_id in request.form.keys(): - for option_id in request.form.getlist(choice_group_id): - session["chosen_option_ids"].append(option_id) - + session["chosen_question_ids_answers"] = [] + for form_control_id in request.form.keys(): + chosen_option_id = int(request.form.getlist(form_control_id)[0]) + session["chosen_option_ids"].append(chosen_option_id) + # If plan has Questions, ask them: + if plan.questions: + return redirect(url_for("views.set_questions", plan_uuid=plan_uuid)) return redirect(url_for("checkout.new_customer", plan=plan_uuid)) return render_template("set_options.html", plan=plan) +@bp.route("/set_questions/", methods=["GET", "POST"]) +def set_questions(plan_uuid): + plan = Plan.query.filter_by(uuid=plan_uuid).first() + + if request.method == "POST": + # Store question answers in session + session["chosen_question_ids_answers"] = [] + for form_control_id in request.form.keys(): + # Question form are named 'question-' + # Choice group options are named: '' + question_id = int(form_control_id.replace("question-", "")) + question = Question.query.get(question_id) + # If question has options, store the value chosen, + # otherwise, take the form contol value + if question.options: + question_answer = QuestionOption.query.get( + request.form.get(form_control_id) + ).title + else: + question_answer = request.form.get(form_control_id) + answer = {"question_id": question_id, "answer": question_answer} + session["chosen_question_ids_answers"].append(answer) + session["questions_form_completed"] = True + return redirect(url_for("checkout.new_customer", plan=plan_uuid)) + # Sort the questions + plan.questions.sort(key=lambda question: question.order or 0, reverse=False) + return render_template("set_questions.html", plan=plan) + + @bp.route("/page/", methods=["GET"]) def custom_page(path): page = Page.query.filter_by(path=path).first() @@ -305,8 +347,7 @@ def get_domain(): pass babel = current_app.extensions["babel"] - ctx.babel_domain = babel.domain_instance - return ctx.babel_domain + return babel.instance.domain_instance def get_translations(): """Returns the correct gettext translations that should be used for diff --git a/test.sh b/test.sh new file mode 100755 index 000000000..b581623f8 --- /dev/null +++ b/test.sh @@ -0,0 +1,6 @@ +#!/bin/bash + +set -exou + +. .venv/bin/activate +python -m pytest diff --git a/tests/browser-automated-tests-playwright/e2e/1219_custom_thank_you_url.spec.js b/tests/browser-automated-tests-playwright/e2e/1219_custom_thank_you_url.spec.js index b289f9697..d8c5f6bb7 100644 --- a/tests/browser-automated-tests-playwright/e2e/1219_custom_thank_you_url.spec.js +++ b/tests/browser-automated-tests-playwright/e2e/1219_custom_thank_you_url.spec.js @@ -32,7 +32,7 @@ test.describe("order free plan tests:", () => { await page.goto('/admin/change-thank-you-url'); await page.getByRole('button', { name: 'default'}).click(); await new Promise(x => setTimeout(x, 2000)); - const default_custom_url = await page.textContent('text="Custom thank you url changed to default"'); + const default_custom_url = await page.textContent('text="Thank page has been set back to the to the default thank you page"'); expect(default_custom_url === 'Custom thank you url changed to default'); }); }); diff --git a/tests/conftest.py b/tests/conftest.py index 63736225e..d1729d6f9 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -5,9 +5,9 @@ from flask_migrate import Migrate from subscribie import create_app -from subscribie import database as _db from subscribie.models import User, Company, Setting from subscribie import seed_db +from sqlalchemy.orm import scoped_session, sessionmaker TESTDB = "test_project.db" @@ -45,67 +45,56 @@ def client(app): def apply_migrations(app): """Applies all alembic migrations.""" - _db.init_app(app) - Migrate(app, _db) + pass + from subscribie import database as db + + Migrate(app, db) upgrade("./migrations") seed_db() @pytest.fixture(scope="session") -def db(app, request): - """Session-wide test database.""" - if os.path.exists(TESTDB_PATH): - os.unlink(TESTDB_PATH) - - def teardown(): - _db.drop_all() - if os.path.exists(TESTDB_PATH): - os.unlink(TESTDB_PATH) - - _db.app = app - apply_migrations(app) - - request.addfinalizer(teardown) - return _db - - -@pytest.fixture(scope="function") -def session(db, request): +def db_session(app, request): """Creates a new database session for a test.""" + from subscribie import database as db + connection = db.engine.connect() transaction = connection.begin() - - options = dict(bind=connection, binds={}) - session = db.create_scoped_session(options=options) + session = scoped_session(sessionmaker(bind=connection)) db.session = session + apply_migrations(app) def teardown(): transaction.rollback() connection.close() + connection.engine.dispose() + session.close() session.remove() + if os.path.exists(TESTDB_PATH): + os.unlink(TESTDB_PATH) request.addfinalizer(teardown) return session @pytest.fixture(scope="function") -def with_shop_owner(db, session): +def with_shop_owner(db_session): user = User() user.email = "admin@example.com" - session.add(user) + db_session.add(user) # Add a company company = Company() company.name = "Subscription Shop" company.slogan = "Buy plans on subscription" - session.commit() + db_session.commit() @pytest.fixture(scope="function") -def with_default_country_code_and_default_currency(db, session): +def with_default_country_code_and_default_currency(db_session): # Add minimal settings setting = Setting() setting.default_currency = "GBP" setting.default_country_code = "GB" - session.add(setting) - session.commit() + db_session.add(setting) + db_session.commit() diff --git a/tests/test_subscribie.py b/tests/test_subscribie.py index 60936aaa3..5ddde8800 100644 --- a/tests/test_subscribie.py +++ b/tests/test_subscribie.py @@ -3,6 +3,7 @@ from contextlib import contextmanager from flask import appcontext_pushed, g +from pprint import pprint # noqa F401 @contextmanager @@ -15,7 +16,11 @@ def handler(sender, **kwargs): def test_admin_can_view_dashboard( - session, app, client, admin_session, with_default_country_code_and_default_currency + db_session, + app, + client, + admin_session, + with_default_country_code_and_default_currency, ): user = User.query.filter_by(email="admin@example.com").first() with user_set(app, user): @@ -23,9 +28,15 @@ def test_admin_can_view_dashboard( def test_admin_can_add_plan( - session, app, client, admin_session, with_default_country_code_and_default_currency + db_session, + app, + client, + admin_session, + with_default_country_code_and_default_currency, ): user = User.query.filter_by(email="admin@example.com").first() + interval_amount = 6.95 + sell_price = 10 with user_set(app, user): req = client.post( "/admin/add", @@ -42,12 +53,12 @@ def test_admin_can_add_plan( "description-0": "A long description", "image-0": "", "subscription-0": "yes", - "interval_amount-0": "6.95", + "interval_amount-0": interval_amount, "interval_unit-0": "monthly", "days_before_first_charge-0": "0", "trial_period_days-0": "0", "instant_payment-0": "yes", - "sell_price-0": "5", + "sell_price-0": sell_price, "note_to_buyer_message-0": "", "position-0": "", }, @@ -62,13 +73,18 @@ def test_admin_can_add_plan( assert "Roasted by us" in req.data.decode("utf-8") assert "Monthly delievey" in req.data.decode("utf-8") assert "Highest Quality" in req.data.decode("utf-8") - assert 'name="sell_price-0" value="5.0" id="sell_price-0"' in req.data.decode( - "utf-8" + assert ( + 'name="sell_price-0" value="10.0" id="sell_price-0"' + in req.data.decode("utf-8") ) def test_admin_can_add_choice_group( - session, app, client, admin_session, with_default_country_code_and_default_currency + db_session, + app, + client, + admin_session, + with_default_country_code_and_default_currency, ): user = User.query.filter_by(email="admin@example.com").first() with user_set(app, user): @@ -83,7 +99,11 @@ def test_admin_can_add_choice_group( def test_admin_can_add_an_option_to_a_choice_group( - session, app, client, admin_session, with_default_country_code_and_default_currency + db_session, + app, + client, + admin_session, + with_default_country_code_and_default_currency, ): user = User.query.filter_by(email="admin@example.com").first() with user_set(app, user): @@ -114,13 +134,13 @@ def admin_session(client, with_shop_owner): sess["user_id"] = "admin@example.com" -def test_homepage(session, client, with_default_country_code_and_default_currency): +def test_homepage(db_session, client, with_default_country_code_and_default_currency): req = client.get("/") assert req.status_code == 200 def test_magic_login_submission_as_shop_owner( - session, client, with_shop_owner, with_default_country_code_and_default_currency + db_session, client, with_shop_owner, with_default_country_code_and_default_currency ): """This does not test a successful login. Only that the login form submission is working. @@ -133,7 +153,7 @@ def test_magic_login_submission_as_shop_owner( def test_shop_owner_forgot_password_submission( - session, client, with_shop_owner, with_default_country_code_and_default_currency + db_session, client, with_shop_owner, with_default_country_code_and_default_currency ): """Test if forgot password form submission works for shop owner""" req = client.post( @@ -144,22 +164,24 @@ def test_shop_owner_forgot_password_submission( assert b"We've sent you an email with a password reset link" in req.data -def test_apiv1_pages(session, client, with_default_country_code_and_default_currency): +def test_apiv1_pages( + db_session, client, with_default_country_code_and_default_currency +): req = client.get("/api/v1/pages") assert req.status_code == 200 assert req.json == [] -def test_user_model(session): +def test_user_model(db_session): user = User() user.email = "test@example.com" - session.add(user) - session.commit() + db_session.add(user) + db_session.commit() assert user.id > 0 def test_create_PriceList_and_price_list_rule_percent_discount( - session, + db_session, app, client, admin_session, @@ -180,47 +202,13 @@ def test_create_PriceList_and_price_list_rule_percent_discount( priceList.rules.append(rule) database.session.add(priceList) database.session.commit() - print(PriceList.query.all()[0].__dict__) - price_list = PriceList.query.first() - - # Create a plan - title = "Coffee Delux" - interval_amount = 6.95 - sell_price = 10000 - user = User.query.filter_by(email="admin@example.com").first() - with user_set(app, user): - req = client.post( - "/admin/add", - follow_redirects=True, - data={ - "company_name": "Coffee Castle", - "slogan": "None", - "email": "admin@example.com", - "title-0": title, - "selling_points-0-0": "Roasted by us", - "selling_points-0-1": "Monthly delievey", - "selling_points-0-3": "Highest Quality", - "description-0": "A long description", - "image-0": "", - "subscription-0": "yes", - "interval_amount-0": interval_amount, - "interval_unit-0": "monthly", - "days_before_first_charge-0": "0", - "trial_period_days-0": "0", - "instant_payment-0": "yes", - "sell_price-0": sell_price, - "note_to_buyer_message-0": "", - "position-0": "", - }, - ) - assert "Plan added." in req.data.decode("utf-8") - plan = Plan.query.first() - plan.price_lists.append(price_list) + plan.price_lists.append(priceList) + database.session.add(plan) database.session.commit() print(f"Ensure price rule is applied {percent_discount}% Discount") - expected_sell_price = 750000 + expected_sell_price = 750 expected_inverval_amount = 522 assert plan.getPrice("USD")[0] == expected_sell_price assert plan.getPrice("USD")[1] == expected_inverval_amount