mirror of
https://github.com/simple-login/app.git
synced 2024-09-27 20:31:30 +02:00
commit
b8ca2d0158
@ -52,6 +52,26 @@
|
|||||||
Yearly <br>
|
Yearly <br>
|
||||||
$29.99/year
|
$29.99/year
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
|
<hr class="my-6">
|
||||||
|
|
||||||
|
<form method="post">
|
||||||
|
{{ coupon_form.csrf_token }}
|
||||||
|
<input type="hidden" name="form-name" value="create">
|
||||||
|
|
||||||
|
<div class="font-weight-bold mb-2">Coupon</div>
|
||||||
|
<div class="small-text">
|
||||||
|
If you have a lifetime coupon, please paste it here. <br>
|
||||||
|
For information, we offer free premium account for education (student, professor or technical staff working at
|
||||||
|
an educational institute). <br>
|
||||||
|
Drop us an email at hi@simplelogin.io with your student ID or certificate to get the coupon.
|
||||||
|
</div>
|
||||||
|
|
||||||
|
|
||||||
|
{{ coupon_form.code(class="form-control", placeholder="Coupon") }}
|
||||||
|
{{ render_field_errors(coupon_form.code) }}
|
||||||
|
<button class="btn btn-lg btn-success mt-2">Apply</button>
|
||||||
|
</form>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
@ -8,10 +8,10 @@ from app.dashboard.base import dashboard_bp
|
|||||||
@login_required
|
@login_required
|
||||||
def billing():
|
def billing():
|
||||||
# sanity check: make sure this page is only for user who has paddle subscription
|
# 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()
|
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)
|
return render_template("dashboard/billing.html", sub=sub)
|
||||||
|
@ -1,13 +1,23 @@
|
|||||||
from flask import render_template, flash, redirect, url_for
|
from flask import render_template, flash, redirect, url_for
|
||||||
from flask_login import login_required, current_user
|
from flask_login import login_required, current_user
|
||||||
|
from flask_wtf import FlaskForm
|
||||||
|
from wtforms import StringField, validators
|
||||||
|
|
||||||
from app.config import (
|
from app.config import (
|
||||||
PADDLE_VENDOR_ID,
|
PADDLE_VENDOR_ID,
|
||||||
PADDLE_MONTHLY_PRODUCT_ID,
|
PADDLE_MONTHLY_PRODUCT_ID,
|
||||||
PADDLE_YEARLY_PRODUCT_ID,
|
PADDLE_YEARLY_PRODUCT_ID,
|
||||||
URL,
|
URL,
|
||||||
|
ADMIN_EMAIL,
|
||||||
)
|
)
|
||||||
from app.dashboard.base import dashboard_bp
|
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"])
|
@dashboard_bp.route("/pricing", methods=["GET", "POST"])
|
||||||
@ -18,12 +28,39 @@ def pricing():
|
|||||||
flash("You are already a premium user", "warning")
|
flash("You are already a premium user", "warning")
|
||||||
return redirect(url_for("dashboard.index"))
|
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(
|
return render_template(
|
||||||
"dashboard/pricing.html",
|
"dashboard/pricing.html",
|
||||||
PADDLE_VENDOR_ID=PADDLE_VENDOR_ID,
|
PADDLE_VENDOR_ID=PADDLE_VENDOR_ID,
|
||||||
PADDLE_MONTHLY_PRODUCT_ID=PADDLE_MONTHLY_PRODUCT_ID,
|
PADDLE_MONTHLY_PRODUCT_ID=PADDLE_MONTHLY_PRODUCT_ID,
|
||||||
PADDLE_YEARLY_PRODUCT_ID=PADDLE_YEARLY_PRODUCT_ID,
|
PADDLE_YEARLY_PRODUCT_ID=PADDLE_YEARLY_PRODUCT_ID,
|
||||||
success_url=URL + "/dashboard/subscription_success",
|
success_url=URL + "/dashboard/subscription_success",
|
||||||
|
coupon_form=coupon_form,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@ -119,6 +119,9 @@ class User(db.Model, ModelMixin, UserMixin):
|
|||||||
db.Boolean, nullable=False, default=False, server_default="0"
|
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)
|
profile_picture = db.relationship(File)
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
@ -143,13 +146,11 @@ class User(db.Model, ModelMixin, UserMixin):
|
|||||||
|
|
||||||
def is_premium(self):
|
def is_premium(self):
|
||||||
"""user is premium if they have a active subscription"""
|
"""user is premium if they have a active subscription"""
|
||||||
|
if self.lifetime:
|
||||||
|
return True
|
||||||
|
|
||||||
sub: Subscription = self.get_subscription()
|
sub: Subscription = self.get_subscription()
|
||||||
if sub:
|
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 True
|
||||||
|
|
||||||
return False
|
return False
|
||||||
@ -217,8 +218,18 @@ class User(db.Model, ModelMixin, UserMixin):
|
|||||||
return "Free Plan"
|
return "Free Plan"
|
||||||
|
|
||||||
def get_subscription(self):
|
def get_subscription(self):
|
||||||
|
"""return *active* subscription
|
||||||
|
TODO: support user unsubscribe and re-subscribe
|
||||||
|
"""
|
||||||
sub = Subscription.get_by(user_id=self.id)
|
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):
|
def verified_custom_domains(self):
|
||||||
return CustomDomain.query.filter_by(user_id=self.id, verified=True).all()
|
return CustomDomain.query.filter_by(user_id=self.id, verified=True).all()
|
||||||
@ -709,3 +720,8 @@ class CustomDomain(db.Model, ModelMixin):
|
|||||||
|
|
||||||
def __repr__(self):
|
def __repr__(self):
|
||||||
return f"<Custom Domain {self.domain}>"
|
return f"<Custom Domain {self.domain}>"
|
||||||
|
|
||||||
|
|
||||||
|
class LifetimeCoupon(db.Model, ModelMixin):
|
||||||
|
code = db.Column(db.String(128), nullable=False, unique=True)
|
||||||
|
nb_used = db.Column(db.Integer, nullable=False)
|
||||||
|
39
migrations/versions/2020_010120_d29cca963221_.py
Normal file
39
migrations/versions/2020_010120_d29cca963221_.py
Normal file
@ -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 ###
|
@ -87,7 +87,7 @@ s3transfer==0.2.1 # via boto3
|
|||||||
sentry-sdk==0.13.5
|
sentry-sdk==0.13.5
|
||||||
simplejson==3.17.0 # via flask-profiler
|
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
|
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
|
sqlalchemy==1.3.12 # via alembic, flask-sqlalchemy, sqlalchemy-utils
|
||||||
strictyaml==1.0.2 # via yacron
|
strictyaml==1.0.2 # via yacron
|
||||||
traitlets==4.3.2 # via ipython
|
traitlets==4.3.2 # via ipython
|
||||||
|
@ -41,6 +41,7 @@ from app.models import (
|
|||||||
PlanEnum,
|
PlanEnum,
|
||||||
ApiKey,
|
ApiKey,
|
||||||
CustomDomain,
|
CustomDomain,
|
||||||
|
LifetimeCoupon,
|
||||||
)
|
)
|
||||||
from app.monitor.base import monitor_bp
|
from app.monitor.base import monitor_bp
|
||||||
from app.oauth.base import oauth_bp
|
from app.oauth.base import oauth_bp
|
||||||
@ -117,6 +118,9 @@ def fake_data():
|
|||||||
)
|
)
|
||||||
db.session.commit()
|
db.session.commit()
|
||||||
|
|
||||||
|
LifetimeCoupon.create(code="coupon", nb_used=10)
|
||||||
|
db.session.commit()
|
||||||
|
|
||||||
# Create a subscription for user
|
# Create a subscription for user
|
||||||
Subscription.create(
|
Subscription.create(
|
||||||
user_id=user.id,
|
user_id=user.id,
|
||||||
|
@ -42,7 +42,7 @@
|
|||||||
</a>
|
</a>
|
||||||
|
|
||||||
<div class="dropdown-menu dropdown-menu-right dropdown-menu-arrow">
|
<div class="dropdown-menu dropdown-menu-right dropdown-menu-arrow">
|
||||||
{% if current_user.is_premium() %}
|
{% if current_user.get_subscription() %}
|
||||||
<a class="dropdown-item" href="{{ url_for('dashboard.billing') }}">
|
<a class="dropdown-item" href="{{ url_for('dashboard.billing') }}">
|
||||||
<i class="dropdown-icon fe fe-dollar-sign"></i> Billing
|
<i class="dropdown-icon fe fe-dollar-sign"></i> Billing
|
||||||
</a>
|
</a>
|
||||||
|
Loading…
Reference in New Issue
Block a user