← Back to team overview

dhis2-devs team mailing list archive

[Branch ~dhis2-devs-core/dhis2/trunk] Rev 16771: Added batch delete, read, unread for MessageConversations in webapi. Implemented batch operations...

 

Merge authors:
  Halvdan Hoem Grelland (halvdanhg)
------------------------------------------------------------
revno: 16771 [merge]
committer: Halvdan Hoem Grelland <halvdanhg@xxxxxxxxx>
branch nick: dhis2
timestamp: Mon 2014-09-22 17:51:59 +0200
message:
  Added batch delete, read, unread for MessageConversations in webapi. Implemented batch operations using these in dashboard messaging app. Added authorisation checks to read and delete MessageConversations.
added:
  dhis-2/dhis-web/dhis-web-commons-resources/src/main/webapp/dhis-web-commons/javascripts/jQuery/jquery.dhisCheckboxMenu.js
modified:
  dhis-2/dhis-api/src/main/java/org/hisp/dhis/message/MessageConversation.java
  dhis-2/dhis-api/src/main/java/org/hisp/dhis/message/MessageConversationStore.java
  dhis-2/dhis-api/src/main/java/org/hisp/dhis/message/MessageService.java
  dhis-2/dhis-services/dhis-service-core/src/main/java/org/hisp/dhis/message/DefaultMessageService.java
  dhis-2/dhis-services/dhis-service-core/src/main/java/org/hisp/dhis/message/hibernate/HibernateMessageConversationStore.java
  dhis-2/dhis-services/dhis-service-core/src/test/java/org/hisp/dhis/message/MessageServiceTest.java
  dhis-2/dhis-web/dhis-web-api/src/main/java/org/hisp/dhis/webapi/controller/MessageConversationController.java
  dhis-2/dhis-web/dhis-web-commons-resources/src/main/webapp/dhis-web-commons/css/widgets.css
  dhis-2/dhis-web/dhis-web-dashboard-integration/src/main/java/org/hisp/dhis/dashboard/message/action/ReadMessageAction.java
  dhis-2/dhis-web/dhis-web-dashboard-integration/src/main/resources/org/hisp/dhis/dashboard/i18n_module.properties
  dhis-2/dhis-web/dhis-web-dashboard-integration/src/main/resources/struts.xml
  dhis-2/dhis-web/dhis-web-dashboard-integration/src/main/webapp/dhis-web-dashboard-integration/javascript/message.js
  dhis-2/dhis-web/dhis-web-dashboard-integration/src/main/webapp/dhis-web-dashboard-integration/message.vm


--
lp:dhis2
https://code.launchpad.net/~dhis2-devs-core/dhis2/trunk

Your team DHIS 2 developers is subscribed to branch lp:dhis2.
To unsubscribe from this branch go to https://code.launchpad.net/~dhis2-devs-core/dhis2/trunk/+edit-subscription
=== modified file 'dhis-2/dhis-api/src/main/java/org/hisp/dhis/message/MessageConversation.java'
--- dhis-2/dhis-api/src/main/java/org/hisp/dhis/message/MessageConversation.java	2014-08-15 07:40:20 +0000
+++ dhis-2/dhis-api/src/main/java/org/hisp/dhis/message/MessageConversation.java	2014-08-19 11:16:02 +0000
@@ -213,7 +213,7 @@
         this.setLastMessage( new Date() );
     }
 
-    public void remove( User user )
+    public boolean remove( User user )
     {
         Iterator<UserMessage> iterator = userMessages.iterator();
 
@@ -225,9 +225,10 @@
             {
                 iterator.remove();
 
-                return;
+                return true;
             }
         }
+        return false;
     }
 
     public Set<User> getUsers()

=== modified file 'dhis-2/dhis-api/src/main/java/org/hisp/dhis/message/MessageConversationStore.java'
--- dhis-2/dhis-api/src/main/java/org/hisp/dhis/message/MessageConversationStore.java	2014-03-18 08:10:10 +0000
+++ dhis-2/dhis-api/src/main/java/org/hisp/dhis/message/MessageConversationStore.java	2014-08-20 14:37:14 +0000
@@ -31,6 +31,7 @@
 import org.hisp.dhis.common.GenericIdentifiableObjectStore;
 import org.hisp.dhis.user.User;
 
+import java.util.Collection;
 import java.util.List;
 
 /**
@@ -49,6 +50,14 @@
      * @return a list of MessageConversations.
      */
     List<MessageConversation> getMessageConversations( User user, boolean followUpOnly, boolean unreadOnly, Integer first, Integer max );
+
+    /**
+     * Returns the MessageConversations given by the supplied UIDs.
+     *
+     * @param messageConversationUids the UIDs of the MessageConversations to get.
+     * @return a collection of MessageConversations.
+     */
+    Collection<MessageConversation> getMessageConversations( String[] messageConversationUids );
     
     int getMessageConversationCount( User user, boolean followUpOnly, boolean unreadOnly );
     

=== modified file 'dhis-2/dhis-api/src/main/java/org/hisp/dhis/message/MessageService.java'
--- dhis-2/dhis-api/src/main/java/org/hisp/dhis/message/MessageService.java	2014-03-18 08:10:10 +0000
+++ dhis-2/dhis-api/src/main/java/org/hisp/dhis/message/MessageService.java	2014-08-20 14:37:14 +0000
@@ -28,6 +28,7 @@
  * SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
  */
 
+import java.util.Collection;
 import java.util.List;
 import java.util.Set;
 
@@ -93,6 +94,8 @@
 
     List<MessageConversation> getMessageConversations( boolean followUpOnly, boolean unreadOnly, int first, int max );
 
+    Collection<MessageConversation> getMessageConversations( String[] messageConversationUids );
+
     int getMessageConversationCount();
 
     int getMessageConversationCount( boolean followUpOnly, boolean unreadOnly );

