Merge master into bitbucket

This commit is contained in:
Joseph Schorr 2015-04-30 15:52:08 -04:00
commit b96e35b28c
44 changed files with 695 additions and 110 deletions

View file

@ -104,7 +104,9 @@ class BuildJob(object):
return None
# Build an in-memory tree of the full heirarchy of images in the repository.
all_images = model.get_repository_images(repo_namespace, repo_name)
all_images = model.get_repository_images_without_placements(repo_build.repository,
with_ancestor=base_image)
all_tags = model.list_repository_tags(repo_namespace, repo_name)
tree = ImageTree(all_images, all_tags, base_filter=base_image.id)

View file

@ -0,0 +1,25 @@
"""add custom-git trigger type to database
Revision ID: 37c47a7af956
Revises: 3fee6f979c2a
Create Date: 2015-04-24 14:50:26.275516
"""
# revision identifiers, used by Alembic.
revision = '37c47a7af956'
down_revision = '3fee6f979c2a'
from alembic import op
import sqlalchemy as sa
def upgrade(tables):
op.bulk_insert(tables.buildtriggerservice, [{'id': 2, 'name': 'custom-git'}])
def downgrade(tables):
op.execute(
tables.buildtriggerservice.delete()
.where(tables.buildtriggerservice.c.name == op.inline_literal('custom-git'))
)

View file

