diff --git a/CHANGELOG.md b/CHANGELOG.md index 5c8d40f5d..3a96f3eb5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,7 @@ The following are features that have been merged, but not yet deployed: +- Add list/table view to the repositories page (#116) - Add ability to disable users via the superuser panel (#26) - Add a Changelog view to the superuser panel (#186) diff --git a/data/model/legacy.py b/data/model/legacy.py index 68b50370c..721bb38bc 100644 --- a/data/model/legacy.py +++ b/data/model/legacy.py @@ -330,29 +330,39 @@ def _list_entity_robots(entity_name): .where(User.robot == True, User.username ** (entity_name + '+%'))) -class _TupleWrapper(object): - def __init__(self, data, fields): - self._data = data - self._fields = fields - - def get(self, field): - return self._data[self._fields.index(field.name + ':' + field.model_class.__name__)] - - class TupleSelector(object): """ Helper class for selecting tuples from a peewee query and easily accessing them as if they were objects. """ + class _TupleWrapper(object): + def __init__(self, data, fields): + self._data = data + self._fields = fields + + def get(self, field): + return self._data[self._fields.index(TupleSelector.tuple_reference_key(field))] + + @classmethod + def tuple_reference_key(cls, field): + """ Returns a string key for referencing a field in a TupleSelector. """ + if field._node_type == 'func': + return field.name + ','.join([cls.tuple_reference_key(arg) for arg in field.arguments]) + + if field._node_type == 'field': + return field.name + ':' + field.model_class.__name__ + + raise Exception('Unknown field type %s in TupleSelector' % field._node_type) + def __init__(self, query, fields): self._query = query.select(*fields).tuples() - self._fields = [field.name + ':' + field.model_class.__name__ for field in fields] + self._fields = [TupleSelector.tuple_reference_key(field) for field in fields] def __iter__(self): return self._build_iterator() def _build_iterator(self): for tuple_data in self._query: - yield _TupleWrapper(tuple_data, self._fields) + yield TupleSelector._TupleWrapper(tuple_data, self._fields) @@ -934,22 +944,22 @@ def get_user_teams_within_org(username, organization): User.username == username) -def get_visible_repository_count(username=None, include_public=True, - namespace=None): - query = _visible_repository_query(username=username, - include_public=include_public, - namespace=namespace) - return query.count() - - def get_visible_repositories(username=None, include_public=True, page=None, - limit=None, sort=False, namespace=None, namespace_only=False): + limit=None, namespace=None, namespace_only=False, + include_actions=False, include_latest_tag=False): + + fields = [Repository.name, Repository.id, Repository.description, Visibility.name, + Namespace.username] + + if include_actions: + fields.append(fn.Max(RepositoryActionCount.count)) + + if include_latest_tag: + fields.append(fn.Max(RepositoryTag.lifetime_start_ts)) + query = _visible_repository_query(username=username, include_public=include_public, page=page, limit=limit, namespace=namespace, - select_models=[Repository, Namespace, Visibility]) - - if sort: - query = query.order_by(Repository.id.desc()) + select_models=fields) if limit: query = query.limit(limit) @@ -957,7 +967,24 @@ def get_visible_repositories(username=None, include_public=True, page=None, if namespace and namespace_only: query = query.where(Namespace.username == namespace) - return query + if include_actions: + # Filter the join to recent entries only. + last_week = datetime.now() - timedelta(weeks=1) + join_query = ((RepositoryActionCount.repository == Repository.id) & + (RepositoryActionCount.date >= last_week)) + + query = (query.switch(Repository) + .join(RepositoryActionCount, JOIN_LEFT_OUTER, on=join_query) + .group_by(RepositoryActionCount.repository, Repository.name, Repository.id, + Repository.description, Visibility.name, Namespace.username)) + + if include_latest_tag: + query = (query.switch(Repository) + .join(RepositoryTag, JOIN_LEFT_OUTER) + .group_by(RepositoryTag.repository, Repository.name, Repository.id, + Repository.description, Visibility.name, Namespace.username)) + + return TupleSelector(query, fields) def _visible_repository_query(username=None, include_public=True, limit=None, @@ -1101,7 +1128,7 @@ def get_matching_repositories(repo_term, username=None, limit=10, include_public namespace_term = repo_term name_term = repo_term - visible = get_visible_repositories(username, include_public=include_public) + visible = _visible_repository_query(username, include_public=include_public) search_clauses = (Repository.name ** ('%' + name_term + '%') | Namespace.username ** ('%' + namespace_term + '%')) diff --git a/endpoints/api/repository.py b/endpoints/api/repository.py index 802a0e36f..d2f3f4085 100644 --- a/endpoints/api/repository.py +++ b/endpoints/api/repository.py @@ -3,11 +3,16 @@ import logging import json import datetime + from datetime import timedelta from flask import request from data import model +from data.model import Namespace +from data.database import (Repository as RepositoryTable, Visibility, RepositoryTag, + RepositoryActionCount, fn) + from endpoints.api import (truthy_bool, format_date, nickname, log_action, validate_json_request, require_repo_read, require_repo_write, require_repo_admin, RepositoryParamResource, resource, query_param, parse_args, ApiResource, @@ -109,11 +114,12 @@ class RepositoryList(ApiResource): type=truthy_bool, default=True) @query_param('private', 'Whether to include private repositories.', type=truthy_bool, default=True) - @query_param('sort', 'Whether to sort the results.', type=truthy_bool, default=False) - @query_param('count', 'Whether to include a count of the total number of results available.', - type=truthy_bool, default=False) @query_param('namespace_only', 'Whether to limit only to the given namespace.', type=truthy_bool, default=False) + @query_param('last_modified', 'Whether to include when the repository was last modified.', + type=truthy_bool, default=False) + @query_param('popularity', 'Whether to include the repository\'s popularity metric.', + type=truthy_bool, default=False) def get(self, args): """Fetch the list of repositories under a variety of situations.""" username = None @@ -126,25 +132,31 @@ class RepositoryList(ApiResource): response = {} - repo_count = None - if args['count']: - repo_count = model.get_visible_repository_count(username, include_public=args['public'], - namespace=args['namespace']) - response['count'] = repo_count - - repo_query = model.get_visible_repositories(username, limit=args['limit'], page=args['page'], - include_public=args['public'], sort=args['sort'], + repo_query = model.get_visible_repositories(username, + limit=args['limit'], + page=args['page'], + include_public=args['public'], namespace=args['namespace'], - namespace_only=args['namespace_only']) + namespace_only=args['namespace_only'], + include_latest_tag=args['last_modified'], + include_actions=args['popularity']) def repo_view(repo_obj): repo = { - 'namespace': repo_obj.namespace_user.username, - 'name': repo_obj.name, - 'description': repo_obj.description, - 'is_public': repo_obj.visibility.name == 'public', + 'namespace': repo_obj.get(Namespace.username), + 'name': repo_obj.get(RepositoryTable.name), + 'description': repo_obj.get(RepositoryTable.description), + 'is_public': repo_obj.get(Visibility.name) == 'public' } + + if args['last_modified']: + repo['last_modified'] = repo_obj.get(fn.Max(RepositoryTag.lifetime_start_ts)) + + if args['popularity']: + repo['popularity'] = repo_obj.get(fn.Max(RepositoryActionCount.count)) or 0 + if get_authenticated_user(): - repo['is_starred'] = repo_obj.id in star_lookup + repo['is_starred'] = repo_obj.get(RepositoryTable.id) in star_lookup + return repo response['repositories'] = [repo_view(repo) for repo in repo_query] diff --git a/static/css/directives/ui/repo-list-table.css b/static/css/directives/ui/repo-list-table.css new file mode 100644 index 000000000..f0f7682a1 --- /dev/null +++ b/static/css/directives/ui/repo-list-table.css @@ -0,0 +1,32 @@ +.repo-list-table { + margin-top: 40px; +} + +.repo-list-table .repo-name-icon .avatar { + margin-right: 10px; +} + +.repo-list-table .repo-name-icon .namespace { + color: #444; + font-size: 85%; +} + +.repo-list-table .repo-name-icon .namespace:after { + content: " / "; +} + +.repo-list-table .empty { + color: #ccc; +} + +.repo-list-table .last-modified { + font-size: 13px; +} + +.repo-list-table .strength-indicator { + display: inline-block; +} + +.repo-list-table .popularity { + line-height: 10px; +} \ No newline at end of file diff --git a/static/css/directives/ui/strength-indicator.css b/static/css/directives/ui/strength-indicator.css new file mode 100644 index 000000000..dcd20638e --- /dev/null +++ b/static/css/directives/ui/strength-indicator.css @@ -0,0 +1,42 @@ +.strength-indicator .indicator-sliver { + margin: 1px; + width: 14px; + height: 3px; + border: 1px solid #D5D5D5; + transition: 0.5s ease; +} + +.strength-indicator .strength-indicator-element.good .indicator-sliver { + background: green; + border: 1px solid green; +} + +.strength-indicator .strength-indicator-element.fair .indicator-sliver { + background: orange; + border: 1px solid orange; +} + +.strength-indicator .strength-indicator-element.fair .indicator-sliver:last-child { + border: 1px solid #D5D5D5; + background: transparent; +} + +.strength-indicator .strength-indicator-element.barely .indicator-sliver { + background: rgb(255, 61, 0); + border: 1px solid rgb(255, 61, 0); +} + +.strength-indicator .strength-indicator-element.barely .indicator-sliver:last-child { + border: 1px solid #D5D5D5; + background: transparent; +} + +.strength-indicator .strength-indicator-element.barely .indicator-sliver:nth-child(3) { + border: 1px solid #D5D5D5; + background: transparent; +} + +.strength-indicator .strength-indicator-element.poor .indicator-sliver:first-child { + border: 1px solid red; + background: red; +} \ No newline at end of file diff --git a/static/css/pages/repo-list.css b/static/css/pages/repo-list.css index ac0481c86..49679b308 100644 --- a/static/css/pages/repo-list.css +++ b/static/css/pages/repo-list.css @@ -1,6 +1,11 @@ .repo-list .repo-list-panel { padding: 20px; - padding-top: 0px; + padding-top: 20px; +} + +.repo-list .repo-list-panel .btn-group { + float: right; + margin-bottom: 10px; } .repo-list .repo-list-namespaces h4 { diff --git a/static/css/quay.css b/static/css/quay.css index ca3c0109a..69adefbf4 100644 --- a/static/css/quay.css +++ b/static/css/quay.css @@ -3943,54 +3943,11 @@ pre.command:before { transition: opacity 0.5s ease; } -.location-view .ping-tower { +.location-view .strength-indicator { display: inline-block; vertical-align: middle; } -.location-view .ping-sliver { - margin: 1px; - width: 14px; - height: 3px; - border: 1px solid #D5D5D5; - transition: 0.5s ease; -} - -.location-view .ping-tower.good .ping-sliver { - background: green; - border: 1px solid green; -} - -.location-view .ping-tower.fair .ping-sliver { - background: orange; - border: 1px solid orange; -} - -.location-view .ping-tower.fair .ping-sliver:first-child { - border: 1px solid #D5D5D5; - background: transparent; -} - -.location-view .ping-tower.barely .ping-sliver { - background: rgb(255, 61, 0); - border: 1px solid rgb(255, 61, 0); -} - -.location-view .ping-tower.barely .ping-sliver:first-child { - border: 1px solid #D5D5D5; - background: transparent; -} - -.location-view .ping-tower.barely .ping-sliver:nth-child(2) { - border: 1px solid #D5D5D5; - background: transparent; -} - -.location-view .ping-tower.poor .ping-sliver:last-child { - border: 1px solid red; - background: red; -} - /* This is the visible area of you carousel. Set a width here to define how much items are visible. diff --git a/static/directives/location-view.html b/static/directives/location-view.html index 8a5ebb421..fbfd241ba 100644 --- a/static/directives/location-view.html +++ b/static/directives/location-view.html @@ -3,10 +3,5 @@ - -
-
-
-
-
+ diff --git a/static/directives/repo-list-table.html b/static/directives/repo-list-table.html new file mode 100644 index 000000000..10daa7d81 --- /dev/null +++ b/static/directives/repo-list-table.html @@ -0,0 +1,49 @@ +
+ + + + + + + + + + + + + + + + +
+ + + {{ repository.namespace }} + {{ repository.name }} + + + + {{ repository.last_modified * 1000 | amCalendar }} + + (Empty Repository) + +
+
\ No newline at end of file diff --git a/static/directives/strength-indicator.html b/static/directives/strength-indicator.html new file mode 100644 index 000000000..439b53639 --- /dev/null +++ b/static/directives/strength-indicator.html @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/static/js/directives/ui/location-view.js b/static/js/directives/ui/location-view.js index d2a043624..388a89b06 100644 --- a/static/js/directives/ui/location-view.js +++ b/static/js/directives/ui/location-view.js @@ -30,7 +30,6 @@ angular.module('quay').directive('locationView', function () { }; $scope.locationPing = null; - $scope.locationPingClass = null; $scope.getLocationTooltip = function(location, ping) { var tip = $scope.getLocationTitle(location) + '
'; @@ -71,35 +70,6 @@ angular.module('quay').directive('locationView', function () { if (!location) { return; } $scope.getLocationPing(location); }); - - $scope.$watch('locationPing', function(locationPing) { - if (locationPing == null) { - $scope.locationPingClass = null; - return; - } - - if (locationPing < 0) { - $scope.locationPingClass = 'error'; - return; - } - - if (locationPing < 100) { - $scope.locationPingClass = 'good'; - return; - } - - if (locationPing < 250) { - $scope.locationPingClass = 'fair'; - return; - } - - if (locationPing < 500) { - $scope.locationPingClass = 'barely'; - return; - } - - $scope.locationPingClass = 'poor'; - }); } }; return directiveDefinitionObject; diff --git a/static/js/directives/ui/repo-list-table.js b/static/js/directives/ui/repo-list-table.js new file mode 100644 index 000000000..c1d4071c5 --- /dev/null +++ b/static/js/directives/ui/repo-list-table.js @@ -0,0 +1,102 @@ +/** + * An element which displays a table of repositories. + */ +angular.module('quay').directive('repoListTable', function () { + var directiveDefinitionObject = { + priority: 0, + templateUrl: '/static/directives/repo-list-table.html', + replace: false, + transclude: true, + restrict: 'C', + scope: { + 'repositoriesResources': '=repositoriesResources', + 'namespaces': '=namespaces' + }, + controller: function($scope, $element, $filter) { + var orderBy = $filter('orderBy'); + + $scope.repositories = null; + $scope.orderedRepositories = []; + + $scope.maxPopularity = 0; + $scope.options = { + 'predicate': 'is_starred', + 'reverse': true + }; + + var buildOrderedRepositories = function() { + if (!$scope.repositories) { return; } + var modifier = $scope.options.reverse ? '-' : ''; + var fields = [modifier + $scope.options.predicate]; + + // Secondary ordering by full name. + if ($scope.options.predicate != 'full_name') { + fields.push('full_name'); + } + + var ordered = orderBy($scope.repositories, fields, false); + $scope.orderedRepositories = ordered; + }; + + $scope.tablePredicateClass = function(name, predicate, reverse) { + if (name != predicate) { + return ''; + } + + return 'current ' + (reverse ? 'reversed' : ''); + }; + + $scope.orderBy = function(predicate) { + if (predicate == $scope.options.predicate) { + $scope.options.reverse = !$scope.options.reverse; + return; + } + + $scope.options.reverse = false; + $scope.options.predicate = predicate; + }; + + $scope.getAvatarData = function(namespace) { + var found = {}; + $scope.namespaces.forEach(function(current) { + if (current.name == namespace) { + found = current.avatar; + } + }); + return found; + }; + + $scope.getStrengthClass = function(value, max, id) { + var adjusted = Math.round((value / max) * 5); + if (adjusted >= id) { + return 'active-' + adjusted; + } + + return ''; + }; + + $scope.$watch('options.predicate', buildOrderedRepositories); + $scope.$watch('options.reverse', buildOrderedRepositories); + + $scope.$watch('repositoriesResources', function(resources) { + $scope.repositories = []; + $scope.maxPopularity = 0; + + resources.forEach(function(resource) { + (resource.value || []).forEach(function(repository) { + var repositoryInfo = $.extend(repository, { + 'full_name': repository.namespace + '/' + repository.name, + 'last_modified_datetime': (new Date(repository.last_modified || 0)).valueOf() * (-1) + }); + + $scope.repositories.push(repositoryInfo); + $scope.maxPopularity = Math.max($scope.maxPopularity, repository.popularity); + }); + }); + + buildOrderedRepositories(); + }, /* deep */ true); + } + }; + return directiveDefinitionObject; +}); \ No newline at end of file diff --git a/static/js/directives/ui/strength-indicator.js b/static/js/directives/ui/strength-indicator.js new file mode 100644 index 000000000..1d67c578f --- /dev/null +++ b/static/js/directives/ui/strength-indicator.js @@ -0,0 +1,54 @@ +/** + * An element which displays the strength of a value (like a signal indicator on a cell phone). + */ +angular.module('quay').directive('strengthIndicator', function () { + var directiveDefinitionObject = { + priority: 0, + templateUrl: '/static/directives/strength-indicator.html', + replace: false, + transclude: true, + restrict: 'C', + scope: { + 'value': '=value', + 'maximum': '=maximum' + }, + controller: function($scope, $element) { + $scope.strengthClass = ''; + + var calculateClass = function() { + if ($scope.value == null || $scope.maximum == null) { + $scope.strengthClass = ''; + return; + } + + var value = Math.round(($scope.value / $scope.maximum) * 4); + + if (value <= 0) { + $scope.strengthClass = 'none'; + return; + } + + if (value <= 1) { + $scope.strengthClass = 'poor'; + return; + } + + if (value <= 2) { + $scope.strengthClass = 'barely'; + return; + } + + if (value <= 3) { + $scope.strengthClass = 'fair'; + return; + } + + $scope.strengthClass = 'good'; + }; + + $scope.$watch('maximum', calculateClass); + $scope.$watch('value', calculateClass); + } + }; + return directiveDefinitionObject; +}); \ No newline at end of file diff --git a/static/js/pages/repo-list.js b/static/js/pages/repo-list.js index 9ea73e774..0e44049e2 100644 --- a/static/js/pages/repo-list.js +++ b/static/js/pages/repo-list.js @@ -16,12 +16,14 @@ }]); - function RepoListCtrl($scope, $sanitize, $q, Restangular, UserService, ApiService) { + function RepoListCtrl($scope, $sanitize, $q, Restangular, UserService, ApiService, CookieService) { $scope.namespace = null; $scope.page = 1; $scope.publicPageCount = null; $scope.allRepositories = {}; $scope.loading = true; + $scope.resources = []; + $scope.showAsList = CookieService.get('quay.repoview') == 'list'; // When loading the UserService, if the user is logged in, create a list of // relevant namespaces and collect the relevant repositories. @@ -48,6 +50,11 @@ } }); + $scope.setShowAsList = function(value) { + $scope.showAsList = value; + CookieService.putPermanent('quay.repoview', value ? 'list' : 'grid'); + }; + $scope.isOrganization = function(namespace) { return !!UserService.getOrganization(namespace); }; @@ -97,11 +104,15 @@ 'public': false, 'sort': true, 'namespace': namespace.name, + 'last_modified': true, + 'popularity': true }; namespace.repositories = ApiService.listReposAsResource().withOptions(options).get(function(resp) { return resp.repositories.map(findDuplicateRepo); }); + + $scope.resources.push(namespace.repositories); }); }; } diff --git a/static/partials/repo-list.html b/static/partials/repo-list.html index 07590a0ac..e53b22fac 100644 --- a/static/partials/repo-list.html +++ b/static/partials/repo-list.html @@ -52,23 +52,37 @@
- -
+
+ +
- -
-
+
+
+
+ + +
+ +
+ + +
+
+
+
-
diff --git a/test/test_api_usage.py b/test/test_api_usage.py index bb7953b80..d72a22123 100644 --- a/test/test_api_usage.py +++ b/test/test_api_usage.py @@ -1244,6 +1244,11 @@ class TestListRepos(ApiTestCase): self.assertEquals(len(json['repositories']), 2) + def test_action_last_modified(self): + self.login(READ_ACCESS_USER) + json = self.getJsonResponse(RepositoryList, params=dict(last_modified=True, popularity=True)) + self.assertTrue(len(json['repositories']) > 2) + class TestViewPublicRepository(ApiTestCase): def test_normalview(self):