Add ability to dismiss notifications

This commit is contained in:
Joseph Schorr 2014-07-28 18:23:46 -04:00
parent 34fc279092
commit 32b2ecdfa6
9 changed files with 162 additions and 11 deletions

View file

@ -363,7 +363,7 @@ class Notification(BaseModel):
target = ForeignKeyField(User, index=True) target = ForeignKeyField(User, index=True)
metadata_json = TextField(default='{}') metadata_json = TextField(default='{}')
created = DateTimeField(default=datetime.now, index=True) created = DateTimeField(default=datetime.now, index=True)
dismissed = BooleanField(default=False)
class ExternalNotificationEvent(BaseModel): class ExternalNotificationEvent(BaseModel):

View file

@ -1699,7 +1699,15 @@ def create_unique_notification(kind_name, target, metadata={}):
create_notification(kind_name, target, metadata) create_notification(kind_name, target, metadata)
def list_notifications(user, kind_name=None): def lookup_notification(user, uuid):
results = list(list_notifications(user, id_filter=uuid, include_dismissed=True))
if not results:
return None
return results[0]
def list_notifications(user, kind_name=None, id_filter=None, include_dismissed=False):
Org = User.alias() Org = User.alias()
AdminTeam = Team.alias() AdminTeam = Team.alias()
AdminTeamMember = TeamMember.alias() AdminTeamMember = TeamMember.alias()
@ -1723,12 +1731,20 @@ def list_notifications(user, kind_name=None):
.order_by(Notification.created) .order_by(Notification.created)
.desc()) .desc())
if not include_dismissed:
query = query.switch(Notification).where(Notification.dismissed == False)
if kind_name: if kind_name:
query = (query query = (query
.switch(Notification) .switch(Notification)
.join(NotificationKind) .join(NotificationKind)
.where(NotificationKind.name == kind_name)) .where(NotificationKind.name == kind_name))
if id_filter:
query = (query
.switch(Notification)
.where(Notification.uuid == id_filter))
return query return query

View file

