package com.socrata.datasync.job;
import com.socrata.api.SodaImporter;
import com.socrata.datasync.*;
import com.socrata.datasync.config.CommandLineOptions;
import com.socrata.datasync.config.controlfile.ControlFile;
import com.socrata.datasync.config.userpreferences.UserPreferences;
import com.socrata.datasync.config.userpreferences.UserPreferencesJava;
import com.socrata.datasync.publishers.GISPublisher;
import com.socrata.datasync.validation.GISJobValidity;
import com.socrata.model.importer.Dataset;
import com.socrata.model.importer.GeoDataset;
import org.apache.commons.cli.CommandLine;
import org.apache.commons.io.FilenameUtils;
import org.apache.http.HttpException;
import org.codehaus.jackson.annotate.JsonIgnoreProperties;
import org.codehaus.jackson.annotate.JsonProperty;
import org.codehaus.jackson.map.DeserializationConfig;
import org.codehaus.jackson.map.ObjectMapper;
import org.codehaus.jackson.map.annotate.JsonSerialize;
import java.io.*;
import java.net.URISyntaxException;
import java.util.*;
import java.util.concurrent.atomic.AtomicInteger;
import java.util.zip.ZipEntry;
import java.util.zip.ZipFile;
@JsonIgnoreProperties(ignoreUnknown=true)
@JsonSerialize(include=JsonSerialize.Inclusion.NON_NULL)
public class GISJob extends Job {
static AtomicInteger jobCounter = new AtomicInteger(0);
int jobNum = jobCounter.getAndIncrement();
private String defaultJobName = "Unsaved GIS Job" + " (" + jobNum + ")";
// Anytime a @JsonProperty is added/removed/updated in this class add 1 to this value
private static final long fileVersionUID = 4L;
private UserPreferences userPrefs;
private String datasetID = "";
private String fileToPublish = "";
private PublishMethod publishMethod = PublishMethod.replace;
private boolean fileToPublishHasHeaderRow = true;
private ControlFile controlFile = null;
private Map<String, String> layerMap = new HashMap<>();
private String userAgent = "datasync";
private String userAgentNameClient = "Client";
private String userAgentNameCli = "CLI";
private String userAgentNameSijFile = ".gij File";
private ObjectMapper controlFileMapper =
new ObjectMapper().enable(DeserializationConfig.Feature.ACCEPT_SINGLE_VALUE_AS_ARRAY);
public GISJob() {
userPrefs = new UserPreferencesJava();
}
/*
* This is a method that enables DataSync preferences to be established
* directly when DataSync is used in "library mode" or "command-line mode"
* (rather than being loaded from Preferences class)
*/
public GISJob(UserPreferences userPrefs) {
this.userPrefs = userPrefs;
}
/**
* Loads GIS job data from a file and
* uses the saved data to populate the fields
* of this object
*/
public GISJob(String pathToFile) throws IOException, ControlDisagreementException {
userPrefs = new UserPreferencesJava();
// first try reading the 'current' format
ObjectMapper mapper = new ObjectMapper();
GISJob loadedJob;
loadedJob = mapper.readValue(new File(pathToFile), GISJob.class);
loadedJob.setPathToSavedFile(pathToFile);
setDatasetID(loadedJob.getDatasetID());
setFileToPublish(loadedJob.getFileToPublish());
setPublishMethod(loadedJob.getPublishMethod());
setPathToSavedFile(pathToFile);
setLayerMap(loadedJob.getLayerMap());
setUserAgentSijFile();
}
@JsonProperty("fileVersionUID")
public long getFileVersionUID() {
return fileVersionUID;
}
public ControlFile getControlFile() {
return controlFile;
}
public void setControlFile(ControlFile cf) {
controlFile = cf;
}
@JsonProperty("datasetID")
public void setDatasetID(String newDatasetID) {
datasetID = newDatasetID;
}
@JsonProperty("datasetID")
public String getDatasetID() {
return datasetID;
}
@JsonProperty("fileToPublish")
public void setFileToPublish(String newFileToPublish) {
fileToPublish = newFileToPublish;
}
@JsonProperty("fileToPublish")
public String getFileToPublish() {
return fileToPublish;
}
@JsonProperty("publishMethod")
public void setPublishMethod(PublishMethod newPublishMethod) {
publishMethod = newPublishMethod.replace;
}
@JsonProperty("publishMethod")
public PublishMethod getPublishMethod() {
return publishMethod;
}
@JsonProperty("layerMap")
public void setLayerMap(Map<String, String> layerMap) {
this.layerMap = layerMap;
}
@JsonProperty("layerMap")
public Map<String, String> getLayerMap() { return layerMap; }
public String getDefaultJobName() { return defaultJobName; }
public void setUserAgent(String usrAgentName) {
userAgent = Utils.getUserAgentString(usrAgentName);
}
public void setUserAgentClient() {
userAgent = Utils.getUserAgentString(userAgentNameClient);
}
public void setUserAgentSijFile() {
userAgent = Utils.getUserAgentString(userAgentNameSijFile);
}
/**
* Checks that the command line arguments are sensible
* NB: it is expected that this is run before 'configure'.
* @param cmd the commandLine object constructed from the user's options
* @return true if the commandLine is approved, false otherwise
*/
public boolean validateArgs(CommandLine cmd) {
return GISJobValidity.validateArgs(cmd);
}
/**
* Configures a GIS job prior to running it; in particular, the fields we need are
* set from the cmd line and the controlFile contents are deserialized
* NB: This should be run after 'validateArgs' and before 'run'
* @param cmd the commandLine object constructed from the user's options
*/
public void configure(CommandLine cmd) {
String method = cmd.getOptionValue(CommandLineOptions.PUBLISH_METHOD_FLAG);
setDatasetID(cmd.getOptionValue(CommandLineOptions.DATASET_ID_FLAG));
setFileToPublish(cmd.getOptionValue(CommandLineOptions.FILE_TO_PUBLISH_FLAG));
if (method != null) {
setPublishMethod(PublishMethod.valueOf(method));
}
String userAgentName = cmd.getOptionValue(CommandLineOptions.USER_AGENT_FLAG);
if (Utils.nullOrEmpty(userAgentName)) {
userAgentName = userAgentNameCli;
}
setUserAgent(userAgentName);
}
/**
* Runs an GIS job. It is expected that 'configure' was run beforehand.
* @return
* @throws IOException
*/
public JobStatus run() {
SocrataConnectionInfo connectionInfo = userPrefs.getConnectionInfo();
String publishExceptions = "";
JobStatus runStatus = JobStatus.SUCCESS;
JobStatus validationStatus = GISJobValidity.validateJobParams(connectionInfo, this);
JobStatus layerMappingStatus = GISJobValidity.validateLayerMapping(this);
if (validationStatus.isError()) {
runStatus = validationStatus;
} else if (layerMappingStatus.isError()) {
runStatus = layerMappingStatus;
} else {
try {
File fileToPublishFile = new File(fileToPublish);
if (publishMethod == PublishMethod.replace) {
runStatus = GISPublisher.replaceGeo(fileToPublishFile, connectionInfo, datasetID, layerMap, userPrefs);
} else {
runStatus = JobStatus.INVALID_PUBLISH_METHOD;
}
} catch (Exception e) {
publishExceptions = e.getMessage();
e.printStackTrace();
}
}
if (publishExceptions.length() > 0) {
runStatus = JobStatus.PUBLISH_ERROR;
runStatus.setMessage(publishExceptions);
}
emailAdmin(runStatus);
return runStatus;
}
/**
* This method attempts to map layers in the existing dataset to layers found in a shapefile,
* so that we replace existing layers where possible instead of creating new ones and changing
* the 4x4 / API endpoint of the dataset.
*/
public void initializeLayerMapping() throws URISyntaxException, IOException, HttpException {
String fileToPublish = getFileToPublish();
String fileExtension = FilenameUtils.getExtension(fileToPublish);
if (fileExtension.equals(GISJobValidity.ZIP_EXT)) {
GeoDataset dataset = DatasetUtils.getDatasetInfo(userPrefs, getDatasetID(), GeoDataset.class);
// Get map of existing layer UIDs/names by looking at child views
Map<String, String> existingLayers = getLayerListFromExistingDataset(userPrefs, dataset);
// Get list of layer names in file by looking at .shp file names inside the zip
List<String> fileLayers = getLayerListFromShapefile(fileToPublish);
if (existingLayers.size() == 1 && fileLayers.size() == 1) {
// If there's one layer in the existing dataset and one layer in the file,
// assume we want to replace the existing layer.
for (String key : existingLayers.keySet()) {
getLayerMap().put(fileLayers.get(0), existingLayers.get(key));
}
} else {
// Otherwise, make a best effort to match file layers to existing layers by name.
for (String fileLayer : fileLayers) {
if (existingLayers.containsKey(fileLayer)) {
getLayerMap().put(fileLayer, existingLayers.get(fileLayer));
}
}
}
} else {
setLayerMap(new HashMap<String, String>());
}
}
private static Map<String, String> getLayerListFromExistingDataset(UserPreferences userPrefs, GeoDataset dataset) {
List<String> existingLayersUids = dataset.getChildViews();
Map<String, String> existingLayerInfo = new HashMap<>();
for (String uid : existingLayersUids) {
try {
Dataset child = DatasetUtils.getDatasetInfo(userPrefs, uid, Dataset.class);
existingLayerInfo.put(child.getName(), uid);
} catch (Exception e) {
// there’s no way for the client to recover,
// so a checked exception is not necessary
throw new RuntimeException(e);
}
}
return existingLayerInfo;
}
private static List<String> getLayerListFromShapefile(String filePath) throws IOException {
try (ZipFile zipFile = new ZipFile(filePath)) {
List<String> layers = new ArrayList<>();
Enumeration entries = zipFile.entries();
while (entries.hasMoreElements()) {
ZipEntry entry = (ZipEntry) entries.nextElement();
if (FilenameUtils.getExtension(entry.getName()).equals("shp")) {
String layerName = FilenameUtils.getBaseName(entry.getName());
layers.add(layerName);
}
}
return layers;
}
}
private void sendErrorNotificationEmail(final String adminEmail,
final SocrataConnectionInfo connectionInfo,
final JobStatus runStatus,
final String runErrorMessage,
final String logDatasetID) {
String errorEmailMessage = "";
String urlToLogDataset = connectionInfo.getUrl() + "/d/" + logDatasetID;
if (runStatus.isError()) {
errorEmailMessage += "There was an error updating a dataset.\n"
+ "\nDataset: " + connectionInfo.getUrl() + "/d/" + getDatasetID()
+ "\nFile to publish: " + fileToPublish
+ "\nFile to publish has header row: " + fileToPublishHasHeaderRow
+ "\nPublish method: " + publishMethod
+ "\nJob File: " + pathToSavedJobFile
+ "\nError message: " + runErrorMessage
+ "\nLog dataset: " + urlToLogDataset + "\n\n";
try {
SMTPMailer.send(adminEmail, "Socrata DataSync Error", errorEmailMessage);
} catch (Exception e) {
System.out.println("Error sending email to: " + adminEmail + "\n" + e.getMessage());
}
}
}
private void emailAdmin(JobStatus status) {
String adminEmail = userPrefs.getAdminEmail();
String logDatasetID = userPrefs.getLogDatasetID();
SocrataConnectionInfo connectionInfo = userPrefs.getConnectionInfo();
if (userPrefs.emailUponError() && adminEmail != null && !adminEmail.equals("")) {
sendErrorNotificationEmail(
adminEmail, connectionInfo, status, status.getMessage(), logDatasetID);
}
}
public class ControlDisagreementException extends Exception {
public ControlDisagreementException(String msg) {
super(msg);
}
}
}