diff --git a/endpoints/api/__init__.py b/endpoints/api/__init__.py index a281b075a..5fd2d7845 100644 --- a/endpoints/api/__init__.py +++ b/endpoints/api/__init__.py @@ -181,6 +181,8 @@ import endpoints.api.repository import endpoints.api.repotoken import endpoints.api.search import endpoints.api.tag +import endpoints.api.team import endpoints.api.trigger +import endpoints.api.organization import endpoints.api.user import endpoints.api.webhook diff --git a/endpoints/api/legacy.py b/endpoints/api/legacy.py index 3f811f958..e3f1b3bf1 100644 --- a/endpoints/api/legacy.py +++ b/endpoints/api/legacy.py @@ -407,6 +407,7 @@ def team_view(orgname, team): } +# Ported @api_bp.route('/organization/', methods=['POST']) @api_login_required @internal_api_call @@ -454,6 +455,7 @@ def org_view(o, teams): return view +# Ported @api_bp.route('/organization/<orgname>', methods=['GET']) @api_login_required def get_organization(orgname): @@ -470,6 +472,7 @@ def get_organization(orgname): abort(403) +# Ported @api_bp.route('/organization/<orgname>', methods=['PUT']) @api_login_required @org_api_call('change_user_details') @@ -718,6 +721,7 @@ def get_organization_member(orgname, membername): abort(403) +# Ported @api_bp.route('/organization/<orgname>/private', methods=['GET']) @api_login_required @internal_api_call @@ -758,6 +762,7 @@ def member_view(member): } +# Ported @api_bp.route('/organization/<orgname>/team/<teamname>', methods=['PUT', 'POST']) @api_login_required @@ -804,6 +809,7 @@ def update_organization_team(orgname, teamname): abort(403) +# Ported @api_bp.route('/organization/<orgname>/team/<teamname>', methods=['DELETE']) @api_login_required @@ -817,6 +823,7 @@ def delete_organization_team(orgname, teamname): abort(403) +# Ported @api_bp.route('/organization/<orgname>/team/<teamname>/members', methods=['GET']) @api_login_required @@ -840,6 +847,7 @@ def get_organization_team_members(orgname, teamname): abort(403) +# Ported @api_bp.route('/organization/<orgname>/team/<teamname>/members/<membername>', methods=['PUT', 'POST']) @api_login_required @@ -869,6 +877,7 @@ def update_organization_team_member(orgname, teamname, membername): abort(403) +# Ported @api_bp.route('/organization/<orgname>/team/<teamname>/members/<membername>', methods=['DELETE']) @api_login_required diff --git a/endpoints/api/organization.py b/endpoints/api/organization.py new file mode 100644 index 000000000..235f36ebf --- /dev/null +++ b/endpoints/api/organization.py @@ -0,0 +1,184 @@ +import logging +import stripe + +from flask import request +from flask.ext.restful import abort + +from endpoints.api import resource, nickname, ApiResource, validate_json_request, request_error +from endpoints.api.team import team_view +from auth.permissions import (AdministerOrganizationPermission, OrganizationMemberPermission, + CreateRepositoryPermission) +from auth.auth_context import get_authenticated_user +from data import model +from data.plans import get_plan +from util.gravatar import compute_hash + + +logger = logging.getLogger(__name__) + + + +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 + + +@resource('/v1/organization/', methods=['POST']) +class OrganizationList(ApiResource): + """ Resource for creating organizations. """ + schemas = { + 'NewOrg': { + 'id': 'NewOrg', + 'type': 'object', + 'description': 'Description of a new organization.', + 'required': True, + 'properties': { + 'name': { + 'type': 'string', + 'description': 'Organization username', + 'required': True, + }, + 'email': { + 'type': 'string', + 'description': 'Organization contact email', + 'required': True, + }, + }, + }, + } + + @nickname('createOrganization') + @validate_json_request('NewOrg') + def post(self): + """ Create a new organization. """ + org_data = request.get_json() + existing = None + + try: + existing = model.get_organization(org_data['name']) + except model.InvalidOrganizationException: + pass + + if not existing: + try: + existing = model.get_user(org_data['name']) + except model.InvalidUserException: + pass + + if existing: + msg = 'A user or organization with this name already exists' + return request_error(message=msg) + + try: + model.create_organization(org_data['name'], org_data['email'], get_authenticated_user()) + return 'Created', 201 + except model.DataModelException as ex: + return request_error(exception=ex) + + +@resource('/v1/organization/<orgname>', methods=['GET']) +class Organization(ApiResource): + """ Resource for managing organizations. """ + schemas = { + 'UpdateOrg': { + 'id': 'UpdateOrg', + 'type': 'object', + 'description': 'Description of updates for an existing organization', + 'required': True, + 'properties': { + 'email': { + 'type': 'string', + 'description': 'Organization contact email', + }, + 'invoice_email': { + 'type': 'boolean', + 'description': 'Whether the organization desires to receive emails for invoices', + }, + }, + }, + } + @nickname('getOrganization') + def get(self, orgname): + """ Get the details for the specified organization """ + permission = OrganizationMemberPermission(orgname) + if permission.can(): + try: + org = model.get_organization(orgname) + except model.InvalidOrganizationException: + abort(404) + + teams = model.get_teams_within_org(org) + return org_view(org, teams) + + abort(403) + + # @org_api_call('change_user_details') + @nickname('changeOrganizationDetails') + @validate_json_request('UpdateOrg') + def put(self, orgname): + """ Change the details for the specified organization. """ + 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']) + + if 'email' in org_data and org_data['email'] != org.email: + new_email = org_data['email'] + if model.find_user_by_email(new_email): + return request_error(message='E-mail address already used') + + logger.debug('Changing email address for organization: %s', org.username) + model.update_email(org, new_email) + + teams = model.get_teams_within_org(org) + return org_view(org, teams) + + +@resource('/v1/organization/<orgname>/private') +class OrgPrivateRepositories(ApiResource): + """ Custom verb to compute whether additional private repositories are available. """ + # @org_api_call('get_user_private_allowed') + @nickname('getOrganizationPrivateAllowed') + def get(self, orgname): + permission = CreateRepositoryPermission(orgname) + if permission.can(): + organization = model.get_organization(orgname) + private_repos = model.get_private_repo_count(organization.username) + data = { + 'privateAllowed': False + } + + if organization.stripe_id: + cus = stripe.Customer.retrieve(organization.stripe_id) + if cus.subscription: + repos_allowed = 0 + plan = get_plan(cus.subscription.plan.id) + if plan: + repos_allowed = plan['privateRepos'] + + data['privateAllowed'] = (private_repos < repos_allowed) + + + if AdministerOrganizationPermission(orgname).can(): + data['privateCount'] = private_repos + + return data + + abort(403) \ No newline at end of file diff --git a/endpoints/api/repotoken.py b/endpoints/api/repotoken.py index c674f463f..ca806ad3e 100644 --- a/endpoints/api/repotoken.py +++ b/endpoints/api/repotoken.py @@ -31,7 +31,7 @@ class RepositoryTokenList(RepositoryParamResource): 'properties': { 'friendlyName': { 'type': 'string', - 'description': 'Friendly name to help identify the token.', + 'description': 'Friendly name to help identify the token', 'required': True, }, }, @@ -72,7 +72,7 @@ class RepositoryToken(RepositoryParamResource): 'TokenPermission': { 'id': 'TokenPermission', 'type': 'object', - 'description': 'Description of a token permission.', + 'description': 'Description of a token permission', 'required': True, 'properties': { 'role': { diff --git a/endpoints/api/team.py b/endpoints/api/team.py new file mode 100644 index 000000000..147b87b98 --- /dev/null +++ b/endpoints/api/team.py @@ -0,0 +1,178 @@ +from flask import request +from flask.ext.restful import abort + +from endpoints.api import (resource, nickname, ApiResource, validate_json_request, request_error, + log_action) +from auth.permissions import AdministerOrganizationPermission, ViewTeamPermission +from auth.auth_context import get_authenticated_user +from data import model + + +def team_view(orgname, team): + view_permission = ViewTeamPermission(orgname, team.name) + role = model.get_team_org_role(team).name + return { + 'id': team.id, + 'name': team.name, + 'description': team.description, + 'can_view': view_permission.can(), + 'role': role + } + +def member_view(member): + return { + 'name': member.username, + 'kind': 'user', + 'is_robot': member.robot, + } + + +@resource('/v1/organization/<orgname>/team/<teamname>', + methods=['PUT', 'POST']) +class OrganizationTeam(ApiResource): + """ Resource for manging an organization's teams. """ + schemas = { + 'TeamDescription': { + 'id': 'TeamDescription', + 'type': 'object', + 'description': 'Description of a team', + 'required': True, + 'properties': { + 'role': { + 'type': 'string', + 'description': 'Org wide permissions that should apply to the team', + 'required': True, + 'enum': [ + 'member', + 'creator', + 'admin', + ], + }, + 'description': { + 'type': 'string', + 'description': 'Markdown description for the team', + 'required': True, + }, + }, + }, + } + + @nickname('updateOrganizationTeam') + @validate_json_request('TeamDescription') + def put(self, orgname, teamname): + """ Update the org-wide permission for the specified team. """ + edit_permission = AdministerOrganizationPermission(orgname) + if edit_permission.can(): + team = None + + details = request.get_json() + is_existing = False + try: + team = model.get_organization_team(orgname, teamname) + is_existing = True + except model.InvalidTeamException: + # Create the new team. + description = details['description'] if 'description' in details else '' + role = details['role'] if 'role' in details else 'member' + + org = model.get_organization(orgname) + team = model.create_team(teamname, org, role, description) + log_action('org_create_team', orgname, {'team': teamname}) + + if is_existing: + 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}) + + 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'], + get_authenticated_user().username) + log_action('org_set_team_role', orgname, {'team': teamname, 'role': details['role']}) + + return team_view(orgname, team), 200 # 201 for post + + abort(403) + + @nickname('deleteOrganizationTeam') + def delete(self, orgname, teamname): + """ Delete the specified team. """ + permission = AdministerOrganizationPermission(orgname) + if permission.can(): + model.remove_team(orgname, teamname, get_authenticated_user().username) + log_action('org_delete_team', orgname, {'team': teamname}) + return 'Deleted', 204 + + abort(403) + + +@resource('/v1/organization/<orgname>/team/<teamname>/members') +class TeamMemberList(ApiResource): + """ Resource for managing the list of members for a team. """ + @nickname('getOrganizationTeamMembers') + def get(self, orgname, teamname): + """ Retrieve the list of members for the specified team. """ + view_permission = ViewTeamPermission(orgname, teamname) + edit_permission = AdministerOrganizationPermission(orgname) + + if view_permission.can(): + team = None + try: + team = model.get_organization_team(orgname, teamname) + except model.InvalidTeamException: + abort(404) + + members = model.get_organization_team_members(team.id) + return { + 'members': {m.username : member_view(m) for m in members}, + 'can_edit': edit_permission.can() + } + + abort(403) + + +@resource('/v1/organization/<orgname>/team/<teamname>/members/<membername>') +class TeamMember(ApiResource): + """ Resource for managing individual members of a team. """ + @nickname('updateOrganizationTeamMember') + def put(self, orgname, teamname, membername): + """ Add a member to an existing team. """ + permission = AdministerOrganizationPermission(orgname) + if permission.can(): + team = None + user = None + + # Find the team. + try: + team = model.get_organization_team(orgname, teamname) + except model.InvalidTeamException: + abort(404) + + # Find the user. + user = model.get_user(membername) + if not user: + return request_error(message='Unknown user') + + # Add the user to the team. + model.add_user_to_team(user, team) + log_action('org_add_team_member', orgname, {'member': membername, 'team': teamname}) + return member_view(user) + + abort(403) + + @nickname('deleteOrganizationTeamMember') + def delete(self, orgname, teamname, membername): + """ Delete an existing member of a team. """ + permission = AdministerOrganizationPermission(orgname) + if permission.can(): + # Remote the user from the team. + invoking_user = get_authenticated_user().username + model.remove_user_from_team(orgname, teamname, membername, invoking_user) + log_action('org_remove_team_member', orgname, {'member': membername, 'team': teamname}) + return 'Deleted', 204 + + abort(403)