From 85eb585a851d22c58902b78d3e3449b061b79662 Mon Sep 17 00:00:00 2001 From: jakedt Date: Thu, 13 Mar 2014 15:19:49 -0400 Subject: [PATCH] Port most of the user related apis. --- endpoints/api/__init__.py | 4 +- endpoints/api/legacy.py | 18 +- endpoints/api/repository.py | 1 + endpoints/api/subscribe.py | 95 ++++++++++ endpoints/api/user.py | 354 ++++++++++++++++++++++++++++++++++++ 5 files changed, 461 insertions(+), 11 deletions(-) create mode 100644 endpoints/api/subscribe.py create mode 100644 endpoints/api/user.py diff --git a/endpoints/api/__init__.py b/endpoints/api/__init__.py index 5fd3865b3..c411cf091 100644 --- a/endpoints/api/__init__.py +++ b/endpoints/api/__init__.py @@ -58,6 +58,7 @@ def method_metadata(func, name): nickname = partial(add_method_metadata, 'nickname') +internal_api = add_method_metadata('internal_api', True) def query_param(name, help_str, type=reqparse.text_type, default=None, @@ -173,4 +174,5 @@ def log_action(kind, user_or_orgname, metadata={}, repo=None): import endpoints.api.legacy import endpoints.api.repository -import endpoints.api.discovery \ No newline at end of file +import endpoints.api.discovery +import endpoints.api.user \ No newline at end of file diff --git a/endpoints/api/legacy.py b/endpoints/api/legacy.py index dcdc0e30f..60917ebea 100644 --- a/endpoints/api/legacy.py +++ b/endpoints/api/legacy.py @@ -160,6 +160,7 @@ def user_view(user): } +# Ported @api_bp.route('/user/', methods=['GET']) @internal_api_call def get_logged_in_user(): @@ -173,6 +174,7 @@ def get_logged_in_user(): return jsonify(user_view(user)) +# Ported @api_bp.route('/user/private', methods=['GET']) @api_login_required @internal_api_call @@ -194,6 +196,7 @@ def get_user_private_allowed(): }) +# Ported @api_bp.route('/user/convert', methods=['POST']) @api_login_required @internal_api_call @@ -225,6 +228,7 @@ def convert_user_to_organization(): return conduct_signin(admin_username, admin_password) +# Ported @api_bp.route('/user/', methods=['PUT']) @api_login_required @internal_api_call @@ -259,6 +263,7 @@ def change_user_details(): return jsonify(user_view(user)) +# Ported @api_bp.route('/user/', methods=['POST']) @internal_api_call def create_new_user(): @@ -278,6 +283,7 @@ def create_new_user(): return request_error(exception=ex) +# Ported @api_bp.route('/signin', methods=['POST']) @internal_api_call def signin_user(): @@ -313,6 +319,7 @@ def conduct_signin(username_or_email, password): return response +# Ported @api_bp.route("/signout", methods=['POST']) @api_login_required @internal_api_call @@ -322,6 +329,7 @@ def logout(): return jsonify({'success': True}) +# Ported @api_bp.route("/recovery", methods=['POST']) @internal_api_call def request_recovery_email(): @@ -331,16 +339,6 @@ def request_recovery_email(): return make_response('Created', 201) -@api_bp.route('/users/', methods=['GET']) -@api_login_required -def get_matching_users(prefix): - users = model.get_matching_users(prefix) - - return jsonify({ - 'users': [user.username for user in users] - }) - - @api_bp.route('/entities/', methods=['GET']) @api_login_required def get_matching_entities(prefix): diff --git a/endpoints/api/repository.py b/endpoints/api/repository.py index b7546159d..7710c8c0e 100644 --- a/endpoints/api/repository.py +++ b/endpoints/api/repository.py @@ -236,6 +236,7 @@ class Repository(RepositoryParamResource): @require_repo_admin @nickname('deleteRepository') def delete(self, namespace, repository): + """ Delete a repository. """ model.purge_repository(namespace, repository) log_action('delete_repo', namespace, {'repo': repository, 'namespace': namespace}) diff --git a/endpoints/api/subscribe.py b/endpoints/api/subscribe.py new file mode 100644 index 000000000..02c9e2a09 --- /dev/null +++ b/endpoints/api/subscribe.py @@ -0,0 +1,95 @@ +import logging +import stripe + +from endpoints.api import request_error, log_action +from data import model +from data.plans import PLANS +from util.http import abort + + +logger = logging.getLogger(__name__) + + +def carderror_response(exc): + return {'carderror': exc.message}, 402 + + +def subscription_view(stripe_subscription, used_repos): + return { + 'currentPeriodStart': stripe_subscription.current_period_start, + 'currentPeriodEnd': stripe_subscription.current_period_end, + 'plan': stripe_subscription.plan.id, + 'usedPrivateRepos': used_repos, + } + + +def subscribe(user, plan, token, require_business_plan): + plan_found = None + for plan_obj in PLANS: + if plan_obj['stripeId'] == plan: + plan_found = plan_obj + + 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): + logger.warning('Business attempting to subscribe to personal plan: %s', + user.username) + return request_error(message='No matching plan found') + + private_repos = model.get_private_repo_count(user.username) + + # This is the default response + response_json = { + 'plan': plan, + 'usedPrivateRepos': private_repos, + } + status_code = 200 + + 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 customer and plan + # simultaneously + card = token + + try: + cus = stripe.Customer.create(email=user.email, plan=plan, card=card) + user.stripe_id = cus.id + user.save() + log_action('account_change_plan', user.username, {'plan': plan}) + except stripe.CardError as e: + return carderror_response(e) + + response_json = subscription_view(cus.subscription, private_repos) + status_code = 201 + + else: + # Change the plan + cus = stripe.Customer.retrieve(user.stripe_id) + + if plan_found['price'] == 0: + if cus.subscription is not None: + # We only have to cancel the subscription if they actually have one + cus.cancel_subscription() + cus.save() + log_action('account_change_plan', user.username, {'plan': plan}) + + else: + # User may have been a previous customer who is resubscribing + if token: + cus.card = token + + cus.plan = plan + + try: + cus.save() + except stripe.CardError as e: + return carderror_response(e) + + response_json = subscription_view(cus.subscription, private_repos) + log_action('account_change_plan', user.username, {'plan': plan}) + + return response_json, status_code diff --git a/endpoints/api/user.py b/endpoints/api/user.py new file mode 100644 index 000000000..1c0bc6c7e --- /dev/null +++ b/endpoints/api/user.py @@ -0,0 +1,354 @@ +import logging +import stripe + +from flask import request +from flask.ext.restful import abort +from flask.ext.login import logout_user +from flask.ext.principal import identity_changed, AnonymousIdentity + +from app import app +from endpoints.api import (ApiResource, nickname, resource, validate_json_request, request_error, + log_action) +from endpoints.api.subscribe import subscribe +from endpoints.common import common_login +from data import model +from data.plans import get_plan +from auth.permissions import AdministerOrganizationPermission, CreateRepositoryPermission +from auth.auth_context import get_authenticated_user +from util.gravatar import compute_hash +from util.email import (send_confirmation_email, send_recovery_email, + send_change_email) + + +logger = logging.getLogger(__name__) + + +def user_view(user): + def org_view(o): + admin_org = AdministerOrganizationPermission(o.username) + return { + 'name': o.username, + 'gravatar': compute_hash(o.email), + 'is_org_admin': admin_org.can(), + 'can_create_repo': admin_org.can() or CreateRepositoryPermission(o.username).can(), + 'preferred_namespace': not (o.stripe_id is None) + } + + organizations = model.get_user_organizations(user.username) + + def login_view(login): + return { + 'service': login.service.name, + 'service_identifier': login.service_ident, + } + + logins = model.list_federated_logins(user) + + return { + 'verified': user.verified, + 'anonymous': False, + 'username': user.username, + 'email': user.email, + 'gravatar': compute_hash(user.email), + 'askForPassword': user.password_hash is None, + 'organizations': [org_view(o) for o in organizations], + 'logins': [login_view(login) for login in logins], + 'can_create_repo': True, + 'invoice_email': user.invoice_email, + 'preferred_namespace': not (user.stripe_id is None) + } + + +@resource('/v1/user/') +class User(ApiResource): + """ Operations related to users. """ + schemas = { + 'NewUser': { + 'id': 'NewUser', + 'type': 'object', + 'description': 'Fields which must be specified for a new user.', + 'required': True, + 'properties': { + 'username': { + 'type': 'string', + 'description': 'The user\'s username', + 'required': True, + }, + 'password': { + 'type': 'string', + 'description': 'The user\'s password', + 'required': True, + }, + 'email': { + 'type': 'string', + 'description': 'The user\'s email address', + 'required': True, + }, + } + }, + 'UpdateUser': { + 'id': 'UpdateUser', + 'type': 'object', + 'description': 'Fields which can be updated in a user.', + 'required': True, + 'properties': { + 'password': { + 'type': 'string', + 'description': 'The user\'s password', + }, + 'invoice_email': { + 'type': 'boolean', + 'description': 'Whether the user desires to receive an invoice email.', + }, + 'email': { + 'type': 'string', + 'description': 'The user\'s email address', + }, + }, + }, + } + + @nickname('getLoggedInUser') + def get(self): + """ Get user information for the authenticated user. """ + if get_authenticated_user() is None: + return {'anonymous': True} + + user = get_authenticated_user() + if not user or user.organization: + return {'anonymous': True} + + return user_view(user) + + @nickname('changeUserDetails') + @validate_json_request('UpdateUser') + def put(self): + """ Update a users details such as password or email. """ + user = get_authenticated_user() + user_data = request.get_json() + + try: + if 'password' in user_data: + logger.debug('Changing password for user: %s', user.username) + log_action('account_change_password', 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']) + + if 'email' in user_data and user_data['email'] != user.email: + new_email = user_data['email'] + if model.find_user_by_email(new_email): + # Email already used. + return request_error(message='E-mail address already used') + + logger.debug('Sending email to change email address for user: %s', + user.username) + code = model.create_confirm_email_code(user, new_email=new_email) + send_change_email(user.username, user_data['email'], code.code) + + except model.InvalidPasswordException, ex: + return request_error(exception=ex) + + return user_view(user) + + @nickname('createNewUser') + @validate_json_request('NewUser') + def post(self): + user_data = request.get_json() + + existing_user = model.get_user(user_data['username']) + if existing_user: + return request_error(message='The username already exists') + + try: + new_user = model.create_user(user_data['username'], user_data['password'], + user_data['email']) + code = model.create_confirm_email_code(new_user) + send_confirmation_email(new_user.username, new_user.email, code.code) + return 'Created', 201 + except model.DataModelException as ex: + return request_error(exception=ex) + + +@resource('/v1/user/private') +class PrivateRepositories(ApiResource): + """ Operations dealing with the available count of private repositories. """ + @nickname('getUserPrivateAllowed') + def get(self): + """ Get the number of private repos this user has, and whether they are allowed to create more. + """ + user = get_authenticated_user() + private_repos = model.get_private_repo_count(user.username) + repos_allowed = 0 + + if user.stripe_id: + cus = stripe.Customer.retrieve(user.stripe_id) + if cus.subscription: + plan = get_plan(cus.subscription.plan.id) + if plan: + repos_allowed = plan['privateRepos'] + + return { + 'privateCount': private_repos, + 'privateAllowed': (private_repos < repos_allowed) + } + + +def conduct_signin(username_or_email, password): + needs_email_verification = False + invalid_credentials = False + + verified = model.verify_user(username_or_email, password) + if verified: + if common_login(verified): + return {'success': True} + else: + needs_email_verification = True + + else: + invalid_credentials = True + + return { + 'needsEmailVerification': needs_email_verification, + 'invalidCredentials': invalid_credentials, + }, 403 + + +@resource('/v1/user/convert') +class ConvertToOrganization(ApiResource): + """ Operations for converting a user to an organization. """ + schemas = { + 'ConvertUser': { + 'id': 'ConvertUser', + 'type': 'object', + 'description': 'Information required to convert a user to an organization.', + 'required': True, + 'properties': { + 'username': { + 'type': 'string', + 'description': 'The user\'s username', + 'required': True, + }, + 'password': { + 'type': 'string', + 'description': 'The user\'s password', + 'required': True, + }, + 'email': { + 'type': 'string', + 'description': 'The user\'s email address', + 'required': True, + }, + }, + }, + } + + @nickname('convertUserToOrganization') + @validate_json_request('ConvertUser') + def post(self): + """ Convert the user to an organization. """ + user = get_authenticated_user() + convert_data = request.get_json() + + # Ensure that the new admin user is the not user being converted. + admin_username = convert_data['adminUser'] + if admin_username == user.username: + return request_error(reason='invaliduser', + message='The admin user is not valid') + + # Ensure that the sign in credentials work. + admin_password = convert_data['adminPassword'] + if not model.verify_user(admin_username, admin_password): + return request_error(reason='invaliduser', + message='The admin user credentials are not valid') + + # Subscribe the organization to the new plan. + plan = convert_data['plan'] + 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)) + log_action('account_convert', user.username) + + # And finally login with the admin credentials. + return conduct_signin(admin_username, admin_password) + + +@resource('/v1/signin') +class Signin(ApiResource): + """ Operations for signing in the user. """ + schemas = { + 'SigninUser': { + 'id': 'SigninUser', + 'type': 'object', + 'description': 'Information required to sign in a user.', + 'required': True, + 'properties': { + 'username': { + 'type': 'string', + 'description': 'The user\'s username', + 'required': True, + }, + 'password': { + 'type': 'string', + 'description': 'The user\'s password', + 'required': True, + }, + }, + }, + } + + @nickname('signinUser') + @validate_json_request('SigninUser') + def post(self): + """ Sign in the user with the specified credentials. """ + signin_data = request.get_json() + if not signin_data: + abort(404) + + username = signin_data['username'] + password = signin_data['password'] + + return conduct_signin(username, password) + + +@resource('/v1/signout') +class Signout(ApiResource): + """ Resource for signing out users. """ + @nickname('logout') + def post(self): + """ Request that the current user be signed out. """ + logout_user() + identity_changed.send(app, identity=AnonymousIdentity()) + return {'success': True} + + +@resource("/v1/recovery") +class Recovery(ApiResource): + """ Resource for requesting a password recovery email. """ + schemas = { + 'RequestRecovery': { + 'id': 'RequestRecovery', + 'type': 'object', + 'description': 'Information required to sign in a user.', + 'required': True, + 'properties': { + 'email': { + 'type': 'string', + 'description': 'The user\'s email address', + 'required': True, + }, + }, + }, + } + + @nickname('requestRecoveryEmail') + @validate_json_request('RequestRecovery') + def post(self): + """ Request a password recovery email.""" + email = request.get_json()['email'] + code = model.create_reset_password_email_code(email) + send_recovery_email(email, code.code) + return 'Created', 201