diff --git a/migrations/versions/535f7111e416_merge_9083452d3a80_bb76d2149316.py b/migrations/versions/535f7111e416_merge_9083452d3a80_bb76d2149316.py new file mode 100644 index 00000000..af2a3786 --- /dev/null +++ b/migrations/versions/535f7111e416_merge_9083452d3a80_bb76d2149316.py @@ -0,0 +1,24 @@ +"""Merge 9083452d3a80 bb76d2149316 + +Revision ID: 535f7111e416 +Revises: 9083452d3a80, bb76d2149316 +Create Date: 2024-02-17 19:03:56.495742 + +""" +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision = '535f7111e416' +down_revision = ('9083452d3a80', 'bb76d2149316') +branch_labels = None +depends_on = None + + +def upgrade(): + pass + + +def downgrade(): + pass diff --git a/migrations/versions/9083452d3a80_add_catagory_user_associations.py b/migrations/versions/9083452d3a80_add_catagory_user_associations.py new file mode 100644 index 00000000..07387ceb --- /dev/null +++ b/migrations/versions/9083452d3a80_add_catagory_user_associations.py @@ -0,0 +1,36 @@ +"""add catagory_user_associations + +Revision ID: 9083452d3a80 +Revises: 48074e6225c6 +Create Date: 2024-02-16 21:38:11.302398 + +""" +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision = "9083452d3a80" +down_revision = "48074e6225c6" +branch_labels = None +depends_on = None + + +def upgrade(): + op.create_table( + "catagory_user_associations", + sa.Column("category_uuid", sa.String(), nullable=True), + sa.Column("user_id", sa.String(), nullable=True), + sa.ForeignKeyConstraint( + ["category_uuid"], + ["category.uuid"], + ), + sa.ForeignKeyConstraint( + ["user_id"], + ["user.id"], + ), + ) + + +def downgrade(): + pass diff --git a/migrations/versions/ac23e0d75b27_primary_keys_association_table_plan_to_.py b/migrations/versions/ac23e0d75b27_primary_keys_association_table_plan_to_.py new file mode 100644 index 00000000..f4c9c85a --- /dev/null +++ b/migrations/versions/ac23e0d75b27_primary_keys_association_table_plan_to_.py @@ -0,0 +1,47 @@ +"""primary keys association_table_plan_to_users + +Revision ID: ac23e0d75b27 +Revises: ba57f5aeba5f +Create Date: 2024-02-24 16:15:56.898449 + +""" +from alembic import op +import sqlalchemy as sa +from sqlalchemy import MetaData, Table, Column, Integer, String + + +# revision identifiers, used by Alembic. +revision = "ac23e0d75b27" +down_revision = "ba57f5aeba5f" +branch_labels = None +depends_on = None + + +def upgrade(): + meta = MetaData() + some_table = Table( + "plan_user_associations", + meta, + Column("plan_uuid", String, nullable=False), + Column("user_id", Integer, nullable=False), + sa.ForeignKeyConstraint( + ["plan_uuid"], + ["plan.uuid"], + ), + sa.ForeignKeyConstraint( + ["user_id"], + ["user.id"], + ), + sa.PrimaryKeyConstraint("plan_uuid", "user_id"), + ) + + with op.batch_alter_table( + "plan_user_associations", copy_from=some_table + ) as batch_op: + batch_op.create_primary_key( + "pk_plan_user_association", ["plan_uuid", "user_id"] + ) + + +def downgrade(): + pass diff --git a/migrations/versions/ba57f5aeba5f_merge_535f7111e416_d80effffd83d.py b/migrations/versions/ba57f5aeba5f_merge_535f7111e416_d80effffd83d.py new file mode 100644 index 00000000..a57276f9 --- /dev/null +++ b/migrations/versions/ba57f5aeba5f_merge_535f7111e416_d80effffd83d.py @@ -0,0 +1,24 @@ +"""Merge 535f7111e416 d80effffd83d + +Revision ID: ba57f5aeba5f +Revises: 535f7111e416, d80effffd83d +Create Date: 2024-02-19 20:09:25.499959 + +""" +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision = 'ba57f5aeba5f' +down_revision = ('535f7111e416', 'd80effffd83d') +branch_labels = None +depends_on = None + + +def upgrade(): + pass + + +def downgrade(): + pass 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 new file mode 100644 index 00000000..7b6130e5 --- /dev/null +++ b/migrations/versions/bb76d2149316_add_parent_plan_revision_uuid_to_plan.py @@ -0,0 +1,27 @@ +"""add parent_plan_revision_uuid to plan + +Revision ID: bb76d2149316 +Revises: 48074e6225c6 +Create Date: 2024-02-16 23:22:02.230866 + +""" +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision = "bb76d2149316" +down_revision = "48074e6225c6" +branch_labels = None +depends_on = None + + +def upgrade(): + with op.batch_alter_table("plan", schema=None) as batch_op: + batch_op.add_column( + sa.Column("parent_plan_revision_uuid", sa.String(), nullable=True) + ) + + +def downgrade(): + pass diff --git a/migrations/versions/c4c7bbe1acdc_feature_can_assign_users_to_plans_.py b/migrations/versions/c4c7bbe1acdc_feature_can_assign_users_to_plans_.py new file mode 100644 index 00000000..8e411507 --- /dev/null +++ b/migrations/versions/c4c7bbe1acdc_feature_can_assign_users_to_plans_.py @@ -0,0 +1,32 @@ +"""feature_can_assign_users_to_plans settings + +Revision ID: c4c7bbe1acdc +Revises: ac23e0d75b27 +Create Date: 2024-02-26 09:06:42.508631 + +""" +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision = "c4c7bbe1acdc" +down_revision = "ac23e0d75b27" +branch_labels = None +depends_on = None + + +def upgrade(): + with op.batch_alter_table("setting", schema=None) as batch_op: + batch_op.add_column( + sa.Column( + "feature_can_assign_users_to_plans", + sa.Boolean(), + nullable=True, + default=False, + ) + ) + + +def downgrade(): + pass diff --git a/migrations/versions/d80effffd83d_add_association_table_plan_to_users.py b/migrations/versions/d80effffd83d_add_association_table_plan_to_users.py new file mode 100644 index 00000000..988db015 --- /dev/null +++ b/migrations/versions/d80effffd83d_add_association_table_plan_to_users.py @@ -0,0 +1,47 @@ +"""add association_table_plan_to_users + +Revision ID: d80effffd83d +Revises: 48074e6225c6 +Create Date: 2024-02-12 12:24:44.482877 + +Why? + +Some shop owners want/need to assign managers (users) to +plans. For example large clubs or membership organisations which +assign a 'manager' to one or more plans. + +The plan_user_associations table begins to make possible the +assignment of Users to Plans. Recall that Users (see class User +in models.py) is a shop owner (admin) which may login to the +Subscribie application. + +""" +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision = "d80effffd83d" +down_revision = "48074e6225c6" +branch_labels = None +depends_on = None + + +def upgrade(): + op.create_table( + "plan_user_associations", + sa.Column("plan_uuid", sa.String(), nullable=True), + sa.Column("user_id", sa.String(), nullable=True), + sa.ForeignKeyConstraint( + ["plan_uuid"], + ["plan.uuid"], + ), + sa.ForeignKeyConstraint( + ["user_id"], + ["user.id"], + ), + ) + + +def downgrade(): + pass diff --git a/subscribie/blueprints/admin/__init__.py b/subscribie/blueprints/admin/__init__.py index 23f753b8..7c675066 100644 --- a/subscribie/blueprints/admin/__init__.py +++ b/subscribie/blueprints/admin/__init__.py @@ -14,6 +14,7 @@ request, session, Response, + g, ) from markupsafe import Markup, escape import jinja2 @@ -73,6 +74,7 @@ Category, UpcomingInvoice, Document, + association_table_plan_to_users, ) from .subscription import update_stripe_subscription_statuses from .stats import ( @@ -494,8 +496,15 @@ def dashboard(): shop_default_country_code = get_shop_default_country_code() saas_url = current_app.config.get("SAAS_URL") + # Query user object including archived plans, see models.py + # _do_orm_execute_hide_archived for details. + user_id = g.user.id + with current_app.app_context(): + database.session.info["include_archived"] = True + user = User.query.where(User.id == user_id).first() return render_template( "admin/dashboard.html", + user=user, stripe_connected=stripe_connected, integration=integration, loadedModules=getLoadedModules(), @@ -544,7 +553,23 @@ def edit(): plan_requirements = PlanRequirements() draftPlan.cancel_at = cancel_at draftPlan.uuid = str(uuid.uuid4()) + draftPlan.parent_plan_revision_uuid = plan.uuid draftPlan.requirements = plan_requirements + + # Preserve / update managers assigned to plan + managers = [] + managersUserIds = request.form.getlist(f"managers-index-{index}") + for userId in managersUserIds: + user = User.query.get(int(userId)) + managers.append(user) + draftPlan.users.clear() + draftPlan.users.extend(managers) + + # Update plan-revisions with managers + for planRevision in plan.get_plan_revisions(): + planRevision.users.clear() + planRevision.users.extend(managers) + # Preserve primary icon if exists draftPlan.primary_icon = plan.primary_icon @@ -645,7 +670,8 @@ def edit(): database.session.commit() # Save flash("Plan(s) updated.") return redirect(url_for("admin.edit")) - return render_template("admin/edit.html", plans=plans, form=form) + users = User.query.all() + return render_template("admin/edit.html", plans=plans, form=form, users=users) @admin.route("/add", methods=["GET", "POST"]) @@ -653,7 +679,16 @@ def edit(): def add_plan(): form = PlansForm() if form.validate_on_submit(): + # Get managers if assigned + users = User.query.all() + managers = [] + for user in users: + if request.form.get(f"user-{user.id}"): + user = User.query.get(int(request.form.get(f"user-{user.id}"))) + managers.append(user) + draftPlan = Plan() + draftPlan.users.extend(managers) database.session.add(draftPlan) plan_requirements = PlanRequirements() draftPlan.requirements = plan_requirements @@ -758,7 +793,8 @@ def add_plan(): database.session.commit() flash("Plan added.") return redirect(url_for("admin.dashboard")) - return render_template("admin/add_plan.html", form=form) + users = User.query.all() + return render_template("admin/add_plan.html", form=form, users=users) @admin.route("/delete", methods=["GET"]) @@ -788,6 +824,110 @@ def delete_plan_by_uuid(uuid): return render_template("admin/delete_plan_choose.html", plans=plans) +@admin.route("assign-manager-to-plan", methods=["POST"]) +@login_required +def assign_manager_to_plan(): + """ + assign user (manager) to a plan. + + Some shop owners want/need to assign managers (users) to + plans. For example large clubs or membership organisations which + assign a 'manager' to one or more plans. + + The plan_user_associations table begins to make possible the + assignment of Users to Plans. Recall that Users (see class User + in models.py) is a shop owner (admin) which may login to the + Subscribie application. + """ + managers = [] + chosen_user_ids = request.form.getlist("user_id") + for chosen_user_id in chosen_user_ids: + user = User.query.where(User.id == chosen_user_id).first() + managers.append(user) + plan_uuid = request.form.get("plan_uuid") + plan = ( + database.session.query(Plan) + .execution_options(include_archived=True) + .where(Plan.uuid == plan_uuid) + .first() + ) + + plan.users.extend(managers) + + flash("Manager(s) have assigned to the plan") + + database.session.commit() + + return redirect(url_for("admin.subscribers", action="show_active")) + + +@admin.route("assign-managers-to-plan") +@login_required +def assign_managers_to_plan(): + """ + assign users (managers) to a plan. + + Some shop owners want/need to assign managers (users) to + plans. For example large clubs or membership organisations which + assign a 'manager' to one or more plans. + + The plan_user_associations table begins to make possible the + assignment of Users to Plans. Recall that Users (see class User + in models.py) is a shop owner (admin) which may login to the + Subscribie application. + """ + with current_app.app_context(): + database.session.info["include_archived"] = True + users = User.query.execution_options(include_archived=True).all() + return render_template("admin/assign_managers_to_plan.html", users=users) + + +@admin.route("/assign-managers-to-plans//assign-plan", methods=["GET", "POST"]) +@login_required +def user_assign_to_plans(user_id): + """ + Note the use of application context to include archived plans + Remember that "plan in user.plans" won't be true if, for example, + 'plan' is defined outside of the new/enclused application context. + """ + if request.method == "POST": + with current_app.app_context(): + database.session.info["include_archived"] = True + plans = Plan.query.execution_options(include_archived=True).all() + user = User.query.get(user_id) + # Remove if not selected + for plan in plans: + log.info(f"Checking if plan {plan} is in user {user}") + if plan in user.plans: + user.plans.remove(plan) + + # If all deselected, remove all plan assignments + if len(request.form.getlist("assign")) == 0: + user.plans.clear() + + # Assign requested managers to plan + for plan_id in request.form.getlist("assign"): + plan = Plan.query.execution_options(include_archived=True).get(plan_id) + plan.users.append(user) + + database.session.commit() + flash("User has been assigned the selected plan(s) as a manager of them") + return redirect(url_for("admin.assign_managers_to_plan")) + + plans = Plan.query.execution_options(include_archived=True).all() + user = ( + User.query.execution_options(include_archived=True) + .where(User.id == user_id) + .first() + ) + + return render_template( + "admin/user_assign_plan.html", + user=user, + plans=plans, + ) + + @admin.route("/list-documents", methods=["get"]) @login_required def list_documents(): @@ -1264,6 +1404,7 @@ def inject_template_globals(): def subscribers(): action = request.args.get("action") show_active = action == "show_active" + show_plans_i_manage = action == "show_plans_i_manage" subscriber_email = request.args.get("subscriber_email", None) subscriber_name = request.args.get("subscriber_name", None) @@ -1286,13 +1427,48 @@ def subscribers(): elif action == "show_one_off_payments": query = query.filter(Person.subscriptions) query = query.where(Subscription.stripe_subscription_id == None) # noqa: E711 + elif action == "show_plans_i_manage": + """ + Only show plans for which the current logged in user is a manager + of. This orm query runs: + + SELECT plan.title, person.given_name, plan_user_associations.user_id, user.email, user.id -- # noqa: E501 + FROM person + JOIN subscription ON + subscription.person_id = person.id + JOIN plan ON + subscription.sku_uuid = plan.uuid + JOIN plan_user_associations ON + plan.uuid = plan_user_associations.plan_uuid + JOIN user ON + user.id = plan_user_associations.user_id + + WHERE plan_user_associations.user_id = 1 + """ + query = ( + query.join(Subscription, Person.id == Subscription.person_id) + .join(Plan, Subscription.sku_uuid == Plan.uuid) + .join( + association_table_plan_to_users, + Plan.uuid == association_table_plan_to_users.c.plan_uuid, + ) + .where(association_table_plan_to_users.c.user_id == g.user.id) + ) people = query.order_by(desc(Person.created_at)) + users = User.query.all() + user_id = g.user.id + with current_app.app_context(): + database.session.info["include_archived"] = True + user = User.query.where(User.id == user_id).first() return render_template( "admin/subscribers.html", people=people.all(), + users=users, + user=user, show_active=show_active, + show_plans_i_manage=show_plans_i_manage, action=action, ) @@ -2058,6 +2234,34 @@ def donations_enabled_settings(): return render_template("admin/settings/donations_enabled.html", settings=settings) +@admin.route("/assign-users-to-plans-feature-toggle", methods=["GET", "POST"]) +@login_required +def feature_assign_users_to_plans_enable_disable(): + settings = Setting.query.first() # Get current shop settings + if settings.feature_can_assign_users_to_plans is None: + settings.feature_can_assign_users_to_plans = False + database.session.commit() + + if request.method == "POST": + if int(request.form.get("feature_can_assign_users_to_plans", 0)) == 1: + settings.feature_can_assign_users_to_plans = 1 + effect = "enabled" + else: + settings.feature_can_assign_users_to_plans = 0 + effect = "disabled" + flash(f"Assign managers to plan feature has been {effect}") + database.session.commit() + return redirect( + url_for( + "admin.feature_assign_users_to_plans_enable_disable", settings=settings + ) + ) + return render_template( + "admin/settings/feature_assign_users_to_plans_enable_disable.html", + settings=settings, + ) + + @admin.route("/api-keys", methods=["GET", "POST"]) @login_required def show_api_keys(): diff --git a/subscribie/blueprints/admin/stats.py b/subscribie/blueprints/admin/stats.py index a75be6b3..c874fc7c 100644 --- a/subscribie/blueprints/admin/stats.py +++ b/subscribie/blueprints/admin/stats.py @@ -102,11 +102,14 @@ def get_number_of_transactions_with_donations(): 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"], - ) + subscription_cancellations = [] + try: + subscription_cancellations = stripe.Event.list( + stripe_account=connect_account_id, + limit=100, + types=["customer.subscription.deleted"], + ) + except (stripe._error.APIConnectionError, stripe._error.AuthenticationError) as e: + log.error(f"stripe._error.*: {e}") return len(subscription_cancellations) diff --git a/subscribie/blueprints/admin/templates/admin/add_plan.html b/subscribie/blueprints/admin/templates/admin/add_plan.html index 5255cd3a..236f4c11 100644 --- a/subscribie/blueprints/admin/templates/admin/add_plan.html +++ b/subscribie/blueprints/admin/templates/admin/add_plan.html @@ -236,6 +236,28 @@

