/**********************************************************************************
* $URL: https://source.sakaiproject.org/svn/announcement/trunk/announcement-tool/tool/src/java/org/sakaiproject/announcement/entityprovider/AnnouncementEntityProviderImpl.java $
* $Id: AnnouncementEntityProviderImpl.java 87813 2011-01-28 13:42:17Z savithap@umich.edu $
***********************************************************************************
*
* Copyright (c) 2003, 2004, 2005, 2006, 2007, 2008, 2009 The Sakai Foundation
*
* Licensed under the Educational Community License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.opensource.org/licenses/ECL-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*
**********************************************************************************/
package org.sakaiproject.announcement.entityprovider;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Calendar;
import java.util.Collections;
import java.util.Date;
import java.util.List;
import java.util.Map;
import java.util.Properties;
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.Setter;
import org.apache.commons.lang.StringUtils;
import org.apache.commons.lang.math.NumberUtils;
import org.apache.commons.logging.Log;
import org.apache.commons.logging.LogFactory;
import org.sakaiproject.announcement.api.AnnouncementChannel;
import org.sakaiproject.announcement.api.AnnouncementMessage;
import org.sakaiproject.announcement.api.AnnouncementMessageHeader;
import org.sakaiproject.announcement.api.AnnouncementService;
import org.sakaiproject.authz.api.SecurityService;
import org.sakaiproject.entity.api.EntityManager;
import org.sakaiproject.entity.api.EntityPermissionException;
import org.sakaiproject.entity.api.Reference;
import org.sakaiproject.entitybroker.EntityReference;
import org.sakaiproject.entitybroker.EntityView;
import org.sakaiproject.entitybroker.entityprovider.EntityProvider;
import org.sakaiproject.entitybroker.entityprovider.annotations.EntityCustomAction;
import org.sakaiproject.entitybroker.entityprovider.capabilities.ActionsExecutable;
import org.sakaiproject.entitybroker.entityprovider.capabilities.AutoRegisterEntityProvider;
import org.sakaiproject.entitybroker.entityprovider.capabilities.Describeable;
import org.sakaiproject.entitybroker.entityprovider.capabilities.Outputable;
import org.sakaiproject.entitybroker.entityprovider.capabilities.Resolvable;
import org.sakaiproject.entitybroker.entityprovider.capabilities.Sampleable;
import org.sakaiproject.entitybroker.entityprovider.extension.Formats;
import org.sakaiproject.entitybroker.exception.EntityException;
import org.sakaiproject.entitybroker.exception.EntityNotFoundException;
import org.sakaiproject.entitybroker.util.AbstractEntityProvider;
import org.sakaiproject.exception.IdUnusedException;
import org.sakaiproject.exception.PermissionException;
import org.sakaiproject.message.api.Message;
import org.sakaiproject.site.api.Site;
import org.sakaiproject.site.api.SiteService;
import org.sakaiproject.site.api.ToolConfiguration;
import org.sakaiproject.time.api.Time;
import org.sakaiproject.time.api.TimeService;
import org.sakaiproject.tool.api.SessionManager;
import org.sakaiproject.tool.api.ToolManager;
import org.sakaiproject.user.api.UserDirectoryService;
import org.sakaiproject.util.MergedList;
import org.sakaiproject.util.ResourceLoader;
import org.sakaiproject.util.Validator;
/**
* Allows some basic functions on announcements.
* Due to limitations of EntityBroker the internal URLs of the announcements service can't be exposed
* directly, so we have to map them, with assumptions about characters used in IDs. Basically we pack together
* the {siteId}:{channelId}:{announcementId} into the ID.
*
*/
public class AnnouncementEntityProviderImpl extends AbstractEntityProvider implements EntityProvider, AutoRegisterEntityProvider, ActionsExecutable, Outputable, Describeable, Sampleable, Resolvable {
public final static String ENTITY_PREFIX = "announcement";
private static final String PORTLET_CONFIG_PARAM_MERGED_CHANNELS = "mergedAnnouncementChannels";
private static final String MOTD_SITEID = "!site";
private static final String ADMIN_SITEID = "!admin";
private static final String MOTD_CHANNEL_SUFFIX = "motd";
public static int DEFAULT_NUM_ANNOUNCEMENTS = 3;
public static int DEFAULT_DAYS_IN_PAST = 10;
private static final Log log = LogFactory.getLog(AnnouncementEntityProviderImpl.class);
private static ResourceLoader rb = new ResourceLoader("announcement");
/**
* Prefix for this provider
*/
@Override
public String getEntityPrefix() {
return ENTITY_PREFIX;
}
/**
* Get the list of announcements for a site (or user site, or !site for motd).
* This is aimed to providing a list of announcements similar to those that the synoptic announcement
* tool shows. It doesn't show announcements that are outside their date range even if you
* have permission to see them (eg from being a maintainer in the site).
*
* @param siteId - siteId requested, or user site, or !site for motd.
* @param params - the raw URL params that were sent, for processing.
* @param onlyPublic - only show public announcements
* @return
*/
private List<?> getAnnouncements(String siteId, Map<String,Object> params, boolean onlyPublic) {
//check if we are loading the MOTD
boolean motdView = false;
if(StringUtils.equals(siteId, MOTD_SITEID)) {
motdView = true;
}
//get number of announcements and days in the past to show from the URL params, validate and set to 0 if not set or conversion fails.
//we use this zero value to determine if we need to look up from the tool config, or use the defaults if still not set.
int numberOfAnnouncements = NumberUtils.toInt((String)params.get("n"), 0);
int numberOfDaysInThePast = NumberUtils.toInt((String)params.get("d"), 0);
//get currentUserId for permissions checks, although unused for motdView and onlyPublic
String currentUserId = sessionManager.getCurrentSessionUserId();
if(log.isDebugEnabled()) {
log.debug("motdView: " + motdView);
log.debug("siteId: " + siteId);
log.debug("currentUserId: " + currentUserId);
log.debug("onlyPublic: " + onlyPublic);
}
//check current user has annc.read permissions for this site, not for public or motd though
if(!onlyPublic && !motdView) {
if(!securityService.unlock(AnnouncementService.SECURE_ANNC_READ, siteService.siteReference(siteId))) {
throw new SecurityException("You do not have access to site: " + siteId);
}
}
// get the channels
List<String> channels = getChannels(siteId);
if(channels.size() == 0){
throw new EntityNotFoundException("No announcement channels found for site: " + siteId, siteId);
}
if(log.isDebugEnabled()) {
log.debug("channels: " + channels.toString());
log.debug("num channels: " + channels.size());
}
Site site = null;
String siteTitle = null;
ToolConfiguration synopticTc = null;
if(!motdView) {
//get site
try {
site = siteService.getSite(siteId);
} catch (IdUnusedException e) {
throw new IllegalArgumentException("No site found for the siteid:" + siteId + " : "+e.getMessage());
}
//get properties for synoptic tool in this site
synopticTc = site.getToolForCommonId("sakai.synoptic.announcement");
}
if(synopticTc != null){
Properties props = synopticTc.getPlacementConfig();
if(props.isEmpty()) {
props = synopticTc.getConfig();
}
if(props != null){
//only get these from the synoptic tool config if not already set in the URL params
if (numberOfAnnouncements == 0 && props.get("items") != null) {
numberOfAnnouncements = getIntegerParameter(props, "items", DEFAULT_NUM_ANNOUNCEMENTS);
}
if (numberOfDaysInThePast == 0 && props.get("days") != null) {
numberOfDaysInThePast = getIntegerParameter(props, "days", DEFAULT_DAYS_IN_PAST);
}
}
}
//get site title
if(!motdView) {
siteTitle = site.getTitle();
} else {
siteTitle = rb.getString("motd.title");
}
//if numbers are still zero, use the defaults
if(numberOfAnnouncements == 0) {
numberOfAnnouncements = DEFAULT_NUM_ANNOUNCEMENTS;
}
if(numberOfDaysInThePast == 0) {
numberOfDaysInThePast = DEFAULT_DAYS_IN_PAST;
}
if(log.isDebugEnabled()) {
log.debug("numberOfAnnouncements: " + numberOfAnnouncements);
log.debug("numberOfDaysInThePast: " + numberOfDaysInThePast);
}
//get the Sakai Time for the given java Date
Time t = timeService.newTime(getTimeForDaysInPast(numberOfDaysInThePast).getTime());
//get the announcements for each channel
List<Message> announcements = new ArrayList<Message>();
//for each channel
for(String channel: channels) {
try {
announcements.addAll(announcementService.getMessages(channel, t, numberOfAnnouncements, true, false, onlyPublic));
} catch (PermissionException e) {
log.warn("User: " + currentUserId + " does not have access to view the announcement channel: " + channel + ". Skipping...");
}
}
if(log.isDebugEnabled()) {
log.debug("announcements.size(): " + announcements.size());
}
//convert raw announcements into decorated announcements
List<DecoratedAnnouncement> decoratedAnnouncements = new ArrayList<DecoratedAnnouncement>();
for (Message m : announcements) {
AnnouncementMessage a = (AnnouncementMessage)m;
if(announcementService.isMessageViewable(a)) {
try {
DecoratedAnnouncement da = createDecoratedAnnouncement(a, siteTitle);
decoratedAnnouncements.add(da);
} catch (Exception e) {
//this can throw an exception if we are not logged in, ie public, this is fine so just deal with it and continue
log.info("Exception caught processing announcement: " + m.getId() + " for user: " + currentUserId + ". Skipping...");
}
}
}
//sort
Collections.sort(decoratedAnnouncements);
//reverse so it is date descending. This could be dependent on a parameter that specifies the sort order
Collections.reverse(decoratedAnnouncements);
//trim to final number, within bounds of list size.
if(numberOfAnnouncements > decoratedAnnouncements.size()) {
numberOfAnnouncements = decoratedAnnouncements.size();
}
decoratedAnnouncements = decoratedAnnouncements.subList(0, numberOfAnnouncements);
return decoratedAnnouncements;
}
private DecoratedAnnouncement createDecoratedAnnouncement(AnnouncementMessage a, String siteTitle) {
String reference = a.getReference();
String announcementId = a.getId();
Reference ref = entityManager.newReference(reference);
String siteId = ref.getContext();
String channel = ref.getContainer();
DecoratedAnnouncement da = new DecoratedAnnouncement(siteId, channel, announcementId);
da.setTitle(a.getAnnouncementHeader().getSubject());
da.setBody(a.getBody());
da.setCreatedByDisplayName(a.getHeader().getFrom().getDisplayName());
da.setCreatedOn(new Date(a.getHeader().getDate().getTime()));
da.setSiteId(siteId);
da.setSiteTitle(siteTitle);
//get attachments
List<DecoratedAttachment> attachments = new ArrayList<DecoratedAttachment>();
for (Reference attachment : (List<Reference>) a.getHeader().getAttachments()) {
String url = attachment.getUrl();
String name = attachment.getProperties().getPropertyFormatted(attachment.getProperties().getNamePropDisplayName());
DecoratedAttachment decoratedAttachment = new DecoratedAttachment(name, url);
attachments.add(decoratedAttachment);
}
da.setAttachments(attachments);
return da;
}
/**
* Return a list of DecoratedAttachment objects
* @param attachments List of Reference objects
* @return
*/
private List<DecoratedAttachment> decorateAttachments(List<Reference> attachments) {
List<DecoratedAttachment> decoAttachments = new ArrayList<DecoratedAttachment>();
for(Reference attachment : attachments){
DecoratedAttachment da = new DecoratedAttachment();
da.setId(Validator.escapeHtml(attachment.getId()));
da.setName(Validator.escapeHtml(attachment.getProperties().getPropertyFormatted(attachment.getProperties().getNamePropDisplayName())));
da.setType(attachment.getProperties().getProperty(attachment.getProperties().getNamePropContentType()));
da.setUrl(attachment.getUrl());
da.setRef(attachment.getEntity().getReference());
decoAttachments.add(da);
}
return decoAttachments;
}
/**
* Gets an announcement based on the id and site
* @param entityId id of the announcement
* @param siteId siteid
* @return
*/
private DecoratedAnnouncement findEntityById(String entityId, String siteId) {
AnnouncementMessage tempMsg=null;
DecoratedAnnouncement decoratedAnnouncement = new DecoratedAnnouncement();
if (entityId != null) {
try {
AnnouncementChannel announcementChannel = announcementService.getAnnouncementChannel("/announcement/channel/"+siteId+"/main");
tempMsg = (AnnouncementMessage)announcementChannel.getMessage(entityId);
} catch (Exception e) {
log.error("Error finding announcement: " + entityId + " in site: " + siteId + "." + e.getClass() + ":" + e.getStackTrace());
}
}
decoratedAnnouncement.setSiteId(tempMsg.getId());
decoratedAnnouncement.setBody(tempMsg.getBody());
AnnouncementMessageHeader header = tempMsg.getAnnouncementHeader();
decoratedAnnouncement.setTitle(header.getSubject());
List attachments = header.getAttachments();
List<DecoratedAttachment> attachmentUrls = decorateAttachments(attachments);
decoratedAnnouncement.setAttachments(attachmentUrls);
decoratedAnnouncement.setCreatedOn(new Date(header.getDate().getTime()));
decoratedAnnouncement.setCreatedByDisplayName(header.getFrom().getDisplayName());
decoratedAnnouncement.setSiteId(siteId);
return decoratedAnnouncement;
}
/**
* Utility routine used to get an integer named value from a map or supply a default value if none is found.
*/
private int getIntegerParameter(Map<?,?> params, String paramName, int defaultValue) {
String intValString = (String) params.get(paramName);
if (StringUtils.trimToNull(intValString) != null) {
return Integer.parseInt(intValString);
}
else {
return defaultValue;
}
}
/**
* Utility to get the date for n days ago
* @param n number of days in the past
* @return
*/
private Date getTimeForDaysInPast(int n) {
Calendar cal = Calendar.getInstance();
cal.add(Calendar.DATE, -n);
return cal.getTime();
}
/**
* Helper to get the channels for a site.
* <p>
* If user site and not superuser, returns all available channels for this user.<br />
* If user site and superuser, return all merged channels.<br />
* If normal site, returns all merged channels.<br />
* If motd site, returns the motd channel.
*
* @param siteId
* @return
*/
private List<String> getChannels(String siteId) {
List<String> channels = new ArrayList<String>();
//if motd
if(StringUtils.equals(siteId, MOTD_SITEID)) {
log.debug("is motd site, returning motd channel");
channels = Collections.singletonList(announcementService.channelReference(siteId, MOTD_CHANNEL_SUFFIX));
return channels;
}
//if user site
if(siteService.isUserSite(siteId)) {
//if not super user, get all channels this user has access to
if(!securityService.isSuperUser()){
log.debug("is user site and not super user, returning all permitted channels");
channels = Arrays.asList(new MergedList().getAllPermittedChannels(new AnnouncementChannelReferenceMaker()));
return channels;
}
}
//this is either a normal site, or we are a super user
//so get the merged announcements for this site
Site site = null;
try {
site = siteService.getSite(siteId);
} catch (IdUnusedException e) {
//this should have been caught and dealt with already so just return empty list
return channels;
}
if(site != null) {
ToolConfiguration toolConfig = site.getToolForCommonId("sakai.announcements");
if(toolConfig != null){
Properties props = toolConfig.getPlacementConfig();
if(props.isEmpty()) {
props = toolConfig.getConfig();
}
if(props != null){
String mergeProp = (String)props.get(PORTLET_CONFIG_PARAM_MERGED_CHANNELS);
if(StringUtils.isNotBlank(mergeProp)) {
log.debug("is normal site or super user, returning all merged channels in this site");
log.debug("mergeProp: " + mergeProp);
channels = Arrays.asList(new MergedList().getChannelReferenceArrayFromDelimitedString(new AnnouncementChannelReferenceMaker().makeReference(siteId), mergeProp));
} else {
log.debug("is normal site or super user but no merged channels, using original siteId channel");
channels = Collections.singletonList(announcementService.channelReference(siteId, SiteService.MAIN_CONTAINER));
}
}
}
}
return channels;
}
/*
* Callback class so that we can form references in a generic way.
*/
private final class AnnouncementChannelReferenceMaker implements MergedList.ChannelReferenceMaker {
public String makeReference(String siteId){
return announcementService.channelReference(siteId, SiteService.MAIN_CONTAINER);
}
}
/**
* site/siteId
*/
@EntityCustomAction(action="site",viewKey=EntityView.VIEW_LIST)
public List<?> getAnnouncementsForSite(EntityView view, Map<String, Object> params) {
//get siteId
String siteId = view.getPathSegment(2);
//check siteId supplied
if (StringUtils.isBlank(siteId)) {
throw new IllegalArgumentException("siteId must be set in order to get the announcements for a site, via the URL /announcement/site/siteId");
}
boolean onlyPublic = true;
//check if logged in
String currentUserId = sessionManager.getCurrentSessionUserId();
boolean isLoggedIn = !StringUtils.isBlank(currentUserId);
boolean canReadThemAnyway = securityService.unlock(AnnouncementService.SECURE_ANNC_READ, siteService.siteReference(siteId));
if (isLoggedIn || canReadThemAnyway) {
//not logged in so set flag to just return any public announcements for the site
onlyPublic = false;
}
//check this is a valid site
if(!siteService.siteExists(siteId)) {
throw new EntityNotFoundException("Invalid siteId: " + siteId, siteId);
}
List<?> l = getAnnouncements(siteId, params, onlyPublic);
return l;
}
/**
* user
*/
@EntityCustomAction(action="user",viewKey=EntityView.VIEW_LIST)
public List<?> getAnnouncementsForUser(EntityView view, Map<String, Object> params) {
String userId = sessionManager.getCurrentSessionUserId();
if (StringUtils.isBlank(userId)) {
//throw new SecurityException("You must be logged in to get your announcements.");
return getMessagesOfTheDay(view, params);
}
//we still need a siteId since Announcements keys it's data on a channel reference created from a siteId.
//in the case of a user, this is the My Workspace siteId for that user (as an internal user id)
String siteId = siteService.getUserSiteId(userId);
if(StringUtils.isBlank(siteId)) {
throw new IllegalArgumentException("No siteId was found for userId: " + userId);
}
//if admin user, siteID is the admin workspace
if(StringUtils.equals(userId, userDirectoryService.ADMIN_EID)){
siteId = ADMIN_SITEID;
}
List<?> l = getAnnouncements(siteId, params, false);
return l;
}
/**
* motd
*/
@EntityCustomAction(action="motd",viewKey=EntityView.VIEW_LIST)
public List<?> getMessagesOfTheDay(EntityView view, Map<String, Object> params) {
//MOTD announcements are published to a special site
List<?> l = getAnnouncements(MOTD_SITEID, params, false);
return l;
}
// The reason this is EntityView.VIEW_LIST, is we want the URL pattern to be /announcement/channel/.... rather
// than //announcement/{id}/channel.
/**
* This handles announcements, URLs should be like, /announcement/msg/{context}/{channelId}/{announcementId}
* an example would be /announcement/msg/21b1984d-af58-43da-8583-f4adee769aa2/main/5641323b-761a-4a4d-8761-688f4928141b .
* Context is normally the site ID and the channelId is normally "main" unless there are multiple channels in a site.
* This is an alternative to using the packed IDs.
*
*/
@EntityCustomAction(action="msg", viewKey=EntityView.VIEW_LIST)
public DecoratedAnnouncement showAnnouncement(EntityView view, Map<String, Object> params) throws EntityPermissionException {
// This is all more complicated because entitybroker isn't very flexible and announcements can only be loaded once you've got the
// channel in which they reside first.
String siteId = view.getPathSegment(2);
String channelId = view.getPathSegment(3);
String announcementId = view.getPathSegment(4);
return getAnnouncement(siteId, channelId, announcementId);
}
/**
* message/siteId/EntityID
*/
@EntityCustomAction(action="message",viewKey=EntityView.VIEW_LIST)
public Object getAnnouncementByID(EntityView view, Map<String, Object> params) {
String siteId = view.getPathSegment(2);
String msgId = view.getPathSegment(3);
//check siteId supplied
if (StringUtils.isBlank(siteId)|| StringUtils.isBlank(msgId)) {
throw new IllegalArgumentException("siteId and msgId must be set in order to get the announcements for a site, via the URL /announcement/message");
}
boolean onlyPublic = false;
//check if logged in
String currentUserId = sessionManager.getCurrentSessionUserId();
if (StringUtils.isBlank(currentUserId)) {
//not logged in so set flag to just return any public announcements for the site
onlyPublic = true;
}
//check this is a valid site
if(!siteService.siteExists(siteId)) {
throw new EntityNotFoundException("Invalid siteId: " + siteId, siteId);
}
return findEntityById(msgId, siteId);
}
/**
* Get a DecoratedAnnouncement given the siteId, channelId and announcementId
* @param siteId
* @param channelId
* @param announcementId
* @return
*/
private DecoratedAnnouncement getAnnouncement(String siteId, String channelId, String announcementId) {
if (announcementId == null || announcementId.length() == 0) {
throw new IllegalArgumentException("You must supply an announcementId");
}
if (siteId == null || siteId.length() == 0) {
throw new IllegalArgumentException("You must supply the siteId.");
}
if (channelId == null || channelId.length() == 0) {
throw new IllegalArgumentException("You must supply an channelId");
}
String ref = announcementService.channelReference(siteId, channelId);
try {
AnnouncementChannel channel = announcementService.getAnnouncementChannel(ref);
AnnouncementMessage message = channel.getAnnouncementMessage(announcementId);
return createDecoratedAnnouncement(message, null);
} catch (IdUnusedException e) {
throw new EntityNotFoundException("Couldn't find: "+ e.getId(), e.getId());
} catch (PermissionException e) {
throw new EntityException("You don't have permissions to access this channel.", e.getResource(), 403);
}
}
/**
* Model class for an attachment
*/
@NoArgsConstructor
public class DecoratedAttachment {
@Getter @Setter private String id;
@Getter @Setter private String name;
@Getter @Setter private String type;
@Getter @Setter private String url;
@Getter @Setter private String ref;
public DecoratedAttachment(String name, String url){
this.name = name;
this.url = url;
}
public DecoratedAttachment(String id, String name, String type, String url, String ref) {
this.id = id;
this.name = name;
this.type = type;
this.url = url;
this.setRef(ref);
}
}
@Override
public Object getSampleEntity() {
return new DecoratedAnnouncement();
}
public String[] getHandledOutputFormats() {
return new String[] { Formats.XML, Formats.JSON };
}
@Override
public Object getEntity(EntityReference ref) {
// This is the packed ID.
String id = ref.getId();
if (id != null) {
String parts[] = id.split(":");
if (parts.length == 3) {
String siteId = parts[0];
String channelId = parts[1];
String announcementId = parts[2];
return getAnnouncement(siteId, channelId, announcementId);
}
}
return null;
}
/**
* Class to hold only the fields that we want to return
*/
@NoArgsConstructor
public class DecoratedAnnouncement implements Comparable<Object> {
@Getter @Setter private String title;
@Getter @Setter private String body;
@Getter @Setter private String createdByDisplayName;
@Getter @Setter private Date createdOn;
@Getter @Setter private List<DecoratedAttachment> attachments;
@Getter @Setter private String siteId;
@Getter @Setter private String announcementId;
@Getter @Setter private String siteTitle;
private String channel;
/**
* As we are packing these fields into the ID, we need all of them.
* @param siteId
* @param channel
* @param announcementId
*/
public DecoratedAnnouncement(String siteId, String channel, String announcementId) {
this.siteId = siteId;
this.channel = channel;
this.announcementId = announcementId;
}
//default sort by date ascending
public int compareTo(Object o) {
Date field = ((DecoratedAnnouncement)o).getCreatedOn();
int lastCmp = createdOn.compareTo(field);
return (lastCmp != 0 ? lastCmp : createdOn.compareTo(field));
}
}
@Setter
private EntityManager entityManager;
@Setter
private SecurityService securityService;
@Setter
private SessionManager sessionManager;
@Setter
private SiteService siteService;
@Setter
private AnnouncementService announcementService;
@Setter
private UserDirectoryService userDirectoryService;
@Setter
private TimeService timeService;
@Setter
private ToolManager toolManager;
}