initial creation

This commit is contained in:
2025-05-20 11:57:43 -04:00
commit c98b612926
8447 changed files with 1862526 additions and 0 deletions
@@ -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,4 @@
[console_scripts]
snakeeyes=cli.cli: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
@@ -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>
@@ -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>
@@ -0,0 +1,7 @@
{% extends 'layouts/app.html' %}
{% block title %}Admin - Coupons / Update{% endblock %}
{% block body %}
{% include 'admin/coupon/_form.html' with context %}
{% endblock %}
@@ -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 %}
@@ -0,0 +1,7 @@
{% extends 'layouts/app.html' %}
{% block title %}Admin - Coupons / Add{% endblock %}
{% block body %}
{% include 'admin/coupon/_form.html' with context %}
{% endblock %}
@@ -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">&mdash;</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 %}
@@ -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 %}
@@ -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>
@@ -0,0 +1,7 @@
{% extends 'layouts/app.html' %}
{% block title %}Admin - Users / Update{% endblock %}
{% block body %}
{% include 'admin/user/_form.html' with context %}
{% endblock %}
@@ -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 %}
@@ -0,0 +1,7 @@
{% extends 'layouts/app.html' %}
{% block title %}Admin - Users / Add{% endblock %}
{% block body %}
{% include 'admin/user/_form.html' with context %}
{% endblock %}
@@ -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)
@@ -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 %}
@@ -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)])
@@ -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
@@ -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 = '&mdash;'
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
@@ -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)
@@ -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
@@ -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 %}
@@ -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 %}
@@ -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 %}
&mdash;
{% else %}
Never
{% endif %}
{%- endmacro %}
{% macro coupon_redeemed(times_redeemed, max_redemptions) -%}
{% set total = max_redemptions if max_redemptions else '&infin;' | 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">&mdash;</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 %}
@@ -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 %}
@@ -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 %}
@@ -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)
@@ -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
@@ -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 %}
@@ -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
@@ -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 %}
@@ -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&#39;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 %}
@@ -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 &mdash; 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&copy; 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