Merge branch 'swaggerlikeus' of https://bitbucket.org/yackob03/quay into swaggerlikeus

This commit is contained in:
Joseph Schorr 2014-03-14 18:57:35 -04:00
commit 767ab1085a
17 changed files with 1357 additions and 28 deletions

View file

@ -58,7 +58,8 @@ def method_metadata(func, name):
nickname = partial(add_method_metadata, 'nickname')
internal_api = add_method_metadata('internal_api', True)
related_user_resource = partial(add_method_metadata, 'related_user_resource')
internal_only = add_method_metadata('internal', True)
def query_param(name, help_str, type=reqparse.text_type, default=None,
@ -173,14 +174,20 @@ def log_action(kind, user_or_orgname, metadata={}, repo=None):
import endpoints.api.legacy
import endpoints.api.billing
import endpoints.api.build
import endpoints.api.discovery
import endpoints.api.image
import endpoints.api.logs
import endpoints.api.organization
import endpoints.api.permission
import endpoints.api.prototype
import endpoints.api.repository
import endpoints.api.repotoken
import endpoints.api.robot
import endpoints.api.search
import endpoints.api.tag
import endpoints.api.team
import endpoints.api.trigger
import endpoints.api.user
import endpoints.api.webhook

316
endpoints/api/billing.py Normal file
View file

@ -0,0 +1,316 @@
import logging
import stripe
from flask import request
from flask.ext.restful import abort
from endpoints.api import (resource, nickname, ApiResource, validate_json_request, log_action,
related_user_resource, internal_only)
from endpoints.api.subscribe import subscribe, subscription_view
from auth.permissions import AdministerOrganizationPermission
from auth.auth_context import get_authenticated_user
from data import model
from data.plans import PLANS
def carderror_response(e):
return {'carderror': e.message}, 402
def get_card(user):
card_info = {
'is_valid': False
}
if user.stripe_id:
cus = stripe.Customer.retrieve(user.stripe_id)
if cus and cus.default_card:
# Find the default card.
default_card = None
for card in cus.cards.data:
if card.id == cus.default_card:
default_card = card
break
if default_card:
card_info = {
'owner': default_card.name,
'type': default_card.type,
'last4': default_card.last4
}
return {'card': card_info}
def set_card(user, token):
if user.stripe_id:
cus = stripe.Customer.retrieve(user.stripe_id)
if cus:
try:
cus.card = token
cus.save()
except stripe.CardError as exc:
return carderror_response(exc)
except stripe.InvalidRequestError as exc:
return carderror_response(exc)
return get_card(user)
def get_invoices(customer_id):
def invoice_view(i):
return {
'id': i.id,
'date': i.date,
'period_start': i.period_start,
'period_end': i.period_end,
'paid': i.paid,
'amount_due': i.amount_due,
'next_payment_attempt': i.next_payment_attempt,
'attempted': i.attempted,
'closed': i.closed,
'total': i.total,
'plan': i.lines.data[0].plan.id if i.lines.data[0].plan else None
}
invoices = stripe.Invoice.all(customer=customer_id, count=12)
return {
'invoices': [invoice_view(i) for i in invoices.data]
}
@resource('/v1/plans/')
class ListPlans(ApiResource):
""" Resource for listing the available plans. """
@nickname('listPlans')
def get(self):
""" List the avaialble plans. """
return {
'plans': PLANS,
}
@resource('/v1/user/card')
@internal_only
class UserCard(ApiResource):
""" Resource for managing a user's credit card. """
schemas = {
'UserCard': {
'id': 'UserCard',
'type': 'object',
'description': 'Description of a user card',
'required': True,
'properties': {
'token': {
'type': 'string',
'description': 'Stripe token that is generated by stripe checkout.js',
'required': True,
},
},
},
}
@nickname('getUserCard')
def get(self):
""" Get the user's credit card. """
user = get_authenticated_user()
return get_card(user)
@nickname('setUserCard')
@validate_json_request('UserCard')
def post(self):
""" Update the user's credit card. """
user = get_authenticated_user()
token = request.get_json()['token']
response = set_card(user, token)
log_action('account_change_cc', user.username)
return response
@resource('/v1/organization/<orgname>/card')
@internal_only
@related_user_resource(UserCard)
class OrganizationCard(ApiResource):
""" Resource for managing an organization's credit card. """
schemas = {
'OrgCard': {
'id': 'OrgCard',
'type': 'object',
'description': 'Description of a user card',
'required': True,
'properties': {
'token': {
'type': 'string',
'description': 'Stripe token that is generated by stripe checkout.js',
'required': True,
},
},
},
}
@nickname('getOrgCard')
def get(self, orgname):
""" Get the organization's credit card. """
permission = AdministerOrganizationPermission(orgname)
if permission.can():
organization = model.get_organization(orgname)
return get_card(organization)
abort(403)
@nickname('setOrgCard')
@validate_json_request('OrgCard')
def post(self, orgname):
""" Update the orgnaization's credit card. """
permission = AdministerOrganizationPermission(orgname)
if permission.can():
organization = model.get_organization(orgname)
token = request.get_json()['token']
response = set_card(organization, token)
log_action('account_change_cc', orgname)
return response
abort(403)
@resource('/v1/user/plan')
@internal_only
class UserPlan(ApiResource):
""" Resource for managing a user's subscription. """
schemas = {
'UserSubscription': {
'id': 'UserSubscription',
'type': 'object',
'description': 'Description of a user card',
'required': True,
'properties': {
'token': {
'type': 'string',
'description': 'Stripe token that is generated by stripe checkout.js',
},
'plan': {
'type': 'string',
'description': 'Plan name to which the user wants to subscribe',
'required': True,
},
},
},
}
@nickname('updateUserSubscription')
@validate_json_request('UserSubscription')
def put(self):
""" Create or update the user's subscription. """
request_data = request.get_json()
plan = request_data['plan']
token = request_data['token'] if 'token' in request_data else None
user = get_authenticated_user()
return subscribe(user, plan, token, False) # Business features not required
@nickname('getUserSubscription')
def get(self):
""" Fetch any existing subscription for the user. """
user = get_authenticated_user()
private_repos = model.get_private_repo_count(user.username)
if user.stripe_id:
cus = stripe.Customer.retrieve(user.stripe_id)
if cus.subscription:
return subscription_view(cus.subscription, private_repos)
return {
'plan': 'free',
'usedPrivateRepos': private_repos,
}
@resource('/v1/organization/<orgname>/plan')
@internal_only
@related_user_resource(UserPlan)
class OrganizationPlan(ApiResource):
""" Resource for managing a org's subscription. """
schemas = {
'OrgSubscription': {
'id': 'OrgSubscription',
'type': 'object',
'description': 'Description of a user card',
'required': True,
'properties': {
'token': {
'type': 'string',
'description': 'Stripe token that is generated by stripe checkout.js',
},
'plan': {
'type': 'string',
'description': 'Plan name to which the user wants to subscribe',
'required': True,
},
},
},
}
@nickname('updateOrgSubscription')
@validate_json_request('OrgSubscription')
def put(self, orgname):
""" Create or update the org's subscription. """
permission = AdministerOrganizationPermission(orgname)
if permission.can():
request_data = request.get_json()
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, True) # Business plan required
abort(403)
@nickname('getOrgSubscription')
def get(self, orgname):
""" Fetch any existing subscription for the org. """
permission = AdministerOrganizationPermission(orgname)
if permission.can():
private_repos = model.get_private_repo_count(orgname)
organization = model.get_organization(orgname)
if organization.stripe_id:
cus = stripe.Customer.retrieve(organization.stripe_id)
if cus.subscription:
return subscription_view(cus.subscription, private_repos)
return {
'plan': 'free',
'usedPrivateRepos': private_repos,
}
abort(403)
@resource('/v1/user/invoices')
class UserInvoiceList(ApiResource):
""" Resource for listing a user's invoices. """
@nickname('listUserInvoices')
def get(self):
""" List the invoices for the current user. """
user = get_authenticated_user()
if not user.stripe_id:
abort(404)
return get_invoices(user.stripe_id)
@resource('/v1/organization/<orgname>/invoices')
@related_user_resource(UserInvoiceList)
class OrgnaizationInvoiceList(ApiResource):
""" Resource for listing an orgnaization's invoices. """
@nickname('listOrgInvoices')
def get(self, orgname):
""" List the invoices for the specified orgnaization. """
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)

