Port over images, permissions, and tags.
This commit is contained in:
parent
21d0ec2012
commit
3d4ece31f3
5 changed files with 390 additions and 0 deletions
|
@ -180,3 +180,6 @@ import endpoints.api.search
|
||||||
import endpoints.api.build
|
import endpoints.api.build
|
||||||
import endpoints.api.webhook
|
import endpoints.api.webhook
|
||||||
import endpoints.api.trigger
|
import endpoints.api.trigger
|
||||||
|
import endpoints.api.image
|
||||||
|
import endpoints.api.tag
|
||||||
|
import endpoints.api.permission
|
90
endpoints/api/image.py
Normal file
90
endpoints/api/image.py
Normal file
|
@ -0,0 +1,90 @@
|
||||||
|
import json
|
||||||
|
|
||||||
|
from collections import defaultdict
|
||||||
|
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
|
||||||
|
|
||||||
|
|
||||||
|
store = app.config['STORAGE']
|
||||||
|
|
||||||
|
|
||||||
|
def image_view(image):
|
||||||
|
extended_props = image
|
||||||
|
if image.storage and image.storage.id:
|
||||||
|
extended_props = image.storage
|
||||||
|
|
||||||
|
command = extended_props.command
|
||||||
|
return {
|
||||||
|
'id': image.docker_image_id,
|
||||||
|
'created': extended_props.created,
|
||||||
|
'comment': extended_props.comment,
|
||||||
|
'command': json.loads(command) if command else None,
|
||||||
|
'ancestors': image.ancestors,
|
||||||
|
'dbid': image.id,
|
||||||
|
'size': extended_props.image_size,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@resource('/v1/repository/<path:repository>/image/')
|
||||||
|
class RepositoryImageList(RepositoryParamResource):
|
||||||
|
""" Resource for listing repository images. """
|
||||||
|
@require_repo_read
|
||||||
|
@nickname('listRepositoryImages')
|
||||||
|
def get(self, namespace, repository):
|
||||||
|
""" List the images for the specified repository. """
|
||||||
|
all_images = model.get_repository_images(namespace, repository)
|
||||||
|
all_tags = model.list_repository_tags(namespace, repository)
|
||||||
|
|
||||||
|
tags_by_image_id = defaultdict(list)
|
||||||
|
for tag in all_tags:
|
||||||
|
tags_by_image_id[tag.image.docker_image_id].append(tag.name)
|
||||||
|
|
||||||
|
|
||||||
|
def add_tags(image_json):
|
||||||
|
image_json['tags'] = tags_by_image_id[image_json['id']]
|
||||||
|
return image_json
|
||||||
|
|
||||||
|
|
||||||
|
return {
|
||||||
|
'images': [add_tags(image_view(image)) for image in all_images]
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@resource('/v1/repository/<path:repository>/image/<image_id>')
|
||||||
|
class RepositoryImage(RepositoryParamResource):
|
||||||
|
""" Resource for handling repository images. """
|
||||||
|
@require_repo_read
|
||||||
|
@nickname('getImage')
|
||||||
|
def get(self, namespace, repository, image_id):
|
||||||
|
image = model.get_repo_image(namespace, repository, image_id)
|
||||||
|
if not image:
|
||||||
|
abort(404)
|
||||||
|
|
||||||
|
return image_view(image)
|
||||||
|
|
||||||
|
|
||||||
|
@resource('/v1/repository/<path:repository>/image/<image_id>/changes')
|
||||||
|
class RepositoryImageChanges(RepositoryParamResource):
|
||||||
|
""" Resource for handling repository image change lists. """
|
||||||
|
|
||||||
|
@cache_control(max_age=60*60) # Cache for one hour
|
||||||
|
@require_repo_read
|
||||||
|
@nickname('getImageChanges')
|
||||||
|
def get(self, namespace, repository, image_id):
|
||||||
|
image = model.get_repo_image(namespace, repository, image_id)
|
||||||
|
|
||||||
|
if not image:
|
||||||
|
abort(404)
|
||||||
|
|
||||||
|
uuid = image.storage and image.storage.uuid
|
||||||
|
diffs_path = store.image_file_diffs_path(namespace, repository, image_id, uuid)
|
||||||
|
|
||||||
|
try:
|
||||||
|
response_json = store.get_content(diffs_path)
|
||||||
|
return response_json
|
||||||
|
except IOError:
|
||||||
|
abort(404)
|
|
@ -1635,6 +1635,7 @@ def wrap_role_view_org(role_json, user, org_members):
|
||||||
return role_json
|
return role_json
|
||||||
|
|
||||||
|
|
||||||
|
# Ported
|
||||||
@api_bp.route('/repository/<path:repository>/image/', methods=['GET'])
|
@api_bp.route('/repository/<path:repository>/image/', methods=['GET'])
|
||||||
@parse_repository_name
|
@parse_repository_name
|
||||||
def list_repository_images(namespace, repository):
|
def list_repository_images(namespace, repository):
|
||||||
|
@ -1660,6 +1661,7 @@ def list_repository_images(namespace, repository):
|
||||||
abort(403)
|
abort(403)
|
||||||
|
|
||||||
|
|
||||||
|
# Ported
|
||||||
@api_bp.route('/repository/<path:repository>/image/<image_id>',
|
@api_bp.route('/repository/<path:repository>/image/<image_id>',
|
||||||
methods=['GET'])
|
methods=['GET'])
|
||||||
@parse_repository_name
|
@parse_repository_name
|
||||||
|
@ -1674,6 +1676,7 @@ def get_image(namespace, repository, image_id):
|
||||||
abort(403)
|
abort(403)
|
||||||
|
|
||||||
|
|
||||||
|
# Ported
|
||||||
@api_bp.route('/repository/<path:repository>/image/<image_id>/changes',
|
@api_bp.route('/repository/<path:repository>/image/<image_id>/changes',
|
||||||
methods=['GET'])
|
methods=['GET'])
|
||||||
@cache_control(max_age=60*60) # Cache for one hour
|
@cache_control(max_age=60*60) # Cache for one hour
|
||||||
|
@ -1699,6 +1702,7 @@ def get_image_changes(namespace, repository, image_id):
|
||||||
abort(403)
|
abort(403)
|
||||||
|
|
||||||
|
|
||||||
|
# Ported
|
||||||
@api_bp.route('/repository/<path:repository>/tag/<tag>',
|
@api_bp.route('/repository/<path:repository>/tag/<tag>',
|
||||||
methods=['DELETE'])
|
methods=['DELETE'])
|
||||||
@parse_repository_name
|
@parse_repository_name
|
||||||
|
@ -1718,6 +1722,7 @@ def delete_full_tag(namespace, repository, tag):
|
||||||
abort(403) # Permission denied
|
abort(403) # Permission denied
|
||||||
|
|
||||||
|
|
||||||
|
# Ported
|
||||||
@api_bp.route('/repository/<path:repository>/tag/<tag>/images',
|
@api_bp.route('/repository/<path:repository>/tag/<tag>/images',
|
||||||
methods=['GET'])
|
methods=['GET'])
|
||||||
@parse_repository_name
|
@parse_repository_name
|
||||||
|
@ -1742,6 +1747,7 @@ def list_tag_images(namespace, repository, tag):
|
||||||
abort(403) # Permission denied
|
abort(403) # Permission denied
|
||||||
|
|
||||||
|
|
||||||
|
# Ported
|
||||||
@api_bp.route('/repository/<path:repository>/permissions/team/',
|
@api_bp.route('/repository/<path:repository>/permissions/team/',
|
||||||
methods=['GET'])
|
methods=['GET'])
|
||||||
@api_login_required
|
@api_login_required
|
||||||
|
@ -1759,6 +1765,7 @@ def list_repo_team_permissions(namespace, repository):
|
||||||
abort(403) # Permission denied
|
abort(403) # Permission denied
|
||||||
|
|
||||||
|
|
||||||
|
# Ported
|
||||||
@api_bp.route('/repository/<path:repository>/permissions/user/',
|
@api_bp.route('/repository/<path:repository>/permissions/user/',
|
||||||
methods=['GET'])
|
methods=['GET'])
|
||||||
@api_login_required
|
@api_login_required
|
||||||
|
@ -1800,6 +1807,7 @@ def list_repo_user_permissions(namespace, repository):
|
||||||
abort(403) # Permission denied
|
abort(403) # Permission denied
|
||||||
|
|
||||||
|
|
||||||
|
# Ported
|
||||||
@api_bp.route('/repository/<path:repository>/permissions/user/<username>',
|
@api_bp.route('/repository/<path:repository>/permissions/user/<username>',
|
||||||
methods=['GET'])
|
methods=['GET'])
|
||||||
@api_login_required
|
@api_login_required
|
||||||
|
@ -1825,6 +1833,7 @@ def get_user_permissions(namespace, repository, username):
|
||||||
abort(403) # Permission denied
|
abort(403) # Permission denied
|
||||||
|
|
||||||
|
|
||||||
|
# Ported
|
||||||
@api_bp.route('/repository/<path:repository>/permissions/team/<teamname>',
|
@api_bp.route('/repository/<path:repository>/permissions/team/<teamname>',
|
||||||
methods=['GET'])
|
methods=['GET'])
|
||||||
@api_login_required
|
@api_login_required
|
||||||
|
@ -1840,6 +1849,7 @@ def get_team_permissions(namespace, repository, teamname):
|
||||||
abort(403) # Permission denied
|
abort(403) # Permission denied
|
||||||
|
|
||||||
|
|
||||||
|
# Ported
|
||||||
@api_bp.route('/repository/<path:repository>/permissions/user/<username>',
|
@api_bp.route('/repository/<path:repository>/permissions/user/<username>',
|
||||||
methods=['PUT', 'POST'])
|
methods=['PUT', 'POST'])
|
||||||
@api_login_required
|
@api_login_required
|
||||||
|
@ -1879,6 +1889,7 @@ def change_user_permissions(namespace, repository, username):
|
||||||
abort(403) # Permission denied
|
abort(403) # Permission denied
|
||||||
|
|
||||||
|
|
||||||
|
# Ported
|
||||||
@api_bp.route('/repository/<path:repository>/permissions/team/<teamname>',
|
@api_bp.route('/repository/<path:repository>/permissions/team/<teamname>',
|
||||||
methods=['PUT', 'POST'])
|
methods=['PUT', 'POST'])
|
||||||
@api_login_required
|
@api_login_required
|
||||||
|
@ -1907,6 +1918,7 @@ def change_team_permissions(namespace, repository, teamname):
|
||||||
abort(403) # Permission denied
|
abort(403) # Permission denied
|
||||||
|
|
||||||
|
|
||||||
|
# Ported
|
||||||
@api_bp.route('/repository/<path:repository>/permissions/user/<username>',
|
@api_bp.route('/repository/<path:repository>/permissions/user/<username>',
|
||||||
methods=['DELETE'])
|
methods=['DELETE'])
|
||||||
@api_login_required
|
@api_login_required
|
||||||
|
@ -1928,6 +1940,7 @@ def delete_user_permissions(namespace, repository, username):
|
||||||
abort(403) # Permission denied
|
abort(403) # Permission denied
|
||||||
|
|
||||||
|
|
||||||
|
# Ported
|
||||||
@api_bp.route('/repository/<path:repository>/permissions/team/<teamname>',
|
@api_bp.route('/repository/<path:repository>/permissions/team/<teamname>',
|
||||||
methods=['DELETE'])
|
methods=['DELETE'])
|
||||||
@api_login_required
|
@api_login_required
|
||||||
|
|
235
endpoints/api/permission.py
Normal file
235
endpoints/api/permission.py
Normal file
|
@ -0,0 +1,235 @@
|
||||||
|
import logging
|
||||||
|
|
||||||
|
from flask import request
|
||||||
|
|
||||||
|
from endpoints.api import (resource, nickname, require_repo_admin, RepositoryParamResource,
|
||||||
|
log_action, request_error, validate_json_request)
|
||||||
|
from data import model
|
||||||
|
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
def role_view(repo_perm_obj):
|
||||||
|
return {
|
||||||
|
'role': repo_perm_obj.role.name,
|
||||||
|
}
|
||||||
|
|
||||||
|
def wrap_role_view_user(role_json, user):
|
||||||
|
role_json['is_robot'] = user.robot
|
||||||
|
return role_json
|
||||||
|
|
||||||
|
|
||||||
|
def wrap_role_view_org(role_json, user, org_members):
|
||||||
|
role_json['is_org_member'] = user.robot or user.username in org_members
|
||||||
|
return role_json
|
||||||
|
|
||||||
|
|
||||||
|
@resource('/v1/repository/<path:repository>/permissions/team/')
|
||||||
|
class RepositoryTeamPermissionList(RepositoryParamResource):
|
||||||
|
""" Resource for repository team permissions. """
|
||||||
|
@require_repo_admin
|
||||||
|
@nickname('listRepoTeamPermissions')
|
||||||
|
def get(self, namespace, repository):
|
||||||
|
""" List all team permission. """
|
||||||
|
repo_perms = model.get_all_repo_teams(namespace, repository)
|
||||||
|
|
||||||
|
return {
|
||||||
|
'permissions': {repo_perm.team.name: role_view(repo_perm)
|
||||||
|
for repo_perm in repo_perms}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@resource('/v1/repository/<path:repository>/permissions/user/')
|
||||||
|
class RepositoryUserPermissionList(RepositoryParamResource):
|
||||||
|
""" Resource for repository user permissions. """
|
||||||
|
@require_repo_admin
|
||||||
|
@nickname('listRepoUserPermissions')
|
||||||
|
def get(self, namespace, repository):
|
||||||
|
""" List all user permissions. """
|
||||||
|
# Lookup the organization (if any).
|
||||||
|
org = None
|
||||||
|
try:
|
||||||
|
org = model.get_organization(namespace) # Will raise an error if not org
|
||||||
|
except model.InvalidOrganizationException:
|
||||||
|
# This repository isn't under an org
|
||||||
|
pass
|
||||||
|
|
||||||
|
# Determine how to wrap the role(s).
|
||||||
|
def wrapped_role_view(repo_perm):
|
||||||
|
return wrap_role_view_user(role_view(repo_perm), repo_perm.user)
|
||||||
|
|
||||||
|
role_view_func = wrapped_role_view
|
||||||
|
|
||||||
|
if org:
|
||||||
|
org_members = model.get_organization_member_set(namespace)
|
||||||
|
current_func = role_view_func
|
||||||
|
|
||||||
|
def wrapped_role_org_view(repo_perm):
|
||||||
|
return wrap_role_view_org(current_func(repo_perm), repo_perm.user,
|
||||||
|
org_members)
|
||||||
|
|
||||||
|
role_view_func = wrapped_role_org_view
|
||||||
|
|
||||||
|
# Load and return the permissions.
|
||||||
|
repo_perms = model.get_all_repo_users(namespace, repository)
|
||||||
|
return {
|
||||||
|
'permissions': {perm.user.username: role_view_func(perm)
|
||||||
|
for perm in repo_perms}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@resource('/v1/repository/<path:repository>/permissions/user/<username>',
|
||||||
|
methods=['GET'])
|
||||||
|
class RepositoryUserPermission(RepositoryParamResource):
|
||||||
|
""" Resource for managing individual user permissions. """
|
||||||
|
schemas = {
|
||||||
|
'UserPermission': {
|
||||||
|
'id': 'UserPermission',
|
||||||
|
'type': 'object',
|
||||||
|
'description': 'Description of a user permission.',
|
||||||
|
'required': True,
|
||||||
|
'properties': {
|
||||||
|
'role': {
|
||||||
|
'type': 'string',
|
||||||
|
'description': 'Visibility which the repository will start with',
|
||||||
|
'enum': [
|
||||||
|
'read',
|
||||||
|
'write',
|
||||||
|
'admin',
|
||||||
|
],
|
||||||
|
'required': True,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
@require_repo_admin
|
||||||
|
@nickname('getUserPermission')
|
||||||
|
def get(self, namespace, repository, username):
|
||||||
|
logger.debug('Get repo: %s/%s permissions for user %s' %
|
||||||
|
(namespace, repository, username))
|
||||||
|
perm = model.get_user_reponame_permission(username, namespace, repository)
|
||||||
|
perm_view = wrap_role_view_user(role_view(perm), perm.user)
|
||||||
|
|
||||||
|
try:
|
||||||
|
model.get_organization(namespace)
|
||||||
|
org_members = model.get_organization_member_set(namespace)
|
||||||
|
perm_view = wrap_role_view_org(perm_view, perm.user, org_members)
|
||||||
|
except model.InvalidOrganizationException:
|
||||||
|
# This repository is not part of an organization
|
||||||
|
pass
|
||||||
|
|
||||||
|
return perm_view
|
||||||
|
|
||||||
|
@require_repo_admin
|
||||||
|
@nickname('changeUserPermissions')
|
||||||
|
@validate_json_request('UserPermission')
|
||||||
|
def put(self, namespace, repository, username): # Also needs to respond to post
|
||||||
|
""" Update the perimssions for an existing repository. """
|
||||||
|
new_permission = request.get_json()
|
||||||
|
|
||||||
|
logger.debug('Setting permission to: %s for user %s' %
|
||||||
|
(new_permission['role'], username))
|
||||||
|
|
||||||
|
perm = model.set_user_repo_permission(username, namespace, repository,
|
||||||
|
new_permission['role'])
|
||||||
|
perm_view = wrap_role_view_user(role_view(perm), perm.user)
|
||||||
|
|
||||||
|
try:
|
||||||
|
model.get_organization(namespace)
|
||||||
|
org_members = model.get_organization_member_set(namespace)
|
||||||
|
perm_view = wrap_role_view_org(perm_view, perm.user, org_members)
|
||||||
|
except model.InvalidOrganizationException:
|
||||||
|
# This repository is not part of an organization
|
||||||
|
pass
|
||||||
|
except model.DataModelException as ex:
|
||||||
|
return request_error(exception=ex)
|
||||||
|
|
||||||
|
log_action('change_repo_permission', namespace,
|
||||||
|
{'username': username, 'repo': repository,
|
||||||
|
'role': new_permission['role']},
|
||||||
|
repo=model.get_repository(namespace, repository))
|
||||||
|
|
||||||
|
return perm_view, 200 # 201 for post
|
||||||
|
|
||||||
|
@require_repo_admin
|
||||||
|
@nickname('deleteUserPermissions')
|
||||||
|
def delete(namespace, repository, username):
|
||||||
|
""" Delete the permission for the user. """
|
||||||
|
try:
|
||||||
|
model.delete_user_permission(username, namespace, repository)
|
||||||
|
except model.DataModelException as ex:
|
||||||
|
return request_error(exception=ex)
|
||||||
|
|
||||||
|
log_action('delete_repo_permission', namespace,
|
||||||
|
{'username': username, 'repo': repository},
|
||||||
|
repo=model.get_repository(namespace, repository))
|
||||||
|
|
||||||
|
return 'Deleted', 204
|
||||||
|
|
||||||
|
|
||||||
|
@resource('/v1/repository/<path:repository>/permissions/team/<teamname>')
|
||||||
|
class RepositoryTeamPermission(RepositoryParamResource):
|
||||||
|
""" Resource for managing individual team permissions. """
|
||||||
|
schemas = {
|
||||||
|
'TeamPermission': {
|
||||||
|
'id': 'TeamPermission',
|
||||||
|
'type': 'object',
|
||||||
|
'description': 'Description of a user permission.',
|
||||||
|
'required': True,
|
||||||
|
'properties': {
|
||||||
|
'role': {
|
||||||
|
'type': 'string',
|
||||||
|
'description': 'Visibility which the repository will start with',
|
||||||
|
'enum': [
|
||||||
|
'read',
|
||||||
|
'write',
|
||||||
|
'admin',
|
||||||
|
],
|
||||||
|
'required': True,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
@require_repo_admin
|
||||||
|
@nickname('getTeamPermissions')
|
||||||
|
def get(self, namespace, repository, teamname):
|
||||||
|
""" Fetch the permission for the specified team. """
|
||||||
|
logger.debug('Get repo: %s/%s permissions for team %s' %
|
||||||
|
(namespace, repository, teamname))
|
||||||
|
perm = model.get_team_reponame_permission(teamname, namespace, repository)
|
||||||
|
return role_view(perm)
|
||||||
|
|
||||||
|
@require_repo_admin
|
||||||
|
@nickname('changeTeamPermissions')
|
||||||
|
@validate_json_request('TeamPermission')
|
||||||
|
def put(self, namespace, repository, teamname):
|
||||||
|
""" Update the existing team permission. """
|
||||||
|
new_permission = request.get_json()
|
||||||
|
|
||||||
|
logger.debug('Setting permission to: %s for team %s' %
|
||||||
|
(new_permission['role'], teamname))
|
||||||
|
|
||||||
|
perm = model.set_team_repo_permission(teamname, namespace, repository,
|
||||||
|
new_permission['role'])
|
||||||
|
|
||||||
|
log_action('change_repo_permission', namespace,
|
||||||
|
{'team': teamname, 'repo': repository,
|
||||||
|
'role': new_permission['role']},
|
||||||
|
repo=model.get_repository(namespace, repository))
|
||||||
|
|
||||||
|
return role_view(perm), 200 # Should be 201 for post
|
||||||
|
|
||||||
|
@require_repo_admin
|
||||||
|
@nickname('deleteTeamPermissions')
|
||||||
|
def delete(self, namespace, repository, teamname):
|
||||||
|
""" Delete the permission for the specified team. """
|
||||||
|
model.delete_team_permission(teamname, namespace, repository)
|
||||||
|
|
||||||
|
log_action('delete_repo_permission', namespace,
|
||||||
|
{'team': teamname, 'repo': repository},
|
||||||
|
repo=model.get_repository(namespace, repository))
|
||||||
|
|
||||||
|
return 'Deleted', 204
|
49
endpoints/api/tag.py
Normal file
49
endpoints/api/tag.py
Normal file
|
@ -0,0 +1,49 @@
|
||||||
|
from flask.ext.restful import abort
|
||||||
|
|
||||||
|
from endpoints.api import (resource, nickname, require_repo_read, require_repo_admin,
|
||||||
|
RepositoryParamResource, log_action)
|
||||||
|
from endpoints.api.image import image_view
|
||||||
|
from data import model
|
||||||
|
from auth.auth_context import get_authenticated_user
|
||||||
|
|
||||||
|
|
||||||
|
@resource('/v1/repository/<path:repository>/tag/<tag>')
|
||||||
|
class RepositoryTag(RepositoryParamResource):
|
||||||
|
""" Resource for managing repository tags. """
|
||||||
|
|
||||||
|
@require_repo_admin
|
||||||
|
@nickname('deleteFullTag')
|
||||||
|
def delete(self, namespace, repository, tag):
|
||||||
|
""" Delete the specified repository tag. """
|
||||||
|
model.delete_tag(namespace, repository, tag)
|
||||||
|
model.garbage_collect_repository(namespace, repository)
|
||||||
|
|
||||||
|
username = get_authenticated_user().username
|
||||||
|
log_action('delete_tag', namespace,
|
||||||
|
{'username': username, 'repo': repository, 'tag': tag},
|
||||||
|
repo=model.get_repository(namespace, repository))
|
||||||
|
|
||||||
|
return 'Deleted', 204
|
||||||
|
|
||||||
|
|
||||||
|
@resource('/v1/repository/<path:repository>/tag/<tag>/images')
|
||||||
|
class RepositoryTagImages(RepositoryParamResource):
|
||||||
|
""" Resource for listing the images in a specific repository tag. """
|
||||||
|
@require_repo_read
|
||||||
|
@nickname('listTagImages')
|
||||||
|
def get(self, namespace, repository, tag):
|
||||||
|
""" List the images for the specified repository tag. """
|
||||||
|
try:
|
||||||
|
tag_image = model.get_tag_image(namespace, repository, tag)
|
||||||
|
except model.DataModelException:
|
||||||
|
abort(404)
|
||||||
|
|
||||||
|
parent_images = model.get_parent_images(tag_image)
|
||||||
|
|
||||||
|
parents = list(parent_images)
|
||||||
|
parents.reverse()
|
||||||
|
all_images = [tag_image] + parents
|
||||||
|
|
||||||
|
return {
|
||||||
|
'images': [image_view(image) for image in all_images]
|
||||||
|
}
|
Reference in a new issue