Create a new plan

Time (e.g. midnight) + +
+
+ + +
+ + If you have lots of plans, and have multiple people in your business, you can + assign your shop admin(s) to those plans. + +
+
+ {% for user in users %} +
+ + +
+ {% endfor %} +
+ diff --git a/subscribie/blueprints/admin/templates/admin/assign_managers_to_plan.html b/subscribie/blueprints/admin/templates/admin/assign_managers_to_plan.html new file mode 100644 index 00000000..f6700c67 --- /dev/null +++ b/subscribie/blueprints/admin/templates/admin/assign_managers_to_plan.html @@ -0,0 +1,68 @@ +{% extends "admin/layout.html" %} +{% block title %} {{ title }} {% endblock %} + +{% block body %} + +

Assign manager(s) to plans

+ +
+ +
+ +
+
+
+

Allow managers to quickly see plans they're responsible for

+
+

Some larger organisations like to assign people to 'manage' Subscribers who are subscribed to specific plans.
+ For example, if you're a sports club or large membership organisation you can assign specific staff to + become 'managers' of specific plans. This helps them see only the Subscribers they are responsible for.

+ +

+ Once you do so, when they login to their account, the visibility of plans they manage + will be highlighted, and by default any Subscribers they are not a manager of given less Precedence, based + on the Plans they are assigned to as a manager.