View file

@ -6,7 +6,8 @@ from flask.ext.restful import abort
from app import app
from endpoints.api import (RepositoryParamResource, parse_args, query_param, nickname, resource,
require_repo_read, require_repo_write, validate_json_request)
require_repo_read, require_repo_write, validate_json_request,
ApiResource, internal_only)
from endpoints.common import start_build
from data import model
from auth.permissions import ModifyRepositoryPermission
@ -126,7 +127,7 @@ class RepositoryBuildStatus(RepositoryParamResource):
return build_status_view(build, can_write)
@resource('/repository/<path:repository>/build/<build_uuid>/logs')
@resource('/v1/repository/<path:repository>/build/<build_uuid>/logs')
class RepositoryBuildLogs(RepositoryParamResource):
""" Resource for loading repository build logs. """
@require_repo_write
@ -148,3 +149,35 @@ class RepositoryBuildLogs(RepositoryParamResource):
})
return response_obj
@resource('/v1/filedrop/')
@internal_only
class FileDropResource(ApiResource):
""" Custom verb for setting up a client side file transfer. """
schemas = {
'FileDropRequest': {
'id': 'FileDropRequest',
'type': 'object',
'description': 'Description of the file that the user wishes to upload.',
'required': True,
'properties': {
'mimeType': {
'type': 'string',
'description': 'Type of the file which is about to be uploaded',
'required': True,
},
},
},
}
@nickname('getFiledropUrl')
@validate_json_request('FileDropRequest')
def post(self):
""" Request a URL to which a file may be uploaded. """
mime_type = request.get_json()['mimeType']
(url, file_id) = user_files.prepare_for_drop(mime_type)
return {
'url': url,
'file_id': file_id
}

