diff --git a/data/database.py b/data/database.py index 162057530..8b1310da9 100644 --- a/data/database.py +++ b/data/database.py @@ -6,6 +6,7 @@ import time from random import SystemRandom from datetime import datetime from peewee import * +from data.read_slave import ReadSlaveModel from sqlalchemy.engine.url import make_url from data.read_slave import ReadSlaveModel @@ -287,7 +288,7 @@ class Repository(BaseModel): # Therefore, we define our own deletion order here and use the dependency system to verify it. ordered_dependencies = [RepositoryAuthorizedEmail, RepositoryTag, Image, LogEntry, RepositoryBuild, RepositoryBuildTrigger, RepositoryNotification, - RepositoryPermission, AccessToken] + RepositoryPermission, AccessToken, Star] for query, fk in self.dependencies(search_nullable=True): model = fk.model_class @@ -301,6 +302,20 @@ class Repository(BaseModel): super(Repository, self).delete_instance(recursive=False, delete_nullable=False) +class Star(BaseModel): + user = ForeignKeyField(User, index=True) + repository = ForeignKeyField(Repository, index=True) + created = DateTimeField(default=datetime.now) + + class Meta: + database = db + read_slaves = (read_slave,) + indexes = ( + # create a unique index on user and repository + (('user', 'repository'), True), + ) + + class Role(BaseModel): name = CharField(index=True, unique=True) @@ -615,4 +630,4 @@ all_models = [User, Repository, Image, AccessToken, Role, RepositoryPermission, ExternalNotificationEvent, ExternalNotificationMethod, RepositoryNotification, RepositoryAuthorizedEmail, ImageStorageTransformation, DerivedImageStorage, TeamMemberInvite, ImageStorageSignature, ImageStorageSignatureKind, - AccessTokenKind] + AccessTokenKind, Star] diff --git a/data/migrations/versions/2088f2b81010_add_stars.py b/data/migrations/versions/2088f2b81010_add_stars.py new file mode 100644 index 000000000..27539f702 --- /dev/null +++ b/data/migrations/versions/2088f2b81010_add_stars.py @@ -0,0 +1,40 @@ +"""add stars + +Revision ID: 2088f2b81010 +Revises: 1c5b738283a5 +Create Date: 2014-12-02 17:45:00.707498 + +""" + +# revision identifiers, used by Alembic. +revision = '2088f2b81010' +down_revision = '4ef04c61fcf9' + +from alembic import op +import sqlalchemy as sa +from sqlalchemy.dialects import mysql + +def upgrade(tables): + op.create_table('star', + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('user_id', sa.Integer(), nullable=False), + sa.Column('repository_id', sa.Integer(), nullable=False), + sa.Column('created', sa.DateTime(), nullable=False), + sa.ForeignKeyConstraint(['repository_id'], ['repository.id'], name=op.f('fk_star_repository_id_repository')), + sa.ForeignKeyConstraint(['user_id'], ['user.id'], name=op.f('fk_star_user_id_user')), + sa.PrimaryKeyConstraint('id', name=op.f('pk_star')) + ) + with op.batch_alter_table('star', schema=None) as batch_op: + batch_op.create_index('star_repository_id', ['repository_id'], unique=False) + batch_op.create_index('star_user_id', ['user_id'], unique=False) + batch_op.create_index('star_user_id_repository_id', ['user_id', 'repository_id'], unique=True) + +def downgrade(tables): + op.drop_constraint('fk_star_repository_id_repository', 'star', type_='foreignkey') + op.drop_constraint('fk_star_user_id_user', 'star', type_='foreignkey') + with op.batch_alter_table('star', schema=None) as batch_op: + batch_op.drop_index('star_user_id_repository_id') + batch_op.drop_index('star_user_id') + batch_op.drop_index('star_repository_id') + + op.drop_table('star') diff --git a/data/model/legacy.py b/data/model/legacy.py index 331bf2720..ccf89d3c2 100644 --- a/data/model/legacy.py +++ b/data/model/legacy.py @@ -18,7 +18,7 @@ from data.database import (User, Repository, Image, AccessToken, Role, Repositor DerivedImageStorage, ImageStorageTransformation, random_string_generator, db, BUILD_PHASE, QuayUserField, ImageStorageSignature, QueueItem, ImageStorageSignatureKind, validate_database_url, db_for_update, - AccessTokenKind) + AccessTokenKind, Star) from peewee import JOIN_LEFT_OUTER, fn from util.validation import (validate_username, validate_email, validate_password, INVALID_PASSWORD_MESSAGE) @@ -813,7 +813,6 @@ def _visible_repository_query(username=None, include_public=True, limit=None, .join(RepositoryPermission, JOIN_LEFT_OUTER)) query = _filter_to_repos_for_user(query, username, namespace, include_public) - if page: query = query.paginate(page, limit) elif limit: @@ -822,8 +821,7 @@ def _visible_repository_query(username=None, include_public=True, limit=None, return query -def _filter_to_repos_for_user(query, username=None, namespace=None, - include_public=True): +def _filter_to_repos_for_user(query, username=None, namespace=None, include_public=True): if not include_public and not username: return Repository.select().where(Repository.id == '-1') @@ -2540,3 +2538,49 @@ def archivable_buildlogs_query(): .where((RepositoryBuild.phase == BUILD_PHASE.COMPLETE) | (RepositoryBuild.phase == BUILD_PHASE.ERROR) | (RepositoryBuild.started < presumed_dead_date), RepositoryBuild.logs_archived == False)) + + +def star_repository(user, repository): + """ Stars a repository. """ + star = Star.create(user=user.id, repository=repository.id) + star.save() + + +def unstar_repository(user, repository): + """ Unstars a repository. """ + try: + star = (Star + .delete() + .where(Star.repository == repository.id, Star.user == user.id) + .execute()) + except Star.DoesNotExist: + raise DataModelException('Star not found.') + + +def get_user_starred_repositories(user, limit=None, page=None): + """ Retrieves all of the repositories a user has starred. """ + query = (Repository + .select() + .join(Star) + .join(User) + .where(User.id == user.id) + .order_by(Star.created)) + + if page and limit: + query = query.paginate(page, limit) + elif limit: + query = query.limit(limit) + + return query + + +def repository_is_starred(user, repository): + """ Determines whether a user has starred a repository or not. """ + try: + (Star + .select() + .where(Star.repository == repository.id, Star.user == user.id) + .get()) + return True + except Star.DoesNotExist: + return False diff --git a/endpoints/api/repository.py b/endpoints/api/repository.py index 0a3acdcd7..3fe5301d3 100644 --- a/endpoints/api/repository.py +++ b/endpoints/api/repository.py @@ -102,24 +102,20 @@ class RepositoryList(ApiResource): @query_param('limit', 'Limit on the number of results (int)', type=int) @query_param('namespace', 'Namespace to use when querying for org repositories.', type=str) @query_param('public', 'Whether to include public repositories.', type=truthy_bool, default=True) - @query_param('private', 'Whether to inlcude private repositories.', type=truthy_bool, + @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) def get(self, args): """Fetch the list of repositories under a variety of situations.""" - def repo_view(repo_obj): - return { - 'namespace': repo_obj.namespace_user.username, - 'name': repo_obj.name, - 'description': repo_obj.description, - 'is_public': repo_obj.visibility.name == 'public', - } - username = None - if get_authenticated_user() and args['private']: - username = get_authenticated_user().username + if get_authenticated_user(): + starred_repos = model.get_user_starred_repositories(get_authenticated_user()) + star_lookup = set([repo.id for repo in starred_repos]) + + if args['private']: + username = get_authenticated_user().username response = {} @@ -132,6 +128,16 @@ class RepositoryList(ApiResource): repo_query = model.get_visible_repositories(username, limit=args['limit'], page=args['page'], include_public=args['public'], sort=args['sort'], namespace=args['namespace']) + 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', + } + if get_authenticated_user(): + repo['is_starred'] = repo_obj.id in star_lookup + return repo response['repositories'] = [repo_view(repo) for repo in repo_query if (repo.visibility.name == 'public' or @@ -271,6 +277,4 @@ class RepositoryVisibility(RepositoryParamResource): log_action('change_repo_visibility', namespace, {'repo': repository, 'visibility': values['visibility']}, repo=repo) - return { - 'success': True - } + return {'success': True} diff --git a/endpoints/api/user.py b/endpoints/api/user.py index d5ffae701..6c3cafd63 100644 --- a/endpoints/api/user.py +++ b/endpoints/api/user.py @@ -4,12 +4,14 @@ import json from flask import request from flask.ext.login import logout_user from flask.ext.principal import identity_changed, AnonymousIdentity +from peewee import IntegrityError from app import app, billing as stripe, authentication, avatar from endpoints.api import (ApiResource, nickname, resource, validate_json_request, request_error, log_action, internal_only, NotFound, require_user_admin, parse_args, query_param, InvalidToken, require_scope, format_date, hide_if, show_if, - license_error, require_fresh_login, path_param, define_json_response) + license_error, require_fresh_login, path_param, define_json_response, + RepositoryParamResource) from endpoints.api.subscribe import subscribe from endpoints.common import common_login from endpoints.api.team import try_accept_invite @@ -673,3 +675,87 @@ class UserAuthorization(ApiResource): access_token.delete_instance(recursive=True, delete_nullable=True) return 'Deleted', 204 + +@resource('/v1/user/starred') +class StarredRepositoryList(ApiResource): + """ Operations for creating and listing starred repositories. """ + schemas = { + 'NewStarredRepository': { + 'id': 'NewStarredRepository', + 'type': 'object', + 'required': [ + 'namespace', + 'repository', + ], + 'properties': { + 'namespace': { + 'type': 'string', + 'description': 'Namespace in which the repository belongs', + }, + 'repository': { + 'type': 'string', + 'description': 'Repository name' + } + } + } + } + + @nickname('listStarredRepos') + @parse_args + @query_param('page', 'Offset page number. (int)', type=int) + @query_param('limit', 'Limit on the number of results (int)', type=int) + @require_user_admin + def get(self, args): + """ List all starred repositories. """ + page = args['page'] + limit = args['limit'] + starred_repos = model.get_user_starred_repositories(get_authenticated_user(), + page=page, + limit=limit) + def repo_view(repo_obj): + return { + 'namespace': repo_obj.namespace_user.username, + 'name': repo_obj.name, + 'description': repo_obj.description, + 'is_public': repo_obj.visibility.name == 'public', + } + + return {'repositories': [repo_view(repo) for repo in starred_repos]} + + @require_scope(scopes.READ_REPO) + @nickname('createStar') + @validate_json_request('NewStarredRepository') + @require_user_admin + def post(self): + """ Star a repository. """ + user = get_authenticated_user() + req = request.get_json() + namespace = req['namespace'] + repository = req['repository'] + repo = model.get_repository(namespace, repository) + + if repo: + try: + model.star_repository(user, repo) + except IntegrityError: + pass + + return { + 'namespace': namespace, + 'repository': repository, + }, 201 + +@resource('/v1/user/starred/') +@path_param('repository', 'The full path of the repository. e.g. namespace/name') +class StarredRepository(RepositoryParamResource): + """ Operations for managing a specific starred repository. """ + + @nickname('deleteStar') + @require_user_admin + def delete(self, namespace, repository): + user = get_authenticated_user() + repo = model.get_repository(namespace, repository) + + if repo: + model.unstar_repository(user, repo) + return 'Deleted', 204 diff --git a/endpoints/web.py b/endpoints/web.py index 6f674cf56..c3af01e44 100644 --- a/endpoints/web.py +++ b/endpoints/web.py @@ -161,6 +161,11 @@ def confirm_invite(): def repository(path): return index('') +@web.route('/starred/') +@no_cache +def starred(): + return index('') + @web.route('/security/') @no_cache diff --git a/static/css/directives/repo-list-grid.css b/static/css/directives/repo-list-grid.css new file mode 100644 index 000000000..301905e11 --- /dev/null +++ b/static/css/directives/repo-list-grid.css @@ -0,0 +1,131 @@ +.repo-panel-title-row .repo-circle { + color: #999; + display: inline-block; + position: relative; + background: #eee; + padding: 4px; + border-radius: 50%; + display: inline-block; + width: 46px; + height: 46px; +} + +.repo-panel-title-row .repo-circle .fa-hdd-o { + font-size: 1.7em; +} + +.repo-panel-title-row .repo-circle.no-background .fa-hdd-o { + font-size: 1.7em; +} + +.repo-panel-title-row .repo-circle .fa-lock { + width: 16px; + height: 16px; + line-height: 16px; + font-size: 12px !important; +} + +.repo-panel-title-row .repo-circle.no-background .fa-lock { + bottom: 5px; + right: 2px; +} + +.empty-primary-msg { + font-size: 18px; + margin-bottom: 30px; + text-align: center; +} + +.empty-secondary-msg { + font-size: 14px; + color: #999; + text-align: center; + margin-bottom: 10px; +} + +.repo-list-title { + margin-bottom: 30px; + margin-top: 10px; + line-height: 24px; + font-size: 18px; +} + +.repo-list-title a { + font-size: 18px; + margin: 0; + display: inline-block; +} + +.repo-list-title i { + display: inline-block; + margin-right: 5px; +} + +.repo-list-title .starred { + color: #ffba6d; +} + +.repo-panel { + padding: 20px; + border: 1px solid #eee; + margin-bottom: 30px; +} + +.repo-panel-title-row { + white-space: nowrap; + text-overflow: ellipsis; + overflow: hidden; +} + +.panel-body.starred { + background: -moz-linear-gradient(top, rgba(255,240,188,1) 0%, rgba(255,255,255,0.5) 5%, rgba(255,255,255,0.49) 51%, rgba(255,255,255,0) 100%); /* FF3.6+ */ + background: -webkit-gradient(linear, left top, left bottom, color-stop(0%,rgba(255,240,188,1)), color-stop(5%,rgba(255,255,255,0.5)), color-stop(51%,rgba(255,255,255,0.49)), color-stop(100%,rgba(255,255,255,0))); /* Chrome,Safari4+ */ + background: -webkit-linear-gradient(top, rgba(255,240,188,1) 0%,rgba(255,255,255,0.5) 5%,rgba(255,255,255,0.49) 51%,rgba(255,255,255,0) 100%); /* Chrome10+,Safari5.1+ */ + background: -o-linear-gradient(top, rgba(255,240,188,1) 0%,rgba(255,255,255,0.5) 5%,rgba(255,255,255,0.49) 51%,rgba(255,255,255,0) 100%); /* Opera 11.10+ */ + background: -ms-linear-gradient(top, rgba(255,240,188,1) 0%,rgba(255,255,255,0.5) 5%,rgba(255,255,255,0.49) 51%,rgba(255,255,255,0) 100%); /* IE10+ */ + background: linear-gradient(to bottom, rgba(255,240,188,1) 0%,rgba(255,255,255,0.5) 5%,rgba(255,255,255,0.49) 51%,rgba(255,255,255,0) 100%); /* W3C */ +} + +.star-icon { + color: #ddd; + display: block; + font-size: 1.2em; + text-align: right; + line-height: 2em; +} + +.star-icon:hover { + cursor: pointer; + cursor: hand; +} + +.star-icon.starred { + color: #ffba6d; +} + +.new-repo-listing { + display: block; + border-bottom: 1px solid #eee; + font-size: 14px; + line-height: normal; + padding-bottom: 30px; +} + +.new-repo-listing .description { + font-size: 0.91em; + padding-top: 13px; +} + +.new-repo-listing .description { + white-space: nowrap; + text-overflow: ellipsis; + overflow: hidden; +} + +.repo-panel-repo-link { + font-size: 1.2em; +} + +.repo-list-grid { + padding-top: 10px; +} diff --git a/static/css/directives/repo-list.css b/static/css/directives/repo-list.css new file mode 100644 index 000000000..2ba509177 --- /dev/null +++ b/static/css/directives/repo-list.css @@ -0,0 +1,7 @@ +.repo-list-sidebar .button-bar-right { + margin-bottom: 20px; +} + +.repo-list-sidebar .panel .panel-body .fa-gear { + float: right; +} diff --git a/static/directives/repo-list-grid.html b/static/directives/repo-list-grid.html new file mode 100644 index 000000000..ea22b7cb4 --- /dev/null +++ b/static/directives/repo-list-grid.html @@ -0,0 +1,56 @@ +
+
+ + +
+ + Starred +
+
+ + {{ namespace.username }} +
+ + + +
+
+ +
+ + +
+
You haven't starred any repositories yet.
+
Stars allow you to easily access your favorite repositories.
+
+
+
This namespace doesn't have any viewable repositories.
+
Either no repositories exist yet or you may not have permission to view any. If you have permission, try creating a new repository.
+
+
+ +
+
diff --git a/static/js/directives/ui/markdown-view.js b/static/js/directives/ui/markdown-view.js index 65f123f33..88bdbf5be 100644 --- a/static/js/directives/ui/markdown-view.js +++ b/static/js/directives/ui/markdown-view.js @@ -10,12 +10,14 @@ angular.module('quay').directive('markdownView', function () { restrict: 'C', scope: { 'content': '=content', - 'firstLineOnly': '=firstLineOnly' + 'firstLineOnly': '=firstLineOnly', + 'placeholderNeeded': '=placeholderNeeded' }, controller: function($scope, $element, $sce, UtilService) { $scope.getMarkedDown = function(content, firstLineOnly) { if (firstLineOnly) { - return $sce.trustAsHtml(UtilService.getFirstMarkdownLineAsText(content)); + console.log($scope.placeholderNeeded); + return $sce.trustAsHtml(UtilService.getFirstMarkdownLineAsText(content, $scope.placeholderNeeded)); } return $sce.trustAsHtml(UtilService.getMarkedDown(content)); }; diff --git a/static/js/directives/ui/repo-list-grid.js b/static/js/directives/ui/repo-list-grid.js new file mode 100644 index 000000000..ae4d8ab81 --- /dev/null +++ b/static/js/directives/ui/repo-list-grid.js @@ -0,0 +1,18 @@ +/** + * An element that displays a list of repositories in a grid. + * + */ +angular.module('quay').directive('repoListGrid', function() { + return { + templateUrl: '/static/directives/repo-list-grid.html', + priority: 0, + restrict: 'C', + scope: { + repositories: '=repositories', + starred: '=starred', + user: "=user", + namespace: '=namespace', + toggleStar: '&toggleStar' + }, + }; +}); diff --git a/static/js/pages/repo-list.js b/static/js/pages/repo-list.js index 354a12895..a71b19a90 100644 --- a/static/js/pages/repo-list.js +++ b/static/js/pages/repo-list.js @@ -4,12 +4,128 @@ */ angular.module('quayPages').config(['pages', function(pages) { pages.create('repo-list', 'repo-list.html', RepoListCtrl, { + 'newLayout': true, 'title': 'Repositories', 'description': 'View and manage Docker repositories' - }); + }, ['layout']) + + pages.create('repo-list', 'old-repo-list.html', OldRepoListCtrl, { + 'title': 'Repositories', + 'description': 'View and manage Docker repositories' + }, ['old-layout']); }]); - function RepoListCtrl($scope, $sanitize, Restangular, UserService, ApiService) { + + function RepoListCtrl($scope, $sanitize, $q, Restangular, UserService, ApiService) { + $scope.namespace = null; + $scope.page = 1; + $scope.publicPageCount = null; + $scope.all_repositories = {}; + + // When loading the UserService, if the user is logged in, create a list of + // relevant namespaces for later collecting relevant repositories. + UserService.load(function() { + var user = UserService.currentUser(); + if (!user.anonymous) { + $scope.namespaces = [user]; + for (var i = 0; i < user.organizations.length; i++) { + $scope.namespaces.push(user.organizations[i]); + } + } + }); + + // If someone signs in on this page, we have to watch the user and re-load + // their repositories after they've signed in to actually have any content + // on the page. + $scope.$watch(function(scope) { return scope.user }, + function(user) { + if (!user.anonymous) { + $scope.namespaces = [user]; + for (var i = 0; i < user.organizations.length; i++) { + $scope.namespaces.push(user.organizations[i]); + } + loadStarredRepos(); + loadRepos(); + } + } + ); + + $scope.toggleStar = function(repo) { + if (repo.is_starred) { + unstarRepo(repo); + } else { + starRepo(repo); + } + } + + var starRepo = function(repo) { + var data = { + 'namespace': repo.namespace, + 'repository': repo.name + }; + ApiService.createStar(data).then(function(result) { + repo.is_starred = true; + $scope.starred_repositories.value.push(repo); + }, ApiService.errorDisplay('Could not star repository')); + }; + + var unstarRepo = function(repo) { + var data = { + 'repository': repo.namespace + '/' + repo.name + }; + ApiService.deleteStar(null, data).then(function(result) { + repo.is_starred = false; + $scope.starred_repositories.value = $scope.starred_repositories.value.filter(function(repo) { + return repo.is_starred; + }); + }, ApiService.errorDisplay('Could not unstar repository')); + }; + + // Finds a duplicate repo if it exists. If it doesn't, inserts the repo. + var findDuplicateRepo = function(repo) { + var found = $scope.all_repositories[repo.namespace + '/' + repo.name]; + if (found != undefined) { + return found; + } else { + $scope.all_repositories[repo.namespace + '/' + repo.name] = repo; + } + }; + + var loadStarredRepos = function() { + if (!$scope.user || $scope.user.anonymous) { + return; + } + + $scope.starred_repositories = ApiService.listStarredReposAsResource().get(function(resp) { + return resp.repositories.map(function(repo) { + repo = findDuplicateRepo(repo); + repo.is_starred = true; + return repo; + }); + }); + }; + + var loadRepos = function() { + if ($scope.namespaces.length == 0 || $scope.user.anonymous) { + return; + } + + for (var i = 0; i < $scope.namespaces.length; i++) { + var namespace = $scope.namespaces[i]; + var namespaceName = namespace.username || namespace.name; + var options = { + 'public': false, + 'sort': true, + 'namespace': namespaceName, + }; + namespace.repositories = ApiService.listReposAsResource().withOptions(options).get(function(resp) { + return resp.repositories.map(findDuplicateRepo); + }); + } + }; + } + + function OldRepoListCtrl($scope, $sanitize, Restangular, UserService, ApiService) { $scope.namespace = null; $scope.page = 1; $scope.publicPageCount = null; @@ -73,4 +189,4 @@ loadPublicRepos(); } -})(); \ No newline at end of file +})(); diff --git a/static/js/services/util-service.js b/static/js/services/util-service.js index 195097b08..64956f1b8 100644 --- a/static/js/services/util-service.js +++ b/static/js/services/util-service.js @@ -10,11 +10,16 @@ angular.module('quay').factory('UtilService', ['$sanitize', function($sanitize) }; utilService.getMarkedDown = function(string) { - return Markdown.getSanitizingConverter().makeHtml(string || ''); + return html = Markdown.getSanitizingConverter().makeHtml(string || ''); }; - utilService.getFirstMarkdownLineAsText = function(commentString) { - if (!commentString) { return ''; } + utilService.getFirstMarkdownLineAsText = function(commentString, placeholderNeeded) { + if (!commentString) { + if (placeholderNeeded) { + return '

placeholder

'; + } + return ''; + } var lines = commentString.split('\n'); var MARKDOWN_CHARS = { diff --git a/static/partials/old-repo-list.html b/static/partials/old-repo-list.html new file mode 100644 index 000000000..ab482de76 --- /dev/null +++ b/static/partials/old-repo-list.html @@ -0,0 +1,74 @@ +
+
+
+ + + +
+ +

Your Repositories

+

Repositories

+ +
+ +
+ +
+ + +
+
+

You don't have any repositories yet!

+

This organization doesn't have any repositories, or you have not been provided access.

+ Click here to learn how to create a repository +
+
+
+ +
+ +
+

Top Public Repositories

+ +
+
diff --git a/static/partials/repo-list.html b/static/partials/repo-list.html index ab482de76..4dc5342ec 100644 --- a/static/partials/repo-list.html +++ b/static/partials/repo-list.html @@ -1,74 +1,81 @@ -
-
-
- - - -
- -

Your Repositories

-

Repositories

- -
- -
- -
- - -
-
-

You don't have any repositories yet!

-

This organization doesn't have any repositories, or you have not been provided access.

- Click here to learn how to create a repository -
-
-
- +
+
+ + Repositories
+
+ +