@ -74,10 +74,12 @@ def user_view(user):
def notification_view(notification): def notification_view(notification):
return { return {
'id': notification.uuid,
'organization': notification.target.username if notification.target.organization else None, 'organization': notification.target.username if notification.target.organization else None,
'kind': notification.kind.name, 'kind': notification.kind.name,
'created': format_date(notification.created), 'created': format_date(notification.created),
'metadata': json.loads(notification.metadata_json), 'metadata': json.loads(notification.metadata_json),
'dismissed': notification.dismissed
} }
@ -409,6 +411,46 @@ class UserNotificationList(ApiResource):
} }
@resource('/v1/user/notifications/<uuid>')
@internal_only
class UserNotification(ApiResource):
schemas = {
'UpdateNotification': {
'id': 'UpdateNotification',
'type': 'object',
'description': 'Information for updating a notification',
'properties': {
'dismissed': {
'type': 'boolean',
'description': 'Whether the notification is dismissed by the user',
},
},
},
}
@require_user_admin
@nickname('getUserNotification')
def get(self, uuid):
notification = model.lookup_notification(get_authenticated_user(), uuid)
if not notification:
raise NotFound()
return notification_view(notification)
@require_user_admin
@nickname('updateUserNotification')
@validate_json_request('UpdateNotification')
def put(self, uuid):
notification = model.lookup_notification(get_authenticated_user(), uuid)
if not notification:
raise NotFound()
notification.dismissed = request.get_json().get('dismissed', False)
notification.save()
return notification_view(notification)
def authorization_view(access_token): def authorization_view(access_token):
oauth_app = access_token.application oauth_app = access_token.application
return { return {

View file

@ -89,6 +89,11 @@ nav.navbar-default .navbar-nav>li>a {
background: rgba(66, 139, 202, 0.1); background: rgba(66, 139, 202, 0.1);
} }
.notification-view-element .right-controls {
text-align: right;
font-size: 12px;
}
.dockerfile-path { .dockerfile-path {
margin-top: 10px; margin-top: 10px;
padding: 20px; padding: 20px;

View file

@ -6,6 +6,11 @@
<img src="//www.gravatar.com/avatar/{{ getGravatar(notification.organization) }}?s=24&d=identicon" /> <img src="//www.gravatar.com/avatar/{{ getGravatar(notification.organization) }}?s=24&d=identicon" />
<span class="orgname">{{ notification.organization }}</span> <span class="orgname">{{ notification.organization }}</span>
</div> </div>
<div class="datetime">{{ parseDate(notification.created) | date:'medium'}}</div> </div>
<div class="datetime">{{ parseDate(notification.created) | date:'medium'}}</div>
<div class="right-controls">
<a href="javascript:void(0)" ng-if="canDismiss(notification)" ng-click="dismissNotification(notification)">
Dismiss Notification
</a>
</div> </div>
</div> </div>

View file

@ -1084,7 +1084,8 @@ quayApp = angular.module('quay', quayDependencies, function($provide, cfpLoading
'test_notification': { 'test_notification': {
'level': 'primary', 'level': 'primary',
'message': 'This notification is a long message for testing', 'message': 'This notification is a long message for testing',
'page': '/about/' 'page': '/about/',
'dismissable': true
}, },
'password_required': { 'password_required': {
'level': 'error', 'level': 'error',
@ -1121,31 +1122,53 @@ quayApp = angular.module('quay', quayDependencies, function($provide, cfpLoading
'message': 'Repository {repository} has been pushed with the following tags updated: {updated_tags}', 'message': 'Repository {repository} has been pushed with the following tags updated: {updated_tags}',
'page': function(metadata) { 'page': function(metadata) {
return '/repository/' + metadata.repository; return '/repository/' + metadata.repository;
} },
'dismissable': true
}, },
'build_queued': { 'build_queued': {
'level': 'info', 'level': 'info',
'message': 'A build has been queued for repository {repository}', 'message': 'A build has been queued for repository {repository}',
'page': function(metadata) { 'page': function(metadata) {
return '/repository/' + metadata.repository + '/build?current=' + metadata.build_id; return '/repository/' + metadata.repository + '/build?current=' + metadata.build_id;
} },
'dismissable': true
}, },
'build_start': { 'build_start': {
'level': 'info', 'level': 'info',
'message': 'A build has been started for repository {repository}', 'message': 'A build has been started for repository {repository}',
'page': function(metadata) { 'page': function(metadata) {
return '/repository/' + metadata.repository + '/build?current=' + metadata.build_id; return '/repository/' + metadata.repository + '/build?current=' + metadata.build_id;
} },
'dismissable': true
}, },
'build_failure': { 'build_failure': {
'level': 'error', 'level': 'error',
'message': 'A build has failed for repository {repository}', 'message': 'A build has failed for repository {repository}',
'page': function(metadata) { 'page': function(metadata) {
return '/repository/' + metadata.repository + '/build?current=' + metadata.build_id; return '/repository/' + metadata.repository + '/build?current=' + metadata.build_id;
} },
'dismissable': true
} }
}; };
notificationService.dismissNotification = function(notification) {
notification.dismissed = true;
var params = {
'uuid': notification.id
};
ApiService.updateUserNotification(notification, params);
var index = $.inArray(notification, notificationService.notifications);
if (index >= 0) {
notificationService.notifications.splice(index, 1);
}
};
notificationService.canDismiss = function(notification) {
return !!notificationKinds[notification['kind']]['dismissable'];
};
notificationService.getPage = function(notification) { notificationService.getPage = function(notification) {
var page = notificationKinds[notification['kind']]['page']; var page = notificationKinds[notification['kind']]['page'];
if (typeof page != 'string') { if (typeof page != 'string') {
@ -4907,7 +4930,7 @@ quayApp.directive('notificationView', function () {
'notification': '=notification', 'notification': '=notification',
'parent': '=parent' 'parent': '=parent'
}, },
controller: function($scope, $element, $window, $location, UserService, NotificationService) { controller: function($scope, $element, $window, $location, UserService, NotificationService, ApiService) {
var stringStartsWith = function (str, prefix) { var stringStartsWith = function (str, prefix) {
return str.slice(0, prefix.length) == prefix; return str.slice(0, prefix.length) == prefix;
}; };
@ -4943,6 +4966,14 @@ quayApp.directive('notificationView', function () {
} }
}; };
$scope.dismissNotification = function(notification) {
NotificationService.dismissNotification(notification);
};
$scope.canDismiss = function(notification) {
return NotificationService.canDismiss(notification);
};
$scope.getClass = function(notification) { $scope.getClass = function(notification) {
return NotificationService.getClass(notification); return NotificationService.getClass(notification);
}; };

Binary file not shown.

View file

@ -21,7 +21,7 @@ from endpoints.api.trigger import (BuildTriggerActivate, BuildTriggerSources, Bu
from endpoints.api.repoemail import RepositoryAuthorizedEmail from endpoints.api.repoemail import RepositoryAuthorizedEmail
from endpoints.api.repositorynotification import RepositoryNotification, RepositoryNotificationList from endpoints.api.repositorynotification import RepositoryNotification, RepositoryNotificationList
from endpoints.api.user import (PrivateRepositories, ConvertToOrganization, Recovery, Signout, from endpoints.api.user import (PrivateRepositories, ConvertToOrganization, Recovery, Signout,
Signin, User, UserAuthorizationList, UserAuthorization) Signin, User, UserAuthorizationList, UserAuthorization, UserNotification)
from endpoints.api.repotoken import RepositoryToken, RepositoryTokenList from endpoints.api.repotoken import RepositoryToken, RepositoryTokenList
from endpoints.api.prototype import PermissionPrototype, PermissionPrototypeList from endpoints.api.prototype import PermissionPrototype, PermissionPrototypeList
from endpoints.api.logs import UserLogs, OrgLogs, RepositoryLogs from endpoints.api.logs import UserLogs, OrgLogs, RepositoryLogs
@ -123,6 +123,37 @@ class TestFindRepositories(ApiTestCase):
self._run_test('GET', 200, 'devtable', None) self._run_test('GET', 200, 'devtable', None)
class TestUserNotification(ApiTestCase):
def setUp(self):
ApiTestCase.setUp(self)
self._set_url(UserNotification, uuid='someuuid')
def test_get_anonymous(self):
self._run_test('GET', 401, None, None)
def test_get_freshuser(self):
self._run_test('GET', 404, 'freshuser', None)
def test_get_reader(self):
self._run_test('GET', 404, 'reader', None)
def test_get_devtable(self):
self._run_test('GET', 404, 'devtable', None)
def test_put_anonymous(self):
self._run_test('PUT', 401, None, {})
def test_put_freshuser(self):
self._run_test('PUT', 404, 'freshuser', {})
def test_put_reader(self):
self._run_test('PUT', 404, 'reader', {})
def test_put_devtable(self):
self._run_test('PUT', 404, 'devtable', {})
class TestUserInvoiceList(ApiTestCase): class TestUserInvoiceList(ApiTestCase):
def setUp(self): def setUp(self):
ApiTestCase.setUp(self) ApiTestCase.setUp(self)

View file

@ -23,7 +23,8 @@ from endpoints.api.trigger import (BuildTriggerActivate, BuildTriggerSources, Bu
from endpoints.api.repoemail import RepositoryAuthorizedEmail from endpoints.api.repoemail import RepositoryAuthorizedEmail
from endpoints.api.repositorynotification import RepositoryNotification, RepositoryNotificationList from endpoints.api.repositorynotification import RepositoryNotification, RepositoryNotificationList
from endpoints.api.user import (PrivateRepositories, ConvertToOrganization, Signout, Signin, User, from endpoints.api.user import (PrivateRepositories, ConvertToOrganization, Signout, Signin, User,
UserAuthorizationList, UserAuthorization) UserAuthorizationList, UserAuthorization, UserNotification,
UserNotificationList)
from endpoints.api.repotoken import RepositoryToken, RepositoryTokenList from endpoints.api.repotoken import RepositoryToken, RepositoryTokenList
from endpoints.api.prototype import PermissionPrototype, PermissionPrototypeList from endpoints.api.prototype import PermissionPrototype, PermissionPrototypeList
@ -208,6 +209,26 @@ class TestLoggedInUser(ApiTestCase):
assert json['username'] == READ_ACCESS_USER assert json['username'] == READ_ACCESS_USER
class TestUserNotification(ApiTestCase):
def test_get(self):
self.login(ADMIN_ACCESS_USER)
json = self.getJsonResponse(UserNotificationList)
# Make sure each notification can be retrieved.
for notification in json['notifications']:
njson = self.getJsonResponse(UserNotification, params=dict(uuid=notification['id']))
self.assertEquals(notification['id'], njson['id'])
# Update a notification.
assert json['notifications']
assert not json['notifications'][0]['dismissed']
pjson = self.putJsonResponse(UserNotification, params=dict(uuid=notification['id']),
data=dict(dismissed=True))
self.assertEquals(True, pjson['dismissed'])
class TestGetUserPrivateAllowed(ApiTestCase): class TestGetUserPrivateAllowed(ApiTestCase):
def test_nonallowed(self): def test_nonallowed(self):
self.login(READ_ACCESS_USER) self.login(READ_ACCESS_USER)