View file

@ -3,7 +3,8 @@ import logging
from flask.ext.restful import reqparse
from endpoints.api import ApiResource, resource, method_metadata, nickname, truthy_bool
from endpoints.api import (ApiResource, resource, method_metadata, nickname, truthy_bool,
parse_args, query_param)
from app import app
from auth import scopes
@ -23,7 +24,12 @@ TYPE_CONVERTER = {
}
def swagger_route_data():
def fully_qualified_name(method_view_class):
inst = method_view_class()
return '%s.%s' % (inst.__module__, inst.__class__.__name__)
def swagger_route_data(include_internal):
apis = []
models = {}
for rule in app.url_map.iter_rules():
@ -47,7 +53,9 @@ def swagger_route_data():
'required': True,
})
if method is not None:
if method is None:
logger.debug('Unable to find method for %s in class %s', method_name, view_class)
else:
req_schema_name = method_metadata(method, 'request_schema')
if req_schema_name:
parameters.append({
@ -87,17 +95,34 @@ def swagger_route_data():
scope = method_metadata(method, 'oauth2_scope')
if scope:
new_operation['authorizations'] = {
'oauth2': [scope]
'oauth2': [scope],
}
operations.append(new_operation)
internal = method_metadata(method, 'internal')
if internal is not None:
new_operation['internal'] = True
if not internal or (internal and include_internal):
operations.append(new_operation)
swagger_path = PARAM_REGEX.sub(r'{\2}', rule.rule)
apis.append({
new_resource = {
'path': swagger_path,
'description': view_class.__doc__ if view_class.__doc__ else "",
'operations': operations,
})
'name': fully_qualified_name(view_class),
}
related_user_res = method_metadata(view_class, 'related_user_resource')
if related_user_res is not None:
new_resource['quayUserRelated'] = fully_qualified_name(related_user_res)
internal = method_metadata(view_class, 'internal')
if internal is not None:
new_resource['internal'] = True
if not internal or (internal and include_internal):
apis.append(new_resource)
swagger_data = {
'apiVersion': 'v1',
@ -133,7 +158,9 @@ def swagger_route_data():
@resource('/v1/discovery')
class DiscoveryResource(ApiResource):
"""Ability to inspect the API for usage information and documentation."""
@parse_args
@query_param('internal', 'Whether to include internal APIs.', type=truthy_bool, default=False)
@nickname('discovery')
def get(self):
def get(self, args):
""" List all of the API endpoints available in the swagger API format."""
return swagger_route_data()
return swagger_route_data(args['internal'])

View file

@ -6,7 +6,7 @@ from flask.ext.restful import abort
from app import app
from endpoints.api import resource, nickname, require_repo_read, RepositoryParamResource
from data import model
from util.cache import cache_control
from util.cache import cache_control_flask_restful
store = app.config['STORAGE']
@ -72,7 +72,7 @@ class RepositoryImage(RepositoryParamResource):
class RepositoryImageChanges(RepositoryParamResource):
""" Resource for handling repository image change lists. """
@cache_control(max_age=60*60) # Cache for one hour
@cache_control_flask_restful(max_age=60*60) # Cache for one hour
@require_repo_read
@nickname('getImageChanges')
def get(self, namespace, repository, image_id):

View file

@ -117,6 +117,7 @@ def welcome():
return jsonify({'version': '0.5'})
# Ported
@api_bp.route('/plans/')
def list_plans():
return jsonify({
@ -407,6 +408,7 @@ def team_view(orgname, team):
}
# Ported
@api_bp.route('/organization/', methods=['POST'])
@api_login_required
@internal_api_call
@ -454,6 +456,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 +473,7 @@ def get_organization(orgname):
abort(403)
# Ported
@api_bp.route('/organization/<orgname>', methods=['PUT'])
@api_login_required
@org_api_call('change_user_details')
@ -523,6 +527,7 @@ def prototype_view(proto, org_members):
'id': proto.uuid,
}
# Ported
@api_bp.route('/organization/<orgname>/prototypes', methods=['GET'])
@api_login_required
def get_organization_prototype_permissions(orgname):
@ -561,6 +566,7 @@ def log_prototype_action(action_kind, orgname, prototype, **kwargs):
log_action(action_kind, orgname, log_params)
# Ported
@api_bp.route('/organization/<orgname>/prototypes', methods=['POST'])
@api_login_required
def create_organization_prototype_permission(orgname):
@ -609,6 +615,7 @@ def create_organization_prototype_permission(orgname):
abort(403)
# Ported
@api_bp.route('/organization/<orgname>/prototypes/<prototypeid>',
methods=['DELETE'])
@api_login_required
@ -631,6 +638,7 @@ def delete_organization_prototype_permission(orgname, prototypeid):
abort(403)
# Ported
@api_bp.route('/organization/<orgname>/prototypes/<prototypeid>',
methods=['PUT'])
@api_login_required
@ -660,6 +668,7 @@ def update_organization_prototype_permission(orgname, prototypeid):
abort(403)
# Ported
@api_bp.route('/organization/<orgname>/members', methods=['GET'])
@api_login_required
def get_organization_members(orgname):
@ -689,6 +698,7 @@ def get_organization_members(orgname):
abort(403)
# Ported
@api_bp.route('/organization/<orgname>/members/<membername>', methods=['GET'])
@api_login_required
def get_organization_member(orgname, membername):
@ -718,6 +728,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 +769,7 @@ def member_view(member):
}
# Ported
@api_bp.route('/organization/<orgname>/team/<teamname>',
methods=['PUT', 'POST'])
@api_login_required
@ -804,6 +816,7 @@ def update_organization_team(orgname, teamname):
abort(403)
# Ported
@api_bp.route('/organization/<orgname>/team/<teamname>',
methods=['DELETE'])
@api_login_required
@ -817,6 +830,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 +854,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 +884,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
@ -1608,6 +1624,7 @@ def delete_build_trigger(namespace, repository, trigger_uuid):
abort(403) # Permission denied
# Ported
@api_bp.route('/filedrop/', methods=['POST'])
@api_login_required
@internal_api_call
@ -2078,6 +2095,7 @@ def subscription_view(stripe_subscription, used_repos):
}
# Ported
@api_bp.route('/user/card', methods=['GET'])
@api_login_required
@internal_api_call
@ -2086,6 +2104,7 @@ def get_user_card():
return get_card(user)
# Ported
@api_bp.route('/organization/<orgname>/card', methods=['GET'])
@api_login_required
@internal_api_call
@ -2099,6 +2118,7 @@ def get_org_card(orgname):
abort(403)
# Ported
@api_bp.route('/user/card', methods=['POST'])
@api_login_required
@internal_api_call
@ -2110,6 +2130,7 @@ def set_user_card():
return response
# Ported
@api_bp.route('/organization/<orgname>/card', methods=['POST'])
@api_login_required
@org_api_call('set_user_card')
@ -2164,6 +2185,7 @@ def get_card(user):
return jsonify({'card': card_info})
# Ported
@api_bp.route('/user/plan', methods=['PUT'])
@api_login_required
@internal_api_call
@ -2257,6 +2279,7 @@ def subscribe(user, plan, token, require_business_plan):
return resp
# Ported
@api_bp.route('/user/invoices', methods=['GET'])
@api_login_required
def list_user_invoices():
@ -2267,6 +2290,7 @@ def list_user_invoices():
return get_invoices(user.stripe_id)
# Ported
@api_bp.route('/organization/<orgname>/invoices', methods=['GET'])
@api_login_required
@org_api_call('list_user_invoices')
@ -2304,6 +2328,7 @@ def get_invoices(customer_id):
})
# Ported
@api_bp.route('/organization/<orgname>/plan', methods=['PUT'])
@api_login_required
@internal_api_call
@ -2320,6 +2345,7 @@ def update_org_subscription(orgname):
abort(403)
# Ported
@api_bp.route('/user/plan', methods=['GET'])
@api_login_required
@internal_api_call
@ -2339,6 +2365,7 @@ def get_user_subscription():
})
# Ported
@api_bp.route('/organization/<orgname>/plan', methods=['GET'])
@api_login_required
@internal_api_call
@ -2369,6 +2396,7 @@ def robot_view(name, token):
}
# Ported
@api_bp.route('/user/robots', methods=['GET'])
@api_login_required
def get_user_robots():
@ -2379,6 +2407,7 @@ def get_user_robots():
})
# Ported
@api_bp.route('/organization/<orgname>/robots', methods=['GET'])
@api_login_required
@org_api_call('get_user_robots')
@ -2393,6 +2422,7 @@ def get_org_robots(orgname):
abort(403)
# Ported
@api_bp.route('/user/robots/<robot_shortname>', methods=['PUT'])
@api_login_required
def create_user_robot(robot_shortname):
@ -2404,6 +2434,7 @@ def create_user_robot(robot_shortname):
return resp
# Ported
@api_bp.route('/organization/<orgname>/robots/<robot_shortname>',
methods=['PUT'])
@api_login_required
@ -2421,6 +2452,7 @@ def create_org_robot(orgname, robot_shortname):
abort(403)
# Ported
@api_bp.route('/user/robots/<robot_shortname>', methods=['DELETE'])
@api_login_required
def delete_user_robot(robot_shortname):
@ -2430,6 +2462,7 @@ def delete_user_robot(robot_shortname):
return make_response('Deleted', 204)
# Ported
@api_bp.route('/organization/<orgname>/robots/<robot_shortname>',
methods=['DELETE'])
@api_login_required
@ -2462,7 +2495,7 @@ def log_view(log):
return view
# Ported
@api_bp.route('/repository/<path:repository>/logs', methods=['GET'])
@api_login_required
@parse_repository_name
@ -2480,6 +2513,7 @@ def list_repo_logs(namespace, repository):
abort(403)
# Ported
@api_bp.route('/organization/<orgname>/logs', methods=['GET'])
@api_login_required
@org_api_call('list_user_logs')
@ -2496,6 +2530,7 @@ def list_org_logs(orgname):
abort(403)
# Ported
@api_bp.route('/user/logs', methods=['GET'])
@api_login_required
def list_user_logs():

