/*
* Copyright (c) 2007-2014 by Public Library of Science
*
* http://plos.org
* http://ambraproject.org
*
* Licensed under the Apache 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.apache.org/licenses/LICENSE-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.ambraproject.service.user;
import com.google.gson.Gson;
import org.ambraproject.configuration.ConfigurationStore;
import org.ambraproject.models.ArticleView;
import org.ambraproject.models.SavedSearch;
import org.ambraproject.models.SavedSearchQuery;
import org.ambraproject.models.SavedSearchType;
import org.ambraproject.models.UserLogin;
import org.ambraproject.models.UserOrcid;
import org.ambraproject.models.UserProfile;
import org.ambraproject.models.UserSearch;
import org.ambraproject.service.hibernate.HibernateServiceImpl;
import org.ambraproject.service.permission.PermissionsService;
import org.ambraproject.service.search.SearchParameters;
import org.ambraproject.util.Pair;
import org.ambraproject.util.TextUtils;
import org.ambraproject.views.OrcidAuthorization;
import org.ambraproject.views.SavedSearchView;
import org.apache.commons.configuration.Configuration;
import org.apache.commons.configuration.HierarchicalConfiguration;
import org.apache.commons.lang.ArrayUtils;
import org.apache.commons.lang.StringUtils;
import org.hibernate.Criteria;
import org.hibernate.FetchMode;
import org.hibernate.criterion.DetachedCriteria;
import org.hibernate.criterion.Restrictions;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.BeanWrapper;
import org.springframework.beans.BeanWrapperImpl;
import org.springframework.beans.factory.annotation.Required;
import org.springframework.dao.support.DataAccessUtils;
import org.springframework.transaction.annotation.Transactional;
import java.beans.PropertyDescriptor;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Calendar;
import java.util.HashSet;
import java.util.Iterator;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.SortedMap;
import java.util.concurrent.ConcurrentSkipListMap;
/**
* Class to roll up web services that a user needs in Ambra. Rest of application should generally
* use AmbraUser to
*
* @author Stephen Cheng
* @author Joe Osowski
*/
public class UserServiceImpl extends HibernateServiceImpl implements UserService {
private static final Logger log = LoggerFactory.getLogger(UserServiceImpl.class);
private static final String ALERTS_CATEGORIES_CATEGORY = "ambra.userAlerts.categories.category";
private static final String ALERTS_WEEKLY = "ambra.userAlerts.weekly";
private static final String ALERTS_MONTHLY = "ambra.userAlerts.monthly";
private static final String SUBJECT_FILTER = "ambra.userAlerts.subjectFilter";
private PermissionsService permissionsService;
private Configuration configuration;
private boolean advancedLogging = false;
@Override
@Transactional(rollbackFor = {Throwable.class})
public UserProfile login(final String authId, final UserLogin loginInfo) {
log.debug("logging in user with auth id {}", authId);
UserProfile user = getUserByAuthId(authId);
if (user != null && this.advancedLogging) {
loginInfo.setUserProfileID(user.getID());
hibernateTemplate.save(loginInfo);
}
return user;
}
@Override
@Transactional(readOnly = true)
public UserProfile getUserByAuthId(String authId) {
log.debug("Attempting to find user with authID: {}", authId);
try {
return (UserProfile) hibernateTemplate.findByCriteria(
DetachedCriteria.forClass(UserProfile.class)
.add(Restrictions.eq("authId", authId))
, 0, 1).get(0);
} catch (IndexOutOfBoundsException e) {
log.warn("Didn't find user for authID: {}", authId);
return null;
}
}
@Override
@Transactional(rollbackFor = {Throwable.class})
public UserProfile updateProfile(final UserProfile userProfile) throws NoSuchUserException {
//get the user by auth id
UserProfile existingUser = getUserByAuthId(userProfile.getAuthId());
if (existingUser == null) {
throw new NoSuchUserException();
}
log.debug("Found a user with authID: {}, updating profile", userProfile.getAuthId());
copyFields(userProfile, existingUser);
hibernateTemplate.update(existingUser);
return existingUser;
}
@Override
@Transactional
public UserProfile setAlerts(String userAuthId, List<String> monthlyAlerts, List<String> weeklyAlerts) {
UserProfile user = getUserByAuthId(userAuthId);
log.debug("updating alerts for user: {}; Montly alerts: {}; weekly alerts: {}",
new Object[]{user.getDisplayName(), StringUtils.join(monthlyAlerts, ","), StringUtils.join(weeklyAlerts, ",")});
List<String> allAlerts;
if (monthlyAlerts != null && weeklyAlerts != null) {
allAlerts = new ArrayList<String>(monthlyAlerts.size() + weeklyAlerts.size());
allAlerts.addAll(getAlertsList(monthlyAlerts, UserProfile.MONTHLY_ALERT_SUFFIX));
allAlerts.addAll(getAlertsList(weeklyAlerts, UserProfile.WEEKLY_ALERT_SUFFIX));
} else if (monthlyAlerts != null) {
allAlerts = new ArrayList<String>(monthlyAlerts.size());
allAlerts.addAll(getAlertsList(monthlyAlerts, UserProfile.MONTHLY_ALERT_SUFFIX));
} else if (weeklyAlerts != null) {
allAlerts = new ArrayList<String>(weeklyAlerts.size());
allAlerts.addAll(getAlertsList(weeklyAlerts, UserProfile.WEEKLY_ALERT_SUFFIX));
} else {
allAlerts = new ArrayList<String>(0);
}
user.setAlertsList(allAlerts);
hibernateTemplate.update(user);
return user;
}
@Override
@Transactional
public UserProfile setSavedSearchAlerts(String userAuthId, List<String> monthlyAlerts, List<String> weeklyAlerts, List<String> deleteAlerts) {
UserProfile user = getUserByAuthId(userAuthId);
log.debug("updating alerts for user: {}; Montly alerts: {}; weekly alerts: {}; delete alerts: {}",
new Object[]{user.getDisplayName(), StringUtils.join(monthlyAlerts, ","), StringUtils.join(weeklyAlerts, ","), StringUtils.join(deleteAlerts, ",")});
List<SavedSearchView> searches = getSavedSearches(user.getID());
Set<String> weeklyItems = new HashSet<String>(weeklyAlerts);
Set<String> monthlyItems = new HashSet<String>(monthlyAlerts);
Set<String> deleteItems = new HashSet<String>(deleteAlerts);
for (SavedSearchView savedSearch: searches) {
//This method should only change user defined search alerts, not journal alerts
if(savedSearch.getSearchType() == SavedSearchType.USER_DEFINED) {
String idstr = String.valueOf(savedSearch.getSavedSearchId());
boolean delete = deleteItems.contains(idstr);
if (delete) {
deleteSavedSearch(user.getID(), savedSearch.getSavedSearchId());
}
else {
boolean weekly = weeklyItems.contains(idstr);
boolean monthly = monthlyItems.contains(idstr);
if (weekly != savedSearch.getWeekly() || monthly != savedSearch.getMonthly()) {
updateSavedSearch(savedSearch.getSavedSearchId(), weekly, monthly);
}
}
}
}
return user;
}
/**
* {@inheritDoc}
*/
@Transactional(rollbackFor = {Throwable.class})
public UserProfile setFilteredWeeklySearchAlert(Long userProfileId, String[] subjects, String journal)
{
SearchParameters searchParameters = new SearchParameters();
searchParameters.setQuery("*:*");
searchParameters.setFilterJournals(new String[] { journal });
searchParameters.setFilterSubjectsDisjunction(subjects);
//We store the saved search here as JSON instead of serializing the object cuz JSON rocks
SavedSearchQuery query = saveSearchQuery(searchParameters);
UserProfile user = getUser(userProfileId);
SavedSearch newSearch = null;
//See if a record exists already, we only allow one weekly alert of type JOURNAL_ALERT per journal
//We key off of the title as it is not user facing
for(SavedSearch savedSearch : user.getSavedSearches()) {
if(savedSearch.getSearchType() == SavedSearchType.JOURNAL_ALERT
&& savedSearch.getWeekly()
&& savedSearch.getSearchName().equals(journal)) {
newSearch = savedSearch;
}
}
if(newSearch == null) {
newSearch = new SavedSearch(journal, query);
newSearch.setSearchType(SavedSearchType.JOURNAL_ALERT);
newSearch.setWeekly(true);
newSearch.setMonthly(false);
user.getSavedSearches().add(newSearch);
} else {
newSearch.setSearchQuery(query);
}
hibernateTemplate.save(user);
return user;
}
/**
* {@inheritDoc}
*/
@Transactional(rollbackFor = {Throwable.class})
public UserProfile removedFilteredWeeklySearchAlert(Long userProfileId, String journal) {
UserProfile user = getUser(userProfileId);
SavedSearch oldSearch = null;
//We key off of the title as it is not user facing
for(SavedSearch savedSearch : user.getSavedSearches()) {
if(savedSearch.getSearchType() == SavedSearchType.JOURNAL_ALERT
&& savedSearch.getWeekly()
&& savedSearch.getSearchName().equals(journal)) {
oldSearch = savedSearch;
}
}
if(oldSearch != null) {
user.getSavedSearches().remove(oldSearch);
}
hibernateTemplate.save(user);
return user;
}
/**
* {@inheritDoc}
*/
@Transactional(rollbackFor = {Throwable.class})
@SuppressWarnings("unchecked")
public void saveSearch(Long userProfileId,
SearchParameters searchParameters,
String name,
boolean weekly,
boolean monthly) {
UserProfile user = hibernateTemplate.get(UserProfile.class, userProfileId);
SavedSearchQuery query = saveSearchQuery(searchParameters);
SavedSearch savedSearch = new SavedSearch(name, query);
savedSearch.setSearchType(SavedSearchType.USER_DEFINED);
savedSearch.setWeekly(weekly);
savedSearch.setMonthly(monthly);
user.getSavedSearches().add(savedSearch);
hibernateTemplate.save(user);
}
/**
* Check to see if a matching savedSearch exists already with the passed in parameters
* if so, reuses that record
*
* @param searchParameters
*
* @return the savedQuery object
*/
private SavedSearchQuery saveSearchQuery(SearchParameters searchParameters) {
Gson gson = new Gson();
//We store the saved search here as JSON instead of serializing the object.
String searchParametersString = gson.toJson(searchParameters);
String queryHash = TextUtils.createHash(searchParametersString);
SavedSearchQuery query;
//Check to see if a matching savedSearch exists already.
List<SavedSearchQuery> queryList =
(List<SavedSearchQuery>) hibernateTemplate.findByCriteria(DetachedCriteria.forClass(SavedSearchQuery.class)
.add(Restrictions.eq("hash", queryHash))
.setResultTransformer(Criteria.DISTINCT_ROOT_ENTITY));
if(queryList.size() == 0) {
query = new SavedSearchQuery(searchParametersString, queryHash);
hibernateTemplate.save(query);
} else {
//It does exist, lets not create a new record
query = queryList.get(0);
}
return query;
}
/**
* {@inheritDoc}
*/
@Transactional(rollbackFor = {Throwable.class})
public List<SavedSearchView> getSavedSearches(Long userProfileId) {
UserProfile userProfile = (UserProfile) DataAccessUtils.uniqueResult(
hibernateTemplate.findByCriteria(
DetachedCriteria.forClass(UserProfile.class)
.add(Restrictions.eq("ID", userProfileId))
.setFetchMode("savedSearches", FetchMode.JOIN)
.setResultTransformer(Criteria.DISTINCT_ROOT_ENTITY)
));
List<SavedSearch> searches = userProfile.getSavedSearches();
List<SavedSearchView> searchViews = new ArrayList<SavedSearchView>(searches.size());
for(SavedSearch savedSearch : searches) {
searchViews.add(new SavedSearchView(savedSearch));
}
return searchViews;
}
/**
* {@inheritDoc}
*/
@Transactional(rollbackFor = {Throwable.class})
public void deleteSavedSearch(Long userProfileId, Long savedSearchId) {
UserProfile userProfile = (UserProfile) DataAccessUtils.uniqueResult(
hibernateTemplate.findByCriteria(
DetachedCriteria.forClass(UserProfile.class)
.add(Restrictions.eq("ID", userProfileId))
.setFetchMode("savedSearches", FetchMode.JOIN)
.setResultTransformer(Criteria.DISTINCT_ROOT_ENTITY)
));
List<SavedSearch> savedSearches = userProfile.getSavedSearches();
for (Iterator<SavedSearch> it=savedSearches.iterator(); it.hasNext(); ) {
SavedSearch savedSearch = it.next();
if (savedSearch.getID().equals(savedSearchId)) {
it.remove();
}
}
hibernateTemplate.update(userProfile);
}
/**
* {@inheritDoc}
*/
@Transactional(rollbackFor = {Throwable.class})
public void updateSavedSearch(Long savedSearchId, boolean weekly, boolean monthly) {
SavedSearch savedSearch = hibernateTemplate.get(SavedSearch.class, savedSearchId);
savedSearch.setMonthly(monthly);
savedSearch.setWeekly(weekly);
hibernateTemplate.update(savedSearch);
}
/**
* return a list of alerts strings with the given suffix added, if they don't already have it
*
* @param alerts the list of alerts
* @param suffix the alerts suffix
* @return a list of alerts strings with the given suffix added, if they don't already have it
*/
private List<String> getAlertsList(List<String> alerts, String suffix) {
List<String> result = new ArrayList<String>(alerts.size());
for (String alert : alerts) {
if (alert.endsWith(suffix)) {
result.add(alert);
} else {
result.add(alert + suffix);
}
}
return result;
}
@Override
@Transactional(readOnly = true)
public UserProfile getUser(Long userId) {
if (userId != null) {
log.debug("Looking up user with id: {}", userId);
return (UserProfile) hibernateTemplate.get(UserProfile.class, userId);
} else {
throw new IllegalArgumentException("Null userId");
}
}
/**
* {@inheritDoc}
*/
@Override
@Transactional
public void saveUserOrcid(Long userProfileId, OrcidAuthorization orcidAuthorization) throws DuplicateOrcidException {
UserOrcid userOrcid = (UserOrcid) DataAccessUtils.uniqueResult(
hibernateTemplate.findByCriteria(
DetachedCriteria.forClass(UserOrcid.class)
.add(Restrictions.eq("orcid", orcidAuthorization.getOrcid()))
.add(Restrictions.ne("ID", userProfileId))
.setResultTransformer(Criteria.DISTINCT_ROOT_ENTITY)
));
if(userOrcid != null) {
throw new DuplicateOrcidException("ORCiD: '" + orcidAuthorization.getOrcid() + "' is already in use by another account");
}
userOrcid = (UserOrcid) DataAccessUtils.uniqueResult(
hibernateTemplate.findByCriteria(
DetachedCriteria.forClass(UserOrcid.class)
.add(Restrictions.eq("ID", userProfileId))
.setResultTransformer(Criteria.DISTINCT_ROOT_ENTITY)
));
boolean isNew = (userOrcid == null);
if(isNew) {
userOrcid = new UserOrcid();
userOrcid.setID(userProfileId);
}
//Note we don't store the token type property of the OrcidAuthorization object.
//http://support.orcid.org/knowledgebase/articles/119985-post-oauth-token
//The token type for our purposes will always be "bearer"
userOrcid.setOrcid(orcidAuthorization.getOrcid());
userOrcid.setAccessToken(orcidAuthorization.getAccessToken());
userOrcid.setRefreshToken(orcidAuthorization.getRefreshToken());
userOrcid.setTokenScope(orcidAuthorization.getScope());
Calendar newExpiresIn = Calendar.getInstance();
//expires-in is "seconds from now"
newExpiresIn.setTimeInMillis(newExpiresIn.getTimeInMillis() + (orcidAuthorization.getExpiresIn() * 1000));
userOrcid.setTokenExpires(newExpiresIn);
if(isNew) {
hibernateTemplate.save(userOrcid);
} else {
hibernateTemplate.update(userOrcid);
}
}
/**
* {@inheritDoc}
*/
@Override
@Transactional
public void removeUserOrcid(Long userProfileId) {
UserOrcid userOrcid = (UserOrcid) DataAccessUtils.uniqueResult(
hibernateTemplate.findByCriteria(
DetachedCriteria.forClass(UserOrcid.class)
.add(Restrictions.eq("ID", userProfileId))
.setResultTransformer(Criteria.DISTINCT_ROOT_ENTITY)
));
if(userOrcid != null) {
hibernateTemplate.delete(userOrcid);
}
}
/**
* {@inheritDoc}
*/
@Override
@Transactional
public UserOrcid getUserOrcid(Long userProfileId) {
UserOrcid userOrcid = (UserOrcid) DataAccessUtils.uniqueResult(
hibernateTemplate.findByCriteria(
DetachedCriteria.forClass(UserOrcid.class)
.add(Restrictions.eq("ID", userProfileId))
.setResultTransformer(Criteria.DISTINCT_ROOT_ENTITY)
));
return userOrcid;
}
@Override
public UserProfile getProfileForDisplay(UserProfile userProfile, boolean showPrivateFields) {
UserProfile display = new UserProfile();
copyFields(userProfile, display);
if (!showPrivateFields) {
log.debug("Removing private fields for display on user: {}", userProfile.getDisplayName());
display.setOrganizationName(null);
display.setOrganizationType(null);
display.setPostalAddress(null);
display.setPositionType(null);
}
//escape html in all string fields
BeanWrapper wrapper = new BeanWrapperImpl(display);
for (PropertyDescriptor property : wrapper.getPropertyDescriptors()) {
if (String.class.isAssignableFrom(property.getPropertyType())) {
String name = property.getName();
wrapper.setPropertyValue(name, TextUtils.escapeHtml((String) wrapper.getPropertyValue(name)));
}
}
return display;
}
@Override
@SuppressWarnings("unchecked")
public List<UserAlert> getAvailableAlerts() {
List<UserAlert> alerts = new ArrayList<UserAlert>();
final SortedMap<Integer, Pair> categoryNames = new ConcurrentSkipListMap<Integer, Pair>();
HierarchicalConfiguration hc = (HierarchicalConfiguration) configuration;
List<HierarchicalConfiguration> categories = hc.configurationsAt(ALERTS_CATEGORIES_CATEGORY);
for (HierarchicalConfiguration c : categories) {
String key = c.getString("[@key]");
int order = c.getInt("[@displayOrder]", categoryNames.size());
String value = c.getString("");
categoryNames.put(order, new Pair<String, String>(key, value));
}
final String[] weeklyCategories = hc.getStringArray(ALERTS_WEEKLY);
final String[] monthlyCategories = hc.getStringArray(ALERTS_MONTHLY);
final String[] subjectFilters = hc.getStringArray(SUBJECT_FILTER);
final Set<Map.Entry<Integer, Pair>> categoryNamesSet = categoryNames.entrySet();
for (final Map.Entry<Integer, Pair> category : categoryNamesSet) {
final String key = (String)category.getValue().getFirst();
boolean weeklyCategoryKey = false;
boolean monthlyCategoryKey = false;
boolean subjectFilter = false;
if (ArrayUtils.contains(weeklyCategories, key)) {
weeklyCategoryKey = true;
}
if (ArrayUtils.contains(monthlyCategories, key)) {
monthlyCategoryKey = true;
}
if (ArrayUtils.contains(subjectFilters, key)) {
subjectFilter = true;
}
alerts.add(new UserAlert((String)category.getValue().getFirst(), (String)category.getValue().getSecond(), weeklyCategoryKey, monthlyCategoryKey, subjectFilter));
}
return alerts;
}
/**
* Copy fields for updating or display. Does <b>not</b> copy some fields:
* <ul>
* <li>ID: never overwrite IDs on hibernate objects</li>
* <li>userAccountUri: these don't come down from display layer, so we don't want to overwrite with null</li>
* <li>userProfileUri: these don't come down from display layer, so we don't want to overwrite with null</li>
* <li>roles: don't want to overwrite a user's roles when updating their profile</li>
* </ul>
*
* @param source
* @param destination
*/
private void copyFields(UserProfile source, UserProfile destination) {
destination.setAuthId(source.getAuthId());
destination.setRealName(source.getRealName());
destination.setGivenNames(source.getGivenNames());
destination.setSurname(source.getSurname());
destination.setTitle(source.getTitle());
destination.setGender(source.getGender());
destination.setEmail(source.getEmail());
destination.setHomePage(source.getHomePage());
destination.setWeblog(source.getWeblog());
destination.setPublications(source.getPublications());
destination.setDisplayName(source.getDisplayName());
destination.setSuffix(source.getSuffix());
destination.setPositionType(source.getPositionType());
destination.setOrganizationName(source.getOrganizationName());
destination.setOrganizationType(source.getOrganizationType());
destination.setPostalAddress(source.getPostalAddress());
destination.setCity(source.getCity());
destination.setCountry(source.getCountry());
destination.setBiography(source.getBiography());
destination.setInterests(source.getInterests());
destination.setResearchAreas(source.getResearchAreas());
destination.setOrganizationVisibility(source.getOrganizationVisibility());
destination.setAlertsJournals(source.getAlertsJournals());
}
@Override
@Transactional
public Long recordArticleView(Long userId, Long articleId, ArticleView.Type type) {
if (this.advancedLogging) {
return (Long) hibernateTemplate.save(new ArticleView(userId, articleId, type));
} else {
return 0L;
}
}
@Override
@Transactional
public Long recordUserSearch(Long userProfileID, String searchTerms, String searchParams) {
if (this.advancedLogging) {
return (Long) hibernateTemplate.save(new UserSearch(userProfileID, searchTerms, searchParams));
} else {
return 0L;
}
}
/**
* {@inheritDoc}
*/
@Override
public List<String> getJournalAlertSubjects(Long userId, String journal) {
List<SavedSearchView> savedSearchViews = getSavedSearches(userId);
List<String> subjects = new ArrayList<String>();
//If the user has a journal alert for this journal, return true
for(SavedSearchView view : savedSearchViews) {
if(view.getSearchType() == SavedSearchType.JOURNAL_ALERT) {
if(view.getSearchParameters().getFilterJournals().length != 1) {
continue;
}
if(view.getSearchParameters().getFilterJournals()[0].equals(journal)) {
String[] storedCategories = view.getSearchParameters().getFilterSubjectsDisjunction();
subjects.addAll(Arrays.asList(storedCategories));
}
}
}
return subjects;
}
/**
* {@inheritDoc}
*/
@Override
public Pair<Boolean,Integer> getJournalAlertAndSubjectCount(Long userId, String journal, String category) {
List<SavedSearchView> savedSearchViews = getSavedSearches(userId);
boolean found = false;
int subjectCount = 0;
//If the user has a journal alert for this journal, return true
for(SavedSearchView view : savedSearchViews) {
if(view.getSearchType() == SavedSearchType.JOURNAL_ALERT) {
if(view.getSearchParameters().getFilterJournals().length != 1) {
continue;
}
if(view.getSearchParameters().getFilterJournals()[0].equals(journal)) {
String[] storedCategories = view.getSearchParameters().getFilterSubjectsDisjunction();
subjectCount += storedCategories.length;
for(String storedCategory : storedCategories) {
if(storedCategory.equals(category)) {
found = true;
}
}
}
}
}
return new Pair<Boolean, Integer>(found, subjectCount);
}
/**
* Getter for property 'permissionsService'.
*
* @return Value for property 'permissionsService'.
*/
public PermissionsService getPermissionsService() {
return permissionsService;
}
/**
* Setter for property 'permissionsService'.
*
* @param permissionsService Value to set for property 'permissionsService'.
*/
@Required
public void setPermissionsService(final PermissionsService permissionsService) {
this.permissionsService = permissionsService;
}
@Required
public void setConfiguration(Configuration configuration) {
this.configuration = configuration;
Object val = configuration.getProperty(ConfigurationStore.ADVANCED_USAGE_LOGGING);
if (val != null && val.equals("true")) {
advancedLogging = true;
}
}
}