=== modified file 'dhis-2/dhis-services/dhis-service-core/src/main/java/org/hisp/dhis/message/DefaultMessageService.java'
--- dhis-2/dhis-services/dhis-service-core/src/main/java/org/hisp/dhis/message/DefaultMessageService.java	2014-08-15 07:40:20 +0000
+++ dhis-2/dhis-services/dhis-service-core/src/main/java/org/hisp/dhis/message/DefaultMessageService.java	2014-08-20 14:37:14 +0000
@@ -28,6 +28,7 @@
  * SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
  */
 
+import java.util.Collection;
 import java.util.HashSet;
 import java.util.List;
 import java.util.Set;
@@ -272,6 +273,11 @@
             unreadOnly, first, max );
     }
 
+    public Collection<MessageConversation> getMessageConversations( String[] messageConversationUids )
+    {
+        return messageConversationStore.getMessageConversations( messageConversationUids );
+    }
+
     public int getMessageConversationCount()
     {
         return messageConversationStore.getMessageConversationCount( currentUserService.getCurrentUser(), false, false );

=== modified file 'dhis-2/dhis-services/dhis-service-core/src/main/java/org/hisp/dhis/message/hibernate/HibernateMessageConversationStore.java'
--- dhis-2/dhis-services/dhis-service-core/src/main/java/org/hisp/dhis/message/hibernate/HibernateMessageConversationStore.java	2014-08-13 10:43:22 +0000
+++ dhis-2/dhis-services/dhis-service-core/src/main/java/org/hisp/dhis/message/hibernate/HibernateMessageConversationStore.java	2014-08-20 14:37:14 +0000
@@ -40,6 +40,7 @@
 
 import java.sql.ResultSet;
 import java.sql.SQLException;
+import java.util.Collection;
 import java.util.List;
 
 /**
@@ -124,6 +125,17 @@
         return conversations;
     }
 
+    @Override
+    public Collection<MessageConversation> getMessageConversations( String[] messageConversationUids )
+    {
+        String hql = ( "FROM MessageConversation where uid in :messageConversationUids" );
+
+        Query query = getQuery( hql );
+        query.setParameterList( "messageConversationUids", messageConversationUids );
+
+        return query.list();
+    }
+
     public int getMessageConversationCount( User user, boolean followUpOnly, boolean unreadOnly )
     {
         String sql = "select count(*) from messageconversation mc "

=== modified file 'dhis-2/dhis-services/dhis-service-core/src/test/java/org/hisp/dhis/message/MessageServiceTest.java'
--- dhis-2/dhis-services/dhis-service-core/src/test/java/org/hisp/dhis/message/MessageServiceTest.java	2014-08-15 07:40:20 +0000
+++ dhis-2/dhis-services/dhis-service-core/src/test/java/org/hisp/dhis/message/MessageServiceTest.java	2014-09-22 14:26:53 +0000
@@ -29,9 +29,11 @@
  */
 
 import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertFalse;
 import static org.junit.Assert.assertNotNull;
 import static org.junit.Assert.assertTrue;
 
+import java.util.Collection;
 import java.util.HashSet;
 import java.util.Set;
 
@@ -199,4 +201,31 @@
         assertEquals( "Subject", message.getSubject() );
         assertEquals( 2, message.getMessages().size() );       
     }
+
+    @Test
+    public void testGetMessageConversations()
+    {
+        MessageConversation conversationA = new MessageConversation( "SubjectA", sender );
+        MessageConversation conversationB = new MessageConversation( "SubjectB", sender );
+        MessageConversation conversationC = new MessageConversation( "SubjectC", userA );
+
+        messageService.saveMessageConversation( conversationA );
+        messageService.saveMessageConversation( conversationB );
+        messageService.saveMessageConversation( conversationC );
+
+        String uidA = conversationA.getUid();
+        String uidB = conversationB.getUid();
+
+        messageService.saveMessageConversation( conversationA );
+        messageService.saveMessageConversation( conversationB );
+        messageService.saveMessageConversation( conversationC );
+
+        String[] uids = { uidA, uidB };
+
+        Collection<MessageConversation> conversations = messageService.getMessageConversations( uids );
+
+        assertTrue( conversations.contains( conversationA ) );
+        assertTrue( conversations.contains( conversationB ) );
+        assertFalse( conversations.contains( conversationC ) );
+    }
 }

=== modified file 'dhis-2/dhis-web/dhis-web-api/src/main/java/org/hisp/dhis/webapi/controller/MessageConversationController.java'
--- dhis-2/dhis-web/dhis-web-api/src/main/java/org/hisp/dhis/webapi/controller/MessageConversationController.java	2014-08-15 07:40:20 +0000
+++ dhis-2/dhis-web/dhis-web-api/src/main/java/org/hisp/dhis/webapi/controller/MessageConversationController.java	2014-09-22 14:26:53 +0000
@@ -29,11 +29,17 @@
  */
 
 import com.google.common.collect.Lists;
+import org.hisp.dhis.acl.AclService;
 import org.hisp.dhis.common.Pager;
 import org.hisp.dhis.dxf2.message.Message;
 import org.hisp.dhis.dxf2.utils.JacksonUtils;
+import org.hisp.dhis.hibernate.exception.DeleteAccessDeniedException;
+import org.hisp.dhis.hibernate.exception.UpdateAccessDeniedException;
 import org.hisp.dhis.message.MessageConversation;
 import org.hisp.dhis.message.MessageService;