+
+

+ Please note this is not a security feature- it does not stop managers being able to access + all plans, it simply makes it easier for a manager of specific plans to quickly see the Subscribers + who are subscribed to the plans they are a manager of. +

+ +

Select a user to assign plan(s) to them.

+ +
+ + + + + + + + {% for user in users %} + + + + + + {% endfor %} +
UserNumber of plans managingAction
+ {{ user.email}} + + {{ user.plans | length }} + + + Assign Plan(s) + +
+ +
+
+
+ +{% endblock body %} diff --git a/subscribie/blueprints/admin/templates/admin/dashboard.html b/subscribie/blueprints/admin/templates/admin/dashboard.html index c5f26a3b..d103cca0 100644 --- a/subscribie/blueprints/admin/templates/admin/dashboard.html +++ b/subscribie/blueprints/admin/templates/admin/dashboard.html @@ -15,6 +15,7 @@

Manage My Shop

Checklist

+ Assign managers to plan

Make sure everything's in order. If tasks appear below, then you'll need to complete them to get the most out of your shop. @@ -34,7 +35,12 @@

Checklist

Stats

-

You have: {{ num_active_subscribers }} subscribers with active subscriptions.

+ {% if user.plans | length > 0 %} +

You manage {{ user.plans | length }} plan(s). Show my subscribers

