diff --git a/data/model/legacy.py b/data/model/legacy.py index 4a4f4ca46..1cce22bfc 100644 --- a/data/model/legacy.py +++ b/data/model/legacy.py @@ -679,6 +679,27 @@ def get_user_or_org_by_customer_id(customer_id): except User.DoesNotExist: return None +def get_matching_user_entities(entity_prefix, user): + matching_user_orgs = ((User.username ** (entity_prefix + '%')) & (User.robot == False)) + matching_robots = ((User.username ** (user.username + '+%' + entity_prefix + '%')) & + (User.robot == True)) + + query = (User.select() + .where(matching_user_orgs | matching_robots) + .limit(10)) + + return query + +def get_matching_user_teams(team_prefix, user): + query = (Team.select() + .join(User) + .switch(Team) + .join(TeamMember) + .where(TeamMember.user == user, Team.name ** (team_prefix + '%')) + .limit(10)) + return query + + def get_matching_teams(team_prefix, organization): query = Team.select().where(Team.name ** (team_prefix + '%'), Team.organization == organization) @@ -942,9 +963,17 @@ def get_matching_repositories(repo_term, username=None): search_clauses = (Repository.name ** ('%' + name_term + '%') & Namespace.username ** ('%' + namespace_term + '%')) - final = visible.where(search_clauses).limit(10) - return list(final) + return visible.where(search_clauses).limit(10) +def get_repository_pull_counts(repositories): + repo_pull = LogEntryKind.get(name = 'pull_repo') + + return (Repository.select(Repository.id, fn.Count(LogEntry.id)) + .where(Repository.id << [r.id for r in repositories]) + .join(LogEntry, JOIN_LEFT_OUTER) + .where(LogEntry.kind == repo_pull) + .group_by(LogEntry.repository) + .tuples()) def change_password(user, new_password): if not validate_password(new_password): diff --git a/endpoints/api/search.py b/endpoints/api/search.py index 76223ac1c..718a19669 100644 --- a/endpoints/api/search.py +++ b/endpoints/api/search.py @@ -6,8 +6,10 @@ from auth.permissions import (OrganizationMemberPermission, ViewTeamPermission, AdministerOrganizationPermission) from auth.auth_context import get_authenticated_user from auth import scopes -from app import avatar +from app import avatar, get_app_url +from operator import itemgetter +import math @resource('/v1/entities/') class EntitySearch(ApiResource): @@ -101,6 +103,79 @@ class EntitySearch(ApiResource): } +@resource('/v1/find/all') +class ConductSearch(ApiResource): + """ Resource for finding users, repositories, teams, etc. """ + @parse_args + @query_param('query', 'The search query.', type=str, default='') + @require_scope(scopes.READ_REPO) + @nickname('conductSearch') + def get(self, args): + """ Get a list of entities and resources that match the specified query. """ + prefix = args['query'] + username = None + results = [] + + def entity_view(entity): + kind = 'user' + avatar_data = avatar.get_data_for_user(entity) + href = '/user/' + entity.username + + if entity.organization: + kind = 'organization' + avatar_data = avatar.get_data_for_org(entity) + href = '/organization/' + entity.username + elif entity.robot: + kind = 'robot' + href = '/user?tab=robots' + avatar_data = None + + return { + 'kind': kind, + 'avatar': avatar_data, + 'name': entity.username, + 'score': 1, + 'href': href + } + + if get_authenticated_user(): + username = get_authenticated_user().username + + # Find the matching teams. + matching_teams = model.get_matching_user_teams(prefix, get_authenticated_user()) + for team in matching_teams: + results.append({ + 'kind': 'team', + 'name': team.name, + 'organization': entity_view(team.organization), + 'avatar': avatar.get_data_for_team(team), + 'score': 2, + 'href': '/organization/' + team.organization.username + '/teams/' + team.name + }) + + # Find the matching repositories. + matching_repos = model.get_matching_repositories(prefix, username) + matching_repo_counts = {t[0]: t[1] for t in model.get_repository_pull_counts(matching_repos)} + + for repo in matching_repos: + results.append({ + 'kind': 'repository', + 'namespace': entity_view(repo.namespace_user), + 'name': repo.name, + 'description': repo.description, + 'is_public': repo.visibility.name == 'public', + 'score': math.log(matching_repo_counts.get(repo.id, 1), 10), + 'href': '/repository/' + repo.namespace_user.username + '/' + repo.name + }) + + # Find the matching users, robots and organizations. + matching_entities = model.get_matching_user_entities(prefix, get_authenticated_user()) + for entity in matching_entities: + results.append(entity_view(entity)) + + return {'results': sorted(results, key=itemgetter('score'), reverse=True)} + + @resource('/v1/find/repository') class FindRepositories(ApiResource): """ Resource for finding repositories. """ diff --git a/static/css/directives/ui/header-bar.css b/static/css/directives/ui/header-bar.css new file mode 100644 index 000000000..1c5a14249 --- /dev/null +++ b/static/css/directives/ui/header-bar.css @@ -0,0 +1,172 @@ +nav.navbar { + border: 0px; + border-radius: 0px; +} + +nav.navbar-default .navbar-nav>li>a { + letter-spacing: 0.5px; + color: #428bca; + font-size: 16px; +} + +nav.navbar-default .navbar-nav>li>a.active { + color: #f04c5c; +} + +.navbar-default .navbar-nav>li>a:hover, .navbar-default .navbar-nav>li>a:focus { + background: #eee; +} + +.navbar-default .navbar-nav>.open>a, .navbar-default .navbar-nav>.open>a:hover, .navbar-default .navbar-nav>.open>a:focus { + cursor: pointer; + background: rgba(255, 255, 255, 0.4) !important; +} + +.header-bar-element .header-bar-content.search-visible { + box-shadow: 0px 1px 4px #ccc; +} + +.header-bar-element .header-bar-content { + z-index: 4; + position: absolute; + top: 0px; + left: 0px; + right: 0px; + background: white; +} + +.header-bar-element .search-box { + position: absolute; + left: 0px; + right: 0px; + top: -50px; + z-index: 3; + height: 83px; + transition: top 0.7s cubic-bezier(.23,.88,.72,.98); + background: white; + box-shadow: 0px 1px 16px #444; + padding: 10px; +} + +.header-bar-element .search-box.search-visible { + top: 50px; +} + +.header-bar-element .search-box.results-visible { + box-shadow: 0px 1px 4px #ccc; +} + +.header-bar-element .search-box .search-label { + display: inline-block; + text-transform: uppercase; + font-size: 12px; + font-weight: bold; + color: #ccc; + margin-right: 10px; + position: absolute; + top: 34px; + left: 14px; +} + +.header-bar-element .search-box .search-box-wrapper { + position: absolute; + top: 0px; + left: 100px; + right: 10px; + padding: 10px; +} + +.header-bar-element .search-box .search-box-wrapper input { + font-size: 28px; + width: 100%; + padding: 10px; + border: 0px; +} + +.header-bar-element .search-results { + position: absolute; + left: 0px; + right: 0px; + top: -130px; + z-index: 2; + transition: top 0.7s cubic-bezier(.23,.88,.72,.98), height 0.5s ease-in-out; + + background: white; + box-shadow: 0px 1px 16px #444; + padding-top: 20px; +} + +.header-bar-element .search-results.loading, .header-bar-element .search-results.results { + top: 130px; +} + +.header-bar-element .search-results.loading { + height: 50px; +} + +.header-bar-element .search-results.no-results { + height: 150px; +} + +.header-bar-element .search-results ul { + padding: 0px; + margin: 0px; +} + +.header-bar-element .search-results li { + list-style: none; + padding: 6px; + margin-bottom: 4px; + padding-left: 20px; + position: relative; +} + +.header-bar-element .search-results li .kind { + text-transform: uppercase; + font-size: 12px; + display: inline-block; + margin-right: 10px; + color: #aaa; + width: 80px; + text-align: right; +} + +.header-bar-element .search-results .avatar { + margin-left: 6px; + margin-right: 2px; +} + +.header-bar-element .search-results li.current { + background: rgb(223, 242, 255); + cursor: pointer; +} + +.header-bar-element .search-results li i.fa { + margin-left: 6px; + margin-right: 4px; +} + +.header-bar-element .search-results li .description { + overflow: hidden; + text-overflow: ellipsis; + max-height: 24px; + padding-left: 10px; + display: inline-block; + color: #aaa; + vertical-align: middle; +} + +.header-bar-element .search-results li a { + color: black; + text-decoration: none !important; +} + +.header-bar-element .search-results li .result-name { + vertical-align: middle; +} + +.header-bar-element .search-results li .clarification { + font-size: 12px; + margin-left: 6px; + display: inline-block; +} \ No newline at end of file diff --git a/static/css/quay.css b/static/css/quay.css index 31812341c..a6c025e18 100644 --- a/static/css/quay.css +++ b/static/css/quay.css @@ -150,30 +150,6 @@ max-width: none !important; } -nav.navbar { - border: 0px; - border-radius: 0px; -} - -nav.navbar-default .navbar-nav>li>a { - letter-spacing: 0.5px; - color: #428bca; - font-size: 16px; -} - -nav.navbar-default .navbar-nav>li>a.active { - color: #f04c5c; -} - -.navbar-default .navbar-nav>li>a:hover, .navbar-default .navbar-nav>li>a:focus { - background: #eee; -} - -.navbar-default .navbar-nav>.open>a, .navbar-default .navbar-nav>.open>a:hover, .navbar-default .navbar-nav>.open>a:focus { - cursor: pointer; - background: rgba(255, 255, 255, 0.4) !important; -} - .notification-view-element { cursor: pointer; margin-bottom: 10px; diff --git a/static/directives/header-bar.html b/static/directives/header-bar.html index 051f51c88..fd40aee34 100644 --- a/static/directives/header-bar.html +++ b/static/directives/header-bar.html @@ -1,81 +1,140 @@ - - - - - - -