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
+
+
+
+
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 @@