diff --git a/db/schema.v0.sql b/db/schema.v0.sql index eb4ac88..8649244 100644 --- a/db/schema.v0.sql +++ b/db/schema.v0.sql @@ -29,6 +29,26 @@ CREATE TABLE IF NOT EXISTS `user` ( PRIMARY KEY (`id`), UNIQUE INDEX `username_unique` (`name` ASC)); +-- ----------------------------------------------------- +-- Table `pinned_team` +-- ----------------------------------------------------- +CREATE TABLE IF NOT EXISTS `pinned_team` ( + `team_id` BIGINT(20) UNSIGNED NOT NULL, + `user_id` BIGINT(20) UNSIGNED NOT NULL, + INDEX `team_member_team_id_idx` (`team_id` ASC), + INDEX `team_member_user_id_idx` (`user_id` ASC), + PRIMARY KEY (`team_id`, `user_id`), + CONSTRAINT `pinned_team_team_id_fk` + FOREIGN KEY (`team_id`) + REFERENCES `team` (`id`) + ON DELETE CASCADE + ON UPDATE CASCADE, + CONSTRAINT `pinned_team_user_id_fk` + FOREIGN KEY (`user_id`) + REFERENCES `user` (`id`) + ON DELETE CASCADE + ON UPDATE CASCADE); + -- ----------------------------------------------------- -- Table `team_user` -- ----------------------------------------------------- diff --git a/e2e/test_pin.py b/e2e/test_pin.py new file mode 100644 index 0000000..dcb2081 --- /dev/null +++ b/e2e/test_pin.py @@ -0,0 +1,65 @@ +import requests +from testutils import prefix, api_v0 + + +@prefix('test_v0_pin_team') +def test_v0_pin_team(user, team): + user_name = user.create() + team_name = team.create() + team_name_2 = team.create() + + # Test pinning teams + re = requests.post(api_v0('users/%s/pinned_teams' % user_name), + json={'team': team_name}) + assert re.status_code == 201 + re = requests.post(api_v0('users/%s/pinned_teams' % user_name), + json={'team': team_name_2}) + assert re.status_code == 201 + + # Test getting pinned teams + re = requests.get(api_v0('users/%s/pinned_teams' % user_name)) + assert re.status_code == 200 + data = re.json() + assert len(data) == 2 + assert team_name in data + assert team_name_2 in data + + # Test deleting pinned teams + re = requests.delete(api_v0('users/%s/pinned_teams/%s' % (user_name, team_name))) + assert re.status_code == 200 + + re = requests.get(api_v0('users/%s/pinned_teams' % user_name)) + assert re.status_code == 200 + data = re.json() + assert len(data) == 1 + assert team_name not in data + + +@prefix('test_v0_pin_invalid') +def test_api_v0_pin_invalid(user, team): + user_name = user.create() + team_name = team.create() + + # Test pinning duplicate team + re = requests.post(api_v0('users/%s/pinned_teams' % user_name), + json={'team': team_name}) + assert re.status_code == 201 + re = requests.post(api_v0('users/%s/pinned_teams' % user_name), + json={'team': team_name}) + assert re.status_code == 400 + + # Test pinning nonexistent team + re = requests.post(api_v0('users/%s/pinned_teams' % user_name), + json={'team': 'nonexistent-team-foobar'}) + assert re.status_code == 422 + + # Test pinning team for nonexistent user + re = requests.post(api_v0('users/%s/pinned_teams' % 'nonexistent-user-foobar'), + json={'team': team_name}) + assert re.status_code == 422 + + # Test deleting unpinned team + re = requests.delete(api_v0('users/%s/pinned_teams/%s' % (user_name, team_name))) + assert re.status_code == 200 + re = requests.delete(api_v0('users/%s/pinned_teams/%s' % (user_name, team_name))) + assert re.status_code == 404 diff --git a/src/oncall/api/v0/__init__.py b/src/oncall/api/v0/__init__.py index 2bd73b7..ef4027a 100644 --- a/src/oncall/api/v0/__init__.py +++ b/src/oncall/api/v0/__init__.py @@ -72,6 +72,10 @@ def init(application, config): from . import upcoming_shifts application.add_route('/api/v0/users/{user_name}/upcoming', upcoming_shifts) + from . import user_pinned_teams, user_pinned_team + application.add_route('/api/v0/users/{user_name}/pinned_teams', user_pinned_teams) + application.add_route('/api/v0/users/{user_name}/pinned_teams/{team_name}', user_pinned_team) + # Optional Iris integration from . import iris_settings application.add_route('/api/v0/iris_settings', iris_settings) diff --git a/src/oncall/api/v0/event.py b/src/oncall/api/v0/event.py index 6311855..5381cff 100644 --- a/src/oncall/api/v0/event.py +++ b/src/oncall/api/v0/event.py @@ -171,6 +171,7 @@ def on_put(req, resp, event_id): def on_delete(req, resp, event_id): """ Delete an event by id, anyone on the team can delete that team's events + **Example request:** .. sourcecode:: http diff --git a/src/oncall/api/v0/user_pinned_team.py b/src/oncall/api/v0/user_pinned_team.py new file mode 100644 index 0000000..cc6827d --- /dev/null +++ b/src/oncall/api/v0/user_pinned_team.py @@ -0,0 +1,34 @@ +from ... import db +from ...auth import login_required, check_user_auth +from falcon import HTTPNotFound + + +@login_required +def on_delete(req, resp, user_name, team_name): + ''' + Delete a pinned team + + **Example request:** + + .. sourcecode:: http + + DELETE /api/v0/users/jdoe/pinned_teams/team-foo HTTP/1.1 + + :statuscode 200: Successful delete + :statuscode 403: Delete not allowed; logged in user does not match user_name + :statuscode 404: Team not found in user's pinned teams + ''' + check_user_auth(user_name, req) + connection = db.connect() + cursor = connection.cursor() + cursor.execute('''DELETE FROM `pinned_team` + WHERE `user_id` = (SELECT `id` FROM `user` WHERE `name` = %s) + AND `team_id` = (SELECT `id` FROM `team` WHERE `name` = %s)''', + (user_name, team_name)) + deleted = cursor.rowcount + connection.commit() + cursor.close() + connection.close() + if deleted == 0: + raise HTTPNotFound() + diff --git a/src/oncall/api/v0/user_pinned_teams.py b/src/oncall/api/v0/user_pinned_teams.py new file mode 100644 index 0000000..c0560cf --- /dev/null +++ b/src/oncall/api/v0/user_pinned_teams.py @@ -0,0 +1,89 @@ +from ... import db +from ...utils import load_json_body +from ...auth import login_required, check_user_auth +from ujson import dumps as json_dumps +from falcon import HTTPBadRequest, HTTP_201, HTTPError + + +def on_get(req, resp, user_name): + ''' + Get all pinned team names for a user + + **Example request**: + + .. sourcecode:: http + + GET /api/v0/users/jdoe/pinned_teams HTTP/1.1 + Host: example.com + + **Example response**: + + .. sourcecode:: http + + HTTP/1.1 200 OK + Content-Type: application/json + + [ + "team-foo" + ] + ''' + connection = db.connect() + cursor = connection.cursor() + cursor.execute('''SELECT `team`.`name` + FROM `pinned_team` JOIN `team` ON `pinned_team`.`team_id` = `team`.`id` + WHERE `pinned_team`.`user_id` = (SELECT `id` FROM `user` WHERE `name` = %s)''', + user_name) + teams = [r[0] for r in cursor] + cursor.close() + connection.close() + resp.body = json_dumps(teams) + + +@login_required +def on_post(req, resp, user_name): + ''' + Pin a team to the landing page for a user + + **Example request**: + + .. sourcecode:: http + + POST /api/v0/users/jdoe/pinned_teams HTTP/1.1 + Host: example.com + + { + "team": "team-foo" + } + + :statuscode 201: Successful team pin + :statuscode 400: Missing team parameter or team already pinned + ''' + check_user_auth(user_name, req) + data = load_json_body(req) + team = data.get('team') + if team is None: + raise HTTPBadRequest('Invalid team pin', 'Missing team parameter') + connection = db.connect() + cursor = connection.cursor() + try: + cursor.execute('''INSERT INTO `pinned_team` (`user_id`, `team_id`) + VALUES ((SELECT `id` FROM `user` WHERE `name` = %s), + (SELECT `id` FROM `team` WHERE `name` = %s))''', + (user_name, team)) + connection.commit() + except db.IntegrityError as e: + # Duplicate key + if e.args[0] == 1062: + raise HTTPBadRequest('Invalid team pin', 'Team already pinned for this user') + # Team/user is null + elif e.args[0] == 1048: + err_msg = str(e.args[1]) + if err_msg == 'Column \'user_id\' cannot be null': + err_msg = 'user "%s" not found' % user_name + elif err_msg == 'Column \'team_id\' cannot be null': + err_msg = 'team "%s" not found' % team + raise HTTPError('422 Unprocessable Entity', 'IntegrityError', err_msg) + finally: + cursor.close() + connection.close() + resp.status = HTTP_201 diff --git a/src/oncall/ui/static/css/oncall.css b/src/oncall/ui/static/css/oncall.css index 43ea74c..f732f25 100644 --- a/src/oncall/ui/static/css/oncall.css +++ b/src/oncall/ui/static/css/oncall.css @@ -537,13 +537,22 @@ nav.subnav li.active { padding: 16px; } -.recently-viewed h3 { +body[data-authenticated="false"] #pinned-teams { + display: none; +} + +.landing-teams { + display: inline-block; + width: 100%; +} + +.landing-teams h3 { margin-top: 10px; padding-top: 10px; border-top: 1px solid #dddedf; } -.module-card .recently-viewed-name { +.module-card .landing-teams-name { width: calc(100% - 60px); text-overflow: ellipsis; white-space: nowrap; @@ -759,6 +768,10 @@ nav.subnav li.active { opacity: 1; } + body[data-authenticated="false"] #pin-team { + display: none; +} + .team-name-input { background: none; color: #FFF; diff --git a/src/oncall/ui/static/js/oncall.js b/src/oncall/ui/static/js/oncall.js index aa1c62c..114d3bc 100644 --- a/src/oncall/ui/static/js/oncall.js +++ b/src/oncall/ui/static/js/oncall.js @@ -411,10 +411,12 @@ var oncall = { summaryUrl: '/api/v0/teams/', pageSource: $('#search-template').html(), searchResultsSource: $('#search-results-template').html(), - cardInnerTemplate: $('#recently-viewed-inner-template').html(), + cardInnerTemplate: $('#landing-teams-inner-template').html(), endpointTypes: ['services', 'teams'], searchForm: '.main-search', - recentlyViewed: null + recentlyViewed: null, + pinnedTeams: null, + pinnedPromise: $.Deferred() }, init: function(query){ var $form, @@ -427,117 +429,130 @@ var oncall = { self = this; Handlebars.registerPartial('dashboard-card-inner', this.data.cardInnerTemplate); + oncall.callbacks.onLogin = function(){ + self.init(); + }; this.data.recentlyViewed = oncall.recentlyViewed.getItems(); - this.renderPage(); - this.getTeamSummaries(); - - $form = this.data.$page.find(this.data.searchForm); - $input = $form.find('.search-input'); - - if (query) { - this.getData.call(this, query); - $form.find('.search-input').val(decodeURIComponent(query.query)); + if (oncall.data.user) { + $.get('/api/v0/users/' + oncall.data.user + '/pinned_teams').done(function(response){ + self.data.pinnedTeams = response; + self.data.pinnedPromise.resolve(); + }) + } else { + this.data.pinnedPromise.resolve(); } - services = new Bloodhound({ - datumTokenizer: Bloodhound.tokenizers.obj.whitespace('value'), - queryTokenizer: Bloodhound.tokenizers.whitespace, - remote: { - url: this.data.url + '?keyword=%QUERY', - rateLimitWait: 200, - wildcard: '%QUERY', - transform: function(resp){ - var newResp = [], + this.data.pinnedPromise.done(function() { + self.renderPage(); + self.getTeamSummaries(); + $form = self.data.$page.find(self.data.searchForm); + $input = $form.find('.search-input'); + + if (query) { + self.getData.call(self, query); + $form.find('.search-input').val(decodeURIComponent(query.query)); + } + + services = new Bloodhound({ + datumTokenizer: Bloodhound.tokenizers.obj.whitespace('value'), + queryTokenizer: Bloodhound.tokenizers.whitespace, + remote: { + url: self.data.url + '?keyword=%QUERY', + rateLimitWait: 200, + wildcard: '%QUERY', + transform: function(resp){ + var newResp = [], keys = Object.keys(resp.services); - servicesCt = keys.length; - for (var i = 0; i < keys.length; i++) { - newResp.push({ - team: resp.services[keys[i]], - service: keys[i] - }); - } + servicesCt = keys.length; + for (var i = 0; i < keys.length; i++) { + newResp.push({ + team: resp.services[keys[i]], + service: keys[i] + }); + } - return newResp; - } - } - }); - teams = new Bloodhound({ - datumTokenizer: Bloodhound.tokenizers.obj.whitespace('value'), - queryTokenizer: Bloodhound.tokenizers.whitespace, - remote: { - url: this.data.url + '?keyword=%QUERY', - rateLimitWait: 200, - wildcard: '%QUERY', - transform: function(resp){ - teamsCt = resp.teams.length; - return resp.teams; - } - } - }); - - $input.typeahead(null, { - name: 'teams', - hint: true, - async: true, - highlight: true, - limit: typeaheadLimit, - source: teams, - templates: { - header: function(){ - return '