+import org.hisp.dhis.node.types.CollectionNode;
+import org.hisp.dhis.node.types.RootNode;
+import org.hisp.dhis.node.types.SimpleNode;
 import org.hisp.dhis.organisationunit.OrganisationUnit;
 import org.hisp.dhis.organisationunit.OrganisationUnitService;
 import org.hisp.dhis.schema.descriptors.MessageConversationSchemaDescriptor;
@@ -46,17 +52,22 @@
 import org.hisp.dhis.webapi.webdomain.WebMetaData;
 import org.hisp.dhis.webapi.webdomain.WebOptions;
 import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.http.MediaType;
+import org.springframework.security.access.AccessDeniedException;
+import org.springframework.security.access.prepost.PreAuthorize;
 import org.springframework.stereotype.Controller;
 import org.springframework.web.bind.annotation.PathVariable;
 import org.springframework.web.bind.annotation.RequestBody;
 import org.springframework.web.bind.annotation.RequestMapping;
 import org.springframework.web.bind.annotation.RequestMethod;
 import org.springframework.web.bind.annotation.RequestParam;
+import org.springframework.web.bind.annotation.ResponseBody;
 
 import javax.servlet.http.HttpServletRequest;
 import javax.servlet.http.HttpServletResponse;
 import java.io.InputStream;
 import java.util.ArrayList;
+import java.util.Collection;
 import java.util.List;
 import java.util.Map;
 
@@ -96,6 +107,28 @@
     }
 
     @Override
+    public RootNode getObject( @PathVariable String uid, Map<String, String> parameters, HttpServletRequest request, HttpServletResponse response )
+        throws Exception
+    {
+        MessageConversation messageConversation = messageService.getMessageConversation( uid );
+
+        if( messageConversation == null )
+        {
+            response.setStatus( HttpServletResponse.SC_NOT_FOUND );
+            RootNode responseNode = new RootNode( "reply" );
+            responseNode.addChild( new SimpleNode( "message", "No MessageConversation found with UID: " + uid ) );
+            return responseNode;
+        }
+
+        if( !canReadMessageConversation( currentUserService.getCurrentUser(), messageConversation ) )
+        {
+            throw new AccessDeniedException( "Not authorized to access this conversation." );
+        }
+
+        return super.getObject( uid, parameters, request, response );
+    }
+
+    @Override
     protected List<MessageConversation> getEntityList( WebMetaData metaData, WebOptions options )
     {
         List<MessageConversation> entityList;
@@ -235,4 +268,260 @@
 
         ContextUtils.createdResponse( response, "Feedback created", null );
     }