121
endpoints/api/logs.py Normal file
View file

@ -0,0 +1,121 @@
import json
from datetime import datetime, timedelta
from flask.ext.restful import abort
from endpoints.api import (resource, nickname, ApiResource, query_param, parse_args,
RepositoryParamResource, require_repo_admin, related_user_resource)
from auth.permissions import AdministerOrganizationPermission, AdministerOrganizationPermission
from auth.auth_context import get_authenticated_user
from data import model
def log_view(log):
view = {
'kind': log.kind.name,
'metadata': json.loads(log.metadata_json),
'ip': log.ip,
'datetime': log.datetime,
}
if log.performer:
view['performer'] = {
'kind': 'user',
'name': log.performer.username,
'is_robot': log.performer.robot,
}
return view
def get_logs(namespace, start_time, end_time, performer_name=None,
repository=None):
performer = None
if performer_name:
performer = model.get_user(performer_name)
if start_time:
try:
start_time = datetime.strptime(start_time + ' UTC', '%m/%d/%Y %Z')
except ValueError:
start_time = None
if not start_time:
start_time = datetime.today() - timedelta(7) # One week
if end_time:
try:
end_time = datetime.strptime(end_time + ' UTC', '%m/%d/%Y %Z')
end_time = end_time + timedelta(days=1)
except ValueError:
end_time = None
if not end_time:
end_time = datetime.today()
logs = model.list_logs(namespace, start_time, end_time, performer=performer,
repository=repository)
return {
'start_time': start_time,
'end_time': end_time,
'logs': [log_view(log) for log in logs]
}
@resource('/v1/repository/<path:repository>/logs')
class RepositoryLogs(RepositoryParamResource):
""" Resource for fetching logs for the specific repository. """
@require_repo_admin
@nickname('listRepoLogs')
@parse_args
@query_param('starttime', 'Earliest time from which to get logs (%m/%d/%Y %Z)', type=str)
@query_param('endtime', 'Latest time to which to get logs (%m/%d/%Y %Z)', type=str)
def get(self, args, namespace, repository):
""" List the logs for the specified repository. """
repo = model.get_repository(namespace, repository)
if not repo:
abort(404)
start_time = args['starttime']
end_time = args['endtime']
return get_logs(namespace, start_time, end_time, repository=repo)
@resource('/v1/user/logs')
class UserLogs(ApiResource):
""" Resource for fetching logs for the current user. """
@nickname('listUserLogs')
@parse_args
@query_param('starttime', 'Earliest time from which to get logs. (%m/%d/%Y %Z)', type=str)
@query_param('endtime', 'Latest time to which to get logs. (%m/%d/%Y %Z)', type=str)
@query_param('performer', 'Username for which to filter logs.', type=str)
def get(self, args):
""" List the logs for the current user. """
performer_name = args['performer']
start_time = args['starttime']
end_time = args['endtime']
return get_logs(get_authenticated_user().username, start_time, end_time,
performer_name=performer_name)
@resource('/v1/organization/<orgname>/logs')
@related_user_resource(UserLogs)
class OrgLogs(ApiResource):
""" Resource for fetching logs for the entire organization. """
@nickname('listOrgLogs')
@parse_args
@query_param('starttime', 'Earliest time from which to get logs. (%m/%d/%Y %Z)', type=str)
@query_param('endtime', 'Latest time to which to get logs. (%m/%d/%Y %Z)', type=str)
@query_param('performer', 'Username for which to filter logs.', type=str)
def get(self, args, orgname):
""" List the logs for the specified organization. """
permission = AdministerOrganizationPermission(orgname)
if permission.can():
performer_name = args['performer']
start_time = args['starttime']
end_time = args['endtime']
return get_logs(orgname, start_time, end_time, performer_name=performer_name)
abort(403)

