Merge branch 'master' of https://bitbucket.org/yackob03/quay
|
@ -21,7 +21,7 @@ running:
|
||||||
|
|
||||||
```
|
```
|
||||||
sudo nginx -c `pwd`/nginx.conf
|
sudo nginx -c `pwd`/nginx.conf
|
||||||
STACK=prod gunicorn -D --workers 4 -b unix:/tmp/gunicorn.sock --worker-class eventlet -t 500 application:application
|
STACK=prod gunicorn -D --workers 4 -b unix:/tmp/gunicorn.sock --worker-class eventlet -t 2000 application:application
|
||||||
```
|
```
|
||||||
|
|
||||||
set up the snapshot script:
|
set up the snapshot script:
|
||||||
|
|
|
@ -11,6 +11,7 @@ import endpoints.api
|
||||||
import endpoints.web
|
import endpoints.web
|
||||||
import endpoints.tags
|
import endpoints.tags
|
||||||
import endpoints.registry
|
import endpoints.registry
|
||||||
|
import endpoints.webhooks
|
||||||
|
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
|
@ -34,6 +34,7 @@ class User(BaseModel):
|
||||||
verified = BooleanField(default=False)
|
verified = BooleanField(default=False)
|
||||||
stripe_id = CharField(index=True, null=True)
|
stripe_id = CharField(index=True, null=True)
|
||||||
organization = BooleanField(default=False, index=True)
|
organization = BooleanField(default=False, index=True)
|
||||||
|
invoice_email = BooleanField(default=False)
|
||||||
|
|
||||||
|
|
||||||
class TeamRole(BaseModel):
|
class TeamRole(BaseModel):
|
||||||
|
|
|
@ -296,6 +296,12 @@ def get_user(username):
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def get_user_or_org_by_customer_id(customer_id):
|
||||||
|
try:
|
||||||
|
return User.get(User.stripe_id == customer_id)
|
||||||
|
except User.DoesNotExist:
|
||||||
|
return None
|
||||||
|
|
||||||
def get_matching_teams(team_prefix, organization):
|
def get_matching_teams(team_prefix, organization):
|
||||||
query = Team.select().where(Team.name ** (team_prefix + '%'),
|
query = Team.select().where(Team.name ** (team_prefix + '%'),
|
||||||
Team.organization == organization)
|
Team.organization == organization)
|
||||||
|
@ -491,6 +497,11 @@ def change_password(user, new_password):
|
||||||
user.save()
|
user.save()
|
||||||
|
|
||||||
|
|
||||||
|
def change_invoice_email(user, invoice_email):
|
||||||
|
user.invoice_email = invoice_email
|
||||||
|
user.save()
|
||||||
|
|
||||||
|
|
||||||
def update_email(user, new_email):
|
def update_email(user, new_email):
|
||||||
user.email = new_email
|
user.email = new_email
|
||||||
user.verified = False
|
user.verified = False
|
||||||
|
|
169
endpoints/api.py
|
@ -71,8 +71,7 @@ def plans_list():
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
||||||
@app.route('/api/user/', methods=['GET'])
|
def user_view(user):
|
||||||
def get_logged_in_user():
|
|
||||||
def org_view(o):
|
def org_view(o):
|
||||||
admin_org = AdministerOrganizationPermission(o.username)
|
admin_org = AdministerOrganizationPermission(o.username)
|
||||||
return {
|
return {
|
||||||
|
@ -82,16 +81,9 @@ def get_logged_in_user():
|
||||||
'can_create_repo': admin_org.can() or CreateRepositoryPermission(o.username).can()
|
'can_create_repo': admin_org.can() or CreateRepositoryPermission(o.username).can()
|
||||||
}
|
}
|
||||||
|
|
||||||
if current_user.is_anonymous():
|
|
||||||
return jsonify({'anonymous': True})
|
|
||||||
|
|
||||||
user = current_user.db_user()
|
|
||||||
if not user or user.organization:
|
|
||||||
return jsonify({'anonymous': True})
|
|
||||||
|
|
||||||
organizations = model.get_user_organizations(user.username)
|
organizations = model.get_user_organizations(user.username)
|
||||||
|
|
||||||
return jsonify({
|
return {
|
||||||
'verified': user.verified,
|
'verified': user.verified,
|
||||||
'anonymous': False,
|
'anonymous': False,
|
||||||
'username': user.username,
|
'username': user.username,
|
||||||
|
@ -99,8 +91,21 @@ def get_logged_in_user():
|
||||||
'gravatar': compute_hash(user.email),
|
'gravatar': compute_hash(user.email),
|
||||||
'askForPassword': user.password_hash is None,
|
'askForPassword': user.password_hash is None,
|
||||||
'organizations': [org_view(o) for o in organizations],
|
'organizations': [org_view(o) for o in organizations],
|
||||||
'can_create_repo': True
|
'can_create_repo': True,
|
||||||
})
|
'invoice_email': user.invoice_email
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@app.route('/api/user/', methods=['GET'])
|
||||||
|
def get_logged_in_user():
|
||||||
|
if current_user.is_anonymous():
|
||||||
|
return jsonify({'anonymous': True})
|
||||||
|
|
||||||
|
user = current_user.db_user()
|
||||||
|
if not user or user.organization:
|
||||||
|
return jsonify({'anonymous': True})
|
||||||
|
|
||||||
|
return jsonify(user_view(user))
|
||||||
|
|
||||||
|
|
||||||
@app.route('/api/user/convert', methods=['POST'])
|
@app.route('/api/user/convert', methods=['POST'])
|
||||||
|
@ -150,6 +155,11 @@ def change_user_details():
|
||||||
if 'password' in user_data:
|
if 'password' in user_data:
|
||||||
logger.debug('Changing password for user: %s', user.username)
|
logger.debug('Changing password for user: %s', user.username)
|
||||||
model.change_password(user, user_data['password'])
|
model.change_password(user, user_data['password'])
|
||||||
|
|
||||||
|
if 'invoice_email' in user_data:
|
||||||
|
logger.debug('Changing invoice_email for user: %s', user.username)
|
||||||
|
model.change_invoice_email(user, user_data['invoice_email'])
|
||||||
|
|
||||||
except model.InvalidPasswordException, ex:
|
except model.InvalidPasswordException, ex:
|
||||||
error_resp = jsonify({
|
error_resp = jsonify({
|
||||||
'message': ex.message,
|
'message': ex.message,
|
||||||
|
@ -157,14 +167,7 @@ def change_user_details():
|
||||||
error_resp.status_code = 400
|
error_resp.status_code = 400
|
||||||
return error_resp
|
return error_resp
|
||||||
|
|
||||||
return jsonify({
|
return jsonify(user_view(user))
|
||||||
'verified': user.verified,
|
|
||||||
'anonymous': False,
|
|
||||||
'username': user.username,
|
|
||||||
'email': user.email,
|
|
||||||
'gravatar': compute_hash(user.email),
|
|
||||||
'askForPassword': user.password_hash is None,
|
|
||||||
})
|
|
||||||
|
|
||||||
|
|
||||||
@app.route('/api/user/', methods=['POST'])
|
@app.route('/api/user/', methods=['POST'])
|
||||||
|
@ -340,6 +343,23 @@ def create_organization_api():
|
||||||
return error_resp
|
return error_resp
|
||||||
|
|
||||||
|
|
||||||
|
def org_view(o, teams):
|
||||||
|
admin_org = AdministerOrganizationPermission(o.username)
|
||||||
|
is_admin = admin_org.can()
|
||||||
|
view = {
|
||||||
|
'name': o.username,
|
||||||
|
'email': o.email if is_admin else '',
|
||||||
|
'gravatar': compute_hash(o.email),
|
||||||
|
'teams': {t.name : team_view(o.username, t) for t in teams},
|
||||||
|
'is_admin': is_admin
|
||||||
|
}
|
||||||
|
|
||||||
|
if is_admin:
|
||||||
|
view['invoice_email'] = o.invoice_email
|
||||||
|
|
||||||
|
return view
|
||||||
|
|
||||||
|
|
||||||
@app.route('/api/organization/<orgname>', methods=['GET'])
|
@app.route('/api/organization/<orgname>', methods=['GET'])
|
||||||
@api_login_required
|
@api_login_required
|
||||||
def get_organization(orgname):
|
def get_organization(orgname):
|
||||||
|
@ -347,17 +367,6 @@ def get_organization(orgname):
|
||||||
if permission.can():
|
if permission.can():
|
||||||
user = current_user.db_user()
|
user = current_user.db_user()
|
||||||
|
|
||||||
def org_view(o, teams):
|
|
||||||
admin_org = AdministerOrganizationPermission(orgname)
|
|
||||||
is_admin = admin_org.can()
|
|
||||||
return {
|
|
||||||
'name': o.username,
|
|
||||||
'email': o.email if is_admin else '',
|
|
||||||
'gravatar': compute_hash(o.email),
|
|
||||||
'teams': {t.name : team_view(orgname, t) for t in teams},
|
|
||||||
'is_admin': is_admin
|
|
||||||
}
|
|
||||||
|
|
||||||
try:
|
try:
|
||||||
org = model.get_organization(orgname)
|
org = model.get_organization(orgname)
|
||||||
except model.InvalidOrganizationException:
|
except model.InvalidOrganizationException:
|
||||||
|
@ -368,6 +377,28 @@ def get_organization(orgname):
|
||||||
|
|
||||||
abort(403)
|
abort(403)
|
||||||
|
|
||||||
|
|
||||||
|
@app.route('/api/organization/<orgname>', methods=['PUT'])
|
||||||
|
@api_login_required
|
||||||
|
def change_organization_details(orgname):
|
||||||
|
permission = AdministerOrganizationPermission(orgname)
|
||||||
|
if permission.can():
|
||||||
|
try:
|
||||||
|
org = model.get_organization(orgname)
|
||||||
|
except model.InvalidOrganizationException:
|
||||||
|
abort(404)
|
||||||
|
|
||||||
|
org_data = request.get_json();
|
||||||
|
if 'invoice_email' in org_data:
|
||||||
|
logger.debug('Changing invoice_email for organization: %s', org.username)
|
||||||
|
model.change_invoice_email(org, org_data['invoice_email'])
|
||||||
|
|
||||||
|
teams = model.get_teams_within_org(org)
|
||||||
|
return jsonify(org_view(org, teams))
|
||||||
|
|
||||||
|
abort(403)
|
||||||
|
|
||||||
|
|
||||||
@app.route('/api/organization/<orgname>/members', methods=['GET'])
|
@app.route('/api/organization/<orgname>/members', methods=['GET'])
|
||||||
@api_login_required
|
@api_login_required
|
||||||
def get_organization_members(orgname):
|
def get_organization_members(orgname):
|
||||||
|
@ -1187,6 +1218,80 @@ def subscription_view(stripe_subscription, used_repos):
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@app.route('/api/user/card', methods=['GET'])
|
||||||
|
@api_login_required
|
||||||
|
def get_user_card_api():
|
||||||
|
user = current_user.db_user()
|
||||||
|
return jsonify(get_card(user))
|
||||||
|
|
||||||
|
|
||||||
|
@app.route('/api/organization/<orgname>/card', methods=['GET'])
|
||||||
|
@api_login_required
|
||||||
|
def get_org_card_api(orgname):
|
||||||
|
permission = AdministerOrganizationPermission(orgname)
|
||||||
|
if permission.can():
|
||||||
|
organization = model.get_organization(orgname)
|
||||||
|
return jsonify(get_card(organization))
|
||||||
|
|
||||||
|
abort(403)
|
||||||
|
|
||||||
|
|
||||||
|
@app.route('/api/user/card', methods=['POST'])
|
||||||
|
@api_login_required
|
||||||
|
def set_user_card_api():
|
||||||
|
user = current_user.db_user()
|
||||||
|
token = request.get_json()['token']
|
||||||
|
return jsonify(set_card(user, token))
|
||||||
|
|
||||||
|
|
||||||
|
@app.route('/api/organization/<orgname>/card', methods=['POST'])
|
||||||
|
@api_login_required
|
||||||
|
def set_org_card_api(orgname):
|
||||||
|
permission = AdministerOrganizationPermission(orgname)
|
||||||
|
if permission.can():
|
||||||
|
organization = model.get_organization(orgname)
|
||||||
|
token = request.get_json()['token']
|
||||||
|
return jsonify(set_card(organization, token))
|
||||||
|
|
||||||
|
abort(403)
|
||||||
|
|
||||||
|
|
||||||
|
def set_card(user, token):
|
||||||
|
print token
|
||||||
|
|
||||||
|
if user.stripe_id:
|
||||||
|
cus = stripe.Customer.retrieve(user.stripe_id)
|
||||||
|
if cus:
|
||||||
|
cus.card = token
|
||||||
|
cus.save()
|
||||||
|
|
||||||
|
return get_card(user)
|
||||||
|
|
||||||
|
|
||||||
|
def get_card(user):
|
||||||
|
card_info = {
|
||||||
|
'is_valid': False
|
||||||
|
}
|
||||||
|
|
||||||
|
if user.stripe_id:
|
||||||
|
cus = stripe.Customer.retrieve(user.stripe_id)
|
||||||
|
if cus and cus.default_card:
|
||||||
|
# Find the default card.
|
||||||
|
default_card = None
|
||||||
|
for card in cus.cards.data:
|
||||||
|
if card.id == cus.default_card:
|
||||||
|
default_card = card
|
||||||
|
break
|
||||||
|
|
||||||
|
if default_card:
|
||||||
|
card_info = {
|
||||||
|
'owner': card.name,
|
||||||
|
'type': card.type,
|
||||||
|
'last4': card.last4
|
||||||
|
}
|
||||||
|
|
||||||
|
return {'card': card_info}
|
||||||
|
|
||||||
@app.route('/api/user/plan', methods=['PUT'])
|
@app.route('/api/user/plan', methods=['PUT'])
|
||||||
@api_login_required
|
@api_login_required
|
||||||
def subscribe_api():
|
def subscribe_api():
|
||||||
|
@ -1218,7 +1323,7 @@ def subscribe(user, plan, token, accepted_plans):
|
||||||
if not user.stripe_id:
|
if not user.stripe_id:
|
||||||
# Check if a non-paying user is trying to subscribe to a free plan
|
# Check if a non-paying user is trying to subscribe to a free plan
|
||||||
if not plan_found['price'] == 0:
|
if not plan_found['price'] == 0:
|
||||||
# They want a real paying plan, create the customerand plan
|
# They want a real paying plan, create the customer and plan
|
||||||
# simultaneously
|
# simultaneously
|
||||||
card = token
|
card = token
|
||||||
cus = stripe.Customer.create(email=user.email, plan=plan, card=card)
|
cus = stripe.Customer.create(email=user.email, plan=plan, card=card)
|
||||||
|
|
|
@ -157,10 +157,15 @@ def create_repository(namespace, repository):
|
||||||
|
|
||||||
response = make_response('Created', 201)
|
response = make_response('Created', 201)
|
||||||
|
|
||||||
|
extra_params = {
|
||||||
|
'repository': '%s/%s' % (namespace, repository),
|
||||||
|
}
|
||||||
|
|
||||||
if get_authenticated_user():
|
if get_authenticated_user():
|
||||||
mixpanel.track(get_authenticated_user().username, 'push_repo')
|
mixpanel.track(get_authenticated_user().username, 'push_repo',
|
||||||
|
extra_params)
|
||||||
else:
|
else:
|
||||||
mixpanel.track(get_validated_token().code, 'push_repo')
|
mixpanel.track(get_validated_token().code, 'push_repo', extra_params)
|
||||||
|
|
||||||
return response
|
return response
|
||||||
|
|
||||||
|
@ -220,7 +225,10 @@ def get_repository_images(namespace, repository):
|
||||||
if get_authenticated_user():
|
if get_authenticated_user():
|
||||||
pull_username = get_authenticated_user().username
|
pull_username = get_authenticated_user().username
|
||||||
|
|
||||||
mixpanel.track(pull_username, 'pull_repo')
|
extra_params = {
|
||||||
|
'repository': '%s/%s' % (namespace, repository),
|
||||||
|
}
|
||||||
|
mixpanel.track(pull_username, 'pull_repo', extra_params)
|
||||||
|
|
||||||
return resp
|
return resp
|
||||||
|
|
||||||
|
|
42
endpoints/webhooks.py
Normal file
|
@ -0,0 +1,42 @@
|
||||||
|
import logging
|
||||||
|
import requests
|
||||||
|
import stripe
|
||||||
|
|
||||||
|
from flask import (abort, redirect, request, url_for, render_template,
|
||||||
|
make_response)
|
||||||
|
from flask.ext.login import login_user, UserMixin, login_required
|
||||||
|
from flask.ext.principal import identity_changed, Identity, AnonymousIdentity
|
||||||
|
|
||||||
|
from data import model
|
||||||
|
from app import app, login_manager, mixpanel
|
||||||
|
from auth.permissions import QuayDeferredPermissionUser
|
||||||
|
from data.plans import USER_PLANS, BUSINESS_PLANS, get_plan
|
||||||
|
from util.invoice import renderInvoiceToHtml
|
||||||
|
from util.email import send_invoice_email
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
@app.route('/webhooks/stripe', methods=['POST'])
|
||||||
|
def stripe_webhook():
|
||||||
|
request_data = request.get_json()
|
||||||
|
logger.debug('Stripe webhook call: %s' % request_data)
|
||||||
|
|
||||||
|
event_type = request_data['type'] if 'type' in request_data else None
|
||||||
|
if event_type == 'charge.succeeded':
|
||||||
|
data = request_data['data'] if 'data' in request_data else {}
|
||||||
|
obj = data['object'] if 'object' in data else {}
|
||||||
|
invoice_id = obj['invoice'] if 'invoice' in obj else None
|
||||||
|
customer_id = obj['customer'] if 'customer' in obj else None
|
||||||
|
|
||||||
|
if invoice_id and customer_id:
|
||||||
|
# Find the user associated with the customer ID.
|
||||||
|
user = model.get_user_or_org_by_customer_id(customer_id)
|
||||||
|
if user and user.invoice_email:
|
||||||
|
# Lookup the invoice.
|
||||||
|
invoice = stripe.Invoice.retrieve(invoice_id)
|
||||||
|
if invoice:
|
||||||
|
invoice_html = renderInvoiceToHtml(invoice, user)
|
||||||
|
send_invoice_email(user.email, invoice_html)
|
||||||
|
|
||||||
|
return make_response('Okay')
|
|
@ -33,7 +33,7 @@ http {
|
||||||
|
|
||||||
server {
|
server {
|
||||||
listen 443 default;
|
listen 443 default;
|
||||||
client_max_body_size 4G;
|
client_max_body_size 8G;
|
||||||
server_name _;
|
server_name _;
|
||||||
|
|
||||||
keepalive_timeout 5;
|
keepalive_timeout 5;
|
||||||
|
@ -63,6 +63,7 @@ http {
|
||||||
proxy_buffering off;
|
proxy_buffering off;
|
||||||
|
|
||||||
proxy_pass http://app_server;
|
proxy_pass http://app_server;
|
||||||
|
proxy_read_timeout 2000;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -6,6 +6,50 @@
|
||||||
visibility: hidden;
|
visibility: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.billing-options-element .current-card {
|
||||||
|
font-size: 16px;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
.billing-options-element .current-card .no-card-outline {
|
||||||
|
display: inline-block;
|
||||||
|
width: 73px;
|
||||||
|
height: 44px;
|
||||||
|
vertical-align: middle;
|
||||||
|
margin-right: 10px;
|
||||||
|
border: 1px dashed #aaa;
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.billing-options-element .current-card .last4 {
|
||||||
|
color: #aaa;
|
||||||
|
}
|
||||||
|
|
||||||
|
.billing-options-element .current-card .last4 b {
|
||||||
|
color: black;
|
||||||
|
}
|
||||||
|
|
||||||
|
.billing-options-element .current-card img {
|
||||||
|
margin-right: 10px;
|
||||||
|
vertical-align: middle;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-option {
|
||||||
|
padding: 4px;
|
||||||
|
font-size: 18px;
|
||||||
|
margin-bottom: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-option label {
|
||||||
|
margin-left: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-option .settings-description {
|
||||||
|
font-size: 12px;
|
||||||
|
color: #aaa;
|
||||||
|
}
|
||||||
|
|
||||||
.organization-header-element {
|
.organization-header-element {
|
||||||
padding: 20px;
|
padding: 20px;
|
||||||
margin-bottom: 20px;
|
margin-bottom: 20px;
|
||||||
|
|
39
static/directives/billing-options.html
Normal file
|
@ -0,0 +1,39 @@
|
||||||
|
<div class="billing-options-element">
|
||||||
|
<!-- Credit Card -->
|
||||||
|
<div class="panel">
|
||||||
|
<div class="panel-title">
|
||||||
|
Credit Card
|
||||||
|
</div>
|
||||||
|
<div class="panel-body">
|
||||||
|
<i class="fa fa-spinner fa-spin fa-2x" ng-show="!currentCard || changingCard"></i>
|
||||||
|
<div class="current-card" ng-show="currentCard && !changingCard">
|
||||||
|
<img src="{{ '/static/img/creditcards/' + getCreditImage(currentCard) }}" ng-show="currentCard.last4">
|
||||||
|
<span class="no-card-outline" ng-show="!currentCard.last4"></span>
|
||||||
|
|
||||||
|
<span class="last4" ng-show="currentCard.last4">****-****-****-<b>{{ currentCard.last4 }}</b></span>
|
||||||
|
<span class="not-found" ng-show="!currentCard.last4">No credit card found</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button class="btn btn-primary" ng-show="currentCard && !changingCard" ng-click="changeCard()">
|
||||||
|
Change Credit Card
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Options -->
|
||||||
|
<div class="panel">
|
||||||
|
<div class="panel-title">
|
||||||
|
Billing Options
|
||||||
|
<i class="fa fa-spinner fa-spin" ng-show="working"></i>
|
||||||
|
</div>
|
||||||
|
<div class="panel-body">
|
||||||
|
<div class="settings-option">
|
||||||
|
<input id="invoiceEmail" type="checkbox" ng-model="invoice_email">
|
||||||
|
<label for="invoiceEmail">Send Receipt Emails</label>
|
||||||
|
<div class="settings-description">
|
||||||
|
If checked, a receipt email will be sent to {{ obj.email }} on every successful charge
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
BIN
static/img/creditcards/amex.png
Normal file
After Width: | Height: | Size: 6.3 KiB |
BIN
static/img/creditcards/credit.png
Normal file
After Width: | Height: | Size: 1.7 KiB |
BIN
static/img/creditcards/dankort.png
Normal file
After Width: | Height: | Size: 2.6 KiB |
BIN
static/img/creditcards/diners.png
Normal file
After Width: | Height: | Size: 2.7 KiB |
BIN
static/img/creditcards/discover.png
Normal file
After Width: | Height: | Size: 3.4 KiB |
BIN
static/img/creditcards/forbru.png
Normal file
After Width: | Height: | Size: 2.3 KiB |
BIN
static/img/creditcards/google.png
Normal file
After Width: | Height: | Size: 3.2 KiB |
BIN
static/img/creditcards/jcb.png
Normal file
After Width: | Height: | Size: 2.7 KiB |
BIN
static/img/creditcards/laser.png
Normal file
After Width: | Height: | Size: 2.3 KiB |
BIN
static/img/creditcards/maestro.png
Normal file
After Width: | Height: | Size: 4.3 KiB |
BIN
static/img/creditcards/mastercard.png
Normal file
After Width: | Height: | Size: 4.1 KiB |
BIN
static/img/creditcards/money.png
Normal file
After Width: | Height: | Size: 5.2 KiB |
BIN
static/img/creditcards/paypa.png
Normal file
After Width: | Height: | Size: 2.9 KiB |
BIN
static/img/creditcards/shopify.png
Normal file
After Width: | Height: | Size: 3.1 KiB |
BIN
static/img/creditcards/solo.png
Normal file
After Width: | Height: | Size: 3.9 KiB |
BIN
static/img/creditcards/visa.png
Normal file
After Width: | Height: | Size: 3.1 KiB |
236
static/js/app.js
|
@ -122,8 +122,22 @@ quayApp = angular.module('quay', ['restangular', 'angularMoment', 'angulartics',
|
||||||
$provide.factory('PlanService', ['Restangular', 'KeyService', 'UserService', function(Restangular, KeyService, UserService) {
|
$provide.factory('PlanService', ['Restangular', 'KeyService', 'UserService', function(Restangular, KeyService, UserService) {
|
||||||
var plans = null;
|
var plans = null;
|
||||||
var planDict = {};
|
var planDict = {};
|
||||||
var planService = {}
|
var planService = {};
|
||||||
|
var listeners = [];
|
||||||
|
|
||||||
|
planService.registerListener = function(obj, callback) {
|
||||||
|
listeners.push({'obj': obj, 'callback': callback});
|
||||||
|
};
|
||||||
|
|
||||||
|
planService.unregisterListener = function(obj) {
|
||||||
|
for (var i = 0; i < listeners.length; ++i) {
|
||||||
|
if (listeners[i].obj == obj) {
|
||||||
|
listeners.splice(i, 1);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
planService.verifyLoaded = function(callback) {
|
planService.verifyLoaded = function(callback) {
|
||||||
if (plans) {
|
if (plans) {
|
||||||
callback(plans);
|
callback(plans);
|
||||||
|
@ -192,8 +206,8 @@ quayApp = angular.module('quay', ['restangular', 'angularMoment', 'angulartics',
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
planService.getSubscription = function(organization, success, failure) {
|
planService.getSubscription = function(orgname, success, failure) {
|
||||||
var url = planService.getSubscriptionUrl(organization);
|
var url = planService.getSubscriptionUrl(orgname);
|
||||||
var getSubscription = Restangular.one(url);
|
var getSubscription = Restangular.one(url);
|
||||||
getSubscription.get().then(success, failure);
|
getSubscription.get().then(success, failure);
|
||||||
};
|
};
|
||||||
|
@ -213,19 +227,93 @@ quayApp = angular.module('quay', ['restangular', 'angularMoment', 'angulartics',
|
||||||
|
|
||||||
var url = planService.getSubscriptionUrl(orgname);
|
var url = planService.getSubscriptionUrl(orgname);
|
||||||
var createSubscriptionRequest = Restangular.one(url);
|
var createSubscriptionRequest = Restangular.one(url);
|
||||||
createSubscriptionRequest.customPUT(subscriptionDetails).then(success, failure);
|
createSubscriptionRequest.customPUT(subscriptionDetails).then(function(resp) {
|
||||||
|
success(resp);
|
||||||
|
planService.getPlan(planId, function(plan) {
|
||||||
|
for (var i = 0; i < listeners.length; ++i) {
|
||||||
|
listeners[i]['callback'](plan);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}, failure);
|
||||||
};
|
};
|
||||||
|
|
||||||
planService.changePlan = function($scope, orgname, planId, hasExistingSubscription, callbacks) {
|
planService.getCardInfo = function(orgname, callback) {
|
||||||
if (!hasExistingSubscription) {
|
var url = orgname ? getRestUrl('organization', orgname, 'card') : 'user/card';
|
||||||
planService.showSubscribeDialog($scope, orgname, planId, callbacks);
|
var getCard = Restangular.one(url);
|
||||||
return;
|
getCard.customGET().then(function(resp) {
|
||||||
}
|
callback(resp.card);
|
||||||
|
}, function() {
|
||||||
|
callback({'is_valid': false});
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
planService.changePlan = function($scope, orgname, planId, callbacks) {
|
||||||
if (callbacks['started']) {
|
if (callbacks['started']) {
|
||||||
callbacks['started']();
|
callbacks['started']();
|
||||||
}
|
}
|
||||||
planService.setSubscription(orgname, planId, callbacks['success'], callbacks['failure']);
|
|
||||||
|
planService.getPlan(planId, function(plan) {
|
||||||
|
planService.getCardInfo(orgname, function(cardInfo) {
|
||||||
|
if (plan.price > 0 && !cardInfo.last4) {
|
||||||
|
planService.showSubscribeDialog($scope, orgname, planId, callbacks);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
planService.setSubscription(orgname, planId, callbacks['success'], callbacks['failure']);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
planService.changeCreditCard = function($scope, orgname, callbacks) {
|
||||||
|
if (callbacks['opening']) {
|
||||||
|
callbacks['opening']();
|
||||||
|
}
|
||||||
|
|
||||||
|
var submitToken = function(token) {
|
||||||
|
$scope.$apply(function() {
|
||||||
|
if (callbacks['started']) {
|
||||||
|
callbacks['started']();
|
||||||
|
}
|
||||||
|
|
||||||
|
var cardInfo = {
|
||||||
|
'token': token.id
|
||||||
|
};
|
||||||
|
|
||||||
|
var url = orgname ? getRestUrl('organization', orgname, 'card') : 'user/card';
|
||||||
|
var changeCardRequest = Restangular.one(url);
|
||||||
|
changeCardRequest.customPOST(cardInfo).then(callbacks['success'], callbacks['failure']);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
var email = planService.getEmail(orgname);
|
||||||
|
StripeCheckout.open({
|
||||||
|
key: KeyService.stripePublishableKey,
|
||||||
|
address: false,
|
||||||
|
email: email,
|
||||||
|
currency: 'usd',
|
||||||
|
name: 'Update credit card',
|
||||||
|
description: 'Enter your credit card number',
|
||||||
|
panelLabel: 'Update',
|
||||||
|
token: submitToken,
|
||||||
|
image: 'static/img/quay-icon-stripe.png',
|
||||||
|
opened: function() { $scope.$apply(function() { callbacks['opened']() }); },
|
||||||
|
closed: function() { $scope.$apply(function() { callbacks['closed']() }); }
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
planService.getEmail = function(orgname) {
|
||||||
|
var email = null;
|
||||||
|
if (UserService.currentUser()) {
|
||||||
|
email = UserService.currentUser().email;
|
||||||
|
|
||||||
|
if (orgname) {
|
||||||
|
org = UserService.getOrganization(orgname);
|
||||||
|
if (org) {
|
||||||
|
emaiil = org.email;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return email;
|
||||||
};
|
};
|
||||||
|
|
||||||
planService.showSubscribeDialog = function($scope, orgname, planId, callbacks) {
|
planService.showSubscribeDialog = function($scope, orgname, planId, callbacks) {
|
||||||
|
@ -240,23 +328,12 @@ quayApp = angular.module('quay', ['restangular', 'angularMoment', 'angulartics',
|
||||||
if (callbacks['started']) {
|
if (callbacks['started']) {
|
||||||
callbacks['started']();
|
callbacks['started']();
|
||||||
}
|
}
|
||||||
planService.setSubscription(orgname, planId, callbacks['success'], callbacks['failure']);
|
planService.setSubscription(orgname, planId, callbacks['success'], callbacks['failure'], token);
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
planService.getPlan(planId, function(planDetails) {
|
planService.getPlan(planId, function(planDetails) {
|
||||||
var email = null;
|
var email = planService.getEmail(orgname);
|
||||||
if (UserService.currentUser()) {
|
|
||||||
email = UserService.currentUser().email;
|
|
||||||
|
|
||||||
if (orgname) {
|
|
||||||
org = UserService.getOrganization(orgname);
|
|
||||||
if (org) {
|
|
||||||
emaiil = org.email;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
StripeCheckout.open({
|
StripeCheckout.open({
|
||||||
key: KeyService.stripePublishableKey,
|
key: KeyService.stripePublishableKey,
|
||||||
address: false,
|
address: false,
|
||||||
|
@ -621,6 +698,106 @@ quayApp.directive('roleGroup', function () {
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
||||||
|
quayApp.directive('billingOptions', function () {
|
||||||
|
var directiveDefinitionObject = {
|
||||||
|
priority: 0,
|
||||||
|
templateUrl: '/static/directives/billing-options.html',
|
||||||
|
replace: false,
|
||||||
|
transclude: false,
|
||||||
|
restrict: 'C',
|
||||||
|
scope: {
|
||||||
|
'user': '=user',
|
||||||
|
'organization': '=organization'
|
||||||
|
},
|
||||||
|
controller: function($scope, $element, PlanService, Restangular) {
|
||||||
|
$scope.invoice_email = false;
|
||||||
|
$scope.currentCard = null;
|
||||||
|
|
||||||
|
// Listen to plan changes.
|
||||||
|
PlanService.registerListener(this, function(plan) {
|
||||||
|
if (plan && plan.price > 0) {
|
||||||
|
update();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
$scope.$on('$destroy', function() {
|
||||||
|
PlanService.unregisterListener(this);
|
||||||
|
});
|
||||||
|
|
||||||
|
$scope.changeCard = function() {
|
||||||
|
$scope.changingCard = true;
|
||||||
|
var callbacks = {
|
||||||
|
'opened': function() { $scope.changingCard = true; },
|
||||||
|
'closed': function() { $scope.changingCard = false; },
|
||||||
|
'started': function() { $scope.currentCard = null; },
|
||||||
|
'success': function(resp) {
|
||||||
|
$scope.currentCard = resp.card;
|
||||||
|
$scope.changingCard = false;
|
||||||
|
},
|
||||||
|
'failure': function() {
|
||||||
|
$('#couldnotchangecardModal').modal({});
|
||||||
|
$scope.changingCard = false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
PlanService.changeCreditCard($scope, $scope.organization ? $scope.organization.name : null, callbacks);
|
||||||
|
};
|
||||||
|
|
||||||
|
$scope.getCreditImage = function(creditInfo) {
|
||||||
|
if (!creditInfo || !creditInfo.type) { return 'credit.png'; }
|
||||||
|
|
||||||
|
var kind = creditInfo.type.toLowerCase() || 'credit';
|
||||||
|
var supported = {
|
||||||
|
'american express': 'amex',
|
||||||
|
'credit': 'credit',
|
||||||
|
'diners club': 'diners',
|
||||||
|
'discover': 'discover',
|
||||||
|
'jcb': 'jcb',
|
||||||
|
'mastercard': 'mastercard',
|
||||||
|
'visa': 'visa'
|
||||||
|
};
|
||||||
|
|
||||||
|
kind = supported[kind] || 'credit';
|
||||||
|
return kind + '.png';
|
||||||
|
};
|
||||||
|
|
||||||
|
var update = function() {
|
||||||
|
if (!$scope.user && !$scope.organization) { return; }
|
||||||
|
$scope.obj = $scope.user ? $scope.user : $scope.organization;
|
||||||
|
$scope.invoice_email = $scope.obj.invoice_email;
|
||||||
|
|
||||||
|
// Load the credit card information.
|
||||||
|
PlanService.getCardInfo($scope.organization ? $scope.organization.name : null, function(card) {
|
||||||
|
$scope.currentCard = card;
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
var save = function() {
|
||||||
|
$scope.working = true;
|
||||||
|
var url = $scope.organization ? getRestUrl('organization', $scope.organization.name) : 'user/';
|
||||||
|
var conductSave = Restangular.one(url);
|
||||||
|
conductSave.customPUT($scope.obj).then(function(resp) {
|
||||||
|
$scope.working = false;
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
var checkSave = function() {
|
||||||
|
if (!$scope.obj) { return; }
|
||||||
|
if ($scope.obj.invoice_email != $scope.invoice_email) {
|
||||||
|
$scope.obj.invoice_email = $scope.invoice_email;
|
||||||
|
save();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
$scope.$watch('invoice_email', checkSave);
|
||||||
|
$scope.$watch('organization', update);
|
||||||
|
$scope.$watch('user', update);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
return directiveDefinitionObject;
|
||||||
|
});
|
||||||
|
|
||||||
|
|
||||||
quayApp.directive('planManager', function () {
|
quayApp.directive('planManager', function () {
|
||||||
var directiveDefinitionObject = {
|
var directiveDefinitionObject = {
|
||||||
priority: 0,
|
priority: 0,
|
||||||
|
@ -631,7 +808,8 @@ quayApp.directive('planManager', function () {
|
||||||
scope: {
|
scope: {
|
||||||
'user': '=user',
|
'user': '=user',
|
||||||
'organization': '=organization',
|
'organization': '=organization',
|
||||||
'readyForPlan': '&readyForPlan'
|
'readyForPlan': '&readyForPlan',
|
||||||
|
'planChanged': '&planChanged'
|
||||||
},
|
},
|
||||||
controller: function($scope, $element, PlanService, Restangular) {
|
controller: function($scope, $element, PlanService, Restangular) {
|
||||||
var hasSubscription = false;
|
var hasSubscription = false;
|
||||||
|
@ -652,7 +830,7 @@ quayApp.directive('planManager', function () {
|
||||||
'failure': function() { $scope.planChanging = false; }
|
'failure': function() { $scope.planChanging = false; }
|
||||||
};
|
};
|
||||||
|
|
||||||
PlanService.changePlan($scope, $scope.organization, planId, hasSubscription, callbacks);
|
PlanService.changePlan($scope, $scope.organization, planId, callbacks);
|
||||||
};
|
};
|
||||||
|
|
||||||
$scope.cancelSubscription = function() {
|
$scope.cancelSubscription = function() {
|
||||||
|
@ -669,6 +847,10 @@ quayApp.directive('planManager', function () {
|
||||||
PlanService.getPlan(sub.plan, function(subscribedPlan) {
|
PlanService.getPlan(sub.plan, function(subscribedPlan) {
|
||||||
$scope.subscribedPlan = subscribedPlan;
|
$scope.subscribedPlan = subscribedPlan;
|
||||||
$scope.planUsagePercent = sub.usedPrivateRepos * 100 / $scope.subscribedPlan.privateRepos;
|
$scope.planUsagePercent = sub.usedPrivateRepos * 100 / $scope.subscribedPlan.privateRepos;
|
||||||
|
|
||||||
|
if ($scope.planChanged) {
|
||||||
|
$scope.planChanged({ 'plan': subscribedPlan });
|
||||||
|
}
|
||||||
|
|
||||||
if (sub.usedPrivateRepos > $scope.subscribedPlan.privateRepos) {
|
if (sub.usedPrivateRepos > $scope.subscribedPlan.privateRepos) {
|
||||||
$scope.limit = 'over';
|
$scope.limit = 'over';
|
||||||
|
|
|
@ -728,6 +728,10 @@ function UserAdminCtrl($scope, $timeout, $location, Restangular, PlanService, Us
|
||||||
|
|
||||||
$('.form-change-pw').popover();
|
$('.form-change-pw').popover();
|
||||||
|
|
||||||
|
$scope.planChanged = function(plan) {
|
||||||
|
$scope.hasPaidPlan = plan && plan.price > 0;
|
||||||
|
};
|
||||||
|
|
||||||
$scope.showConvertForm = function() {
|
$scope.showConvertForm = function() {
|
||||||
PlanService.getMatchingBusinessPlan(function(plan) {
|
PlanService.getMatchingBusinessPlan(function(plan) {
|
||||||
$scope.org.plan = plan;
|
$scope.org.plan = plan;
|
||||||
|
@ -1206,6 +1210,10 @@ function OrgAdminCtrl($rootScope, $scope, Restangular, $routeParams, UserService
|
||||||
$scope.membersFound = null;
|
$scope.membersFound = null;
|
||||||
$scope.invoiceLoading = true;
|
$scope.invoiceLoading = true;
|
||||||
|
|
||||||
|
$scope.planChanged = function(plan) {
|
||||||
|
$scope.hasPaidPlan = plan && plan.price > 0;
|
||||||
|
};
|
||||||
|
|
||||||
$scope.loadInvoices = function() {
|
$scope.loadInvoices = function() {
|
||||||
if ($scope.invoices) { return; }
|
if ($scope.invoices) { return; }
|
||||||
$scope.invoiceLoading = true;
|
$scope.invoiceLoading = true;
|
||||||
|
|
|
@ -15,7 +15,8 @@
|
||||||
<ul class="nav nav-pills nav-stacked">
|
<ul class="nav nav-pills nav-stacked">
|
||||||
<li class="active"><a href="javascript:void(0)" data-toggle="tab" data-target="#plan">Plan and Usage</a></li>
|
<li class="active"><a href="javascript:void(0)" data-toggle="tab" data-target="#plan">Plan and Usage</a></li>
|
||||||
<li><a href="javascript:void(0)" data-toggle="tab" data-target="#members" ng-click="loadMembers()">Members</a></li>
|
<li><a href="javascript:void(0)" data-toggle="tab" data-target="#members" ng-click="loadMembers()">Members</a></li>
|
||||||
<li><a href="javascript:void(0)" data-toggle="tab" data-target="#billing" ng-click="loadInvoices()">Billing</a></li>
|
<li ng-show="hasPaidPlan"><a href="javascript:void(0)" data-toggle="tab" data-target="#billingoptions">Billing</a></li>
|
||||||
|
<li ng-show="hasPaidPlan"><a href="javascript:void(0)" data-toggle="tab" data-target="#billing" ng-click="loadInvoices()">Billing History</a></li>
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
@ -24,10 +25,15 @@
|
||||||
<div class="tab-content">
|
<div class="tab-content">
|
||||||
<!-- Plans tab -->
|
<!-- Plans tab -->
|
||||||
<div id="plan" class="tab-pane active">
|
<div id="plan" class="tab-pane active">
|
||||||
<div class="plan-manager" organization="orgname"></div>
|
<div class="plan-manager" organization="orgname" plan-changed="planChanged(plan)"></div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Billing tab -->
|
<!-- Billing Options tab -->
|
||||||
|
<div id="billingoptions" class="tab-pane">
|
||||||
|
<div class="billing-options" organization="organization"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Billing History tab -->
|
||||||
<div id="billing" class="tab-pane">
|
<div id="billing" class="tab-pane">
|
||||||
<div ng-show="invoiceLoading">
|
<div ng-show="invoiceLoading">
|
||||||
<i class="fa fa-spinner fa-spin fa-3x"></i>
|
<i class="fa fa-spinner fa-spin fa-3x"></i>
|
||||||
|
@ -52,7 +58,8 @@
|
||||||
<td>
|
<td>
|
||||||
<span class="invoice-status">
|
<span class="invoice-status">
|
||||||
<span class="success" ng-show="invoice.paid">Paid - Thank you!</span>
|
<span class="success" ng-show="invoice.paid">Paid - Thank you!</span>
|
||||||
<span class="danger" ng-show="!invoice.paid && invoice.attempted">Payment failed - Will retry soon</span>
|
<span class="danger" ng-show="!invoice.paid && invoice.attempted && invoice.closed">Payment failed</span>
|
||||||
|
<span class="danger" ng-show="!invoice.paid && invoice.attempted && !invoice.closed">Payment failed - Will retry soon</span>
|
||||||
<span class="pending" ng-show="!invoice.paid && !invoice.attempted">Payment pending</span>
|
<span class="pending" ng-show="!invoice.paid && !invoice.attempted">Payment pending</span>
|
||||||
</span>
|
</span>
|
||||||
</td>
|
</td>
|
||||||
|
@ -69,7 +76,6 @@
|
||||||
<dt>Plan</dt>
|
<dt>Plan</dt>
|
||||||
<dd>
|
<dd>
|
||||||
<span>{{ plan_map[invoice.plan].title }}</span>
|
<span>{{ plan_map[invoice.plan].title }}</span>
|
||||||
<span>{{ plan_map[invoice.plan].price / 100 }}</span>
|
|
||||||
</dd>
|
</dd>
|
||||||
</dl>
|
</dl>
|
||||||
</td>
|
</td>
|
||||||
|
|
|
@ -11,164 +11,189 @@
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="container repo repo-admin" ng-show="!loading && repo && permissions">
|
<div class="container repo repo-admin" ng-show="!loading && repo && permissions">
|
||||||
<div class="header">
|
<div class="header row">
|
||||||
<a href="{{ '/repository/' + repo.namespace + '/' + repo.name }}" class="back"><i class="fa fa-chevron-left"></i></a>
|
<a href="{{ '/repository/' + repo.namespace + '/' + repo.name }}" class="back"><i class="fa fa-chevron-left"></i></a>
|
||||||
<h3>
|
<h3>
|
||||||
<span class="repo-circle no-background" repo="repo"></span> <span style="color: #aaa;"> {{repo.namespace}}</span> <span style="color: #ccc">/</span> {{repo.name}}
|
<span class="repo-circle no-background" repo="repo"></span> <span style="color: #aaa;"> {{repo.namespace}}</span> <span style="color: #ccc">/</span> {{repo.name}}
|
||||||
</h3>
|
</h3>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- User Access Permissions -->
|
<div class="row">
|
||||||
<div class="panel panel-default">
|
<!-- Side tabs -->
|
||||||
<div class="panel-heading">User <span ng-show="repo.is_organization">and Team</span> Access Permissions
|
<div class="col-md-2">
|
||||||
|
<ul class="nav nav-pills nav-stacked">
|
||||||
<i class="info-icon fa fa-info-circle" data-placement="left" data-content="Allow any number of users or teams to read, write or administer this repository"></i>
|
<li class="active"><a href="javascript:void(0)" data-toggle="tab" data-target="#permissions">Permissions</a></li>
|
||||||
|
<li><a href="javascript:void(0)" data-toggle="tab" data-target="#publicprivate">Public/Private</a></li>
|
||||||
|
<li><a href="javascript:void(0)" data-toggle="tab" data-target="#delete">Delete</a></li>
|
||||||
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
<div class="panel-body">
|
|
||||||
|
<!-- Content -->
|
||||||
<table class="permissions">
|
<div class="col-md-10">
|
||||||
<thead>
|
|
||||||
<tr>
|
<div class="tab-content">
|
||||||
<td>User<span ng-show="repo.is_organization">/Team</span></td>
|
<!-- Permissions tab -->
|
||||||
<td>Permissions</td>
|
<div id="permissions" class="tab-pane active">
|
||||||
<td style="width: 95px;"></td>
|
<!-- User Access Permissions -->
|
||||||
</tr>
|
<div class="panel panel-default">
|
||||||
</thead>
|
<div class="panel-heading">User <span ng-show="repo.is_organization">and Team</span> Access Permissions
|
||||||
|
|
||||||
<!-- Team Permissions -->
|
<i class="info-icon fa fa-info-circle" data-placement="left" data-content="Allow any number of users or teams to read, write or administer this repository"></i>
|
||||||
<tr ng-repeat="(name, permission) in permissions['team']">
|
|
||||||
<td class="team entity">
|
|
||||||
<i class="fa fa-group"></i>
|
|
||||||
<span><a href="/organization/{{ repo.namespace }}/teams/{{ name }}">{{name}}</a></span>
|
|
||||||
</td>
|
|
||||||
<td class="user-permissions">
|
|
||||||
<span class="role-group" current-role="permission.role" role-changed="setRole(role, name, 'team')" roles="roles"></span>
|
|
||||||
</td>
|
|
||||||
<td>
|
|
||||||
<span class="delete-ui" tabindex="0">
|
|
||||||
<span class="delete-ui-button" ng-click="deleteRole(name, 'team')"><button class="btn btn-danger">Delete</button></span>
|
|
||||||
<i class="fa fa-times" bs-tooltip="tooltip.title" data-placement="right" title="Delete Permission"></i>
|
|
||||||
</span>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
|
|
||||||
<!-- User Permissions -->
|
|
||||||
<tr ng-repeat="(name, permission) in permissions['user']">
|
|
||||||
<td class="{{ 'user entity ' + (permission.is_org_member? '' : 'outside') }}">
|
|
||||||
<i class="fa fa-user"></i>
|
|
||||||
<span>{{name}}</span>
|
|
||||||
<i class="fa fa-exclamation-triangle" ng-show="permission.is_org_member === false" data-trigger="hover" bs-popover="{'content': 'This user is not a member of the organization'}"></i>
|
|
||||||
</td>
|
|
||||||
<td class="user-permissions">
|
|
||||||
<div class="btn-group btn-group-sm">
|
|
||||||
<span class="role-group" current-role="permission.role" role-changed="setRole(role, name, 'user')" roles="roles"></span>
|
|
||||||
</div>
|
</div>
|
||||||
</td>
|
<div class="panel-body">
|
||||||
<td>
|
|
||||||
<span class="delete-ui" tabindex="0" title="Delete Permission">
|
<table class="permissions">
|
||||||
<span class="delete-ui-button" ng-click="deleteRole(name, 'user')"><button class="btn btn-danger">Delete</button></span>
|
<thead>
|
||||||
<i class="fa fa-times" bs-tooltip="tooltip.title" data-placement="right" title="Delete Permission"></i>
|
<tr>
|
||||||
</span>
|
<td>User<span ng-show="repo.is_organization">/Team</span></td>
|
||||||
</td>
|
<td>Permissions</td>
|
||||||
</tr>
|
<td style="width: 95px;"></td>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
|
||||||
|
<!-- Team Permissions -->
|
||||||
|
<tr ng-repeat="(name, permission) in permissions['team']">
|
||||||
|
<td class="team entity">
|
||||||
|
<i class="fa fa-group"></i>
|
||||||
|
<span><a href="/organization/{{ repo.namespace }}/teams/{{ name }}">{{name}}</a></span>
|
||||||
|
</td>
|
||||||
|
<td class="user-permissions">
|
||||||
|
<span class="role-group" current-role="permission.role" role-changed="setRole(role, name, 'team')" roles="roles"></span>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<span class="delete-ui" tabindex="0">
|
||||||
|
<span class="delete-ui-button" ng-click="deleteRole(name, 'team')"><button class="btn btn-danger">Delete</button></span>
|
||||||
|
<i class="fa fa-times" bs-tooltip="tooltip.title" data-placement="right" title="Delete Permission"></i>
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
|
||||||
|
<!-- User Permissions -->
|
||||||
|
<tr ng-repeat="(name, permission) in permissions['user']">
|
||||||
|
<td class="{{ 'user entity ' + (permission.is_org_member? '' : 'outside') }}">
|
||||||
|
<i class="fa fa-user"></i>
|
||||||
|
<span>{{name}}</span>
|
||||||
|
<i class="fa fa-exclamation-triangle" ng-show="permission.is_org_member === false" data-trigger="hover" bs-popover="{'content': 'This user is not a member of the organization'}"></i>
|
||||||
|
</td>
|
||||||
|
<td class="user-permissions">
|
||||||
|
<div class="btn-group btn-group-sm">
|
||||||
|
<span class="role-group" current-role="permission.role" role-changed="setRole(role, name, 'user')" roles="roles"></span>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<span class="delete-ui" tabindex="0" title="Delete Permission">
|
||||||
|
<span class="delete-ui-button" ng-click="deleteRole(name, 'user')"><button class="btn btn-danger">Delete</button></span>
|
||||||
|
<i class="fa fa-times" bs-tooltip="tooltip.title" data-placement="right" title="Delete Permission"></i>
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
|
||||||
<tr>
|
<tr>
|
||||||
<td colspan="2">
|
<td colspan="2">
|
||||||
<span class="entity-search" organization="repo.namespace" input-title="'Add a ' + (repo.is_organization ? 'team or ' : '') + 'user...'" entity-selected="addNewPermission"></span>
|
<span class="entity-search" organization="repo.namespace" input-title="'Add a ' + (repo.is_organization ? 'team or ' : '') + 'user...'" entity-selected="addNewPermission"></span>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Token Permissions -->
|
<!-- Token Permissions -->
|
||||||
<div class="panel panel-default">
|
<div class="panel panel-default">
|
||||||
<div class="panel-heading">Access Token Permissions
|
<div class="panel-heading">Access Token Permissions
|
||||||
|
|
||||||
<i class="info-icon fa fa-info-circle" data-placement="left" data-content="Grant permissions to this repository by creating unique tokens that can be used without entering account passwords<br><br>To use in docker:<br><dl class='dl-horizontal'><dt>Username</dt><dd>$token</dd><dt>Password</dt><dd>(token value)</dd><dt>Email</dt><dd>(any value)</dd></dl>"></i>
|
<i class="info-icon fa fa-info-circle" data-placement="left" data-content="Grant permissions to this repository by creating unique tokens that can be used without entering account passwords<br><br>To use in docker:<br><dl class='dl-horizontal'><dt>Username</dt><dd>$token</dd><dt>Password</dt><dd>(token value)</dd><dt>Email</dt><dd>(any value)</dd></dl>"></i>
|
||||||
</div>
|
</div>
|
||||||
<div class="panel-body">
|
<div class="panel-body">
|
||||||
<form name="createTokenForm" ng-submit="createToken()">
|
<form name="createTokenForm" ng-submit="createToken()">
|
||||||
<table class="permissions">
|
<table class="permissions">
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<td>Token Description</td>
|
<td>Token Description</td>
|
||||||
<td>Permissions</td>
|
<td>Permissions</td>
|
||||||
<td></td>
|
<td></td>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
|
|
||||||
<tr ng-repeat="(code, token) in tokens">
|
<tr ng-repeat="(code, token) in tokens">
|
||||||
<td class="user token">
|
<td class="user token">
|
||||||
<i class="fa fa-key"></i>
|
<i class="fa fa-key"></i>
|
||||||
<a ng-click="showToken(token.code)">{{ token.friendlyName }}</a>
|
<a ng-click="showToken(token.code)">{{ token.friendlyName }}</a>
|
||||||
</td>
|
</td>
|
||||||
<td class="user-permissions">
|
<td class="user-permissions">
|
||||||
<div class="btn-group btn-group-sm">
|
<div class="btn-group btn-group-sm">
|
||||||
<button type="button" class="btn btn-default" ng-click="changeTokenAccess(token.code, 'read')" ng-class="{read: 'active', write: ''}[token.role]">Read only</button>
|
<button type="button" class="btn btn-default" ng-click="changeTokenAccess(token.code, 'read')" ng-class="{read: 'active', write: ''}[token.role]">Read only</button>
|
||||||
<button type="button" class="btn btn-default" ng-click="changeTokenAccess(token.code, 'write')" ng-class="{read: '', write: 'active'}[token.role]">Write</button>
|
<button type="button" class="btn btn-default" ng-click="changeTokenAccess(token.code, 'write')" ng-class="{read: '', write: 'active'}[token.role]">Write</button>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<span class="delete-ui" tabindex="0">
|
||||||
|
<span class="delete-ui-button" ng-click="deleteToken(token.code)"><button class="btn btn-danger" type="button">Delete</button></span>
|
||||||
|
<i class="fa fa-times" bs-tooltip="tooltip.title" data-placement="right" title="Delete Token"></i>
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
|
||||||
|
<tr>
|
||||||
|
<td>
|
||||||
|
<input type="text" class="form-control" placeholder="New token description" ng-model="newToken.friendlyName"required>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<button type="submit" ng-disabled="createTokenForm.$invalid" class="btn btn-sm btn-default">Create</button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Public/private tab -->
|
||||||
|
<div id="publicprivate" class="tab-pane">
|
||||||
|
<!-- Public/Private -->
|
||||||
|
<div class="panel panel-default">
|
||||||
|
<div class="panel-body">
|
||||||
|
<div class="repo-access-state" ng-show="!repo.is_public">
|
||||||
|
<div class="state-icon"><i class="fa fa-lock"></i></div>
|
||||||
|
|
||||||
|
This repository is currently <b>private</b>. Only users on the permissions list may view and interact with it.
|
||||||
|
|
||||||
|
<div class="change-access">
|
||||||
|
<button class="btn btn-danger" ng-click="askChangeAccess('public')">Make Public</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</td>
|
|
||||||
<td>
|
|
||||||
<span class="delete-ui" tabindex="0">
|
|
||||||
<span class="delete-ui-button" ng-click="deleteToken(token.code)"><button class="btn btn-danger" type="button">Delete</button></span>
|
|
||||||
<i class="fa fa-times" bs-tooltip="tooltip.title" data-placement="right" title="Delete Token"></i>
|
|
||||||
</span>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
|
|
||||||
<tr>
|
<div class="repo-access-state" ng-show="repo.is_public">
|
||||||
<td>
|
<div class="state-icon"><i class="fa fa-unlock"></i></div>
|
||||||
<input type="text" class="form-control" placeholder="New token description" ng-model="newToken.friendlyName"required>
|
|
||||||
</td>
|
|
||||||
<td>
|
|
||||||
<button type="submit" ng-disabled="createTokenForm.$invalid" class="btn btn-sm btn-default">Create</button>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
</table>
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Public/Private -->
|
This repository is currently <b>public</b> and is visible to all users, and may be pulled by all users.
|
||||||
<div class="panel panel-default">
|
|
||||||
<div class="panel-heading">Repository Settings</div>
|
|
||||||
<div class="panel-body">
|
|
||||||
<div class="repo-access-state" ng-show="!repo.is_public">
|
|
||||||
<div class="state-icon"><i class="fa fa-lock"></i></div>
|
|
||||||
|
|
||||||
This repository is currently <b>private</b>. Only users on the above access list may view and interact with it.
|
<div class="change-access">
|
||||||
|
<button class="btn btn-danger" ng-click="askChangeAccess('private')">Make Private</button>
|
||||||
<div class="change-access">
|
</div>
|
||||||
<button class="btn btn-danger" ng-click="askChangeAccess('public')">Make Public</button>
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="repo-access-state" ng-show="repo.is_public">
|
<!-- Delete tab -->
|
||||||
<div class="state-icon"><i class="fa fa-unlock"></i></div>
|
<div id="delete" class="tab-pane">
|
||||||
|
<!-- Delete Repo -->
|
||||||
This repository is currently <b>public</b> and is visible to all users, and may be pulled by all users.
|
<div class="panel panel-default">
|
||||||
|
<div class="panel-body">
|
||||||
<div class="change-access">
|
<div class="repo-delete">
|
||||||
<button class="btn btn-danger" ng-click="askChangeAccess('private')">Make Private</button>
|
<div class="alert alert-danger">Deleting a repository <b>cannot be undone</b>. Here be dragons!</div>
|
||||||
|
<button class="btn btn-danger" ng-click="askDelete()">Delete Repository</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<br>
|
|
||||||
|
|
||||||
<!-- Delete Repo -->
|
|
||||||
<div class="panel panel-default">
|
|
||||||
<div class="panel-heading">Delete Repository</div>
|
|
||||||
<div class="panel-body">
|
|
||||||
<div class="repo-delete">
|
|
||||||
<div class="alert alert-danger">Deleting a repository <b>cannot be undone</b>. Here be dragons!</div>
|
|
||||||
<button class="btn btn-danger" ng-click="askDelete()">Delete Repository</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Modal message dialog -->
|
<!-- Modal message dialog -->
|
||||||
<div class="modal fade" id="cannotchangeModal">
|
<div class="modal fade" id="cannotchangeModal">
|
||||||
<div class="modal-dialog">
|
<div class="modal-dialog">
|
||||||
<div class="modal-content">
|
<div class="modal-content">
|
||||||
|
@ -201,13 +226,13 @@
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="modal-footer">
|
<div class="modal-footer">
|
||||||
<span class="download-cfg" ng-show="isDownloadSupported()">
|
<span class="download-cfg" ng-show="isDownloadSupported()">
|
||||||
<i class="icon-download"></i>
|
<i class="icon-download"></i>
|
||||||
<a href="javascript:void(0)" ng-click="downloadCfg(shownToken)">Download .dockercfg file</a>
|
<a href="javascript:void(0)" ng-click="downloadCfg(shownToken)">Download .dockercfg file</a>
|
||||||
</span>
|
</span>
|
||||||
<div id="clipboardCopied" style="display: none">
|
<div id="clipboardCopied" style="display: none">
|
||||||
Copied to clipboard
|
Copied to clipboard
|
||||||
</div>
|
</div>
|
||||||
<button id="copyClipboard" type="button" class="btn btn-primary" data-clipboard-target="token-view">Copy to clipboard</button>
|
<button id="copyClipboard" type="button" class="btn btn-primary" data-clipboard-target="token-view">Copy to clipboard</button>
|
||||||
<button type="button" class="btn btn-default" data-dismiss="modal">Close</button>
|
<button type="button" class="btn btn-default" data-dismiss="modal">Close</button>
|
||||||
</div>
|
</div>
|
||||||
|
@ -216,7 +241,7 @@
|
||||||
</div><!-- /.modal -->
|
</div><!-- /.modal -->
|
||||||
|
|
||||||
|
|
||||||
<!-- Modal message dialog -->
|
<!-- Modal message dialog -->
|
||||||
<div class="modal fade" id="makepublicModal">
|
<div class="modal fade" id="makepublicModal">
|
||||||
<div class="modal-dialog">
|
<div class="modal-dialog">
|
||||||
<div class="modal-content">
|
<div class="modal-content">
|
||||||
|
@ -321,4 +346,3 @@
|
||||||
</div><!-- /.modal-dialog -->
|
</div><!-- /.modal-dialog -->
|
||||||
</div><!-- /.modal -->
|
</div><!-- /.modal -->
|
||||||
|
|
||||||
</div>
|
|
||||||
|
|
|
@ -27,6 +27,7 @@
|
||||||
<div class="col-md-2">
|
<div class="col-md-2">
|
||||||
<ul class="nav nav-pills nav-stacked">
|
<ul class="nav nav-pills nav-stacked">
|
||||||
<li class="active"><a href="javascript:void(0)" data-toggle="tab" data-target="#plan">Plan and Usage</a></li>
|
<li class="active"><a href="javascript:void(0)" data-toggle="tab" data-target="#plan">Plan and Usage</a></li>
|
||||||
|
<li ng-show="hasPaidPlan"><a href="javascript:void(0)" data-toggle="tab" data-target="#billing">Billing Options</a></li>
|
||||||
<li><a href="javascript:void(0)" data-toggle="tab" data-target="#password">Set Password</a></li>
|
<li><a href="javascript:void(0)" data-toggle="tab" data-target="#password">Set Password</a></li>
|
||||||
<li><a href="javascript:void(0)" data-toggle="tab" data-target="#migrate" id="migrateTab">Convert to Organization</a></li>
|
<li><a href="javascript:void(0)" data-toggle="tab" data-target="#migrate" id="migrateTab">Convert to Organization</a></li>
|
||||||
</ul>
|
</ul>
|
||||||
|
@ -37,7 +38,7 @@
|
||||||
<div class="tab-content">
|
<div class="tab-content">
|
||||||
<!-- Plans tab -->
|
<!-- Plans tab -->
|
||||||
<div id="plan" class="tab-pane active">
|
<div id="plan" class="tab-pane active">
|
||||||
<div class="plan-manager" user="user.username" ready-for-plan="readyForPlan()"></div>
|
<div class="plan-manager" user="user.username" ready-for-plan="readyForPlan()" plan-changed="planChanged(plan)"></div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Change password tab -->
|
<!-- Change password tab -->
|
||||||
|
@ -57,6 +58,11 @@
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Billing options tab -->
|
||||||
|
<div id="billing" class="tab-pane">
|
||||||
|
<div class="billing-options" user="user"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- Convert to organization tab -->
|
<!-- Convert to organization tab -->
|
||||||
<div id="migrate" class="tab-pane">
|
<div id="migrate" class="tab-pane">
|
||||||
|
|
|
@ -39,3 +39,11 @@ def send_recovery_email(email, token):
|
||||||
recipients=[email])
|
recipients=[email])
|
||||||
msg.html = RECOVERY_MESSAGE % (token, token)
|
msg.html = RECOVERY_MESSAGE % (token, token)
|
||||||
mail.send(msg)
|
mail.send(msg)
|
||||||
|
|
||||||
|
|
||||||
|
def send_invoice_email(email, contents):
|
||||||
|
msg = Message('Quay.io payment received - Thank you!',
|
||||||
|
sender='support@quay.io', # Why do I need this?
|
||||||
|
recipients=[email])
|
||||||
|
msg.html = contents
|
||||||
|
mail.send(msg)
|
||||||
|
|
36
util/invoice.py
Normal file
|
@ -0,0 +1,36 @@
|
||||||
|
from datetime import datetime
|
||||||
|
from jinja2 import Environment, FileSystemLoader
|
||||||
|
|
||||||
|
jinja_options = {
|
||||||
|
"loader": FileSystemLoader('util'),
|
||||||
|
}
|
||||||
|
|
||||||
|
env = Environment(**jinja_options)
|
||||||
|
|
||||||
|
def renderInvoiceToHtml(invoice, user):
|
||||||
|
""" Renders a nice HTML display for the given invoice. """
|
||||||
|
def get_price(price):
|
||||||
|
if not price:
|
||||||
|
return '$0'
|
||||||
|
|
||||||
|
return '$' + '{0:.2f}'.format(float(price) / 100)
|
||||||
|
|
||||||
|
def get_range(line):
|
||||||
|
if line.period and line.period.start and line.period.end:
|
||||||
|
return ': ' + format_date(line.period.start) + ' - ' + format_date(line.period.end)
|
||||||
|
return ''
|
||||||
|
|
||||||
|
def format_date(timestamp):
|
||||||
|
return datetime.fromtimestamp(timestamp).strftime('%Y-%m-%d')
|
||||||
|
|
||||||
|
data = {
|
||||||
|
'user': user.username,
|
||||||
|
'invoice': invoice,
|
||||||
|
'invoice_date': format_date(invoice.date),
|
||||||
|
'getPrice': get_price,
|
||||||
|
'getRange': get_range
|
||||||
|
}
|
||||||
|
|
||||||
|
template = env.get_template('invoice.tmpl')
|
||||||
|
rendered = template.render(data)
|
||||||
|
return rendered
|
63
util/invoice.tmpl
Normal file
|
@ -0,0 +1,63 @@
|
||||||
|
<html>
|
||||||
|
<body>
|
||||||
|
<table width="100%" style="max-width: 640px">
|
||||||
|
<tr>
|
||||||
|
<td valign="center" style="padding: 10px;">
|
||||||
|
<img src="https://quay.io/static/img/quay-logo.png" alt="Quay.io" style="width: 100px;">
|
||||||
|
</td>
|
||||||
|
<td valign="center">
|
||||||
|
<h3>Quay.io</h3>
|
||||||
|
<p style="font-size: 12px; -webkit-text-adjust: none">
|
||||||
|
DevTable, LLC<br>
|
||||||
|
https://devtable.com<br>
|
||||||
|
PO Box 48<br>
|
||||||
|
New York, NY 10009
|
||||||
|
</p>
|
||||||
|
</td>
|
||||||
|
<td align="right" width="100%">
|
||||||
|
<h1 style="color: #ddd;">RECEIPT</h1>
|
||||||
|
<table>
|
||||||
|
<tr><td>Date:</td><td>{{ invoice_date }}</td></tr>
|
||||||
|
<tr><td>Invoice #:</td><td style="font-size: 10px">{{ invoice.id }}</td></tr>
|
||||||
|
</table>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
<hr>
|
||||||
|
|
||||||
|
<table width="100%" style="max-width: 640px">
|
||||||
|
<thead>
|
||||||
|
<th style="padding: 4px; background: #eee; text-align: center; font-weight: bold">Description</th>
|
||||||
|
<th style="padding: 4px; background: #eee; text-align: center; font-weight: bold">Line Total</th>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{%- for line in invoice.lines.data -%}
|
||||||
|
<tr>
|
||||||
|
<td width="100%" style="padding: 4px;">{{ line.description or ('Plan Subscription' + getRange(line)) }}</td>
|
||||||
|
<td style="padding: 4px; min-width: 150px;">{{ getPrice(line.amount) }}</td>
|
||||||
|
</tr>
|
||||||
|
{%- endfor -%}
|
||||||
|
|
||||||
|
|
||||||
|
<tr>
|
||||||
|
<td></td>
|
||||||
|
<td valign="right">
|
||||||
|
<table>
|
||||||
|
<tr><td><b>Subtotal: </b></td><td>{{ getPrice(invoice.subtotal) }}</td></tr>
|
||||||
|
<tr><td><b>Total: </b></td><td>{{ getPrice(invoice.total) }}</td></tr>
|
||||||
|
<tr><td><b>Paid: </b></td><td>{{ getPrice(invoice.total) if invoice.paid else 0 }}</td></tr>
|
||||||
|
<tr><td><b>Total Due:</b></td>
|
||||||
|
<td>{{ getPrice(invoice.ending_balance) }}</td></tr>
|
||||||
|
</table>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
<div style="margin: 6px; padding: 6px; width: 100%; max-width: 640px; border-top: 2px solid #eee; text-align: center; font-size: 14px; -webkit-text-adjust: none; font-weight: bold;">
|
||||||
|
We thank you for your continued business!
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</body>
|
||||||
|
</html>
|
|
@ -1,5 +1,4 @@
|
||||||
import re
|
import re
|
||||||
import urllib
|
|
||||||
|
|
||||||
INVALID_PASSWORD_MESSAGE = 'Invalid password, password must be at least ' + \
|
INVALID_PASSWORD_MESSAGE = 'Invalid password, password must be at least ' + \
|
||||||
'8 characters and contain no whitespace.'
|
'8 characters and contain no whitespace.'
|
||||||
|
@ -12,9 +11,9 @@ def validate_email(email_address):
|
||||||
|
|
||||||
def validate_username(username):
|
def validate_username(username):
|
||||||
# Minimum length of 2, maximum length of 255, no url unsafe characters
|
# Minimum length of 2, maximum length of 255, no url unsafe characters
|
||||||
return (urllib.quote(username, safe='') == username and
|
return (re.search(r'[^a-z0-9_]', username) is None and
|
||||||
len(username) > 1 and
|
len(username) >= 4 and
|
||||||
len(username) < 256)
|
len(username) <= 30)
|
||||||
|
|
||||||
|
|
||||||
def validate_password(password):
|
def validate_password(password):
|
||||||
|
|