+ {% endif %} +
+

You have: {{ num_active_subscribers }} subscribers with active subscriptions. +

You've had: {{ num_recent_subscription_cancellations }} subscription cancellations in the last 30 days.

You've had: {{ num_subscribers }} subscribers since starting your shop.

You've had: {{ num_one_off_purchases }} people buy a one-off item from your shop.

@@ -538,6 +544,10 @@

Templates

href="{{ url_for('admin.change_thank_you_url') }}"> Change Thank You Page/Payment Successful Page URL + + Assign Manager(s) to plans feature (enable/disable) +
diff --git a/subscribie/blueprints/admin/templates/admin/edit.html b/subscribie/blueprints/admin/templates/admin/edit.html index 7b3050fb..5ca143ea 100644 --- a/subscribie/blueprints/admin/templates/admin/edit.html +++ b/subscribie/blueprints/admin/templates/admin/edit.html @@ -107,7 +107,7 @@

Edit Plans

- +
@@ -233,11 +233,44 @@

Edit Plans

Then, you can send a link to your customer(s) to sign-up to the private plan.
+ + +
+
+ 0 %} checked {% endif %} /> + + +
+ + If you have lots of plans, and have multiple people in your business, you may want to + assign your shop admin(s) to those plans. + +
+ +
+
+ {% set outer_loop = loop %} + {% for user in users %} +
+ + +
+ {% endfor %} +
+
+ + Share URL - + diff --git a/subscribie/blueprints/admin/templates/admin/settings/feature_assign_users_to_plans_enable_disable.html b/subscribie/blueprints/admin/templates/admin/settings/feature_assign_users_to_plans_enable_disable.html new file mode 100644 index 00000000..6e37f3ca --- /dev/null +++ b/subscribie/blueprints/admin/templates/admin/settings/feature_assign_users_to_plans_enable_disable.html @@ -0,0 +1,54 @@ +{% extends "admin/layout.html" %} +{% block title %} {{ _('Assign Manager(s) to plans feature enable/disable') }} {% endblock %} + +{% block body %} + +