View file

@ -0,0 +1,252 @@
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,
related_user_resource, internal_only)
from endpoints.api.team import team_view
from endpoints.api.user import User, PrivateRepositories
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/')
@internal_only
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>')
@related_user_resource(User)
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)
@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')
@related_user_resource(PrivateRepositories)
class OrgPrivateRepositories(ApiResource):
""" Custom verb to compute whether additional private repositories are available. """
@nickname('getOrganizationPrivateAllowed')
def get(self, orgname):
""" Return whether or not this org is allowed to create new private repositories. """
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)
@resource('/v1/organization/<orgname>/members')
class OrgnaizationMemberList(ApiResource):
""" Resource for listing the members of an organization. """
@nickname('getOrganizationMembers')
def get(self, orgname):
""" List the members of the specified organization. """
permission = AdministerOrganizationPermission(orgname)
if permission.can():
try:
org = model.get_organization(orgname)
except model.InvalidOrganizationException:
abort(404)
# Loop to create the members dictionary. Note that the members collection
# will return an entry for *every team* a member is on, so we will have
# duplicate keys (which is why we pre-build the dictionary).
members_dict = {}
members = model.get_organization_members_with_teams(org)
for member in members:
if not member.user.username in members_dict:
members_dict[member.user.username] = {'name': member.user.username,
'kind': 'user',
'is_robot': member.user.robot,
'teams': []}
members_dict[member.user.username]['teams'].append(member.team.name)
return {'members': members_dict}
abort(403)
@resource('/v1/organization/<orgname>/members/<membername>')
class OrganizationMember(ApiResource):
""" Resource for managing individual organization members. """
@nickname('getOrganizationMember')
def get(self, orgname, membername):
""" Get information on the specific orgnaization member. """
permission = AdministerOrganizationPermission(orgname)
if permission.can():
try:
org = model.get_organization(orgname)
except model.InvalidOrganizationException:
abort(404)
member_dict = None
member_teams = model.get_organization_members_with_teams(org, membername=membername)
for member in member_teams:
if not member_dict:
member_dict = {'name': member.user.username,
'kind': 'user',
'is_robot': member.user.robot,
'teams': []}
member_dict['teams'].append(member.team.name)
if not member_dict:
abort(404)
return {'member': member_dict}
abort(403)

