initial creation
This commit is contained in:
@@ -0,0 +1,18 @@
|
||||
FROM python:3.7.5-slim-buster
|
||||
MAINTAINER Nick Janetakis <nick.janetakis@gmail.com>
|
||||
|
||||
RUN apt-get update && apt-get install -qq -y \
|
||||
build-essential libpq-dev --no-install-recommends
|
||||
|
||||
ENV INSTALL_PATH /snakeeyes
|
||||
RUN mkdir -p $INSTALL_PATH
|
||||
|
||||
WORKDIR $INSTALL_PATH
|
||||
|
||||
COPY requirements.txt requirements.txt
|
||||
RUN pip install -r requirements.txt
|
||||
|
||||
COPY . .
|
||||
RUN pip install --editable .
|
||||
|
||||
CMD gunicorn -c "python:config.gunicorn" "snakeeyes.app:create_app()"
|
||||
@@ -0,0 +1,10 @@
|
||||
Metadata-Version: 1.0
|
||||
Name: SnakeEyes-CLI
|
||||
Version: 1.0
|
||||
Summary: UNKNOWN
|
||||
Home-page: UNKNOWN
|
||||
Author: UNKNOWN
|
||||
Author-email: UNKNOWN
|
||||
License: UNKNOWN
|
||||
Description: UNKNOWN
|
||||
Platform: UNKNOWN
|
||||
@@ -0,0 +1,9 @@
|
||||
SnakeEyes_CLI.egg-info/PKG-INFO
|
||||
SnakeEyes_CLI.egg-info/SOURCES.txt
|
||||
SnakeEyes_CLI.egg-info/dependency_links.txt
|
||||
SnakeEyes_CLI.egg-info/entry_points.txt
|
||||
SnakeEyes_CLI.egg-info/requires.txt
|
||||
SnakeEyes_CLI.egg-info/top_level.txt
|
||||
cli/__init__.py
|
||||
cli/cli.py
|
||||
cli/commands/__init__.py
|
||||
@@ -0,0 +1 @@
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
|
||||
[console_scripts]
|
||||
snakeeyes=cli.cli:cli
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
click
|
||||
@@ -0,0 +1 @@
|
||||
cli
|
||||
@@ -0,0 +1,49 @@
|
||||
import os
|
||||
|
||||
import click
|
||||
|
||||
cmd_folder = os.path.join(os.path.dirname(__file__), 'commands')
|
||||
cmd_prefix = 'cmd_'
|
||||
|
||||
|
||||
class CLI(click.MultiCommand):
|
||||
def list_commands(self, ctx):
|
||||
"""
|
||||
Obtain a list of all available commands.
|
||||
|
||||
:param ctx: Click context
|
||||
:return: List of sorted commands
|
||||
"""
|
||||
commands = []
|
||||
|
||||
for filename in os.listdir(cmd_folder):
|
||||
if filename.endswith('.py') and filename.startswith(cmd_prefix):
|
||||
commands.append(filename[4:-3])
|
||||
|
||||
commands.sort()
|
||||
|
||||
return commands
|
||||
|
||||
def get_command(self, ctx, name):
|
||||
"""
|
||||
Get a specific command by looking up the module.
|
||||
|
||||
:param ctx: Click context
|
||||
:param name: Command name
|
||||
:return: Module's cli function
|
||||
"""
|
||||
ns = {}
|
||||
|
||||
filename = os.path.join(cmd_folder, cmd_prefix + name + '.py')
|
||||
|
||||
with open(filename) as f:
|
||||
code = compile(f.read(), filename, 'exec')
|
||||
eval(code, ns, ns)
|
||||
|
||||
return ns['cli']
|
||||
|
||||
|
||||
@click.command(cls=CLI)
|
||||
def cli():
|
||||
""" Commands to help manage your project. """
|
||||
pass
|
||||
@@ -0,0 +1,276 @@
|
||||
import click
|
||||
import random
|
||||
|
||||
from datetime import datetime
|
||||
|
||||
from faker import Faker
|
||||
|
||||
from snakeeyes.app import create_app
|
||||
from snakeeyes.extensions import db
|
||||
from snakeeyes.blueprints.user.models import User
|
||||
from snakeeyes.blueprints.billing.models.invoice import Invoice
|
||||
from snakeeyes.blueprints.bet.models.bet import Bet
|
||||
from snakeeyes.blueprints.bet.models.dice import roll
|
||||
|
||||
# Create an app context for the database connection.
|
||||
app = create_app()
|
||||
db.app = app
|
||||
|
||||
fake = Faker()
|
||||
|
||||
|
||||
def _log_status(count, model_label):
|
||||
"""
|
||||
Log the output of how many records were created.
|
||||
|
||||
:param count: Amount created
|
||||
:type count: int
|
||||
:param model_label: Name of the model
|
||||
:type model_label: str
|
||||
:return: None
|
||||
"""
|
||||
click.echo('Created {0} {1}'.format(count, model_label))
|
||||
|
||||
return None
|
||||
|
||||
|
||||
def _bulk_insert(model, data, label):
|
||||
"""
|
||||
Bulk insert data to a specific model and log it. This is much more
|
||||
efficient than adding 1 row at a time in a loop.
|
||||
|
||||
:param model: Model being affected
|
||||
:type model: SQLAlchemy
|
||||
:param data: Data to be saved
|
||||
:type data: list
|
||||
:param label: Label for the output
|
||||
:type label: str
|
||||
:param skip_delete: Optionally delete previous records
|
||||
:type skip_delete: bool
|
||||
:return: None
|
||||
"""
|
||||
with app.app_context():
|
||||
model.query.delete()
|
||||
|
||||
db.session.commit()
|
||||
db.engine.execute(model.__table__.insert(), data)
|
||||
|
||||
_log_status(model.query.count(), label)
|
||||
|
||||
return None
|
||||
|
||||
|
||||
@click.group()
|
||||
def cli():
|
||||
""" Add items to the database. """
|
||||
pass
|
||||
|
||||
|
||||
@click.command()
|
||||
def users():
|
||||
"""
|
||||
Generate fake users.
|
||||
"""
|
||||
random_emails = []
|
||||
data = []
|
||||
|
||||
click.echo('Working...')
|
||||
|
||||
# Ensure we get about 100 unique random emails.
|
||||
for i in range(0, 99):
|
||||
random_emails.append(fake.email())
|
||||
|
||||
random_emails.append(app.config['SEED_ADMIN_EMAIL'])
|
||||
random_emails = list(set(random_emails))
|
||||
|
||||
while True:
|
||||
if len(random_emails) == 0:
|
||||
break
|
||||
|
||||
fake_datetime = fake.date_time_between(
|
||||
start_date='-1y', end_date='now').strftime('%s')
|
||||
|
||||
created_on = datetime.utcfromtimestamp(
|
||||
float(fake_datetime)).strftime('%Y-%m-%dT%H:%M:%S Z')
|
||||
|
||||
random_percent = random.random()
|
||||
|
||||
if random_percent >= 0.05:
|
||||
role = 'member'
|
||||
else:
|
||||
role = 'admin'
|
||||
|
||||
email = random_emails.pop()
|
||||
|
||||
random_percent = random.random()
|
||||
|
||||
if random_percent >= 0.5:
|
||||
random_trail = str(int(round((random.random() * 1000))))
|
||||
username = fake.first_name() + random_trail
|
||||
last_bet_on = created_on
|
||||
else:
|
||||
username = None
|
||||
last_bet_on = None
|
||||
|
||||
fake_datetime = fake.date_time_between(
|
||||
start_date='-1y', end_date='now').strftime('%s')
|
||||
|
||||
current_sign_in_on = datetime.utcfromtimestamp(
|
||||
float(fake_datetime)).strftime('%Y-%m-%dT%H:%M:%S Z')
|
||||
|
||||
params = {
|
||||
'created_on': created_on,
|
||||
'updated_on': created_on,
|
||||
'role': role,
|
||||
'email': email,
|
||||
'username': username,
|
||||
'password': User.encrypt_password('password'),
|
||||
'sign_in_count': random.random() * 100,
|
||||
'coins': 100,
|
||||
'last_bet_on': last_bet_on,
|
||||
'current_sign_in_on': current_sign_in_on,
|
||||
'current_sign_in_ip': fake.ipv4(),
|
||||
'last_sign_in_on': current_sign_in_on,
|
||||
'last_sign_in_ip': fake.ipv4()
|
||||
}
|
||||
|
||||
# Ensure the seeded admin is always an admin with the seeded password.
|
||||
if email == app.config['SEED_ADMIN_EMAIL']:
|
||||
password = User.encrypt_password(app.config['SEED_ADMIN_PASSWORD'])
|
||||
|
||||
params['role'] = 'admin'
|
||||
params['password'] = password
|
||||
|
||||
data.append(params)
|
||||
|
||||
return _bulk_insert(User, data, 'users')
|
||||
|
||||
|
||||
@click.command()
|
||||
def invoices():
|
||||
"""
|
||||
Generate random invoices.
|
||||
"""
|
||||
data = []
|
||||
|
||||
users = db.session.query(User).all()
|
||||
|
||||
for user in users:
|
||||
for i in range(0, random.randint(1, 12)):
|
||||
# Create a fake unix timestamp in the future.
|
||||
created_on = fake.date_time_between(
|
||||
start_date='-1y', end_date='now').strftime('%s')
|
||||
period_start_on = fake.date_time_between(
|
||||
start_date='now', end_date='+1y').strftime('%s')
|
||||
period_end_on = fake.date_time_between(
|
||||
start_date=period_start_on, end_date='+14d').strftime('%s')
|
||||
exp_date = fake.date_time_between(
|
||||
start_date='now', end_date='+2y').strftime('%s')
|
||||
|
||||
created_on = datetime.utcfromtimestamp(
|
||||
float(created_on)).strftime('%Y-%m-%dT%H:%M:%S Z')
|
||||
period_start_on = datetime.utcfromtimestamp(
|
||||
float(period_start_on)).strftime('%Y-%m-%d')
|
||||
period_end_on = datetime.utcfromtimestamp(
|
||||
float(period_end_on)).strftime('%Y-%m-%d')
|
||||
exp_date = datetime.utcfromtimestamp(
|
||||
float(exp_date)).strftime('%Y-%m-%d')
|
||||
|
||||
plans = ['BRONZE', 'GOLD', 'PLATINUM']
|
||||
cards = ['Visa', 'Mastercard', 'AMEX',
|
||||
'J.C.B', "Diner's Club"]
|
||||
|
||||
params = {
|
||||
'created_on': created_on,
|
||||
'updated_on': created_on,
|
||||
'user_id': user.id,
|
||||
'receipt_number': fake.md5(),
|
||||
'description': '{0} MONTHLY'.format(random.choice(plans)),
|
||||
'period_start_on': period_start_on,
|
||||
'period_end_on': period_end_on,
|
||||
'currency': 'usd',
|
||||
'tax': random.random() * 100,
|
||||
'tax_percent': random.random() * 10,
|
||||
'total': random.random() * 1000,
|
||||
'brand': random.choice(cards),
|
||||
'last4': random.randint(1000, 9000),
|
||||
'exp_date': exp_date
|
||||
}
|
||||
|
||||
data.append(params)
|
||||
|
||||
return _bulk_insert(Invoice, data, 'invoices')
|
||||
|
||||
|
||||
@click.command()
|
||||
def bets():
|
||||
"""
|
||||
Generate random bets.
|
||||
"""
|
||||
data = []
|
||||
|
||||
users = db.session.query(User).all()
|
||||
|
||||
for user in users:
|
||||
for i in range(0, random.randint(10, 20)):
|
||||
fake_datetime = fake.date_time_between(
|
||||
start_date='-1y', end_date='now').strftime('%s')
|
||||
|
||||
created_on = datetime.utcfromtimestamp(
|
||||
float(fake_datetime)).strftime('%Y-%m-%dT%H:%M:%S Z')
|
||||
|
||||
wagered = random.randint(1, 100)
|
||||
die_1 = roll()
|
||||
die_2 = roll()
|
||||
outcome = die_1 + die_2
|
||||
|
||||
random_percent = random.random()
|
||||
|
||||
if random_percent >= 0.75:
|
||||
guess = outcome
|
||||
else:
|
||||
guess = random.randint(2, 12)
|
||||
|
||||
payout = float(app.config['DICE_ROLL_PAYOUT'][str(guess)])
|
||||
is_winner = Bet.is_winner(guess, outcome)
|
||||
payout = Bet.determine_payout(payout, is_winner)
|
||||
net = Bet.calculate_net(wagered, payout, is_winner)
|
||||
|
||||
params = {
|
||||
'created_on': created_on,
|
||||
'updated_on': created_on,
|
||||
'user_id': user.id,
|
||||
'guess': guess,
|
||||
'die_1': die_1,
|
||||
'die_2': die_1,
|
||||
'roll': outcome,
|
||||
'wagered': wagered,
|
||||
'payout': payout,
|
||||
'net': net
|
||||
}
|
||||
|
||||
data.append(params)
|
||||
|
||||
return _bulk_insert(Bet, data, 'bets')
|
||||
|
||||
|
||||
@click.command()
|
||||
@click.pass_context
|
||||
def all(ctx):
|
||||
"""
|
||||
Generate all data.
|
||||
|
||||
:param ctx:
|
||||
:return: None
|
||||
"""
|
||||
ctx.invoke(users)
|
||||
ctx.invoke(invoices)
|
||||
ctx.invoke(bets)
|
||||
|
||||
return None
|
||||
|
||||
|
||||
cli.add_command(users)
|
||||
cli.add_command(invoices)
|
||||
cli.add_command(bets)
|
||||
cli.add_command(all)
|
||||
@@ -0,0 +1,16 @@
|
||||
import subprocess
|
||||
|
||||
import click
|
||||
|
||||
|
||||
@click.command()
|
||||
@click.argument('path', default='snakeeyes')
|
||||
def cli(path):
|
||||
"""
|
||||
Run a test coverage report.
|
||||
|
||||
:param path: Test coverage path
|
||||
:return: Subprocess call result
|
||||
"""
|
||||
cmd = 'py.test --cov-report term-missing --cov {0}'.format(path)
|
||||
return subprocess.call(cmd, shell=True)
|
||||
@@ -0,0 +1,80 @@
|
||||
import click
|
||||
|
||||
from sqlalchemy_utils import database_exists, create_database
|
||||
|
||||
from snakeeyes.app import create_app
|
||||
from snakeeyes.extensions import db
|
||||
from snakeeyes.blueprints.user.models import User
|
||||
|
||||
# Create an app context for the database connection.
|
||||
app = create_app()
|
||||
db.app = app
|
||||
|
||||
|
||||
@click.group()
|
||||
def cli():
|
||||
""" Run PostgreSQL related tasks. """
|
||||
pass
|
||||
|
||||
|
||||
@click.command()
|
||||
@click.option('--with-testdb/--no-with-testdb', default=False,
|
||||
help='Create a test db too?')
|
||||
def init(with_testdb):
|
||||
"""
|
||||
Initialize the database.
|
||||
|
||||
:param with_testdb: Create a test database
|
||||
:return: None
|
||||
"""
|
||||
db.drop_all()
|
||||
db.create_all()
|
||||
|
||||
if with_testdb:
|
||||
db_uri = '{0}_test'.format(app.config['SQLALCHEMY_DATABASE_URI'])
|
||||
|
||||
if not database_exists(db_uri):
|
||||
create_database(db_uri)
|
||||
|
||||
return None
|
||||
|
||||
|
||||
@click.command()
|
||||
def seed():
|
||||
"""
|
||||
Seed the database with an initial user.
|
||||
|
||||
:return: User instance
|
||||
"""
|
||||
if User.find_by_identity(app.config['SEED_ADMIN_EMAIL']) is not None:
|
||||
return None
|
||||
|
||||
params = {
|
||||
'role': 'admin',
|
||||
'email': app.config['SEED_ADMIN_EMAIL'],
|
||||
'password': app.config['SEED_ADMIN_PASSWORD']
|
||||
}
|
||||
|
||||
return User(**params).save()
|
||||
|
||||
|
||||
@click.command()
|
||||
@click.option('--with-testdb/--no-with-testdb', default=False,
|
||||
help='Create a test db too?')
|
||||
@click.pass_context
|
||||
def reset(ctx, with_testdb):
|
||||
"""
|
||||
Init and seed automatically.
|
||||
|
||||
:param with_testdb: Create a test database
|
||||
:return: None
|
||||
"""
|
||||
ctx.invoke(init, with_testdb=with_testdb)
|
||||
ctx.invoke(seed)
|
||||
|
||||
return None
|
||||
|
||||
|
||||
cli.add_command(init)
|
||||
cli.add_command(seed)
|
||||
cli.add_command(reset)
|
||||
@@ -0,0 +1,24 @@
|
||||
import subprocess
|
||||
|
||||
import click
|
||||
|
||||
|
||||
@click.command()
|
||||
@click.option('--skip-init/--no-skip-init', default=True,
|
||||
help='Skip __init__.py files?')
|
||||
@click.argument('path', default='snakeeyes')
|
||||
def cli(skip_init, path):
|
||||
"""
|
||||
Run flake8 to analyze your code base.
|
||||
|
||||
:param skip_init: Skip checking __init__.py files
|
||||
:param path: Test coverage path
|
||||
:return: Subprocess call result
|
||||
"""
|
||||
flake8_flag_exclude = ''
|
||||
|
||||
if skip_init:
|
||||
flake8_flag_exclude = ' --exclude __init__.py'
|
||||
|
||||
cmd = 'flake8 {0}{1}'.format(path, flake8_flag_exclude)
|
||||
return subprocess.call(cmd, shell=True)
|
||||
@@ -0,0 +1,44 @@
|
||||
from subprocess import check_output
|
||||
|
||||
import click
|
||||
|
||||
|
||||
def count_locs(file_type, comment_pattern):
|
||||
"""
|
||||
Detect if a program is on the system path.
|
||||
|
||||
:param file_type: Which file type will be searched?
|
||||
:param file_type: str
|
||||
:param comment_pattern: Escaped characters that are comments
|
||||
:param comment_pattern: str
|
||||
:return: str
|
||||
"""
|
||||
find = "find . -name '*.{0}' -print0".format(file_type)
|
||||
sed_pattern = "'/^\s*{0}/d;/^\s*$/d'".format(comment_pattern)
|
||||
|
||||
cmd = "{0} | xargs -0 sed {1} | wc -l".format(find, sed_pattern)
|
||||
|
||||
return check_output(cmd, shell=True).decode('utf-8').replace('\n', '')
|
||||
|
||||
|
||||
@click.command()
|
||||
def cli():
|
||||
"""
|
||||
Count lines of code in the project.
|
||||
|
||||
:return: None
|
||||
"""
|
||||
file_types = (
|
||||
['Python', 'py', '#'],
|
||||
['HTML', 'html', '<!--'],
|
||||
['CSS', 'css', '\/\*'],
|
||||
['JS', 'js', '\/\/']
|
||||
)
|
||||
|
||||
click.echo('Lines of code\n-------------')
|
||||
|
||||
for file_type in file_types:
|
||||
click.echo("{0}: {1}".format(file_type[0], count_locs(file_type[1],
|
||||
file_type[2])))
|
||||
|
||||
return None
|
||||
@@ -0,0 +1,30 @@
|
||||
import click
|
||||
|
||||
from snakeeyes.app import create_app
|
||||
|
||||
app = create_app()
|
||||
|
||||
|
||||
@click.command()
|
||||
def cli():
|
||||
"""
|
||||
List all of the available routes.
|
||||
|
||||
:return: str
|
||||
"""
|
||||
output = {}
|
||||
|
||||
for rule in app.url_map.iter_rules():
|
||||
route = {
|
||||
'path': rule.rule,
|
||||
'methods': '({0})'.format(', '.join(rule.methods))
|
||||
}
|
||||
|
||||
output[rule.endpoint] = route
|
||||
|
||||
endpoint_padding = max(len(endpoint) for endpoint in output.keys()) + 2
|
||||
|
||||
for key in sorted(output):
|
||||
if 'debugtoolbar' not in key and 'debug_toolbar' not in key:
|
||||
click.echo('{0: >{1}}: {2}'.format(key, endpoint_padding,
|
||||
output[key]))
|
||||
@@ -0,0 +1,15 @@
|
||||
import binascii
|
||||
import os
|
||||
|
||||
import click
|
||||
|
||||
|
||||
@click.command()
|
||||
@click.argument('bytes', default=128)
|
||||
def cli(bytes):
|
||||
"""
|
||||
Generate a random secret token.
|
||||
|
||||
:return: str
|
||||
"""
|
||||
return click.echo(binascii.b2a_hex(os.urandom(bytes)))
|
||||
@@ -0,0 +1,69 @@
|
||||
import click
|
||||
|
||||
from snakeeyes.app import create_app
|
||||
from snakeeyes.extensions import db
|
||||
from snakeeyes.blueprints.billing.gateways.stripecom import Plan as PaymentPlan
|
||||
|
||||
# Create an app context for the database connection.
|
||||
app = create_app()
|
||||
db.app = app
|
||||
|
||||
|
||||
@click.group()
|
||||
def cli():
|
||||
""" Perform various tasks with Stripe's API. """
|
||||
pass
|
||||
|
||||
|
||||
@click.command()
|
||||
def sync_plans():
|
||||
"""
|
||||
Sync (upsert) STRIPE_PLANS to Stripe.
|
||||
|
||||
:return: None
|
||||
"""
|
||||
if app.config['STRIPE_PLANS'] is None:
|
||||
return None
|
||||
|
||||
for _, value in app.config['STRIPE_PLANS'].items():
|
||||
plan = PaymentPlan.retrieve(value.get('id'))
|
||||
|
||||
if plan:
|
||||
PaymentPlan.update(id=value.get('id'),
|
||||
name=value.get('name'),
|
||||
metadata=value.get('metadata'),
|
||||
statement_descriptor=value.get(
|
||||
'statement_descriptor'))
|
||||
else:
|
||||
PaymentPlan.create(**value)
|
||||
|
||||
return None
|
||||
|
||||
|
||||
@click.command()
|
||||
@click.argument('plan_ids', nargs=-1)
|
||||
def delete_plans(plan_ids):
|
||||
"""
|
||||
Delete 1 or more plans from Stripe.
|
||||
|
||||
:return: None
|
||||
"""
|
||||
for plan_id in plan_ids:
|
||||
PaymentPlan.delete(plan_id)
|
||||
|
||||
return None
|
||||
|
||||
|
||||
@click.command()
|
||||
def list_plans():
|
||||
"""
|
||||
List all existing plans on Stripe.
|
||||
|
||||
:return: Stripe plans
|
||||
"""
|
||||
click.echo(PaymentPlan.list())
|
||||
|
||||
|
||||
cli.add_command(sync_plans)
|
||||
cli.add_command(delete_plans)
|
||||
cli.add_command(list_plans)
|
||||
@@ -0,0 +1,17 @@
|
||||
import os
|
||||
import subprocess
|
||||
|
||||
import click
|
||||
|
||||
|
||||
@click.command()
|
||||
@click.argument('path', default=os.path.join('snakeeyes', 'tests'))
|
||||
def cli(path):
|
||||
"""
|
||||
Run tests with Pytest.
|
||||
|
||||
:param path: Test path
|
||||
:return: Subprocess call result
|
||||
"""
|
||||
cmd = 'py.test {0}'.format(path)
|
||||
return subprocess.call(cmd, shell=True)
|
||||
@@ -0,0 +1,5 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
|
||||
bind = '0.0.0.0:8000'
|
||||
accesslog = '-'
|
||||
access_log_format = '%(h)s %(l)s %(u)s %(t)s "%(r)s" %(s)s %(b)s "%(f)s" "%(a)s" in %(D)sµs'
|
||||
@@ -0,0 +1,121 @@
|
||||
from datetime import timedelta
|
||||
|
||||
from celery.schedules import crontab
|
||||
|
||||
|
||||
DEBUG = True
|
||||
LOG_LEVEL = 'DEBUG' # CRITICAL / ERROR / WARNING / INFO / DEBUG
|
||||
|
||||
SERVER_NAME = 'localhost:8000'
|
||||
SECRET_KEY = 'insecurekeyfordev'
|
||||
|
||||
# Flask-Mail.
|
||||
MAIL_DEFAULT_SENDER = 'contact@local.host'
|
||||
MAIL_SERVER = 'smtp.gmail.com'
|
||||
MAIL_PORT = 587
|
||||
MAIL_USE_TLS = True
|
||||
MAIL_USE_SSL = False
|
||||
MAIL_USERNAME = 'you@gmail.com'
|
||||
MAIL_PASSWORD = 'awesomepassword'
|
||||
|
||||
# Celery.
|
||||
CELERY_BROKER_URL = 'redis://:devpassword@redis:6379/0'
|
||||
CELERY_RESULT_BACKEND = CELERY_BROKER_URL
|
||||
CELERY_ACCEPT_CONTENT = ['json']
|
||||
CELERY_TASK_SERIALIZER = 'json'
|
||||
CELERY_RESULT_SERIALIZER = 'json'
|
||||
CELERY_REDIS_MAX_CONNECTIONS = 5
|
||||
CELERYBEAT_SCHEDULE = {
|
||||
'mark-soon-to-expire-credit-cards': {
|
||||
'task': 'snakeeyes.blueprints.billing.tasks.mark_old_credit_cards',
|
||||
'schedule': crontab(hour=0, minute=0)
|
||||
},
|
||||
'expire-old-coupons': {
|
||||
'task': 'snakeeyes.blueprints.billing.tasks.expire_old_coupons',
|
||||
'schedule': crontab(hour=0, minute=1)
|
||||
},
|
||||
}
|
||||
|
||||
# SQLAlchemy.
|
||||
db_uri = 'postgresql://snakeeyes:devpassword@postgres:5432/snakeeyes'
|
||||
SQLALCHEMY_DATABASE_URI = db_uri
|
||||
SQLALCHEMY_TRACK_MODIFICATIONS = False
|
||||
|
||||
# User.
|
||||
SEED_ADMIN_EMAIL = 'dev@local.host'
|
||||
SEED_ADMIN_PASSWORD = 'devpassword'
|
||||
REMEMBER_COOKIE_DURATION = timedelta(days=90)
|
||||
|
||||
# Billing.
|
||||
STRIPE_SECRET_KEY = None
|
||||
STRIPE_PUBLISHABLE_KEY = None
|
||||
STRIPE_API_VERSION = '2016-03-07'
|
||||
STRIPE_CURRENCY = 'usd'
|
||||
STRIPE_PLANS = {
|
||||
'0': {
|
||||
'id': 'bronze',
|
||||
'name': 'Bronze',
|
||||
'amount': 100,
|
||||
'currency': STRIPE_CURRENCY,
|
||||
'interval': 'month',
|
||||
'interval_count': 1,
|
||||
'trial_period_days': 14,
|
||||
'statement_descriptor': 'SNAKEEYES BRONZE',
|
||||
'metadata': {
|
||||
'coins': 110
|
||||
}
|
||||
},
|
||||
'1': {
|
||||
'id': 'gold',
|
||||
'name': 'Gold',
|
||||
'amount': 500,
|
||||
'currency': STRIPE_CURRENCY,
|
||||
'interval': 'month',
|
||||
'interval_count': 1,
|
||||
'trial_period_days': 14,
|
||||
'statement_descriptor': 'SNAKEEYES GOLD',
|
||||
'metadata': {
|
||||
'coins': 600,
|
||||
'recommended': True
|
||||
}
|
||||
},
|
||||
'2': {
|
||||
'id': 'platinum',
|
||||
'name': 'Platinum',
|
||||
'amount': 1000,
|
||||
'currency': STRIPE_CURRENCY,
|
||||
'interval': 'month',
|
||||
'interval_count': 1,
|
||||
'trial_period_days': 14,
|
||||
'statement_descriptor': 'SNAKEEYES PLATINUM',
|
||||
'metadata': {
|
||||
'coins': 1500
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
COIN_BUNDLES = [
|
||||
{'coins': 100, 'price_in_cents': 100, 'label': '100 for $1'},
|
||||
{'coins': 1000, 'price_in_cents': 900, 'label': '1,000 for $9'},
|
||||
{'coins': 5000, 'price_in_cents': 4000, 'label': '5,000 for $40'},
|
||||
{'coins': 10000, 'price_in_cents': 7000, 'label': '10,000 for $70'},
|
||||
]
|
||||
|
||||
# Bet.
|
||||
DICE_ROLL_PAYOUT = {
|
||||
'2': 36.0,
|
||||
'3': 18.0,
|
||||
'4': 12.0,
|
||||
'5': 9.0,
|
||||
'6': 7.2,
|
||||
'7': 6.0,
|
||||
'8': 7.2,
|
||||
'9': 9.0,
|
||||
'10': 12.0,
|
||||
'11': 18.0,
|
||||
'12': 36.0
|
||||
}
|
||||
|
||||
RATELIMIT_STORAGE_URL = CELERY_BROKER_URL
|
||||
RATELIMIT_STRATEGY = 'fixed-window-elastic-expiry'
|
||||
RATELIMIT_HEADERS_ENABLED = True
|
||||
@@ -0,0 +1,42 @@
|
||||
version: '2'
|
||||
|
||||
services:
|
||||
postgres:
|
||||
image: 'postgres:9.5'
|
||||
env_file:
|
||||
- '.env'
|
||||
volumes:
|
||||
- 'postgres:/var/lib/postgresql/data'
|
||||
ports:
|
||||
- '5432:5432'
|
||||
|
||||
redis:
|
||||
image: 'redis:3.0-alpine'
|
||||
command: redis-server --requirepass devpassword
|
||||
volumes:
|
||||
- 'redis:/var/lib/redis/data'
|
||||
ports:
|
||||
- '6379:6379'
|
||||
|
||||
website:
|
||||
build: .
|
||||
command: >
|
||||
gunicorn -c "python:config.gunicorn" --reload "snakeeyes.app:create_app()"
|
||||
env_file:
|
||||
- '.env'
|
||||
volumes:
|
||||
- '.:/snakeeyes'
|
||||
ports:
|
||||
- '8000:8000'
|
||||
|
||||
celery:
|
||||
build: .
|
||||
command: celery worker -B -l info -A snakeeyes.blueprints.contact.tasks
|
||||
env_file:
|
||||
- '.env'
|
||||
volumes:
|
||||
- '.:/snakeeyes'
|
||||
|
||||
volumes:
|
||||
postgres:
|
||||
redis:
|
||||
@@ -0,0 +1,5 @@
|
||||
MAIL_USERNAME = 'buildasaasappwithflask@gmail.com'
|
||||
MAIL_PASSWORD = 'helicopterpantswalrusfoot'
|
||||
|
||||
STRIPE_SECRET_KEY = 'sk_test_nycOOQdO9C16zxubr2WWtbug'
|
||||
STRIPE_PUBLISHABLE_KEY = 'pk_test_ClU5mzNj1YxRRnrdZB5jEO29'
|
||||
@@ -0,0 +1,18 @@
|
||||
DEBUG = False
|
||||
|
||||
ANALYTICS_GOOGLE_UA = 'UA-abc123trackingcode-1'
|
||||
|
||||
SERVER_NAME = 'nickjanetakis.com'
|
||||
SECRET_KEY = 'generateastrong128chartoken'
|
||||
|
||||
MAIL_USERNAME = 'you@realemailaccount.com'
|
||||
MAIL_PASSWORD = 'thebestpasswordyouevermade'
|
||||
|
||||
CELERY_BROKER_URL = 'redis://:amuchmoresecurepassword@redis:6379/0'
|
||||
CELERY_RESULT_BACKEND = 'redis://:amuchmoresecurepassword@redis:6379/0'
|
||||
|
||||
db_uri = 'postgresql://snakeeyes:themostsecurepasswordevercreated@postgres:5432/snakeeyes'
|
||||
SQLALCHEMY_DATABASE_URI = db_uri
|
||||
|
||||
SEED_ADMIN_EMAIL = 'you@realemailaccount.com'
|
||||
SEED_ADMIN_PASSWORD = 'thebestpasswordyouevermade'
|
||||
@@ -0,0 +1,69 @@
|
||||
from flask import render_template
|
||||
|
||||
from snakeeyes.extensions import mail
|
||||
|
||||
|
||||
def send_template_message(template=None, ctx=None, *args, **kwargs):
|
||||
"""
|
||||
Send a templated e-mail using a similar signature as Flask-Mail:
|
||||
http://pythonhosted.org/Flask-Mail/
|
||||
|
||||
Except, it also supports template rendering. If you want to use a template
|
||||
then just omit the body and html kwargs to Flask-Mail and instead supply
|
||||
a path to a template. It will auto-lookup and render text/html messages.
|
||||
|
||||
Example:
|
||||
ctx = {'user': current_user, 'reset_token': token}
|
||||
send_template_message('Password reset from Foo', ['you@example.com'],
|
||||
template='user/mail/password_reset', ctx=ctx)
|
||||
|
||||
:param subject:
|
||||
:param recipients:
|
||||
:param body:
|
||||
:param html:
|
||||
:param sender:
|
||||
:param cc:
|
||||
:param bcc:
|
||||
:param attachments:
|
||||
:param reply_to:
|
||||
:param date:
|
||||
:param charset:
|
||||
:param extra_headers:
|
||||
:param mail_options:
|
||||
:param rcpt_options:
|
||||
:param template: Path to a template without the extension
|
||||
:param context: Dictionary of anything you want in the template context
|
||||
:return: None
|
||||
"""
|
||||
if ctx is None:
|
||||
ctx = {}
|
||||
|
||||
if template is not None:
|
||||
if 'body' in kwargs:
|
||||
raise Exception('You cannot have both a template and body arg.')
|
||||
elif 'html' in kwargs:
|
||||
raise Exception('You cannot have both a template and body arg.')
|
||||
|
||||
kwargs['body'] = _try_renderer_template(template, **ctx)
|
||||
kwargs['html'] = _try_renderer_template(template, ext='html', **ctx)
|
||||
|
||||
mail.send_message(*args, **kwargs)
|
||||
|
||||
return None
|
||||
|
||||
|
||||
def _try_renderer_template(template_path, ext='txt', **kwargs):
|
||||
"""
|
||||
Attempt to render a template. We use a try/catch here to avoid having to
|
||||
do a path exists based on a relative path to the template.
|
||||
|
||||
:param template_path: Template path
|
||||
:type template_path: str
|
||||
:param ext: File extension
|
||||
:type ext: str
|
||||
:return: str
|
||||
"""
|
||||
try:
|
||||
return render_template('{0}.{1}'.format(template_path, ext), **kwargs)
|
||||
except IOError:
|
||||
pass
|
||||
@@ -0,0 +1,158 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
|
||||
from collections import OrderedDict
|
||||
|
||||
|
||||
class Currency(object):
|
||||
TYPES = OrderedDict([
|
||||
('usd', u'United States Dollar'),
|
||||
('aed', u'United Arab Emirates Dirham'),
|
||||
('afn', u'Afghan Afghani'),
|
||||
('all', u'Albanian Lek'),
|
||||
('amd', u'Armenian Dram'),
|
||||
('ang', u'Netherlands Antillean Gulden'),
|
||||
('aoa', u'Angolan Kwanza'),
|
||||
('ars', u'Argentine Peso'),
|
||||
('aud', u'Australian Dollar'),
|
||||
('awg', u'Aruban Florin'),
|
||||
('azn', u'Azerbaijani Manat'),
|
||||
('bam', u'Bosnia & Herzegovina Convertible Mark'),
|
||||
('bbd', u'Barbadian Dollar'),
|
||||
('bdt', u'Bangladeshi Taka'),
|
||||
('bgn', u'Bulgarian Lev'),
|
||||
('bif', u'Burundian Franc'),
|
||||
('bmd', u'Bermudian Dollar'),
|
||||
('bnd', u'Brunei Dollar'),
|
||||
('bob', u'Bolivian Boliviano'),
|
||||
('brl', u'Brazilian Real'),
|
||||
('bsd', u'Bahamian Dollar'),
|
||||
('bwp', u'Botswana Pula'),
|
||||
('bzd', u'Belize Dollar'),
|
||||
('cad', u'Canadian Dollar'),
|
||||
('cdf', u'Congolese Franc'),
|
||||
('chf', u'Swiss Franc'),
|
||||
('clp', u'Chilean Peso'),
|
||||
('cny', u'Chinese Renminbi Yuan'),
|
||||
('cop', u'Colombian Peso'),
|
||||
('crc', u'Costa Rican Colón'),
|
||||
('cve', u'Cape Verdean Escudo'),
|
||||
('czk', u'Czech Koruna'),
|
||||
('djf', u'Djiboutian Franc'),
|
||||
('dkk', u'Danish Krone'),
|
||||
('dop', u'Dominican Peso'),
|
||||
('dzd', u'Algerian Dinar'),
|
||||
('eek', u'Estonian Kroon'),
|
||||
('egp', u'Egyptian Pound'),
|
||||
('etb', u'Ethiopian Birr'),
|
||||
('eur', u'Euro'),
|
||||
('fjd', u'Fijian Dollar'),
|
||||
('fkp', u'Falkland Islands Pound'),
|
||||
('gbp', u'British Pound'),
|
||||
('gel', u'Georgian Lari'),
|
||||
('gip', u'Gibraltar Pound'),
|
||||
('gmd', u'Gambian Dalasi'),
|
||||
('gnf', u'Guinean Franc'),
|
||||
('gtq', u'Guatemalan Quetzal'),
|
||||
('gyd', u'Guyanese Dollar'),
|
||||
('hkd', u'Hong Kong Dollar'),
|
||||
('hnl', u'Honduran Lempira'),
|
||||
('hrk', u'Croatian Kuna'),
|
||||
('htg', u'Haitian Gourde'),
|
||||
('huf', u'Hungarian Forint'),
|
||||
('idr', u'Indonesian Rupiah'),
|
||||
('ils', u'Israeli New Sheqel'),
|
||||
('inr', u'Indian Rupee'),
|
||||
('isk', u'Icelandic Króna'),
|
||||
('jmd', u'Jamaican Dollar'),
|
||||
('jpy', u'Japanese Yen'),
|
||||
('kes', u'Kenyan Shilling'),
|
||||
('kgs', u'Kyrgyzstani Som'),
|
||||
('khr', u'Cambodian Riel'),
|
||||
('kmf', u'Comorian Franc'),
|
||||
('krw', u'South Korean Won'),
|
||||
('kyd', u'Cayman Islands Dollar'),
|
||||
('kzt', u'Kazakhstani Tenge'),
|
||||
('lak', u'Lao Kip'),
|
||||
('lbp', u'Lebanese Pound'),
|
||||
('lkr', u'Sri Lankan Rupee'),
|
||||
('lrd', u'Liberian Dollar'),
|
||||
('lsl', u'Lesotho Loti'),
|
||||
('ltl', u'Lithuanian Litas'),
|
||||
('lvl', u'Latvian Lats'),
|
||||
('mad', u'Moroccan Dirham'),
|
||||
('mdl', u'Moldovan Leu'),
|
||||
('mga', u'Malagasy Ariary'),
|
||||
('mkd', u'Macedonian Denar'),
|
||||
('mnt', u'Mongolian Tögrög'),
|
||||
('mop', u'Macanese Pataca'),
|
||||
('mro', u'Mauritanian Ouguiya'),
|
||||
('mur', u'Mauritian Rupee'),
|
||||
('mvr', u'Maldivian Rufiyaa'),
|
||||
('mwk', u'Malawian Kwacha'),
|
||||
('mxn', u'Mexican Peso'),
|
||||
('myr', u'Malaysian Ringgit'),
|
||||
('mzn', u'Mozambican Metical'),
|
||||
('nad', u'Namibian Dollar'),
|
||||
('ngn', u'Nigerian Naira'),
|
||||
('nio', u'Nicaraguan Córdoba'),
|
||||
('nok', u'Norwegian Krone'),
|
||||
('npr', u'Nepalese Rupee'),
|
||||
('nzd', u'New Zealand Dollar'),
|
||||
('pab', u'Panamanian Balboa'),
|
||||
('pen', u'Peruvian Nuevo Sol'),
|
||||
('pgk', u'Papua New Guinean Kina'),
|
||||
('php', u'Philippine Peso'),
|
||||
('pkr', u'Pakistani Rupee'),
|
||||
('pln', u'Polish Złoty'),
|
||||
('pyg', u'Paraguayan Guaraní'),
|
||||
('qar', u'Qatari Riyal'),
|
||||
('ron', u'Romanian Leu'),
|
||||
('rsd', u'Serbian Dinar'),
|
||||
('rub', u'Russian Ruble'),
|
||||
('rwf', u'Rwandan Franc'),
|
||||
('sar', u'Saudi Riyal'),
|
||||
('sbd', u'Solomon Islands Dollar'),
|
||||
('scr', u'Seychellois Rupee'),
|
||||
('sek', u'Swedish Krona'),
|
||||
('sgd', u'Singapore Dollar'),
|
||||
('shp', u'Saint Helenian Pound'),
|
||||
('sll', u'Sierra Leonean Leone'),
|
||||
('sos', u'Somali Shilling'),
|
||||
('srd', u'Surinamese Dollar'),
|
||||
('std', u'São Tomé and Príncipe Dobra'),
|
||||
('svc', u'Salvadoran Colón'),
|
||||
('szl', u'Swazi Lilangeni'),
|
||||
('thb', u'Thai Baht'),
|
||||
('tjs', u'Tajikistani Somoni'),
|
||||
('top', u'Tongan Paʻanga'),
|
||||
('try', u'Turkish Lira'),
|
||||
('ttd', u'Trinidad and Tobago Dollar'),
|
||||
('twd', u'New Taiwan Dollar'),
|
||||
('tzs', u'Tanzanian Shilling'),
|
||||
('uah', u'Ukrainian Hryvnia'),
|
||||
('ugx', u'Ugandan Shilling'),
|
||||
('uyu', u'Uruguayan Peso'),
|
||||
('uzs', u'Uzbekistani Som'),
|
||||
('vef', u'Venezuelan Bolívar'),
|
||||
('vnd', u'Vietnamese Đồng'),
|
||||
('vuv', u'Vanuatu Vatu'),
|
||||
('wst', u'Samoan Tala'),
|
||||
('xaf', u'Central African Cfa Franc'),
|
||||
('xcd', u'East Caribbean Dollar'),
|
||||
('xof', u'West African Cfa Franc'),
|
||||
('xpf', u'Cfp Franc'),
|
||||
('yer', u'Yemeni Rial'),
|
||||
('zar', u'South African Rand'),
|
||||
('zmw', u'Zambian Kwacha')
|
||||
])
|
||||
|
||||
@classmethod
|
||||
def lookup(cls, currency_code):
|
||||
"""
|
||||
Return the full currency name.
|
||||
|
||||
:param currency_code: Currency abbreviation
|
||||
:type currency_code: str
|
||||
:return: str
|
||||
"""
|
||||
return Currency.TYPES[currency_code]
|
||||
@@ -0,0 +1,20 @@
|
||||
def cents_to_dollars(cents):
|
||||
"""
|
||||
Convert cents to dollars.
|
||||
|
||||
:param cents: Amount in cents
|
||||
:type cents: int
|
||||
:return: float
|
||||
"""
|
||||
return round(cents / 100.0, 2)
|
||||
|
||||
|
||||
def dollars_to_cents(dollars):
|
||||
"""
|
||||
Convert dollars to cents.
|
||||
|
||||
:param dollars: Amount in dollars
|
||||
:type dollars: float
|
||||
:return: int
|
||||
"""
|
||||
return int(dollars * 100)
|
||||
@@ -0,0 +1,19 @@
|
||||
try:
|
||||
from urlparse import urljoin
|
||||
except ImportError:
|
||||
from urllib.parse import urljoin
|
||||
|
||||
|
||||
from flask import request
|
||||
|
||||
|
||||
def safe_next_url(target):
|
||||
"""
|
||||
Ensure a relative URL path is on the same domain as this host.
|
||||
This protects against the 'Open redirect vulnerability'.
|
||||
|
||||
:param target: Relative url (typically supplied by Flask-Login)
|
||||
:type target: str
|
||||
:return: str
|
||||
"""
|
||||
return urljoin(request.host_url, target)
|
||||
@@ -0,0 +1,75 @@
|
||||
import pytest
|
||||
from flask import url_for
|
||||
|
||||
|
||||
def assert_status_with_message(status_code=200, response=None, message=None):
|
||||
"""
|
||||
Check to see if a message is contained within a response.
|
||||
|
||||
:param status_code: Status code that defaults to 200
|
||||
:type status_code: int
|
||||
:param response: Flask response
|
||||
:type response: str
|
||||
:param message: String to check for
|
||||
:type message: str
|
||||
:return: None
|
||||
"""
|
||||
assert response.status_code == status_code
|
||||
assert message in str(response.data)
|
||||
|
||||
|
||||
class ViewTestMixin(object):
|
||||
"""
|
||||
Automatically load in a session and client, this is common for a lot of
|
||||
tests that work with views.
|
||||
"""
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def set_common_fixtures(self, session, client):
|
||||
self.session = session
|
||||
self.client = client
|
||||
|
||||
def login(self, identity='admin@local.host', password='password'):
|
||||
"""
|
||||
Login a specific user.
|
||||
|
||||
:return: Flask response
|
||||
"""
|
||||
return login(self.client, identity, password)
|
||||
|
||||
def logout(self):
|
||||
"""
|
||||
Logout a specific user.
|
||||
|
||||
:return: Flask response
|
||||
"""
|
||||
return logout(self.client)
|
||||
|
||||
|
||||
def login(client, username='', password=''):
|
||||
"""
|
||||
Log a specific user in.
|
||||
|
||||
:param client: Flask client
|
||||
:param username: The username
|
||||
:type username: str
|
||||
:param password: The password
|
||||
:type password: str
|
||||
:return: Flask response
|
||||
"""
|
||||
user = dict(identity=username, password=password)
|
||||
|
||||
response = client.post(url_for('user.login'), data=user,
|
||||
follow_redirects=True)
|
||||
|
||||
return response
|
||||
|
||||
|
||||
def logout(client):
|
||||
"""
|
||||
Log a specific user out.
|
||||
|
||||
:param client: Flask client
|
||||
:return: Flask response
|
||||
"""
|
||||
return client.get(url_for('user.logout'), follow_redirects=True)
|
||||
@@ -0,0 +1,30 @@
|
||||
import datetime
|
||||
import pytz
|
||||
|
||||
|
||||
def tzware_datetime():
|
||||
"""
|
||||
Return a timezone aware datetime.
|
||||
|
||||
:return: Datetime
|
||||
"""
|
||||
return datetime.datetime.now(pytz.utc)
|
||||
|
||||
|
||||
def timedelta_months(months, compare_date=None):
|
||||
"""
|
||||
Return a new datetime with a month offset applied.
|
||||
|
||||
:param months: Amount of months to offset
|
||||
:type months: int
|
||||
:param compare_date: Date to compare at
|
||||
:type compare_date: date
|
||||
:return: datetime
|
||||
"""
|
||||
if compare_date is None:
|
||||
compare_date = datetime.date.today()
|
||||
|
||||
delta = months * 365 / 12
|
||||
compare_date_with_delta = compare_date + datetime.timedelta(delta)
|
||||
|
||||
return compare_date_with_delta
|
||||
@@ -0,0 +1,21 @@
|
||||
from flask import jsonify
|
||||
|
||||
|
||||
def render_json(status, *args, **kwargs):
|
||||
"""
|
||||
Return a JSON response.
|
||||
|
||||
Example usage:
|
||||
render_json(404, {'error': 'Discount code not found.'})
|
||||
render_json(200, {'data': coupon.to_json()})
|
||||
|
||||
:param status: HTTP status code
|
||||
:type status: int
|
||||
:param args:
|
||||
:param kwargs:
|
||||
:return: Flask response
|
||||
"""
|
||||
response = jsonify(*args, **kwargs)
|
||||
response.status_code = status
|
||||
|
||||
return response
|
||||
@@ -0,0 +1,139 @@
|
||||
import datetime
|
||||
|
||||
from sqlalchemy import DateTime
|
||||
from sqlalchemy.types import TypeDecorator
|
||||
|
||||
from lib.util_datetime import tzware_datetime
|
||||
from snakeeyes.extensions import db
|
||||
|
||||
|
||||
class AwareDateTime(TypeDecorator):
|
||||
"""
|
||||
A DateTime type which can only store tz-aware DateTimes.
|
||||
|
||||
Source:
|
||||
https://gist.github.com/inklesspen/90b554c864b99340747e
|
||||
"""
|
||||
impl = DateTime(timezone=True)
|
||||
|
||||
def process_bind_param(self, value, dialect):
|
||||
if isinstance(value, datetime.datetime) and value.tzinfo is None:
|
||||
raise ValueError('{!r} must be TZ-aware'.format(value))
|
||||
return value
|
||||
|
||||
def __repr__(self):
|
||||
return 'AwareDateTime()'
|
||||
|
||||
|
||||
class ResourceMixin(object):
|
||||
# Keep track when records are created and updated.
|
||||
created_on = db.Column(AwareDateTime(),
|
||||
default=tzware_datetime)
|
||||
updated_on = db.Column(AwareDateTime(),
|
||||
default=tzware_datetime,
|
||||
onupdate=tzware_datetime)
|
||||
|
||||
@classmethod
|
||||
def sort_by(cls, field, direction):
|
||||
"""
|
||||
Validate the sort field and direction.
|
||||
|
||||
:param field: Field name
|
||||
:type field: str
|
||||
:param direction: Direction
|
||||
:type direction: str
|
||||
:return: tuple
|
||||
"""
|
||||
if field not in cls.__table__.columns:
|
||||
field = 'created_on'
|
||||
|
||||
if direction not in ('asc', 'desc'):
|
||||
direction = 'asc'
|
||||
|
||||
return field, direction
|
||||
|
||||
@classmethod
|
||||
def get_bulk_action_ids(cls, scope, ids, omit_ids=[], query=''):
|
||||
"""
|
||||
Determine which IDs are to be modified.
|
||||
|
||||
:param scope: Affect all or only a subset of items
|
||||
:type scope: str
|
||||
:param ids: List of ids to be modified
|
||||
:type ids: list
|
||||
:param omit_ids: Remove 1 or more IDs from the list
|
||||
:type omit_ids: list
|
||||
:param query: Search query (if applicable)
|
||||
:type query: str
|
||||
:return: list
|
||||
"""
|
||||
# Hello. This is Nick from the future (July 2020 to be exact). This
|
||||
# needed to be patched to include a call to list(), otherwise it was
|
||||
# deleting every user instead of skipping the current_user.
|
||||
omit_ids = list(map(str, omit_ids))
|
||||
|
||||
# Hello. This is Nick from the future (July 2022 to be exact). I added
|
||||
# the query condition below to make this a bit more intuitive so that
|
||||
# an empty search query won't delete everything. Prior to this an empty
|
||||
# query would have been technically all of the results.
|
||||
if query and scope == 'all_search_results':
|
||||
# Change the scope to go from selected ids to all search results.
|
||||
ids = cls.query.with_entities(cls.id).filter(cls.search(query))
|
||||
|
||||
# SQLAlchemy returns back a list of tuples, we want a list of strs.
|
||||
ids = [str(item[0]) for item in ids]
|
||||
|
||||
# Remove 1 or more items from the list, this could be useful in spots
|
||||
# where you may want to protect the current user from deleting themself
|
||||
# when bulk deleting user accounts.
|
||||
if omit_ids:
|
||||
ids = [id for id in ids if id not in omit_ids]
|
||||
|
||||
return ids
|
||||
|
||||
@classmethod
|
||||
def bulk_delete(cls, ids):
|
||||
"""
|
||||
Delete 1 or more model instances.
|
||||
|
||||
:param ids: List of ids to be deleted
|
||||
:type ids: list
|
||||
:return: Number of deleted instances
|
||||
"""
|
||||
delete_count = cls.query.filter(cls.id.in_(ids)).delete(
|
||||
synchronize_session=False)
|
||||
db.session.commit()
|
||||
|
||||
return delete_count
|
||||
|
||||
def save(self):
|
||||
"""
|
||||
Save a model instance.
|
||||
|
||||
:return: Model instance
|
||||
"""
|
||||
db.session.add(self)
|
||||
db.session.commit()
|
||||
|
||||
return self
|
||||
|
||||
def delete(self):
|
||||
"""
|
||||
Delete a model instance.
|
||||
|
||||
:return: db.session.commit()'s result
|
||||
"""
|
||||
db.session.delete(self)
|
||||
return db.session.commit()
|
||||
|
||||
def __str__(self):
|
||||
"""
|
||||
Create a human readable version of a class instance.
|
||||
|
||||
:return: self
|
||||
"""
|
||||
obj_id = hex(id(self))
|
||||
columns = self.__table__.c.keys()
|
||||
|
||||
values = ', '.join("%s=%r" % (n, getattr(self, n)) for n in columns)
|
||||
return '<%s %s(%s)>' % (obj_id, self.__class__.__name__, values)
|
||||
@@ -0,0 +1,89 @@
|
||||
from flask_wtf import Form
|
||||
|
||||
|
||||
class ModelForm(Form):
|
||||
"""
|
||||
wtforms_components exposes ModelForm but their ModelForm does not inherit
|
||||
from flask_wtf's Form, but instead WTForm's Form.
|
||||
|
||||
However, in order to get CSRF protection handled by default we need to
|
||||
inherit from flask_wtf's Form. So let's just copy his class directly.
|
||||
|
||||
We modified it by removing the format argument so that wtforms_component
|
||||
uses its own default which is to pass in request.form automatically.
|
||||
"""
|
||||
def __init__(self, obj=None, prefix='', **kwargs):
|
||||
Form.__init__(
|
||||
self, obj=obj, prefix=prefix, **kwargs
|
||||
)
|
||||
self._obj = obj
|
||||
|
||||
|
||||
def choices_from_dict(source, prepend_blank=True):
|
||||
"""
|
||||
Convert a dict to a format that's compatible with WTForm's choices. It also
|
||||
optionally prepends a "Please select one..." value.
|
||||
|
||||
Example:
|
||||
# Convert this data structure:
|
||||
STATUS = OrderedDict([
|
||||
('unread', 'Unread'),
|
||||
('open', 'Open'),
|
||||
('contacted', 'Contacted'),
|
||||
('closed', 'Closed')
|
||||
])
|
||||
|
||||
# Into this:
|
||||
choices = [('', 'Please select one...'), ('unread', 'Unread) ...]
|
||||
|
||||
:param source: Input source
|
||||
:type source: dict
|
||||
:param prepend_blank: An optional blank item
|
||||
:type prepend_blank: bool
|
||||
:return: list
|
||||
"""
|
||||
choices = []
|
||||
|
||||
if prepend_blank:
|
||||
choices.append(('', 'Please select one...'))
|
||||
|
||||
for key, value in source.items():
|
||||
pair = (key, value)
|
||||
choices.append(pair)
|
||||
|
||||
return choices
|
||||
|
||||
|
||||
def choices_from_list(source, prepend_blank=True):
|
||||
"""
|
||||
Convert a list to a format that's compatible with WTForm's choices. It also
|
||||
optionally prepends a "Please select one..." value.
|
||||
|
||||
Example:
|
||||
# Convert this data structure:
|
||||
TIMEZONES = (
|
||||
'Africa/Abidjan',
|
||||
'Africa/Accra',
|
||||
'Africa/Addis_Ababa'
|
||||
)
|
||||
|
||||
# Into this:
|
||||
choices = [('', 'Please select one...'),
|
||||
('Africa/Abidjan', 'Africa/Abidjan) ...]
|
||||
|
||||
:param source: Input source
|
||||
:type source: list or tuple
|
||||
:param prepend_blank: An optional blank item
|
||||
:type prepend_blank: bool
|
||||
:return: list
|
||||
"""
|
||||
choices = []
|
||||
|
||||
if prepend_blank:
|
||||
choices.append(('', 'Please select one...'))
|
||||
|
||||
for item in source:
|
||||
pair = (item, item)
|
||||
choices.append(pair)
|
||||
|
||||
return choices
|
||||
@@ -0,0 +1,105 @@
|
||||
Flask==0.10.1
|
||||
|
||||
# Hello. This is Nick from the future (March 2019 to be exact). An emergency
|
||||
# addition had to be made below to fix an issue related to a recent version of
|
||||
# werkzeug. This library was not version locked in this file.
|
||||
#
|
||||
# The line below isn't covered on video, but it locks werkzeug to the latest
|
||||
# version that works with this course's code base. A future update video will
|
||||
# cover upgrading this package and more.
|
||||
werkzeug==0.14.1
|
||||
|
||||
# Hello. This is Nick from the future (Feb 2022 to be exact). A new addition
|
||||
# had to be made below to fix an issue related to the version of Flask
|
||||
# that we use. A new major version of both itsdangerous and markupsafe came up
|
||||
# that are no longer backwards compatible with our version of Flask.
|
||||
#
|
||||
# These packages are both dependencies of Flask and now we're locking them to
|
||||
# a specific version that works with our version of Flask.
|
||||
itsdangerous==1.1.0
|
||||
markupsafe==1.1.1
|
||||
|
||||
# Hello. This is Nick from the future (March 2022 to be exact). A new addition
|
||||
# had to be made below to fix an issue with a recent release of Jinja 3.1. It's
|
||||
# breaking all sorts of libraries, so let's lock it to a stable 3.0.X version.
|
||||
#
|
||||
# Jinja 2 is the HTML templating library that Flask uses.
|
||||
jinja2==3.0.3
|
||||
|
||||
# Application server for both development and production.
|
||||
gunicorn==19.4.5
|
||||
|
||||
# Testing and static analysis.
|
||||
#
|
||||
# Hello. This is Nick from the future (December 2019 to be exact). Since we
|
||||
# upgraded to Python 3.7.x, we also have to update pytest to 5.x.x since older
|
||||
# versions of it are not compatable with Python 3.7+.
|
||||
#
|
||||
# This line is different than what's on video but we do cover this update in
|
||||
# more detail in the October 2019 update video. When you run your tests you
|
||||
# may also see extra warnings and details than what's on video. Don't sweat it.
|
||||
pytest==5.1.0
|
||||
pytest-cov==2.7.1
|
||||
flake8==3.7.8
|
||||
mock==1.3.0
|
||||
|
||||
# CLI.
|
||||
Click==6.4
|
||||
|
||||
# Data and workers.
|
||||
#
|
||||
# Hello. This is Nick from the future (August 2019 to be exact). An emergency
|
||||
# addition had to be made below to fix an issue related to Docker recently
|
||||
# changing the internals of their Python image.
|
||||
#
|
||||
# On video it shows psycopg2 version 2.6.1 but I had to bump it to 2.8.3 here.
|
||||
psycopg2==2.8.3
|
||||
Flask-SQLAlchemy==2.1
|
||||
|
||||
# Hello. This is Nick from the future (March 2019 to be exact). An emergency
|
||||
# addition had to be made below to fix an issue that resulted in SQLAlchemy
|
||||
# throwing an error. Flask-SQLAlchemy 2.1 doesn't version lock SQLAlchemy and
|
||||
# SQLAlchemy 1.3.x introduced breaking changes.
|
||||
#
|
||||
# The line below isn't covered on video, but it locks SQLAlchemy to 1.2.0. A
|
||||
# future update video will cover upgrading SQLAlchemy to 1.3.0 and more.
|
||||
SQLAlchemy==1.2.0
|
||||
|
||||
# Hello. This is Nick from the future (October 2022 to be exact). Celery
|
||||
# started to break because they didn't version lock this dependency and it was
|
||||
# recently updated to 5.X which had breaking changes. Let's stick with 4.X.
|
||||
importlib-metadata==4.13.0
|
||||
|
||||
# Hello. This is Nick from the future (December 2019 to be exact). Since we
|
||||
# upgraded to Python 3.7.x, we also have to update Celery to 4.3.x since older
|
||||
# versions of it are not compatible with Python 3.7+.
|
||||
#
|
||||
# This line is different than what's on video but we do cover this update in
|
||||
# more detail in the October 2019 update video.
|
||||
#
|
||||
# And this is Nick again from September 2020. Turns out Celery 4.3.x started to
|
||||
# break due to one of its dependencies not being locked. Now we're using 4.4.0.
|
||||
redis==3.3.7
|
||||
celery==4.4.0
|
||||
|
||||
# Forms.
|
||||
Flask-WTF==0.9.5
|
||||
WTForms-Components==0.9.7
|
||||
|
||||
# Hello. This is Nick from the future (Nov 2021 to be exact). WTForms 3.x came
|
||||
# out recently and has a number of backwards incompatible changes. Flask-WTF
|
||||
# will install the latest version so we need to version lock WTForms to 2.3.3.
|
||||
# A future update video will address using the latest WTForms version.
|
||||
WTForms==2.3.3
|
||||
|
||||
# Payments.
|
||||
stripe==1.32.0
|
||||
|
||||
# Utils.
|
||||
fake-factory==0.5.7
|
||||
|
||||
# Extensions.
|
||||
flask-debugtoolbar==0.10.0
|
||||
Flask-Mail==0.9.1
|
||||
Flask-Login==0.3.2
|
||||
Flask-Limiter==0.9.3
|
||||
@@ -0,0 +1,15 @@
|
||||
from setuptools import setup
|
||||
|
||||
setup(
|
||||
name='SnakeEyes-CLI',
|
||||
version='1.0',
|
||||
packages=['cli', 'cli.commands'],
|
||||
include_package_data=True,
|
||||
install_requires=[
|
||||
'click',
|
||||
],
|
||||
entry_points="""
|
||||
[console_scripts]
|
||||
snakeeyes=cli.cli:cli
|
||||
""",
|
||||
)
|
||||
@@ -0,0 +1,226 @@
|
||||
import logging
|
||||
|
||||
from logging.handlers import SMTPHandler
|
||||
|
||||
import stripe
|
||||
|
||||
from werkzeug.contrib.fixers import ProxyFix
|
||||
from flask import Flask, render_template
|
||||
from celery import Celery
|
||||
from itsdangerous import URLSafeTimedSerializer
|
||||
|
||||
from snakeeyes.blueprints.admin import admin
|
||||
from snakeeyes.blueprints.page import page
|
||||
from snakeeyes.blueprints.contact import contact
|
||||
from snakeeyes.blueprints.user import user
|
||||
from snakeeyes.blueprints.billing import billing
|
||||
from snakeeyes.blueprints.billing import stripe_webhook
|
||||
from snakeeyes.blueprints.bet import bet
|
||||
from snakeeyes.blueprints.user.models import User
|
||||
from snakeeyes.blueprints.billing.template_processors import (
|
||||
format_currency,
|
||||
current_year
|
||||
)
|
||||
from snakeeyes.extensions import (
|
||||
debug_toolbar,
|
||||
mail,
|
||||
csrf,
|
||||
db,
|
||||
login_manager,
|
||||
limiter
|
||||
)
|
||||
|
||||
CELERY_TASK_LIST = [
|
||||
'snakeeyes.blueprints.contact.tasks',
|
||||
'snakeeyes.blueprints.user.tasks',
|
||||
'snakeeyes.blueprints.billing.tasks',
|
||||
]
|
||||
|
||||
|
||||
def create_celery_app(app=None):
|
||||
"""
|
||||
Create a new Celery object and tie together the Celery config to the app's
|
||||
config. Wrap all tasks in the context of the application.
|
||||
|
||||
:param app: Flask app
|
||||
:return: Celery app
|
||||
"""
|
||||
app = app or create_app()
|
||||
|
||||
celery = Celery(app.import_name, broker=app.config['CELERY_BROKER_URL'],
|
||||
include=CELERY_TASK_LIST)
|
||||
celery.conf.update(app.config)
|
||||
TaskBase = celery.Task
|
||||
|
||||
class ContextTask(TaskBase):
|
||||
abstract = True
|
||||
|
||||
def __call__(self, *args, **kwargs):
|
||||
with app.app_context():
|
||||
return TaskBase.__call__(self, *args, **kwargs)
|
||||
|
||||
celery.Task = ContextTask
|
||||
return celery
|
||||
|
||||
|
||||
def create_app(settings_override=None):
|
||||
"""
|
||||
Create a Flask application using the app factory pattern.
|
||||
|
||||
:param settings_override: Override settings
|
||||
:return: Flask app
|
||||
"""
|
||||
app = Flask(__name__, instance_relative_config=True)
|
||||
|
||||
app.config.from_object('config.settings')
|
||||
app.config.from_pyfile('settings.py', silent=True)
|
||||
|
||||
if settings_override:
|
||||
app.config.update(settings_override)
|
||||
|
||||
stripe.api_key = app.config.get('STRIPE_SECRET_KEY')
|
||||
stripe.api_version = app.config.get('STRIPE_API_VERSION')
|
||||
|
||||
middleware(app)
|
||||
error_templates(app)
|
||||
exception_handler(app)
|
||||
app.register_blueprint(admin)
|
||||
app.register_blueprint(page)
|
||||
app.register_blueprint(contact)
|
||||
app.register_blueprint(user)
|
||||
app.register_blueprint(billing)
|
||||
app.register_blueprint(stripe_webhook)
|
||||
app.register_blueprint(bet)
|
||||
template_processors(app)
|
||||
extensions(app)
|
||||
authentication(app, User)
|
||||
|
||||
return app
|
||||
|
||||
|
||||
def extensions(app):
|
||||
"""
|
||||
Register 0 or more extensions (mutates the app passed in).
|
||||
|
||||
:param app: Flask application instance
|
||||
:return: None
|
||||
"""
|
||||
debug_toolbar.init_app(app)
|
||||
mail.init_app(app)
|
||||
csrf.init_app(app)
|
||||
db.init_app(app)
|
||||
login_manager.init_app(app)
|
||||
limiter.init_app(app)
|
||||
|
||||
return None
|
||||
|
||||
|
||||
def template_processors(app):
|
||||
"""
|
||||
Register 0 or more custom template processors (mutates the app passed in).
|
||||
|
||||
:param app: Flask application instance
|
||||
:return: App jinja environment
|
||||
"""
|
||||
app.jinja_env.filters['format_currency'] = format_currency
|
||||
app.jinja_env.globals.update(current_year=current_year)
|
||||
|
||||
return app.jinja_env
|
||||
|
||||
|
||||
def authentication(app, user_model):
|
||||
"""
|
||||
Initialize the Flask-Login extension (mutates the app passed in).
|
||||
|
||||
:param app: Flask application instance
|
||||
:param user_model: Model that contains the authentication information
|
||||
:type user_model: SQLAlchemy model
|
||||
:return: None
|
||||
"""
|
||||
login_manager.login_view = 'user.login'
|
||||
|
||||
@login_manager.user_loader
|
||||
def load_user(uid):
|
||||
return user_model.query.get(uid)
|
||||
|
||||
@login_manager.token_loader
|
||||
def load_token(token):
|
||||
duration = app.config['REMEMBER_COOKIE_DURATION'].total_seconds()
|
||||
serializer = URLSafeTimedSerializer(app.secret_key)
|
||||
|
||||
data = serializer.loads(token, max_age=duration)
|
||||
user_uid = data[0]
|
||||
|
||||
return user_model.query.get(user_uid)
|
||||
|
||||
|
||||
def middleware(app):
|
||||
"""
|
||||
Register 0 or more middleware (mutates the app passed in).
|
||||
|
||||
:param app: Flask application instance
|
||||
:return: None
|
||||
"""
|
||||
# Swap request.remote_addr with the real IP address even if behind a proxy.
|
||||
app.wsgi_app = ProxyFix(app.wsgi_app)
|
||||
|
||||
return None
|
||||
|
||||
|
||||
def error_templates(app):
|
||||
"""
|
||||
Register 0 or more custom error pages (mutates the app passed in).
|
||||
|
||||
:param app: Flask application instance
|
||||
:return: None
|
||||
"""
|
||||
|
||||
def render_status(status):
|
||||
"""
|
||||
Render a custom template for a specific status.
|
||||
Source: http://stackoverflow.com/a/30108946
|
||||
|
||||
:param status: Status as a written name
|
||||
:type status: str
|
||||
:return: None
|
||||
"""
|
||||
# Get the status code from the status, default to a 500 so that we
|
||||
# catch all types of errors and treat them as a 500.
|
||||
code = getattr(status, 'code', 500)
|
||||
return render_template('errors/{0}.html'.format(code)), code
|
||||
|
||||
for error in [404, 429, 500]:
|
||||
app.errorhandler(error)(render_status)
|
||||
|
||||
return None
|
||||
|
||||
|
||||
def exception_handler(app):
|
||||
"""
|
||||
Register 0 or more exception handlers (mutates the app passed in).
|
||||
|
||||
:param app: Flask application instance
|
||||
:return: None
|
||||
"""
|
||||
mail_handler = SMTPHandler((app.config.get('MAIL_SERVER'),
|
||||
app.config.get('MAIL_PORT')),
|
||||
app.config.get('MAIL_USERNAME'),
|
||||
[app.config.get('MAIL_USERNAME')],
|
||||
'[Exception handler] A 5xx was thrown',
|
||||
(app.config.get('MAIL_USERNAME'),
|
||||
app.config.get('MAIL_PASSWORD')),
|
||||
secure=())
|
||||
|
||||
mail_handler.setLevel(logging.ERROR)
|
||||
mail_handler.setFormatter(logging.Formatter("""
|
||||
Time: %(asctime)s
|
||||
Message type: %(levelname)s
|
||||
|
||||
|
||||
Message:
|
||||
|
||||
%(message)s
|
||||
"""))
|
||||
app.logger.addHandler(mail_handler)
|
||||
|
||||
return None
|
||||
@@ -0,0 +1 @@
|
||||
from snakeeyes.blueprints.admin.views import admin
|
||||
@@ -0,0 +1,124 @@
|
||||
from collections import OrderedDict
|
||||
|
||||
from flask_wtf import Form
|
||||
from wtforms import (
|
||||
SelectField,
|
||||
StringField,
|
||||
BooleanField,
|
||||
HiddenField,
|
||||
IntegerField,
|
||||
FloatField,
|
||||
DateTimeField
|
||||
)
|
||||
from wtforms.validators import (
|
||||
DataRequired,
|
||||
Length,
|
||||
Optional,
|
||||
Regexp,
|
||||
NumberRange
|
||||
)
|
||||
from wtforms_components import Unique
|
||||
|
||||
from lib.locale import Currency
|
||||
from lib.util_wtforms import ModelForm, choices_from_dict
|
||||
from snakeeyes.blueprints.user.models import db, User
|
||||
from snakeeyes.blueprints.billing.models.coupon import Coupon
|
||||
|
||||
|
||||
class SearchForm(Form):
|
||||
q = StringField('Search terms', [Optional(), Length(1, 256)])
|
||||
|
||||
|
||||
class BulkDeleteForm(Form):
|
||||
SCOPE = OrderedDict([
|
||||
('all_selected_items', 'All selected items'),
|
||||
('all_search_results', 'All search results')
|
||||
])
|
||||
|
||||
# Hello. This is Nick from the future (July 2022 to be exact). I modified
|
||||
# things by adding this hidden form field to the admin users and coupons
|
||||
# pages. This hidden field is now also included in both admin index pages
|
||||
# that include the bulk delete form:
|
||||
#
|
||||
# It's on line 22 in the admin/user/index.html page and line 34 in the
|
||||
# admin/coupon/index.html page. No inline comments were added there.
|
||||
q = HiddenField('Search term', [Optional(), Length(1, 10)])
|
||||
|
||||
scope = SelectField('Privileges', [DataRequired()],
|
||||
choices=choices_from_dict(SCOPE, prepend_blank=False))
|
||||
|
||||
|
||||
class UserForm(ModelForm):
|
||||
username_message = 'Letters, numbers and underscores only please.'
|
||||
|
||||
coins = IntegerField('Coins', [DataRequired(),
|
||||
NumberRange(min=1, max=2147483647)])
|
||||
|
||||
username = StringField(validators=[
|
||||
Unique(
|
||||
User.username,
|
||||
get_session=lambda: db.session
|
||||
),
|
||||
Optional(),
|
||||
Length(1, 16),
|
||||
# Part of the Python 3.7.x update included updating flake8 which means
|
||||
# we need to explicitly define our regex pattern with r'xxx'.
|
||||
Regexp(r'^\w+$', message=username_message)
|
||||
])
|
||||
|
||||
role = SelectField('Privileges', [DataRequired()],
|
||||
choices=choices_from_dict(User.ROLE,
|
||||
prepend_blank=False))
|
||||
active = BooleanField('Yes, allow this user to sign in')
|
||||
|
||||
|
||||
class UserCancelSubscriptionForm(Form):
|
||||
pass
|
||||
|
||||
|
||||
class CouponForm(Form):
|
||||
percent_off = IntegerField('Percent off (%)', [Optional(),
|
||||
NumberRange(min=1,
|
||||
max=100)])
|
||||
amount_off = FloatField('Amount off ($)', [Optional(),
|
||||
NumberRange(min=0.01,
|
||||
max=21474836.47)])
|
||||
code = StringField('Code', [DataRequired(), Length(1, 32)])
|
||||
currency = SelectField('Currency', [DataRequired()],
|
||||
choices=choices_from_dict(Currency.TYPES,
|
||||
prepend_blank=False))
|
||||
duration = SelectField('Duration', [DataRequired()],
|
||||
choices=choices_from_dict(Coupon.DURATION,
|
||||
prepend_blank=False))
|
||||
duration_in_months = IntegerField('Duration in months', [Optional(),
|
||||
NumberRange(
|
||||
min=1,
|
||||
max=12)])
|
||||
max_redemptions = IntegerField('Max Redemptions',
|
||||
[Optional(), NumberRange(min=1,
|
||||
max=2147483647)])
|
||||
redeem_by = DateTimeField('Redeem by', [Optional()],
|
||||
format='%Y-%m-%d %H:%M:%S')
|
||||
|
||||
def validate(self):
|
||||
if not Form.validate(self):
|
||||
return False
|
||||
|
||||
result = True
|
||||
percent_off = self.percent_off.data
|
||||
amount_off = self.amount_off.data
|
||||
|
||||
if percent_off is None and amount_off is None:
|
||||
empty_error = 'Pick at least one.'
|
||||
self.percent_off.errors.append(empty_error)
|
||||
self.amount_off.errors.append(empty_error)
|
||||
result = False
|
||||
elif percent_off and amount_off:
|
||||
both_error = 'Cannot pick both.'
|
||||
self.percent_off.errors.append(both_error)
|
||||
self.amount_off.errors.append(both_error)
|
||||
result = False
|
||||
else:
|
||||
pass
|
||||
|
||||
return result
|
||||
@@ -0,0 +1,73 @@
|
||||
from sqlalchemy import func
|
||||
|
||||
from snakeeyes.blueprints.user.models import db, User
|
||||
from snakeeyes.blueprints.billing.models.subscription import Subscription
|
||||
from snakeeyes.blueprints.bet.models.bet import Bet
|
||||
|
||||
|
||||
class Dashboard(object):
|
||||
@classmethod
|
||||
def group_and_count_users(cls):
|
||||
"""
|
||||
Perform a group by/count on all users.
|
||||
|
||||
:return: dict
|
||||
"""
|
||||
return Dashboard._group_and_count(User, User.role)
|
||||
|
||||
@classmethod
|
||||
def group_and_count_plans(cls):
|
||||
"""
|
||||
Perform a group by/count on all subscriber types.
|
||||
|
||||
:return: dict
|
||||
"""
|
||||
return Dashboard._group_and_count(Subscription, Subscription.plan)
|
||||
|
||||
@classmethod
|
||||
def group_and_count_coupons(cls):
|
||||
"""
|
||||
Obtain coupon usage statistics across all subscribers.
|
||||
|
||||
:return: tuple
|
||||
"""
|
||||
not_null = db.session.query(Subscription).filter(
|
||||
Subscription.coupon.isnot(None)).count()
|
||||
total = db.session.query(func.count(Subscription.id)).scalar()
|
||||
|
||||
if total == 0:
|
||||
percent = 0
|
||||
else:
|
||||
percent = round((not_null / float(total)) * 100, 1)
|
||||
|
||||
return not_null, total, percent
|
||||
|
||||
@classmethod
|
||||
def group_and_count_payouts(cls):
|
||||
"""
|
||||
Perform a group by/count on all payouts.
|
||||
|
||||
:return: dict
|
||||
"""
|
||||
return Dashboard._group_and_count(Bet, Bet.payout)
|
||||
|
||||
@classmethod
|
||||
def _group_and_count(cls, model, field):
|
||||
"""
|
||||
Group results for a specific model and field.
|
||||
|
||||
:param model: Name of the model
|
||||
:type model: SQLAlchemy model
|
||||
:param field: Name of the field to group on
|
||||
:type field: SQLAlchemy field
|
||||
:return: dict
|
||||
"""
|
||||
count = func.count(field)
|
||||
query = db.session.query(count, field).group_by(field).all()
|
||||
|
||||
results = {
|
||||
'query': query,
|
||||
'total': model.query.count()
|
||||
}
|
||||
|
||||
return results
|
||||
+5
@@ -0,0 +1,5 @@
|
||||
<li><a href="{{ url_for('admin.dashboard') }}">Dashboard</a></li>
|
||||
<li role="separator" class="divider"></li>
|
||||
<li><a href="{{ url_for('admin.users') }}">Users</a></li>
|
||||
<li><a href="{{ url_for('admin.coupons') }}">Coupons</a></li>
|
||||
<li><a href="{{ url_for('admin.invoices') }}">Invoices</a></li>
|
||||
+76
@@ -0,0 +1,76 @@
|
||||
{% import 'macros/form.html' as f with context %}
|
||||
|
||||
{% if request.endpoint.endswith('new') %}
|
||||
{% set endpoint = 'admin.coupons_new' %}
|
||||
{% set form_kwargs = {} %}
|
||||
{% set legend = 'Add a new coupon' %}
|
||||
{% set button = 'Add' %}
|
||||
{% else %}
|
||||
{% set endpoint = 'admin.coupons_edit' %}
|
||||
{% set form_kwargs = {'id': coupon.id} %}
|
||||
{% set legend = 'Update this coupon' %}
|
||||
{% set button = 'Save' %}
|
||||
{% endif %}
|
||||
|
||||
<div class="row">
|
||||
<div class="col-md-4 col-md-offset-4 well">
|
||||
{% call f.form_tag(endpoint, **form_kwargs) %}
|
||||
<legend>{{ legend }}</legend>
|
||||
<p class="small text-muted">
|
||||
You may pick either a percent <strong>or</strong> amount off.
|
||||
</p>
|
||||
|
||||
<div class="row margin-bottom">
|
||||
<div class="col-md-6">
|
||||
{% call f.form_group(form.percent_off) %}
|
||||
{% endcall %}
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
{% call f.form_group(form.amount_off) %}
|
||||
{% endcall %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% call f.form_group(form.code, css_class='sm-margin-bottom') %}
|
||||
{% endcall %}
|
||||
|
||||
{% call f.form_group(form.currency, css_class='sm-margin-bottom') %}
|
||||
{% endcall %}
|
||||
|
||||
{% call f.form_group(form.duration, css_class='sm-margin-bottom') %}
|
||||
{% endcall %}
|
||||
|
||||
<div id="duration-in-months">
|
||||
{% call f.form_group(form.duration_in_months, css_class='sm-margin-bottom') %}
|
||||
{% endcall %}
|
||||
</div>
|
||||
|
||||
{% call f.form_group(form.max_redemptions, css_class='sm-margin-bottom') %}
|
||||
{% endcall %}
|
||||
|
||||
<div class="dt relative">
|
||||
{% call f.form_group(form.redeem_by) %}
|
||||
{% endcall %}
|
||||
</div>
|
||||
|
||||
<hr/>
|
||||
<div class="row">
|
||||
<div class="col-md-6">
|
||||
<button type="submit" class="btn btn-primary btn-block">
|
||||
<img src="{{ url_for('static', filename='images/spinner.gif') }}"
|
||||
class="spinner"
|
||||
width="16" height="11" alt="Spinner"/>
|
||||
{{ button }}
|
||||
</button>
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<div class="visible-xs visible-sm sm-margin-top"></div>
|
||||
<a href="{{ url_for('admin.coupons') }}"
|
||||
class="btn btn-default btn-block">
|
||||
Cancel
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
{% endcall %}
|
||||
</div>
|
||||
</div>
|
||||
+7
@@ -0,0 +1,7 @@
|
||||
{% extends 'layouts/app.html' %}
|
||||
|
||||
{% block title %}Admin - Coupons / Update{% endblock %}
|
||||
|
||||
{% block body %}
|
||||
{% include 'admin/coupon/_form.html' with context %}
|
||||
{% endblock %}
|
||||
+103
@@ -0,0 +1,103 @@
|
||||
{% extends 'layouts/app.html' %}
|
||||
{% import 'macros/items.html' as items %}
|
||||
{% import 'macros/form.html' as f with context %}
|
||||
{% import 'billing/macros/billing.html' as billing with context %}
|
||||
|
||||
{% block title %}Admin - Coupons / List{% endblock %}
|
||||
|
||||
{% block body %}
|
||||
<div class="row">
|
||||
<div class="col-md-6">
|
||||
{{ f.search('admin.coupons') }}
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<a href="{{ url_for('admin.coupons_new') }}"
|
||||
class="btn btn-primary pull-right">
|
||||
Create coupon
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% if coupons.total == 0 %}
|
||||
<h3>No results found</h3>
|
||||
|
||||
{% if request.args.get('q') %}
|
||||
<p>Try limiting or removing your search terms.</p>
|
||||
{% else %}
|
||||
<p>
|
||||
There are no coupons present,
|
||||
you should <a href="{{ url_for('admin.coupons_new') }}">create one</a>.
|
||||
</p>
|
||||
{% endif %}
|
||||
{% else %}
|
||||
{% call f.form_tag('admin.coupons_bulk_delete') %}
|
||||
<input type="hidden" id="q" name="q" value="{{ request.args.get('q') }}">
|
||||
|
||||
<table class="table table-striped">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>
|
||||
<label for="select_all"></label>
|
||||
<input id="select_all" name="select_all" type="checkbox">
|
||||
</th>
|
||||
<th class="col-header">
|
||||
{{ items.sort('created_on', 'Created') }}
|
||||
</th>
|
||||
<th class="col-header">
|
||||
{{ items.sort('code') }}
|
||||
</th>
|
||||
<th class="col-header">
|
||||
{{ items.sort('duration') }}
|
||||
</th>
|
||||
<th class="col-header">
|
||||
{{ items.sort('redeem_by', 'Expires') }}
|
||||
</th>
|
||||
<th class="col-header">
|
||||
{{ items.sort('times_redeemed', 'Redeemed') }}
|
||||
</th>
|
||||
<th id="bulk_actions" colspan="5">
|
||||
<div class="form-inline">
|
||||
{{ f.field(bulk_form.scope, inline=True) }}
|
||||
<button type="submit"
|
||||
class="btn btn-danger btn-sm">
|
||||
Delete items
|
||||
</button>
|
||||
</div>
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for coupon in coupons.items %}
|
||||
<tr class="{{ 'half-faded' if not coupon.valid }}">
|
||||
<td>
|
||||
<label for="bulk_ids"></label>
|
||||
<input class="checkbox-item" id="bulk_ids"
|
||||
name="bulk_ids" type="checkbox" value="{{ coupon.id }}">
|
||||
</td>
|
||||
<td>
|
||||
<time class="from-now"
|
||||
data-datetime="{{ coupon.created_on }}">
|
||||
{{ coupon.created_on }}
|
||||
</time>
|
||||
</td>
|
||||
<td>
|
||||
{{ coupon.code }}
|
||||
|
||||
<p class="small text-muted">
|
||||
{{ billing.coupon_discount(coupon.amount_off, coupon.percent_off) }}
|
||||
discount
|
||||
</p>
|
||||
</td>
|
||||
<td>{{ billing.coupon_duration_tag_for(coupon) }}</td>
|
||||
<td>{{ billing.coupon_expiration(coupon) }}</td>
|
||||
<td>{{ billing.coupon_redeemed(coupon.times_redeemed, coupon.max_redemptions) }}</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
{% endcall %}
|
||||
|
||||
{{ items.paginate(coupons) }}
|
||||
{% endif %}
|
||||
{% endblock %}
|
||||
|
||||
+7
@@ -0,0 +1,7 @@
|
||||
{% extends 'layouts/app.html' %}
|
||||
|
||||
{% block title %}Admin - Coupons / Add{% endblock %}
|
||||
|
||||
{% block body %}
|
||||
{% include 'admin/coupon/_form.html' with context %}
|
||||
{% endblock %}
|
||||
+88
@@ -0,0 +1,88 @@
|
||||
{% extends 'layouts/app.html' %}
|
||||
{% import 'macros/items.html' as items %}
|
||||
{% import 'macros/form.html' as f with context %}
|
||||
{% import 'macros/user.html' as account %}
|
||||
|
||||
{% block title %}Admin - Invoices / List{% endblock %}
|
||||
|
||||
{% block body %}
|
||||
{{ f.search('admin.invoices') }}
|
||||
|
||||
{% if invoices.total == 0 %}
|
||||
<h3>No results found</h3>
|
||||
|
||||
{% if request.args.get('q') %}
|
||||
<p>Try limiting or removing your search terms.</p>
|
||||
{% else %}
|
||||
<p>There are no invoices present, you should market your service.</p>
|
||||
{% endif %}
|
||||
{% else %}
|
||||
<table class="table table-striped">
|
||||
<thead>
|
||||
<tr>
|
||||
<th class="col-header">
|
||||
{{ items.sort('created_on', 'Date') }}
|
||||
</th>
|
||||
<th class="col-header">
|
||||
{{ items.sort('user_id', 'User') }}
|
||||
</th>
|
||||
<th class="col-header">
|
||||
{{ items.sort('description', 'Description') }}
|
||||
</th>
|
||||
<th class="col-header">
|
||||
{{ items.sort('period_start_on', 'Service period') }}
|
||||
</th>
|
||||
<th class="col-header">
|
||||
{{ items.sort('tax', 'Tax') }}
|
||||
</th>
|
||||
<th class="col-header">
|
||||
{{ items.sort('total', 'Total') }}
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for invoice in invoices.items %}
|
||||
<tr>
|
||||
<td>
|
||||
<time class="from-now"
|
||||
data-datetime="{{ invoice.created_on }}">
|
||||
{{ invoice.created_on }}
|
||||
</time>
|
||||
</td>
|
||||
<td>
|
||||
<a href="{{ url_for('admin.users_edit', id=invoice.users.id) }}">
|
||||
<div class="pull-left">
|
||||
{{ account.role_icon_for(invoice.users) }}
|
||||
</div>
|
||||
<h4 class="media-heading">{{ invoice.users.username }}</h4>
|
||||
|
||||
<p class="text-muted">{{ invoice.users.email }}</p>
|
||||
</a>
|
||||
</td>
|
||||
<td class="small">
|
||||
{{ invoice.brand }} ****{{ invoice.last4 }}
|
||||
<br/>
|
||||
{{ invoice.description }}
|
||||
</td>
|
||||
<td>
|
||||
<time class="short-date" data-datetime="{{ invoice.period_start_on }}">
|
||||
{{ invoice.period_start_on }}
|
||||
</time>
|
||||
<span class="text-info">—</span>
|
||||
<time class="short-date" data-datetime="{{ invoice.period_end_on }}">
|
||||
{{ invoice.period_end_on }}
|
||||
</time>
|
||||
</td>
|
||||
<td title="{{ (invoice.tax_percent | round(2)) | string + '%' if invoice.tax_percent }}">
|
||||
{{ '$' + invoice.tax | format_currency if invoice.tax }}
|
||||
</td>
|
||||
<td title="{{ invoice.currency }}">
|
||||
${{ invoice.total | format_currency }}
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
{{ items.paginate(invoices) }}
|
||||
{% endif %}
|
||||
{% endblock %}
|
||||
+107
@@ -0,0 +1,107 @@
|
||||
{% extends 'layouts/app.html' %}
|
||||
|
||||
{% block title %}Admin - Dashboard{% endblock %}
|
||||
|
||||
{% block body %}
|
||||
<div class="row">
|
||||
<div class="col-md-4">
|
||||
<div class="panel panel-default">
|
||||
<div class="panel-heading">
|
||||
<a href="{{ url_for('admin.coupons') }}">Billing</a>
|
||||
<span class="pull-right text-muted">
|
||||
{{ group_and_count_plans.total }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="panel-body">
|
||||
<h4>Subscriptions</h4>
|
||||
{% for item in group_and_count_plans.query %}
|
||||
{% set percent = ((item[0] / group_and_count_plans.total) * 100) | round %}
|
||||
<h5>
|
||||
{{ item[1] | title }}
|
||||
<span class="text-muted">({{ item[0] }})</span>
|
||||
</h5>
|
||||
<div class="progress">
|
||||
<div class="progress-bar" role="progressbar"
|
||||
aria-valuenow="{{ percent }}" aria-valuemin="0"
|
||||
aria-valuemax="100" style="width: {{ percent }}%;">
|
||||
{{ percent }}%
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
<hr/>
|
||||
<h4>
|
||||
Coupons
|
||||
<a href="{{ url_for('admin.coupons_new') }}"
|
||||
class="btn btn-default btn-sm pull-right">Add</a>
|
||||
</h4>
|
||||
<h5 class="small text-muted">
|
||||
Subscribers are using
|
||||
{{ group_and_count_coupons[0] }} coupon(s)
|
||||
</h5>
|
||||
|
||||
<div class="progress">
|
||||
<div class="progress-bar" role="progressbar"
|
||||
aria-valuenow="{{ group_and_count_coupons[2] }}"
|
||||
aria-valuemin="0"
|
||||
aria-valuemax="100"
|
||||
style="width: {{ group_and_count_coupons[2] }}%;">
|
||||
{{ group_and_count_coupons[2] }}%
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-4">
|
||||
<div class="panel panel-default">
|
||||
<div class="panel-heading">
|
||||
<a href="{{ url_for('admin.users') }}">Users</a>
|
||||
<span class="pull-right text-muted">
|
||||
{{ group_and_count_users.total }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="panel-body">
|
||||
{% for item in group_and_count_users.query %}
|
||||
{% set percent = ((item[0] / group_and_count_users.total) * 100) | round %}
|
||||
<h5>
|
||||
{{ item[1] | title }}
|
||||
<span class="text-muted">({{ item[0] }})</span>
|
||||
</h5>
|
||||
<div class="progress">
|
||||
<div class="progress-bar" role="progressbar"
|
||||
aria-valuenow="{{ percent }}" aria-valuemin="0"
|
||||
aria-valuemax="100" style="width: {{ percent }}%;">
|
||||
{{ percent }}%
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-4">
|
||||
<div class="panel panel-default">
|
||||
<div class="panel-heading">
|
||||
Payouts
|
||||
<span class="pull-right text-muted">
|
||||
{{ group_and_count_payouts.total }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="panel-body">
|
||||
{% for item in group_and_count_payouts.query %}
|
||||
{% set percent = ((item[0] / group_and_count_payouts.total) * 100) | round %}
|
||||
<h5>
|
||||
{{ item[1] | title }}x
|
||||
<span class="text-muted">({{ item[0] }})</span>
|
||||
</h5>
|
||||
<div class="progress">
|
||||
<div class="progress-bar" role="progressbar"
|
||||
aria-valuenow="{{ percent }}" aria-valuemin="0"
|
||||
aria-valuemax="100" style="width: {{ percent }}%;">
|
||||
{{ percent }}%
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
+178
@@ -0,0 +1,178 @@
|
||||
{% import 'macros/form.html' as f with context %}
|
||||
|
||||
{% if request.endpoint.endswith('new') %}
|
||||
{% set endpoint = 'admin.users_new' %}
|
||||
{% set form_kwargs = {} %}
|
||||
{% set legend = 'Add a new user' %}
|
||||
{% set button = 'Add' %}
|
||||
{% else %}
|
||||
{% set endpoint = 'admin.users_edit' %}
|
||||
{% set form_kwargs = {'id': user.id} %}
|
||||
{% set legend = 'Update this user' %}
|
||||
{% set button = 'Save' %}
|
||||
{% endif %}
|
||||
|
||||
<div class="row">
|
||||
<div class="col-md-5 well">
|
||||
{% call f.form_tag(endpoint, **form_kwargs) %}
|
||||
<legend>{{ legend }}</legend>
|
||||
<div class="form-group">
|
||||
<label class="control-label"><strong>Registered</strong></label>
|
||||
<p class="sm-margin-bottom form-control-static">
|
||||
<time class="from-now" data-datetime="{{ user.created_on }}">
|
||||
{{ user.created_on }}
|
||||
</time>
|
||||
</p>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="control-label"><strong>E-mail address</strong></label>
|
||||
<p class="sm-margin-bottom form-control-static">
|
||||
{{ user.email }}
|
||||
</p>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="control-label"><strong>Last bet on</strong></label>
|
||||
<p class="sm-margin-bottom form-control-static">
|
||||
{% if user.last_bet_on %}
|
||||
<time class="from-now" data-datetime="{{ user.last_bet_on }}">
|
||||
{{ user.last_bet_on }}
|
||||
</time>
|
||||
{% else %}
|
||||
N/A
|
||||
{% endif %}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{% call f.form_group(form.coins, css_class='sm-margin-bottom',
|
||||
autofocus='autofocus') %}
|
||||
{% endcall %}
|
||||
|
||||
{% call f.form_group(form.username, css_class='sm-margin-bottom') %}
|
||||
{% endcall %}
|
||||
|
||||
{% call f.form_group(form.role, css_class='sm-margin-bottom') %}
|
||||
{% endcall %}
|
||||
|
||||
{% call f.form_group(form.active) %}
|
||||
{% endcall %}
|
||||
|
||||
<hr/>
|
||||
<div class="row">
|
||||
<div class="col-md-6">
|
||||
<button type="submit" class="btn btn-primary btn-block">
|
||||
{{ button }}
|
||||
</button>
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<div class="visible-xs visible-sm sm-margin-top"></div>
|
||||
<a href="{{ url_for('admin.users') }}"
|
||||
class="btn btn-default btn-block">
|
||||
Cancel
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
{% endcall %}
|
||||
</div>
|
||||
<div class="col-md-5 col-md-push-1 col-md-offset-1">
|
||||
<h3>Billing details</h3>
|
||||
<div class="panel panel-default">
|
||||
<div class="panel-body">
|
||||
<dl>
|
||||
{% if user.subscription and user.credit_card %}
|
||||
<dt>Subscribed</dt>
|
||||
<dd class="sm-margin-bottom">
|
||||
<time class="from-now"
|
||||
data-datetime="{{ user.subscription.created_on }}">
|
||||
{{ user.subscription.created_on }}
|
||||
</time>
|
||||
(<a href="{{ url_for('admin.invoices', q=user.email)}}">
|
||||
View billing details
|
||||
</a>)
|
||||
</dd>
|
||||
<dt>Plan</dt>
|
||||
<dd class="sm-margin-bottom">
|
||||
{{ user.subscription.plan | title }}
|
||||
{% if user.subscription.coupon %}
|
||||
<span class="small text-muted">
|
||||
(coupon: {{ user.subscription.coupon }})
|
||||
</span>
|
||||
{% endif %}
|
||||
</dd>
|
||||
<dt>Credit card</dt>
|
||||
<dd class="sm-margin-bottom">
|
||||
{{ user.name }}<br/>
|
||||
{{ user.credit_card.brand }}
|
||||
****{{ user.credit_card.last4 }}
|
||||
({{ user.credit_card.exp_date.strftime('%m/%Y') }})
|
||||
</dd>
|
||||
{% call f.form_tag('admin.users_cancel_subscription') %}
|
||||
<input type="hidden" id="id" name="id" value="{{ user.id }}"/>
|
||||
<button type="submit" class="btn btn-danger">
|
||||
<img src="{{ url_for('static', filename='images/spinner.gif') }}"
|
||||
class="spinner"
|
||||
width="16" height="11" alt="Spinner"/>
|
||||
Cancel subscription
|
||||
</button>
|
||||
{% endcall %}
|
||||
{% else %}
|
||||
{% if user.cancelled_subscription_on %}
|
||||
<dt>Cancelled</dt>
|
||||
<dd>
|
||||
<time class="from-now"
|
||||
data-datetime="{{ user.cancelled_subscription_on }}">
|
||||
{{ user.cancelled_subscription_on }}
|
||||
</time>
|
||||
{% if user.invoices %}
|
||||
(<a href="{{ url_for('admin.invoices', q=user.email)}}">
|
||||
View billing details
|
||||
</a>)
|
||||
{% endif %}
|
||||
</dd>
|
||||
{% else %}
|
||||
<p>
|
||||
This user has never subscribed.
|
||||
{% if user.invoices %}
|
||||
(<a href="{{ url_for('admin.invoices', q=user.email)}}">
|
||||
View billing details
|
||||
</a>)
|
||||
{% endif %}
|
||||
</p>
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
</dl>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h3>Login activity</h3>
|
||||
<div class="panel panel-default">
|
||||
<div class="panel-body">
|
||||
<dl>
|
||||
<dt>Sign in count</dt>
|
||||
<dd class="sm-margin-bottom">{{ user.sign_in_count }}</dd>
|
||||
<dt>Current sign in date</dt>
|
||||
<dd class="sm-margin-bottom">
|
||||
{% if user.current_sign_in_on %}
|
||||
<time class="from-now"
|
||||
data-datetime="{{ user.current_sign_in_on }}">
|
||||
{{ user.current_sign_in_on }}
|
||||
</time>
|
||||
{% endif %}
|
||||
</dd>
|
||||
<dt>Current sign in IP address</dt>
|
||||
<dd class="sm-margin-bottom">{{ user.current_sign_in_ip }}</dd>
|
||||
<dt>Previous sign in date</dt>
|
||||
<dd class="sm-margin-bottom">
|
||||
{% if user.last_sign_in_on %}
|
||||
<time class="from-now"
|
||||
data-datetime="{{ user.last_sign_in_on }}">
|
||||
{{ user.last_sign_in_on }}
|
||||
</time>
|
||||
{% endif %}
|
||||
</dd>
|
||||
<dt>Previous sign in IP address</dt>
|
||||
<dd>{{ user.last_sign_in_ip }}</dd>
|
||||
</dl>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
+7
@@ -0,0 +1,7 @@
|
||||
{% extends 'layouts/app.html' %}
|
||||
|
||||
{% block title %}Admin - Users / Update{% endblock %}
|
||||
|
||||
{% block body %}
|
||||
{% include 'admin/user/_form.html' with context %}
|
||||
{% endblock %}
|
||||
+104
@@ -0,0 +1,104 @@
|
||||
{% extends 'layouts/app.html' %}
|
||||
{% import 'macros/items.html' as items %}
|
||||
{% import 'macros/form.html' as f with context %}
|
||||
{% import 'macros/user.html' as account %}
|
||||
|
||||
{% block title %}Admin - Users / List{% endblock %}
|
||||
|
||||
{% block body %}
|
||||
{{ f.search('admin.users') }}
|
||||
|
||||
{% if users.total == 0 %}
|
||||
<h3>No results found</h3>
|
||||
|
||||
{% if request.args.get('q') %}
|
||||
<p>Try limiting or removing your search terms.</p>
|
||||
{% else %}
|
||||
<p>There are no users present,
|
||||
you should <a href="{{ url_for('user.signup') }}">sign up</a>.</p>
|
||||
{% endif %}
|
||||
{% else %}
|
||||
{% call f.form_tag('admin.users_bulk_delete') %}
|
||||
<input type="hidden" id="q" name="q" value="{{ request.args.get('q') }}">
|
||||
|
||||
<table class="table table-striped">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>
|
||||
<label for="select_all"></label>
|
||||
<input id="select_all" name="select_all" type="checkbox">
|
||||
</th>
|
||||
<th class="col-header">
|
||||
{{ items.sort('created_on', 'Registered') }}
|
||||
</th>
|
||||
<th class="col-header">
|
||||
{{ items.sort('name') }}
|
||||
</th>
|
||||
<th class="col-header">
|
||||
{{ items.sort('last_bet_on', 'Activity') }}
|
||||
</th>
|
||||
<th class="col-header">
|
||||
{{ items.sort('sign_in_count', 'Sign in count') }}
|
||||
</th>
|
||||
<th id="bulk_actions" colspan="4">
|
||||
<div class="form-inline">
|
||||
{{ f.field(bulk_form.scope, inline=True) }}
|
||||
<button type="submit"
|
||||
class="btn btn-danger btn-sm">
|
||||
Delete items
|
||||
</button>
|
||||
</div>
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for user in users.items %}
|
||||
<tr>
|
||||
<td>
|
||||
<label for="bulk_ids"></label>
|
||||
<input class="checkbox-item" id="bulk_ids" name="bulk_ids"
|
||||
type="checkbox" value="{{ user.id }}">
|
||||
</td>
|
||||
<td>
|
||||
<time class="from-now"
|
||||
data-datetime="{{ user.created_on }}">
|
||||
{{ user.created_on }}
|
||||
</time>
|
||||
</td>
|
||||
<td>
|
||||
<a href="{{ url_for('admin.users_edit', id=user.id) }}">
|
||||
<div class="pull-left">
|
||||
{{ account.role_icon_for(user) }}
|
||||
</div>
|
||||
<h4 class="media-heading">{{ user.username }}</h4>
|
||||
|
||||
<p class="text-muted">{{ user.email }}</p>
|
||||
</a>
|
||||
</td>
|
||||
<td class="small">
|
||||
{% if user.last_sign_in_on %}
|
||||
Last seen:
|
||||
<time class="from-now"
|
||||
data-datetime="{{ user.last_sign_in_on }}">
|
||||
{{ user.last_sign_in_on }}
|
||||
</time>
|
||||
{% endif %}
|
||||
{% if user.last_bet_on %}
|
||||
<br/>
|
||||
Latest bet:
|
||||
<time class="from-now"
|
||||
data-datetime="{{ user.last_bet_on }}">
|
||||
{{ user.last_bet_on }}
|
||||
</time>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td>{{ user.sign_in_count }}</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
{% endcall %}
|
||||
|
||||
{{ items.paginate(users) }}
|
||||
{% endif %}
|
||||
{% endblock %}
|
||||
+7
@@ -0,0 +1,7 @@
|
||||
{% extends 'layouts/app.html' %}
|
||||
|
||||
{% block title %}Admin - Users / Add{% endblock %}
|
||||
|
||||
{% block body %}
|
||||
{% include 'admin/user/_form.html' with context %}
|
||||
{% endblock %}
|
||||
+11
@@ -0,0 +1,11 @@
|
||||
{% macro role_icon_for(user) -%}
|
||||
{% if user.role == 'admin' %}
|
||||
<i class="fa fa-3x fa-fw fa-shield" title="Admin"></i>
|
||||
{% else %}
|
||||
{% if user.subscription %}
|
||||
<i class="fa fa-3x fa-fw fa-star text-success" title="Paid member"></i>
|
||||
{% else %}
|
||||
<i class="fa fa-3x fa-fw fa-user text-muted" title="Member"></i>
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
{% endmacro %}
|
||||
@@ -0,0 +1,246 @@
|
||||
from flask import (
|
||||
Blueprint,
|
||||
redirect,
|
||||
request,
|
||||
flash,
|
||||
url_for,
|
||||
render_template)
|
||||
from flask_login import login_required, current_user
|
||||
from sqlalchemy import text
|
||||
|
||||
from snakeeyes.blueprints.admin.models import Dashboard
|
||||
from snakeeyes.blueprints.user.decorators import role_required
|
||||
from snakeeyes.blueprints.billing.decorators import handle_stripe_exceptions
|
||||
from snakeeyes.blueprints.billing.models.coupon import Coupon
|
||||
from snakeeyes.blueprints.billing.models.subscription import Subscription
|
||||
from snakeeyes.blueprints.billing.models.invoice import Invoice
|
||||
from snakeeyes.blueprints.user.models import User
|
||||
from snakeeyes.blueprints.admin.forms import (
|
||||
SearchForm,
|
||||
BulkDeleteForm,
|
||||
UserForm,
|
||||
UserCancelSubscriptionForm,
|
||||
CouponForm
|
||||
)
|
||||
|
||||
admin = Blueprint('admin', __name__,
|
||||
template_folder='templates', url_prefix='/admin')
|
||||
|
||||
|
||||
@admin.before_request
|
||||
@login_required
|
||||
@role_required('admin')
|
||||
def before_request():
|
||||
""" Protect all of the admin endpoints. """
|
||||
pass
|
||||
|
||||
|
||||
# Dashboard -------------------------------------------------------------------
|
||||
@admin.route('')
|
||||
def dashboard():
|
||||
group_and_count_plans = Dashboard.group_and_count_plans()
|
||||
group_and_count_coupons = Dashboard.group_and_count_coupons()
|
||||
group_and_count_users = Dashboard.group_and_count_users()
|
||||
group_and_count_payouts = Dashboard.group_and_count_payouts()
|
||||
|
||||
return render_template('admin/page/dashboard.html',
|
||||
group_and_count_plans=group_and_count_plans,
|
||||
group_and_count_coupons=group_and_count_coupons,
|
||||
group_and_count_users=group_and_count_users,
|
||||
group_and_count_payouts=group_and_count_payouts)
|
||||
|
||||
|
||||
# Users -----------------------------------------------------------------------
|
||||
@admin.route('/users', defaults={'page': 1})
|
||||
@admin.route('/users/page/<int:page>')
|
||||
def users(page):
|
||||
search_form = SearchForm()
|
||||
bulk_form = BulkDeleteForm()
|
||||
|
||||
sort_by = User.sort_by(request.args.get('sort', 'created_on'),
|
||||
request.args.get('direction', 'desc'))
|
||||
order_values = '{0} {1}'.format(sort_by[0], sort_by[1])
|
||||
|
||||
paginated_users = User.query \
|
||||
.filter(User.search(request.args.get('q', ''))) \
|
||||
.order_by(User.role.asc(), User.payment_id, text(order_values)) \
|
||||
.paginate(page, 50, True)
|
||||
|
||||
return render_template('admin/user/index.html',
|
||||
form=search_form, bulk_form=bulk_form,
|
||||
users=paginated_users)
|
||||
|
||||
|
||||
@admin.route('/users/edit/<int:id>', methods=['GET', 'POST'])
|
||||
def users_edit(id):
|
||||
user = User.query.get(id)
|
||||
form = UserForm(obj=user)
|
||||
|
||||
if current_user.subscription:
|
||||
coupon = Coupon.query \
|
||||
.filter(Coupon.code == current_user.subscription.coupon).first()
|
||||
else:
|
||||
coupon = None
|
||||
|
||||
if form.validate_on_submit():
|
||||
if User.is_last_admin(user,
|
||||
request.form.get('role'),
|
||||
request.form.get('active')):
|
||||
flash('You are the last admin, you cannot do that.', 'error')
|
||||
return redirect(url_for('admin.users'))
|
||||
|
||||
form.populate_obj(user)
|
||||
|
||||
if not user.username:
|
||||
user.username = None
|
||||
|
||||
user.save()
|
||||
|
||||
flash('User has been saved successfully.', 'success')
|
||||
return redirect(url_for('admin.users'))
|
||||
|
||||
return render_template('admin/user/edit.html', form=form, user=user,
|
||||
coupon=coupon)
|
||||
|
||||
|
||||
@admin.route('/users/bulk_delete', methods=['POST'])
|
||||
def users_bulk_delete():
|
||||
form = BulkDeleteForm()
|
||||
|
||||
if form.validate_on_submit():
|
||||
# Hello. This is Nick from the future (July 2022 to be exact). I
|
||||
# modified this behavior a bit by reading the query from a hidden form
|
||||
# field instead of the request.args that was shown on video.
|
||||
#
|
||||
# We needed to make this a hidden field to persist the value when the
|
||||
# form was submit since the GET args are not readable in this POST.
|
||||
ids = User.get_bulk_action_ids(request.form.get('scope'),
|
||||
request.form.getlist('bulk_ids'),
|
||||
omit_ids=[current_user.id],
|
||||
query=request.form.get('q'))
|
||||
|
||||
# Prevent circular imports.
|
||||
from snakeeyes.blueprints.billing.tasks import delete_users
|
||||
|
||||
delete_users.delay(ids)
|
||||
|
||||
flash('{0} user(s) were scheduled to be deleted.'.format(len(ids)),
|
||||
'success')
|
||||
else:
|
||||
flash('No users were deleted, something went wrong.', 'error')
|
||||
|
||||
return redirect(url_for('admin.users'))
|
||||
|
||||
|
||||
@admin.route('/users/cancel_subscription', methods=['POST'])
|
||||
def users_cancel_subscription():
|
||||
form = UserCancelSubscriptionForm()
|
||||
|
||||
if form.validate_on_submit():
|
||||
user = User.query.get(request.form.get('id'))
|
||||
|
||||
if user:
|
||||
subscription = Subscription()
|
||||
if subscription.cancel(user):
|
||||
flash('Subscription has been cancelled for {0}.'
|
||||
.format(user.name), 'success')
|
||||
else:
|
||||
flash('No subscription was cancelled, something went wrong.',
|
||||
'error')
|
||||
|
||||
return redirect(url_for('admin.users'))
|
||||
|
||||
|
||||
# Coupons ---------------------------------------------------------------------
|
||||
@admin.route('/coupons', defaults={'page': 1})
|
||||
@admin.route('/coupons/page/<int:page>')
|
||||
def coupons(page):
|
||||
search_form = SearchForm()
|
||||
bulk_form = BulkDeleteForm()
|
||||
|
||||
sort_by = Coupon.sort_by(request.args.get('sort', 'created_on'),
|
||||
request.args.get('direction', 'desc'))
|
||||
order_values = '{0} {1}'.format(sort_by[0], sort_by[1])
|
||||
|
||||
paginated_coupons = Coupon.query \
|
||||
.filter(Coupon.search(request.args.get('q', ''))) \
|
||||
.order_by(text(order_values)) \
|
||||
.paginate(page, 50, True)
|
||||
|
||||
return render_template('admin/coupon/index.html',
|
||||
form=search_form, bulk_form=bulk_form,
|
||||
coupons=paginated_coupons)
|
||||
|
||||
|
||||
@admin.route('/coupons/new', methods=['GET', 'POST'])
|
||||
@handle_stripe_exceptions
|
||||
def coupons_new():
|
||||
coupon = Coupon()
|
||||
form = CouponForm(obj=coupon)
|
||||
|
||||
if form.validate_on_submit():
|
||||
form.populate_obj(coupon)
|
||||
|
||||
params = {
|
||||
'code': coupon.code,
|
||||
'duration': coupon.duration,
|
||||
'percent_off': coupon.percent_off,
|
||||
'amount_off': coupon.amount_off,
|
||||
'currency': coupon.currency,
|
||||
'redeem_by': coupon.redeem_by,
|
||||
'max_redemptions': coupon.max_redemptions,
|
||||
'duration_in_months': coupon.duration_in_months,
|
||||
}
|
||||
|
||||
if Coupon.create(params):
|
||||
flash('Coupon has been created successfully.', 'success')
|
||||
return redirect(url_for('admin.coupons'))
|
||||
|
||||
return render_template('admin/coupon/new.html', form=form, coupon=coupon)
|
||||
|
||||
|
||||
@admin.route('/coupons/bulk_delete', methods=['POST'])
|
||||
def coupons_bulk_delete():
|
||||
form = BulkDeleteForm()
|
||||
|
||||
if form.validate_on_submit():
|
||||
# Hello. This is Nick from the future (July 2022 to be exact). I
|
||||
# modified this behavior a bit by reading the query from a hidden form
|
||||
# field instead of the request.args that was shown on video.
|
||||
#
|
||||
# We needed to make this a hidden field to persist the value when the
|
||||
# form was submit since the GET args are not readable in this POST.
|
||||
ids = Coupon.get_bulk_action_ids(request.form.get('scope'),
|
||||
request.form.getlist('bulk_ids'),
|
||||
query=request.form.get('q'))
|
||||
|
||||
# Prevent circular imports.
|
||||
from snakeeyes.blueprints.billing.tasks import delete_coupons
|
||||
|
||||
delete_coupons.delay(ids)
|
||||
|
||||
flash('{0} coupons(s) were scheduled to be deleted.'.format(len(ids)),
|
||||
'success')
|
||||
else:
|
||||
flash('No coupons were deleted, something went wrong.', 'error')
|
||||
|
||||
return redirect(url_for('admin.coupons'))
|
||||
|
||||
|
||||
# Invoices --------------------------------------------------------------------
|
||||
@admin.route('/invoices', defaults={'page': 1})
|
||||
@admin.route('/invoices/page/<int:page>')
|
||||
def invoices(page):
|
||||
search_form = SearchForm()
|
||||
|
||||
sort_by = Invoice.sort_by(request.args.get('sort', 'created_on'),
|
||||
request.args.get('direction', 'desc'))
|
||||
order_values = 'invoices.{0} {1}'.format(sort_by[0], sort_by[1])
|
||||
|
||||
paginated_invoices = Invoice.query.join(User) \
|
||||
.filter(Invoice.search(request.args.get('q', ''))) \
|
||||
.order_by(text(order_values)) \
|
||||
.paginate(page, 50, True)
|
||||
|
||||
return render_template('admin/invoice/index.html',
|
||||
form=search_form, invoices=paginated_invoices)
|
||||
@@ -0,0 +1 @@
|
||||
from snakeeyes.blueprints.bet.views import bet
|
||||
@@ -0,0 +1,22 @@
|
||||
from functools import wraps
|
||||
|
||||
from flask import flash, redirect, url_for
|
||||
from flask_login import current_user
|
||||
|
||||
|
||||
def coins_required(f):
|
||||
"""
|
||||
Restrict access from users who have no coins.
|
||||
|
||||
:return: Function
|
||||
"""
|
||||
@wraps(f)
|
||||
def decorated_function(*args, **kwargs):
|
||||
if current_user.coins == 0:
|
||||
flash("Sorry, you're out of coins. You should buy more.",
|
||||
'warning')
|
||||
return redirect(url_for('billing.purchase_coins'))
|
||||
|
||||
return f(*args, **kwargs)
|
||||
|
||||
return decorated_function
|
||||
@@ -0,0 +1,8 @@
|
||||
from flask_wtf import Form
|
||||
from wtforms import IntegerField
|
||||
from wtforms.validators import DataRequired, NumberRange
|
||||
|
||||
|
||||
class BetForm(Form):
|
||||
guess = IntegerField('Guess', [DataRequired(), NumberRange(min=2, max=12)])
|
||||
wagered = IntegerField('Wagered', [DataRequired(), NumberRange(min=1)])
|
||||
@@ -0,0 +1,108 @@
|
||||
from lib.util_sqlalchemy import ResourceMixin
|
||||
from lib.util_datetime import tzware_datetime
|
||||
from snakeeyes.extensions import db
|
||||
|
||||
|
||||
class Bet(ResourceMixin, db.Model):
|
||||
__tablename__ = 'bets'
|
||||
id = db.Column(db.Integer, primary_key=True)
|
||||
|
||||
# Relationships.
|
||||
user_id = db.Column(db.Integer, db.ForeignKey('users.id',
|
||||
onupdate='CASCADE',
|
||||
ondelete='CASCADE'),
|
||||
index=True, nullable=False)
|
||||
|
||||
# Bet details.
|
||||
guess = db.Column(db.Integer())
|
||||
die_1 = db.Column(db.Integer())
|
||||
die_2 = db.Column(db.Integer())
|
||||
roll = db.Column(db.Integer())
|
||||
wagered = db.Column(db.BigInteger())
|
||||
payout = db.Column(db.Float())
|
||||
net = db.Column(db.BigInteger())
|
||||
|
||||
def __init__(self, **kwargs):
|
||||
# Call Flask-SQLAlchemy's constructor.
|
||||
super(Bet, self).__init__(**kwargs)
|
||||
|
||||
@classmethod
|
||||
def is_winner(cls, guess, roll):
|
||||
"""
|
||||
Determine if the result is a win or loss.
|
||||
|
||||
:param guess: Dice guess
|
||||
:type guess: int
|
||||
:param roll: Dice roll
|
||||
:type roll: int
|
||||
:return: bool
|
||||
"""
|
||||
if guess == roll:
|
||||
return True
|
||||
|
||||
return False
|
||||
|
||||
@classmethod
|
||||
def determine_payout(cls, payout, is_winner):
|
||||
"""
|
||||
Determine the payout.
|
||||
|
||||
:param payout: Dice guess
|
||||
:type payout: float
|
||||
:param is_winner: Was the bet won or lost
|
||||
:type is_winner: bool
|
||||
:return: int
|
||||
"""
|
||||
if is_winner:
|
||||
return payout
|
||||
|
||||
return 1.0
|
||||
|
||||
@classmethod
|
||||
def calculate_net(cls, wagered, payout, is_winner):
|
||||
"""
|
||||
Calculate the net won or lost.
|
||||
|
||||
:param wagered: Dice guess
|
||||
:type wagered: int
|
||||
:param payout: Dice roll
|
||||
:type payout: float
|
||||
:param is_winner: Was the bet won or lost
|
||||
:type is_winner: bool
|
||||
:return: int
|
||||
"""
|
||||
if is_winner:
|
||||
return int(wagered * payout)
|
||||
|
||||
return -wagered
|
||||
|
||||
def save_and_update_user(self, user):
|
||||
"""
|
||||
Commit the bet and update the user's information.
|
||||
|
||||
:return: SQLAlchemy save result
|
||||
"""
|
||||
self.save()
|
||||
|
||||
user.coins += self.net
|
||||
user.last_bet_on = tzware_datetime()
|
||||
return user.save()
|
||||
|
||||
def to_json(self):
|
||||
"""
|
||||
Return JSON fields to represent a bet.
|
||||
|
||||
:return: dict
|
||||
"""
|
||||
params = {
|
||||
'guess': self.guess,
|
||||
'die_1': self.die_1,
|
||||
'die_2': self.die_2,
|
||||
'roll': self.roll,
|
||||
'wagered': self.wagered,
|
||||
'payout': self.payout,
|
||||
'net': self.net,
|
||||
'is_winner': Bet.is_winner(self.guess, self.roll)
|
||||
}
|
||||
|
||||
return params
|
||||
@@ -0,0 +1,49 @@
|
||||
def add_subscription_coins(coins, previous_plan, plan, cancelled_on):
|
||||
"""
|
||||
Add an amount of coins to an existing coin value.
|
||||
|
||||
:param coins: Existing coin value
|
||||
:type coins: int
|
||||
:param previous_plan: Previous subscription plan
|
||||
:type previous_plan: dict
|
||||
:param plan: New subscription plan
|
||||
:type plan: dict
|
||||
:param cancelled_on: When a plan has potentially been cancelled
|
||||
:type cancelled_on: datetime
|
||||
:return: int
|
||||
"""
|
||||
# Some people will try to game the system and cheat us for extra coins.
|
||||
#
|
||||
# Users should only be able to gain coins via subscription when:
|
||||
# Subscribes for the first time
|
||||
# Subscriber updates to a better plan (one with more coins)
|
||||
#
|
||||
# That means the following actions should result in no coins:
|
||||
# Subscriber cancels and signs up for the same plan
|
||||
# Subscriber downgrades to a worse plan
|
||||
#
|
||||
# This method is still cheatable by signing up for a free trial on a plan,
|
||||
# and then upgrading to a higher plan. However, only a small amount of
|
||||
# users will do this, and once their subscription runs out they will be
|
||||
# removed from the leaderboard.
|
||||
#
|
||||
# I feel like it's better to allow them to temporarily cheat the system
|
||||
# instead of adding subscription coins during the invoicing phase which
|
||||
# will mean that honest people who subscribe won't be able to get their
|
||||
# coins until after the free trial period.
|
||||
previous_plan_coins = 0
|
||||
plan_coins = plan['metadata']['coins']
|
||||
|
||||
if previous_plan:
|
||||
previous_plan_coins = previous_plan['metadata']['coins']
|
||||
|
||||
if cancelled_on is None and plan_coins == previous_plan_coins:
|
||||
coin_adjustment = plan_coins
|
||||
elif plan_coins <= previous_plan_coins:
|
||||
return coins
|
||||
else:
|
||||
# We only want to add the difference between upgrading plans,
|
||||
# because they were already credited the previous plan's coins.
|
||||
coin_adjustment = plan_coins - previous_plan_coins
|
||||
|
||||
return coins + coin_adjustment
|
||||
@@ -0,0 +1,10 @@
|
||||
import random
|
||||
|
||||
|
||||
def roll():
|
||||
"""
|
||||
Randomly roll a dice.
|
||||
|
||||
:return: int
|
||||
"""
|
||||
return random.randint(1, 6)
|
||||
+54
@@ -0,0 +1,54 @@
|
||||
{% extends 'layouts/app.html' %}
|
||||
{% import 'macros/items.html' as items %}
|
||||
|
||||
{% block title %}Betting history{% endblock %}
|
||||
|
||||
{% block body %}
|
||||
<h2>Betting history</h2>
|
||||
{% if bets.total == 0 %}
|
||||
<p>No bets found.</p>
|
||||
{% else %}
|
||||
<table class="table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Date</th>
|
||||
<th>Guess</th>
|
||||
<th>Roll</th>
|
||||
<th>Wagered</th>
|
||||
<th>Payout</th>
|
||||
<th>Net</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for bet in bets.items %}
|
||||
<tr>
|
||||
<td>
|
||||
<time class="from-now"
|
||||
data-datetime="{{ bet.created_on }}">
|
||||
{{ bet.created_on }}
|
||||
</time>
|
||||
</td>
|
||||
<td>{{ bet.guess }}</td>
|
||||
<td>
|
||||
{{ bet.roll }}
|
||||
<span class="text-muted">({{ bet.die_1 }} + {{ bet.die_2 }})</span>
|
||||
</td>
|
||||
<td class="text-warning">
|
||||
<i class="fa fa-fw fa-database"></i> {{ bet.wagered }}
|
||||
</td>
|
||||
<td>{{ bet.payout }}x</td>
|
||||
<td class="text-{{ 'success' if bet.net > 0 else 'danger' }}">
|
||||
<i class="fa fa-fw fa-database"></i> {{ bet.net }}
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
{{ items.paginate(bets) }}
|
||||
{% endif %}
|
||||
<hr/>
|
||||
<a href="{{ url_for('user.settings') }}" class="margin-top btn btn-primary">
|
||||
Go back to your account
|
||||
</a>
|
||||
{% endblock %}
|
||||
+104
@@ -0,0 +1,104 @@
|
||||
{% extends 'layouts/app.html' %}
|
||||
{% import 'macros/form.html' as f with context %}
|
||||
|
||||
{% block title %}Place Bet{% endblock %}
|
||||
|
||||
{% block body %}
|
||||
<div class="row">
|
||||
<div class="col-md-4">
|
||||
<form id="place_bet" class="sm-margin-top">
|
||||
<legend class="text-warning">
|
||||
<strong id="user_coins">{{ current_user.coins }}</strong> coins left
|
||||
</legend>
|
||||
<div class="form-group">
|
||||
<label for="guess"><strong>Guess</strong></label>
|
||||
<select class="form-control" id="guess" name="guess"
|
||||
title="What number will be rolled?">
|
||||
<option value="2">2</option>
|
||||
<option value="3">3</option>
|
||||
<option value="4">4</option>
|
||||
<option value="5">5</option>
|
||||
<option value="6">6</option>
|
||||
<option value="7">7</option>
|
||||
<option value="8">8</option>
|
||||
<option value="9">9</option>
|
||||
<option value="10">10</option>
|
||||
<option value="11">11</option>
|
||||
<option value="12">12</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="wager"><strong>Wager</strong></label>
|
||||
<div class="input-group">
|
||||
<div class="input-group-addon">
|
||||
<i class="fa fa-fw fa-database"></i>
|
||||
</div>
|
||||
<input type="number" min="1" class="form-control"
|
||||
id="wagered" name="wagered"
|
||||
placeholder="100" title="How many coins do you want to wager?">
|
||||
</div>
|
||||
</div>
|
||||
<button type="submit" class="btn btn-primary btn-block sm-margin-top">
|
||||
<img src="{{ url_for('static', filename='images/spinner.gif') }}"
|
||||
class="spinner"
|
||||
width="16" height="11" alt="Spinner"/>
|
||||
Place Bet
|
||||
</button>
|
||||
|
||||
<noscript>
|
||||
<p class="alert alert-sm alert-warning text-center sm-margin-top">
|
||||
You need to enable JavaScript to place bets.
|
||||
</p>
|
||||
</noscript>
|
||||
|
||||
<div id="dice" class="text-center"></div>
|
||||
<div id="outcome" class="alert alert-sm text-center sm-margin-top"></div>
|
||||
|
||||
<hr/>
|
||||
|
||||
<div class="text-center">
|
||||
<a href="{{ url_for('billing.purchase_coins') }}"
|
||||
class="btn btn-default">Buy more coins</a>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
<div class="col-md-7 col-md-offset-1">
|
||||
<div id="recent_bets">
|
||||
<h2>Recent bets</h2>
|
||||
{% if recent_bets.count() == 0 %}
|
||||
<p>No bets found.</p>
|
||||
{% else %}
|
||||
<table class="table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Guess</th>
|
||||
<th>Roll</th>
|
||||
<th>Wagered</th>
|
||||
<th>Payout</th>
|
||||
<th>Net</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for bet in recent_bets %}
|
||||
<tr>
|
||||
<td>{{ bet.guess }}</td>
|
||||
<td>{{ bet.roll }}</td>
|
||||
<td class="text-warning">
|
||||
<i class="fa fa-fw fa-database"></i> {{ bet.wagered }}
|
||||
</td>
|
||||
<td>{{ bet.payout }}x</td>
|
||||
<td class="text-{{ 'success' if bet.net > 0 else 'danger' }}">
|
||||
<i class="fa fa-fw fa-database"></i> {{ bet.net }}
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
{% endif %}
|
||||
<a href="{{ url_for('bet.history') }}">
|
||||
<span class="btn btn-sm btn-default">View full betting history</span>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,78 @@
|
||||
from flask import Blueprint, current_app, render_template, request
|
||||
from flask_login import current_user, login_required
|
||||
|
||||
from lib.util_json import render_json
|
||||
from snakeeyes.extensions import limiter
|
||||
from snakeeyes.blueprints.bet.decorators import coins_required
|
||||
from snakeeyes.blueprints.bet.forms import BetForm
|
||||
from snakeeyes.blueprints.bet.models.bet import Bet
|
||||
from snakeeyes.blueprints.bet.models.dice import roll
|
||||
|
||||
bet = Blueprint('bet', __name__, template_folder='templates',
|
||||
url_prefix='/bet')
|
||||
|
||||
|
||||
@bet.before_request
|
||||
@login_required
|
||||
def before_request():
|
||||
""" Protect all of the bet endpoints. """
|
||||
pass
|
||||
|
||||
|
||||
@bet.route('/place', methods=['GET', 'POST'])
|
||||
@coins_required
|
||||
@limiter.limit('3/second')
|
||||
def place_bet():
|
||||
if request.method == 'GET':
|
||||
recent_bets = Bet.query.filter(Bet.user_id == current_user.id) \
|
||||
.order_by(Bet.created_on.desc()).limit(10)
|
||||
|
||||
return render_template('bet/place_bet.html', recent_bets=recent_bets)
|
||||
|
||||
form = BetForm()
|
||||
|
||||
if form.validate_on_submit():
|
||||
guess = int(request.form.get('guess'))
|
||||
wagered = int(request.form.get('wagered'))
|
||||
|
||||
if wagered > current_user.coins:
|
||||
error = 'You cannot wager more than your total coins.'
|
||||
return render_json(400, {'error': error})
|
||||
|
||||
payout = float(current_app.config['DICE_ROLL_PAYOUT'][str(guess)])
|
||||
die_1 = roll()
|
||||
die_2 = roll()
|
||||
outcome = die_1 + die_2
|
||||
is_winner = Bet.is_winner(guess, outcome)
|
||||
payout = Bet.determine_payout(payout, is_winner)
|
||||
net = Bet.calculate_net(wagered, payout, is_winner)
|
||||
|
||||
params = {
|
||||
'user_id': current_user.id,
|
||||
'guess': guess,
|
||||
'die_1': die_1,
|
||||
'die_2': die_2,
|
||||
'roll': outcome,
|
||||
'wagered': wagered,
|
||||
'payout': payout,
|
||||
'net': net
|
||||
}
|
||||
|
||||
bet = Bet(**params)
|
||||
bet.save_and_update_user(current_user)
|
||||
|
||||
return render_json(200, {'data': bet.to_json()})
|
||||
else:
|
||||
return render_json(400,
|
||||
{'error': 'You need to wager at least 1 coin.'})
|
||||
|
||||
|
||||
@bet.route('/history', defaults={'page': 1})
|
||||
@bet.route('/history/page/<int:page>')
|
||||
def history(page):
|
||||
paginated_bets = Bet.query \
|
||||
.filter(Bet.user_id == current_user.id) \
|
||||
.order_by(Bet.created_on.desc()) \
|
||||
.paginate(page, 50, True)
|
||||
|
||||
return render_template('bet/history.html', bets=paginated_bets)
|
||||
@@ -0,0 +1,2 @@
|
||||
from snakeeyes.blueprints.billing.views.billing import billing
|
||||
from snakeeyes.blueprints.billing.views.stripe_webhook import stripe_webhook
|
||||
@@ -0,0 +1,56 @@
|
||||
from functools import wraps
|
||||
|
||||
import stripe
|
||||
from flask import redirect, url_for, flash
|
||||
from flask_login import current_user
|
||||
|
||||
|
||||
def subscription_required(f):
|
||||
"""
|
||||
Ensure a user is subscribed, if not redirect them to the pricing table.
|
||||
|
||||
:return: Function
|
||||
"""
|
||||
@wraps(f)
|
||||
def decorated_function(*args, **kwargs):
|
||||
if not current_user.subscription:
|
||||
return redirect(url_for('billing.pricing'))
|
||||
|
||||
return f(*args, **kwargs)
|
||||
|
||||
return decorated_function
|
||||
|
||||
|
||||
def handle_stripe_exceptions(f):
|
||||
"""
|
||||
Handle Stripe exceptions so they do not throw 500s.
|
||||
|
||||
:param f:
|
||||
:type f: Function
|
||||
:return: Function
|
||||
"""
|
||||
@wraps(f)
|
||||
def decorated_function(*args, **kwargs):
|
||||
try:
|
||||
return f(*args, **kwargs)
|
||||
except stripe.error.CardError:
|
||||
flash('Sorry, your card was declined. Try again perhaps?', 'error')
|
||||
return redirect(url_for('user.settings'))
|
||||
except stripe.error.InvalidRequestError as e:
|
||||
flash(e, 'error')
|
||||
return redirect(url_for('user.settings'))
|
||||
except stripe.error.AuthenticationError:
|
||||
flash('Authentication with our payment gateway failed.', 'error')
|
||||
return redirect(url_for('user.settings'))
|
||||
except stripe.error.APIConnectionError:
|
||||
flash(
|
||||
'Our payment gateway is experiencing connectivity issues'
|
||||
', please try again.', 'error')
|
||||
return redirect(url_for('user.settings'))
|
||||
except stripe.error.StripeError:
|
||||
flash(
|
||||
'Our payment gateway is having issues, please try again.',
|
||||
'error')
|
||||
return redirect(url_for('user.settings'))
|
||||
|
||||
return decorated_function
|
||||
@@ -0,0 +1,51 @@
|
||||
from flask_wtf import Form
|
||||
from wtforms import StringField, HiddenField, SelectField
|
||||
from wtforms.validators import DataRequired, Optional, Length
|
||||
|
||||
from config.settings import COIN_BUNDLES
|
||||
|
||||
|
||||
def choices_from_coin_bundles():
|
||||
"""
|
||||
Convert the COIN_BUNDLE settings into select box items.
|
||||
|
||||
:return: list
|
||||
"""
|
||||
choices = []
|
||||
|
||||
for bundle in COIN_BUNDLES:
|
||||
pair = (str(bundle.get('coins')), bundle.get('label'))
|
||||
choices.append(pair)
|
||||
|
||||
return choices
|
||||
|
||||
|
||||
class SubscriptionForm(Form):
|
||||
stripe_key = HiddenField('Stripe publishable key',
|
||||
[DataRequired(), Length(1, 254)])
|
||||
plan = HiddenField('Plan',
|
||||
[DataRequired(), Length(1, 254)])
|
||||
coupon_code = StringField('Do you have a coupon code?',
|
||||
[Optional(), Length(1, 128)])
|
||||
name = StringField('Name on card',
|
||||
[DataRequired(), Length(1, 254)])
|
||||
|
||||
|
||||
class UpdateSubscriptionForm(Form):
|
||||
coupon_code = StringField('Do you have a coupon code?',
|
||||
[Optional(), Length(1, 254)])
|
||||
|
||||
|
||||
class CancelSubscriptionForm(Form):
|
||||
pass
|
||||
|
||||
|
||||
class PaymentForm(Form):
|
||||
stripe_key = HiddenField('Stripe publishable key',
|
||||
[DataRequired(), Length(1, 254)])
|
||||
coin_bundles = SelectField('How many coins do you want?', [DataRequired()],
|
||||
choices=choices_from_coin_bundles())
|
||||
coupon_code = StringField('Do you have a coupon code?',
|
||||
[Optional(), Length(1, 128)])
|
||||
name = StringField('Name on card',
|
||||
[DataRequired(), Length(1, 254)])
|
||||
+332
@@ -0,0 +1,332 @@
|
||||
import stripe
|
||||
|
||||
|
||||
class Event(object):
|
||||
@classmethod
|
||||
def retrieve(cls, event_id):
|
||||
"""
|
||||
Retrieve an event, this is used to validate the event in attempt to
|
||||
protect us from potentially malicious events not sent from Stripe.
|
||||
|
||||
API Documentation:
|
||||
https://stripe.com/docs/api#retrieve_event
|
||||
|
||||
:param event_id: Stripe event id
|
||||
:type event_id: int
|
||||
:return: Stripe event
|
||||
"""
|
||||
return stripe.Event.retrieve(event_id)
|
||||
|
||||
|
||||
class Customer(object):
|
||||
@classmethod
|
||||
def create(cls, token=None, email=None, coupon=None, plan=None):
|
||||
"""
|
||||
Create a new customer.
|
||||
|
||||
API Documentation:
|
||||
https://stripe.com/docs/api#create_customer
|
||||
|
||||
:param token: Token returned by JavaScript
|
||||
:type token: str
|
||||
:param email: E-mail address of the customer
|
||||
:type email: str
|
||||
:param coupon: Coupon code
|
||||
:type coupon: str
|
||||
:param plan: Plan identifier
|
||||
:type plan: str
|
||||
:return: Stripe customer
|
||||
"""
|
||||
params = {
|
||||
'source': token,
|
||||
'email': email
|
||||
}
|
||||
|
||||
if plan:
|
||||
params['plan'] = plan
|
||||
|
||||
if coupon:
|
||||
params['coupon'] = coupon
|
||||
|
||||
return stripe.Customer.create(**params)
|
||||
|
||||
|
||||
class Charge(object):
|
||||
@classmethod
|
||||
def create(cls, customer_id=None, currency=None, amount=None):
|
||||
"""
|
||||
Create a new charge.
|
||||
|
||||
:param customer_id: Stripe customer id
|
||||
:type customer_id: int
|
||||
:param amount: Stripe currency
|
||||
:type amount: str
|
||||
:param amount: Amount in cents
|
||||
:type amount: int
|
||||
:return: Stripe charge
|
||||
"""
|
||||
return stripe.Charge.create(
|
||||
amount=amount,
|
||||
currency=currency,
|
||||
customer=customer_id,
|
||||
statement_descriptor='SNAKEEYES COINS')
|
||||
|
||||
|
||||
class Coupon(object):
|
||||
@classmethod
|
||||
def create(cls, code=None, duration=None, amount_off=None,
|
||||
percent_off=None, currency=None, duration_in_months=None,
|
||||
max_redemptions=None, redeem_by=None):
|
||||
"""
|
||||
Create a new coupon.
|
||||
|
||||
API Documentation:
|
||||
https://stripe.com/docs/api#create_coupon
|
||||
|
||||
:param code: Coupon code
|
||||
:param duration: How long the coupon will be in effect
|
||||
:type duration: str
|
||||
:param amount_off: Discount in a fixed amount
|
||||
:type amount_off: int
|
||||
:param percent_off: Discount based on percent off
|
||||
:type percent_off: int
|
||||
:param currency: 3 digit currency abbreviation
|
||||
:type currency: str
|
||||
:param duration_in_months: Number of months in effect
|
||||
:type duration_in_months: int
|
||||
:param max_redemptions: Max number of times it can be redeemed
|
||||
:type max_redemptions: int
|
||||
:param redeem_by: Redeemable by this date
|
||||
:type redeem_by: date
|
||||
:return: Stripe coupon
|
||||
"""
|
||||
return stripe.Coupon.create(id=code,
|
||||
duration=duration,
|
||||
amount_off=amount_off,
|
||||
percent_off=percent_off,
|
||||
currency=currency,
|
||||
duration_in_months=duration_in_months,
|
||||
max_redemptions=max_redemptions,
|
||||
redeem_by=redeem_by)
|
||||
|
||||
@classmethod
|
||||
def delete(cls, id=None):
|
||||
"""
|
||||
Delete an existing coupon.
|
||||
|
||||
API Documentation:
|
||||
https://stripe.com/docs/api#delete_coupon
|
||||
|
||||
:param id: Coupon code
|
||||
:return: Stripe coupon
|
||||
"""
|
||||
coupon = stripe.Coupon.retrieve(id)
|
||||
return coupon.delete()
|
||||
|
||||
|
||||
class Card(object):
|
||||
@classmethod
|
||||
def update(cls, customer_id, stripe_token=None):
|
||||
"""
|
||||
Update an existing card through a customer.
|
||||
|
||||
API Documentation:
|
||||
https://stripe.com/docs/api/python#update_card
|
||||
|
||||
:param customer_id: Stripe customer id
|
||||
:type customer_id: int
|
||||
:param stripe_token: Stripe token
|
||||
:type stripe_token: str
|
||||
:return: Stripe customer
|
||||
"""
|
||||
customer = stripe.Customer.retrieve(customer_id)
|
||||
customer.source = stripe_token
|
||||
|
||||
return customer.save()
|
||||
|
||||
|
||||
class Invoice(object):
|
||||
@classmethod
|
||||
def upcoming(cls, customer_id):
|
||||
"""
|
||||
Retrieve an upcoming invoice item for a user.
|
||||
|
||||
API Documentation:
|
||||
https://stripe.com/docs/api#retrieve_customer_invoice
|
||||
|
||||
:param customer_id: Stripe customer id
|
||||
:type customer_id: int
|
||||
:return: Stripe invoice
|
||||
"""
|
||||
return stripe.Invoice.upcoming(customer=customer_id)
|
||||
|
||||
|
||||
class Subscription(object):
|
||||
@classmethod
|
||||
def update(cls, customer_id=None, coupon=None, plan=None):
|
||||
"""
|
||||
Update an existing subscription.
|
||||
|
||||
API Documentation:
|
||||
https://stripe.com/docs/api/python#update_subscription
|
||||
|
||||
:param customer_id: Customer id
|
||||
:type customer_id: str
|
||||
:param coupon: Coupon code
|
||||
:type coupon: str
|
||||
:param plan: Plan identifier
|
||||
:type plan: str
|
||||
:return: Stripe subscription
|
||||
"""
|
||||
customer = stripe.Customer.retrieve(customer_id)
|
||||
subscription_id = customer.subscriptions.data[0].id
|
||||
subscription = customer.subscriptions.retrieve(subscription_id)
|
||||
|
||||
subscription.plan = plan
|
||||
|
||||
if coupon:
|
||||
subscription.coupon = coupon
|
||||
|
||||
return subscription.save()
|
||||
|
||||
@classmethod
|
||||
def cancel(cls, customer_id=None):
|
||||
"""
|
||||
Cancel an existing subscription.
|
||||
|
||||
API Documentation:
|
||||
https://stripe.com/docs/api#cancel_subscription
|
||||
|
||||
:param customer_id: Stripe customer id
|
||||
:type customer_id: int
|
||||
:return: Stripe subscription object
|
||||
"""
|
||||
customer = stripe.Customer.retrieve(customer_id)
|
||||
subscription_id = customer.subscriptions.data[0].id
|
||||
|
||||
return customer.subscriptions.retrieve(subscription_id).delete()
|
||||
|
||||
|
||||
class Plan(object):
|
||||
@classmethod
|
||||
def retrieve(cls, plan):
|
||||
"""
|
||||
Retrieve an existing plan.
|
||||
|
||||
API Documentation:
|
||||
https://stripe.com/docs/api#retrieve_plan
|
||||
|
||||
:param plan: Plan identifier
|
||||
:type plan: str
|
||||
:return: Stripe plan
|
||||
"""
|
||||
try:
|
||||
return stripe.Plan.retrieve(plan)
|
||||
except stripe.error.StripeError as e:
|
||||
print(e)
|
||||
|
||||
@classmethod
|
||||
def list(cls):
|
||||
"""
|
||||
List all plans.
|
||||
|
||||
API Documentation:
|
||||
https://stripe.com/docs/api#list_plans
|
||||
|
||||
:return: Stripe plans
|
||||
"""
|
||||
try:
|
||||
return stripe.Plan.all()
|
||||
except stripe.error.StripeError as e:
|
||||
print(e)
|
||||
|
||||
@classmethod
|
||||
def create(cls, id=None, name=None, amount=None, currency=None,
|
||||
interval=None, interval_count=None, trial_period_days=None,
|
||||
metadata=None, statement_descriptor=None):
|
||||
"""
|
||||
Create a new plan.
|
||||
|
||||
API Documentation:
|
||||
https://stripe.com/docs/api#create_plan
|
||||
|
||||
:param id: Plan identifier
|
||||
:type id: str
|
||||
:param name: Plan name
|
||||
:type name: str
|
||||
:param amount: Amount in cents to charge or 0 for a free plan
|
||||
:type amount: int
|
||||
:param currency: 3 digit currency abbreviation
|
||||
:type currency: str
|
||||
:param interval: Billing frequency
|
||||
:type interval: str
|
||||
:param interval_count: Number of intervals between each bill
|
||||
:type interval_count: int
|
||||
:param trial_period_days: Number of days to run a free trial
|
||||
:type trial_period_days: int
|
||||
:param metadata: Additional data to save with the plan
|
||||
:type metadata: dct
|
||||
:param statement_descriptor: Arbitrary string to appear on CC statement
|
||||
:type statement_descriptor: str
|
||||
:return: Stripe plan
|
||||
"""
|
||||
try:
|
||||
return stripe.Plan.create(id=id,
|
||||
name=name,
|
||||
amount=amount,
|
||||
currency=currency,
|
||||
interval=interval,
|
||||
interval_count=interval_count,
|
||||
trial_period_days=trial_period_days,
|
||||
metadata=metadata,
|
||||
statement_descriptor=statement_descriptor
|
||||
)
|
||||
except stripe.error.StripeError as e:
|
||||
print(e)
|
||||
|
||||
@classmethod
|
||||
def update(cls, id=None, name=None, metadata=None,
|
||||
statement_descriptor=None):
|
||||
"""
|
||||
Update an existing plan.
|
||||
|
||||
API Documentation:
|
||||
https://stripe.com/docs/api#update_plan
|
||||
|
||||
:param id: Plan identifier
|
||||
:type id: str
|
||||
:param name: Plan name
|
||||
:type name: str
|
||||
:param metadata: Additional data to save with the plan
|
||||
:type metadata: dct
|
||||
:param statement_descriptor: Arbitrary string to appear on CC statement
|
||||
:type statement_descriptor: str
|
||||
:return: Stripe plan
|
||||
"""
|
||||
try:
|
||||
plan = stripe.Plan.retrieve(id)
|
||||
|
||||
plan.name = name
|
||||
plan.metadata = metadata
|
||||
plan.statement_descriptor = statement_descriptor
|
||||
return plan.save()
|
||||
except stripe.error.StripeError as e:
|
||||
print(e)
|
||||
|
||||
@classmethod
|
||||
def delete(cls, plan):
|
||||
"""
|
||||
Delete an existing plan.
|
||||
|
||||
API Documentation:
|
||||
https://stripe.com/docs/api#delete_plan
|
||||
|
||||
:param plan: Plan identifier
|
||||
:type plan: str
|
||||
:return: Stripe plan object
|
||||
"""
|
||||
try:
|
||||
plan = stripe.Plan.retrieve(plan)
|
||||
return plan.delete()
|
||||
except stripe.error.StripeError as e:
|
||||
print(e)
|
||||
@@ -0,0 +1,240 @@
|
||||
import datetime
|
||||
import string
|
||||
from collections import OrderedDict
|
||||
from random import choice
|
||||
|
||||
import pytz
|
||||
from sqlalchemy import or_, and_
|
||||
|
||||
from sqlalchemy.ext.hybrid import hybrid_property
|
||||
|
||||
from lib.util_sqlalchemy import ResourceMixin, AwareDateTime
|
||||
from lib.money import cents_to_dollars, dollars_to_cents
|
||||
from snakeeyes.extensions import db
|
||||
from snakeeyes.blueprints.billing.gateways.stripecom import \
|
||||
Coupon as PaymentCoupon
|
||||
|
||||
|
||||
class Coupon(ResourceMixin, db.Model):
|
||||
DURATION = OrderedDict([
|
||||
('forever', 'Forever'),
|
||||
('once', 'Once'),
|
||||
('repeating', 'Repeating')
|
||||
])
|
||||
|
||||
__tablename__ = 'coupons'
|
||||
id = db.Column(db.Integer, primary_key=True)
|
||||
|
||||
# Coupon details.
|
||||
code = db.Column(db.String(128), index=True, unique=True)
|
||||
duration = db.Column(db.Enum(*DURATION, name='duration_types'),
|
||||
index=True, nullable=False, server_default='forever')
|
||||
amount_off = db.Column(db.Integer())
|
||||
percent_off = db.Column(db.Integer())
|
||||
currency = db.Column(db.String(8))
|
||||
duration_in_months = db.Column(db.Integer())
|
||||
max_redemptions = db.Column(db.Integer(), index=True)
|
||||
redeem_by = db.Column(AwareDateTime(), index=True)
|
||||
times_redeemed = db.Column(db.Integer(), index=True,
|
||||
nullable=False, default=0)
|
||||
valid = db.Column(db.Boolean(), nullable=False, server_default='1')
|
||||
|
||||
def __init__(self, **kwargs):
|
||||
if self.code:
|
||||
self.code = self.code.upper()
|
||||
else:
|
||||
self.code = Coupon.random_coupon_code()
|
||||
|
||||
# Call Flask-SQLAlchemy's constructor.
|
||||
super(Coupon, self).__init__(**kwargs)
|
||||
|
||||
@hybrid_property
|
||||
def redeemable(self):
|
||||
"""
|
||||
Return coupons that are still redeemable. Coupons will become invalid
|
||||
once they run out on save. We want to explicitly do a date check to
|
||||
avoid having to hit Stripe's API to get back potentially valid codes.
|
||||
|
||||
:return: SQLAlchemy query object
|
||||
"""
|
||||
is_redeemable = or_(self.redeem_by.is_(None),
|
||||
self.redeem_by >= datetime.datetime.now(pytz.utc))
|
||||
|
||||
return and_(self.valid, is_redeemable)
|
||||
|
||||
@classmethod
|
||||
def search(cls, query):
|
||||
"""
|
||||
Search a resource by 1 or more fields.
|
||||
|
||||
:param query: Search query
|
||||
:type query: str
|
||||
:return: SQLAlchemy filter
|
||||
"""
|
||||
if not query:
|
||||
return ''
|
||||
|
||||
search_query = '%{0}%'.format(query)
|
||||
|
||||
return or_(Coupon.code.ilike(search_query))
|
||||
|
||||
@classmethod
|
||||
def random_coupon_code(cls):
|
||||
"""
|
||||
Create a human readable random coupon code.
|
||||
|
||||
:return: str
|
||||
"""
|
||||
charset = string.digits + string.ascii_uppercase
|
||||
charset = charset.replace('B', '').replace('I', '')
|
||||
charset = charset.replace('O', '').replace('S', '')
|
||||
charset = charset.replace('0', '').replace('1', '')
|
||||
|
||||
random_chars = ''.join(choice(charset) for _ in range(14))
|
||||
|
||||
coupon_code = '{0}-{1}-{2}'.format(random_chars[0:4],
|
||||
random_chars[5:9],
|
||||
random_chars[10:14])
|
||||
|
||||
return coupon_code
|
||||
|
||||
@classmethod
|
||||
def expire_old_coupons(cls, compare_datetime=None):
|
||||
"""
|
||||
Invalidate coupons that are past their redeem date.
|
||||
|
||||
:param compare_datetime: Time to compare at
|
||||
:type compare_datetime: date
|
||||
:return: The result of updating the records
|
||||
"""
|
||||
if compare_datetime is None:
|
||||
compare_datetime = datetime.datetime.now(pytz.utc)
|
||||
|
||||
Coupon.query.filter(Coupon.redeem_by <= compare_datetime) \
|
||||
.update({Coupon.valid: not Coupon.valid})
|
||||
|
||||
return db.session.commit()
|
||||
|
||||
@classmethod
|
||||
def create(cls, params):
|
||||
"""
|
||||
Return whether or not the coupon was created successfully.
|
||||
|
||||
:return: bool
|
||||
"""
|
||||
payment_params = params
|
||||
|
||||
payment_params['code'] = payment_params['code'].upper()
|
||||
|
||||
if payment_params.get('amount_off'):
|
||||
payment_params['amount_off'] = \
|
||||
dollars_to_cents(payment_params['amount_off'])
|
||||
|
||||
PaymentCoupon.create(**payment_params)
|
||||
|
||||
if 'id' in payment_params:
|
||||
payment_params['code'] = payment_params['id']
|
||||
del payment_params['id']
|
||||
|
||||
if 'redeem_by' in payment_params:
|
||||
if payment_params.get('redeem_by') is not None:
|
||||
params['redeem_by'] = payment_params.get('redeem_by').replace(
|
||||
tzinfo=pytz.UTC)
|
||||
|
||||
coupon = Coupon(**payment_params)
|
||||
|
||||
db.session.add(coupon)
|
||||
db.session.commit()
|
||||
|
||||
return True
|
||||
|
||||
@classmethod
|
||||
def bulk_delete(cls, ids):
|
||||
"""
|
||||
Override the general bulk_delete method because we need to delete them
|
||||
one at a time while also deleting them on Stripe.
|
||||
|
||||
:param ids: List of ids to be deleted
|
||||
:type ids: list
|
||||
:return: int
|
||||
"""
|
||||
delete_count = 0
|
||||
|
||||
for id in ids:
|
||||
coupon = Coupon.query.get(id)
|
||||
|
||||
if coupon is None:
|
||||
continue
|
||||
|
||||
# Delete on Stripe.
|
||||
stripe_response = PaymentCoupon.delete(coupon.code)
|
||||
|
||||
# If successful, delete it locally.
|
||||
if stripe_response.get('deleted'):
|
||||
coupon.delete()
|
||||
delete_count += 1
|
||||
|
||||
return delete_count
|
||||
|
||||
@classmethod
|
||||
def find_by_code(cls, code):
|
||||
"""
|
||||
Find a coupon by its code.
|
||||
|
||||
:param code: Coupon code to find
|
||||
:type code: str
|
||||
:return: Coupon instance
|
||||
"""
|
||||
formatted_code = code.upper()
|
||||
coupon = Coupon.query.filter(Coupon.redeemable,
|
||||
Coupon.code == formatted_code).first()
|
||||
|
||||
return coupon
|
||||
|
||||
def redeem(self):
|
||||
"""
|
||||
Update the redeem stats for this coupon.
|
||||
|
||||
:return: Result of saving the record
|
||||
"""
|
||||
self.times_redeemed += 1
|
||||
|
||||
if self.max_redemptions:
|
||||
if self.times_redeemed >= self.max_redemptions:
|
||||
self.valid = False
|
||||
|
||||
return db.session.commit()
|
||||
|
||||
def apply_discount_to(self, amount):
|
||||
"""
|
||||
Apply the discount to an amount.
|
||||
|
||||
:param amount: Amount in cents
|
||||
:type amount: int
|
||||
:return: int
|
||||
"""
|
||||
if self.amount_off:
|
||||
amount -= self.amount_off
|
||||
elif self.percent_off:
|
||||
amount *= (1 - (self.percent_off * 0.01))
|
||||
|
||||
return int(amount)
|
||||
|
||||
def to_json(self):
|
||||
"""
|
||||
Return JSON fields to represent a coupon.
|
||||
|
||||
:return: dict
|
||||
"""
|
||||
params = {
|
||||
'duration': self.duration,
|
||||
'duration_in_months': self.duration_in_months,
|
||||
}
|
||||
|
||||
if self.amount_off:
|
||||
params['amount_off'] = cents_to_dollars(self.amount_off)
|
||||
|
||||
if self.percent_off:
|
||||
params['percent_off'] = self.percent_off,
|
||||
|
||||
return params
|
||||
+80
@@ -0,0 +1,80 @@
|
||||
import datetime
|
||||
|
||||
from lib.util_datetime import timedelta_months
|
||||
from lib.util_sqlalchemy import ResourceMixin
|
||||
from snakeeyes.extensions import db
|
||||
|
||||
|
||||
class CreditCard(ResourceMixin, db.Model):
|
||||
IS_EXPIRING_THRESHOLD_MONTHS = 2
|
||||
|
||||
__tablename__ = 'credit_cards'
|
||||
id = db.Column(db.Integer, primary_key=True)
|
||||
|
||||
# Relationships.
|
||||
user_id = db.Column(db.Integer, db.ForeignKey('users.id',
|
||||
onupdate='CASCADE',
|
||||
ondelete='CASCADE'),
|
||||
index=True, nullable=False)
|
||||
|
||||
# Card details.
|
||||
brand = db.Column(db.String(32))
|
||||
last4 = db.Column(db.Integer)
|
||||
exp_date = db.Column(db.Date, index=True)
|
||||
is_expiring = db.Column(db.Boolean(), nullable=False, server_default='0')
|
||||
|
||||
def __init__(self, **kwargs):
|
||||
# Call Flask-SQLAlchemy's constructor.
|
||||
super(CreditCard, self).__init__(**kwargs)
|
||||
|
||||
@classmethod
|
||||
def is_expiring_soon(cls, compare_date=None, exp_date=None):
|
||||
"""
|
||||
Determine whether or not this credit card is expiring soon.
|
||||
|
||||
:param compare_date: Date to compare at
|
||||
:type compare_date: date
|
||||
:param exp_date: Expiration date
|
||||
:type exp_date: date
|
||||
:return: bool
|
||||
"""
|
||||
return exp_date <= timedelta_months(
|
||||
CreditCard.IS_EXPIRING_THRESHOLD_MONTHS, compare_date=compare_date)
|
||||
|
||||
@classmethod
|
||||
def mark_old_credit_cards(cls, compare_date=None):
|
||||
"""
|
||||
Mark credit cards that are going to expire soon or have expired.
|
||||
|
||||
:param compare_date: Date to compare at
|
||||
:type compare_date: date
|
||||
:return: Result of updating the records
|
||||
"""
|
||||
today_with_delta = timedelta_months(
|
||||
CreditCard.IS_EXPIRING_THRESHOLD_MONTHS, compare_date)
|
||||
|
||||
CreditCard.query.filter(CreditCard.exp_date <= today_with_delta) \
|
||||
.update({CreditCard.is_expiring: True})
|
||||
|
||||
return db.session.commit()
|
||||
|
||||
@classmethod
|
||||
def extract_card_params(cls, customer):
|
||||
"""
|
||||
Extract the credit card info from a payment customer object.
|
||||
|
||||
:param customer: Payment customer
|
||||
:type customer: Payment customer
|
||||
:return: dict
|
||||
"""
|
||||
card_data = customer.sources.data[0]
|
||||
exp_date = datetime.date(card_data.exp_year, card_data.exp_month, 1)
|
||||
|
||||
card = {
|
||||
'brand': card_data.brand,
|
||||
'last4': card_data.last4,
|
||||
'exp_date': exp_date,
|
||||
'is_expiring': CreditCard.is_expiring_soon(exp_date=exp_date)
|
||||
}
|
||||
|
||||
return card
|
||||
@@ -0,0 +1,219 @@
|
||||
import datetime
|
||||
|
||||
from sqlalchemy import or_
|
||||
|
||||
from lib.util_sqlalchemy import ResourceMixin
|
||||
from snakeeyes.extensions import db
|
||||
from snakeeyes.blueprints.billing.models.credit_card import CreditCard
|
||||
from snakeeyes.blueprints.billing.models.coupon import Coupon
|
||||
from snakeeyes.blueprints.billing.gateways.stripecom import (
|
||||
Customer as PaymentCustomer,
|
||||
Charge as PaymentCharge,
|
||||
Invoice as PaymentInvoice
|
||||
)
|
||||
|
||||
|
||||
class Invoice(ResourceMixin, db.Model):
|
||||
__tablename__ = 'invoices'
|
||||
id = db.Column(db.Integer, primary_key=True)
|
||||
|
||||
# Relationships.
|
||||
user_id = db.Column(db.Integer, db.ForeignKey('users.id',
|
||||
onupdate='CASCADE',
|
||||
ondelete='CASCADE'),
|
||||
index=True, nullable=False)
|
||||
|
||||
# Invoice details.
|
||||
plan = db.Column(db.String(128), index=True)
|
||||
receipt_number = db.Column(db.String(128), index=True)
|
||||
description = db.Column(db.String(128))
|
||||
period_start_on = db.Column(db.Date)
|
||||
period_end_on = db.Column(db.Date)
|
||||
currency = db.Column(db.String(8))
|
||||
tax = db.Column(db.Integer())
|
||||
tax_percent = db.Column(db.Float())
|
||||
total = db.Column(db.Integer())
|
||||
|
||||
# De-normalize the card details so we can render a user's history properly
|
||||
# even if they have no active subscription or changed cards at some point.
|
||||
brand = db.Column(db.String(32))
|
||||
last4 = db.Column(db.Integer)
|
||||
exp_date = db.Column(db.Date, index=True)
|
||||
|
||||
def __init__(self, **kwargs):
|
||||
# Call Flask-SQLAlchemy's constructor.
|
||||
super(Invoice, self).__init__(**kwargs)
|
||||
|
||||
@classmethod
|
||||
def search(cls, query):
|
||||
"""
|
||||
Search a resource by 1 or more fields.
|
||||
|
||||
:param query: Search query
|
||||
:type query: str
|
||||
:return: SQLAlchemy filter
|
||||
"""
|
||||
from snakeeyes.blueprints.user.models import User
|
||||
|
||||
if not query:
|
||||
return ''
|
||||
|
||||
search_query = '%{0}%'.format(query)
|
||||
search_chain = (User.email.ilike(search_query),
|
||||
User.username.ilike(search_query))
|
||||
|
||||
return or_(*search_chain)
|
||||
|
||||
@classmethod
|
||||
def parse_from_event(cls, payload):
|
||||
"""
|
||||
Parse and return the invoice information that will get saved locally.
|
||||
|
||||
:return: dict
|
||||
"""
|
||||
data = payload['data']['object']
|
||||
plan_info = data['lines']['data'][0]['plan']
|
||||
|
||||
period_start_on = datetime.datetime.utcfromtimestamp(
|
||||
data['lines']['data'][0]['period']['start']).date()
|
||||
period_end_on = datetime.datetime.utcfromtimestamp(
|
||||
data['lines']['data'][0]['period']['end']).date()
|
||||
|
||||
invoice = {
|
||||
'payment_id': data['customer'],
|
||||
'plan': plan_info['name'],
|
||||
'receipt_number': data['receipt_number'],
|
||||
'description': plan_info['statement_descriptor'],
|
||||
'period_start_on': period_start_on,
|
||||
'period_end_on': period_end_on,
|
||||
'currency': data['currency'],
|
||||
'tax': data['tax'],
|
||||
'tax_percent': data['tax_percent'],
|
||||
'total': data['total']
|
||||
}
|
||||
|
||||
return invoice
|
||||
|
||||
@classmethod
|
||||
def parse_from_api(cls, payload):
|
||||
"""
|
||||
Parse and return the invoice information we are interested in.
|
||||
|
||||
:return: dict
|
||||
"""
|
||||
plan_info = payload['lines']['data'][0]['plan']
|
||||
date = datetime.datetime.utcfromtimestamp(payload['date'])
|
||||
|
||||
invoice = {
|
||||
'plan': plan_info['name'],
|
||||
'description': plan_info['statement_descriptor'],
|
||||
'next_bill_on': date,
|
||||
'amount_due': payload['amount_due'],
|
||||
'interval': plan_info['interval']
|
||||
}
|
||||
|
||||
return invoice
|
||||
|
||||
@classmethod
|
||||
def prepare_and_save(cls, parsed_event):
|
||||
"""
|
||||
Potentially save the invoice after argument the event fields.
|
||||
|
||||
:param parsed_event: Event params to be saved
|
||||
:type parsed_event: dict
|
||||
:return: User instance
|
||||
"""
|
||||
# Avoid circular imports.
|
||||
from snakeeyes.blueprints.user.models import User
|
||||
|
||||
# Only save the invoice if the user is valid at this point.
|
||||
id = parsed_event.get('payment_id')
|
||||
user = User.query.filter((User.payment_id == id)).first()
|
||||
|
||||
if user and user.credit_card:
|
||||
parsed_event['user_id'] = user.id
|
||||
parsed_event['brand'] = user.credit_card.brand
|
||||
parsed_event['last4'] = user.credit_card.last4
|
||||
parsed_event['exp_date'] = user.credit_card.exp_date
|
||||
|
||||
del parsed_event['payment_id']
|
||||
|
||||
invoice = Invoice(**parsed_event)
|
||||
invoice.save()
|
||||
|
||||
return user
|
||||
|
||||
@classmethod
|
||||
def upcoming(cls, customer_id):
|
||||
"""
|
||||
Return the upcoming invoice item.
|
||||
|
||||
:param customer_id: Stripe customer id
|
||||
:type customer_id: int
|
||||
:return: Stripe invoice object
|
||||
"""
|
||||
invoice = PaymentInvoice.upcoming(customer_id)
|
||||
|
||||
return Invoice.parse_from_api(invoice)
|
||||
|
||||
def create(self, user=None, currency=None, amount=None, coins=None,
|
||||
coupon=None, token=None):
|
||||
"""
|
||||
Create an invoice item.
|
||||
|
||||
:param user: User to apply the subscription to
|
||||
:type user: User instance
|
||||
:param amount: Stripe currency
|
||||
:type amount: str
|
||||
:param amount: Amount in cents
|
||||
:type amount: int
|
||||
:param coins: Amount of coins
|
||||
:type coins: int
|
||||
:param coupon: Coupon code to apply
|
||||
:type coupon: str
|
||||
:param token: Token returned by JavaScript
|
||||
:type token: str
|
||||
:return: bool
|
||||
"""
|
||||
if token is None:
|
||||
return False
|
||||
|
||||
customer = PaymentCustomer.create(token=token, email=user.email)
|
||||
|
||||
if coupon:
|
||||
self.coupon = coupon.upper()
|
||||
coupon = Coupon.query.filter(Coupon.code == self.coupon).first()
|
||||
amount = coupon.apply_discount_to(amount)
|
||||
|
||||
charge = PaymentCharge.create(customer.id, currency, amount)
|
||||
|
||||
# Redeem the coupon.
|
||||
if coupon:
|
||||
coupon.redeem()
|
||||
|
||||
# Add the coins to the user.
|
||||
user.coins += coins
|
||||
|
||||
# Create the invoice item.
|
||||
period_on = datetime.datetime.utcfromtimestamp(charge.get('created'))
|
||||
card_params = CreditCard.extract_card_params(customer)
|
||||
|
||||
self.user_id = user.id
|
||||
self.plan = '—'
|
||||
self.receipt_number = charge.get('receipt_number')
|
||||
self.description = charge.get('statement_descriptor')
|
||||
self.period_start_on = period_on
|
||||
self.period_end_on = period_on
|
||||
self.currency = charge.get('currency')
|
||||
self.tax = None
|
||||
self.tax_percent = None
|
||||
self.total = charge.get('amount')
|
||||
self.brand = card_params.get('brand')
|
||||
self.last4 = card_params.get('last4')
|
||||
self.exp_date = card_params.get('exp_date')
|
||||
|
||||
db.session.add(user)
|
||||
db.session.add(self)
|
||||
db.session.commit()
|
||||
|
||||
return True
|
||||
+223
@@ -0,0 +1,223 @@
|
||||
import datetime
|
||||
|
||||
import pytz
|
||||
|
||||
from config import settings
|
||||
from lib.util_sqlalchemy import ResourceMixin
|
||||
from snakeeyes.extensions import db
|
||||
from snakeeyes.blueprints.billing.models.credit_card import CreditCard
|
||||
from snakeeyes.blueprints.billing.models.coupon import Coupon
|
||||
from snakeeyes.blueprints.billing.gateways.stripecom import Card as PaymentCard
|
||||
from snakeeyes.blueprints.billing.gateways.stripecom import \
|
||||
Customer as PaymentCustomer, Subscription as PaymentSubscription
|
||||
from snakeeyes.blueprints.bet.models.coin import add_subscription_coins
|
||||
|
||||
|
||||
class Subscription(ResourceMixin, db.Model):
|
||||
__tablename__ = 'subscriptions'
|
||||
id = db.Column(db.Integer, primary_key=True)
|
||||
|
||||
# Relationships.
|
||||
user_id = db.Column(db.Integer, db.ForeignKey('users.id',
|
||||
onupdate='CASCADE',
|
||||
ondelete='CASCADE'),
|
||||
index=True, nullable=False)
|
||||
|
||||
# Subscription details.
|
||||
plan = db.Column(db.String(128))
|
||||
coupon = db.Column(db.String(128))
|
||||
|
||||
def __init__(self, **kwargs):
|
||||
# Call Flask-SQLAlchemy's constructor.
|
||||
super(Subscription, self).__init__(**kwargs)
|
||||
|
||||
@classmethod
|
||||
def get_plan_by_id(cls, plan):
|
||||
"""
|
||||
Pick the plan based on the plan identifier.
|
||||
|
||||
:param plan: Plan identifier
|
||||
:type plan: str
|
||||
:return: dict or None
|
||||
"""
|
||||
for key, value in settings.STRIPE_PLANS.items():
|
||||
if value.get('id') == plan:
|
||||
return settings.STRIPE_PLANS[key]
|
||||
|
||||
return None
|
||||
|
||||
@classmethod
|
||||
def get_new_plan(cls, keys):
|
||||
"""
|
||||
Pick the plan based on the plan identifier.
|
||||
|
||||
:param keys: Keys to look through
|
||||
:type keys: list
|
||||
:return: str or None
|
||||
"""
|
||||
for key in keys:
|
||||
split_key = key.split('submit_')
|
||||
|
||||
if isinstance(split_key, list) and len(split_key) == 2:
|
||||
if Subscription.get_plan_by_id(split_key[1]):
|
||||
return split_key[1]
|
||||
|
||||
return None
|
||||
|
||||
def create(self, user=None, name=None, plan=None, coupon=None, token=None):
|
||||
"""
|
||||
Create a recurring subscription.
|
||||
|
||||
:param user: User to apply the subscription to
|
||||
:type user: User instance
|
||||
:param name: User's billing name
|
||||
:type name: str
|
||||
:param plan: Plan identifier
|
||||
:type plan: str
|
||||
:param coupon: Coupon code to apply
|
||||
:type coupon: str
|
||||
:param token: Token returned by JavaScript
|
||||
:type token: str
|
||||
:return: bool
|
||||
"""
|
||||
if token is None:
|
||||
return False
|
||||
|
||||
if coupon:
|
||||
self.coupon = coupon.upper()
|
||||
|
||||
customer = PaymentCustomer.create(token=token,
|
||||
email=user.email,
|
||||
plan=plan,
|
||||
coupon=self.coupon)
|
||||
|
||||
# Update the user account.
|
||||
user.payment_id = customer.id
|
||||
user.name = name
|
||||
user.previous_plan = plan
|
||||
user.coins = add_subscription_coins(user.coins,
|
||||
Subscription.get_plan_by_id(
|
||||
user.previous_plan),
|
||||
Subscription.get_plan_by_id(plan),
|
||||
user.cancelled_subscription_on)
|
||||
user.cancelled_subscription_on = None
|
||||
|
||||
# Set the subscription details.
|
||||
self.user_id = user.id
|
||||
self.plan = plan
|
||||
|
||||
# Redeem the coupon.
|
||||
if coupon:
|
||||
coupon = Coupon.query.filter(Coupon.code == self.coupon).first()
|
||||
coupon.redeem()
|
||||
|
||||
# Create the credit card.
|
||||
credit_card = CreditCard(user_id=user.id,
|
||||
**CreditCard.extract_card_params(customer))
|
||||
|
||||
db.session.add(user)
|
||||
db.session.add(credit_card)
|
||||
db.session.add(self)
|
||||
|
||||
db.session.commit()
|
||||
|
||||
return True
|
||||
|
||||
def update(self, user=None, coupon=None, plan=None):
|
||||
"""
|
||||
Update an existing subscription.
|
||||
|
||||
:param user: User to apply the subscription to
|
||||
:type user: User instance
|
||||
:param coupon: Coupon code to apply
|
||||
:type coupon: str
|
||||
:param plan: Plan identifier
|
||||
:type plan: str
|
||||
:return: bool
|
||||
"""
|
||||
PaymentSubscription.update(user.payment_id, coupon, plan)
|
||||
|
||||
user.previous_plan = user.subscription.plan
|
||||
user.subscription.plan = plan
|
||||
user.coins = add_subscription_coins(user.coins,
|
||||
Subscription.get_plan_by_id(
|
||||
user.previous_plan),
|
||||
Subscription.get_plan_by_id(plan),
|
||||
user.cancelled_subscription_on)
|
||||
|
||||
if coupon:
|
||||
user.subscription.coupon = coupon
|
||||
coupon = Coupon.query.filter(Coupon.code == coupon).first()
|
||||
|
||||
if coupon:
|
||||
coupon.redeem()
|
||||
|
||||
db.session.add(user.subscription)
|
||||
db.session.commit()
|
||||
|
||||
return True
|
||||
|
||||
def cancel(self, user=None, discard_credit_card=True):
|
||||
"""
|
||||
Cancel an existing subscription.
|
||||
|
||||
:param user: User to apply the subscription to
|
||||
:type user: User instance
|
||||
:param discard_credit_card: Delete the user's credit card
|
||||
:type discard_credit_card: bool
|
||||
:return: bool
|
||||
"""
|
||||
PaymentSubscription.cancel(user.payment_id)
|
||||
|
||||
user.payment_id = None
|
||||
user.cancelled_subscription_on = datetime.datetime.now(pytz.utc)
|
||||
user.previous_plan = user.subscription.plan
|
||||
|
||||
db.session.add(user)
|
||||
db.session.delete(user.subscription)
|
||||
|
||||
# Explicitly delete the credit card because the FK is on the
|
||||
# user, not subscription so we can't depend on cascading deletes.
|
||||
# This is for cases where you may want to keep a user's card
|
||||
# on file even if they cancelled.
|
||||
if discard_credit_card:
|
||||
db.session.delete(user.credit_card)
|
||||
|
||||
db.session.commit()
|
||||
|
||||
return True
|
||||
|
||||
def update_payment_method(self, user=None, credit_card=None,
|
||||
name=None, token=None):
|
||||
"""
|
||||
Update the subscription.
|
||||
|
||||
:param user: User to modify
|
||||
:type user: User instance
|
||||
:param credit_card: Card to modify
|
||||
:type credit_card: Credit Card instance
|
||||
:param name: User's billing name
|
||||
:type name: str
|
||||
:param token: Token returned by JavaScript
|
||||
:type token: str
|
||||
:return: bool
|
||||
"""
|
||||
if token is None:
|
||||
return False
|
||||
|
||||
customer = PaymentCard.update(user.payment_id, token)
|
||||
user.name = name
|
||||
|
||||
# Update the credit card.
|
||||
new_card = CreditCard.extract_card_params(customer)
|
||||
credit_card.brand = new_card.get('brand')
|
||||
credit_card.last4 = new_card.get('last4')
|
||||
credit_card.exp_date = new_card.get('exp_date')
|
||||
credit_card.is_expiring = new_card.get('is_expiring')
|
||||
|
||||
db.session.add(user)
|
||||
db.session.add(credit_card)
|
||||
|
||||
db.session.commit()
|
||||
|
||||
return True
|
||||
@@ -0,0 +1,50 @@
|
||||
from snakeeyes.app import create_celery_app
|
||||
from snakeeyes.blueprints.user.models import User
|
||||
from snakeeyes.blueprints.billing.models.credit_card import CreditCard
|
||||
from snakeeyes.blueprints.billing.models.coupon import Coupon
|
||||
|
||||
celery = create_celery_app()
|
||||
|
||||
|
||||
@celery.task()
|
||||
def mark_old_credit_cards():
|
||||
"""
|
||||
Mark credit cards that are going to expire soon or have expired.
|
||||
|
||||
:return: Result of updating the records
|
||||
"""
|
||||
return CreditCard.mark_old_credit_cards()
|
||||
|
||||
|
||||
@celery.task()
|
||||
def expire_old_coupons():
|
||||
"""
|
||||
Invalidate coupons that are past their redeem date.
|
||||
|
||||
:return: Result of updating the records
|
||||
"""
|
||||
return Coupon.expire_old_coupons()
|
||||
|
||||
|
||||
@celery.task()
|
||||
def delete_users(ids):
|
||||
"""
|
||||
Delete users and potentially cancel their subscription.
|
||||
|
||||
:param ids: List of ids to be deleted
|
||||
:type ids: list
|
||||
:return: int
|
||||
"""
|
||||
return User.bulk_delete(ids)
|
||||
|
||||
|
||||
@celery.task()
|
||||
def delete_coupons(ids):
|
||||
"""
|
||||
Delete coupons both on the payment gateway and locally.
|
||||
|
||||
:param ids: List of ids to be deleted
|
||||
:type ids: list
|
||||
:return: int
|
||||
"""
|
||||
return Coupon.bulk_delete(ids)
|
||||
+30
@@ -0,0 +1,30 @@
|
||||
import datetime
|
||||
import pytz
|
||||
|
||||
from lib.money import cents_to_dollars
|
||||
|
||||
|
||||
def format_currency(amount, convert_to_dollars=True):
|
||||
"""
|
||||
Pad currency with 2 decimals and commas,
|
||||
optionally convert cents to dollars.
|
||||
|
||||
:param amount: Amount in cents or dollars
|
||||
:type amount: int or float
|
||||
:param convert_to_dollars: Convert cents to dollars
|
||||
:type convert_to_dollars: bool
|
||||
:return: str
|
||||
"""
|
||||
if convert_to_dollars:
|
||||
amount = cents_to_dollars(amount)
|
||||
|
||||
return '{:,.2f}'.format(amount)
|
||||
|
||||
|
||||
def current_year():
|
||||
"""
|
||||
Return this year.
|
||||
|
||||
:return: int
|
||||
"""
|
||||
return datetime.datetime.now(pytz.utc).year
|
||||
+15
@@ -0,0 +1,15 @@
|
||||
{% extends 'layouts/app.html' %}
|
||||
{% import 'billing/macros/billing.html' as billing %}
|
||||
|
||||
{% block title %}Billing details and history{% endblock %}
|
||||
|
||||
{% block body %}
|
||||
{{ billing.subscription_details(coupon) }}
|
||||
{{ billing.upcoming_invoice(upcoming) }}
|
||||
{{ billing.invoices(paginated_invoices) }}
|
||||
|
||||
<hr/>
|
||||
<a href="{{ url_for('user.settings') }}" class="btn btn-primary">
|
||||
Go back to your account
|
||||
</a>
|
||||
{% endblock %}
|
||||
+43
@@ -0,0 +1,43 @@
|
||||
{% extends 'layouts/app.html' %}
|
||||
{% import 'macros/form.html' as f with context %}
|
||||
|
||||
{% block title %}You are about to cancel your subscription{% endblock %}
|
||||
|
||||
{% block body %}
|
||||
<div class="row">
|
||||
<div class="col-md-4 col-md-offset-4 well">
|
||||
{% call f.form_tag('billing.cancel') %}
|
||||
<legend>Cancel subscription</legend>
|
||||
<p class="small help-block sm-margin-bottom">
|
||||
<strong>Hi {{ current_user.name }}</strong>,<br/>
|
||||
Before you cancel your account, please read the following:
|
||||
</p>
|
||||
|
||||
<ul class="md-margin-bottom">
|
||||
<li>You will no longer be billed</li>
|
||||
<li>You will lose access to the leaderboard</li>
|
||||
<li>Your account <strong>will</strong> remain open</li>
|
||||
<li>You may subscribe again in the future</li>
|
||||
</ul>
|
||||
|
||||
<div class="row">
|
||||
<div class="col-md-6">
|
||||
<button type="submit" class="btn btn-danger btn-block">
|
||||
<img src="{{ url_for('static', filename='images/spinner.gif') }}"
|
||||
class="spinner"
|
||||
width="16" height="11" alt="Spinner"/>
|
||||
Cancel it
|
||||
</button>
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<div class="visible-xs visible-sm sm-margin-top"></div>
|
||||
<a href="{{ url_for('user.settings') }}"
|
||||
class="btn btn-default btn-block">
|
||||
Nevermind
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
{% endcall %}
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
+314
@@ -0,0 +1,314 @@
|
||||
{% import 'macros/form.html' as f with context %}
|
||||
{% import 'macros/items.html' as items %}
|
||||
|
||||
{% macro card_expiring_warning() -%}
|
||||
{% if current_user.is_authenticated and
|
||||
current_user.subscription and current_user.credit_card.is_expiring %}
|
||||
<div class="alert alert-warning" role="alert">
|
||||
<div class="container">
|
||||
Your credit card is going to expire soon,
|
||||
<a href="{{ url_for('billing.update_payment_method') }}">
|
||||
please update it</a>
|
||||
.
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
{%- endmacro %}
|
||||
|
||||
|
||||
{% macro plan(plan) -%}
|
||||
<div class="col-md-4{{ ' scale-up' if plan.metadata.recommended }} {{ ' quarter-faded' if not plan.metadata.recommended }}">
|
||||
<div class="panel panel-default{{ ' panel-danger' if plan.metadata.recommended }}">
|
||||
<div class="panel-heading text-center"><h3>{{ plan.name }}</h3></div>
|
||||
<div class="panel-body">
|
||||
{{ caller () }}
|
||||
|
||||
{% if current_user.is_authenticated %}
|
||||
{% if current_user.subscription %}
|
||||
{% if plan == active_plan %}
|
||||
<strong class="btn btn-default btn-lg btn-block">
|
||||
Current plan
|
||||
</strong>
|
||||
{% else %}
|
||||
<button id="submit_{{ plan.id }}" name="submit_{{ plan.id }}"
|
||||
class="btn btn-primary btn-lg btn-block">
|
||||
<img src="{{ url_for('static', filename='images/spinner.gif') }}"
|
||||
class="spinner"
|
||||
width="16" height="11" alt="Spinner"/>
|
||||
Change plan
|
||||
</button>
|
||||
{% endif %}
|
||||
{% else %}
|
||||
<a href="{{ url_for('billing.create', plan=plan.id) }}"
|
||||
class="btn btn-primary btn-lg btn-block">Continue</a>
|
||||
{% endif %}
|
||||
{% else %}
|
||||
<a href="{{ url_for('user.signup') }}"
|
||||
class="btn btn-primary btn-lg btn-block">
|
||||
Sign up
|
||||
</a>
|
||||
{% endif %}
|
||||
|
||||
<p class="small text-muted sm-margin-top text-center">
|
||||
${{ plan.amount | format_currency }} / {{ plan.interval }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{%- endmacro %}
|
||||
|
||||
|
||||
{% macro coupon_data(coupon) -%}
|
||||
{% if coupon.amount_off %}
|
||||
{% set discount = '$' + coupon.amount_off | format_currency %}
|
||||
{% else %}
|
||||
{% set discount = coupon.percent_off | string + '%' %}
|
||||
{% endif %}
|
||||
{% if coupon.duration == 'forever' %}
|
||||
{% set label = 'It is saving you ' + discount + ' per month forever.' %}
|
||||
{% elif coupon.duration == 'once' %}
|
||||
{% set label = 'It saved you ' + discount + ' this month.' %}
|
||||
{% else %}
|
||||
{% set label = 'It is saving you ' + discount + ' for the next ' + coupon.duration_in_months | string + ' months.' %}
|
||||
{% endif %}
|
||||
|
||||
{{ label }}
|
||||
{%- endmacro %}
|
||||
|
||||
|
||||
{% macro coupon_duration_tag_for(coupon) -%}
|
||||
{% if coupon.duration == 'forever' %}
|
||||
<span class="label label-success">Forever</span>
|
||||
{% elif coupon.duration == 'once' %}
|
||||
<span class="label label-warning">Once</span>
|
||||
{% else %}
|
||||
<span class="label label-info">
|
||||
{{ coupon.duration_in_months }} months
|
||||
</span>
|
||||
{% endif %}
|
||||
{%- endmacro %}
|
||||
|
||||
|
||||
{% macro coupon_expiration(coupon) -%}
|
||||
{% if coupon.redeem_by %}
|
||||
<time class="from-now"
|
||||
data-datetime="{{ coupon.redeem_by }}">
|
||||
{{ coupon.redeem_by }}
|
||||
</time>
|
||||
{% elif coupon.duration_in_months %}
|
||||
—
|
||||
{% else %}
|
||||
Never
|
||||
{% endif %}
|
||||
{%- endmacro %}
|
||||
|
||||
|
||||
{% macro coupon_redeemed(times_redeemed, max_redemptions) -%}
|
||||
{% set total = max_redemptions if max_redemptions else '∞' | safe %}
|
||||
{{ times_redeemed }} / {{ total }}
|
||||
{%- endmacro %}
|
||||
|
||||
|
||||
{% macro coupon_discount(amount_off, percent_off) -%}
|
||||
{% if amount_off %}
|
||||
${{ amount_off | format_currency }}
|
||||
{% else %}
|
||||
{{ percent_off }}%
|
||||
{% endif %}
|
||||
{%- endmacro %}
|
||||
|
||||
|
||||
{% macro subscription_details(coupon) -%}
|
||||
<h2>Subscription details</h2>
|
||||
{% if coupon %}
|
||||
<div class="alert alert-info alert-small">
|
||||
Coupon code <strong>{{ coupon.code }}</strong> is applied to your
|
||||
subscription.
|
||||
</div>
|
||||
<span class="small text-muted">
|
||||
{{ coupon_data(coupon) }}
|
||||
</span>
|
||||
<br class="sm-margin-bottom">
|
||||
<br class="sm-margin-bottom">
|
||||
{% endif %}
|
||||
{%- endmacro %}
|
||||
|
||||
|
||||
{% macro upcoming_invoice(invoice) -%}
|
||||
{% if invoice == None %}
|
||||
<h3>No upcoming payments</h3>
|
||||
<p>You are not currently subscribed, so there's nothing to see here.</p>
|
||||
{% else %}
|
||||
<table class="table table-striped sm-margin-bottom">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Subscription</th>
|
||||
<th>Description</th>
|
||||
<th>Next billing date</th>
|
||||
<th>Price</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td>{{ invoice.plan }}</td>
|
||||
<td>{{ invoice.description }}</td>
|
||||
<td>
|
||||
<time class="short-date" data-datetime="{{ invoice.next_bill_on }}">
|
||||
{{ invoice.next_bill_on }}
|
||||
</time>
|
||||
</td>
|
||||
<td>
|
||||
${{ invoice.amount_due | format_currency }}
|
||||
<span class="color--muted"> / {{ invoice.interval }}</span>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
{% endif %}
|
||||
{%- endmacro %}
|
||||
|
||||
|
||||
{% macro invoices(invoices) -%}
|
||||
<h2>Billing history</h2>
|
||||
{% if invoices.total == 0 %}
|
||||
<h4>No invoices found</h4>
|
||||
<p>This isn't an error. You just haven't been invoiced yet.</p>
|
||||
{% else %}
|
||||
<table class="table table-striped sm-margin-bottom">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Date</th>
|
||||
<th>Method</th>
|
||||
<th>Description</th>
|
||||
<th>Service period</th>
|
||||
<th>Tax</th>
|
||||
<th>Total</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for invoice in invoices.items %}
|
||||
<tr>
|
||||
<td>
|
||||
<time class="from-now" data-datetime="{{ invoice.created_on }}">
|
||||
{{ invoice.created_on }}
|
||||
</time>
|
||||
</td>
|
||||
<td class="small">
|
||||
{{ invoice.brand }} ****{{ invoice.last4 }}
|
||||
</td>
|
||||
<td class="small">{{ invoice.description }}</td>
|
||||
<td>
|
||||
<time class="short-date" data-datetime="{{ invoice.period_start_on }}">
|
||||
{{ invoice.period_start_on }}
|
||||
</time>
|
||||
<span class="text-info">—</span>
|
||||
<time class="short-date" data-datetime="{{ invoice.period_end_on }}">
|
||||
{{ invoice.period_end_on }}
|
||||
</time>
|
||||
</td>
|
||||
<td title="{{ (invoice.tax_percent | round(2)) | string + '%' if invoice.tax_percent }}">
|
||||
{{ '$' + invoice.tax | format_currency if invoice.tax }}
|
||||
</td>
|
||||
<td title="{{ invoice.currency }}">
|
||||
${{ invoice.total | format_currency }}
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
{{ items.paginate(invoices) }}
|
||||
{% endif %}
|
||||
{%- endmacro %}
|
||||
|
||||
|
||||
{% macro payment_form(form, button_label, with_coupon) -%}
|
||||
<div class="alert alert-small alert-danger payment-errors"></div>
|
||||
|
||||
<noscript>
|
||||
<p class="alert alert-sm alert-warning text-center sm-margin-top">
|
||||
You need to enable JavaScript to proceed.
|
||||
</p>
|
||||
</noscript>
|
||||
|
||||
{% if with_coupon %}
|
||||
<div class="coupon-code">
|
||||
{{ form.coupon_code.label }}
|
||||
{{ f.field(form.coupon_code) }}
|
||||
<div id="coupon_code_status"
|
||||
class="small alert alert-small alert-success">
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% call f.form_group(form.name, css_class='sm-margin-bottom',
|
||||
autofocus='autofocus') %}
|
||||
{% endcall %}
|
||||
|
||||
<div class="form-group">
|
||||
<label for=""><strong>Card number</strong></label>
|
||||
<input data-stripe="number" class="form-control"
|
||||
value="{{ '****' + card_last4 if card_last4 }}"/>
|
||||
</div>
|
||||
|
||||
<div class="row">
|
||||
<div class="col-md-6">
|
||||
<div class="form-group pull-left form-inline">
|
||||
<label for="" class="block"><strong>Exp. date</strong></label><br/>
|
||||
<select data-stripe="exp-month" class="form-control cc-details">
|
||||
<option value=""></option>
|
||||
{% for month in range(1,13) %}
|
||||
<option value="{{ '%02d' % month }}">
|
||||
{{ '%02d' % month }}
|
||||
</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
<select data-stripe="exp-year" class="form-control cc-details">
|
||||
<option value=""></option>
|
||||
{% for year in range(current_year(),current_year()+15) %}
|
||||
<option value="{{ '%04d' % year }}">
|
||||
{{ '%04d' % year }}
|
||||
</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<div class="form-group pull-right">
|
||||
<label for="">
|
||||
<strong>
|
||||
<abbr title="3-4 digit code on the back">CVC</abbr>
|
||||
</strong>
|
||||
</label>
|
||||
<input data-stripe="cvc" class="form-control cc-details"/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button type="submit" class="btn btn-primary btn-block lg-margin-bottom">
|
||||
<img src="{{ url_for('static', filename='images/spinner.gif') }}"
|
||||
class="spinner"
|
||||
width="16" height="11" alt="Spinner"/>
|
||||
{{ button_label }}
|
||||
</button>
|
||||
|
||||
<div class="text-center quarter-faded">
|
||||
<img src="{{ url_for('static', filename='images/cc/visa.png') }}"
|
||||
width="40" height="24" class="cc-images" alt="Visa"/>
|
||||
<img src="{{ url_for('static', filename='images/cc/mastercard.png') }}"
|
||||
width="40" height="24" class="cc-images"
|
||||
alt="Mastercard"/>
|
||||
<img src="{{ url_for('static', filename='images/cc/american-express.png') }}"
|
||||
width="40" height="24" class="cc-images"
|
||||
alt="American Express"/>
|
||||
<img src="{{ url_for('static', filename='images/cc/jcb.png') }}"
|
||||
width="40" height="24" class="cc-images" alt="J.C.B"/>
|
||||
<img src="{{ url_for('static', filename='images/cc/diners-club.png') }}"
|
||||
width="40" height="24" class="cc-images"
|
||||
alt="Diner's Club"/>
|
||||
|
||||
<div class="text-success sm-margin-top">
|
||||
<i class="fa fa-fw fa-lock"></i>
|
||||
<span class="small">Protected by 128-bit SSL encryption</span>
|
||||
</div>
|
||||
</div>
|
||||
{%- endmacro %}
|
||||
+39
@@ -0,0 +1,39 @@
|
||||
{% extends 'layouts/app.html' %}
|
||||
{% import 'macros/form.html' as f with context %}
|
||||
{% import 'billing/macros/billing.html' as billing %}
|
||||
|
||||
{% set plan_name = request.args.get('plan', '') %}
|
||||
{% set is_create = request.endpoint.endswith('create') %}
|
||||
{% if is_create %}
|
||||
{% set title = 'Enter payment information' %}
|
||||
{% set endpoint = 'billing.create' %}
|
||||
{% set button = 'Process payment' %}
|
||||
{% else %}
|
||||
{% set title = 'Update payment information' %}
|
||||
{% set endpoint = 'billing.update_payment_method' %}
|
||||
{% set button = 'Update payment method' %}
|
||||
{% endif %}
|
||||
|
||||
{% block title %}{{ title }}{% endblock %}
|
||||
|
||||
{% block body %}
|
||||
<div class="row">
|
||||
<div class="col-md-4 col-md-offset-4 well">
|
||||
{% call f.form_tag(endpoint, fid='subscription_form') %}
|
||||
<legend>
|
||||
{% if is_create %}
|
||||
{{ plan_name | title }} subscription<br/>
|
||||
<span class="small text-muted">
|
||||
This plan costs
|
||||
${{ plan.amount | format_currency }} / {{ plan.interval }}
|
||||
</span>
|
||||
{% else %}
|
||||
{{ title }}
|
||||
{% endif %}
|
||||
</legend>
|
||||
|
||||
{{ billing.payment_form(form, button, is_create) }}
|
||||
{% endcall %}
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
+70
@@ -0,0 +1,70 @@
|
||||
{% extends 'layouts/app.html' %}
|
||||
{% import 'macros/form.html' as f with context %}
|
||||
{% import 'billing/macros/billing.html' as billing with context %}
|
||||
|
||||
{% block title %}Ready to start winning?{% endblock %}
|
||||
{% block meta_description %}Sign up today to subscribe so you can begin to
|
||||
win points by guessing the outcome of dice rolls.{% endblock %}
|
||||
|
||||
{% block heading %}
|
||||
{% if current_user.subscription %}
|
||||
<h2 class="text-center">You're about to change plans</h2>
|
||||
<div class="alert alert-warning text-center margin-bottom">
|
||||
Your plan will change <strong>immediately</strong> after clicking
|
||||
'Change plan'.
|
||||
</div>
|
||||
{% else %}
|
||||
<h2 class="text-center">Ready to start winning?</h2>
|
||||
<h3 class="text-center text-muted lg-margin-bottom">
|
||||
You're a few clicks away from being able to play
|
||||
</h3>
|
||||
{% endif %}
|
||||
{% endblock %}
|
||||
|
||||
{% block body %}
|
||||
{% call f.form_tag('billing.update', class='text-center') %}
|
||||
{% if current_user.subscription %}
|
||||
<div class="lg-margin-bottom">
|
||||
<div class="coupon-code">
|
||||
{{ form.coupon_code.label }}
|
||||
{{ f.field(form.coupon_code) }}
|
||||
<div id="coupon_code_status"
|
||||
class="small alert alert-small alert-success">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
<div class="row">
|
||||
{% call billing.plan(plans['0']) %}
|
||||
<ul class="list-group list-group-flush text-center">
|
||||
<li class="list-group-item">110 tokens per month</li>
|
||||
<li class="list-group-item">Leaderboard rankings</li>
|
||||
<li class="list-group-item strike">Dicta optio cumque dolore</li>
|
||||
<li class="list-group-item strike">Dicta optio cumque dolore</li>
|
||||
<li class="list-group-item strike">Consequuntur voluptatum</li>
|
||||
<li class="list-group-item strike">Consequuntur voluptatum</li>
|
||||
</ul>
|
||||
{% endcall %}
|
||||
{% call billing.plan(plans['1']) %}
|
||||
<ul class="list-group list-group-flush text-center">
|
||||
<li class="list-group-item">600 tokens per month</li>
|
||||
<li class="list-group-item">Leaderboard rankings</li>
|
||||
<li class="list-group-item">Dicta optio cumque dolore</li>
|
||||
<li class="list-group-item">Dicta optio cumque dolore</li>
|
||||
<li class="list-group-item strike">Consequuntur voluptatum</li>
|
||||
<li class="list-group-item strike">Consequuntur voluptatum</li>
|
||||
</ul>
|
||||
{% endcall %}
|
||||
{% call billing.plan(plans['2']) %}
|
||||
<ul class="list-group list-group-flush text-center">
|
||||
<li class="list-group-item">1,500 tokens per month</li>
|
||||
<li class="list-group-item">Leaderboard rankings</li>
|
||||
<li class="list-group-item">Dicta optio cumque dolore</li>
|
||||
<li class="list-group-item">Dicta optio cumque dolore</li>
|
||||
<li class="list-group-item">Consequuntur voluptatum</li>
|
||||
<li class="list-group-item">Consequuntur voluptatum</li>
|
||||
</ul>
|
||||
{% endcall %}
|
||||
</div>
|
||||
{% endcall %}
|
||||
{% endblock %}
|
||||
+20
@@ -0,0 +1,20 @@
|
||||
{% extends 'layouts/app.html' %}
|
||||
{% import 'macros/form.html' as f with context %}
|
||||
{% import 'billing/macros/billing.html' as billing %}
|
||||
|
||||
{% block title %}Purchase coins{% endblock %}
|
||||
|
||||
{% block body %}
|
||||
<div class="row">
|
||||
<div class="col-md-4 col-md-offset-4 well">
|
||||
{% call f.form_tag('billing.purchase_coins', fid='payment_form') %}
|
||||
<legend>Purchase coins</legend>
|
||||
|
||||
{% call f.form_group(form.coin_bundles, css_class='sm-margin-bottom') %}
|
||||
{% endcall %}
|
||||
|
||||
{{ billing.payment_form(form, 'Process payment', True) }}
|
||||
{% endcall %}
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,240 @@
|
||||
from flask import (
|
||||
Blueprint,
|
||||
current_app,
|
||||
render_template,
|
||||
url_for,
|
||||
request,
|
||||
redirect,
|
||||
flash,
|
||||
)
|
||||
|
||||
from flask_login import login_required, current_user
|
||||
|
||||
from config import settings
|
||||
from lib.util_json import render_json
|
||||
from snakeeyes.blueprints.billing.forms import SubscriptionForm, \
|
||||
UpdateSubscriptionForm, CancelSubscriptionForm, PaymentForm
|
||||
from snakeeyes.blueprints.billing.models.coupon import Coupon
|
||||
from snakeeyes.blueprints.billing.models.subscription import Subscription
|
||||
from snakeeyes.blueprints.billing.models.invoice import Invoice
|
||||
from snakeeyes.blueprints.billing.decorators import subscription_required, \
|
||||
handle_stripe_exceptions
|
||||
|
||||
billing = Blueprint('billing', __name__, template_folder='../templates',
|
||||
url_prefix='/subscription')
|
||||
|
||||
|
||||
@billing.route('/pricing')
|
||||
def pricing():
|
||||
if current_user.is_authenticated and current_user.subscription:
|
||||
return redirect(url_for('billing.update'))
|
||||
|
||||
form = UpdateSubscriptionForm()
|
||||
|
||||
return render_template('billing/pricing.html', form=form,
|
||||
plans=settings.STRIPE_PLANS)
|
||||
|
||||
|
||||
@billing.route('/coupon_code', methods=['POST'])
|
||||
@login_required
|
||||
def coupon_code():
|
||||
code = request.form.get('coupon_code')
|
||||
if code is None:
|
||||
return render_json(422,
|
||||
{'error': 'Coupon code cannot be processed.'})
|
||||
|
||||
coupon = Coupon.find_by_code(code)
|
||||
if coupon is None:
|
||||
return render_json(404, {'error': 'Coupon code not found.'})
|
||||
|
||||
return render_json(200, {'data': coupon.to_json()})
|
||||
|
||||
|
||||
@billing.route('/create', methods=['GET', 'POST'])
|
||||
@handle_stripe_exceptions
|
||||
@login_required
|
||||
def create():
|
||||
if current_user.subscription:
|
||||
flash('You already have an active subscription.', 'info')
|
||||
return redirect(url_for('user.settings'))
|
||||
|
||||
plan = request.args.get('plan')
|
||||
subscription_plan = Subscription.get_plan_by_id(plan)
|
||||
|
||||
# Guard against an invalid or missing plan.
|
||||
if subscription_plan is None and request.method == 'GET':
|
||||
flash('Sorry, that plan did not exist.', 'error')
|
||||
return redirect(url_for('billing.pricing'))
|
||||
|
||||
stripe_key = current_app.config.get('STRIPE_PUBLISHABLE_KEY')
|
||||
form = SubscriptionForm(stripe_key=stripe_key, plan=plan)
|
||||
|
||||
if form.validate_on_submit():
|
||||
subscription = Subscription()
|
||||
created = subscription.create(user=current_user,
|
||||
name=request.form.get('name'),
|
||||
plan=request.form.get('plan'),
|
||||
coupon=request.form.get('coupon_code'),
|
||||
token=request.form.get('stripe_token'))
|
||||
|
||||
if created:
|
||||
flash('Awesome, thanks for subscribing!', 'success')
|
||||
else:
|
||||
flash('You must enable JavaScript for this request.', 'warning')
|
||||
|
||||
return redirect(url_for('user.settings'))
|
||||
|
||||
return render_template('billing/payment_method.html',
|
||||
form=form, plan=subscription_plan)
|
||||
|
||||
|
||||
@billing.route('/update', methods=['GET', 'POST'])
|
||||
@handle_stripe_exceptions
|
||||
@subscription_required
|
||||
@login_required
|
||||
def update():
|
||||
current_plan = current_user.subscription.plan
|
||||
active_plan = Subscription.get_plan_by_id(current_plan)
|
||||
new_plan = Subscription.get_new_plan(request.form.keys())
|
||||
|
||||
plan = Subscription.get_plan_by_id(new_plan)
|
||||
|
||||
# Guard against an invalid, missing or identical plan.
|
||||
is_same_plan = new_plan == active_plan['id']
|
||||
if ((new_plan is not None and plan is None) or is_same_plan) and \
|
||||
request.method == 'POST':
|
||||
return redirect(url_for('billing.update'))
|
||||
|
||||
form = UpdateSubscriptionForm(coupon_code=current_user.subscription.coupon)
|
||||
|
||||
if form.validate_on_submit():
|
||||
subscription = Subscription()
|
||||
updated = subscription.update(user=current_user,
|
||||
coupon=request.form.get('coupon_code'),
|
||||
plan=plan.get('id'))
|
||||
|
||||
if updated:
|
||||
flash('Your subscription has been updated.', 'success')
|
||||
return redirect(url_for('user.settings'))
|
||||
|
||||
return render_template('billing/pricing.html',
|
||||
form=form,
|
||||
plans=settings.STRIPE_PLANS,
|
||||
active_plan=active_plan)
|
||||
|
||||
|
||||
@billing.route('/cancel', methods=['GET', 'POST'])
|
||||
@handle_stripe_exceptions
|
||||
@login_required
|
||||
def cancel():
|
||||
if not current_user.subscription:
|
||||
flash('You do not have an active subscription.', 'error')
|
||||
return redirect(url_for('user.settings'))
|
||||
|
||||
form = CancelSubscriptionForm()
|
||||
|
||||
if form.validate_on_submit():
|
||||
subscription = Subscription()
|
||||
cancelled = subscription.cancel(user=current_user)
|
||||
|
||||
if cancelled:
|
||||
flash('Sorry to see you go, your subscription has been cancelled.',
|
||||
'success')
|
||||
return redirect(url_for('user.settings'))
|
||||
|
||||
return render_template('billing/cancel.html', form=form)
|
||||
|
||||
|
||||
@billing.route('/update_payment_method', methods=['GET', 'POST'])
|
||||
@handle_stripe_exceptions
|
||||
@login_required
|
||||
def update_payment_method():
|
||||
if not current_user.credit_card:
|
||||
flash('You do not have a payment method on file.', 'error')
|
||||
return redirect(url_for('user.settings'))
|
||||
|
||||
active_plan = Subscription.get_plan_by_id(
|
||||
current_user.subscription.plan)
|
||||
|
||||
card = current_user.credit_card
|
||||
stripe_key = current_app.config.get('STRIPE_PUBLISHABLE_KEY')
|
||||
form = SubscriptionForm(stripe_key=stripe_key,
|
||||
plan=active_plan,
|
||||
name=current_user.name)
|
||||
|
||||
if form.validate_on_submit():
|
||||
subscription = Subscription()
|
||||
updated = subscription.update_payment_method(user=current_user,
|
||||
credit_card=card,
|
||||
name=request.form.get(
|
||||
'name'),
|
||||
token=request.form.get(
|
||||
'stripe_token'))
|
||||
|
||||
if updated:
|
||||
flash('Your payment method has been updated.', 'success')
|
||||
else:
|
||||
flash('You must enable JavaScript for this request.', 'warning')
|
||||
|
||||
return redirect(url_for('user.settings'))
|
||||
|
||||
return render_template('billing/payment_method.html', form=form,
|
||||
plan=active_plan, card_last4=str(card.last4))
|
||||
|
||||
|
||||
@billing.route('/billing_details', defaults={'page': 1})
|
||||
@billing.route('/billing_details/page/<int:page>')
|
||||
@handle_stripe_exceptions
|
||||
@login_required
|
||||
def billing_details(page):
|
||||
paginated_invoices = Invoice.query.filter(
|
||||
Invoice.user_id == current_user.id) \
|
||||
.order_by(Invoice.created_on.desc()).paginate(page, 12, True)
|
||||
|
||||
if current_user.subscription:
|
||||
upcoming = Invoice.upcoming(current_user.payment_id)
|
||||
coupon = Coupon.query \
|
||||
.filter(Coupon.code == current_user.subscription.coupon).first()
|
||||
else:
|
||||
upcoming = None
|
||||
coupon = None
|
||||
|
||||
return render_template('billing/billing_details.html',
|
||||
paginated_invoices=paginated_invoices,
|
||||
upcoming=upcoming, coupon=coupon)
|
||||
|
||||
|
||||
@billing.route('/purchase_coins', methods=['GET', 'POST'])
|
||||
@login_required
|
||||
def purchase_coins():
|
||||
stripe_key = current_app.config.get('STRIPE_PUBLISHABLE_KEY')
|
||||
form = PaymentForm(stripe_key=stripe_key)
|
||||
|
||||
if form.validate_on_submit():
|
||||
coin_bundles = current_app.config.get('COIN_BUNDLES')
|
||||
coin_bundles_form = int(request.form.get('coin_bundles'))
|
||||
|
||||
bundle = next((item for item in coin_bundles if
|
||||
item['coins'] == coin_bundles_form), None)
|
||||
|
||||
if bundle is not None:
|
||||
invoice = Invoice()
|
||||
created = invoice.create(user=current_user,
|
||||
currency=current_app.config.get(
|
||||
'STRIPE_CURRENCY'),
|
||||
amount=bundle.get('price_in_cents'),
|
||||
coins=coin_bundles_form,
|
||||
coupon=request.form.get('coupon_code'),
|
||||
token=request.form.get('stripe_token'))
|
||||
|
||||
if created:
|
||||
flash('{0} coins have been added to your account.'.format(
|
||||
coin_bundles_form),
|
||||
'success')
|
||||
else:
|
||||
flash('You must enable JavaScript for this request.',
|
||||
'warning')
|
||||
|
||||
return redirect(url_for('bet.place_bet'))
|
||||
|
||||
return render_template('billing/purchase_coins.html', form=form)
|
||||
+41
@@ -0,0 +1,41 @@
|
||||
from flask import Blueprint, request
|
||||
from stripe.error import InvalidRequestError
|
||||
|
||||
from lib.util_json import render_json
|
||||
from snakeeyes.extensions import csrf
|
||||
from snakeeyes.blueprints.billing.models.invoice import Invoice
|
||||
from snakeeyes.blueprints.billing.models.subscription import Subscription
|
||||
from snakeeyes.blueprints.billing.gateways.stripecom import \
|
||||
Event as PaymentEvent
|
||||
|
||||
stripe_webhook = Blueprint('stripe_webhook', __name__,
|
||||
url_prefix='/stripe_webhook')
|
||||
|
||||
|
||||
@stripe_webhook.route('/event', methods=['POST'])
|
||||
@csrf.exempt
|
||||
def event():
|
||||
if not request.json:
|
||||
return render_json(406, {'error': 'Mime-type is not application/json'})
|
||||
|
||||
if request.json.get('id') is None:
|
||||
return render_json(406, {'error': 'Invalid Stripe event'})
|
||||
|
||||
try:
|
||||
safe_event = PaymentEvent.retrieve(request.json.get('id'))
|
||||
parsed_event = Invoice.parse_from_event(safe_event)
|
||||
|
||||
user = Invoice.prepare_and_save(parsed_event)
|
||||
|
||||
if parsed_event.get('total') > 0:
|
||||
plan = Subscription.get_plan_by_id(user.subscription.plan)
|
||||
user.add_coins(plan)
|
||||
except InvalidRequestError as e:
|
||||
# We could not parse the event.
|
||||
return render_json(422, {'error': str(e)})
|
||||
except Exception as e:
|
||||
# Return a 200 because something is really wrong and we want Stripe to
|
||||
# stop trying to fulfill this webhook request.
|
||||
return render_json(200, {'error': str(e)})
|
||||
|
||||
return render_json(200, {'success': True})
|
||||
@@ -0,0 +1 @@
|
||||
from snakeeyes.blueprints.contact.views import contact
|
||||
@@ -0,0 +1,11 @@
|
||||
from flask_wtf import Form
|
||||
from wtforms import TextAreaField
|
||||
from wtforms_components import EmailField
|
||||
from wtforms.validators import DataRequired, Length
|
||||
|
||||
|
||||
class ContactForm(Form):
|
||||
email = EmailField("What's your e-mail address?",
|
||||
[DataRequired(), Length(3, 254)])
|
||||
message = TextAreaField("What's your question or issue?",
|
||||
[DataRequired(), Length(1, 8192)])
|
||||
@@ -0,0 +1,26 @@
|
||||
from lib.flask_mailplus import send_template_message
|
||||
from snakeeyes.app import create_celery_app
|
||||
|
||||
celery = create_celery_app()
|
||||
|
||||
|
||||
@celery.task()
|
||||
def deliver_contact_email(email, message):
|
||||
"""
|
||||
Send a contact e-mail.
|
||||
|
||||
:param email: E-mail address of the visitor
|
||||
:type user_id: str
|
||||
:param message: E-mail message
|
||||
:type user_id: str
|
||||
:return: None
|
||||
"""
|
||||
ctx = {'email': email, 'message': message}
|
||||
|
||||
send_template_message(subject='[Snake Eyes] Contact',
|
||||
sender=email,
|
||||
recipients=[celery.conf.get('MAIL_USERNAME')],
|
||||
reply_to=email,
|
||||
template='contact/mail/index', ctx=ctx)
|
||||
|
||||
return None
|
||||
+27
@@ -0,0 +1,27 @@
|
||||
{% extends 'layouts/app.html' %}
|
||||
{% import 'macros/form.html' as f with context %}
|
||||
|
||||
{% block title %}Contact us with your feedback or issues{% endblock %}
|
||||
{% block meta_description %}Contact our customer service department with any
|
||||
questions or concerns.{% endblock %}
|
||||
|
||||
{% block body %}
|
||||
<div class="row">
|
||||
<div class="col-md-8 col-md-offset-2 well">
|
||||
{% call f.form_tag('contact.index') %}
|
||||
<legend>We're here to answer your questions</legend>
|
||||
|
||||
{% call f.form_group(form.email, css_class='margin-bottom',
|
||||
placeholder='E-mail address') %}
|
||||
{% endcall %}
|
||||
|
||||
{% call f.form_group(form.message, css_class='margin-bottom',
|
||||
placeholder='The more we know, the easier it will be to help you',
|
||||
rows='12') %}
|
||||
{% endcall %}
|
||||
|
||||
<button type="submit" class="btn btn-primary">Send e-mail</button>
|
||||
{% endcall %}
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
+3
@@ -0,0 +1,3 @@
|
||||
{{ email }} wrote:
|
||||
|
||||
{{ message }}
|
||||
@@ -0,0 +1,30 @@
|
||||
from flask import (
|
||||
Blueprint,
|
||||
flash,
|
||||
redirect,
|
||||
request,
|
||||
url_for,
|
||||
render_template)
|
||||
from flask_login import current_user
|
||||
|
||||
from snakeeyes.blueprints.contact.forms import ContactForm
|
||||
|
||||
contact = Blueprint('contact', __name__, template_folder='templates')
|
||||
|
||||
|
||||
@contact.route('/contact', methods=['GET', 'POST'])
|
||||
def index():
|
||||
# Pre-populate the email field if the user is signed in.
|
||||
form = ContactForm(obj=current_user)
|
||||
|
||||
if form.validate_on_submit():
|
||||
# This prevents circular imports.
|
||||
from snakeeyes.blueprints.contact.tasks import deliver_contact_email
|
||||
|
||||
deliver_contact_email.delay(request.form.get('email'),
|
||||
request.form.get('message'))
|
||||
|
||||
flash('Thanks, expect a response shortly.', 'success')
|
||||
return redirect(url_for('contact.index'))
|
||||
|
||||
return render_template('contact/index.html', form=form)
|
||||
@@ -0,0 +1 @@
|
||||
from snakeeyes.blueprints.page.views import page
|
||||
+42
@@ -0,0 +1,42 @@
|
||||
{% extends 'layouts/app.html' %}
|
||||
|
||||
{% block title %}Test your luck against the roll of the dice{% endblock %}
|
||||
{% block meta_description %}Compete against yourself and others by guessing the
|
||||
outcome of a dice roll. Win points and dominate the leaderboard.{% endblock %}
|
||||
|
||||
{% block body %}
|
||||
<div class="container sm-margin-top">
|
||||
<div class="row text-center lg-margin-bottom">
|
||||
<h1>Feeling lucky?</h1>
|
||||
<h3 class="text-muted">
|
||||
Test your luck against the roll of the dice and win today
|
||||
</h3>
|
||||
</div>
|
||||
<div class="row text-center">
|
||||
<div class="col-lg-4">
|
||||
<i class="fa fa-fw fa-4x fa-bullhorn text-primary"></i>
|
||||
<h2>Place bets</h2>
|
||||
<p class="text-muted">Correctly guess the dice's roll.</p>
|
||||
<p>
|
||||
<a href="{{ url_for('user.signup') }}" class="btn btn-default">Sign up</a>
|
||||
</p>
|
||||
</div>
|
||||
<div class="col-lg-4">
|
||||
<i class="fa fa-fw fa-4x fa-database text-primary"></i>
|
||||
<h2>Win currency</h2>
|
||||
<p class="text-muted">Gain coins if your guess is correct.</p>
|
||||
<p>
|
||||
<a href="{{ url_for('user.signup') }}" class="btn btn-default">Sign up</a>
|
||||
</p>
|
||||
</div>
|
||||
<div class="col-lg-4">
|
||||
<i class="fa fa-fw fa-4x fa-trophy text-primary"></i>
|
||||
<h2>Leaderboard</h2>
|
||||
<p class="text-muted">Compare your results to others.</p>
|
||||
<p>
|
||||
<a href="{{ url_for('user.signup') }}" class="btn btn-default">Sign up</a>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
+143
@@ -0,0 +1,143 @@
|
||||
{% extends 'layouts/app.html' %}
|
||||
|
||||
{% block title %}Privacy Policy{% endblock %}
|
||||
{% block heading %}<h2>Privacy Policy</h2>{% endblock %}
|
||||
|
||||
{% block body %}
|
||||
<p>
|
||||
This privacy policy discloses the privacy practices for
|
||||
<a href="http://{{ config.get('SERVER_NAME') }}">
|
||||
http://{{ config.get('SERVER_NAME') }}
|
||||
</a>.
|
||||
This privacy policy applies solely to information collected by this web
|
||||
site. It will notify you of the following:
|
||||
</p>
|
||||
<ul>
|
||||
<li>
|
||||
What personally identifiable information is collected from you through
|
||||
the web site, how it is used and with whom it may be shared.
|
||||
</li>
|
||||
<li>
|
||||
What choices are available to you regarding the use of your data.
|
||||
</li>
|
||||
<li>
|
||||
The security procedures in place to protect the misuse of your information.
|
||||
</li>
|
||||
<li>How you can correct any inaccuracies in the information.</li>
|
||||
</ul>
|
||||
|
||||
<hr/>
|
||||
|
||||
<h3>Information collection, use, and sharing</h3>
|
||||
<p>
|
||||
We are the sole owners of the information collected on this site. We
|
||||
only have access to/collect information that you voluntarily give us via
|
||||
email or other direct contact from you. We will not sell or rent this
|
||||
information to anyone.
|
||||
</p>
|
||||
<p>
|
||||
We will use your information to respond to you, regarding the reason you
|
||||
contacted us. We will not share your information with any third party
|
||||
outside of our organization, other than as necessary to fulfill your
|
||||
request, e.g. to process a credit card.
|
||||
</p>
|
||||
<p>
|
||||
Unless you ask us not to, we may contact you via email in the future to
|
||||
tell you about specials, new products or services, or changes to this
|
||||
privacy policy.
|
||||
</p>
|
||||
|
||||
<hr/>
|
||||
|
||||
<h3>Your access to and control over information</h3>
|
||||
<p>
|
||||
You may opt out of any future contacts from us at any time. You can do the
|
||||
following at any time by contacting us via the email address or phone
|
||||
number given on our website:
|
||||
</p>
|
||||
<ul>
|
||||
<li>See what data we have about you, if any.</li>
|
||||
<li>Change/correct any data we have about you.</li>
|
||||
<li>Have us delete any data we have about you.</li>
|
||||
<li>Express any concern you have about our use of your data.</li>
|
||||
</ul>
|
||||
|
||||
<hr/>
|
||||
|
||||
<h3>Security</h3>
|
||||
<p>
|
||||
We take precautions to protect your information. When you submit sensitive
|
||||
information via the website, your information is protected both online and
|
||||
offline.
|
||||
</p>
|
||||
<p>
|
||||
Wherever we collect sensitive information (such as credit card data),
|
||||
that information is encrypted and transmitted to us in a secure way.
|
||||
You can verify this by looking for a closed lock icon at the bottom of your
|
||||
web browser, or looking for "https" at the beginning of the address of
|
||||
the web page.
|
||||
</p>
|
||||
<p>
|
||||
While we use encryption to protect sensitive information transmitted
|
||||
online, we also protect your information offline. Only employees who
|
||||
need the information to perform a specific job (for example, billing or
|
||||
customer service) are granted access to personally identifiable information.
|
||||
The computers/servers in which we store personally identifiable information
|
||||
are kept in a secure environment.
|
||||
</p>
|
||||
|
||||
<h4>Credit card information</h4>
|
||||
<p>
|
||||
We do not store your credit card information directly. It is passed
|
||||
through to a third party credit card processing merchant using 128-bit
|
||||
SSL encryption. We only keep your credit card's brand, last 4 digits and
|
||||
expiration date to assist and remind you of any billing related issues.
|
||||
</p>
|
||||
|
||||
<hr/>
|
||||
|
||||
<h3>Cookies</h3>
|
||||
<p>
|
||||
We use "cookies" on this site. A cookie is a piece of data stored on a
|
||||
site visitor's hard drive to help us improve your access to our site
|
||||
and identify repeat visitors to our site. For instance, when we use a
|
||||
cookie to identify you, you would not have to log in with a password more
|
||||
than once, thereby saving time while on our site. Cookies can also enable us
|
||||
to track and target the interests of our users to enhance the experience
|
||||
on our site. Usage of a cookie is in no way linked to any personally
|
||||
identifiable information on our site.
|
||||
</p>
|
||||
<p>
|
||||
If you do not want to be tracked by Google Analytics you may opt-out using
|
||||
<a target="_blank" href="https://tools.google.com/dlpage/gaoptout/">
|
||||
Google's official opt-out browser add-on
|
||||
</a>.
|
||||
</p>
|
||||
|
||||
<hr/>
|
||||
|
||||
<h3>Links</h3>
|
||||
<p>
|
||||
This web site contains links to other sites. Please be aware that we are
|
||||
not responsible for the content or privacy practices of such other sites.
|
||||
|
||||
We encourage our users to be aware when they leave our site and to read
|
||||
the privacy statements of any other site that collects personally
|
||||
identifiable information.
|
||||
</p>
|
||||
|
||||
<hr/>
|
||||
|
||||
<h3>Updates</h3>
|
||||
<p>
|
||||
Our privacy policy may change from time to time and all updates will be
|
||||
posted on this page.
|
||||
</p>
|
||||
|
||||
<p>
|
||||
If you feel that we are not abiding by this privacy policy, you should
|
||||
contact us immediately via telephone at XXX-XXX-XXXX or via email.
|
||||
</p>
|
||||
|
||||
<small class="text-muted">Last updated April 1st 2042</small>
|
||||
{% endblock %}
|
||||
+233
@@ -0,0 +1,233 @@
|
||||
{% extends 'layouts/app.html' %}
|
||||
|
||||
{% block title %}Terms of Service{% endblock %}
|
||||
{% block heading %}<h2>Terms of Service</h2>{% endblock %}
|
||||
|
||||
{% block body %}
|
||||
<p>
|
||||
By using the Snake Eyes ("service"), you are agreeing to be bound by the
|
||||
following terms and conditions ("terms of service").
|
||||
</p>
|
||||
<p>
|
||||
Snake Eyes, LLC ("company") reserves the right to update and change these
|
||||
terms of service without notice.
|
||||
</p>
|
||||
<p>
|
||||
Violation of any of the terms below may result in the termination of your
|
||||
account.
|
||||
</p>
|
||||
|
||||
<hr/>
|
||||
|
||||
<h3>Account terms</h3>
|
||||
<p>
|
||||
You are responsible for maintaining the security of your account and
|
||||
password. The company cannot and will not be liable for any loss or
|
||||
damage from your failure to comply with this security obligation.
|
||||
</p>
|
||||
<p>
|
||||
You are responsible for all content posted and activity that occurs under
|
||||
your account.
|
||||
</p>
|
||||
<p>
|
||||
You may not use the service for any illegal purpose or to violate any laws
|
||||
in your jurisdiction (including but not limited to copyright laws).
|
||||
</p>
|
||||
<p>
|
||||
You must provide your legal full name, a valid email address, and any other
|
||||
information requested in order to complete the optional paid membership
|
||||
signup process.
|
||||
</p>
|
||||
<p>
|
||||
Your login may only be used by one person — a single login shared
|
||||
by multiple people is not permitted. You may create separate logins for
|
||||
as many people as you'd like.
|
||||
</p>
|
||||
<p>
|
||||
You must be a human. Accounts registered by "bots" or other automated
|
||||
methods are not permitted.
|
||||
</p>
|
||||
|
||||
<hr/>
|
||||
|
||||
<h3>Payment, refunds, upgrading and downgrading terms</h3>
|
||||
<p>
|
||||
The service is offered for free with an option to sign up for more
|
||||
features with a free trial. Once that trial is up, you will only be able
|
||||
to continue using that plan's features by paying in advance for additional
|
||||
usage. If you fail to pay for additional usage, your account will be
|
||||
downgraded until payment is made.
|
||||
</p>
|
||||
<p>
|
||||
For any upgrade or downgrade in plan level, will result in the new rate
|
||||
being charged at the next billing cycle. There will be no prorating for
|
||||
downgrades in between billing cycles.
|
||||
</p>
|
||||
<p>
|
||||
Downgrading your service may cause the loss of features or capacity of
|
||||
your account. The company does not accept any liability for such loss.
|
||||
</p>
|
||||
<p>
|
||||
All fees are exclusive of all taxes, levies, or duties imposed by taxing
|
||||
authorities, and you shall be responsible for payment of all such taxes,
|
||||
levies, or duties, excluding only United States (federal or state) taxes.
|
||||
</p>
|
||||
<p>Refunds are processed according to our fair refund policy.</p>
|
||||
|
||||
<hr/>
|
||||
|
||||
<h3>Cancellation and termination</h3>
|
||||
<p>
|
||||
You are solely responsible for properly canceling your account. An email
|
||||
or phone request to cancel your account is not considered cancellation.
|
||||
You can cancel your account at any time by clicking on the Settings
|
||||
link
|
||||
in the global navigation bar at the top of the screen. The Settings
|
||||
screen provides a simple no-questions-asked cancellation link.
|
||||
</p>
|
||||
<p>
|
||||
All of your content will be immediately be inaccessible from the service
|
||||
upon cancellation. Within 30 days, all this content will be permanently
|
||||
deleted from all backups and logs. This information can not be
|
||||
recovered
|
||||
once it has been permanently deleted.
|
||||
</p>
|
||||
<p>
|
||||
If you cancel the service before the end of your current paid up month,
|
||||
your cancellation will take effect immediately, and you will not be charged
|
||||
again. But there will not be any prorating of unused time in the last
|
||||
billing cycle.
|
||||
</p>
|
||||
<p>
|
||||
The company, in its sole discretion, has the right to suspend or
|
||||
terminate your account and refuse any and all current or future use of
|
||||
the service for any reason at any time. Such termination of the service
|
||||
will result in the deactivation or deletion of your account or your access
|
||||
to your account, and the forfeiture and relinquishment of all content in
|
||||
your account. The company reserves the right to refuse service to anyone
|
||||
for any reason at any time.
|
||||
</p>
|
||||
|
||||
<hr/>
|
||||
|
||||
<h3>Modifications to the service and prices</h3>
|
||||
<p>
|
||||
The company reserves the right at any time and from time to time to modify
|
||||
or discontinue, temporarily or permanently, any part of the service with
|
||||
or without notice.
|
||||
</p>
|
||||
<p>
|
||||
Prices of all services are subject to change upon 30 days notice from
|
||||
us. Such notice may be provided at any time by posting the changes to
|
||||
the company's site or the service itself.
|
||||
</p>
|
||||
<p>
|
||||
The company shall not be liable to you or to any third party for any
|
||||
modification, price change, suspension or discontinuance of the service.
|
||||
</p>
|
||||
|
||||
<hr/>
|
||||
|
||||
<h3>Copyright and content ownership</h3>
|
||||
<p>
|
||||
All content posted on the service must comply with U.S. copyright law.
|
||||
</p>
|
||||
<p>
|
||||
We claim no intellectual property rights over the material you provide
|
||||
to the service. All materials uploaded remain yours.
|
||||
</p>
|
||||
<p>
|
||||
The company does not pre-screen content, but reserves the right (but not
|
||||
the obligation) in their sole discretion to refuse or remove any content
|
||||
that is available via the service.
|
||||
</p>
|
||||
<p>
|
||||
The look and feel of the service is copyright© Snake Eyes, LLC.
|
||||
All rights reserved. You may not duplicate, copy, or reuse any portion
|
||||
of the HTML, CSS, JavaScript, or visual design elements without express
|
||||
written permission from the company.
|
||||
</p>
|
||||
|
||||
<hr/>
|
||||
|
||||
<h3>General conditions</h3>
|
||||
<p>
|
||||
Your use of the Service is at your sole risk. The service is provided on
|
||||
an "as is" and "as available" basis.
|
||||
</p>
|
||||
<p>Technical support is only provided via email.</p>
|
||||
<p>
|
||||
You understand that the company uses third party vendors and hosting
|
||||
partners to provide the necessary hardware, software, networking,
|
||||
storage, and related technology required to run the service.
|
||||
</p>
|
||||
<p>You must not modify, adapt or hack the service.</p>
|
||||
<p>
|
||||
You must not modify another website so as to falsely imply that it is
|
||||
associated with the service or the company.
|
||||
</p>
|
||||
<p>
|
||||
You agree not to reproduce, duplicate, copy, sell, resell or exploit any
|
||||
portion of the service, use of the service, or access to the service
|
||||
without the express written permission by the company.
|
||||
</p>
|
||||
<p>
|
||||
We may, but have no obligation to, remove content and accounts that we
|
||||
determine in our sole discretion are unlawful or violates any party's
|
||||
intellectual property or these terms of service.
|
||||
</p>
|
||||
<p>
|
||||
Verbal, physical, written or other abuse (including threats of abuse or
|
||||
retribution) of any service customer, company employee or officer will
|
||||
result in immediate account termination.
|
||||
</p>
|
||||
<p>
|
||||
You understand that the technical processing and transmission of the
|
||||
service, including your content, may be transferred unencrypted and
|
||||
involve (a) transmissions over various networks; and (b) changes to conform
|
||||
and adapt to technical requirements of connecting networks or devices.
|
||||
</p>
|
||||
<p>
|
||||
The company does not warrant that (i) the service will meet your
|
||||
specific requirements, (ii) the service will be uninterrupted, timely,
|
||||
secure, or error-free, (iii) the results that may be obtained from the
|
||||
use of the service will be accurate or reliable, (iv) the quality of any
|
||||
products, services, information, or other material purchased or obtained
|
||||
by you through the service will meet your expectations, and (v) any
|
||||
errors in the service will be corrected.
|
||||
</p>
|
||||
<p>
|
||||
You expressly understand and agree that the company shall not be liable
|
||||
for any direct, indirect, incidental, special, consequential or exemplary
|
||||
damages, including but not limited to, damages for loss of profits,
|
||||
goodwill, use, data or other intangible losses (even if the company has
|
||||
been advised of the possibility of such damages), resulting from: (i) the
|
||||
use or the inability to use the service; (ii) the cost of procurement
|
||||
of substitute goods and services resulting from any goods, data, information
|
||||
or services purchased or obtained or messages received or transactions
|
||||
entered into through or from the service; (iii) unauthorized access to
|
||||
or alteration of your transmissions or data; (iv) statements or conduct of
|
||||
any third party on the service; (v) or any other matter relating to the
|
||||
service.
|
||||
</p>
|
||||
<p>
|
||||
The failure of the company to exercise or enforce any right or provision
|
||||
of the terms of service shall not constitute a waiver of such right or
|
||||
provision. The terms of service constitutes the entire agreement
|
||||
between you and the company and govern your use of the service, super-ceding
|
||||
any prior agreements between you and the company (including, but not
|
||||
limited to, any prior versions of the terms of service).
|
||||
</p>
|
||||
<p>
|
||||
Questions about the terms of service should be sent to
|
||||
<a href="mailto:support@local.host">support@local.host</a>.
|
||||
</p>
|
||||
<p>
|
||||
Any new features that augment or enhance the current service, including
|
||||
the release of new tools and resources, shall be subject to the terms
|
||||
of service. Continued use of the service after any such changes shall
|
||||
constitute your consent to such changes.
|
||||
</p>
|
||||
|
||||
<small class="text-muted">Last updated April 1st 2042</small>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,18 @@
|
||||
from flask import Blueprint, render_template
|
||||
|
||||
page = Blueprint('page', __name__, template_folder='templates')
|
||||
|
||||
|
||||
@page.route('/')
|
||||
def home():
|
||||
return render_template('page/home.html')
|
||||
|
||||
|
||||
@page.route('/terms')
|
||||
def terms():
|
||||
return render_template('page/terms.html')
|
||||
|
||||
|
||||
@page.route('/privacy')
|
||||
def privacy():
|
||||
return render_template('page/privacy.html')
|
||||
@@ -0,0 +1 @@
|
||||
from snakeeyes.blueprints.user.views import user
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user