+
+
+    //--------------------------------------------------------------------------
+    // Mark conversations read
+    //--------------------------------------------------------------------------
+
+    @RequestMapping( value = "/read", method = RequestMethod.PUT, produces = { MediaType.APPLICATION_JSON_VALUE, MediaType.APPLICATION_XML_VALUE } )
+    public @ResponseBody RootNode markMessageConversationsRead(
+        @RequestParam( value = "user", required = false ) String userUid, @RequestBody String[] uids, HttpServletResponse response )
+    {
+        RootNode responseNode = new RootNode( "response" );
+
+        User currentUser = currentUserService.getCurrentUser();
+        User user = userUid != null ? userService.getUser( userUid ) : currentUserService.getCurrentUser();
+
+        if( user == null )
+        {
+            response.setStatus( HttpServletResponse.SC_NOT_FOUND );
+            responseNode.addChild( new SimpleNode( "message", "No user with uid: " + userUid ) );
+            return responseNode;
+        }
+
+        if( !canModifyUserConversation( currentUser, user ) )
+        {
+            throw new UpdateAccessDeniedException( "Not authorized to modify this object." );
+        }
+
+        Collection<MessageConversation> messageConversations = messageService.getMessageConversations( uids );
+
+        if ( messageConversations.isEmpty() )
+        {
+            response.setStatus( HttpServletResponse.SC_NOT_FOUND );
+            responseNode.addChild( new SimpleNode( "message", "No MessageConversations found for the given UIDs." ) );
+            return responseNode;
+        }
+
+        CollectionNode marked = responseNode.addChild( new CollectionNode( "markedRead" ) );
+        marked.setWrapping( false );
+
+        for( MessageConversation conversation : messageConversations )
+        {
+            if( conversation.markRead( user ) )
+            {
+                messageService.updateMessageConversation( conversation );
+                marked.addChild( new SimpleNode( "uid", conversation.getUid() ) );
+            }
+        }
+
+        response.setStatus( HttpServletResponse.SC_OK );
+
+        return responseNode;
+    }
+
+    //--------------------------------------------------------------------------
+    // Mark conversations unread
+    //--------------------------------------------------------------------------
+
+    @RequestMapping( value = "/unread", method = RequestMethod.PUT, produces = { MediaType.APPLICATION_JSON_VALUE, MediaType.APPLICATION_XML_VALUE } )
+    public @ResponseBody RootNode markMessageConversationsUnread(
+        @RequestParam( value = "user", required = false ) String userUid, @RequestBody String[] uids, HttpServletResponse response )
+    {
+        RootNode responseNode = new RootNode( "response" );
+
+        User currentUser = currentUserService.getCurrentUser();
+        User user = userUid != null ? userService.getUser( userUid ) : currentUser;
+
+        if( user == null )
+        {
+            response.setStatus( HttpServletResponse.SC_NOT_FOUND );
+            responseNode.addChild( new SimpleNode( "message", "No user with uid: " + userUid ) );
+            return responseNode;
+        }
+
+        if( !canModifyUserConversation( currentUser, user ) )
+        {
+            throw new UpdateAccessDeniedException( "Not authorized to modify this object." );
+        }
+
+        Collection<MessageConversation> messageConversations = messageService.getMessageConversations( uids );
+
+        if ( messageConversations.isEmpty() )
+        {
+            response.setStatus( HttpServletResponse.SC_NOT_FOUND );
+            responseNode.addChild( new SimpleNode( "message", "No MessageConversations found for the given UIDs." ) );
+            return responseNode;
+        }
+
+        CollectionNode marked = responseNode.addChild( new CollectionNode( "markedUnread" ) );
+        marked.setWrapping( false );
+
+        for( MessageConversation conversation : messageConversations )
+        {
+            if( conversation.markUnread( user ) )
+            {
+                messageService.updateMessageConversation( conversation );
+                marked.addChild( new SimpleNode( "uid", conversation.getUid() ) );
+            }
+        }
+
+        response.setStatus( HttpServletResponse.SC_OK );
+
+        return responseNode;
+    }
+
+
+    //--------------------------------------------------------------------------
+    // Delete a MessageConversation (requires override auth)
+    //--------------------------------------------------------------------------
+
+    /**
+     * Deletes a MessageConversation.
+     * Note that this is a HARD delete and therefore requires override authority for the current user.
+     * @param uid the uid of the MessageConversation to delete.
+     * @throws Exception
+     */
+    @Override
+    @PreAuthorize( "hasRole('ALL') or hasRole('F_METADATA_IMPORT')" )
+    public void deleteObject( HttpServletResponse response, HttpServletRequest request, @PathVariable String uid )
+        throws Exception
+    {
+        super.deleteObject( response, request, uid );
+    }
+
+    //--------------------------------------------------------------------------
+    // Remove a user from a MessageConversation
+    // In practice a DELETE on MessageConversation <-> User relationship
+    //--------------------------------------------------------------------------
+
+    @RequestMapping( value = "/{mc-uid}/{user-uid}", method = RequestMethod.DELETE, produces = { MediaType.APPLICATION_JSON_VALUE, MediaType.APPLICATION_XML_VALUE } )
+    public @ResponseBody RootNode removeUserFromMessageConversation(
+        @PathVariable( value = "mc-uid" ) String mcUid, @PathVariable( value = "user-uid" ) String userUid, HttpServletResponse response )
+        throws DeleteAccessDeniedException
+    {
+        RootNode responseNode = new RootNode( "reply" );
+
+        User user = userService.getUser( userUid );
+
+        if( user == null )
+        {
+            responseNode.addChild( new SimpleNode( "message", "No user with uid: " + userUid ) );
+            response.setStatus( HttpServletResponse.SC_NOT_FOUND );
+            return responseNode;
+        }
+
+        if( !canModifyUserConversation( currentUserService.getCurrentUser(), user ) )
+        {
+
+            throw new DeleteAccessDeniedException( "Not authorized to modify user: " + user.getUid() );
+        }
+
+        MessageConversation messageConversation = messageService.getMessageConversation( mcUid );
+
+        if( messageConversation == null )
+        {
+            responseNode.addChild( new SimpleNode( "message", "No messageConversation with uid: " + mcUid ) );
+            response.setStatus( HttpServletResponse.SC_NOT_FOUND );
+            return responseNode;
+        }
+
+        CollectionNode removed = responseNode.addChild( new CollectionNode( "removed" ) );
+
+        if( messageConversation.remove( user ) )
+        {
+            messageService.updateMessageConversation( messageConversation );
+            removed.addChild( new SimpleNode( "uid", messageConversation.getUid() ) );
+        }
+
+        response.setStatus( HttpServletResponse.SC_OK );
+
+        return responseNode;
+    }
+
+    //--------------------------------------------------------------------------
+    // Remove a user from one or more MessageConversations (batch operation)
+    //--------------------------------------------------------------------------
+
+    @RequestMapping( method = RequestMethod.DELETE, produces = { MediaType.APPLICATION_JSON_VALUE, MediaType.APPLICATION_XML_VALUE } )
+    public @ResponseBody RootNode removeUserFromMessageConversations(
+        @RequestParam( "mc" ) String[] mcUids, @RequestParam( value = "user", required = false ) String userUid, HttpServletResponse response )
+        throws DeleteAccessDeniedException
+    {
+        RootNode responseNode = new RootNode( "response" );
+
+        User currentUser = currentUserService.getCurrentUser();
+
+        User user = userUid == null ? currentUser : userService.getUser( userUid ) ;
+
+        if( user == null )
+        {
+            response.setStatus( HttpServletResponse.SC_NOT_FOUND );
+            responseNode.addChild( new SimpleNode( "message", "User does not exist: " + userUid ) );
+            return responseNode;
+        }
+
+        if( !canModifyUserConversation( currentUser, user ) )
+        {
+            throw new DeleteAccessDeniedException( "Not authorized to modify user: " + user.getUid() );
+        }
+
+        Collection<MessageConversation> messageConversations = messageService.getMessageConversations( mcUids );
+
+        if( messageConversations.isEmpty() )
+        {
+            response.setStatus( HttpServletResponse.SC_NOT_FOUND );
+            responseNode.addChild( new SimpleNode( "message", "No MessageConversations found for the given UIDs." ) );
+            return responseNode;
+        }
+
+        CollectionNode removed = responseNode.addChild( new CollectionNode( "removed" ) );
+
+        for( MessageConversation mc : messageConversations )
+        {
+            if( mc.remove( user ) )
+            {
+                messageService.updateMessageConversation( mc );
+                removed.addChild( new SimpleNode( "uid", mc.getUid() ) );
+            }
+        }
+
+        response.setStatus( HttpServletResponse.SC_OK );
+
+        return responseNode;
+    }
+
+    //--------------------------------------------------------------------------
+    // Supportive methods
+    //--------------------------------------------------------------------------
+
+    /**
+     * Determines whether the current user has permission to modify the given user in a MessageConversation.
+     *
+     * The modification is either marking a conversation read/unread for the user or removing the user from the MessageConversation.
+     *
+     * Since there are no per-conversation authorities provided the permission is given if the current user equals the user
+     * or if the current user has update-permission to User objects.
+     *
+     * @param currentUser the current user to check authorization for.
+     * @param user the user to remove from a conversation.
+     * @return true if the current user is allowed to remove the user from a conversation, false otherwise.
+     */
+    private boolean canModifyUserConversation( User currentUser, User user )
+    {
+        return currentUser.equals( user ) || currentUser.getUserCredentials().hasAnyAuthority( AclService.ACL_OVERRIDE_AUTHORITIES );
+    }
+
+    /**
+     * Determines whether the given user has permission to read the MessageConversation.
+     *
+     * @param user the user to check permission for.
+     * @param messageConversation the MessageConversation to access.
+     * @return true if the user can read the MessageConversation, false otherwise.
+     */
+    private boolean canReadMessageConversation( User user, MessageConversation messageConversation )
+    {
+        return messageConversation.getUsers().contains( user ) || user.getUserCredentials().hasAnyAuthority( AclService.ACL_OVERRIDE_AUTHORITIES );
+    }
 }