View file

@ -79,8 +79,7 @@ class RepositoryUserPermissionList(RepositoryParamResource):
}
@resource('/v1/repository/<path:repository>/permissions/user/<username>',
methods=['GET'])
@resource('/v1/repository/<path:repository>/permissions/user/<username>')
class RepositoryUserPermission(RepositoryParamResource):
""" Resource for managing individual user permissions. """
schemas = {
@ -152,7 +151,7 @@ class RepositoryUserPermission(RepositoryParamResource):
'role': new_permission['role']},
repo=model.get_repository(namespace, repository))
return perm_view, 200 # 201 for post
return perm_view, 200
@require_repo_admin
@nickname('deleteUserPermissions')
@ -221,7 +220,7 @@ class RepositoryTeamPermission(RepositoryParamResource):
'role': new_permission['role']},
repo=model.get_repository(namespace, repository))
return role_view(perm), 200 # Should be 201 for post
return role_view(perm), 200
@require_repo_admin
@nickname('deleteTeamPermissions')

248
endpoints/api/prototype.py Normal file
View file

@ -0,0 +1,248 @@
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
from auth.auth_context import get_authenticated_user
from data import model
def prototype_view(proto, org_members):
def prototype_user_view(user):
return {
'name': user.username,
'is_robot': user.robot,
'kind': 'user',
'is_org_member': user.robot or user.username in org_members,
}
if proto.delegate_user:
delegate_view = prototype_user_view(proto.delegate_user)
else:
delegate_view = {
'name': proto.delegate_team.name,
'kind': 'team',
}
return {
'activating_user': (prototype_user_view(proto.activating_user)
if proto.activating_user else None),
'delegate': delegate_view,
'role': proto.role.name,
'id': proto.uuid,
}
def log_prototype_action(action_kind, orgname, prototype, **kwargs):
username = get_authenticated_user().username
log_params = {
'prototypeid': prototype.uuid,
'username': username,
'activating_username': (prototype.activating_user.username
if prototype.activating_user else None),
'role': prototype.role.name
}
for key, value in kwargs.items():
log_params[key] = value
if prototype.delegate_user:
log_params['delegate_user'] = prototype.delegate_user.username
elif prototype.delegate_team:
log_params['delegate_team'] = prototype.delegate_team.name
log_action(action_kind, orgname, log_params)
@resource('/v1/organization/<orgname>/prototypes')
class PermissionPrototypeList(ApiResource):
""" Resource for listing and creating permission prototypes. """
schemas = {
'NewPrototype': {
'id': 'NewPrototype',
'type': 'object',
'description': 'Description of a new prototype',
'required': True,
'properties': {
'role': {
'type': 'string',
'description': 'Role that should be applied to the delegate',
'required': True,
'enum': [
'read',
'write',
'admin',
],
},
'activating_user': {
'type': 'object',
'description': 'Repository creating user to whom the rule should apply',
'properties': {
'name': {
'type': 'string',
'description': 'The username for the activating_user',
'required': True,
},
},
},
'delegate': {
'type': 'object',
'description': 'Information about the user or team to which the rule grants access',
'required': True,
'properties': {
'name': {
'type': 'string',
'description': 'The name for the delegate team or user',
'required': True,
},
'kind': {
'type': 'string',
'description': 'Whether the delegate is a user or a team',
'required': True,
'enum': [
'user',
'team',
],
},
},
},
},
},
}
@nickname('getOrganizationPrototypePermissions')
def get(self, orgname):
""" List the existing prototypes for this organization. """
permission = AdministerOrganizationPermission(orgname)
if permission.can():
try:
org = model.get_organization(orgname)
except model.InvalidOrganizationException:
abort(404)
permissions = model.get_prototype_permissions(org)
org_members = model.get_organization_member_set(orgname)
return {'prototypes': [prototype_view(p, org_members) for p in permissions]}
abort(403)
@nickname('createOrganizationPrototypePermission')
@validate_json_request('NewPrototype')
def post(self, orgname):
""" Create a new permission prototype. """
permission = AdministerOrganizationPermission(orgname)
if permission.can():
try:
org = model.get_organization(orgname)
except model.InvalidOrganizationException:
abort(404)
details = request.get_json()
activating_username = None
if ('activating_user' in details and details['activating_user'] and
'name' in details['activating_user']):
activating_username = details['activating_user']['name']
delegate = details['delegate'] if 'delegate' in details else {}
delegate_kind = delegate.get('kind', None)
delegate_name = delegate.get('name', None)
delegate_username = delegate_name if delegate_kind == 'user' else None
delegate_teamname = delegate_name if delegate_kind == 'team' else None
activating_user = (model.get_user(activating_username)
if activating_username else None)
delegate_user = (model.get_user(delegate_username)
if delegate_username else None)
delegate_team = (model.get_organization_team(orgname, delegate_teamname)
if delegate_teamname else None)
if activating_username and not activating_user:
return request_error(message='Unknown activating user')
if not delegate_user and not delegate_team:
return request_error(message='Missing delegate user or team')
role_name = details['role']
prototype = model.add_prototype_permission(org, role_name, activating_user,
delegate_user, delegate_team)
log_prototype_action('create_prototype_permission', orgname, prototype)
org_members = model.get_organization_member_set(orgname)
return prototype_view(prototype, org_members)
abort(403)
@resource('/v1/organization/<orgname>/prototypes/<prototypeid>')
class PermissionPrototype(ApiResource):
""" Resource for managingin individual permission prototypes. """
schemas = {
'PrototypeUpdate': {
'id': 'PrototypeUpdate',
'type': 'object',
'description': 'Description of a the new prototype role',
'required': True,
'properties': {
'role': {
'type': 'string',
'description': 'Role that should be applied to the permission',
'required': True,
'enum': [
'read',
'write',
'admin',
],
},
},
},
}
@nickname('deleteOrganizationPrototypePermission')
def delete(self, orgname, prototypeid):
""" Delete an existing permission prototype. """
permission = AdministerOrganizationPermission(orgname)
if permission.can():
try:
org = model.get_organization(orgname)
except model.InvalidOrganizationException:
abort(404)
prototype = model.delete_prototype_permission(org, prototypeid)
if not prototype:
abort(404)
log_prototype_action('delete_prototype_permission', orgname, prototype)
return 'Deleted', 204
abort(403)
@nickname('updateOrganizationPrototypePermission')
@validate_json_request('PrototypeUpdate')
def put(self, orgname, prototypeid):
""" Update the role of an existing permission prototype. """
permission = AdministerOrganizationPermission(orgname)
if permission.can():
try:
org = model.get_organization(orgname)
except model.InvalidOrganizationException:
abort(404)
existing = model.get_prototype_permission(org, prototypeid)
if not existing:
abort(404)
details = request.get_json()
role_name = details['role']
prototype = model.update_prototype_permission(org, prototypeid, role_name)
if not prototype:
abort(404)
log_prototype_action('modify_prototype_permission', orgname, prototype,
original_role=existing.role.name)
org_members = model.get_organization_member_set(orgname)
return prototype_view(prototype, org_members)
abort(403)

