Port most of the user related apis.
This commit is contained in:
parent
0e3fe8f3b1
commit
85eb585a85
5 changed files with 461 additions and 11 deletions
|
@ -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
|
||||
import endpoints.api.discovery
|
||||
import endpoints.api.user
|
|
@ -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/<prefix>', 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/<prefix>', methods=['GET'])
|
||||
@api_login_required
|
||||
def get_matching_entities(prefix):
|
||||
|
|
|
@ -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})
|
||||
|
|
95
endpoints/api/subscribe.py
Normal file
95
endpoints/api/subscribe.py
Normal file
|
@ -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
|
354
endpoints/api/user.py
Normal file
354
endpoints/api/user.py
Normal file
|
@ -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
|
Reference in a new issue