@ -1751,6 +1751,21 @@ def get_matching_repository_images(namespace_name, repository_name, docker_image
return _get_repository_images_base(namespace_name, repository_name, modify_query)
def get_repository_images_without_placements(repository, with_ancestor=None):
query = (Image
.select(Image, ImageStorage)
.join(ImageStorage)
.where(Image.repository == repository))
if with_ancestor:
ancestors_string = '%s%s/' % (with_ancestor.ancestors, with_ancestor.id)
query = query.where((Image.ancestors ** (ancestors_string + '%')) |
(Image.id == with_ancestor.id))
return query
def get_repository_images(namespace_name, repository_name):
return _get_repository_images_base(namespace_name, repository_name, lambda q: q)

View file

@ -444,19 +444,19 @@ class ConvertToOrganization(ApiResource):
user = get_authenticated_user()
convert_data = request.get_json()
# Ensure that the new admin user is the not user being converted.
admin_username = convert_data['adminUser']
if admin_username == user.username:
raise request_error(reason='invaliduser',
message='The admin user is not valid')
# Ensure that the sign in credentials work.
admin_username = convert_data['adminUser']
admin_password = convert_data['adminPassword']
(admin_user, error_message) = authentication.verify_user(admin_username, admin_password)
if not admin_user:
raise request_error(reason='invaliduser',
message='The admin user credentials are not valid')
# Ensure that the new admin user is the not user being converted.
if admin_user.id == user.id:
raise request_error(reason='invaliduser',
message='The admin user is not valid')
# Subscribe the organization to the new plan.
if features.BILLING:
plan = convert_data.get('plan', 'free')

View file

@ -140,7 +140,7 @@ def _repo_verb_signature(namespace, repository, tag, verb, checker=None, **kwarg
# Lookup the derived image storage for the verb.
derived = model.find_derived_storage(repo_image.storage, verb)
if derived is None or derived.uploading:
abort(404)
return make_response('', 202)
# Check if we have a valid signer configured.
if not signer.name:

View file

@ -28,6 +28,7 @@ from util.systemlogs import build_logs_archive
from auth import scopes
import features
import json
logger = logging.getLogger(__name__)
@ -432,16 +433,16 @@ def request_authorization_code():
# Load the application information.
oauth_app = provider.get_application_for_client_id(client_id)
app_email = oauth_app.email or organization.email
app_email = oauth_app.avatar_email or oauth_app.organization.email
oauth_app_view = {
'name': oauth_app.name,
'description': oauth_app.description,
'url': oauth_app.application_uri,
'avatar': avatar.get_data(oauth_app.name, app_email, 'app'),
'avatar': json.dumps(avatar.get_data(oauth_app.name, app_email, 'app')),
'organization': {
'name': oauth_app.organization.username,
'avatar': avatar.get_data_for_org(oauth_app.organization)
'avatar': json.dumps(avatar.get_data_for_org(oauth_app.organization))
}
}
@ -562,6 +563,9 @@ def redirect_to_repository(namespace, reponame, tag):
permission = ReadRepositoryPermission(namespace, reponame)
is_public = model.repository_is_public(namespace, reponame)
if request.args.get('ac-discovery', 0) == 1:
return index('')
if permission.can() or is_public:
repository_name = '/'.join([namespace, reponame])
return redirect(url_for('web.repository', path=repository_name, tag=tag))

View file

@ -20,7 +20,7 @@ EXTERNAL_JS = [
EXTERNAL_CSS = [
'netdna.bootstrapcdn.com/font-awesome/4.3.0/css/font-awesome.css',
'netdna.bootstrapcdn.com/bootstrap/3.3.2/css/bootstrap.min.css',
'fonts.googleapis.com/css?family=Source+Sans+Pro:400,700',
'fonts.googleapis.com/css?family=Source+Sans+Pro:300,400,700',
's3.amazonaws.com/cdn.core-os.net/icons/core-icons.css'
]

View file

@ -1,3 +1,10 @@
a:active {
outline: none !important;
}
a:focus {
outline: none !important;
}
.co-options-menu .fa-gear {
color: #999;
@ -879,6 +886,7 @@
}
.cor-title-link {
font-weight: 300;
line-height: 30px;
margin-top: 22px;
margin-bottom: 10px;

View file

@ -26,3 +26,9 @@
margin-right: 8px;
vertical-align: middle;
}
.repo-panel-changes .multiselect-dropdown {
display: inline-block;
margin-left: 10px;
min-width: 200px;
}

View file

@ -67,4 +67,8 @@
.repo-panel-info-element .builds-list {
min-height: 200px;
}
.repo-panel-info-element .copy-box {
vertical-align: middle;
}

View file

@ -48,7 +48,7 @@ nav.navbar-default .navbar-nav>li>a.active {
right: 0px;
top: -50px;
z-index: 4;
height: 83px;
height: 56px;
transition: top 0.3s cubic-bezier(.23,.88,.72,.98);
background: white;
box-shadow: 0px 1px 16px #444;
@ -71,7 +71,7 @@ nav.navbar-default .navbar-nav>li>a.active {
color: #ccc;
margin-right: 10px;
position: absolute;
top: 34px;
top: 20px;
left: 14px;
}
@ -84,9 +84,9 @@ nav.navbar-default .navbar-nav>li>a.active {
}
.header-bar-element .search-box .search-box-wrapper input {
font-size: 28px;
font-size: 18px;
width: 100%;
padding: 10px;
padding: 6px;
border: 0px;
}
@ -94,7 +94,7 @@ nav.navbar-default .navbar-nav>li>a.active {
position: absolute;
left: 0px;
right: 0px;
top: -130px;
top: -106px;
z-index: 3;
transition: top 0.4s cubic-bezier(.23,.88,.72,.98), height 0.25s ease-in-out;
@ -104,7 +104,7 @@ nav.navbar-default .navbar-nav>li>a.active {
}
.header-bar-element .search-results.loading, .header-bar-element .search-results.results {
top: 130px;
top: 106px;
}
.header-bar-element .search-results.loading {
@ -153,7 +153,7 @@ nav.navbar-default .navbar-nav>li>a.active {
margin-right: 4px;
}
.header-bar-element .search-results li .description {
.header-bar-element .search-results li .result-description {
overflow: hidden;
text-overflow: ellipsis;
max-height: 24px;
@ -161,6 +161,11 @@ nav.navbar-default .navbar-nav>li>a.active {
display: inline-block;
color: #aaa;
vertical-align: middle;
margin-top: 2px;
}
.header-bar-element .search-results li .description img {
display: none;
}
.header-bar-element .search-results li .score:before {

View file

@ -94,7 +94,12 @@
white-space: nowrap;
}
.logs-view-element .side-controls .filter-input {
vertical-align: middle;
}
.logs-view-element .side-controls {
float: none !important;
text-align: right;
margin-bottom: 20px;
}

View file

@ -0,0 +1,42 @@
.multiselect-dropdown .dropdown,
.multiselect-dropdown .dropdown .btn-dropdown,
.multiselect-dropdown .dropdown .dropdown-menu {
width: 100%;
}
.multiselect-dropdown .dropdown .btn-dropdown {
text-align: left;
position: relative;
padding-right: 16px;
}
.multiselect-dropdown .dropdown .btn-dropdown .caret {
position: absolute;
top: 14px;
right: 10px;
}
.multiselect-dropdown .none {
color: #ccc;
margin-right: 10px;
}
.multiselect-dropdown .dropdown-menu {
padding: 10px;
}
.multiselect-dropdown .dropdown-menu .menu-item {
padding: 4px;
}
.multiselect-dropdown .dropdown-menu .menu-item .co-checkable-item {
margin-right: 6px;
}
.multiselect-dropdown .dropdown-menu .menu-item .menu-item-template {
vertical-align: middle;
}
.multiselect-dropdown .selected-item-template {
margin-right: 10px;
}

View file

@ -0,0 +1,35 @@
.namespace-selector-dropdown .namespace {
padding: 6px;
padding-left: 10px;
cursor: pointer;
font-size: 14px;
color: black;
}
.namespace-selector-dropdown .namespace-item {
position: relative;
}
.namespace-selector-dropdown .namespace-item .fa {
position: absolute;
right: 12px;
top: 12px;
color: #aaa;
}
.namespace-selector-dropdown .avatar {
margin-right: 4px;
}
.namespace-selector-dropdown a.namespace {
color: black !important;
}
.namespace-selector-dropdown .namespace-item.disabled .avatar {
-webkit-filter: grayscale(1);
opacity: 0.5;
}
.namespace-selector-dropdown .namespace-item .tooltip-inner {
min-width: 200px;
}

View file

@ -90,9 +90,7 @@
.new-repo-listing .description {
font-size: 0.91em;
padding-top: 13px;
}
.new-repo-listing .description {
padding-left: 11px;
white-space: nowrap;
text-overflow: ellipsis;
overflow: hidden;

View file

@ -50,9 +50,7 @@
}
@media (max-width: 767px) {
.repository-view .repo-star {
position: absolute;
top: 16px;
left: -16px;
.repository-view .cor-title-content {
padding-top: 8px;
}
}

View file

@ -548,38 +548,6 @@ i.toggle-icon:hover {
float: right;
}
.namespace-selector-dropdown .namespace {
padding: 6px;
padding-left: 10px;
cursor: pointer;
font-size: 14px;
color: black;
}
.namespace-selector-dropdown .namespace-item {
position: relative;
}
.namespace-selector-dropdown .namespace-item .fa {
position: absolute;
right: 12px;
top: 12px;
color: #aaa;
}
.namespace-selector-dropdown a.namespace {
color: black !important;
}
.namespace-selector-dropdown .namespace-item.disabled img {
-webkit-filter: grayscale(1);
opacity: 0.5;
}
.namespace-selector-dropdown .namespace-item .tooltip-inner {
min-width: 200px;
}
.notification-primary {
background: #428bca;
color: white;

View file

@ -30,7 +30,7 @@
<div class="container-logs" ng-show="container.logs.isVisible">
<div class="log-entry" bindonce ng-repeat="entry in container.logs.visibleEntries">
<span class="id" bo-text="$index + container.index + 1" ng-if="!useTimestamps"></span>
<span class="id" bo-text="formatDatetime(entry.datetime)" ng-if="useTimestamps"></span>
<span class="id" bo-text="formatDatetime(entry.data.datetime)" ng-if="useTimestamps"></span>
<span class="message" bo-html="processANSI(entry.message, container)"></span>
<span class="timestamp" bo-text="formatDatetime(entry.data.datetime)" ng-if="!useTimestamps"></span>
</div>

View file

@ -1,6 +1,7 @@
<span class="build-mini-status-element">
<span class="anchor" href="/repository/{{ build.repository.namespace }}/{{ build.repository.name }}/build/{{ build.id }}"
is-text-only="!isAdmin">
<span class="anchor"
href="/repository/{{ build.repository.namespace }}/{{ build.repository.name }}/build/{{ build.id }}"
is-only-text="!isAdmin">
<div>
<span class="build-state-icon" build="build"></span>
<span class="timing">

View file

@ -1,3 +1,3 @@
<div class="col-lg-6 col-md-6 col-sm-6 col-xs-12">
<div class="col-lg-6 col-md-6 col-sm-5 col-xs-12">
<h2 class="co-nav-title-content co-fx-text-shadow" ng-transclude></h2>
</div>

View file

@ -1 +1 @@
<div class="col-lg-3 col-md-3 hidden-sm hidden-xs" ng-transclude></div>
<div class="col-lg-3 col-md-3 col-sm-3 hidden-xs" ng-transclude></div>

View file

@ -0,0 +1,31 @@
<div class="multiselect-dropdown-element">
<div class="dropdown" style="text-align: left;">
<button class="btn-dropdown btn btn-default" data-toggle="dropdown">
<span class="selected-item-template" ng-repeat="item in selectedItems" ng-transcope></span>
<span class="none" ng-if="!selectedItems.length">(No {{ itemName }}s selected)</span>
<span class="caret" ng-if="!readOnly"></span>
</button>
<ul class="dropdown-menu noclose">
<li>
<input type="search" class="form-control" ng-model="filter" placeholder="{{ itemName }} filter...">
</li>
<li role="presentation" class="divider"></li>
<li ng-repeat="item in items | filter:filter">
<a class="menu-item" href="javascript:void(0)" ng-click="toggleItem(item)">
<span class="co-checkable-item" ng-class="isChecked(selectedItems, item) ? 'checked': 'not-checked'">
</span>
<span class="menu-item-template" ng-transcope></span>
</a>
</li>
<li role="presentation" ng-if="(items | filter:filter).length == 0">
<div class="empty">
<div class="empty-primary-msg">No matching {{ itemName }}s found</div>
<div class="empty-secondary-msg">
Please reduce your filter above
</div>
</div>
</li>
</ul>
</div>
</div>

View file

@ -108,8 +108,8 @@
data-container="body" data-animation="am-slide-right" bs-aside>
<i class="fa fa-bell user-tool"
data-placement="bottom" data-title="Notifications" bs-tooltip></i>
<span class="notifications-bubble"></span>
</a>
<span class="notifications-bubble"></span>
</span>
</li>
@ -185,7 +185,7 @@
<span ng-switch-when="repository">
<span class="avatar" data="result.namespace.avatar" size="16"></span>
<span class="result-name">{{ result.namespace.name }}/{{ result.name }}</span>
<div class="description" ng-if="result.description">
<div class="result-description" ng-if="result.description">
<div class="description markdown-view" content="result.description"
first-line-only="true" placeholder-needed="false"></div>
</div>

View file

@ -1,6 +1,6 @@
<div class="plan-manager-element">
<!-- Loading/Changing -->
<div class="quay-spinner 3x" ng-show="planLoading"></div>
<div class="cor-loader" ng-show="planLoading"></div>
<!-- Alerts -->
<div class="co-alert co-alert-danger" ng-show="limit == 'over' && !planLoading">
@ -58,7 +58,7 @@
<div ng-switch='plan.deprecated'>
<div ng-switch-when='true'>
<button class="btn btn-danger" ng-click="cancelSubscription()">
<span class="quay-spinner" ng-show="planChanging"></span>
<span class="cor-loader-inline" ng-show="planChanging"></span>
<span ng-show="!planChanging">Cancel</span>
</button>
</div>
@ -66,14 +66,14 @@
<button class="btn" ng-show="subscribedPlan.stripeId !== plan.stripeId"
ng-class="subscribedPlan.price == 0 ? 'btn-primary' : 'btn-default'"
ng-click="changeSubscription(plan.stripeId)">
<span class="quay-spinner" ng-show="planChanging"></span>
<span class="cor-loader-inline" ng-show="planChanging"></span>
<span ng-show="!planChanging && subscribedPlan.price != 0">Change</span>
<span ng-show="!planChanging && subscribedPlan.price == 0 && !isExistingCustomer">Start Free Trial</span>
<span ng-show="!planChanging && subscribedPlan.price == 0 && isExistingCustomer">Subscribe</span>
</button>
<button class="btn btn-danger" ng-show="subscription.plan === plan.stripeId && plan.price > 0"
ng-click="cancelSubscription()">
<span class="quay-spinner" ng-show="planChanging"></span>
<span class="cor-loader-inline" ng-show="planChanging"></span>
<span ng-show="!planChanging">Cancel</span>
</button>
</div>

View file

@ -1,23 +1,24 @@
<div class="repo-panel-changes-element">
<div class="resource-view" resource="imagesResource"
error-message="'Could not load repository images'">
<h3 class="tab-header">
Visualize Tags:
<span class="multiselect-dropdown" items="tagNames" selected-items="selectedTags"
item-name="tag" item-checked="updateState()">
<span class="tag-span">{{ item }}</span>
</span>
</h3>
<!-- No Tags Selected -->
<div class="empty" ng-if="!selectedTags.length">
<div class="empty-primary-msg">No tags selected to view</div>
<div class="empty-secondary-msg">
Please select one or more tags in the <i class="fa fa-tags" style="margin-left: 4px; margin-right: 4px;"></i> Tags tab to visualize.
Please select one or more tags above.
</div>
</div>
<!-- Tags Selected -->
<div ng-if="selectedTags.length > 0">
<h3 class="tab-header">
Visualize Tags:
<span class="visualized-tag" ng-repeat="tag in selectedTags">
<i class="fa fa-tag"></i>{{ tag }}
</span>
</h3>
<div ng-show="selectedTags.length > 0">
<div id="image-history row" class="resource-view" resource="imagesResource"
error-message="'Cannot load repository images'">

View file

@ -6,12 +6,12 @@
<div class="stat-title">Repo Pulls</div>
<div class="stat">
<div class="stat-value">{{ repository.stats.pulls.today }}</div>
<div class="stat-value">{{ repository.stats.pulls.today | abbreviated }}</div>
<div class="stat-subtitle">Last 24 hours</div>
</div>
<div class="stat">
<div class="stat-value">{{ repository.stats.pulls.thirty_day }}</div>
<div class="stat-value">{{ repository.stats.pulls.thirty_day | abbreviated }}</div>
<div class="stat-subtitle">Last 30 days</div>
</div>
</div>
@ -21,12 +21,12 @@
<div class="stat-title">Repo Pushes</div>
<div class="stat">
<div class="stat-value">{{ repository.stats.pushes.today }}</div>
<div class="stat-value">{{ repository.stats.pushes.today | abbreviated }}</div>
<div class="stat-subtitle">Last 24 hours</div>
</div>
<div class="stat">
<div class="stat-value">{{ repository.stats.pushes.thirty_day }}</div>
<div class="stat-value">{{ repository.stats.pushes.thirty_day | abbreviated }}</div>
<div class="stat-subtitle">Last 30 days</div>
</div>
</div>

View file

@ -133,12 +133,12 @@
</td>
<td class="options-col">
<span class="cor-options-menu" ng-if="repository.can_write">
<span class="cor-option" option-click="askDeleteTag(tag.name)">
<i class="fa fa-times"></i> Delete Tag
</span>
<span class="cor-option" option-click="askAddTag(tag)">
<i class="fa fa-plus"></i> Add New Tag
</span>
<span class="cor-option" option-click="askDeleteTag(tag.name)">
<i class="fa fa-times"></i> Delete Tag
</span>
</span>
</td>
</tr>

View file

@ -65,7 +65,7 @@
<span class="empty" bo-if="robotInfo.teams.length > 0">
<span ng-repeat="team in robotInfo.teams"
data-title="Team {{ team.name }}" bs-tooltip>
<span class="anchor" is-text-only="!organization.admin" href="/organization/{{ organization.name }}/teams/{{ team.name }}">
<span class="anchor" is-only-text="!organization.admin" href="/organization/{{ organization.name }}/teams/{{ team.name }}">
<span class="avatar" size="24" data="team.avatar"></span>
</span>
</span>
@ -78,7 +78,7 @@
<span class="member-perm-summary" bo-if="robotInfo.repositories.length > 0">
Direct Permissions on
<span class="anchor hidden-xs" href="javascript:void(0)" is-text-only="!organization.is_admin"
<span class="anchor hidden-xs" href="javascript:void(0)" is-only-text="!organization.is_admin"
ng-click="showPermissions(robotInfo)">
<span bo-text="robotInfo.repositories.length"></span>
<span bo-if="robotInfo.repositories.length == 1">repository</span>
@ -96,7 +96,7 @@
<span class="cor-option" option-click="showRobot(robotInfo)">
<i class="fa fa-key"></i> View Credentials
</span>
<span class="cor-option" option-click="deleteRobot(robotInfo)">
<span class="cor-option" option-click="askDeleteRobot(robotInfo)">
<i class="fa fa-times"></i> Delete Robot {{ robotInfo.name }}
</span>
</span>

View file

@ -0,0 +1,26 @@
/**
* Filter which displays numbers with suffixes.
*
* Based on: https://gist.github.com/pedrorocha-net/9aa21d5f34d9cc15d18f
*/
angular.module('quay').filter('abbreviated', function() {
return function(number) {
if (number >= 10000000) {
return (number / 1000000).toFixed(0) + 'M'
}
if (number >= 1000000) {
return (number / 1000000).toFixed(1) + 'M'
}
if (number >= 10000) {
return (number / 1000).toFixed(0) + 'K'
}
if (number >= 1000) {
return (number / 1000).toFixed(1) + 'K'
}
return number
}
});

View file

@ -0,0 +1,25 @@
/**
* Directive to transclude a template under an ng-repeat. From: http://stackoverflow.com/a/24512435
*/
angular.module('quay').directive('ngTranscope', function() {
return {
link: function( $scope, $element, $attrs, controller, $transclude ) {
if ( !$transclude ) {
throw minErr( 'ngTranscope' )( 'orphan',
'Illegal use of ngTransclude directive in the template! ' +
'No parent directive that requires a transclusion found. ' +
'Element: {0}',
startingTag( $element ));
}
var innerScope = $scope.$new();
$transclude( innerScope, function( clone ) {
$element.empty();
$element.append( clone );
$element.on( '$destroy', function() {
innerScope.$destroy();
});
});
}
};
});

View file

@ -57,14 +57,16 @@ angular.module('quay').directive('repoPanelBuilds', function () {
$scope.fullBuilds = orderBy(unordered, $scope.options.predicate, $scope.options.reverse);
};
var loadBuilds = function() {
var loadBuilds = function(opt_forcerefresh) {
if (!$scope.builds || !$scope.repository || !$scope.options.filter) {
return;
}
// Note: We only refresh if the filter has changed.
var filter = $scope.options.filter;
if ($scope.buildsResource && filter == $scope.currentFilter) { return; }
if ($scope.buildsResource && filter == $scope.currentFilter && !opt_forcerefresh) {
return;
}
var since = null;
var limit = 10;
@ -104,17 +106,30 @@ angular.module('quay').directive('repoPanelBuilds', function () {
}
// Replace any build records with updated records from the server.
var requireReload = false;
$scope.builds.map(function(build) {
var found = false;
for (var i = 0; i < $scope.allBuilds.length; ++i) {
var current = $scope.allBuilds[i];
if (current.id == build.id && current.phase != build.phase) {
$scope.allBuilds[i] = build;
break
found = true;
break;
}
}
// If the build was not found, then a new build has started. Reload
// the builds list.
if (!found) {
requireReload = true;
}
});
updateBuilds();
if (requireReload) {
loadBuilds(/* force refresh */true);
} else {
updateBuilds();
}
};
var loadBuildTriggers = function() {

View file

@ -96,20 +96,24 @@ angular.module('quay').directive('repoPanelChanges', function () {
'isEnabled': '=isEnabled'
},
controller: function($scope, $element, $timeout, ApiService, UtilService, ImageMetadataService) {
$scope.tagNames = [];
var update = function() {
if (!$scope.repository || !$scope.selectedTags) { return; }
if (!$scope.repository || !$scope.isEnabled) { return; }
$scope.tagNames = Object.keys($scope.repository.tags);
$scope.currentImage = null;
$scope.currentTag = null;
if (!$scope.tracker) {
if ($scope.tracker) {
refreshTree();
} else {
updateImages();
}
};
var updateImages = function() {
if (!$scope.repository || !$scope.images) { return; }
if (!$scope.repository || !$scope.images || !$scope.isEnabled) { return; }
$scope.tracker = new RepositoryImageTracker($scope.repository, $scope.images);
@ -120,16 +124,17 @@ angular.module('quay').directive('repoPanelChanges', function () {
$scope.$watch('selectedTags', update)
$scope.$watch('repository', update);
$scope.$watch('isEnabled', update);
$scope.$watch('images', updateImages);
$scope.$watch('isEnabled', function(isEnabled) {
if (isEnabled) {
refreshTree();
}
});
$scope.updateState = function() {
update();
};
var refreshTree = function() {
if (!$scope.repository || !$scope.images) { return; }
if (!$scope.repository || !$scope.images || !$scope.isEnabled) { return; }
if ($scope.selectedTags.length < 1) { return; }
$('#image-history-container').empty();
@ -149,6 +154,7 @@ angular.module('quay').directive('repoPanelChanges', function () {
// Give enough time for the UI to be drawn before we resize the tree.
$timeout(function() {
$scope.tree.notifyResized();
$scope.setTag($scope.selectedTags[0]);
}, 100);
// Listen for changes to the selected tag and image in the tree.

View file

@ -0,0 +1,35 @@
/**
* An element which displays a dropdown for selecting multiple elements.
*/
angular.module('quay').directive('multiselectDropdown', function ($compile) {
var directiveDefinitionObject = {
priority: 0,
templateUrl: '/static/directives/multiselect-dropdown.html',
transclude: true,
replace: false,
restrict: 'C',
scope: {
'items': '=items',
'selectedItems': '=selectedItems',
'itemName': '@itemName',
'itemChecked': '&itemChecked'
},
controller: function($scope, $element) {
$scope.isChecked = function(checked, item) {
return checked.indexOf(item) >= 0;
};
$scope.toggleItem = function(item) {
var isChecked = $scope.isChecked($scope.selectedItems, item);
if (!isChecked) {
$scope.selectedItems.push(item);
} else {
var index = $scope.selectedItems.indexOf(item);
$scope.selectedItems.splice(index, 1);
}
$scope.itemChecked({'item': item, 'checked': !isChecked});
};
}
};
return directiveDefinitionObject;
});

View file

@ -128,6 +128,15 @@ angular.module('quay').directive('robotsManager', function () {
}, ApiService.errorDisplay('Cannot delete robot account'));
};
$scope.askDeleteRobot = function(info) {
bootbox.confirm('Are you sure you want to delete robot ' + info.name + '?', function(resp) {
if (resp) {
$scope.deleteRobot(info);
}
});
};
var update = function() {
if (!$scope.user && !$scope.organization) { return; }
if ($scope.loading || !$scope.isEnabled) { return; }

View file

@ -56,6 +56,16 @@ function($rootScope, $interval, UserService, ApiService, StringBuilderService, P
'<br><br>Please upgrade your plan to avoid disruptions in service.',
'page': function(metadata) {
var organization = UserService.getOrganization(metadata['namespace']);
// TODO(jschorr): Remove once the new layout is in prod.
if (Config.isNewLayout()) {
if (organization) {
return '/organization/' + metadata['namespace'] + '?tab=billing';
} else {
return '/user/' + metadata['namespace'] + '?tab=billing';
}
}
if (organization) {
return '/organization/' + metadata['namespace'] + '/admin';
} else {

View file

@ -0,0 +1,267 @@
/* ========================================================================
* Bootstrap Dropdowns Enhancement: dropdowns-enhancement.js v3.1.1 (Beta 1)
* http://behigh.github.io/bootstrap_dropdowns_enhancement/
* ========================================================================
* Licensed under MIT (https://github.com/twbs/bootstrap/blob/master/LICENSE)
* ======================================================================== */
(function($) {
"use strict";
var toggle = '[data-toggle="dropdown"]',
disabled = '.disabled, :disabled',
backdrop = '.dropdown-backdrop',
menuClass = 'dropdown-menu',
subMenuClass = 'dropdown-submenu',
namespace = '.bs.dropdown.data-api',
eventNamespace = '.bs.dropdown',
openClass = 'open',
touchSupport = 'ontouchstart' in document.documentElement,
opened;
function Dropdown(element) {
$(element).on('click' + eventNamespace, this.toggle)
}
var proto = Dropdown.prototype;
proto.toggle = function(event) {
var $element = $(this);
if ($element.is(disabled)) return;
var $parent = getParent($element);
var isActive = $parent.hasClass(openClass);
var isSubMenu = $parent.hasClass(subMenuClass);
var menuTree = isSubMenu ? getSubMenuParents($parent) : null;
closeOpened(event, menuTree);
if (!isActive) {
if (!menuTree)
menuTree = [$parent];
if (touchSupport && !$parent.closest('.navbar-nav').length && !menuTree[0].find(backdrop).length) {
// if mobile we use a backdrop because click events don't delegate
$('<div class="' + backdrop.substr(1) + '"/>').appendTo(menuTree[0]).on('click', closeOpened)
}
for (var i = 0, s = menuTree.length; i < s; i++) {
if (!menuTree[i].hasClass(openClass)) {
menuTree[i].addClass(openClass);
positioning(menuTree[i].children('.' + menuClass), menuTree[i]);
}
}
opened = menuTree[0];
}
return false;
};
proto.keydown = function (e) {
if (!/(38|40|27)/.test(e.keyCode)) return;
var $this = $(this);
e.preventDefault();
e.stopPropagation();
if ($this.is('.disabled, :disabled')) return;
var $parent = getParent($this);
var isActive = $parent.hasClass('open');
if (!isActive || (isActive && e.keyCode == 27)) {
if (e.which == 27) $parent.find(toggle).trigger('focus');
return $this.trigger('click')
}
var desc = ' li:not(.divider):visible a';
var desc1 = 'li:not(.divider):visible > input:not(disabled) ~ label';
var $items = $parent.find(desc1 + ', ' + '[role="menu"]' + desc + ', [role="listbox"]' + desc);
if (!$items.length) return;
var index = $items.index($items.filter(':focus'));
if (e.keyCode == 38 && index > 0) index--; // up
if (e.keyCode == 40 && index < $items.length - 1) index++; // down
if (!~index) index = 0;
$items.eq(index).trigger('focus')
};
proto.change = function (e) {
var
$parent,
$menu,
$toggle,
selector,
text = '',
$items;
$menu = $(this).closest('.' + menuClass);
$toggle = $menu.parent().find('[data-label-placement]');
if (!$toggle || !$toggle.length) {
$toggle = $menu.parent().find(toggle);
}
if (!$toggle || !$toggle.length || $toggle.data('placeholder') === false)
return; // do nothing, no control
($toggle.data('placeholder') == undefined && $toggle.data('placeholder', $.trim($toggle.text())));
text = $.data($toggle[0], 'placeholder');
$items = $menu.find('li > input:checked');
if ($items.length) {
text = [];
$items.each(function () {
var str = $(this).parent().find('label').eq(0),
label = str.find('.data-label');
if (label.length) {
var p = $('<p></p>');
p.append(label.clone());
str = p.html();
}
else {
str = str.html();
}
str && text.push($.trim(str));
});
text = text.length < 4 ? text.join(', ') : text.length + ' selected';
}
var caret = $toggle.find('.caret');
$toggle.html(text || '&nbsp;');
if (caret.length)
$toggle.append(' ') && caret.appendTo($toggle);
};
function positioning($menu, $control) {
if ($menu.hasClass('pull-center')) {
$menu.css('margin-right', $menu.outerWidth() / -2);
}
if ($menu.hasClass('pull-middle')) {
$menu.css('margin-top', ($menu.outerHeight() / -2) - ($control.outerHeight() / 2));
}
}
function closeOpened(event, menuTree) {
if (opened) {
if (!menuTree) {
menuTree = [opened];
}
var parent;
if (opened[0] !== menuTree[0][0]) {
parent = opened;
} else {
parent = menuTree[menuTree.length - 1];
if (parent.parent().hasClass(menuClass)) {
parent = parent.parent();
}
}
parent.find('.' + openClass).removeClass(openClass);
if (parent.hasClass(openClass))
parent.removeClass(openClass);
if (parent === opened) {
opened = null;
$(backdrop).remove();
}
}
}
function getSubMenuParents($submenu) {
var result = [$submenu];
var $parent;
while (!$parent || $parent.hasClass(subMenuClass)) {
$parent = ($parent || $submenu).parent();
if ($parent.hasClass(menuClass)) {
$parent = $parent.parent();
}
if ($parent.children(toggle)) {
result.unshift($parent);
}
}
return result;
}
function getParent($this) {
var selector = $this.attr('data-target');
if (!selector) {
selector = $this.attr('href');
selector = selector && /#[A-Za-z]/.test(selector) && selector.replace(/.*(?=#[^\s]*$)/, ''); //strip for ie7
}
var $parent = selector && $(selector);
return $parent && $parent.length ? $parent : $this.parent()
}
// DROPDOWN PLUGIN DEFINITION
// ==========================
var old = $.fn.dropdown;
$.fn.dropdown = function (option) {
return this.each(function () {
var $this = $(this);
var data = $this.data('bs.dropdown');
if (!data) $this.data('bs.dropdown', (data = new Dropdown(this)));
if (typeof option == 'string') data[option].call($this);
})
};
$.fn.dropdown.Constructor = Dropdown;
$.fn.dropdown.clearMenus = function(e) {
$(backdrop).remove();
$('.' + openClass + ' ' + toggle).each(function () {
var $parent = getParent($(this));
var relatedTarget = { relatedTarget: this };
if (!$parent.hasClass('open')) return;
$parent.trigger(e = $.Event('hide' + eventNamespace, relatedTarget));
if (e.isDefaultPrevented()) return;
$parent.removeClass('open').trigger('hidden' + eventNamespace, relatedTarget);
});
return this;
};
// DROPDOWN NO CONFLICT
// ====================
$.fn.dropdown.noConflict = function () {
$.fn.dropdown = old;
return this
};
$(document).off(namespace)
.on('click' + namespace, closeOpened)
.on('click' + namespace, toggle, proto.toggle)
.on('click' + namespace, '.dropdown-menu > li > input[type="checkbox"] ~ label, .dropdown-menu > li > input[type="checkbox"], .dropdown-menu.noclose > li', function (e) {
e.stopPropagation()
})
.on('change' + namespace, '.dropdown-menu > li > input[type="checkbox"], .dropdown-menu > li > input[type="radio"]', proto.change)
.on('keydown' + namespace, toggle + ', [role="menu"], [role="listbox"]', proto.keydown)
}(jQuery));

View file

@ -14,7 +14,7 @@
</span>
</div>
<div class="row" style="margin-top: 10px" ng-if="!application.redirect_uri">
<div class="row" style="padding: 14px; padding-top: 0px; padding-bottom: 0px;" ng-if="!application.redirect_uri">
<div class="co-alert co-alert-warning">
Warning: There is no OAuth Redirect setup for this application. Please enter it in the <strong>Settings</strong> tab.
</div>

View file

@ -90,9 +90,9 @@
<!-- Payment -->
<div class="required-plan" ng-show="repo.is_public == '0' && planRequired && planRequired.title">
<div class="co-alert co-alert-warning">
In order to make this repository private
<span ng-if="isUserNamespace">under your personal namespace</span>
<span ng-if="!isUserNamespace">under the organization <b>{{ repo.namespace }}</b></span>, you will need to upgrade your plan to
In order to make this repository private under
<strong ng-if="isUserNamespace">your personal namespace</strong>
<strong ng-if="!isUserNamespace">organization <b>{{ repo.namespace }}</b></strong>, you will need to upgrade your plan to
<b style="border-bottom: 1px dotted black;" data-html="true"
data-title="{{ '<b>' + planRequired.title + '</b><br>' + planRequired.privateRepos + ' private repositories' }}" bs-tooltip>
{{ planRequired.title }}
@ -102,10 +102,10 @@
<a class="btn btn-primary" ng-click="upgradePlan()" ng-show="!planChanging">Upgrade now</a>
<span ng-if="isUserNamespace && user.organizations.length == 1" style="margin-left: 6px; display: inline-block;">or did you mean to create this repository
under <a href="javascript:void(0)" ng-click="changeNamespace(user.organizations[0].name)"><b>{{ user.organizations[0].name }}</b></a>?</span>
<div class="quay-spinner" ng-show="planChanging"></div>
<div class="cor-loader-inline" ng-show="planChanging"></div>
</div>
<div class="quay-spinner" ng-show="repo.is_public == '0' && checkingPlan"></div>
<div class="cor-loader-inline" ng-show="repo.is_public == '0' && checkingPlan"></div>
<div class="required-plan" ng-show="repo.is_public == '0' && planRequired && !isUserNamespace && !planRequired.title">
<div class="co-alert co-alert-warning">

View file

@ -8,6 +8,12 @@
<span class="avatar" size="32" data="organization.avatar"></span>
<span class="organization-name">{{ organization.name }}</span>
</span>
<span class="cor-title-action" ng-if="isMember">
<a href="/new/?namespace={{ organization.name }}">
<i class="fa fa-plus" data-title="Create new repository"></i>
Create New Repository
</a>
</span>
</div>
<div class="co-main-content-panel" ng-if="user.anonymous || !isMember">

View file

@ -11,7 +11,7 @@
<span class="cor-title-content">
<span class="repo-circle no-background hidden-xs" repo="viewScope.repository"></span>
{{ namespace }} / {{ name }}
<span class="repo-star" repository="viewScope.repository" ng-if="!user.anonymous"></span>
<span class="repo-star hidden-xs" repository="viewScope.repository" ng-if="!user.anonymous"></span>
</span>
</div>

View file

@ -8,6 +8,12 @@
<span class="avatar" size="32" data="viewuser.avatar"></span>
<span class="user-name">{{ viewuser.username }}</span>
</span>
<span class="cor-title-action" ng-if="viewuser.is_me">
<a href="/new/?namespace={{ viewuser.username }}">
<i class="fa fa-plus" data-title="Create new repository"></i>
Create New Repository
</a>
</span>
</div>
<div class="co-main-content-panel user-repo-list" ng-if="!viewuser.is_me">

View file

@ -13,10 +13,10 @@
<div class="container auth-container" ng-if="!user.anonymous">
<div class="auth-header">
<span class="avatar" size="48" hash="'{{ application.avatar }}'"></span>
<span class="avatar" size="48" data="{{ application.avatar }}"></span>
<h2><a href="{{ application.url }}" target="_blank">{{ application.name }}</a></h2>
<h4>
<span class="avatar" size="24" hash="'{{ application.organization.avatar }}'"
<span class="avatar" size="24" data="{{ application.organization.avatar }}"
style="vertical-align: middle; margin-right: 4px;"></span>
<span style="vertical-align: middle">{{ application.organization.name }}</span>
</h4>

View file

@ -308,6 +308,16 @@ class TestConvertToOrganization(ApiTestCase):
self.assertEqual('The admin user is not valid', json['message'])
def test_sameadminuser_by_email(self):
self.login(READ_ACCESS_USER)
json = self.postJsonResponse(ConvertToOrganization,
data={'adminUser': 'no1@thanks.com',
'adminPassword': 'password',
'plan': 'free'},
expected_code=400)
self.assertEqual('The admin user is not valid', json['message'])
def test_invalidadminuser(self):
self.login(READ_ACCESS_USER)
json = self.postJsonResponse(ConvertToOrganization,

View file

@ -91,6 +91,28 @@ class TestImageTree(unittest.TestCase):
self.assertEquals('staging', tree.tag_containing_image(result[0]))
def test_longest_path_simple_repo_direct_lookup(self):
repository = model.get_repository(NAMESPACE, SIMPLE_REPO)
all_images = list(model.get_repository_images(NAMESPACE, SIMPLE_REPO))
all_tags = list(model.list_repository_tags(NAMESPACE, SIMPLE_REPO))
base_image = self._get_base_image(all_images)
tag_image = all_tags[0].image
def checker(index, image):
return True
filtered_images = model.get_repository_images_without_placements(repository,
with_ancestor=base_image)
self.assertEquals(set([f.id for f in filtered_images]), set([a.id for a in all_images]))
tree = ImageTree(filtered_images, all_tags)
ancestors = tag_image.ancestors.split('/')[2:-1] # Skip the first image.
result = tree.find_longest_path(base_image.id, checker)
self.assertEquals(3, len(result))
self.assertEquals('latest', tree.tag_containing_image(result[-1]))
if __name__ == '__main__':
unittest.main()