From 5633c1fc79b1411c6feea06b1d6eaf16c43962cd Mon Sep 17 00:00:00 2001 From: yackob03 Date: Thu, 19 Dec 2013 17:06:04 -0500 Subject: [PATCH 01/13] Merge the plans and mark many as deprecated. Fix a bunch of pylint errors. --- data/plans.py | 51 +++++++++++------ endpoints/api.py | 128 +++++++++++++++++++++++++----------------- endpoints/webhooks.py | 21 +++---- 3 files changed, 119 insertions(+), 81 deletions(-) diff --git a/data/plans.py b/data/plans.py index aa17ed4b7..2b8b6af2b 100644 --- a/data/plans.py +++ b/data/plans.py @@ -1,20 +1,13 @@ -import json -import itertools - -USER_PLANS = [ - { - 'title': 'Open Source', - 'price': 0, - 'privateRepos': 0, - 'stripeId': 'free', - 'audience': 'Share with the world', - }, +PLANS = [ + # Deprecated Plans { 'title': 'Micro', 'price': 700, 'privateRepos': 5, 'stripeId': 'micro', 'audience': 'For smaller teams', + 'bus_features': False, + 'deprecated': True, }, { 'title': 'Basic', @@ -22,6 +15,8 @@ USER_PLANS = [ 'privateRepos': 10, 'stripeId': 'small', 'audience': 'For your basic team', + 'bus_features': False, + 'deprecated': True, }, { 'title': 'Medium', @@ -29,6 +24,8 @@ USER_PLANS = [ 'privateRepos': 20, 'stripeId': 'medium', 'audience': 'For medium teams', + 'bus_features': False, + 'deprecated': True, }, { 'title': 'Large', @@ -36,16 +33,28 @@ USER_PLANS = [ 'privateRepos': 50, 'stripeId': 'large', 'audience': 'For larger teams', + 'bus_features': False, + 'deprecated': True, }, -] -BUSINESS_PLANS = [ + # Active plans { 'title': 'Open Source', 'price': 0, 'privateRepos': 0, - 'stripeId': 'bus-free', + 'stripeId': 'free', 'audience': 'Committment to FOSS', + 'bus_features': False, + 'deprecated': False, + }, + { + 'title': 'Personal', + 'price': 1200, + 'privateRepos': 5, + 'stripeId': 'personal', + 'audience': 'Individuals', + 'bus_features': False, + 'deprecated': False, }, { 'title': 'Skiff', @@ -53,6 +62,8 @@ BUSINESS_PLANS = [ 'privateRepos': 10, 'stripeId': 'bus-micro', 'audience': 'For startups', + 'bus_features': True, + 'deprecated': False, }, { 'title': 'Yacht', @@ -60,6 +71,8 @@ BUSINESS_PLANS = [ 'privateRepos': 20, 'stripeId': 'bus-small', 'audience': 'For small businesses', + 'bus_features': True, + 'deprecated': False, }, { 'title': 'Freighter', @@ -67,6 +80,8 @@ BUSINESS_PLANS = [ 'privateRepos': 50, 'stripeId': 'bus-medium', 'audience': 'For normal businesses', + 'bus_features': True, + 'deprecated': False, }, { 'title': 'Tanker', @@ -74,14 +89,16 @@ BUSINESS_PLANS = [ 'privateRepos': 125, 'stripeId': 'bus-large', 'audience': 'For large businesses', + 'bus_features': True, + 'deprecated': False, }, ] -def get_plan(id): +def get_plan(plan_id): """ Returns the plan with the given ID or None if none. """ - for plan in itertools.chain(USER_PLANS, BUSINESS_PLANS): - if plan['stripeId'] == id: + for plan in PLANS: + if plan['stripeId'] == plan_id: return plan return None diff --git a/endpoints/api.py b/endpoints/api.py index 95912b570..19947c62f 100644 --- a/endpoints/api.py +++ b/endpoints/api.py @@ -12,7 +12,7 @@ from collections import defaultdict from data import model from data.queue import dockerfile_build_queue -from data.plans import USER_PLANS, BUSINESS_PLANS, get_plan +from data.plans import PLANS, get_plan from app import app from util.email import send_confirmation_email, send_recovery_email from util.names import parse_repository_name, format_robot_username @@ -30,6 +30,7 @@ from endpoints.web import common_login from util.cache import cache_control from datetime import datetime, timedelta + store = app.config['STORAGE'] user_files = app.config['USERFILES'] logger = logging.getLogger(__name__) @@ -37,8 +38,9 @@ logger = logging.getLogger(__name__) def log_action(kind, user_or_orgname, metadata={}, repo=None): performer = current_user.db_user() - model.log_action(kind, user_or_orgname, performer=performer, ip=request.remote_addr, - metadata=metadata, repository=repo) + model.log_action(kind, user_or_orgname, performer=performer, + ip=request.remote_addr, metadata=metadata, repository=repo) + def api_login_required(f): @wraps(f) @@ -64,7 +66,7 @@ def handle_dme(ex): @app.errorhandler(KeyError) -def handle_dme(ex): +def handle_dme_key_error(ex): return make_response(ex.message, 400) @@ -76,8 +78,7 @@ def welcome(): @app.route('/api/plans/') def plans_list(): return jsonify({ - 'user': USER_PLANS, - 'business': BUSINESS_PLANS, + 'plans': PLANS, }) @@ -164,7 +165,7 @@ def convert_user_to_organization(): # Subscribe the organization to the new plan. plan = convert_data['plan'] - subscribe(user, plan, None, BUSINESS_PLANS) + subscribe(user, plan, None, PLANS) # Convert the user to an organization. model.convert_user_to_organization(user, model.get_user(admin_username)) @@ -172,7 +173,6 @@ def convert_user_to_organization(): # And finally login with the admin credentials. return conduct_signin(admin_username, admin_password) - @app.route('/api/user/', methods=['PUT']) @@ -427,7 +427,7 @@ def change_organization_details(orgname): except model.InvalidOrganizationException: abort(404) - org_data = request.get_json(); + 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']) @@ -477,7 +477,7 @@ def get_organization_member(orgname, membername): abort(404) member_dict = None - member_teams = model.get_organization_members_with_teams(org, membername = membername) + member_teams = model.get_organization_members_with_teams(org, membername=membername) for member in member_teams: if not member_dict: member_dict = {'username': member.user.username, @@ -551,17 +551,20 @@ def update_organization_team(orgname, teamname): log_action('org_create_team', orgname, {'team': teamname}) if is_existing: - if 'description' in details and team.description != details['description']: + if ('description' in details and + team.description != details['description']): team.description = details['description'] team.save() - log_action('org_set_team_description', orgname, {'team': teamname, 'description': team.description}) + log_action('org_set_team_description', orgname, + {'team': teamname, 'description': team.description}) if 'role' in details: role = model.get_team_org_role(team).name if role != details['role']: team = model.set_team_org_permission(team, details['role'], current_user.db_user().username) - log_action('org_set_team_role', orgname, {'team': teamname, 'role': details['role']}) + log_action('org_set_team_role', orgname, + {'team': teamname, 'role': details['role']}) resp = jsonify(team_view(orgname, team)) if not is_existing: @@ -629,7 +632,8 @@ def update_organization_team_member(orgname, teamname, membername): # Add the user to the team. model.add_user_to_team(user, team) - log_action('org_add_team_member', orgname, {'member': membername, 'team': teamname}) + log_action('org_add_team_member', orgname, + {'member': membername, 'team': teamname}) return jsonify(member_view(user)) abort(403) @@ -644,7 +648,8 @@ def delete_organization_team_member(orgname, teamname, membername): # Remote the user from the team. invoking_user = current_user.db_user().username model.remove_user_from_team(orgname, teamname, membername, invoking_user) - log_action('org_remove_team_member', orgname, {'member': membername, 'team': teamname}) + log_action('org_remove_team_member', orgname, + {'member': membername, 'team': teamname}) return make_response('Deleted', 204) abort(403) @@ -673,7 +678,9 @@ def create_repo_api(): repo.description = req['description'] repo.save() - log_action('create_repo', namespace_name, {'repo': repository_name, 'namespace': namespace_name}, repo=repo) + log_action('create_repo', namespace_name, + {'repo': repository_name, 'namespace': namespace_name}, + repo=repo) return jsonify({ 'namespace': namespace_name, 'name': repository_name @@ -758,7 +765,8 @@ def update_repo_api(namespace, repository): repo.description = values['description'] repo.save() - log_action('set_repo_description', namespace, {'repo': repository, 'description': values['description']}, + log_action('set_repo_description', namespace, + {'repo': repository, 'description': values['description']}, repo=repo) return jsonify({ 'success': True @@ -778,7 +786,8 @@ def change_repo_visibility_api(namespace, repository): if repo: values = request.get_json() model.set_repository_visibility(repo, values['visibility']) - log_action('change_repo_visibility', namespace, {'repo': repository, 'visibility': values['visibility']}, + log_action('change_repo_visibility', namespace, + {'repo': repository, 'visibility': values['visibility']}, repo=repo) return jsonify({ 'success': True @@ -795,7 +804,8 @@ def delete_repository(namespace, repository): if permission.can(): model.purge_repository(namespace, repository) registry.delete_repository_storage(namespace, repository) - log_action('delete_repo', namespace, {'repo': repository, 'namespace': namespace}) + log_action('delete_repo', namespace, + {'repo': repository, 'namespace': namespace}) return make_response('Deleted', 204) abort(403) @@ -912,7 +922,8 @@ def request_repo_build(namespace, repository): dockerfile_build_queue.put(json.dumps({'build_id': build_request.id})) log_action('build_dockerfile', namespace, - {'repo': repository, 'namespace': namespace, 'fileid': dockerfile_id}, repo=repo) + {'repo': repository, 'namespace': namespace, + 'fileid': dockerfile_id}, repo=repo) resp = jsonify({ 'started': True @@ -943,7 +954,8 @@ def create_webhook(namespace, repository): resp.headers['Location'] = url_for('get_webhook', repository=repo_string, public_id=webhook.public_id) log_action('add_repo_webhook', namespace, - {'repo': repository, 'webhook_id': webhook.public_id}, repo=repo) + {'repo': repository, 'webhook_id': webhook.public_id}, + repo=repo) return resp abort(403) # Permissions denied @@ -1143,7 +1155,8 @@ def list_repo_user_permissions(namespace, repository): current_func = role_view_func def wrapped_role_org_view(repo_perm): - return wrap_role_view_org(current_func(repo_perm), repo_perm.user, org_members) + return wrap_role_view_org(current_func(repo_perm), repo_perm.user, + org_members) role_view_func = wrapped_role_org_view @@ -1228,7 +1241,8 @@ def change_user_permissions(namespace, repository, username): return error_resp log_action('change_repo_permission', namespace, - {'username': username, 'repo': repository, 'role': new_permission['role']}, + {'username': username, 'repo': repository, + 'role': new_permission['role']}, repo=model.get_repository(namespace, repository)) resp = jsonify(perm_view) @@ -1255,7 +1269,8 @@ def change_team_permissions(namespace, repository, teamname): new_permission['role']) log_action('change_repo_permission', namespace, - {'team': teamname, 'repo': repository, 'role': new_permission['role']}, + {'team': teamname, 'repo': repository, + 'role': new_permission['role']}, repo=model.get_repository(namespace, repository)) resp = jsonify(role_view(perm)) @@ -1282,7 +1297,8 @@ def delete_user_permissions(namespace, repository, username): error_resp.status_code = 400 return error_resp - log_action('delete_repo_permission', namespace, {'username': username, 'repo': repository}, + log_action('delete_repo_permission', namespace, + {'username': username, 'repo': repository}, repo=model.get_repository(namespace, repository)) return make_response('Deleted', 204) @@ -1299,7 +1315,8 @@ def delete_team_permissions(namespace, repository, teamname): if permission.can(): model.delete_team_permission(teamname, namespace, repository) - log_action('delete_repo_permission', namespace, {'team': teamname, 'repo': repository}, + log_action('delete_repo_permission', namespace, + {'team': teamname, 'repo': repository}, repo=model.get_repository(namespace, repository)) return make_response('Deleted', 204) @@ -1353,7 +1370,8 @@ def create_token(namespace, repository): token = model.create_delegate_token(namespace, repository, token_params['friendlyName']) - log_action('add_repo_accesstoken', namespace, {'repo': repository, 'token': token_params['friendlyName']}, + log_action('add_repo_accesstoken', namespace, + {'repo': repository, 'token': token_params['friendlyName']}, repo = model.get_repository(namespace, repository)) resp = jsonify(token_view(token)) @@ -1378,7 +1396,8 @@ def change_token(namespace, repository, code): new_permission['role']) log_action('change_repo_permission', namespace, - {'repo': repository, 'token': token.friendly_name, 'code': code, 'role': new_permission['role']}, + {'repo': repository, 'token': token.friendly_name, 'code': code, + 'role': new_permission['role']}, repo = model.get_repository(namespace, repository)) resp = jsonify(token_view(token)) @@ -1397,7 +1416,8 @@ def delete_token(namespace, repository, code): token = model.delete_delegate_token(namespace, repository, code) log_action('delete_repo_accesstoken', namespace, - {'repo': repository, 'token': token.friendly_name, 'code': code}, + {'repo': repository, 'token': token.friendly_name, + 'code': code}, repo = model.get_repository(namespace, repository)) return make_response('Deleted', 204) @@ -1486,9 +1506,9 @@ def get_card(user): if default_card: card_info = { - 'owner': card.name, - 'type': card.type, - 'last4': card.last4 + 'owner': default_card.name, + 'type': default_card.type, + 'last4': default_card.last4 } return jsonify({'card': card_info}) @@ -1500,7 +1520,7 @@ def subscribe_api(): plan = request_data['plan'] token = request_data['token'] if 'token' in request_data else None user = current_user.db_user() - return subscribe(user, plan, token, USER_PLANS) + return subscribe(user, plan, token, PLANS) def carderror_response(e): @@ -1511,15 +1531,18 @@ def carderror_response(e): return resp -def subscribe(user, plan, token, accepted_plans): +def subscribe(user, plan, token, require_business_plan): plan_found = None - for plan_obj in accepted_plans: + for plan_obj in PLANS: if plan_obj['stripeId'] == plan: plan_found = plan_obj if not plan_found: abort(404) + if require_business_plan and not plan_found['bus_features']: + abort(404) + private_repos = model.get_private_repo_count(user.username) # This is the default response @@ -1619,7 +1642,7 @@ def subscribe_org_api(orgname): plan = request_data['plan'] token = request_data['token'] if 'token' in request_data else None organization = model.get_organization(orgname) - return subscribe(organization, plan, token, BUSINESS_PLANS) + return subscribe(organization, plan, token, PLANS) abort(403) @@ -1743,20 +1766,20 @@ def delete_org_robot(orgname, robot_shortname): def log_view(log): - view = { - 'kind': log.kind.name, - 'metadata': json.loads(log.metadata_json), - 'ip': log.ip, - 'datetime': log.datetime, + view = { + 'kind': log.kind.name, + 'metadata': json.loads(log.metadata_json), + 'ip': log.ip, + 'datetime': log.datetime, + } + + if log.performer: + view['performer'] = { + 'username': log.performer.username, + 'is_robot': log.performer.robot, } - if log.performer: - view['performer'] = { - 'username': log.performer.username, - 'is_robot': log.performer.robot, - } - - return view + return view @@ -1786,12 +1809,14 @@ def org_logs_api(orgname): start_time = request.args.get('starttime', None) end_time = request.args.get('endtime', None) - return get_logs(orgname, start_time, end_time, performer_name=performer_name) + return get_logs(orgname, start_time, end_time, + performer_name=performer_name) abort(403) -def get_logs(namespace, start_time, end_time, performer_name=None, repository=None): +def get_logs(namespace, start_time, end_time, performer_name=None, + repository=None): performer = None if performer_name: performer = model.get_user(performer_name) @@ -1815,7 +1840,8 @@ def get_logs(namespace, start_time, end_time, performer_name=None, repository=No if not end_time: end_time = datetime.today() - logs = model.list_logs(namespace, start_time, end_time, performer = performer, repository=repository) + logs = model.list_logs(namespace, start_time, end_time, performer=performer, + repository=repository) return jsonify({ 'start_time': start_time, 'end_time': end_time, diff --git a/endpoints/webhooks.py b/endpoints/webhooks.py index 9675fe000..f93ef7a70 100644 --- a/endpoints/webhooks.py +++ b/endpoints/webhooks.py @@ -1,19 +1,14 @@ 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 flask import request, make_response 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 app import app from util.invoice import renderInvoiceToHtml from util.email import send_invoice_email + logger = logging.getLogger(__name__) @@ -33,10 +28,10 @@ def stripe_webhook(): # 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) + # 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') From e3504b91deb319d24fd5244bd20b893ad2cdf92b Mon Sep 17 00:00:00 2001 From: yackob03 Date: Thu, 19 Dec 2013 17:10:09 -0500 Subject: [PATCH 02/13] Make sure the right people are required to select a business plan. --- endpoints/api.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/endpoints/api.py b/endpoints/api.py index 19947c62f..7c03e39fe 100644 --- a/endpoints/api.py +++ b/endpoints/api.py @@ -165,7 +165,7 @@ def convert_user_to_organization(): # Subscribe the organization to the new plan. plan = convert_data['plan'] - subscribe(user, plan, None, PLANS) + subscribe(user, plan, None, True) # Require business plans # Convert the user to an organization. model.convert_user_to_organization(user, model.get_user(admin_username)) @@ -1520,7 +1520,7 @@ def subscribe_api(): plan = request_data['plan'] token = request_data['token'] if 'token' in request_data else None user = current_user.db_user() - return subscribe(user, plan, token, PLANS) + return subscribe(user, plan, token, False) # Business features not required def carderror_response(e): @@ -1642,7 +1642,7 @@ def subscribe_org_api(orgname): plan = request_data['plan'] token = request_data['token'] if 'token' in request_data else None organization = model.get_organization(orgname) - return subscribe(organization, plan, token, PLANS) + return subscribe(organization, plan, token, True) # Business plan required abort(403) From 3f062ee60281c6440d9559770324a7d45b52fd14 Mon Sep 17 00:00:00 2001 From: Joseph Schorr Date: Thu, 19 Dec 2013 21:51:46 -0500 Subject: [PATCH 03/13] NOTE: The plans page is still broken - Change the subscribe method to allow for subscribing to the free plan, even when an org - Change the frontend to no longer have different plan groups - Change the frontend to display the proper plans (i.e. hide the deprecated plans unless it is the current plan, etc) --- endpoints/api.py | 4 +- static/directives/plan-manager.html | 9 +- static/js/app.js | 144 +++++++++++++++------------- static/js/controllers.js | 19 ++-- 4 files changed, 91 insertions(+), 85 deletions(-) diff --git a/endpoints/api.py b/endpoints/api.py index 7c03e39fe..256fec8a9 100644 --- a/endpoints/api.py +++ b/endpoints/api.py @@ -1540,7 +1540,7 @@ def subscribe(user, plan, token, require_business_plan): if not plan_found: abort(404) - if require_business_plan and not plan_found['bus_features']: + if require_business_plan and not plan_found['bus_features'] and not plan_found['price'] == 0: abort(404) private_repos = model.get_private_repo_count(user.username) @@ -1679,7 +1679,7 @@ def get_org_subscription(orgname): return jsonify(subscription_view(cus.subscription, private_repos)) return jsonify({ - 'plan': 'bus-free', + 'plan': 'free', 'usedPrivateRepos': private_repos, }) diff --git a/static/directives/plan-manager.html b/static/directives/plan-manager.html index 8ee324d20..41e07c4e0 100644 --- a/static/directives/plan-manager.html +++ b/static/directives/plan-manager.html @@ -32,14 +32,15 @@ - + {{ plan.title }} {{ plan.privateRepos }}
${{ plan.price / 100 }}
-
-
- +
+
+ Existing Plan
- All plans include unlimited public repositories and unlimited sharing. All paid plans have a 14-day free trial. + All plans include unlimited public repositories and unlimited sharing. +
+ All business plans include: organizations and teams with delegated access to the organization.
-
-
+
+
{{ plan.title }}
${{ plan.price/100 }}
{{ plan.privateRepos }} private repositories
{{ plan.audience }}
SSL secured connections
- -
-
-
- -
- Business Plan Pricing -
- -
- All business plans include all of the personal plan features, plus: organizations and teams with delegated access to the organization. All business plans have a 14-day free trial. -
- -
-
-
-
-
{{ plan.title }}
-
${{ plan.price/100 }}
-
{{ plan.privateRepos }} private repositories
-
{{ plan.audience }}
-
SSL secured connections
- +
From c20e7dbcf70126bf79658da343c34d5d92806dc4 Mon Sep 17 00:00:00 2001 From: Joseph Schorr Date: Fri, 20 Dec 2013 22:38:53 -0500 Subject: [PATCH 05/13] - Add some more analytics events - Enable business features for personal users on business plans - Fix a bug in the credit card image view --- endpoints/api.py | 49 +++++++++++++---- endpoints/web.py | 33 +++++++---- static/css/quay.css | 20 +++---- static/directives/billing-invoices.html | 53 ++++++++++++++++++ static/directives/billing-options.html | 2 +- static/js/app.js | 73 +++++++++++++++++++++++-- static/js/controllers.js | 45 ++++++++------- static/partials/new-organization.html | 3 +- static/partials/org-admin.html | 55 +------------------ static/partials/user-admin.html | 29 +++++++--- 10 files changed, 241 insertions(+), 121 deletions(-) create mode 100644 static/directives/billing-invoices.html diff --git a/endpoints/api.py b/endpoints/api.py index 256fec8a9..205fabb0b 100644 --- a/endpoints/api.py +++ b/endpoints/api.py @@ -1601,9 +1601,31 @@ def subscribe(user, plan, token, require_business_plan): return resp +@app.route('/api/user/invoices', methods=['GET']) +@api_login_required +def user_invoices_api(): + user = current_user.db_user() + if not user.stripe_id: + abort(404) + + return get_invoices(user.stripe_id) + + @app.route('/api/organization//invoices', methods=['GET']) @api_login_required def org_invoices_api(orgname): + permission = AdministerOrganizationPermission(orgname) + if permission.can(): + organization = model.get_organization(orgname) + if not organization.stripe_id: + abort(404) + + return get_invoices(organization.stripe_id) + + abort(403) + + +def get_invoices(customer_id): def invoice_view(i): return { 'id': i.id, @@ -1619,18 +1641,10 @@ def org_invoices_api(orgname): 'plan': i.lines.data[0].plan.id if i.lines.data[0].plan else None } - permission = AdministerOrganizationPermission(orgname) - if permission.can(): - organization = model.get_organization(orgname) - if not organization.stripe_id: - abort(404) - - invoices = stripe.Invoice.all(customer=organization.stripe_id, count=12) - return jsonify({ - 'invoices': [invoice_view(i) for i in invoices.data] - }) - - abort(403) + invoices = stripe.Invoice.all(customer=customer_id, count=12) + return jsonify({ + 'invoices': [invoice_view(i) for i in invoices.data] + }) @app.route('/api/organization//plan', methods=['PUT']) @@ -1815,6 +1829,17 @@ def org_logs_api(orgname): abort(403) +@app.route('/api/user/logs', methods=['GET']) +@api_login_required +def user_logs_api(): + performer_name = request.args.get('performer', None) + start_time = request.args.get('starttime', None) + end_time = request.args.get('endtime', None) + + return get_logs(current_user.db_user().username, start_time, end_time, + performer_name=performer_name) + + def get_logs(namespace, start_time, end_time, performer_name=None, repository=None): performer = None diff --git a/endpoints/web.py b/endpoints/web.py index 22fb279a1..c11c9ac3e 100644 --- a/endpoints/web.py +++ b/endpoints/web.py @@ -4,7 +4,7 @@ import stripe from flask import (abort, redirect, request, url_for, render_template, make_response, Response) -from flask.ext.login import login_user, UserMixin +from flask.ext.login import login_user, UserMixin, current_user from flask.ext.principal import identity_changed from urlparse import urlparse @@ -134,21 +134,34 @@ def privacy(): @app.route('/receipt', methods=['GET']) def receipt(): + if not current_user.is_authenticated(): + abort(401) + return + id = request.args.get('id') if id: invoice = stripe.Invoice.retrieve(id) if invoice: - org = model.get_user_or_org_by_customer_id(invoice.customer) - if org and org.organization: - admin_org = AdministerOrganizationPermission(org.username) - if admin_org.can(): - file_data = renderInvoiceToPdf(invoice, org) - return Response(file_data, - mimetype="application/pdf", - headers={"Content-Disposition": - "attachment;filename=receipt.pdf"}) + user_or_org = model.get_user_or_org_by_customer_id(invoice.customer) + + if user_or_org: + if user_or_org.organization: + admin_org = AdministerOrganizationPermission(user_or_org.username) + if not admin_org.can(): + abort(404) + return + else: + if not user_or_org.username == current_user.db_user().username: + abort(404) + return + + file_data = renderInvoiceToPdf(invoice, user_or_org) + return Response(file_data, + mimetype="application/pdf", + headers={"Content-Disposition": "attachment;filename=receipt.pdf"}) abort(404) + def common_login(db_user): if login_user(_LoginWrappedDBUser(db_user.username, db_user)): logger.debug('Successfully signed in as: %s' % db_user.username) diff --git a/static/css/quay.css b/static/css/quay.css index 46c972cdd..b0e981909 100644 --- a/static/css/quay.css +++ b/static/css/quay.css @@ -1919,28 +1919,28 @@ p.editable:hover i { display: inline-block; } -.org-admin .invoice-title { +.billing-invoices-element .invoice-title { padding: 6px; cursor: pointer; } -.org-admin .invoice-status .success { +.billing-invoices-element .invoice-status .success { color: green; } -.org-admin .invoice-status .pending { +.billing-invoices-element .invoice-status .pending { color: steelblue; } -.org-admin .invoice-status .danger { +.billing-invoices-element .invoice-status .danger { color: red; } -.org-admin .invoice-amount:before { +.billing-invoices-element .invoice-amount:before { content: '$'; } -.org-admin .invoice-details { +.billing-invoices-element .invoice-details { margin-left: 10px; margin-bottom: 10px; @@ -1949,21 +1949,21 @@ p.editable:hover i { border-left: 2px solid #eee !important; } -.org-admin .invoice-details td { +.billing-invoices-element .invoice-details td { border: 0px solid transparent !important; } -.org-admin .invoice-details dl { +.billing-invoices-element .invoice-details dl { margin: 0px; } -.org-admin .invoice-details dd { +.billing-invoices-element .invoice-details dd { margin-left: 10px; padding: 6px; margin-bottom: 10px; } -.org-admin .invoice-title:hover { +.billing-invoices-element .invoice-title:hover { color: steelblue; } diff --git a/static/directives/billing-invoices.html b/static/directives/billing-invoices.html new file mode 100644 index 000000000..fc64b1abf --- /dev/null +++ b/static/directives/billing-invoices.html @@ -0,0 +1,53 @@ +
+
+
+
+ +
+ No invoices have been created +
+ +
+ + + + + + + + + + + + + + + + + + + + +
Billing Date/TimeAmount DueStatus
{{ invoice.date * 1000 | date:'medium' }}{{ invoice.amount_due / 100 }} + + Paid - Thank you! + Payment failed + Payment failed - Will retry soon + Payment pending + + + + + +
+
+
Billing Period
+
+ {{ invoice.period_start * 1000 | date:'mediumDate' }} - + {{ invoice.period_end * 1000 | date:'mediumDate' }} +
+
+
+
+ +
diff --git a/static/directives/billing-options.html b/static/directives/billing-options.html index 3b43e6805..8ae5115d5 100644 --- a/static/directives/billing-options.html +++ b/static/directives/billing-options.html @@ -7,7 +7,7 @@
- + ****-****-****-{{ currentCard.last4 }} diff --git a/static/js/app.js b/static/js/app.js index daf28dc24..c7a45f3bb 100644 --- a/static/js/app.js +++ b/static/js/app.js @@ -969,6 +969,59 @@ quayApp.filter('visibleLogFilter', function () { }); +quayApp.directive('billingInvoices', function () { + var directiveDefinitionObject = { + priority: 0, + templateUrl: '/static/directives/billing-invoices.html', + replace: false, + transclude: false, + restrict: 'C', + scope: { + 'organization': '=organization', + 'user': '=user', + 'visible': '=visible' + }, + controller: function($scope, $element, $sce, Restangular) { + $scope.loading = false; + $scope.invoiceExpanded = {}; + + $scope.toggleInvoice = function(id) { + $scope.invoiceExpanded[id] = !$scope.invoiceExpanded[id]; + }; + + var update = function() { + var hasValidUser = !!$scope.user; + var hasValidOrg = !!$scope.organization; + var isValid = hasValidUser || hasValidOrg; + + if (!$scope.visible || !isValid) { + return; + } + + $scope.loading = true; + + var url = getRestUrl('user/invoices'); + if ($scope.organization) { + url = getRestUrl('organization', $scope.organization.name, 'invoices'); + } + + var getInvoices = Restangular.one(url); + getInvoices.get().then(function(resp) { + $scope.invoices = resp.invoices; + $scope.loading = false; + }); + }; + + $scope.$watch('organization', update); + $scope.$watch('user', update); + $scope.$watch('visible', update); + } + }; + + return directiveDefinitionObject; +}); + + quayApp.directive('logsView', function () { var directiveDefinitionObject = { priority: 0, @@ -2112,15 +2165,27 @@ quayApp.run(['$location', '$rootScope', 'Restangular', 'UserService', 'PlanServi // Check if we need to redirect based on a previously chosen plan. PlanService.handleNotedPlan(); - var changeTab = function(activeTab) { + var changeTab = function(activeTab, opt_timeout) { + var checkCount = 0; + $timeout(function() { + if (checkCount > 5) { return; } + checkCount++; + $('a[data-toggle="tab"]').each(function(index) { var tabName = this.getAttribute('data-target').substr(1); - if (tabName == activeTab) { - this.click(); + if (tabName != activeTab) { + return; } + + if (this.clientWidth == 0) { + changeTab(activeTab, 500); + return; + } + + this.click(); }); - }); + }, opt_timeout); }; var resetDefaultTab = function() { diff --git a/static/js/controllers.js b/static/js/controllers.js index 108b00ac0..2054f3e84 100644 --- a/static/js/controllers.js +++ b/static/js/controllers.js @@ -602,8 +602,22 @@ function UserAdminCtrl($scope, $timeout, $location, Restangular, PlanService, Us $('.form-change-pw').popover(); + $scope.logsShown = 0; + $scope.invoicesShown = 0; + + $scope.loadLogs = function() { + if (!$scope.hasPaidBusinessPlan) { return; } + $scope.logsShown++; + }; + + $scope.loadInvoices = function() { + if (!$scope.hasPaidBusinessPlan) { return; } + $scope.invoicesShown++; + }; + $scope.planChanged = function(plan) { $scope.hasPaidPlan = plan && plan.price > 0; + $scope.hasPaidBusinessPlan = PlanService.isOrgCompatible(plan) && plan.price > 0; }; $scope.showConvertForm = function() { @@ -981,11 +995,6 @@ function NewRepoCtrl($scope, $location, $http, $timeout, UserService, Restangula function OrgViewCtrl($rootScope, $scope, Restangular, ApiService, $routeParams) { var orgname = $routeParams.orgname; - $('.info-icon').popover({ - 'trigger': 'hover', - 'html': true - }); - $scope.TEAM_PATTERN = TEAM_PATTERN; $rootScope.title = 'Loading...'; @@ -1053,6 +1062,11 @@ function OrgViewCtrl($rootScope, $scope, Restangular, ApiService, $routeParams) $scope.organization = org; $rootScope.title = orgname; $rootScope.description = 'Viewing organization ' + orgname; + + $('.info-icon').popover({ + 'trigger': 'hover', + 'html': true + }); }); }; @@ -1078,31 +1092,20 @@ function OrgAdminCtrl($rootScope, $scope, Restangular, $routeParams, UserService $scope.membersFound = null; $scope.invoiceLoading = true; $scope.logsShown = 0; + $scope.invoicesShown = 0; $scope.loadLogs = function() { $scope.logsShown++; }; + $scope.loadInvoices = function() { + $scope.invoicesShown++; + }; + $scope.planChanged = function(plan) { $scope.hasPaidPlan = plan && plan.price > 0; }; - $scope.loadInvoices = function() { - if ($scope.invoices) { return; } - $scope.invoiceLoading = true; - - var getInvoices = Restangular.one(getRestUrl('organization', orgname, 'invoices')); - getInvoices.get().then(function(resp) { - $scope.invoiceExpanded = {}; - $scope.invoices = resp.invoices; - $scope.invoiceLoading = false; - }); - }; - - $scope.toggleInvoice = function(id) { - $scope.invoiceExpanded[id] = !$scope.invoiceExpanded[id]; - }; - $scope.loadMembers = function() { if ($scope.membersFound) { return; } $scope.membersLoading = true; diff --git a/static/partials/new-organization.html b/static/partials/new-organization.html index 3033a20b7..79d29e3f9 100644 --- a/static/partials/new-organization.html +++ b/static/partials/new-organization.html @@ -72,7 +72,8 @@
-
diff --git a/static/partials/org-admin.html b/static/partials/org-admin.html index a4843005e..5c506cc6d 100644 --- a/static/partials/org-admin.html +++ b/static/partials/org-admin.html @@ -40,60 +40,7 @@
-
-
-
- -
- No invoices have been created -
- -
- - - - - - - - - - - - - - - - - - - - -
Billing Date/TimeAmount DueStatus
{{ invoice.date * 1000 | date:'medium' }}{{ invoice.amount_due / 100 }} - - Paid - Thank you! - Payment failed - Payment failed - Will retry soon - Payment pending - - - - - -
-
-
Billing Period
-
- {{ invoice.period_start * 1000 | date:'mediumDate' }} - - {{ invoice.period_end * 1000 | date:'mediumDate' }} -
-
Plan
-
- {{ invoice.plan ? plan_map[invoice.plan].title : '(N/A)' }} -
-
-
-
+
diff --git a/static/partials/user-admin.html b/static/partials/user-admin.html index 746af084d..1b05ac382 100644 --- a/static/partials/user-admin.html +++ b/static/partials/user-admin.html @@ -27,16 +27,23 @@
-
+
+ +
+
+
+
@@ -69,10 +76,15 @@
-
+
+ +
+
+
+
@@ -86,11 +98,11 @@
-
- Converting a user account into an organization cannot be undone.
Here be many fire-breathing dragons! +
+ Note: Converting a user account into an organization cannot be undone
- +
@@ -113,7 +125,7 @@ ng-model="org.adminUser" required autofocus> - The username and password for an existing account that will become administrator of the organization + The username and password for the account that will become administrator of the organization
@@ -123,7 +135,8 @@
-
From 44d0505990ffb05cb646ffc09c905b1eb1b4714b Mon Sep 17 00:00:00 2001 From: Joseph Schorr Date: Fri, 20 Dec 2013 22:41:00 -0500 Subject: [PATCH 06/13] getMinimumPlan should ignore deprecated plans --- static/js/app.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/static/js/app.js b/static/js/app.js index c7a45f3bb..155e80f2a 100644 --- a/static/js/app.js +++ b/static/js/app.js @@ -394,7 +394,7 @@ quayApp = angular.module('quay', ['ngRoute', 'chieffancypants.loadingBar', 'rest }; planService.getMinimumPlan = function(privateCount, isBusiness, callback) { - planService.verifyLoaded(function() { + planService.getPlans(function(plans) { for (var i = 0; i < plans.length; i++) { var plan = plans[i]; if (isBusiness && !planService.isOrgCompatible(plan)) { From 0cee1942a1395d51956461ef60ec2a5470d97895 Mon Sep 17 00:00:00 2001 From: Joseph Schorr Date: Fri, 20 Dec 2013 22:45:03 -0500 Subject: [PATCH 07/13] Add a nice tooltip on hover on the plan name in the new repo view --- static/partials/new-repo.html | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/static/partials/new-repo.html b/static/partials/new-repo.html index bafc2dbb1..3f61d9c7e 100644 --- a/static/partials/new-repo.html +++ b/static/partials/new-repo.html @@ -74,7 +74,11 @@
- In order to make this repository private, you’ll need to upgrade your plan to {{ planRequired.title }}. This will cost ${{ planRequired.price / 100 }}/month. + In order to make this repository private, you’ll need to upgrade your plan to + + {{ planRequired.title }} + . + This will cost ${{ planRequired.price / 100 }}/month.
Upgrade now
From 7e4291575546558a26005e18c3441346a2de2240 Mon Sep 17 00:00:00 2001 From: Joseph Schorr Date: Fri, 20 Dec 2013 22:48:47 -0500 Subject: [PATCH 08/13] Add a tooltip to the notification bubble --- static/directives/header-bar.html | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/static/directives/header-bar.html b/static/directives/header-bar.html index 6df3e1fe8..32ee88d23 100644 --- a/static/directives/header-bar.html +++ b/static/directives/header-bar.html @@ -38,7 +38,10 @@ {{ user.username }} - + {{ (user.askForPassword ? 1 : 0) + (overPlan ? 1 : 0) }} From 3950904fb3bcb8e42f47b1b18b100a396feaa5ad Mon Sep 17 00:00:00 2001 From: yackob03 Date: Mon, 23 Dec 2013 14:50:01 -0500 Subject: [PATCH 09/13] Prevent a user from subscribing to a deprecated plan through the API. --- endpoints/api.py | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/endpoints/api.py b/endpoints/api.py index 205fabb0b..b5c4a49ec 100644 --- a/endpoints/api.py +++ b/endpoints/api.py @@ -1537,11 +1537,15 @@ def subscribe(user, plan, token, require_business_plan): if plan_obj['stripeId'] == plan: plan_found = plan_obj - if not plan_found: + if not plan_found or plan_found['deprecated']: + logger.warning('Plan not found or deprecated: %s', plan) abort(404) - if require_business_plan and not plan_found['bus_features'] and not plan_found['price'] == 0: - abort(404) + if (require_business_plan and not plan_found['bus_features'] and not + plan_found['price'] == 0): + logger.warning('Business attempting to subscribe to personal plan: %s', + user.username) + abort(400) private_repos = model.get_private_repo_count(user.username) From 06806c97ddc73185401717583bb42a2ea657c1b9 Mon Sep 17 00:00:00 2001 From: Joseph Schorr Date: Mon, 23 Dec 2013 15:04:52 -0500 Subject: [PATCH 10/13] New plans page --- static/css/quay.css | 155 +++++++++++++++++++++++++++++++++---- static/partials/plans.html | 109 +++++++++++++++++++++----- 2 files changed, 229 insertions(+), 35 deletions(-) diff --git a/static/css/quay.css b/static/css/quay.css index b0e981909..93af07091 100644 --- a/static/css/quay.css +++ b/static/css/quay.css @@ -662,42 +662,163 @@ i.toggle-icon:hover { .plans-list .plan { vertical-align: top; - - padding: 10px; - border: 1px solid #eee; - border-top: 4px solid #94C9F7; font-size: 1.4em; - margin-top: 5px; -} - -.plans-list .plan.business-plan { - border: 1px solid #eee; - border-top: 4px solid #94F794; } .plans-list .plan.bus-small { - border: 1px solid #ddd; - border-top: 4px solid #47A447; - margin-top: 0px; - font-size: 1.6em; + border-top: 6px solid #46ac39; + margin-top: -10px; +} + +.plans-list .plan.bus-small .plan-box { + background: black !important; } .plans-list .plan:last-child { margin-right: 0px; } +.plans-list .plan .plan-box { + background: #444; + padding: 10px; + color: white; +} + .plans-list .plan .plan-title { + text-transform: uppercase; + padding-top: 25px; + padding-bottom: 20px; margin-bottom: 10px; - display: block; font-weight: bold; + border-bottom: 1px solid #eee; +} + +.visible-sm-inline { + display: none; +} + +.hidden-sm-inline { + display: inline; +} + +@media (max-width: 991px) and (min-width: 768px) { + .visible-sm-inline { + display: inline; + } + + .hidden-sm-inline { + display: none; + } +} + +.plans-list .plan-box .description { + color: white; + margin-top: 6px; + font-size: 12px !important; +} + +.plans-list .plan button { + margin-top: 6px; + margin-bottom: 6px; +} + +.plans-list .plan.bus-small button { + font-size: 1em; +} + +.plans-list .features-bar { + padding-top: 248px; +} + +.plans-list .features-bar .feature .count { + padding: 10px; +} + +.plans-list .features-bar .feature { + height: 43px; + text-align: right; + white-space: nowrap; +} + +.plans-list .features-bar .feature .feature-tooltip { + border-bottom: 1px dotted black; + cursor: default; +} + +.plans-list .features-bar .feature i { + margin-left: 16px; + float: right; + width: 16px; + font-size: 16px; + text-align: center; + margin-top: 2px; +} + +.plans-list .plan .features { + padding: 6px; + background: #eee; + padding-bottom: 0px; +} + +.plans-list .plan .feature { + text-align: center; + padding: 10px; + border-bottom: 1px solid #ddd; + font-size: 14px; +} + +.plans-list .plan .feature:after { + content: ""; + border-radius: 50%; + display: inline-block; + width: 16px; + height: 16px; +} + +.plans-list .plan .visible-xs .feature { + text-align: left; +} + +.plans-list .plan .visible-xs .feature:after { + float: left; + margin-right: 10px; +} + +.plans-list .plan .feature.notpresent { + color: #ccc; +} + +.plans-list .plan .feature.present:after { + background: #428bca; +} + +.plans-list .plan.business-plan .feature.present:after { + background: #46ac39; +} + +.plans-list .plan .count, .plans-list .features-bar .count { + background: white; + border-bottom: 0px; + text-align: center !important; +} + +.plans-list .plan .count b, .plans-list .features-bar .count b { + font-size: 1.5em; + display: block; +} + +.plans-list .plan .feature:last-child { + border-bottom: 0px; +} + +.plans-list .plan-price { + margin-bottom: 10px; } .plan-price { - margin-bottom: 10px; display: block; font-weight: bold; font-size: 1.8em; - position: relative; } diff --git a/static/partials/plans.html b/static/partials/plans.html index ab90bd927..400c646c7 100644 --- a/static/partials/plans.html +++ b/static/partials/plans.html @@ -1,25 +1,98 @@
-
- Plans & Pricing -
- -
- All plans include unlimited public repositories and unlimited sharing. -
- All business plans include: organizations and teams with delegated access to the organization. -
-
-
-
+
+ + +
+
-
{{ plan.title }}
-
${{ plan.price/100 }}
-
{{ plan.privateRepos }} private repositories
-
{{ plan.audience }}
-
SSL secured connections
+
+
{{ plan.title }}
+
${{ plan.price/100 }}
+ +
{{ plan.audience }}
+
+ + + +
+
{{ plan.privateRepos }} private repositories
+
Unlimited Public Repositories
+
SSL Encryption
+
Robot accounts
+
Dockerfile Build
+
Teams
+
Logging
+
Invoice History
+
+ + ng-click="buyNow(plan.stripeId)">Start Free Trial +
From cb969989169531930624eb7b5da8db20dab6e4c7 Mon Sep 17 00:00:00 2001 From: yackob03 Date: Mon, 23 Dec 2013 16:37:10 -0500 Subject: [PATCH 11/13] Tweaks to the new plans page. --- static/css/quay.css | 5 ++++- static/partials/plans.html | 19 ++++++++++++++----- 2 files changed, 18 insertions(+), 6 deletions(-) diff --git a/static/css/quay.css b/static/css/quay.css index 93af07091..b4efa9230 100644 --- a/static/css/quay.css +++ b/static/css/quay.css @@ -800,6 +800,8 @@ i.toggle-icon:hover { background: white; border-bottom: 0px; text-align: center !important; + font-size: 14px; + padding: 10px; } .plans-list .plan .count b, .plans-list .features-bar .count b { @@ -846,7 +848,8 @@ i.toggle-icon:hover { .plans-list .plan .description { font-size: 1em; font-size: 16px; - margin-bottom: 10px; + height: 34px; + } .plans-list .plan .smaller { diff --git a/static/partials/plans.html b/static/partials/plans.html index 400c646c7..02214c485 100644 --- a/static/partials/plans.html +++ b/static/partials/plans.html @@ -2,9 +2,8 @@
@@ -69,7 +76,7 @@
-
{{ plan.privateRepos }} private repositories
+
{{ plan.privateRepos }} private repositories
Unlimited Public Repositories
SSL Encryption
Robot accounts
@@ -88,6 +96,7 @@
Teams
Logging
Invoice History
+
14-Day Free Trial
diff --git a/static/partials/plans.html b/static/partials/plans.html index 02214c485..da49bf12b 100644 --- a/static/partials/plans.html +++ b/static/partials/plans.html @@ -61,7 +61,7 @@ 14-Day Free Trial 14-Day Trial - +