=== modified file 'dhis-2/dhis-web/dhis-web-commons-resources/src/main/webapp/dhis-web-commons/css/widgets.css'
--- dhis-2/dhis-web/dhis-web-commons-resources/src/main/webapp/dhis-web-commons/css/widgets.css	2014-09-12 06:09:29 +0000
+++ dhis-2/dhis-web/dhis-web-commons-resources/src/main/webapp/dhis-web-commons/css/widgets.css	2014-09-22 12:02:33 +0000
@@ -742,6 +742,87 @@
 }
 
 /*----------------------------------------------------------------------------*/
+/* Multi select/checkbox combo button                                         */
+/*----------------------------------------------------------------------------*/
+
+.multiSelectButton
+{
+    padding: 6px 12px;
+    height: 25px;
+    border: 1px solid #bbb;
+    border-radius: 3px;
+    margin-right: 4px;
+    font-weight: bold;
+    font-size: 13px;
+    background-color: #f3f3f3;
+    color: #606060 !important;
+    text-decoration: none !important;
+}
+
+.multiSelectButton:hover
+{
+    text-decoration: none;
+    background-color: #f8f8f8;
+}
+
+.multiSelectButton .downArrow
+{
+    border: thick;
+    border-color: #777777 transparent white;
+    border-style: solid dashed dashed;
+    margin-left: 5px;
+    position: relative;
+    top: 10px;
+}
+
+.multiSelectButton .downArrow:hover
+{
+    border-color: #999999 transparent white;
+}
+
+.multiSelectButton.disabled .downArrow
+{
+    border-color: #e2e2e2 transparent white;
+}
+
+.multiSelectButton input[type='checkbox']
+{
+    margin: 0;
+}
+
+.multiSelectMenu
+{
+    position: absolute;
+    margin-top: -15px;
+    z-index: 999;
+    padding: 1px 1px;
+    border: 1px solid #bbb;
+    border-radius: 3px;
+    margin-right: 4px;
+    font-weight: bold;
+    font-size: 13px;
+    background-color: #f3f3f3;
+    color: #606060 !important;
+    text-decoration: none !important;
+}
+
+.multiSelectMenu li
+{
+    float: none !important;
+    display: block;
+    text-decoration: none;
+    color: #606060;
+    padding: 6px 9px;
+}
+
+.multiSelectMenu li:hover
+{
+    cursor: pointer;
+    text-decoration: none;
+    background-color: #f8f8f8;
+}
+
+/*----------------------------------------------------------------------------*/
 /* Placeholders                                                               */
 /*----------------------------------------------------------------------------*/
 