{{ _('Assign Manager(s) to plans feature enable/disable') }}

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

{{ _('Assign Manager(s) to plans feature') }}

+

{{ _('Enable this feature to allow you to assign admins to manage plans. If you have a lot of plans/subscribers, this makes it easier to divide the work of managing groups of subscribers. This is especially useful for large clubs and membership organisations where you might not know all of the subscribers in your organisaton, but other coworkers know and manage them.') }}

+ +
+
+
+ + +
+
+
+ + +
+
+
+ +
+
+ +
+ +
+
+
+ + + +{% endblock %} diff --git a/subscribie/blueprints/admin/templates/admin/subscribers.html b/subscribie/blueprints/admin/templates/admin/subscribers.html index 119214cf..32b4f83a 100644 --- a/subscribie/blueprints/admin/templates/admin/subscribers.html +++ b/subscribie/blueprints/admin/templates/admin/subscribers.html @@ -1,4 +1,5 @@ {% extends "admin/layout.html" %} +{% from 'macros/plan_assign_managers_form.html' import plan_assign_managers_form %} {% block title %} Subscribers {% endblock %} {% block body %} @@ -16,22 +17,33 @@

My Subscribers

- {% if show_active %} - Show all - {% else %} - Show Active - {%endif %} + Show all + Show all Active {% if settings.donations_enabled %} {% if action == "show_donors" %} - Show all + Show all {% else %} Show Donors {% endif %} {% endif %} + Show only Subscribers with plans I manage

