/**
* =============================================================================
*
* ORCID (R) Open Source
* http://orcid.org
*
* Copyright (c) 2012-2014 ORCID, Inc.
* Licensed under an MIT-Style License (MIT)
* http://orcid.org/open-source-license
*
* This copyright and license information (including a link to the full license)
* shall be included in its entirety in all copies or substantial portion of
* the software.
*
* =============================================================================
*/
package org.orcid.core.cli;
import java.io.BufferedReader;
import java.io.File;
import java.io.FileReader;
import java.io.IOException;
import java.util.Arrays;
import java.util.Date;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.TreeSet;
import java.util.stream.Collectors;
import org.apache.commons.lang.LocaleUtils;
import org.apache.commons.lang3.StringUtils;
import org.apache.commons.lang3.time.DurationFormatUtils;
import org.kohsuke.args4j.CmdLineException;
import org.kohsuke.args4j.CmdLineParser;
import org.kohsuke.args4j.Option;
import org.orcid.core.locale.LocaleManager;
import org.orcid.core.manager.AffiliationsManager;
import org.orcid.core.manager.NotificationManager;
import org.orcid.core.manager.ProfileFundingManager;
import org.orcid.core.manager.TemplateManager;
import org.orcid.core.manager.impl.MailGunManager;
import org.orcid.core.manager.impl.OrcidUrlManager;
import org.orcid.jaxb.model.message.Locale;
import org.orcid.persistence.dao.ProfileDao;
import org.orcid.persistence.jpa.entities.CountryIsoEntity;
import org.orcid.persistence.jpa.entities.IndexingStatus;
import org.orcid.persistence.jpa.entities.OrgAffiliationRelationEntity;
import org.orcid.persistence.jpa.entities.OrgDisambiguatedEntity;
import org.orcid.persistence.jpa.entities.OrgEntity;
import org.orcid.persistence.jpa.entities.ProfileEntity;
import org.orcid.persistence.jpa.entities.ProfileFundingEntity;
import org.orcid.utils.NullUtils;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.context.ApplicationContext;
import org.springframework.context.MessageSource;
import org.springframework.context.support.ClassPathXmlApplicationContext;
import org.springframework.transaction.TransactionStatus;
import org.springframework.transaction.support.TransactionCallbackWithoutResult;
import org.springframework.transaction.support.TransactionTemplate;
/**
*
* @author Will Simpson
*
*/
public class SendBadOrgsEmail {
private static Logger LOG = LoggerFactory.getLogger(SendBadOrgsEmail.class);
private static final String FROM_ADDRESS = "\"Laure Haak, Executive Director, ORCID\" <laure@notify.orcid.org>";
private static final String SUBJECT = "Affiliation bug in ORCID record";
private TransactionTemplate transactionTemplate;
private ProfileDao profileDao;
private AffiliationsManager affiliationsManager;
private ProfileFundingManager profileFundingManager;
private LocaleManager localeManager;
private TemplateManager templateManager;
private MessageSource messageSource;
private OrcidUrlManager orcidUrlManager;
private MailGunManager mailGunManager;
private NotificationManager notificationManager;
@Option(name = "-f", usage = "Path to file containing ORCIDs to check and send")
private File fileToLoad;
@Option(name = "-o", usage = "ORCID to check and send")
private String singleOrcidToProcess;
@Option(name = "-d", usage = "Dry run only (default is false)")
private boolean dryRun;
@Option(name = "-b", usage = "Show email body in console output (default is false)")
private boolean showEmailBody;
@Option(name = "-l", usage = "Lenient mode (default is false)")
private boolean lenientMode;
@Option(name = "-c", usage = "Continue to next record if there is an error (default = stop on error)")
private boolean continueOnError;
private int doneCount;
private int errorCount;
public static void main(String[] args) throws IOException {
SendBadOrgsEmail sendBadOrgsEmail = new SendBadOrgsEmail();
CmdLineParser parser = new CmdLineParser(sendBadOrgsEmail);
try {
parser.parseArgument(args);
sendBadOrgsEmail.validateArgs(parser);
sendBadOrgsEmail.init();
sendBadOrgsEmail.execute();
} catch (CmdLineException e) {
System.err.println(e.getMessage());
parser.printUsage(System.err);
System.exit(1);
} catch (Throwable t) {
t.printStackTrace(System.err);
System.exit(2);
}
System.exit(0);
}
private void validateArgs(CmdLineParser parser) throws CmdLineException {
if (NullUtils.allNull(fileToLoad, singleOrcidToProcess)) {
throw new CmdLineException(parser, "At least one of -f | -o must be specificed");
}
}
public void execute() throws IOException {
if (fileToLoad != null) {
processFile();
}
if (singleOrcidToProcess != null) {
processOrcid(singleOrcidToProcess);
}
}
private void processFile() throws IOException {
long startTime = System.currentTimeMillis();
try (BufferedReader br = new BufferedReader(new FileReader(fileToLoad))) {
String line = null;
while ((line = br.readLine()) != null) {
if (StringUtils.isNotBlank(line)) {
processOrcid(line.trim());
}
}
long endTime = System.currentTimeMillis();
String timeTaken = DurationFormatUtils.formatDurationHMS(endTime - startTime);
LOG.info("Finished sending bad org emails: doneCount={}, errorCount={}, timeTaken={} (H:m:s.S)", new Object[] { doneCount, errorCount, timeTaken });
}
}
private void processOrcid(final String orcid) {
LOG.info("Checking record: {}", orcid);
try {
transactionTemplate.execute(new TransactionCallbackWithoutResult() {
@Override
protected void doInTransactionWithoutResult(TransactionStatus status) {
ProfileEntity profile = profileDao.find(orcid);
final Locale locale = calculateLocale(profile);
Set<String> orgDescriptions = new TreeSet<>();
List<OrgAffiliationRelationEntity> badAffs = processAffs(profile, locale, orgDescriptions);
List<ProfileFundingEntity> badFundings = processFundings(profile, locale, orgDescriptions);
if (!badAffs.isEmpty() || !badFundings.isEmpty()) {
sendEmail(profile, locale, badAffs, badFundings, orgDescriptions);
}
}
});
} catch (RuntimeException e) {
errorCount++;
if (continueOnError) {
LOG.error("Error checking and sending record: orcid={}", orcid, e);
return;
} else {
throw e;
}
}
doneCount++;
}
@SuppressWarnings("resource")
private void init() {
ApplicationContext context = new ClassPathXmlApplicationContext("orcid-core-context.xml");
transactionTemplate = (TransactionTemplate) context.getBean("transactionTemplate");
profileDao = (ProfileDao) context.getBean("profileDao");
affiliationsManager = (AffiliationsManager) context.getBean("affiliationsManager");
profileFundingManager = (ProfileFundingManager) context.getBean("profileFundingManager");
localeManager = (LocaleManager) context.getBean("localeManager");
templateManager = (TemplateManager) context.getBean("templateManager");
messageSource = (MessageSource) context.getBean("messageSource");
orcidUrlManager = (OrcidUrlManager) context.getBean("orcidUrlManager");
mailGunManager = (MailGunManager) context.getBean("mailGunManager");
notificationManager = (NotificationManager) context.getBean("notificationManager");
}
private String createOrgDescription(OrgEntity org, Locale locale) {
String orgCountry = localeManager.resolveMessage(CountryIsoEntity.class.getName() + "." + org.getCountry().name(), locale);
return Arrays.asList(new String[] { org.getName(), org.getCity(), org.getRegion(), orgCountry }).stream().filter(e -> e != null)
.collect(Collectors.joining(", "));
}
private Locale calculateLocale(ProfileEntity profile) {
Locale locale = (profile.getLocale() == null) ? null : Locale.fromValue(profile.getLocale().value());
if (locale == null) {
locale = Locale.EN;
}
final Locale finalLocale = locale;
return finalLocale;
}
private List<OrgAffiliationRelationEntity> processAffs(ProfileEntity profile, final Locale locale, Set<String> orgDescriptions) {
List<OrgAffiliationRelationEntity> badAffs = profile.getOrgAffiliationRelations().stream().filter(e -> isBadOrg(e.getOrg(), e.getDateCreated()))
.collect(Collectors.toList());
badAffs.forEach(a -> {
String orgDescription = createOrgDescription(a.getOrg(), locale);
orgDescriptions.add(orgDescription);
LOG.info("Found bad affiliation: orcid={}, affiliation id={}, visibility={}, orgDescription={}",
new Object[] { profile.getId(), a.getId(), a.getVisibility(), orgDescription });
if (!dryRun) {
affiliationsManager.updateVisibility(profile.getId(), a.getId(), org.orcid.jaxb.model.common_v2.Visibility.PRIVATE);
}
});
return badAffs;
}
private List<ProfileFundingEntity> processFundings(ProfileEntity profile, final Locale locale, Set<String> orgDescriptions) {
List<ProfileFundingEntity> badFundings = profile.getProfileFunding().stream().filter(e -> isBadOrg(e.getOrg(), e.getDateCreated())).collect(Collectors.toList());
badFundings.forEach(a -> {
String orgDescription = createOrgDescription(a.getOrg(), locale);
orgDescriptions.add(orgDescription);
LOG.info("Found bad funding: orcid={}, funding id={}, visibility={}, orgDescription={}",
new Object[] { profile.getId(), a.getId(), a.getVisibility(), orgDescription });
if (!dryRun) {
profileFundingManager.updateProfileFundingVisibility(profile.getId(), a.getId(), org.orcid.jaxb.model.common_v2.Visibility.PRIVATE);
}
});
return badFundings;
}
private boolean isBadOrg(OrgEntity org, Date activityDateCreated) {
boolean wasModified = org.getLastModified().after(org.getDateCreated());
if (wasModified) {
if (lenientMode) {
OrgDisambiguatedEntity orgDisambiguated = org.getOrgDisambiguated();
if (orgDisambiguated != null) {
long justAfterOrgDisambiguatedCreated = orgDisambiguated.getDateCreated().getTime() + 60000;
long justBeforeOrgDisambiguatedCreated = orgDisambiguated.getDateCreated().getTime() - 60000;
if (org.getDateCreated().getTime() > justAfterOrgDisambiguatedCreated) {
// The org was created well after the disambiguated org,
// so it is probably not the original org created for
// the disambiguated org
return true;
}
if (org.getDateCreated().getTime() < justBeforeOrgDisambiguatedCreated) {
// The org was created well before the disambiguated
// org, so can't be the original one.
return true;
}
// Likely to be the original one, and if anything is
// different, we could manually revert?
if (needsReverting(org, orgDisambiguated)) {
LOG.info("Found org to revert to disambiguated info: orcid={}, org id={}, disambiguated org id={}",
new Object[] { org.getId(), orgDisambiguated.getId() });
// The org will be bad once we revert it, if the user
// saw the modified version.
return activityDateCreated.after(org.getLastModified());
} else {
// Likely the original org, it matches the
// disambiguated org, yet appears to have been modified.
// This probably means it was changed to something
// different, then changed back! Have to flag as bad.
return true;
}
} else {
return true;
}
} else {
return true;
}
}
return false;
}
private boolean needsReverting(OrgEntity org, OrgDisambiguatedEntity orgDisambiguated) {
boolean needsRevertingToDisambiguatedInfo = false;
if (!StringUtils.equals(org.getName(), orgDisambiguated.getName())) {
needsRevertingToDisambiguatedInfo = true;
} else if (!StringUtils.equals(org.getCity(), orgDisambiguated.getCity())) {
needsRevertingToDisambiguatedInfo = true;
} else if (!StringUtils.equals(org.getRegion(), orgDisambiguated.getRegion())) {
needsRevertingToDisambiguatedInfo = true;
} else if (!org.getCountry().equals(orgDisambiguated.getCountry())) {
needsRevertingToDisambiguatedInfo = true;
}
return needsRevertingToDisambiguatedInfo;
}
private Map<String, Object> createTemplateParams(String orcid, String emailName, Locale locale, Set<String> orgDescriptions) {
Map<String, Object> templateParams = new HashMap<String, Object>();
templateParams.put("messages", messageSource);
templateParams.put("messageArgs", new Object[0]);
templateParams.put("orcidId", orcid);
templateParams.put("emailName", emailName);
templateParams.put("locale", LocaleUtils.toLocale(locale.value()));
templateParams.put("baseUri", orcidUrlManager.getBaseUrl());
templateParams.put("baseUriHttp", orcidUrlManager.getBaseUriHttp());
templateParams.put("subject", SUBJECT);
templateParams.put("orgDescriptions", orgDescriptions);
return templateParams;
}
private void sendEmail(ProfileEntity profile, final Locale locale, List<OrgAffiliationRelationEntity> badAffs, List<ProfileFundingEntity> badFundings,
Set<String> orgDescriptions) {
LOG.info("Sending bad orgs email: orcid={}, num bad affs={}, num bad fundings={}, claimed={}, deactivated={}, deprecated={}, locked={}",
new Object[] { profile.getId(), badAffs.size(), badFundings.size(), profile.getClaimed(), profile.getDeactivationDate() != null,
profile.getDeprecatedDate() != null, profile.getRecordLocked() });
String emailName = notificationManager.deriveEmailFriendlyName(profile);
Map<String, Object> templateParams = createTemplateParams(profile.getId(), emailName, locale, orgDescriptions);
// Generate body from template
String body = templateManager.processTemplate("bad_orgs_email.ftl", templateParams);
// Generate html from template
String html = templateManager.processTemplate("bad_orgs_email_html.ftl", templateParams);
if (showEmailBody) {
LOG.info("text email={}", body);
LOG.info("html email={}", html);
}
if (!dryRun) {
// Update the profile for re-index and cache refresh
profileDao.updateLastModifiedDateAndIndexingStatus(profile.getId(), IndexingStatus.REINDEX);
profileDao.flush();
// Send the email
boolean mailSent = mailGunManager.sendEmail(FROM_ADDRESS, profile.getPrimaryEmail().getId(), SUBJECT, body, html);
if (!mailSent) {
throw new RuntimeException("Failed to send email, orcid=" + profile.getId());
}
}
}
}