Teams

'; - }, - suggestion: function(resp){ - return '
' + resp + '
'; - }, - footer: function(resp){ - if (teamsCt > typeaheadLimit) { - return '
See all ' + teamsCt + ' results for teams »
'; - } - }, - empty: function(resp){ - return '

No results found for "' + resp.query + '"

'; - } - } - }, - { - name: 'services', - hint: true, - async: true, - highlight: true, - limit: typeaheadLimit, - displayKey: 'team', - source: services, - templates: { - header: function(){ - return '

Services

'; - }, - suggestion: function(resp){ - return '
' + resp.service + ' - ' + '' + resp.team + '
'; - }, - footer: function(resp){ - if (servicesCt > typeaheadLimit) { - return '
See all ' + servicesCt + ' results for services »
'; + return newResp; } } - } - }); + }); + teams = new Bloodhound({ + datumTokenizer: Bloodhound.tokenizers.obj.whitespace('value'), + queryTokenizer: Bloodhound.tokenizers.whitespace, + remote: { + url: self.data.url + '?keyword=%QUERY', + rateLimitWait: 200, + wildcard: '%QUERY', + transform: function(resp){ + teamsCt = resp.teams.length; + return resp.teams; + } + } + }); - $input - .on('typeahead:asyncrequest', function(){ - $input.parents(self.data.searchForm).addClass('loading'); - }) - .on('typeahead:asyncreceive', function(){ - $input.parents(self.data.searchForm).removeClass('loading'); - }) - .on('typeahead:asynccancel', function(){ - $input.parents(self.data.searchForm).removeClass('loading'); - }) - .on('typeahead:render', function(){ - router.updatePageLinks(); - }) - .on('typeahead:selected', function(){ - router.navigate('/team/' + $(this).val()); + $input.typeahead(null, { + name: 'teams', + hint: true, + async: true, + highlight: true, + limit: typeaheadLimit, + source: teams, + templates: { + header: function(){ + return '