=== added file 'dhis-2/dhis-web/dhis-web-commons-resources/src/main/webapp/dhis-web-commons/javascripts/jQuery/jquery.dhisCheckboxMenu.js'
--- dhis-2/dhis-web/dhis-web-commons-resources/src/main/webapp/dhis-web-commons/javascripts/jQuery/jquery.dhisCheckboxMenu.js	1970-01-01 00:00:00 +0000
+++ dhis-2/dhis-web/dhis-web-commons-resources/src/main/webapp/dhis-web-commons/javascripts/jQuery/jquery.dhisCheckboxMenu.js	2014-09-22 15:24:24 +0000
@@ -0,0 +1,176 @@
+/*
+ * Copyright (c) 2004-2013, University of Oslo
+ * All rights reserved.
+ *
+ * Redistribution and use in source and binary forms, with or without
+ * modification, are permitted provided that the following conditions are met:
+ * Redistributions of source code must retain the above copyright notice, this
+ * list of conditions and the following disclaimer.
+ *
+ * Redistributions in binary form must reproduce the above copyright notice,
+ * this list of conditions and the following disclaimer in the documentation
+ * and/or other materials provided with the distribution.
+ * Neither the name of the HISP project nor the names of its contributors may
+ * be used to endorse or promote products derived from this software without
+ * specific prior written permission.
+ *
+ * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND
+ * ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
+ * WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
+ * DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR
+ * ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES
+ * (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES;
+ * LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON
+ * ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
+ * (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS
+ * SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
+ */
+
+/**
+ * Checkbox/dropdown combo menu for DHIS 2 Dashboard.
+ *
+ * @author Halvdan Hoem Grelland
+ */
+( function ( $ ) {
+    /*
+     Example markup:
+     <div>
+     <div></div> <!-- Empty, will be filled with button markup -->
+     <ul> <!-- Menu markup -->
+     <li data-action="actionA">Item A</li>
+     <li data-action="actionB">Item B</li>
+     <li data-action="actionC">Item C</li>
+     </ul>
+     </div>
+     ...
+     <div id="checkboxes">
+     ...
+     <input type="checkbox" value="someValue" .... />
+     ...
+     </div>
+
+     The string parameter given in data-action denotes the name of the
+     function which is called on click of the menu item.
+
+     The selected checkboxes' values will be given as an array argument
+     to the function when called.
+
+     Usage:
+     $( "#myDiv" ).multiCheckboxMenu( $( "#myCheckboxContainer" ), {} );
+
+     */
+
+    function getCheckedValues( $checkboxContainer ) {
+        var checked = [];
+        $checkboxContainer.find( "input:checkbox:checked" ).each( function() {
+            checked.push( this.value );
+        });
+        return checked;
+    }
+
+    var multiCheckboxMenu = $.fn.multiCheckboxMenu;
+
+    $.fn.multiCheckboxMenu = function( $checkboxContainer, options ) {
+
+        if( typeof options !== "object" ) {
+            options = {};
+        }
+
+        var $cb = $( "<input>", { type: "checkbox" } );
+        options = $.extend( true, options , {
+            checkbox: $cb,
+            buttonElements: [
+                $( "<span>", { "class": "downArrow" } )
+            ],
+            menuClass: "multiSelectMenu",
+            buttonClass: "multiSelectButton"
+        });
+
+        var $checkbox = $( options.checkbox );
+        var $slaveCheckboxes = $checkboxContainer.find( "input:checkbox" );
+
+        var $button = $( "<a>", { href: "#" } );
+        $button.addClass( options.buttonClass );
+
+        $button.append( $( options.checkbox ) );
+
+        $( options.buttonElements ).each( function() {
+            $button.append( $( this ) );
+        });
+
+        $( this ).find( "div:first" ).append($button);
+
+        var $menu = $( this ).find( "ul" );
+        $menu.addClass( options.menuClass );
+        $menu.css( "visibility", "hidden" );
+        $menu.position({
+            my: "left top",
+            at: "left bottom",
+            of: $button
+        });
+
+        $button.click( function ( event ) {
+            $( document ).one( "click", function() {
+                $menu.css( "visibility", "hidden" );
+            });
+
+            if( $menu.css( "visibility" ) !== "visible" )
+            {
+                $menu.css( "visibility", "visible" );
+            }
+            else
+            {
+                $menu.css( "visibility", "hidden" );
+            }
+            event.stopPropagation();
+        });
+
+        $menu.find( "li" ).each( function() {
+            var el = $( this );
+            el.action = this.getAttribute( "data-action" );
+
+            if( typeof el.action === "undefined" )
+            {
+                el.action = function(){};
+            }
+
+            el.click( function() {
+                var checked = getCheckedValues( $checkboxContainer );
+
+                $checkbox.removeAttr( "checked" );
+                $slaveCheckboxes.removeAttr( "checked" );
+
+                return window[ el.action ]( checked );
+            });
+        });
+
+        $checkbox.click( function( event ) {
+            if( this.checked )
+            {
+                $slaveCheckboxes.attr( "checked", "checked" );
+            }
+            else
+            {
+                $slaveCheckboxes.removeAttr( "checked" );
+            }
+            event.stopPropagation();
+        });
+
+        $slaveCheckboxes.click( function() {
+            var checked = $slaveCheckboxes.filter( ":checked" );
+
+            if( checked.length < 1 )
+            {
+                $checkbox.removeAttr( "checked" );
+            }
+            else if( checked.length > 0 && checked.length < $slaveCheckboxes.length )
+            {
+                $checkbox.removeAttr( "checked" );
+            }
+            else
+            {
+                $checkbox.attr( "checked", "checked" );
+            }
+        });
+    };
+})( jQuery );

