diff --git a/data/model.py b/data/model.py index 9182950be..7c4f80fb5 100644 --- a/data/model.py +++ b/data/model.py @@ -740,6 +740,9 @@ def list_repository_tags(namespace_name, repository_name): return with_image.where(Repository.name == repository_name, Repository.namespace == namespace_name) +def delete_tag_and_images(namespace_name, repository_name, tag_name): + # TODO: Implement this. + pass def get_tag_image(namespace_name, repository_name, tag_name): joined = Image.select().join(RepositoryTag).join(Repository) diff --git a/endpoints/api.py b/endpoints/api.py index b954befb1..d1c5f8d2d 100644 --- a/endpoints/api.py +++ b/endpoints/api.py @@ -1158,6 +1158,24 @@ def get_image_changes(namespace, repository, image_id): abort(403) +@app.route('/api/repository//tag/', + methods=['DELETE']) +@parse_repository_name +def delete_full_tag(namespace, repository, tag): + permission = AdministerRepositoryPermission(namespace, repository) + if permission.can(): + model.delete_tag_and_images(namespace, repository, tag) + + username = current_user.db_user().username + log_action('delete_tag', namespace, + {'username': username, 'repo': repository, 'tag': tag}, + repo=model.get_repository(namespace, repository)) + + return make_response('Deleted', 204) + + abort(403) # Permission denied + + @app.route('/api/repository//tag//images', methods=['GET']) @parse_repository_name diff --git a/initdb.py b/initdb.py index 5dc75e52f..8df4e7f00 100644 --- a/initdb.py +++ b/initdb.py @@ -123,6 +123,7 @@ def initialize_database(): LogEntryKind.create(name='push_repo') LogEntryKind.create(name='pull_repo') LogEntryKind.create(name='delete_repo') + LogEntryKind.create(name='delete_tag') LogEntryKind.create(name='add_repo_permission') LogEntryKind.create(name='change_repo_permission') LogEntryKind.create(name='delete_repo_permission') @@ -279,6 +280,9 @@ def populate_database(): model.log_action('pull_repo', org.username, repository=org_repo, timestamp=today, metadata={'token': 'sometoken', 'token_code': 'somecode', 'repo': 'orgrepo'}) + model.log_action('delete_tag', org.username, performer=new_user_2, repository=org_repo, timestamp=today, + metadata={'username': new_user_2.username, 'repo': 'orgrepo', 'tag': 'sometag'}) + if __name__ == '__main__': logging.basicConfig(**app.config['LOGGING_CONFIG']) initialize_database() diff --git a/static/css/quay.css b/static/css/quay.css index 8cbb3bb25..7adb2cfd7 100644 --- a/static/css/quay.css +++ b/static/css/quay.css @@ -1148,6 +1148,34 @@ p.editable:hover i { font-size: 1.15em; } +#tagContextMenu { + display: none; + position: absolute; + z-index: 100000; + outline: none; +} + +#tagContextMenu ul { + display: block; + position: static; + margin-bottom: 5px; +} + +.tag-controls { + display: inline-block; + margin-right: 20px; + margin-top: 2px; + opacity: 0; + float: right; + -webkit-transition: opacity 200ms ease-in-out; + -moz-transition: opacity 200ms ease-in-out; + transition: opacity 200ms ease-in-out; +} + +.tag-heading:hover .tag-controls { + opacity: 1; +} + .right-title { display: inline-block; float: right; diff --git a/static/js/controllers.js b/static/js/controllers.js index f66f052dc..c58dd564a 100644 --- a/static/js/controllers.js +++ b/static/js/controllers.js @@ -194,6 +194,38 @@ function RepoCtrl($scope, Restangular, ApiService, $routeParams, $rootScope, $lo } }; + $scope.askDeleteTag = function(tagName) { + if (!$scope.repo.can_admin) { return; } + + $scope.tagToDelete = tagName; + $('#confirmdeleteTagModal').modal('show'); + }; + + $scope.deleteTag = function(tagName) { + if (!$scope.repo.can_admin) { return; } + $('#confirmdeleteTagModal').modal('hide'); + + var params = { + 'repository': namespace + '/' + name, + 'tag': tagName + }; + + ApiService.deleteFullTag(null, params).then(function() { + loadViewInfo(); + }, function(resp) { + bootbox.dialog({ + "message": resp.data ? resp.data : 'Could not delete tag', + "title": "Cannot delete tag", + "buttons": { + "close": { + "label": "Close", + "className": "btn-primary" + } + } + }); + }); + }; + $scope.setTag = function(tagName, opt_updateURL) { var repo = $scope.repo; var proposedTag = repo.tags[tagName]; @@ -229,6 +261,40 @@ function RepoCtrl($scope, Restangular, ApiService, $routeParams, $rootScope, $lo return count; }; + $scope.hideTagMenu = function(tagName, clientX, clientY) { + $scope.currentMenuTag = null; + + var tagMenu = $("#tagContextMenu"); + tagMenu.hide(); + }; + + $scope.showTagMenu = function(tagName, clientX, clientY) { + if (!$scope.repo.can_admin) { return; } + + $scope.currentMenuTag = tagName; + + var tagMenu = $("#tagContextMenu"); + tagMenu.css({ + display: "block", + left: clientX, + top: clientY + }); + + tagMenu.on("blur", function() { + setTimeout(function() { + tagMenu.hide(); + }, 100); // Needed to allow clicking on menu items. + }); + + tagMenu.on("click", "a", function() { + setTimeout(function() { + tagMenu.hide(); + }, 100); // Needed to allow clicking on menu items. + }); + + tagMenu[0].focus(); + }; + var getDefaultTag = function() { if ($scope.repo === undefined) { return undefined; @@ -343,6 +409,14 @@ function RepoCtrl($scope, Restangular, ApiService, $routeParams, $rootScope, $lo $scope.$apply(function() { $scope.setImage(e.image); }); }); + $($scope.tree).bind('showTagMenu', function(e) { + $scope.$apply(function() { $scope.showTagMenu(e.tag, e.clientX, e.clientY); }); + }); + + $($scope.tree).bind('hideTagMenu', function(e) { + $scope.$apply(function() { $scope.hideTagMenu(); }); + }); + return resp.images; }); }; diff --git a/static/js/graphing.js b/static/js/graphing.js index fbf5f2162..b7466da6a 100644 --- a/static/js/graphing.js +++ b/static/js/graphing.js @@ -69,6 +69,25 @@ ImageHistoryTree.prototype.calculateDimensions_ = function(container) { }; +ImageHistoryTree.prototype.setupOverscroll_ = function() { + var container = this.container_; + var that = this; + var overscroll = $('#' + container).overscroll(); + + overscroll.on('overscroll:dragstart', function() { + $(that).trigger({ + 'type': 'hideTagMenu' + }); + }); + + overscroll.on('scroll', function() { + $(that).trigger({ + 'type': 'hideTagMenu' + }); + }); +}; + + /** * Updates the dimensions of the tree. */ @@ -88,8 +107,8 @@ ImageHistoryTree.prototype.updateDimensions_ = function() { var boundingBox = document.getElementById(container).getBoundingClientRect(); document.getElementById(container).style.maxHeight = (viewportHeight - boundingBox.top - 110) + 'px'; - $('#' + container).overscroll(); - + this.setupOverscroll_(); + // Update the tree. var rootSvg = this.rootSvg_; var tree = this.tree_; @@ -183,8 +202,7 @@ ImageHistoryTree.prototype.draw = function(container) { this.root_.y0 = 0; this.setTag_(this.currentTag_); - - $('#' + container).overscroll(); + this.setupOverscroll_(); }; @@ -642,7 +660,7 @@ ImageHistoryTree.prototype.update_ = function(source) { if (tag == currentTag) { kind = 'success'; } - html += '' + tag + ''; + html += '' + tag + ''; } return html; }); @@ -654,6 +672,19 @@ ImageHistoryTree.prototype.update_ = function(source) { if (tag) { that.changeTag_(tag); } + }) + .on("contextmenu", function(d, e) { + d3.event.preventDefault(); + + var tag = this.getAttribute('data-tag'); + if (tag) { + $(that).trigger({ + 'type': 'showTagMenu', + 'tag': tag, + 'clientX': d3.event.clientX, + 'clientY': d3.event.clientY + }); + } }); // Ensure the tags are visible. diff --git a/static/partials/view-repo.html b/static/partials/view-repo.html index ebc1ea5b2..7d119d4fa 100644 --- a/static/partials/view-repo.html +++ b/static/partials/view-repo.html @@ -1,3 +1,9 @@ + +
@@ -73,7 +79,7 @@ sudo docker push quay.io/{{repo.namespace}}/{{repo.name}}
-
+
Tags + + Delete Tag +
@@ -180,3 +189,25 @@ sudo docker push quay.io/{{repo.namespace}}/{{repo.name}}
+ + + + diff --git a/test/data/test.db b/test/data/test.db index c2ef5d652..41dd4cb0b 100644 Binary files a/test/data/test.db and b/test/data/test.db differ