View file

@ -3,7 +3,6 @@ import json
from flask import current_app, request
from flask.ext.restful import reqparse, abort
from flask.ext.login import current_user
from data import model
from endpoints.api import (truthy_bool, format_date, nickname, log_action, validate_json_request,
@ -13,8 +12,8 @@ from endpoints.api import (truthy_bool, format_date, nickname, log_action, valid
from auth.permissions import (ReadRepositoryPermission, ModifyRepositoryPermission,
AdministerRepositoryPermission, CreateRepositoryPermission)
from auth.auth import process_auth
from auth import scopes
from auth.auth_context import get_authenticated_user
from auth import scopes
logger = logging.getLogger(__name__)
@ -113,8 +112,8 @@ class RepositoryList(ApiResource):
}
username = None
if current_user.is_authenticated() and args['private']:
username = current_user.db_user().username
if get_authenticated_user() and args['private']:
username = get_authenticated_user().username
response = {}

View file

@ -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': {

96
endpoints/api/robot.py Normal file
View file

@ -0,0 +1,96 @@
from flask.ext.restful import abort
from endpoints.api import resource, nickname, ApiResource, log_action, related_user_resource
from auth.permissions import AdministerOrganizationPermission, OrganizationMemberPermission
from auth.auth_context import get_authenticated_user
from data import model
from util.names import format_robot_username
def robot_view(name, token):
return {
'name': name,
'token': token,
}
@resource('/v1/user/robots')
class UserRobotList(ApiResource):
""" Resource for listing user robots. """
@nickname('getUserRobots')
def get(self):
""" List the available robots for the user. """
user = get_authenticated_user()
robots = model.list_entity_robots(user.username)
return {
'robots': [robot_view(name, password) for name, password in robots]
}
@resource('/v1/user/robots/<robot_shortname>')
class UserRobot(ApiResource):
""" Resource for managing a user's robots. """
@nickname('createUserRobot')
def put(self, robot_shortname):
""" Create a new user robot with the specified name. """
parent = get_authenticated_user()
robot, password = model.create_robot(robot_shortname, parent)
resp = robot_view(robot.username, password)
log_action('create_robot', parent.username, {'robot': robot_shortname})
resp.status_code = 201
return resp
@nickname('deleteUserRobot')
def delete(self, robot_shortname):
""" Delete an existing robot. """
parent = get_authenticated_user()
model.delete_robot(format_robot_username(parent.username, robot_shortname))
log_action('delete_robot', parent.username, {'robot': robot_shortname})
return 'Deleted', 204
@resource('/v1/organization/<orgname>/robots')
@related_user_resource(UserRobotList)
class OrgRobotList(ApiResource):
""" Resource for listing an organization's robots. """
@nickname('getOrgRobots')
def get(self, orgname):
""" List the organization's robots. """
permission = OrganizationMemberPermission(orgname)
if permission.can():
robots = model.list_entity_robots(orgname)
return {
'robots': [robot_view(name, password) for name, password in robots]
}
abort(403)
@resource('/v1/organization/<orgname>/robots/<robot_shortname>')
@related_user_resource(UserRobot)
class OrgRobot(ApiResource):
""" Resource for managing an organization's robots. """
@nickname('createOrgRobot')
def put(self, orgname, robot_shortname):
""" Create a new robot in the organization. """
permission = AdministerOrganizationPermission(orgname)
if permission.can():
parent = model.get_organization(orgname)
robot, password = model.create_robot(robot_shortname, parent)
resp = robot_view(robot.username, password)
log_action('create_robot', orgname, {'robot': robot_shortname})
resp.status_code = 201
return resp
abort(403)
@nickname('deleteOrgRobot')
def delete(self, orgname, robot_shortname):
""" Delete an existing organization robot. """
permission = AdministerOrganizationPermission(orgname)
if permission.can():
model.delete_robot(format_robot_username(orgname, robot_shortname))
log_action('delete_robot', orgname, {'robot': robot_shortname})
return 'Deleted', 204
abort(403)

177
endpoints/api/team.py Normal file
View file

@ -0,0 +1,177 @@
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>')
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
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)