=== modified file 'dhis-2/dhis-web/dhis-web-dashboard-integration/src/main/java/org/hisp/dhis/dashboard/message/action/ReadMessageAction.java'
--- dhis-2/dhis-web/dhis-web-dashboard-integration/src/main/java/org/hisp/dhis/dashboard/message/action/ReadMessageAction.java	2014-03-18 08:10:10 +0000
+++ dhis-2/dhis-web/dhis-web-dashboard-integration/src/main/java/org/hisp/dhis/dashboard/message/action/ReadMessageAction.java	2014-09-01 14:56:40 +0000
@@ -89,9 +89,19 @@
     public String execute()
         throws Exception
     {
+        if( id == null )
+        {
+            return ERROR;
+        }
+
         User user = currentUserService.getCurrentUser();
-        
+
         conversation = messageService.getMessageConversation( id );
+
+        if( conversation == null )
+        {
+            return ERROR;
+        }
                 
         if ( conversation.markRead( user ) )
         {        

=== modified file 'dhis-2/dhis-web/dhis-web-dashboard-integration/src/main/resources/org/hisp/dhis/dashboard/i18n_module.properties'
--- dhis-2/dhis-web/dhis-web-dashboard-integration/src/main/resources/org/hisp/dhis/dashboard/i18n_module.properties	2013-12-02 13:59:00 +0000
+++ dhis-2/dhis-web/dhis-web-dashboard-integration/src/main/resources/org/hisp/dhis/dashboard/i18n_module.properties	2014-08-11 14:16:43 +0000
@@ -25,10 +25,15 @@
 write_new_feedback=Write new feedback
 recipients=Recipients
 mark_unread=Mark as unread
+mark_read=Mark as read
 read=Read
+delete=Delete
 confirm_delete_message=Are you sure you want to delete the message?
+confirm_delete_all_selected_messages=Are you sure you want to delete all selected messages?
+no_messages_selected=No messages selected
 unread_messages=unread messages
 unread_message=unread message
+messages_were_deleted=Messages were deleted
 discard=Discard
 enter_subject=Please enter a subject
 enter_text=Please enter text

=== modified file 'dhis-2/dhis-web/dhis-web-dashboard-integration/src/main/resources/struts.xml'
--- dhis-2/dhis-web/dhis-web-dashboard-integration/src/main/resources/struts.xml	2013-08-18 18:54:32 +0000
+++ dhis-2/dhis-web/dhis-web-dashboard-integration/src/main/resources/struts.xml	2014-09-22 13:49:28 +0000
@@ -26,7 +26,7 @@
       <result name="success" type="velocity">/main.vm</result>
       <param name="page">/dhis-web-dashboard-integration/message.vm</param>
       <param name="menu">/dhis-web-commons/about/menuDashboard.vm</param>
-      <param name="javascripts">javascript/message.js</param>
+      <param name="javascripts">javascript/message.js,../dhis-web-commons/javascripts/jQuery/jquery.dhisCheckboxMenu.js</param>
       <param name="stylesheets">style/dashboard.css</param>
     </action>
 

=== modified file 'dhis-2/dhis-web/dhis-web-dashboard-integration/src/main/webapp/dhis-web-dashboard-integration/javascript/message.js'
--- dhis-2/dhis-web/dhis-web-dashboard-integration/src/main/webapp/dhis-web-dashboard-integration/javascript/message.js	2012-10-12 15:48:35 +0000
+++ dhis-2/dhis-web/dhis-web-dashboard-integration/src/main/webapp/dhis-web-dashboard-integration/javascript/message.js	2014-09-22 13:49:28 +0000
@@ -1,7 +1,7 @@
 
 function submitMessage()
 {
-	$( "#messageForm" ).submit();
+    $( "#messageForm" ).submit();
 }
 
 function removeMessage( id )
@@ -9,6 +9,102 @@
     removeItem( id, "", i18n_confirm_delete_message, "removeMessage.action" );
 }
 
+function removeMessages( messages )
+{
+    if( typeof messages === "undefined" || messages.length < 1 )
+    {
+        return;
+    }
+
+    var confirmed = window.confirm( i18n_confirm_delete_all_selected_messages );
+
+    if ( confirmed )
+    {
+        setHeaderWaitMessage( i18n_deleting );
+
+        $.ajax(
+        {
+            url: "../../api/messageConversations?" + $.param( { mc: messages }, true ),
+            contentType: "application/json",
+            dataType: "json",
+            type: "DELETE",
+            success: function( response )
+            {
+                for( var i = 0 ; i < response.removed.length ; i++ )
+                {
+                    $( "#messages" ).find( "[name='" + response.removed[i] + "']" ).remove();
+                }
+                setHeaderDelayMessage( i18n_messages_were_deleted );
+            },
+            error: function( response )
+            {
+                showErrorMessage( response.message, 3 );
+            }
+        });
+    }
+}
+
+function markMessagesRead( messages )
+{
+    if( messages.length < 1 )
+    {
+        return;
+    }
+
+    $.ajax(
+    {
+        url: "../../api/messageConversations/read",
+        type: "PUT",
+        data: JSON.stringify( messages ),
+        contentType: "application/json",
+        dataType: "json",
+        success: function( response )
+        {
+            toggleMessagesRead( response.markedRead );
+        },
+        error: function( response )
+        {
+            showErrorMessage( response.message, 3 );
+        }
+    });
+}
+
+function markMessagesUnread( messages )
+{
+    if( messages.length < 1 )
+    {
+        return;
+    }
+
+    $.ajax(
+    {
+        url: "../../api/messageConversations/unread",
+        type: "PUT",
+        data: JSON.stringify( messages ),
+        contentType: "application/json",
+        dataType: "json",
+        success: function( response )
+        {
+            toggleMessagesRead( response.markedUnread );
+        },
+        error: function( response )
+        {
+            showErrorMessage( response.message, 3 );
+        }
+    });
+}
+
+function toggleMessagesRead( messageUids )
+{
+    var messages = $( "#messages" );
+
+    for( var i = 0 ; i < messageUids.length ; i++ )
+    {
+        messages.find( "[name='" + messageUids[i] + "']" ).toggleClass( "unread bold" );
+        messages.find( "input:checkbox" ).removeAttr( "checked" );
+    }
+}
+
 function read( id )
 {
     window.location.href = "readMessage.action?id=" + id;

=== modified file 'dhis-2/dhis-web/dhis-web-dashboard-integration/src/main/webapp/dhis-web-dashboard-integration/message.vm'
--- dhis-2/dhis-web/dhis-web-dashboard-integration/src/main/webapp/dhis-web-dashboard-integration/message.vm	2013-10-28 13:30:18 +0000
+++ dhis-2/dhis-web/dhis-web-dashboard-integration/src/main/webapp/dhis-web-dashboard-integration/message.vm	2014-09-22 15:08:21 +0000
@@ -1,50 +1,77 @@
-
 <h3>$i18n.getString( "messages" ) #openHelp( "dashboard_messages" )</h3>
 
 <div class="horizontalMenu" style="padding: 8px 0 40px 0;">
     <ul>
-    	#if( $auth.hasAccess( "dhis-web-dashboard-integration", "sendMessage" ) )
+
+        #if( $auth.hasAccess( "dhis-web-dashboard-integration", "sendMessage" ) )
         <li><a class="blueButtonLink" href="showSendMessage.action">$i18n.getString( 'write_message' )</a></li>
         #end
         <li><a class="blueButtonLink" href="showSendFeedback.action">$i18n.getString( "write_feedback" )</a></li>
         <li><span style="padding-left:12px"></span></li>
+        <li>
+            <div id="checkboxDropdown">
+                <div></div>
+                <ul>
+                    <li data-action="removeMessages">Delete</li>
+                    <li data-action="markMessagesRead">Mark read</li>
+                    <li data-action="markMessagesUnread">Mark unread</li>
+                </ul>
+            </div>
+        </li>
+        <li><span style="padding-left:12px"></span></li>
         <li><a class="greyButtonLink" href="message.action">$i18n.getString( "inbox" )</a></li>
         <li><a class="greyButtonLink" href="message.action?followUp=true">$i18n.getString( "follow_up" )</a></li>
         <li><a class="greyButtonLink" href="message.action?unread=true">$i18n.getString( "unread" )</a></li>
     </ul>
 </div>
-
 <div style="width:100%">
-<div style="padding-right:15px">
-<table class="plainList" style="width:100%">
-	<tr>
-		<th></th>
-		<th>$i18n.getString( "sender" )</th>
-		<th>$i18n.getString( "subject" )</th>
-		<th>$i18n.getString( "date" )</th>
-		<th></th>
-	</tr>
-	#foreach( $conversation in $conversations )
-	<tr id="tr${conversation.id}" #if( !$conversation.read )class="unread bold"#end>
-		<td style="width:40px;padding-left:5px;" onclick="toggleFollowUp( '${conversation.id}' )">
-			<img id="followUp${conversation.id}" #if( $conversation.followUp ) src="../images/marked.png"#else src="../images/unmarked.png"#end></td>		
-		<td style="width:200px" onclick="read( '${conversation.uid}' )">
-			#if( $conversation.lastSenderName )$!encoder.htmlEncode( $conversation.lastSenderName )#else$i18n.getString( "system_notification" )#end
-			#if( $conversation.messageCount > 1 ) <span class="normal">(${conversation.messageCount})</span>#end
-		</td>
-		<td onclick="read( '${conversation.uid}' )">$!encoder.htmlEncode( $conversation.subject )</td>
-		<td onclick="read( '${conversation.uid}' )" style="width:80px">$!format.formatDate( $conversation.lastMessage )</td>
-		<td style="width:70px; text-align:center;">
-			<a href="readMessage.action?id=${conversation.uid}"><img src="../images/read.png" title="$i18n.getString( 'read' )"></a>
-			<a href="javascript:removeMessage( '${conversation.id}' )"><img src="../images/delete.png" title="$i18n.getString( 'delete' )"></a>
-		</td>
-	</tr>
-	#end
-</table>
-#parse( "/dhis-web-commons/paging/paging.vm" )
-</div>
-</div>
-
+    <div style="padding-right:15px">
+        <table class="plainList" style="width:100%">
+            <thead>
+            <tr>
+                <th></th>
+                <th></th>
+                <th>$i18n.getString( "sender" )</th>
+                <th>$i18n.getString( "subject" )</th>
+                <th>$i18n.getString( "date" )</th>
+                <th></th>
+            </tr>
+            </thead>
+            <tbody id="messages" >
+                #foreach( $conversation in $conversations )
+                <tr name="${conversation.uid}" id="tr${conversation.id}" #if( !$conversation.read )class="unread bold"#end>
+                    <td style="width:20px;padding-left:5px;">
+                        <input type="checkbox" value="${conversation.uid}" />
+                    </td>
+                    <td style="width:40px;padding-left:5px;" onclick="toggleFollowUp( '${conversation.id}' )">
+                        <img id="followUp${conversation.id}" #if( $conversation.followUp ) src="../images/marked.png"#else src="../images/unmarked.png"#end></td>
+                    <td style="width:200px" onclick="read( '${conversation.uid}' )">
+                        #if( $conversation.lastSenderName )$!encoder.htmlEncode( $conversation.lastSenderName )#else$i18n.getString( "system_notification" )#end
+                        #if( $conversation.messageCount > 1 ) <span class="normal">(${conversation.messageCount})</span>#end
+                    </td>
+                    <td onclick="read( '${conversation.uid}' )">$!encoder.htmlEncode( $conversation.subject )</td>
+                    <td onclick="read( '${conversation.uid}' )" style="width:80px">$!format.formatDate( $conversation.lastMessage )</td>
+                    <td style="width:70px; text-align:center;">
+                        <a href="readMessage.action?id=${conversation.uid}"><img src="../images/read.png" title="$i18n.getString( 'read' )"></a>
+                        <a href="javascript:removeMessage( '${conversation.id}' )"><img src="../images/delete.png" title="$i18n.getString( 'delete' )"></a>
+                    </td>
+                </tr>
+                #end
+            </tbody>
+        </table>
+        #parse( "/dhis-web-commons/paging/paging.vm" )
+    </div>
+</div>
 <script type="text/javascript">
-	var i18n_confirm_delete_message = '$encoder.jsEscape( $i18n.getString( "confirm_delete_message" ) , "'" )';
+$( document ).ready( function() {
+    i18n_confirm_delete_message = '$encoder.jsEscape( $i18n.getString( "confirm_delete_message" ) , "'" )';
+    i18n_confirm_delete_all_selected_messages = '$encoder.jsEscape( $i18n.getString( "confirm_delete_all_selected_messages" ), "'" )';
+    i18n_no_messages_selected = '$encoder.jsEscape( $i18n.getString( "no_messages_selected" ), "'" )';
+    i18n_messages_were_deleted = '$encoder.jsEscape( $i18n.getString( "messages_were_deleted" ), "'" )';
+    i18n_delete = '$encoder.jsEscape( $i18n.getString( "delete" ), "'" )';
+    i18n_mark_read = '$encoder.jsEscape( $i18n.getString( "mark_read" ), "'" )';
+    i18n_mark_unread = '$encoder.jsEscape( $i18n.getString( "mark_unread" ) , "'" )';
+
+    $( "#checkboxDropdown" ).multiCheckboxMenu( $( "#messages" ) );
+});
 </script>