From a0821765ac4edf7fa5e87362a7b074fc58bcaf39 Mon Sep 17 00:00:00 2001 From: Jan Baykara Date: Thu, 23 Nov 2023 14:46:42 +0000 Subject: [PATCH 1/4] WIP signup flow --- app/apps.py | 4 + app/forms.py | 17 +- ...69_membershipplanpage_benefits_and_more.py | 115 + ...er_membershipplanpage_benefits_and_more.py | 47 + app/models/stripe.py | 35 +- app/models/wagtail.py | 124 +- app/settings/base.py | 8 +- app/slippers_autoload_components.py | 73 + app/templates/account/signup.html | 7 +- app/templates/app/blocks/book_grid_block.html | 6 +- app/templates/app/confirm_shipping.html | 32 +- app/templates/app/donate.html | 49 +- app/templates/app/includes/donation_form.html | 47 + .../app/includes/simple_book_card.html | 8 +- app/templates/app/signup/select_billing.html | 46 + .../app/signup/select_deliveries.html | 63 + app/templates/app/signup/select_donation.html | 16 + app/templates/app/signup/select_shipping.html | 25 + app/templates/app/signup/select_syllabus.html | 42 + app/templates/base.html | 23 +- app/templates/components.yaml | 1 + app/templates/components/breadcrumb_item.html | 11 + app/templates/components/signup_header.html | 25 + app/templatetags/books.py | 11 +- app/urls.py | 46 + app/utils/stripe.py | 26 + app/views.py | 387 ++- app/wagtail_hooks.py | 21 +- poetry.lock | 2611 +++++++++-------- pyproject.toml | 1 + 30 files changed, 2581 insertions(+), 1346 deletions(-) create mode 100644 app/migrations/0069_membershipplanpage_benefits_and_more.py create mode 100644 app/migrations/0070_alter_membershipplanpage_benefits_and_more.py create mode 100644 app/slippers_autoload_components.py create mode 100644 app/templates/app/includes/donation_form.html create mode 100644 app/templates/app/signup/select_billing.html create mode 100644 app/templates/app/signup/select_deliveries.html create mode 100644 app/templates/app/signup/select_donation.html create mode 100644 app/templates/app/signup/select_shipping.html create mode 100644 app/templates/app/signup/select_syllabus.html create mode 100644 app/templates/components.yaml create mode 100644 app/templates/components/breadcrumb_item.html create mode 100644 app/templates/components/signup_header.html diff --git a/app/apps.py b/app/apps.py index a8afd70a..b3c7396d 100644 --- a/app/apps.py +++ b/app/apps.py @@ -17,6 +17,10 @@ def ready(self): self.configure_posthog() self.configure_shopify() + from .slippers_autoload_components import register + + register() + def configure_shopify(self): stripe.api_key = djstripe.settings.djstripe_settings.STRIPE_SECRET_KEY stripe.api_version = "2020-08-27" diff --git a/app/forms.py b/app/forms.py index e84eb238..b38c5c58 100644 --- a/app/forms.py +++ b/app/forms.py @@ -578,7 +578,7 @@ def update_subscription(self, *args, **kwargs): # Create a form with a field for user_id, donation_amount, and on submission add a donation product to the subscription class DonationForm(forms.Form): - user_id = forms.IntegerField(widget=forms.HiddenInput) + user_id = forms.IntegerField(widget=forms.HiddenInput, required=False) donation_amount = forms.DecimalField( min_value=0, max_value=1000, @@ -714,3 +714,18 @@ def process_request(self, *args, **kwargs): else None, }, ) + + +### V2 flow forms + + +class SelectDeliveriesForm(forms.Form): + delivery_plan_id = forms.CharField(widget=forms.HiddenInput) + + +class SelectSyllabusForm(forms.Form): + syllabus_id = forms.CharField(widget=forms.HiddenInput) + + +class SelectPaymentPlanForm(forms.Form): + payment_plan_id = forms.CharField(widget=forms.HiddenInput) diff --git a/app/migrations/0069_membershipplanpage_benefits_and_more.py b/app/migrations/0069_membershipplanpage_benefits_and_more.py new file mode 100644 index 00000000..2bdf9950 --- /dev/null +++ b/app/migrations/0069_membershipplanpage_benefits_and_more.py @@ -0,0 +1,115 @@ +# Generated by Django 4.0.8 on 2023-11-22 14:58 + +import django.contrib.postgres.fields +import django.db.models.deletion +import modelcluster.fields +import wagtail.fields +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("wagtailcore", "0069_log_entry_jsonfield"), + ("app", "0068_bookpage_authors_bookpage_forward_by_and_more"), + ] + + operations = [ + migrations.AddField( + model_name="membershipplanpage", + name="benefits", + field=django.contrib.postgres.fields.ArrayField( + base_field=models.CharField(blank=True, max_length=100), + default=list, + help_text="List of pithy beneficial features of this plan", + size=None, + ), + preserve_default=False, + ), + migrations.AddField( + model_name="membershipplanpage", + name="display_in_quiz_flow", + field=models.BooleanField(default=False), + ), + migrations.AddField( + model_name="membershipplanprice", + name="benefits", + field=django.contrib.postgres.fields.ArrayField( + base_field=models.CharField(blank=True, max_length=100), + default=list, + help_text="List of pithy beneficial features of this plan", + size=None, + ), + preserve_default=False, + ), + migrations.AddField( + model_name="membershipplanprice", + name="title", + field=models.CharField(blank=True, max_length=150, null=True), + ), + migrations.AlterField( + model_name="membershipplanprice", + name="products", + field=modelcluster.fields.ParentalManyToManyField( + blank=True, + help_text="(Not used in the new flow). The stripe product that the user will be subscribed to. If multiple products are set here, then the user will be asked to pick which one they want, e.g. Classic or Contemporary books.", + to="app.lbcproduct", + ), + ), + migrations.CreateModel( + name="SyllabusPage", + fields=[ + ( + "page_ptr", + models.OneToOneField( + auto_created=True, + on_delete=django.db.models.deletion.CASCADE, + parent_link=True, + primary_key=True, + serialize=False, + to="wagtailcore.page", + ), + ), + ( + "sort_order", + models.IntegerField(blank=True, editable=False, null=True), + ), + ("description", wagtail.fields.RichTextField(blank=True, null=True)), + ( + "book_types", + models.CharField( + choices=[ + ("classic", "classic"), + ("contemporary", "contemporary"), + ("all-books", "all-books"), + ], + default="all-books", + help_text="Used to display relevant books", + max_length=100, + ), + ), + ( + "stripe_product", + models.ForeignKey( + help_text="The stripe product that the user will be subscribed to.", + on_delete=django.db.models.deletion.CASCADE, + related_name="syllabi", + to="app.lbcproduct", + ), + ), + ], + options={ + "abstract": False, + }, + bases=("wagtailcore.page", models.Model), + ), + migrations.AddField( + model_name="membershipplanpage", + name="syllabi", + field=modelcluster.fields.ParentalManyToManyField( + help_text="The syllabi available for this plan", + related_name="plans", + to="app.syllabuspage", + ), + ), + ] diff --git a/app/migrations/0070_alter_membershipplanpage_benefits_and_more.py b/app/migrations/0070_alter_membershipplanpage_benefits_and_more.py new file mode 100644 index 00000000..b163ed91 --- /dev/null +++ b/app/migrations/0070_alter_membershipplanpage_benefits_and_more.py @@ -0,0 +1,47 @@ +# Generated by Django 4.0.8 on 2023-11-22 15:25 + +import wagtail.fields +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("app", "0069_membershipplanpage_benefits_and_more"), + ] + + operations = [ + migrations.AlterField( + model_name="membershipplanpage", + name="benefits", + field=wagtail.fields.RichTextField( + blank=True, + help_text="List of pithy beneficial features of this plan", + null=True, + ), + ), + migrations.AlterField( + model_name="membershipplanprice", + name="benefits", + field=wagtail.fields.RichTextField( + blank=True, + help_text="List of pithy beneficial features of this plan", + null=True, + ), + ), + migrations.AlterField( + model_name="syllabuspage", + name="book_types", + field=models.CharField( + blank=True, + choices=[ + ("classic", "classic"), + ("contemporary", "contemporary"), + ("all-books", "all-books"), + ], + default="all-books", + help_text="Used to display relevant books", + max_length=100, + ), + ), + ] diff --git a/app/models/stripe.py b/app/models/stripe.py index c02b81ae..f11ea2bc 100644 --- a/app/models/stripe.py +++ b/app/models/stripe.py @@ -26,7 +26,7 @@ from app.utils.stripe import ( DONATION_PRODUCT_NAME, SHIPPING_PRODUCT_NAME, - get_donation_product, + create_donation_line_item, get_primary_product_for_djstripe_subscription, ) @@ -127,20 +127,13 @@ def upsert_regular_donation(self, amount: float, metadata: Dict[str, Any] = dict # Create a new donation SI with the amount arg plan = self.items.first().plan items.append( - { - "price_data": { - "unit_amount_decimal": int(amount * 100), - "product": get_donation_product().id, - "metadata": {**metadata}, - # Mirror details from another SI - "currency": plan.currency, - "recurring": { - "interval": plan.interval, - "interval_count": plan.interval_count, - }, - }, - "quantity": 1, - } + create_donation_line_item( + amount=amount, + interval_count=plan.interval_count, + interval=plan.interval, + currency=plan.currency, + metadata=metadata, + ) ) subscription = stripe.Subscription.modify( @@ -330,6 +323,10 @@ def next_billing_date(self): @register_snippet class LBCProduct(djstripe.models.Product): + """ + Prices are for v1 flow; not applicable to v2 flow + """ + class Meta: proxy = True @@ -360,12 +357,12 @@ def basic_price(self): autocomplete_search_field = "name" def autocomplete_label(self): - from app.models import MembershipPlanPrice + # from app.models import MembershipPlanPrice - price = MembershipPlanPrice.objects.filter(products=self).first() + # price = MembershipPlanPrice.objects.filter(products=self).first() s = getattr(self, self.autocomplete_search_field) - if price: - return f"{s} (applies to price: {price})" + # if price: + # return f"{s} (applies to price: {price})" return s def get_prices_for_country(self, iso_a2: str, **kwargs): diff --git a/app/models/wagtail.py b/app/models/wagtail.py index 9da77a14..c6b77f7b 100644 --- a/app/models/wagtail.py +++ b/app/models/wagtail.py @@ -7,6 +7,7 @@ from urllib.parse import urlencode import djstripe.models +import orjson import shopify import stripe from django.conf import settings @@ -59,7 +60,6 @@ from app.utils.cache import django_cached from app.utils.shopify import metafields_to_dict from app.utils.stripe import create_shipping_zone_metadata, get_shipping_product -import orjson from .stripe import LBCProduct, ShippingZone @@ -187,6 +187,16 @@ class Interval(models.TextChoices): interval_count = models.IntegerField(default=1, null=False, blank=True) + ### v2 flow + title = models.CharField(max_length=150, blank=True, null=True) + benefits = RichTextField( + features=["ul"], + help_text="List of pithy beneficial features of this plan", + null=True, + blank=True, + ) + ### /v2 + description = RichTextField(null=True, blank=True) free_shipping_zones = ParentalManyToManyField( @@ -204,21 +214,16 @@ class Interval(models.TextChoices): products = ParentalManyToManyField( LBCProduct, blank=True, - help_text="The stripe product that the user will be subscribed to. If multiple products are set here, then the user will be asked to pick which one they want, e.g. Classic or Contemporary books.", + help_text="(For V1-only signup flow.) The stripe product that the user will be subscribed to. If multiple products are set here, then the user will be asked to pick which one they want, e.g. Classic or Contemporary books.", ) panels = [ + FieldPanel("title", classname="full title"), FieldRowPanel( [ FieldPanel("price"), ] ), - MultiFieldPanel( - [ - AutocompletePanel("products", target_model=LBCProduct), - ], - heading="Product", - ), FieldRowPanel( [ FieldPanel("interval_count"), @@ -238,6 +243,14 @@ class Interval(models.TextChoices): classname="full", help_text="Displayed to visitors who are considering purchasing a plan at this price.", ), + FieldPanel("benefits"), + MultiFieldPanel( + [ + AutocompletePanel("products", target_model=LBCProduct), + ], + heading="V1 signup flow", + classname="collapsible collapsed", + ), ] @property @@ -321,6 +334,12 @@ def equivalent_monthly_price_string(self) -> str: return f"{s} + p&p" return s + @property + def price_string_uk_pp(self) -> str: + return self.price_string_including_shipping( + ShippingZone.get_for_country(iso_a2="GB") + ) + def __str__(self) -> str: return f"{self.price_string} on {self.plan}" @@ -469,6 +488,40 @@ def url(self, country_id=None): ) +book_types = [ + ("classic", "classic"), + ("contemporary", "contemporary"), + ("all-books", "all-books"), +] + + +@register_snippet +class SyllabusPage(Page, Orderable, ClusterableModel): + # title + description = RichTextField(null=True, blank=True) + book_types = models.CharField( + max_length=100, + choices=book_types, + default="all-books", + help_text="Used to display relevant books", + blank=True, + ) + stripe_product = models.ForeignKey( + LBCProduct, + on_delete=models.CASCADE, + related_name="syllabi", + help_text="The stripe product that the user will be subscribed to.", + ) + + # plan = ParentalManyToManyField("app.MembershipPlanPage", related_name="syllabi", help_text="The delivery plans that this syllabus is available on.") + + content_panels = Page.content_panels + [ + FieldPanel("description"), + FieldPanel("book_types"), + AutocompletePanel("stripe_product", target_model=LBCProduct), + ] + + class PlanTitleBlock(blocks.StructBlock): class Meta: template = "app/blocks/plan_title_block.html" @@ -490,11 +543,7 @@ class Meta: class BookTypeChoice(blocks.ChoiceBlock): - choices = [ - ("classic", "classic"), - ("contemporary", "contemporary"), - ("all-books", "all-books"), - ] + choices = book_types class Meta: default = "all-books" @@ -723,10 +772,9 @@ def get_args_for_page(cls, product, metafields): description=product.attributes.get("body_html"), image_url=image_urls[0] if len(images) > 0 else "", image_urls=image_urls, - cached_price=cls.get_lowest_price(product) + cached_price=cls.get_lowest_price(product), ) - @classmethod def get_root_page(cls): """ @@ -776,7 +824,7 @@ def sync_from_shopify_product_id(cls, shopify_product_id): metafields = metafields_to_dict(metafields) if cls.objects.filter(shopify_product_id=shopify_product_id).exists(): return cls.update_instance_for_product(product, metafields) - else: + else: return cls.create_instance_for_product(product, metafields) @property @@ -1220,6 +1268,7 @@ def get_args_for_page(cls, product, metafields): class Meta: ordering = ["-published_date"] + def metafields_array_to_list(arg): value = [] if isinstance(arg, str): @@ -1231,10 +1280,26 @@ def metafields_array_to_list(arg): else: return [] + @method_decorator(cache_page, name="serve") class MembershipPlanPage(WagtailCacheMixin, ArticleSeoMixin, Page): parent_page_types = ["app.HomePage"] + ### v2 signup flow fields + display_in_quiz_flow = models.BooleanField(default=False) + benefits = RichTextField( + features=["ul"], + help_text="List of pithy beneficial features of this plan", + null=True, + blank=True, + ) + syllabi = ParentalManyToManyField( + SyllabusPage, + related_name="plans", + help_text="The syllabi available for this plan", + ) + ### /v2 + deliveries_per_year = models.PositiveIntegerField( default=0, validators=[MinValueValidator(0)] ) @@ -1258,12 +1323,27 @@ class MembershipPlanPage(WagtailCacheMixin, ArticleSeoMixin, Page): ) panels = content_panels = Page.content_panels + [ - FieldPanel("deliveries_per_year"), - FieldPanel("description"), - InlinePanel("prices", min_num=1, label="Subscription Pricing Options"), - InlinePanel("upsells", heading="Upsell options", label="Upsell option"), - FieldPanel("pick_product_title", classname="full title"), - FieldPanel("pick_product_text"), + FieldPanel("deliveries_per_year", heading="[v1+v2] Deliveries per year"), + MultiFieldPanel( + [ + FieldPanel("display_in_quiz_flow"), + FieldPanel("description"), + FieldPanel("benefits"), + AutocompletePanel("syllabi", target_model=SyllabusPage), + ], + heading="V2 signup flow", + classname="collapsible", + ), + MultiFieldPanel( + [ + InlinePanel("prices", min_num=1, label="Prices"), + InlinePanel("upsells", label="Upsell prices"), + FieldPanel("pick_product_title", classname="full title"), + FieldPanel("pick_product_text"), + ], + heading="V1 signup flow", + classname="collapsible collapsed", + ), StreamFieldPanel("layout"), ] diff --git a/app/settings/base.py b/app/settings/base.py index f1ad2bcf..8fcb7418 100644 --- a/app/settings/base.py +++ b/app/settings/base.py @@ -68,6 +68,7 @@ "oauth2_provider", "django.contrib.humanize", "django_dbq", + "slippers", ] IMPORT_EXPORT_USE_TRANSACTIONS = True @@ -112,8 +113,9 @@ "wagtailmenus.context_processors.wagtailmenus", # "app.context_processors.user_data", ], + "builtins": ["slippers.templatetags.slippers"], }, - }, + } ] WSGI_APPLICATION = "app.wsgi.application" @@ -458,4 +460,6 @@ EMAIL_BACKEND = "django.core.mail.backends.console.EmailBackend" -DATA_UPLOAD_MAX_NUMBER_FIELDS = 10240 \ No newline at end of file +DATA_UPLOAD_MAX_NUMBER_FIELDS = 10240 + +SESSION_ENGINE = "django.contrib.sessions.backends.signed_cookies" diff --git a/app/slippers_autoload_components.py b/app/slippers_autoload_components.py new file mode 100644 index 00000000..2381c4eb --- /dev/null +++ b/app/slippers_autoload_components.py @@ -0,0 +1,73 @@ +from pathlib import Path + +from django.conf import settings +from slippers.templatetags.slippers import register_components + +SLIPPERS_SUBDIR = "components" + + +def find_slipper_dirs(): + dirs = [] + from django.template import engines + + for backend in engines.all(): + for loader in backend.engine.template_loaders: + if not hasattr(loader, "get_dirs"): + continue + for templates_dir in loader.get_dirs(): + templates_path = Path(templates_dir) + slippers_dir = templates_path / SLIPPERS_SUBDIR + if slippers_dir.exists(): + dirs.append(templates_path) + return dirs + + +def register_dir_components(dir, templates_path): + register_components( + { + template.stem: str(template.relative_to(templates_path)) + for template in dir.glob("*.html") + } + ) + + +def register(): + """ + Register discovered slippers components. + """ + import os + + from django.template import engines + + dir_path = Path(os.path.dirname(os.path.realpath(__file__))) + template_dirs = [dir_path / "templates"] + # template_dirs = find_slipper_dirs() + + slippers_dirs = [] + + for templates_dir in template_dirs: + print("Registering slippers components from", templates_dir) + if templates_dir.exists(): + templates_path = Path(templates_dir) + slippers_dir = templates_path / SLIPPERS_SUBDIR + register_dir_components(slippers_dir, templates_path) + slippers_dirs.append(slippers_dir) + + if settings.DEBUG: + # To support autoreload for `manage.py runserver`, also add a watch so that + # we re-run this code if new slippers templates are added + + from django.dispatch import receiver + from django.utils.autoreload import autoreload_started, file_changed + + @receiver(autoreload_started, dispatch_uid="watch_slippers_dirs") + def watch_slippers_dirs(sender, **kwargs): + for path in slippers_dirs: + sender.watch_dir(path, "*.html") + + @receiver(file_changed, dispatch_uid="slippers_template_changed") + def template_changed(sender, file_path, **kwargs): + path = Path(file_path) + if path.exists() and path.is_dir(): + # This happens when new html files are created, re-run registration + register() diff --git a/app/templates/account/signup.html b/app/templates/account/signup.html index 97163748..8b97faf4 100644 --- a/app/templates/account/signup.html +++ b/app/templates/account/signup.html @@ -7,8 +7,11 @@ {% block modal_content %} {% get_providers as socialaccount_providers %}

{% trans "Let's get you set up" %}

- {% url_test includes="/checkout" as checkout_flow %} - {% if checkout_flow %} + {% url_test includes="/checkout" as v2_checkout_flow %} + {% url_test includes="/checkout" as v1_checkout_flow %} + {% if v2_checkout_flow %} + {% signup_header title="Let's get you set up" step="Create account" %} + {% elif v1_checkout_flow %}