Teams

'; + }, + suggestion: function(resp){ + return '
' + resp + '
'; + }, + footer: function(resp){ + if (teamsCt > typeaheadLimit) { + return '
See all ' + teamsCt + ' results for teams »
'; + } + }, + empty: function(resp){ + return '

No results found for "' + resp.query + '"

'; + } + } + }, + { + name: 'services', + hint: true, + async: true, + highlight: true, + limit: typeaheadLimit, + displayKey: 'team', + source: services, + templates: { + header: function(){ + return '

Services

'; + }, + suggestion: function(resp){ + return '
' + resp.service + ' - ' + '' + resp.team + '
'; + }, + footer: function(resp){ + if (servicesCt > typeaheadLimit) { + return '
See all ' + servicesCt + ' results for services »
'; + } + } + } + }); + + $input + .on('typeahead:asyncrequest', function(){ + $input.parents(self.data.searchForm).addClass('loading'); + }) + .on('typeahead:asyncreceive', function(){ + $input.parents(self.data.searchForm).removeClass('loading'); + }) + .on('typeahead:asynccancel', function(){ + $input.parents(self.data.searchForm).removeClass('loading'); + }) + .on('typeahead:render', function(){ + router.updatePageLinks(); + }) + .on('typeahead:selected', function(){ + router.navigate('/team/' + $(this).val()); + }); }); }, events: function(){ @@ -555,7 +570,7 @@ var oncall = { $.get(this.data.url, param, this.renderResults.bind(this)); }, getTeamSummaries: function(){ - var data = this.data.recentlyViewed, + var data = this.data.pinnedTeams ? this.data.recentlyViewed.concat(this.data.pinnedTeams) : this.data.recentlyViewed, self = this; if (data) { for (var i = 0; i < data.length; i++) { @@ -581,7 +596,31 @@ var oncall = { }, renderPage: function(){ var template = Handlebars.compile(this.data.pageSource); - this.data.$page.html(template(this.data.recentlyViewed)); + this.data.$page.html(template({recent: this.data.recentlyViewed, pinned: this.data.pinnedTeams})); + + this.data.$page.on('click','.remove-card-column', function(){ + var $teamCard = $(this).closest('.module-card'), + $pinnedTeams = $('#pinned-teams'), + teamName = $teamCard.attr('data-team'); + + oncall.data.$body.addClass('loading-view'); + $.ajax({ + type: 'DELETE', + url: 'api/v0/users/' + oncall.data.user + '/pinned_teams/' + teamName, + dataType: 'html' + }).done(function(){ + $teamCard.hide(); + if ($teamCard.siblings(':visible').length === 0) { + $pinnedTeams.hide() + } + }).fail(function(data){ + var error = oncall.isJson(data.responseText) ? JSON.parse(data.responseText).description : data.responseText || 'Could not unpin team.'; + oncall.alerts.createAlert('Failed: ' + error, 'danger'); + }).always(function(){ + oncall.data.$body.removeClass('loading-view'); + }); + + }); this.events(); }, renderCardInner: function(data){ @@ -612,7 +651,9 @@ var oncall = { team: { data: { $page: $('.content-wrapper'), + $pinButton: $('#pin-team'), url: '/api/v0/teams/', + pinUrl: '/api/v0/users/', teamSubheaderTemplate: $('#team-subheader-template').html(), subheaderWrapper: '.subheader-wrapper', deleteTeam: '#delete-team', @@ -737,6 +778,28 @@ var oncall = { $cta.removeClass('loading disabled').prop('disabled', false); }); }, + pinTeam: function($modal) { + var $cta = $modal.find('.modal-cta'), + self = this; + + $cta.addClass('loading disabled').prop('disabled', true); + $.ajax({ + type: 'POST', + url: self.data.pinUrl + oncall.data.user + '/pinned_teams/', + contentType: 'application/json', + dataType: 'html', + data: JSON.stringify({team:self.data.teamName}) + }).done(function(){ + oncall.alerts.removeAlerts(); + oncall.alerts.createAlert('Pinned team to home page', 'success'); + }).fail(function(data){ + var error = oncall.isJson(data.responseText) ? JSON.parse(data.responseText).description : data.responseText || 'Pinning team failed.'; + oncall.alerts.createAlert(error, 'danger'); + }).always(function(){ + $cta.removeClass('loading disabled').prop('disabled', false); + $modal.modal('hide'); + }); + }, calendar: { data: { $page: $('.content-wrapper'), diff --git a/src/oncall/ui/templates/index.html b/src/oncall/ui/templates/index.html index c8dc806..4398959 100644 --- a/src/oncall/ui/templates/index.html +++ b/src/oncall/ui/templates/index.html @@ -129,11 +129,39 @@
- {{#if this}} -
+ {{#if this.pinned}} +
+

Pinned Teams

+
+ {{#each this.pinned}} +
+
+

+ + {{this}} + +

+ + + + + + + +
+
+ +
+
+ {{/each}} +
+
+ {{/if}} + {{#if this.recent}} +

Most Recently Viewed

- {{#each .}} + {{#each this.recent}}

@@ -199,14 +227,14 @@ {{/if}} - -