View file

@ -209,7 +209,7 @@ class BuildTriggerActivate(RepositoryParamResource):
abort(403)
@resource('/repository/<path:repository>/trigger/<trigger_uuid>/start')
@resource('/v1/repository/<path:repository>/trigger/<trigger_uuid>/start')
class ActivateBuildTrigger(RepositoryParamResource):
""" Custom verb to manually activate a build trigger. """
@ -260,7 +260,7 @@ class TriggerBuildList(RepositoryParamResource):
}
@resource('/repository/<path:repository>/trigger/<trigger_uuid>/sources')
@resource('/v1/repository/<path:repository>/trigger/<trigger_uuid>/sources')
class BuildTriggerSources(RepositoryParamResource):
""" Custom verb to fetch the list of build sources for the trigger config. """
@require_repo_admin

View file

@ -8,7 +8,7 @@ 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)
log_action, internal_only)
from endpoints.api.subscribe import subscribe
from endpoints.common import common_login
from data import model
@ -121,6 +121,7 @@ class User(ApiResource):
return user_view(user)
@nickname('changeUserDetails')
@internal_only
@validate_json_request('UpdateUser')
def put(self):
""" Update a users details such as password or email. """
@ -154,6 +155,7 @@ class User(ApiResource):
return user_view(user)
@nickname('createNewUser')
@internal_only
@validate_json_request('NewUser')
def post(self):
""" Create a new user. """
@ -218,6 +220,7 @@ def conduct_signin(username_or_email, password):
@resource('/v1/user/convert')
@internal_only
class ConvertToOrganization(ApiResource):
""" Operations for converting a user to an organization. """
schemas = {
@ -278,6 +281,7 @@ class ConvertToOrganization(ApiResource):
@resource('/v1/signin')
@internal_only
class Signin(ApiResource):
""" Operations for signing in the user. """
schemas = {
@ -316,6 +320,7 @@ class Signin(ApiResource):
@resource('/v1/signout')
@internal_only
class Signout(ApiResource):
""" Resource for signing out users. """
@nickname('logout')
@ -327,6 +332,7 @@ class Signout(ApiResource):
@resource("/v1/recovery")
@internal_only
class Recovery(ApiResource):
""" Resource for requesting a password recovery email. """
schemas = {

View file

@ -1,4 +1,5 @@
from functools import wraps
from flask.ext.restful.utils import unpack
def cache_control(max_age=55):
@ -12,6 +13,18 @@ def cache_control(max_age=55):
return wrap
def cache_control_flask_restful(max_age=55):
def wrap(f):
@wraps(f)
def add_max_age(*args, **kwargs):
response = f(*args, **kwargs)
body, status_code, headers = unpack(response)
headers['Cache-Control'] = 'max-age=%d' % max_age
return body, status_code, headers
return add_max_age
return wrap
def no_cache(f):
@wraps(f)
def add_no_cache(*args, **kwargs):