diff --git a/app/dashboard/templates/dashboard/pricing.html b/app/dashboard/templates/dashboard/pricing.html index b1b0b0db..b7306dd0 100644 --- a/app/dashboard/templates/dashboard/pricing.html +++ b/app/dashboard/templates/dashboard/pricing.html @@ -52,6 +52,26 @@ Yearly
$29.99/year + +
+ +
+ {{ coupon_form.csrf_token }} + + +
Coupon
+
+ If you have a lifetime coupon, please paste it here.
+ For information, we offer free premium account for education (student, professor or technical staff working at + an educational institute).
+ Drop us an email at hi@simplelogin.io with your student ID or certificate to get the coupon. +
+ + + {{ coupon_form.code(class="form-control", placeholder="Coupon") }} + {{ render_field_errors(coupon_form.code) }} + +
diff --git a/app/dashboard/views/billing.py b/app/dashboard/views/billing.py index 65ce62cd..0648be24 100644 --- a/app/dashboard/views/billing.py +++ b/app/dashboard/views/billing.py @@ -8,10 +8,10 @@ from app.dashboard.base import dashboard_bp @login_required def billing(): # sanity check: make sure this page is only for user who has paddle subscription - if not current_user.is_premium(): - flash("This page is for paid customer only", "warning") - return redirect(url_for("dashboard.index")) - sub = current_user.get_subscription() + if not sub: + flash("You don't have any active subscription", "warning") + return redirect(url_for("dashboard.index")) + return render_template("dashboard/billing.html", sub=sub) diff --git a/app/dashboard/views/pricing.py b/app/dashboard/views/pricing.py index d29891bc..aa74ea34 100644 --- a/app/dashboard/views/pricing.py +++ b/app/dashboard/views/pricing.py @@ -1,13 +1,23 @@ from flask import render_template, flash, redirect, url_for from flask_login import login_required, current_user +from flask_wtf import FlaskForm +from wtforms import StringField, validators from app.config import ( PADDLE_VENDOR_ID, PADDLE_MONTHLY_PRODUCT_ID, PADDLE_YEARLY_PRODUCT_ID, URL, + ADMIN_EMAIL, ) from app.dashboard.base import dashboard_bp +from app.email_utils import send_email +from app.extensions import db +from app.models import LifetimeCoupon + + +class CouponForm(FlaskForm): + code = StringField("Coupon Code", validators=[validators.DataRequired()]) @dashboard_bp.route("/pricing", methods=["GET", "POST"]) @@ -18,12 +28,39 @@ def pricing(): flash("You are already a premium user", "warning") return redirect(url_for("dashboard.index")) + coupon_form = CouponForm() + + if coupon_form.validate_on_submit(): + code = coupon_form.code.data + + coupon = LifetimeCoupon.get_by(code=code) + + if coupon and coupon.nb_used > 0: + coupon.nb_used -= 1 + current_user.lifetime = True + db.session.commit() + + # notify admin + send_email( + ADMIN_EMAIL, + subject=f"User {current_user.id} used lifetime coupon. Coupon nb_used: {coupon.nb_used}", + plaintext="", + html="", + ) + + flash("You are upgraded to lifetime premium!", "success") + return redirect(url_for("dashboard.index")) + + else: + flash(f"Coupon *{code}* expired or invalid", "warning") + return render_template( "dashboard/pricing.html", PADDLE_VENDOR_ID=PADDLE_VENDOR_ID, PADDLE_MONTHLY_PRODUCT_ID=PADDLE_MONTHLY_PRODUCT_ID, PADDLE_YEARLY_PRODUCT_ID=PADDLE_YEARLY_PRODUCT_ID, success_url=URL + "/dashboard/subscription_success", + coupon_form=coupon_form, ) diff --git a/app/models.py b/app/models.py index 9ff2f931..5557eee6 100644 --- a/app/models.py +++ b/app/models.py @@ -119,6 +119,9 @@ class User(db.Model, ModelMixin, UserMixin): db.Boolean, nullable=False, default=False, server_default="0" ) + # some users could have lifetime premium + lifetime = db.Column(db.Boolean, default=False, nullable=False, server_default="0") + profile_picture = db.relationship(File) @classmethod @@ -143,13 +146,11 @@ class User(db.Model, ModelMixin, UserMixin): def is_premium(self): """user is premium if they have a active subscription""" + if self.lifetime: + return True + sub: Subscription = self.get_subscription() if sub: - if sub.cancelled: - # user is premium until the next billing_date + 1 - return sub.next_bill_date >= arrow.now().shift(days=-1).date() - - # subscription active, ie not cancelled return True return False @@ -217,8 +218,18 @@ class User(db.Model, ModelMixin, UserMixin): return "Free Plan" def get_subscription(self): + """return *active* subscription + TODO: support user unsubscribe and re-subscribe + """ sub = Subscription.get_by(user_id=self.id) - return sub + if sub and sub.cancelled: + # sub is active until the next billing_date + 1 + if sub.next_bill_date >= arrow.now().shift(days=-1).date(): + return sub + else: # past subscription, user is considered not having a subscription + return None + else: + return sub def verified_custom_domains(self): return CustomDomain.query.filter_by(user_id=self.id, verified=True).all() @@ -709,3 +720,8 @@ class CustomDomain(db.Model, ModelMixin): def __repr__(self): return f"" + + +class LifetimeCoupon(db.Model, ModelMixin): + code = db.Column(db.String(128), nullable=False, unique=True) + nb_used = db.Column(db.Integer, nullable=False) diff --git a/migrations/versions/2020_010120_d29cca963221_.py b/migrations/versions/2020_010120_d29cca963221_.py new file mode 100644 index 00000000..291f2aeb --- /dev/null +++ b/migrations/versions/2020_010120_d29cca963221_.py @@ -0,0 +1,39 @@ +"""empty message + +Revision ID: d29cca963221 +Revises: 01f808f15b2e +Create Date: 2020-01-01 20:01:51.861329 + +""" +import sqlalchemy_utils +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision = 'd29cca963221' +down_revision = '01f808f15b2e' +branch_labels = None +depends_on = None + + +def upgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.create_table('lifetime_coupon', + sa.Column('id', sa.Integer(), autoincrement=True, nullable=False), + sa.Column('created_at', sqlalchemy_utils.types.arrow.ArrowType(), nullable=False), + sa.Column('updated_at', sqlalchemy_utils.types.arrow.ArrowType(), nullable=True), + sa.Column('code', sa.String(length=128), nullable=False), + sa.Column('nb_used', sa.Integer(), nullable=False), + sa.PrimaryKeyConstraint('id'), + sa.UniqueConstraint('code') + ) + op.add_column('users', sa.Column('lifetime', sa.Boolean(), server_default='0', nullable=False)) + # ### end Alembic commands ### + + +def downgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.drop_column('users', 'lifetime') + op.drop_table('lifetime_coupon') + # ### end Alembic commands ### diff --git a/requirements.txt b/requirements.txt index b5652536..5fb3010e 100644 --- a/requirements.txt +++ b/requirements.txt @@ -87,7 +87,7 @@ s3transfer==0.2.1 # via boto3 sentry-sdk==0.13.5 simplejson==3.17.0 # via flask-profiler six==1.12.0 # via bcrypt, cryptography, flask-cors, packaging, pip-tools, prompt-toolkit, pyopenssl, pytest, python-dateutil, sqlalchemy-utils, traitlets -sqlalchemy-utils==0.33.11 +sqlalchemy-utils==0.36.1 sqlalchemy==1.3.12 # via alembic, flask-sqlalchemy, sqlalchemy-utils strictyaml==1.0.2 # via yacron traitlets==4.3.2 # via ipython diff --git a/server.py b/server.py index 605d28c4..725c9c8f 100644 --- a/server.py +++ b/server.py @@ -41,6 +41,7 @@ from app.models import ( PlanEnum, ApiKey, CustomDomain, + LifetimeCoupon, ) from app.monitor.base import monitor_bp from app.oauth.base import oauth_bp @@ -117,6 +118,9 @@ def fake_data(): ) db.session.commit() + LifetimeCoupon.create(code="coupon", nb_used=10) + db.session.commit() + # Create a subscription for user Subscription.create( user_id=user.id, diff --git a/templates/header.html b/templates/header.html index a7c0fd7d..48731ff8 100644 --- a/templates/header.html +++ b/templates/header.html @@ -42,7 +42,7 @@