Merge branch 'master' of ssh://bitbucket.org/yackob03/quay into webhooks

Conflicts:
	endpoints/api.py
	test/data/test.db
This commit is contained in:
yackob03 2013-11-15 16:48:55 -05:00
commit 7ae78e5370
18 changed files with 487 additions and 185 deletions

View file

@ -21,7 +21,7 @@ running:
```
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:

View file

@ -11,6 +11,7 @@ import endpoints.api
import endpoints.web
import endpoints.tags
import endpoints.registry
import endpoints.webhooks
logger = logging.getLogger(__name__)

View file

@ -34,6 +34,7 @@ class User(BaseModel):
verified = BooleanField(default=False)
stripe_id = CharField(index=True, null=True)
organization = BooleanField(default=False, index=True)
invoice_email = BooleanField(default=False)
class TeamRole(BaseModel):

View file

@ -301,6 +301,12 @@ def get_user(username):
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):
query = Team.select().where(Team.name ** (team_prefix + '%'),
Team.organization == organization)
@ -496,6 +502,11 @@ def change_password(user, new_password):
user.save()
def change_invoice_email(user, invoice_email):
user.invoice_email = invoice_email
user.save()
def update_email(user, new_email):
user.email = new_email
user.verified = False

View file

@ -71,8 +71,7 @@ def plans_list():
})
@app.route('/api/user/', methods=['GET'])
def get_logged_in_user():
def user_view(user):
def org_view(o):
admin_org = AdministerOrganizationPermission(o.username)
return {
@ -82,16 +81,9 @@ def get_logged_in_user():
'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)
return jsonify({
return {
'verified': user.verified,
'anonymous': False,
'username': user.username,
@ -99,8 +91,21 @@ def get_logged_in_user():
'gravatar': compute_hash(user.email),
'askForPassword': user.password_hash is None,
'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'])
@ -150,6 +155,11 @@ def change_user_details():
if 'password' in user_data:
logger.debug('Changing password for user: %s', user.username)
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:
error_resp = jsonify({
'message': ex.message,
@ -157,14 +167,7 @@ def change_user_details():
error_resp.status_code = 400
return error_resp
return jsonify({
'verified': user.verified,
'anonymous': False,
'username': user.username,
'email': user.email,
'gravatar': compute_hash(user.email),
'askForPassword': user.password_hash is None,
})
return jsonify(user_view(user))
@app.route('/api/user/', methods=['POST'])
@ -341,22 +344,28 @@ def create_organization_api():
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'])
@api_login_required
def get_organization(orgname):
permission = OrganizationMemberPermission(orgname)
if permission.can():
def org_view(org, teams):
admin_org = AdministerOrganizationPermission(orgname)
is_admin = admin_org.can()
return {
'name': org.username,
'email': org.email if is_admin else '',
'gravatar': compute_hash(org.email),
'teams': {t.name : team_view(orgname, t) for t in teams},
'is_admin': is_admin
}
try:
org = model.get_organization(orgname)
except model.InvalidOrganizationException:
@ -367,6 +376,28 @@ def get_organization(orgname):
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'])
@api_login_required
def get_organization_members(orgname):
@ -1265,7 +1296,7 @@ def subscribe(user, plan, token, accepted_plans):
if not user.stripe_id:
# Check if a non-paying user is trying to subscribe to a free plan
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
card = token
cus = stripe.Customer.create(email=user.email, plan=plan, card=card)

42
endpoints/webhooks.py Normal file
View 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')

View file

@ -31,7 +31,7 @@ http {
server {
listen 443 default;
client_max_body_size 4G;
client_max_body_size 8G;
server_name _;
keepalive_timeout 5;
@ -61,6 +61,7 @@ http {
proxy_buffering off;
proxy_pass http://app_server;
proxy_read_timeout 2000;
}
}
}

View file

@ -6,6 +6,20 @@
visibility: hidden;
}
.settings-option {
padding: 4px;
font-size: 18px;
}
.settings-option label {
margin-left: 6px;
}
.settings-option .settings-description {
font-size: 12px;
color: #aaa;
}
.organization-header-element {
padding: 20px;
margin-bottom: 20px;

View file

@ -0,0 +1,17 @@
<div class="billing-options-element">
<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 billing
</div>
</div>
</div>
</div>
</div>

View file

@ -240,7 +240,7 @@ quayApp = angular.module('quay', ['restangular', 'angularMoment', 'angulartics',
if (callbacks['started']) {
callbacks['started']();
}
planService.setSubscription(orgname, planId, callbacks['success'], callbacks['failure']);
planService.setSubscription(orgname, planId, callbacks['success'], callbacks['failure'], token);
});
};
@ -621,6 +621,52 @@ 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, Restangular) {
$scope.invoice_email = false;
var update = function() {
if (!$scope.user && !$scope.organization) { return; }
$scope.obj = $scope.user ? $scope.user : $scope.organization;
$scope.invoice_email = $scope.obj.invoice_email;
};
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 () {
var directiveDefinitionObject = {
priority: 0,

View file

@ -29,8 +29,10 @@
<!-- Billing tab -->
<div id="billing" class="tab-pane">
<div class="billing-options" organization="organization"></div>
<div ng-show="invoiceLoading">
<i class="fa fa-spinner fa-spin fa-3x"></i>
Loading billing history: <i class="fa fa-spinner fa-spin fa-2x" style="vertical-align: middle; margin-left: 4px"></i>
</div>
<div ng-show="!invoiceLoading && !invoices">

View file

@ -11,164 +11,189 @@
</div>
<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>
<h3>
<span class="repo-circle no-background" repo="repo"></span> <span style="color: #aaa;"> {{repo.namespace}}</span> <span style="color: #ccc">/</span> {{repo.name}}
</h3>
</div>
</div>
<!-- User Access Permissions -->
<div class="panel panel-default">
<div class="panel-heading">User <span ng-show="repo.is_organization">and Team</span> Access 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>
<div class="row">
<!-- Side tabs -->
<div class="col-md-2">
<ul class="nav nav-pills nav-stacked">
<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 Repo</a></li>
</ul>
</div>
<div class="panel-body">
<table class="permissions">
<thead>
<tr>
<td>User<span ng-show="repo.is_organization">/Team</span></td>
<td>Permissions</td>
<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>
<!-- Content -->
<div class="col-md-10">
<div class="tab-content">
<!-- Permissions tab -->
<div id="permissions" class="tab-pane active">
<!-- User Access Permissions -->
<div class="panel panel-default">
<div class="panel-heading">User <span ng-show="repo.is_organization">and Team</span> Access 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>
</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>
<div class="panel-body">
<table class="permissions">
<thead>
<tr>
<td>User<span ng-show="repo.is_organization">/Team</span></td>
<td>Permissions</td>
<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>
<td colspan="2">
<span class="entity-search" organization="repo.namespace" input-title="'Add a ' + (repo.is_organization ? 'team or ' : '') + 'user...'" entity-selected="addNewPermission"></span>
</td>
</tr>
</table>
</div>
</div>
<tr>
<td colspan="2">
<span class="entity-search" organization="repo.namespace" input-title="'Add a ' + (repo.is_organization ? 'team or ' : '') + 'user...'" entity-selected="addNewPermission"></span>
</td>
</tr>
</table>
</div>
</div>
<!-- Token Permissions -->
<div class="panel panel-default">
<div class="panel-heading">Access Token Permissions
<!-- Token Permissions -->
<div class="panel panel-default">
<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>
</div>
<div class="panel-body">
<form name="createTokenForm" ng-submit="createToken()">
<table class="permissions">
<thead>
<tr>
<td>Token Description</td>
<td>Permissions</td>
<td></td>
</tr>
</thead>
<tr ng-repeat="(code, token) in tokens">
<td class="user token">
<i class="fa fa-key"></i>
<a ng-click="showToken(token.code)">{{ token.friendlyName }}</a>
</td>
<td class="user-permissions">
<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, 'write')" ng-class="{read: '', write: 'active'}[token.role]">Write</button>
<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 class="panel-body">
<form name="createTokenForm" ng-submit="createToken()">
<table class="permissions">
<thead>
<tr>
<td>Token Description</td>
<td>Permissions</td>
<td></td>
</tr>
</thead>
<tr ng-repeat="(code, token) in tokens">
<td class="user token">
<i class="fa fa-key"></i>
<a ng-click="showToken(token.code)">{{ token.friendlyName }}</a>
</td>
<td class="user-permissions">
<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, '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>
</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 class="repo-access-state" ng-show="repo.is_public">
<div class="state-icon"><i class="fa fa-unlock"></i></div>
<!-- Public/Private -->
<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>public</b> and is visible to all users, and may be pulled by all users.
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('public')">Make Public</button>
<div class="change-access">
<button class="btn btn-danger" ng-click="askChangeAccess('private')">Make Private</button>
</div>
</div>
</div>
</div>
</div>
</div>
<div class="repo-access-state" ng-show="repo.is_public">
<div class="state-icon"><i class="fa fa-unlock"></i></div>
This repository is currently <b>public</b> and is visible to all users, and may be pulled by all users.
<div class="change-access">
<button class="btn btn-danger" ng-click="askChangeAccess('private')">Make Private</button>
<!-- Delete tab -->
<div id="delete" class="tab-pane">
<!-- Delete Repo -->
<div class="panel panel-default">
<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>
</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-dialog">
<div class="modal-content">
@ -201,13 +226,13 @@
</div>
</div>
<div class="modal-footer">
<span class="download-cfg" ng-show="isDownloadSupported()">
<i class="icon-download"></i>
<a href="javascript:void(0)" ng-click="downloadCfg(shownToken)">Download .dockercfg file</a>
</span>
<div id="clipboardCopied" style="display: none">
Copied to clipboard
</div>
<span class="download-cfg" ng-show="isDownloadSupported()">
<i class="icon-download"></i>
<a href="javascript:void(0)" ng-click="downloadCfg(shownToken)">Download .dockercfg file</a>
</span>
<div id="clipboardCopied" style="display: none">
Copied to clipboard
</div>
<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>
</div>
@ -216,7 +241,7 @@
</div><!-- /.modal -->
<!-- Modal message dialog -->
<!-- Modal message dialog -->
<div class="modal fade" id="makepublicModal">
<div class="modal-dialog">
<div class="modal-content">
@ -321,4 +346,3 @@
</div><!-- /.modal-dialog -->
</div><!-- /.modal -->
</div>

View file

@ -28,6 +28,7 @@
<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><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="#billing">Billing Options</a></li>
<li><a href="javascript:void(0)" data-toggle="tab" data-target="#migrate" id="migrateTab">Convert to Organization</a></li>
</ul>
</div>
@ -57,6 +58,11 @@
</form>
</div>
</div>
<!-- Billing options tab -->
<div id="billing" class="tab-pane">
<div class="billing-options" user="user"></div>
</div>
<!-- Convert to organization tab -->
<div id="migrate" class="tab-pane">

Binary file not shown.

View file

@ -39,3 +39,11 @@ def send_recovery_email(email, token):
recipients=[email])
msg.html = RECOVERY_MESSAGE % (token, token)
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
View 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
View 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>

View file

@ -1,5 +1,4 @@
import re
import urllib
INVALID_PASSWORD_MESSAGE = 'Invalid password, password must be at least ' + \
'8 characters and contain no whitespace.'
@ -12,9 +11,9 @@ def validate_email(email_address):
def validate_username(username):
# Minimum length of 2, maximum length of 255, no url unsafe characters
return (urllib.quote(username, safe='') == username and
len(username) > 1 and
len(username) < 256)
return (re.search(r'[^a-z0-9_]', username) is None and
len(username) >= 4 and
len(username) <= 30)
def validate_password(password):