Search...

+ {% if show_plans_i_manage %} +
+

Showing only subscribers with plans you are a manager of.

+
+ {% endif %} + + {% if user.plans | length == 0 and settings.feature_can_assign_users_to_plans %} +
+ You don't currently have any plans assigned to your user. To assign plans to your user login, + Edit an existing plan or Create a new plan and click 'Managers' when editing or adding + a plan to assign manager(s). +
+ {% endif %} {% if request.args.get("subscriber_email") or request.args.get("subscriber_name") %}
You currently have a search filter active. Click here to show all subscribers again. @@ -83,6 +95,13 @@

Search...

{% for subscription in person.subscriptions %}
  • + {% if subscription.plan.users| length == 0 %} +
    +
    Assign a manager
    + No manager(s) are assigned to this plan. + {{ plan_assign_managers_form(subscription.plan, users) }} +
    + {% endif %}
    • Title: {{ subscription.plan.title }} @@ -213,6 +232,13 @@

      Search...

    {% endif %}
  • + {% if subscription.plan.users | length > 0 %} +
  • Manager(s): + {{ plan_assign_managers_form(subscription.plan, users) }} +
  • + {% endif %} + +
    @@ -275,7 +301,7 @@

    Search...

    + +{% endblock body %} diff --git a/subscribie/blueprints/admin/templates/macros/plan_assign_managers_form.html b/subscribie/blueprints/admin/templates/macros/plan_assign_managers_form.html new file mode 100644 index 00000000..06840f2c --- /dev/null +++ b/subscribie/blueprints/admin/templates/macros/plan_assign_managers_form.html @@ -0,0 +1,19 @@ +{% macro plan_assign_managers_form(plan, users) -%} + + + {% for user in users %} +
    + + +
    + {% endfor %} + +
    + +
    +
    + +{%- endmacro %} diff --git a/subscribie/forms.py b/subscribie/forms.py index d96c8ad4..a2481a74 100644 --- a/subscribie/forms.py +++ b/subscribie/forms.py @@ -79,6 +79,9 @@ class PlansForm(StripWhitespaceForm): description = FieldList( StringField("Description", [validators.optional()], default=False) ) + managers = FieldList( + BooleanField("Managers", [validators.optional()]) + ) class ChoiceGroupForm(FlaskForm): diff --git a/subscribie/models.py b/subscribie/models.py index 697424c2..5756a36a 100644 --- a/subscribie/models.py +++ b/subscribie/models.py @@ -1,3 +1,4 @@ +from __future__ import annotations import logging from sqlalchemy.orm import relationship from sqlalchemy.orm.query import Query @@ -6,6 +7,11 @@ from sqlalchemy import event from sqlalchemy import Column from sqlalchemy import Boolean +from sqlalchemy import text +from sqlalchemy import MetaData +from sqlalchemy.orm import Mapped +from typing import List + from typing import Optional from datetime import datetime @@ -33,9 +39,46 @@ log = logging.getLogger(__name__) +metadata_obj = MetaData() + @event.listens_for(database.session, "do_orm_execute") def _do_orm_execute_hide_archived(orm_execute_state): + """ + By default, archived plans are excluded from all queries. + So when you want to *include* archived plans, there are two + ways to achieve that: + + Example toggling back *on* in include archived plans + + - Method 1: Use query.execution_options(include_archived=1) + - Method 2: Object based + + Object based: Create a new app context (because sqlalchemy's listens_for event + will apply listeners to all Session instances globally), creating a + new app contexts side-steps this. + + from subscribie.database import database + from subscribie.models import User + from subscribie import create_app + + app = create_app() + + with app.app_context(): + # Add include_archived to the session.info dict + database.session.info["include_archived"] = True + + # Perform query + user = User.query.all()[0] + print(user.plans) + + + + See also: + - https://flask.palletsprojects.com/en/2.3.x/appcontext/ + - https://stackoverflow.com/q/74900879 + + """ if ( orm_execute_state.is_select and not orm_execute_state.is_column_load @@ -43,7 +86,9 @@ def _do_orm_execute_hide_archived(orm_execute_state): and not orm_execute_state.execution_options.get( "include_archived", False ) # noqa: E501 + and "include_archived" not in database.session.info ): + log.info("Archived plans are being excluded in orm execution by default") orm_execute_state.statement = orm_execute_state.statement.options( with_loader_criteria( HasArchived, @@ -55,11 +100,16 @@ def _do_orm_execute_hide_archived(orm_execute_state): @event.listens_for(Query, "before_compile", retval=True, bake_ok=True) def filter_archived(query): + return query + breakpoint() for desc in query.column_descriptions: entity = desc["entity"] if desc["type"] is Person and "archived-subscribers" in request.path: query = query.filter(entity.archived == 1) return query + # elif desc["type"] is User and "assign-managers-to-plan" in request.path: + # query = query.execution_options(include_archived=True) + # return query elif ( desc["type"] is Person and request.path != "/" @@ -108,25 +158,28 @@ class HasReadOnly(object): read_only = Column(Boolean, nullable=False, default=0) -class User(database.Model): - __tablename__ = "user" - 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) - active = database.Column(database.String) - login_token = database.Column(database.String) - password_reset_string = database.Column(database.String()) - password_expired = database.Column(database.Boolean(), default=0) - - def set_password(self, password): - self.password = generate_password_hash(password) +association_table_plan_to_users = database.Table( + "plan_user_associations", + database.Model.metadata, + database.Column( + "plan_uuid", database.String, ForeignKey("plan.uuid"), primary_key=True + ), + database.Column( + "user_id", database.String, ForeignKey("user.id"), primary_key=True + ), +) - def check_password(self, password): - return check_password_hash(self.password, password) - def __repr__(self): - return "".format(self.email) +association_table_plan_to_users_yolo = database.Table( + "yolo", + database.Model.metadata, + database.Column( + "plan_uuid", database.String, ForeignKey("plan.uuid"), primary_key=True + ), + database.Column( + "user_id", database.String, ForeignKey("user.id"), primary_key=True + ), +) class Person(database.Model, HasArchived): @@ -579,7 +632,16 @@ class Company(database.Model): database.Column("plan_id", database.Integer, ForeignKey("plan.id")), ) +""" +Some shop owners want/need to assign managers (users) to +plans. For example large clubs or membership organisations which +assign a 'manager' to one or more plans. +The plan_user_associations table begins to make possible the +assignment of Users to Plans. Recall that Users (see class User +in models.py) is a shop owner (admin) which may login to the +Subscribie application. +""" association_table_plan_to_price_lists = database.Table( "plan_price_list_associations", database.Column( @@ -621,9 +683,11 @@ class INTERVAL_UNITS(Enum): class Plan(database.Model, HasArchived): __tablename__ = "plan" + metadata_obj, id = database.Column(database.Integer(), primary_key=True) created_at = database.Column(database.DateTime, default=datetime.utcnow) uuid = database.Column(database.String(), default=uuid_string) + parent_plan_revision_uuid = database.Column(database.String(), default=uuid_string) title = database.Column(database.String()) description = database.Column(database.String()) interval_unit = database.Column(database.String()) # Charge interval @@ -656,6 +720,12 @@ class Plan(database.Model, HasArchived): price_lists = relationship( "PriceList", secondary=association_table_plan_to_price_lists ) + subscriptions = relationship( + "Subscription", primaryjoin="foreign(Subscription.sku_uuid)==Plan.uuid" + ) + users: Mapped[List["User"]] = relationship( + secondary=association_table_plan_to_users, back_populates="plans" + ) def getPrice(self, currency): """Returns a tuple of sell_price and interval_amount of the plan for @@ -715,6 +785,31 @@ def getPrice(self, currency): ) return sell_price, interval_amount + def get_plan_revisions(self): + textual_sql = text( + f""" + WITH RECURSIVE RevisionChain AS + (SELECT id, created_at, uuid, title, parent_plan_revision_uuid + FROM plan WHERE uuid = '{self.uuid}' + UNION ALL + SELECT p.id, p.created_at, p.uuid, p.title, p.parent_plan_revision_uuid + FROM plan p + INNER JOIN RevisionChain rc + ON p.uuid = rc.parent_plan_revision_uuid + ) + SELECT * FROM RevisionChain""" + ) + textual_sql = textual_sql.columns( + Plan.id, + Plan.created_at, + Plan.uuid, + Plan.title, + Plan.parent_plan_revision_uuid, + ) + orm_sql = database.select(Plan).from_statement(textual_sql) + plan_decendants = database.session.execute(orm_sql).scalars().all() + return plan_decendants + def applyRules(self, rules=[], context={}): """Apply pricelist rules to a given plan @@ -983,6 +1078,31 @@ def is_free(self): return False +class User(database.Model): + __tablename__ = "user" + database.Model.metadata, + 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) + active = database.Column(database.String) + login_token = database.Column(database.String) + password_reset_string = database.Column(database.String()) + password_expired = database.Column(database.Boolean(), default=0) + plans: Mapped[List["Plan"]] = relationship( + secondary=association_table_plan_to_users, back_populates="users", lazy=False + ) + + def set_password(self, password): + self.password = generate_password_hash(password) + + def check_password(self, password): + return check_password_hash(self.password, password) + + def __repr__(self): + return "".format(self.email) + + class Category(database.Model): __tablename__ = "category" id = database.Column(database.Integer(), primary_key=True) @@ -1173,6 +1293,9 @@ class Setting(database.Model): api_key_secret_test = database.Column(database.String(), default=None) donations_enabled = database.Column(database.Boolean(), default=False) custom_thank_you_url = database.Column(database.String(), default=None) + feature_can_assign_users_to_plans = database.Column( + database.Boolean(), default=False + ) class File(database.Model):