diff --git a/app/app/account_linking.py b/app/app/account_linking.py index 61fcc9a..ea0451d 100644 --- a/app/app/account_linking.py +++ b/app/app/account_linking.py @@ -5,10 +5,11 @@ from typing import Optional from arrow import Arrow from newrelic import agent +from sqlalchemy import or_ from app.db import Session from app.email_utils import send_welcome_email -from app.utils import sanitize_email +from app.utils import sanitize_email, canonicalize_email from app.errors import ( AccountAlreadyLinkedToAnotherPartnerException, AccountIsUsingAliasAsEmail, @@ -131,8 +132,9 @@ class ClientMergeStrategy(ABC): class NewUserStrategy(ClientMergeStrategy): def process(self) -> LinkResult: # Will create a new SL User with a random password + canonical_email = canonicalize_email(self.link_request.email) new_user = User.create( - email=self.link_request.email, + email=canonical_email, name=self.link_request.name, password=random_string(20), activated=True, @@ -213,11 +215,21 @@ def process_login_case( partner_id=partner.id, external_user_id=link_request.external_user_id ) if partner_user is None: + canonical_email = canonicalize_email(link_request.email) # We didn't find any SimpleLogin user registered with that partner user id # Make sure they aren't using an alias as their link email check_alias(link_request.email) + check_alias(canonical_email) # Try to find it using the partner's e-mail address - user = User.get_by(email=link_request.email) + users = User.filter( + or_(User.email == link_request.email, User.email == canonical_email) + ).all() + if len(users) > 1: + user = [user for user in users if user.email == canonical_email][0] + elif len(users) == 1: + user = users[0] + else: + user = None return get_login_strategy(link_request, user, partner).process() else: # We found the SL user registered with that partner user id diff --git a/app/app/admin_model.py b/app/app/admin_model.py index 4f7e6d0..36c69c7 100644 --- a/app/app/admin_model.py +++ b/app/app/admin_model.py @@ -611,6 +611,26 @@ class NewsletterAdmin(SLModelView): else: flash(error_msg, "error") + @action( + "clone_newsletter", + "Clone this newsletter", + ) + def clone_newsletter(self, newsletter_ids): + if len(newsletter_ids) != 1: + flash("you can only select 1 newsletter", "error") + return + + newsletter_id = newsletter_ids[0] + newsletter: Newsletter = Newsletter.get(newsletter_id) + new_newsletter = Newsletter.create( + subject=newsletter.subject, + html=newsletter.html, + plain_text=newsletter.plain_text, + commit=True, + ) + + flash(f"Newsletter {new_newsletter.subject} has been cloned", "success") + class NewsletterUserAdmin(SLModelView): column_searchable_list = ["id"] diff --git a/app/app/models.py b/app/app/models.py index 9293617..69f825e 100644 --- a/app/app/models.py +++ b/app/app/models.py @@ -3517,7 +3517,7 @@ class PartnerSubscription(Base, ModelMixin): class Newsletter(Base, ModelMixin): __tablename__ = "newsletter" - subject = sa.Column(sa.String(), nullable=False, unique=True, index=True) + subject = sa.Column(sa.String(), nullable=False, index=True) html = sa.Column(sa.Text) plain_text = sa.Column(sa.Text) diff --git a/app/migrations/versions/2023_110714_4bc54632d9aa_.py b/app/migrations/versions/2023_110714_4bc54632d9aa_.py new file mode 100644 index 0000000..874b494 --- /dev/null +++ b/app/migrations/versions/2023_110714_4bc54632d9aa_.py @@ -0,0 +1,31 @@ +"""empty message + +Revision ID: 4bc54632d9aa +Revises: 46ecb648a47e +Create Date: 2023-11-07 14:02:17.610226 + +""" +import sqlalchemy_utils +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision = '4bc54632d9aa' +down_revision = '46ecb648a47e' +branch_labels = None +depends_on = None + + +def upgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.drop_index('ix_newsletter_subject', table_name='newsletter') + op.create_index(op.f('ix_newsletter_subject'), 'newsletter', ['subject'], unique=False) + # ### end Alembic commands ### + + +def downgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.drop_index(op.f('ix_newsletter_subject'), table_name='newsletter') + op.create_index('ix_newsletter_subject', 'newsletter', ['subject'], unique=True) + # ### end Alembic commands ### diff --git a/app/static/images/coupon.png b/app/static/images/coupon.png index c7cccbf..99f21aa 100644 Binary files a/app/static/images/coupon.png and b/app/static/images/coupon.png differ diff --git a/app/templates/base.html b/app/templates/base.html index 59c88b5..3a94b82 100644 --- a/app/templates/base.html +++ b/app/templates/base.html @@ -86,6 +86,12 @@ </head> <body> <div class="page"> + {% if current_user.is_authenticated and current_user.should_show_upgrade_button() %} + + <div class="alert alert-success text-center mb-0" role="alert"> + Black Friday: $20 for the first year instead of $30. Available until December 1st. + </div> + {% endif %} {% block announcement %}{% endblock %} <div class="container"> <!-- For flash messages --> diff --git a/app/templates/dashboard/pricing.html b/app/templates/dashboard/pricing.html index 462e1e7..93e6f2e 100644 --- a/app/templates/dashboard/pricing.html +++ b/app/templates/dashboard/pricing.html @@ -57,6 +57,19 @@ {% endblock %} {% block default_content %} + <div class="alert alert-info"> + Black Friday Deal: 33% off on the yearly plan for the <b>first</b> year ($20 instead of $30). + <br> + Please use this coupon code + <em data-toggle="tooltip" + title="Click to copy" + class="clipboard" + data-clipboard-text="BF2023">BF2023</em> during the checkout. + <br> + <img src="/static/images/coupon.png" class="m-2" style="max-width: 300px"> + <br> + Available until December 1, 2023. + </div> <div class="pb-8"> <div class="text-center mx-md-auto mb-8 mt-6"> <h1>Upgrade to unlock premium features</h1> diff --git a/app/tests/test_account_linking.py b/app/tests/test_account_linking.py index 0825daf..1ef7222 100644 --- a/app/tests/test_account_linking.py +++ b/app/tests/test_account_linking.py @@ -18,7 +18,7 @@ from app.db import Session from app.errors import AccountAlreadyLinkedToAnotherPartnerException from app.models import Partner, PartnerUser, User from app.proton.utils import get_proton_partner -from app.utils import random_string +from app.utils import random_string, canonicalize_email from tests.utils import random_email @@ -377,3 +377,48 @@ def test_link_account_with_uppercase(flask_client): ) assert partner_user.partner_id == get_proton_partner().id assert partner_user.external_user_id == partner_user_id + + +def test_login_to_account_with_canonical_email(flask_client): + email = "a.{rand}@gmail.com".format(rand=random_string(10)) + canonical_email = canonicalize_email(email) + assert email != canonical_email + partner_user_id = random_string() + link_request = random_link_request( + external_user_id=partner_user_id, email=email.upper() + ) + user = create_user(canonical_email) + assert user.email == canonical_email + res = process_login_case(link_request, get_proton_partner()) + assert res.user.id == user.id + + +def test_login_to_account_with_canonical_email_if_there_is_also_non_canonical( + flask_client, +): + email = "a.{rand}@gmail.com".format(rand=random_string(10)) + canonical_email = canonicalize_email(email) + assert email != canonical_email + partner_user_id = random_string() + link_request = random_link_request( + external_user_id=partner_user_id, email=email.upper() + ) + user = create_user(canonical_email) + create_user(email) + assert user.email == canonical_email + res = process_login_case(link_request, get_proton_partner()) + assert res.user.id == user.id + + +def test_login_creates_account_with_canonical_email( + flask_client, +): + email = "a.{rand}@gmail.com".format(rand=random_string(10)) + canonical_email = canonicalize_email(email) + assert email != canonical_email + partner_user_id = random_string() + link_request = random_link_request( + external_user_id=partner_user_id, email=email.upper() + ) + res = process_login_case(link_request, get_proton_partner()) + assert res.user.email == canonical_email