From a2dadfd97df75d171d0d9f235418282d4b91e0ce Mon Sep 17 00:00:00 2001 From: Michael Bar-Sinai Date: Thu, 31 Dec 2015 15:41:34 +0200 Subject: [PATCH 001/267] Initial test working, mock factory added --- .../engine/command/{ => impl}/MoveDataverseCommandTest.java | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename src/test/java/edu/harvard/iq/dataverse/engine/command/{ => impl}/MoveDataverseCommandTest.java (100%) diff --git a/src/test/java/edu/harvard/iq/dataverse/engine/command/MoveDataverseCommandTest.java b/src/test/java/edu/harvard/iq/dataverse/engine/command/impl/MoveDataverseCommandTest.java similarity index 100% rename from src/test/java/edu/harvard/iq/dataverse/engine/command/MoveDataverseCommandTest.java rename to src/test/java/edu/harvard/iq/dataverse/engine/command/impl/MoveDataverseCommandTest.java From 35f1f8c94909987cd0b628d60371d58c6751fe16 Mon Sep 17 00:00:00 2001 From: Michael Bar-Sinai Date: Thu, 31 Dec 2015 23:26:52 +0200 Subject: [PATCH 002/267] The CreateDatasetVersionCommand is now testible, and has unit tests (re: #2746) --- .gitignore | 1 + .../iq/dataverse/DatasetServiceBean.java | 6 + .../engine/command/CommandContext.java | 7 ++ .../engine/command/DataverseRequest.java | 7 +- .../impl/CreateDatasetVersionCommand.java | 14 ++- .../iq/dataverse/engine/MocksFactory.java | 108 ++++++++++++++++ .../impl/CreateDatasetVersionCommandTest.java | 115 ++++++++++++++++++ .../impl/MoveDataverseCommandTest.java | 2 +- 8 files changed, 253 insertions(+), 7 deletions(-) create mode 100644 src/test/java/edu/harvard/iq/dataverse/engine/MocksFactory.java create mode 100644 src/test/java/edu/harvard/iq/dataverse/engine/command/impl/CreateDatasetVersionCommandTest.java diff --git a/.gitignore b/.gitignore index 37db4a9d156..390a4a56ce0 100644 --- a/.gitignore +++ b/.gitignore @@ -22,3 +22,4 @@ scripts/api/py_api_wrapper/local-data/* doc/sphinx-guides/build faces-config.NavData src/main/java/BuildNumber.properties +/nbproject/ \ No newline at end of file diff --git a/src/main/java/edu/harvard/iq/dataverse/DatasetServiceBean.java b/src/main/java/edu/harvard/iq/dataverse/DatasetServiceBean.java index b966d250043..b66c126b2d2 100644 --- a/src/main/java/edu/harvard/iq/dataverse/DatasetServiceBean.java +++ b/src/main/java/edu/harvard/iq/dataverse/DatasetServiceBean.java @@ -205,6 +205,12 @@ public boolean isUniqueIdentifier(String userIdentifier, String protocol, String return u; } + public DatasetVersion storeVersion( DatasetVersion dsv ) { + em.persist(dsv); + return dsv; + } + + public String createCitationRIS(DatasetVersion version) { return createCitationRIS(version, null); } diff --git a/src/main/java/edu/harvard/iq/dataverse/engine/command/CommandContext.java b/src/main/java/edu/harvard/iq/dataverse/engine/command/CommandContext.java index 5ee56e78cb0..4ec31d6c723 100644 --- a/src/main/java/edu/harvard/iq/dataverse/engine/command/CommandContext.java +++ b/src/main/java/edu/harvard/iq/dataverse/engine/command/CommandContext.java @@ -37,6 +37,13 @@ */ public interface CommandContext { + /** + * Note: While this method is not deprecated *yet*, please consider not using it, + * and using a method on the service bean instead. Using the em directly makes + * the command less testable. + * + * @return the entity manager + */ public EntityManager em(); public DataverseEngine engine(); diff --git a/src/main/java/edu/harvard/iq/dataverse/engine/command/DataverseRequest.java b/src/main/java/edu/harvard/iq/dataverse/engine/command/DataverseRequest.java index d0c08cd17ce..a0e34b43190 100644 --- a/src/main/java/edu/harvard/iq/dataverse/engine/command/DataverseRequest.java +++ b/src/main/java/edu/harvard/iq/dataverse/engine/command/DataverseRequest.java @@ -15,7 +15,7 @@ public class DataverseRequest { private final User user; private final IpAddress sourceAddress; - + public DataverseRequest(User aUser, HttpServletRequest aHttpServletRequest) { this.user = aUser; String remoteAddressStr = null; @@ -37,6 +37,11 @@ public DataverseRequest(User aUser, HttpServletRequest aHttpServletRequest) { sourceAddress = IpAddress.valueOf( remoteAddressStr ); } + public DataverseRequest( User aUser, IpAddress aSourceAddress ) { + user = aUser; + sourceAddress = aSourceAddress; + } + public User getUser() { return user; } diff --git a/src/main/java/edu/harvard/iq/dataverse/engine/command/impl/CreateDatasetVersionCommand.java b/src/main/java/edu/harvard/iq/dataverse/engine/command/impl/CreateDatasetVersionCommand.java index 8742a35679c..060da0565ca 100644 --- a/src/main/java/edu/harvard/iq/dataverse/engine/command/impl/CreateDatasetVersionCommand.java +++ b/src/main/java/edu/harvard/iq/dataverse/engine/command/impl/CreateDatasetVersionCommand.java @@ -51,6 +51,7 @@ public DatasetVersion execute(CommandContext ctxt) throws CommandException { throw new IllegalCommandException("Latest version is already a draft. Cannot add another draft", this); } } + newVersion.setDataset(dataset); newVersion.setDatasetFields(newVersion.initDatasetFields()); Set constraintViolations = newVersion.validate(); @@ -78,7 +79,6 @@ public DatasetVersion execute(CommandContext ctxt) throws CommandException { FileMetadata fmdCopy = fmd.createCopy(); fmdCopy.setDatasetVersion(newVersion); newVersionMetadatum.add( fmdCopy ); - logger.info( "added file metadata " + fmdCopy ); } newVersion.setFileMetadatas(newVersionMetadatum); @@ -88,12 +88,16 @@ public DatasetVersion execute(CommandContext ctxt) throws CommandException { newVersion.setLastUpdateTime(now); dataset.setModificationTime(now); newVersion.setDataset(dataset); - ctxt.em().persist(newVersion); - + final List currentVersions = dataset.getVersions(); + ArrayList dsvs = new ArrayList<>(currentVersions.size()); + dsvs.addAll(currentVersions); + dsvs.set(0, newVersion); + dataset.setVersions( dsvs ); + // TODO make async - // ctxt.index().indexDataset(dataset); + // ctxt.index().indexDataset(dataset); + return ctxt.datasets().storeVersion(newVersion); - return newVersion; } } diff --git a/src/test/java/edu/harvard/iq/dataverse/engine/MocksFactory.java b/src/test/java/edu/harvard/iq/dataverse/engine/MocksFactory.java new file mode 100644 index 00000000000..21c8cf5abc2 --- /dev/null +++ b/src/test/java/edu/harvard/iq/dataverse/engine/MocksFactory.java @@ -0,0 +1,108 @@ +package edu.harvard.iq.dataverse.engine; + +import edu.harvard.iq.dataverse.DataFile; +import edu.harvard.iq.dataverse.Dataset; +import edu.harvard.iq.dataverse.DatasetFieldType; +import edu.harvard.iq.dataverse.Dataverse; +import edu.harvard.iq.dataverse.MetadataBlock; +import edu.harvard.iq.dataverse.authorization.groups.impl.ipaddress.ip.IpAddress; +import edu.harvard.iq.dataverse.authorization.users.AuthenticatedUser; +import edu.harvard.iq.dataverse.engine.command.DataverseRequest; +import java.sql.Timestamp; +import java.time.LocalDate; +import java.time.Month; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Date; +import java.util.List; +import java.util.concurrent.atomic.AtomicInteger; + +/** + * A utility class for creating mock objects for unit tests. Mostly, the non-parameter + * methods created objects with reasonable defaults that should fit most tests. + * Of course, feel free to change of make these mocks more elaborate as the code + * evolves. + * + * @author michael + */ +public class MocksFactory { + + private static final AtomicInteger NEXT_ID = new AtomicInteger(); + + public static Long nextId() { + return Long.valueOf( NEXT_ID.incrementAndGet() ); + } + + public static Date date(int year, int month, int day ) { + return new Date( LocalDate.of(year, Month.of(month), day).toEpochDay() ); + } + + public static Timestamp timestamp(int year, int month, int day ) { + return new Timestamp( date(year, month, day).getTime() ); + } + + public static DataFile makeDataFile() { + DataFile retVal = new DataFile(); + retVal.setId( nextId() ); + retVal.setContentType("application/unitTests"); + retVal.setCreateDate( new Timestamp(System.currentTimeMillis()) ); + retVal.setModificationTime( retVal.getCreateDate() ); + return retVal; + } + + public static List makeFiles( int count ) { + List retVal = new ArrayList<>(count); + for ( int i=0; i Date: Sat, 2 Jan 2016 23:27:18 +0200 Subject: [PATCH 003/267] TestEngine stores the requested permissions, so dynamic logic for permission requests can also be tested. More aspectes of CreateDatasetVersionCommand validated. --- .../iq/dataverse/DataFileCategory.java | 1 - .../edu/harvard/iq/dataverse/Dataset.java | 11 ++-- .../groups/impl/ipaddress/ip/IPv4Range.java | 2 +- .../command/impl/MoveDataverseCommand.java | 13 +++-- .../iq/dataverse/engine/MocksFactory.java | 56 ++++++++++++++++++- .../dataverse/engine/TestDataverseEngine.java | 19 ++++++- .../impl/CreateDatasetVersionCommandTest.java | 9 +++ 7 files changed, 97 insertions(+), 14 deletions(-) diff --git a/src/main/java/edu/harvard/iq/dataverse/DataFileCategory.java b/src/main/java/edu/harvard/iq/dataverse/DataFileCategory.java index dbc4a3c4788..67b1fd1dd1c 100644 --- a/src/main/java/edu/harvard/iq/dataverse/DataFileCategory.java +++ b/src/main/java/edu/harvard/iq/dataverse/DataFileCategory.java @@ -9,7 +9,6 @@ import java.io.Serializable; import java.util.ArrayList; import java.util.Collection; -import javax.persistence.CascadeType; import javax.persistence.Column; import javax.persistence.Entity; import javax.persistence.GeneratedValue; diff --git a/src/main/java/edu/harvard/iq/dataverse/Dataset.java b/src/main/java/edu/harvard/iq/dataverse/Dataset.java index 26409015032..7a97b1a1617 100644 --- a/src/main/java/edu/harvard/iq/dataverse/Dataset.java +++ b/src/main/java/edu/harvard/iq/dataverse/Dataset.java @@ -5,6 +5,7 @@ import java.text.SimpleDateFormat; import java.util.ArrayList; import java.util.Collection; +import java.util.Collections; import java.util.Date; import java.util.List; import java.util.Objects; @@ -400,13 +401,15 @@ public DataFileCategory getCategoryByName(String categoryName) { } private Collection getCategoryNames() { - ArrayList ret = new ArrayList<>(); if (dataFileCategories != null) { - for (int i = 0; i < dataFileCategories.size(); i++) { - ret.add(dataFileCategories.get(i).getName()); + ArrayList ret = new ArrayList<>(dataFileCategories.size()); + for ( DataFileCategory dfc : dataFileCategories ) { + ret.add( dfc.getName() ); } + return ret; + } else { + return new ArrayList<>(); } - return ret; } public Path getFileSystemDirectory() { diff --git a/src/main/java/edu/harvard/iq/dataverse/authorization/groups/impl/ipaddress/ip/IPv4Range.java b/src/main/java/edu/harvard/iq/dataverse/authorization/groups/impl/ipaddress/ip/IPv4Range.java index 5ca62f45bf0..3b6348737ed 100644 --- a/src/main/java/edu/harvard/iq/dataverse/authorization/groups/impl/ipaddress/ip/IPv4Range.java +++ b/src/main/java/edu/harvard/iq/dataverse/authorization/groups/impl/ipaddress/ip/IPv4Range.java @@ -30,7 +30,7 @@ public class IPv4Range extends IpAddressRange implements java.io.Serializable { @GeneratedValue Long id; - /** The most significant bits of {@code this} range's top addre, i.e the first two numbers of the IP address */ + /** The most significant bits of {@code this} range's top address, i.e the first two numbers of the IP address */ long topAsLong; /** The least significant bits, i.e the last tow numbers of the IP address */ diff --git a/src/main/java/edu/harvard/iq/dataverse/engine/command/impl/MoveDataverseCommand.java b/src/main/java/edu/harvard/iq/dataverse/engine/command/impl/MoveDataverseCommand.java index b7644a96432..16cd6d0be52 100644 --- a/src/main/java/edu/harvard/iq/dataverse/engine/command/impl/MoveDataverseCommand.java +++ b/src/main/java/edu/harvard/iq/dataverse/engine/command/impl/MoveDataverseCommand.java @@ -1,9 +1,12 @@ package edu.harvard.iq.dataverse.engine.command.impl; import edu.harvard.iq.dataverse.Dataverse; +import edu.harvard.iq.dataverse.authorization.Permission; import edu.harvard.iq.dataverse.engine.command.AbstractVoidCommand; import edu.harvard.iq.dataverse.engine.command.CommandContext; import edu.harvard.iq.dataverse.engine.command.DataverseRequest; +import edu.harvard.iq.dataverse.engine.command.RequiredPermissions; +import edu.harvard.iq.dataverse.engine.command.RequiredPermissionsMap; import edu.harvard.iq.dataverse.engine.command.exception.CommandException; import edu.harvard.iq.dataverse.engine.command.exception.IllegalCommandException; @@ -15,12 +18,12 @@ //@todo We will need to revist the permissions for move, once we add this //(will probably need different move commands for unplublished which checks add, //versus published which checks publish -/* + @RequiredPermissionsMap({ - @RequiredPermissions( dataverseName = "moved", value = {Permission.UndoableEdit, Permission.AssignRole} ), - @RequiredPermissions( dataverseName = "source", value = Permission.UndoableEdit ), - @RequiredPermissions( dataverseName = "destination", value = Permission.DestructiveEdit ) -})*/ + @RequiredPermissions( dataverseName = "moved", value = {Permission.ManageDataversePermissions, Permission.EditDataverse} ), + @RequiredPermissions( dataverseName = "source", value = Permission.DeleteDataverse ), + @RequiredPermissions( dataverseName = "destination", value = Permission.AddDataverse ) +}) public class MoveDataverseCommand extends AbstractVoidCommand { final Dataverse moved; diff --git a/src/test/java/edu/harvard/iq/dataverse/engine/MocksFactory.java b/src/test/java/edu/harvard/iq/dataverse/engine/MocksFactory.java index 21c8cf5abc2..32f7527c201 100644 --- a/src/test/java/edu/harvard/iq/dataverse/engine/MocksFactory.java +++ b/src/test/java/edu/harvard/iq/dataverse/engine/MocksFactory.java @@ -1,9 +1,14 @@ package edu.harvard.iq.dataverse.engine; import edu.harvard.iq.dataverse.DataFile; +import edu.harvard.iq.dataverse.DataFileCategory; import edu.harvard.iq.dataverse.Dataset; +import edu.harvard.iq.dataverse.DatasetField; import edu.harvard.iq.dataverse.DatasetFieldType; +import edu.harvard.iq.dataverse.DatasetFieldType.FieldType; +import edu.harvard.iq.dataverse.DatasetVersion; import edu.harvard.iq.dataverse.Dataverse; +import edu.harvard.iq.dataverse.FileMetadata; import edu.harvard.iq.dataverse.MetadataBlock; import edu.harvard.iq.dataverse.authorization.groups.impl.ipaddress.ip.IpAddress; import edu.harvard.iq.dataverse.authorization.users.AuthenticatedUser; @@ -14,7 +19,9 @@ import java.util.ArrayList; import java.util.Arrays; import java.util.Date; +import java.util.LinkedList; import java.util.List; +import java.util.Random; import java.util.concurrent.atomic.AtomicInteger; /** @@ -46,6 +53,7 @@ public static DataFile makeDataFile() { retVal.setId( nextId() ); retVal.setContentType("application/unitTests"); retVal.setCreateDate( new Timestamp(System.currentTimeMillis()) ); + addFileMetadata( retVal ); retVal.setModificationTime( retVal.getCreateDate() ); return retVal; } @@ -58,6 +66,22 @@ public static List makeFiles( int count ) { return retVal; } + public static FileMetadata addFileMetadata( DataFile df ) { + FileMetadata fmd = new FileMetadata(); + + fmd.setId( nextId() ); + fmd.setLabel( "Metadata for DataFile " + df.getId() ); + + fmd.setDataFile(df); + if ( df.getFileMetadatas() != null ) { + df.getFileMetadatas().add( fmd ); + } else { + df.setFileMetadatas( new LinkedList(Arrays.asList(fmd)) ); + } + + return fmd; + } + public static AuthenticatedUser makeAuthentiucatedUser( String firstName, String lastName ) { AuthenticatedUser user = new AuthenticatedUser(); user.setId( nextId() ); @@ -100,9 +124,37 @@ public static Dataverse makeDataverse() { public static Dataset makeDataset() { Dataset ds = new Dataset(); - ds.setIdentifier("sample-ds"); - ds.setFiles( makeFiles(10) ); + ds.setId( nextId() ); + ds.setIdentifier("sample-ds-" + ds.getId() ); + ds.setCategoriesByName( Arrays.asList("CatOne", "CatTwo", "CatThree") ); + final List files = makeFiles(10); + final List metadatas = new ArrayList<>(10); + final List categories = ds.getCategories(); + Random rand = new Random(); + for ( DataFile df : files ) { + df.getFileMetadata().addCategory(categories.get(rand.nextInt(categories.size()))); + metadatas.add( df.getFileMetadata() ); + } + ds.setFiles(files); + final DatasetVersion initialVersion = ds.getVersions().get(0); + initialVersion.setFileMetadatas(metadatas); + + List fields = new ArrayList<>(); + DatasetField field = new DatasetField(); + field.setId(nextId()); + field.setSingleValue("Sample Field Value"); + field.setDatasetFieldType( makeDatasetFieldType() ); + fields.add( field ); + initialVersion.setDatasetFields(fields); ds.setOwner( makeDataverse() ); + return ds; } + + public static DatasetFieldType makeDatasetFieldType() { + DatasetFieldType retVal = new DatasetFieldType("SampleType", FieldType.TEXT, false); + retVal.setId( nextId() ); + return retVal; + } + } diff --git a/src/test/java/edu/harvard/iq/dataverse/engine/TestDataverseEngine.java b/src/test/java/edu/harvard/iq/dataverse/engine/TestDataverseEngine.java index 29c677a3714..17aebc179c2 100644 --- a/src/test/java/edu/harvard/iq/dataverse/engine/TestDataverseEngine.java +++ b/src/test/java/edu/harvard/iq/dataverse/engine/TestDataverseEngine.java @@ -1,7 +1,12 @@ package edu.harvard.iq.dataverse.engine; +import edu.harvard.iq.dataverse.DvObject; +import edu.harvard.iq.dataverse.authorization.Permission; import edu.harvard.iq.dataverse.engine.command.Command; import edu.harvard.iq.dataverse.engine.command.exception.CommandException; +import java.util.HashMap; +import java.util.Map; +import java.util.Set; /** * Test implementation of the dataverse engine service. Does not check permissions. @@ -10,14 +15,26 @@ public class TestDataverseEngine implements DataverseEngine { private final TestCommandContext ctxt; - + + private Map> reqiredPermissionsForObjects = new HashMap<>(); + public TestDataverseEngine(TestCommandContext ctxt) { this.ctxt = ctxt; } @Override public R submit(Command aCommand) throws CommandException { + Map affectedDvs = aCommand.getAffectedDvObjects(); + final Map> requiredPermissions = aCommand.getRequiredPermissions(); + aCommand.getRequest(); + for ( String dvObjKey : affectedDvs.keySet() ) { + reqiredPermissionsForObjects.put( affectedDvs.get(dvObjKey), requiredPermissions.get(dvObjKey) ); + } return aCommand.execute(ctxt); } + + public Map> getReqiredPermissionsForObjects() { + return reqiredPermissionsForObjects; + } } diff --git a/src/test/java/edu/harvard/iq/dataverse/engine/command/impl/CreateDatasetVersionCommandTest.java b/src/test/java/edu/harvard/iq/dataverse/engine/command/impl/CreateDatasetVersionCommandTest.java index 365ea4b175d..84811e006b7 100644 --- a/src/test/java/edu/harvard/iq/dataverse/engine/command/impl/CreateDatasetVersionCommandTest.java +++ b/src/test/java/edu/harvard/iq/dataverse/engine/command/impl/CreateDatasetVersionCommandTest.java @@ -6,13 +6,19 @@ import edu.harvard.iq.dataverse.Dataset; import edu.harvard.iq.dataverse.DatasetServiceBean; import edu.harvard.iq.dataverse.DatasetVersion; +import edu.harvard.iq.dataverse.DvObject; +import edu.harvard.iq.dataverse.authorization.Permission; import edu.harvard.iq.dataverse.engine.MocksFactory; import static edu.harvard.iq.dataverse.engine.MocksFactory.*; import edu.harvard.iq.dataverse.engine.TestCommandContext; import edu.harvard.iq.dataverse.engine.TestDataverseEngine; import edu.harvard.iq.dataverse.engine.command.exception.IllegalCommandException; import java.text.SimpleDateFormat; +import java.util.Collections; import java.util.Date; +import java.util.HashMap; +import java.util.Map; +import java.util.Set; import org.junit.After; import org.junit.AfterClass; import static org.junit.Assert.assertEquals; @@ -83,6 +89,9 @@ public void testSimpleVersionAddition() throws Exception { assertEquals( dsvCreationDate.getTime(), ds.getModificationTime().getTime() ); assertEquals( ds, dsvNew.getDataset() ); assertEquals( dsvNew, ds.getEditVersion() ); + Map> expected = new HashMap<>(); + expected.put(ds, Collections.singleton(Permission.AddDataset)); + assertEquals(expected, testEngine.getReqiredPermissionsForObjects() ); } @Test(expected=IllegalCommandException.class) From 9f91b93e7ce9dda05dd7e1ab84f491a332f789e4 Mon Sep 17 00:00:00 2001 From: Michael Bar-Sinai Date: Mon, 4 Jan 2016 13:23:37 +0200 Subject: [PATCH 004/267] Added a the testable command guide --- JAVADOC_GUIDE.md => doc/JAVADOC_GUIDE.md | 0 .../TheTestableCommand-outline.md | 47 ++++++++ doc/theTestibleCommand/TheTestableCommand.md | 106 ++++++++++++++++++ .../non-testable-container.png | Bin 0 -> 65072 bytes doc/theTestibleCommand/non-testable-ut.png | Bin 0 -> 54668 bytes doc/theTestibleCommand/testable-container.png | Bin 0 -> 63068 bytes doc/theTestibleCommand/testable-ut.png | Bin 0 -> 47103 bytes .../dataverse/engine/TestDataverseEngine.java | 2 +- .../impl/CreateDatasetVersionCommandTest.java | 3 - 9 files changed, 154 insertions(+), 4 deletions(-) rename JAVADOC_GUIDE.md => doc/JAVADOC_GUIDE.md (100%) create mode 100644 doc/theTestibleCommand/TheTestableCommand-outline.md create mode 100644 doc/theTestibleCommand/TheTestableCommand.md create mode 100644 doc/theTestibleCommand/non-testable-container.png create mode 100644 doc/theTestibleCommand/non-testable-ut.png create mode 100644 doc/theTestibleCommand/testable-container.png create mode 100644 doc/theTestibleCommand/testable-ut.png diff --git a/JAVADOC_GUIDE.md b/doc/JAVADOC_GUIDE.md similarity index 100% rename from JAVADOC_GUIDE.md rename to doc/JAVADOC_GUIDE.md diff --git a/doc/theTestibleCommand/TheTestableCommand-outline.md b/doc/theTestibleCommand/TheTestableCommand-outline.md new file mode 100644 index 00000000000..84e3d03b9fc --- /dev/null +++ b/doc/theTestibleCommand/TheTestableCommand-outline.md @@ -0,0 +1,47 @@ +# The Testable Command + +_outline_ +* Intro + *√ Application Complexity + *√ Definitions of unit tests + *√ Positive def. + *√ Quick runs + *√ validate small portions of limited complexity + *√ Use the code in another context (aids portability, reuse, and, thus, overall quality) + *√ To some extent, can be read as a use guide and a specification + *√ No dependency on other processes + *√ No dependency on external files + *√ No dependency on hard-coded data that needs to be manually changed + * running under JUnit not enough + * Make sure to test the right thing, and to test the thing right (e.g. no `toString` equality, unless you're testing some logic that generates Strings). + *√ Why no embedded test servers + *√ Too many moving parts + *√ Part code, part spec, part magic (e.g. putting stuff in `private` fields!) + * Mention the Weld bug + +* Commands in Dataverse + *√ Command pattern + *√ Refer to the "Lean Beans are Made of This" presentation + * Making a command testable - what to do in the service bean and what should be done in a command + * √Command should not deal directly with anything that's not a service bean + or a domain model object - including the entity manager, API calls to Solr, file system calls, + HTTPRequest, JSFContext, etc. + * √ This roughly amounts to - Storage and retrieval "primitives" (of models) go in the bean, actions go on the commands. + * True, I've added the `em()` method to the `CommandContext` class. That was + while exploring the idea of removing the beans altogether. It works, but + its not testable. So it will be deprecated at some point. + *√ Any context object (JSFContext, HTTPRequest) should not be used by the command. Extract exactly what the command needs, and pass it as a parameter to the command's constructor. + * x e.g. `DataverseRequest` had a constructor that got a `HTTPRequest` as a parameter. Internally, that constructor extracted the source IP address and stored it in a field. To allow testing, a new constructor, one that gets only the IPAddress, was added. + +* Testing the command + * Setting up the domain context in on which the command acts + * Dataverses, Datasets.... + * Use `MocksFactory` (lives in the test folder, not in src) to create sensible default objects. + * Hand-craft the instances needed for the test, to make sure the test case really tests what it needs to test. + * Create a `TestCommandContext` subclass, and override the methods providing the required service beans. The service beans might need to be subclassed as well, typically replacing database calls with actions on in-memory data structures. + * Create a `TestDataverseEngine` instance, and pass it an instance of the `TestCommandContext` subclass. + * Submit the command + * `Assert` ad nauseum. + * Command results + * Calls within the beans (e.g. validating that a method did get called, or not called more than once) + * Permissions required by the command diff --git a/doc/theTestibleCommand/TheTestableCommand.md b/doc/theTestibleCommand/TheTestableCommand.md new file mode 100644 index 00000000000..4acbe1759fc --- /dev/null +++ b/doc/theTestibleCommand/TheTestableCommand.md @@ -0,0 +1,106 @@ +# The Testable Command + +_2016-01-03_ + +_Michael Bar-Sinai_ + +Dataverse is a rather complex system, implementing a rather complex set of requirements. There are many moving parts within the application itself, and many moving parts in it's infrastructure (Glassfish, Solr, etc.). Thus, it's hard to detect erroneous behaviors, let alone find a way to reproduce them and spot the point in the code where the failure happens. Moreover, testing a web application requires setup and scripting of its UI or its API - which makes the tests hard to write, laborious to set up, and to make matters worse, brittle. That's not saying that these tests are not important; they are. But it is unrealistic for developers to create, maintain and run these tests very often. + +On the other hand, developers can create, maintain and frequently run unit tests. + +## Unit Tests + +The term "unit test" has been reused, confused and abused since it became popular, so let's start with a definition of what it means in the scope of this document. A unit test is a short piece of code that tests a small and distinct part of a system - the "unit". Executing a unit test does not take long (typically less than a second) and requires no configuration. This implies that during a unit test all activities are limited to the application memory - no reading files, no going to the network, and no querying another process in the same machine. + +While they can't replace end-to-end tests, unit tests are a great way to validate small portions of a system and protect against regressions. But having unit tests improves application code in more ways. First off, to have unit tests one needs to have units. That is, the code has to be designed in modules with clear boundaries that can be reused in at least two contexts - run and test. This aids code comprehension and reuse. Unit tests also serve as an example of how the tested units are used by client code, and provide some examples of inputs and outputs. Sort of a poor man's specification document, if you will. Additionally, when writing tests the developer uses a reverse mindset, trying to break her code rather than make it work. This process makes the code much more resilient. + +Because unit tests are easy to create (Java only, no configuration needed) and quick to run, it is possible to write many of them, such that many aspects of the code are tested. Normally, a single unit test would test a single use case of the unit. This way, when a unit test fails, the failure describes exactly what part stopped functioning. Other unit tests are not blocked by the failure, and so by running the entire test suite, the developer can get a good overview of which parts are broken and which parts are functioning well. + +Unit Testing of application logic in Java EE applications is normally hard to do, as the application logic lives in the service beans, which rely on dependency injections. Writing unit tests for service beans is possible, but as it involves a test container, and a persistent context (read: in-memory database) these unit tests are not very unit-y. + +Luckily for Dataverse, most of the application logic lives in sub-classes of `Command`. As these classes are plain old Java classes that get their service beans through another plain old Java class, `CommandContext`, unit testing them is pretty straightforward. That is, if we write them to be testable. + +## Writing Testable Commands + +Ideally, commands should only handle domain model objects, such as `DvObject`s. In particular, they should not talk directly to any of the persistence systems (JPA etc.) or rely on objects from the presentation layer, such as `HTTPRequest` or `FacesContext`. + +Dataverse has both service beans and commands. When deciding whether an action on the models should go in the bean or in a command, remember that beans are not unit-testible and commands can't talk to JPA. This normally boils down to keeping straightforward storage and retrieval logic in the beans, and possibly complex application logic in the command. We call it the "lean bean" pattern (more about this pattern [in this Java One presentation](http://iqss.github.io/javaone2014-bof5619/)). An application with testable, well-behaved commands will look like the image in figure 1: + +
+
+ Fig. 1: Dataverse application layers inside a Java EE container, when commands are testable. +
+ + +When testing, the production environment commands live in can be easily replaced by mock objects, as shown in figure 2. The presentation and storage layers are not present. The mock objects implement the same interface as the runtime objects, and thus the command runs during testing exactly as it does in production ([enter your VW joke here](http://www.slate.com/articles/technology/future_tense/2015/09/volkswagen_s_cheating_emissions_software_and_the_threat_of_black_boxes.html)). + +
+
+ Fig. 2: Dataverse application layers inside during unit testing, when commands are testable. +
+ +When a command directly accesses a storage-level module (as figure 3), mocking its environment becomes much harder. While technically possible, creating a mock file system, database, or a remote server is a lot of work and defects the object of creating lots of small tests. + +
+ +
+ Fig. 3: A Command that directly accesses storage-level modules is much harder to unit-test. +
+ +> In the early days of the commands in Dataverse, I've added the `em()` method in the `CommandContext` interface, which allows commands to directly access a JPA entity manager. The idea was to try and remove all service beans, and replace them with commands. That worked, but it made the commands too detailed, and non-testable. So in hind sight, not the best move. +> If all goes well, `em()` will be removed after we migrate all commands that use it to use service bean methods instead. + +## Testing Commands + +Writing unit test for a (testable) command is not very different from writing unit tests for other classes. There are some utility code that can be reused for mocking the environment, and a `TestDataverseEngine` class that executes the command in a test context. + +A unit test for a command would might follow the below pattern: + +1. Set up the domain objects on which the command will work. + * Use `MocksFactory` (lives in the test folder, not in src) to create sensible default objects for the business logic context (e.g. metadata blocks and dataverses when testing a `DatasetVersion` object). + * Hand-craft the instances needed for the test, to make sure the test case really tests what it needs to test +1. Create a `TestCommandContext` subclass, and override the methods providing the required service beans. The service beans might need to be subclassed as well, typically replacing database calls with actions on in-memory data structures. + * Often, the context can be reused across tests. + ````Java + class MockDatasetServiceBean extends DatasetServiceBean { + @Override + public DatasetVersion storeVersion(DatasetVersion dsv) { + dsv.setId( nextId() ); + return dsv; + } + } + final DatasetServiceBeans serviceBean = new MockDatasetServiceBean(); + CommandContext ctxt = new TestCommandContext(){ + @Override public DatasetServiceBean datasets() { return serviceBean; } + }; + ```` +1. Create a new `TestDataverseEngine` instance, with the context as a parameter. + ````Java + TestDataverseEngine testEngine = new TestDataverseEngine( ctxt ); + ```` +1. Submit the command to the engine + ````Java + Dataverse result = testEngine.submit(sut); + ```` +1. `Assert` all that needs assertion. + * It is also possible to assert the permissions required by the command using `TestDataverseEngine#getReqiredPermissionsForObjects` + +## Tips for Unit Testing + +Numerous blogs, books and tweets have been written about creating good unit tests. Here are some non-exhaustive tips, that might be more relevant to the context of Dataverse and its commands. + +* Commands that might throw an `IllegalCommandException` should get a unit test validating that they indeed throw it. Use the `expected` parameter of the `@Test` annotation, like so: + ````Java + @Test(expected=IllegalCommandException.class) + public void testSomethingThatShouldNotBeDoneCantBeDone() throws Exception ... + ```` +* The old adage about "testing the right thing, and testing the thing right" holds, and it is good to keep it in mind when asserting equality of complex objects. One common pitfall when testing such objects is to use naïve `toString` on the actual and expected objects, and then test the string for equality. Sadly, this would create many false negatives. The following two XML snippets are semantically equal, but a string-level test would fail: + + ````XML + + + ```` + +Happy Testing! + +-- Michael diff --git a/doc/theTestibleCommand/non-testable-container.png b/doc/theTestibleCommand/non-testable-container.png new file mode 100644 index 0000000000000000000000000000000000000000..0e9f16ba2089d0ec4e0ca608f0660d423c3f53e9 GIT binary patch literal 65072 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 acd`SS-JH-!0}MSuz-4jL+O*P`9QhxPxnbr2 literal 0 HcmV?d00001 diff --git a/doc/theTestibleCommand/testable-container.png b/doc/theTestibleCommand/testable-container.png new file mode 100644 index 0000000000000000000000000000000000000000..e00569ba88edc9ea18dc0178dd9799c70605829a GIT binary patch literal 63068 zcmZ_01ytM55-1G8QZ!J!Sa7FEAyC}i-CYXBTU>&>I~2Df#oZ}Rix+9JVuj+t{iXNb zzr6RIFXwQ6$!2DEW_D(FWJ#o|vJ4hFDLMiI0+yVtq&flu02=`TaS@0LAEDzhO@luW z-PC2o5tl|;1K=N?Im_y~As}E6{P{ynWx;*{2l!^Esq3z*q$ptF^p@Ss(#hP4-Se$8 z92x;Z&{F{Z_N|q>8I|W-2S+ynPa*36KnTF!{~6|>ruq+vyS)&#u97O1gp;ck)hl)$ z_LtPc=u}ixg07a<0_u{||3im=5~8+scXt-x;PCM9VE5o=cXGAi;N<7$=XlA*!NtV} zhhTH_a&$NIWOH<*`R^ou%_C{$X5nh*>~81eNcCr4GjpeR?n2bme-`@t^}uua2A^N}mEeCM|F5xs*7+~Is;!f| z6I=?eb{2Au?pCgFWOuVa)CqI_5BvWg;{TmX#nsLV&h>w=Isb?K@3H^E3v&En{9l6j zuay5Y3Kz35x**5jTPBQNyUDeJfFOz>Cn=`siFn|L7N|9N@z~#?1awBWm31xZP&6*DqRiA9~S>Jc_CJl*Vcd-<$c zjQeyPcfGY)KYHu-YvN%wlY1?PpZj?z48}B-4`c*H1ycX#A&LN$ENJW$4D=+G!c|4a zN0bu%&qI<0^)HY=kjPPic;TXnGoJq%Bt%R73mT3DBtvY1AjhQ+Y5p}ymJmRmEUOF1 zi0~JZ=sJ+m1xUsc`|&RdczlpkAUr;}stA9r4DH2L-2g$fNj3f=$MzDAWqV1TkMK9y zP@4P!uyYkp>3^9Bq@Fs!Ef3gG)kd|ndUZ!Bj`%kxq)31|+{vw-uf54C2rSwN`wK`f zctr`n12#6H!hV6PT~OR%1RWEs;u0xApIh<&aSwl#lR`)I$j~vMEdJOzkX7(A9b=Tv zd-Pg+z6290WOJI6R!|fM1JY{`1c`8jmWPkObN0#q!H$3c5)H_?IdD9Wg-oNWHpq!| z&cb)L5RN;M4E?>ny1WYei}HM+Br#*t|5(YF=*+u3>RqrSJztZeduMHIgqD=zLW zp8S9(8nL!(>h+Rz=wh$e*Tl$Of`1T!uK>`6FXSb!%{CzX{%Z5XXOZu1DAWG$n zfk6T; zgh1tno`A9AQsHw^EviD9f21Ks<@`%_0(QOZ*8BY(0y*8v&${8j*N$kb1md{1`80HL zfDYm1pQ$=z7?MWeKJBw}7XR9lX8`DPv|H+wR7A40J$meOzePp`v%BYkS@l=F!M(_O z0@-P3sLGEi9}eBMI$;%VN>9xHIJ97KDhOrx(<9OKMNLjYMotwI1KPSnl2JPXW%QcF zWNx1y*$1j$6Yp}^`4RDF`8P3b2uIh|XBPjWJ&+oNh#W+N{RQ-aNN;Ht2dW*hx5xS3Rx&C0%X={bz|#5F8={51j=-dlG3|y%5_ER};|r62@98e6JQEU=ctdb| zfpqjFlHaAcsHp{X2b5RjF6{ra=+rtc9L{;kUjGFIU_M(~A;P%$EWbiNveL;_IGjSL zBC244;BSAMp9?Bq;w*5pv5_8@;9Bc{$3?(HPW8|dj0P>sMgPk1<$gp@G5SoZ^Ul$B zW=``TEuo?XBJ+~D4Pg$GBs~|Nre4QJLFu)x5ul-7Uaw4X?X&e5($n~BI8(}u`+kXEads}Q(D@`K|2w^k$fJ5v>b`lxmZA+IH zu2-)-SoDNi8UVT;t~SAOhO!FiXcGn*T@}{zoM-*x`$O=dygx(#U zb?MA5#F%jeiwm(=R(M!eonWowIk%m zGl=ueb~njl=h7aa?a`ZxY4j>Lm+2p??4kw{yR0Y3O^~$(i1ebbkso|X3}_wnYuedN zWBXuN_0SFWu3LxSlQEXN#n`w zA|n`CZ@K<)9_5(O-bkdp(pQ1|3J%HO%o6`Iju?ov_GIU$dJj#%*tF0#H^e01C!NG3 z%p5nzZ4FhO4^gXX|2|GZt!}gofmw_+6vp@)&ezT>4IZ;jRumB`fyv6QRTyr<$xP)n zF841U@7CJOYMB1zJV$VWUxuMI+wN02$Zz&}so1KINc8HFGni*JlM@Wz(e*wn;Ej|D zmf&G90UEkUt*jON8(S6^_^C2zUkx+)XA;qiF; zltxB$OprdjMuMNGof_KX%Ph#^GFqD9`Ky0hDs`Ob`T&?^T(8o+M{_qdbW|g7VXgc? z`P%|S-vVy)oo9hDm<+2&E4>OFqu+`6bRKg4Edg)1^WNxvN2gu$URp$xSCdPR?8Vz{ zlW%3d*9E$Gm)h?Z>U1&5faiM}@5s7}{-qLO@MZg~6N{MOhh~^syUyq08gb2(SgjlX z3SBx|t8(V8Ed-xSgn*QH97k!6nEzJFd=M}WvivTcwdA2^A`AcAZn16rh8TN#nQ9`| zV_EMizDUbF33F7Cm9|hSy1lII-*OuP0evC#Y>2d2Zo=s#v^dw+`5Fv*2lCQ#A=Ei+ z$>LVl057isS*6*(qe6Trh#GY5ta$sfxESIOJYs*QeKIM8alVV2<-mE!#?_bd|HKwD zWE7rLdR_GrXn{qnajyw=JLXBGZjP!8# zx0qCX?Zhzp*%+rlp4E+6qUFXPh2wC{eZ_~XZa zsk1Byu8bUwt=aFGGd+dcj+bo;S9BRrHj>ld&Njt?!9v$66EV2i)>s@ zb1jU3i|9adl6hj^28X3BV~JVOK$uO7^tDu-*!GRYp0wwa&@g9xx;w*S|M17snvU+< zVhDOsGOI0tE#o(FJVs5i!80B4%bdtog5;sRrXRc%SMMup zoSL@l>S4dScZBC*dL}*E7spMmgSw9}o8z|auS0cL6)IiE$EY%7M;((MJ0o(*IbWPo zy@()gv>mJ!=!X1m)dSNYFXXDEI`pt#M;5t1Vi(@}W@}uv-7T&zbWu+wfKGHekK8KD zSA1Ifn-^9V`XAmlD=u-3Ub(ypKwUjsTRwFld%pC1jbgvsA9Rw==fTdL%)w%>jZHjk z>{hm|T$US;#DIO2Nh1@HY{`(hGL1Sxb~B%SSp^eeG0@t#wMhw^X92mi6y-xd{d=NGoyHYSr@r%sB zN=azYg60lyEjL^O+A&UjW<6fir5x0&G|n(>H_%7pa}Nl(dF@zC6RtgRAdCmoL6d}& zRFKP`q@iizpV_cs-G-0-ly5}v7=i4e+*dT?a_sd*OSz4M)qQ94+Ek#>`m5wkpMxaB z5SXcfK%Gk?VTOHL!8_QeZb%!MlpPRy7wux}#qR1sAwB(Qm^{5}5mw~T-`3k^E_TRf zRBQN!C zw#8C|(9=0@CTq})AQ5&wLAKQGx4!gKT)S%E(K)(-tR$nu-<2X)mU-w)VmSN2O)BK?_{n@Ex?AGaHtL40YyDpGnx zl#trrzq5>m5z60S;6nOv(Ykpwxn&-E6awMS^=uMny3Cf#*xd9P;L0(ScDz7l<<=*K z;}+GAgEYj&BUY;NXv3!vzK=V=l=M`Kix(`X+a^0;czI>4rUUnSGcV`oFU@6IELuFw zG*yTV^r>k(Nkw<27bX`p>6ZC|;dfeglP_wVZE_?D?hOW5A08fKmyo>!oJ=d`oAk8X zK>cJ8S%uR>5I~2RG@6`s#8Nvczu`C>(BR(`YdSTZ(hH-R6fQ z+K=?v^1jN>GvCeCj+$6;RDN!PAGRj2VS~ZQBZKuLpPX({k1KI;KtdL&r?&qCOW>?R zJSjZep}bjEP&a*r92Tf0pT?8G+L}6(tOD@3CqUw$jG3hnLgIAEP17*_XwNE@zXYy5 z9`5qI>ugC3w@m4}7kKHMAVINQx+%(r)9i~U)e;IFcK7xD%>*Y!xxeDcwB|n5XUj>I ze5>YG=i6_`^#SPF%kzDf1_26CGb=%IfN9I;eWz=a>RaD0w(F>zX!R;;5{J08@}o+m zi$Mk7pz?crWze2S5{n~YTpB0b=)^zH9|(kY`gwTlSrP7B4!3 z;)~hNx|tdsG1ZIAR##bclb5j4lNn16>%CiK*!P5Lh-F5f_F)na-ntYc- zIkD#f%UPGOxx)VXu4_Z;zKA=U)+XhA?RS>HMnEpys_Fe%40z^esOH0VeICP7=3H|w zmL=)1l#B?^@A{(Wf8W zf;`1PbGbRUEu?g+SD8`;E99bfF}*^*Q?gp5oA?m%j3YPtQ6v6M6mXZ134klJ{xIrf zu_^Eqp+VTA-QjIzymypx^7}Y617SIOU1>dDJ@&S`n6X*~wmjUY8`1}iE*Re#++#qf z`t-Y(VMI_4g2)>B1ja9UIQNa(tk^<15k1$Tig7bbdit-q8?wSHTLh}4OR2MbM%zy_}Q{;>n44ju~lIUSV^39v&hAVLS zD?rgParir$T*Or$qE_en&9z>3(T+O78Fz90`^zmb+JGP`Ok?!RVL2GE-l4@)E}ivj30#1icEBRP|+A4DuE%W(B@#- zp*xGu+)rYhWo6A0+-!*qcC|)?&AzbZw=M}6rj5Q~J6E=jAK~gW;6}D#8d;IuSWkiF zR-Ox)D(sESvt+-b2~wordJZ6u<2RhqZ&}T3zxCil^0SI43tI=}C+LO_bkSk)g!-SN zm?v*cxrraI&&>VFpj2r952a+GuQW=s)1n_;@JZyLaG%SoD?WKgp%A=Rkx{ulD_(rJ zj(ak;w%QeUzjVY&KWKqB@6CYSh>99oNCdC~VuE?e=gL#Er4{22QtL{gtN zgxnFRq2+cM^OQf2uSNhmJsxSUpEkaEFX1365PN2#M+TW2aBC`ic`@F+XY$UOo;?;5 z(n7|Es#1aSvH~ZHUeowLmJwJ@rb!qGx3z31`{39)kNv`9%PY7BXW5Y&-x#bXij1YD zaz;m{O88KO`CS+hn0XfPjkdWBem}`jRCSNMN}@mxL`Y3pVSK4=D@#^v@R79z;V^yz zNCl29gy#cIV)-jmmuS65vgKES9BWb;)<#a9_v6 zo7KMoSAh8BJ!P~(VxG*XAX*Oy+`y;a$RCSQTY-8BKQbI83Oe-Uf@ zeuO|*$!$1|3>iecC>iqnCLE{q&ctQn-Ok9E%TKLs^4infP z3moAF;L?CTe#9h6#>)ccWUF&Lo+^VEF7oA(@$?xAXJ-6ezWNzxS580y;yx>w9nM4zQz;8X^%9lc5Gtatc15)H^`_2-O!Z${4HLgz%M5>t{1}`SYiHQ>PZ}dK<6L8sEie$E!RbW~OcoTndNXbd8 z_wxy~Hy_zzw7z=-eyOn&(sP+E<}1$~^@bo-OFPX=yUA3SJ4jkY3pauopt;!0T8~*@ zV)H=e`C%-_r7CCvMpBnos@EA&KNt(;8e$U%yAD7<^HbiWR|y5YK7rQ=4^)cx&8r)x z+Y`Vvh~8K4n{l~lwu7IL)zztyC1|{2MJgNRqnBFbs}g;qtTk|L@qmk5BlV*e%uWW% zhjm)Nt?*=msX@%hRla(#UUxP#tZ%O?WEouH*=QgQDNc z0U?y|Aal83W_mOlxKok@Af~-GKyCAQEe*@*TWztLYpVz#fVR_^?ju~~4r9JQ&I!GD z+KQC0%F8b<;L?oils0pAis^H!%$dXYrnQC_)PY0*R*xf?ES^&^Tl(@@`;wq#|Il<7 zwJH&E)xBDCRgRull|nnBV5PA;V&GhUq3TQG&an{{+nkQuKb7zP*g&m$ZI_PmQok0h zt(YP)Tp%5w8N1d_U$4Q{wkF)HW&n(tA0{+9mXe@mY!=Ya{ijc}3~WtpG;rxK2yX8D z-gW0Cq>cP0Ixv-Qj9{BBNGwtUx$o0iC9{UQ@Ysk&Z24I<&z}NfIWA($M)R=mh>l#k8Sa^FUP*lrxCqO8$I4HJo0IWOk`*nAZ>dCTk!`DsXb1D+zvi(2fhKwHC29euZ=x*7$kviNB-IKi*mqR4ymC;NC+L1Ua5GF&+Pv z{WU%B=@Fl{NeL!qc6q^F=QGNV|BDbTAdJe^(I{Tyc5Ax69gO?@mtxud<6KK(gAiJ( z&~dV^hDaUE>I(PXm<}5k*}&wRFYaQ410S}NP)NPG2}9fR3jgB8A+|fo!eF3Aj)B8O zk%i?BAC?jlBJ-uU>Jb)J=gfw>@6(n#J~?|zCf|6S|E)e|_nUGRUcP|lXGss}4Y7TZ%X0qA4nN zc05thg=z_uvCBEcWCCQt!Fn!`4Dx8CyUkh~qJ`NbBMo_P6Zocz2lnO)nG{OW7ceog zC)%wkB+M0l`9d*zqZx%>l*q8nb7xvrrH0FZVK`{!amJo!(6boRAB;i_6Zw4dSwrD; zwxcqh3j0zg^LmDswn;^LwOdeddYrS2^!JBN5&kwXoDaJ!;|3m-I|m1l@ULAmQo-< zAUrd#r8zkpKT7nl98aS$$aOGe4Wp;ainjf_{l+clvAM*d+R}c0+wHK!9vNU-4norh zHGf$O=|2F_Hdc763BR{;C)oddOJeWhY?OwF7M%ZUGF9i^D6Z9&beltmcgz&(kRst| z#ywi@R-9k?zgjYtFEAyVz2 zd3=HhT&9M;D4M$K^xwho_qKUgVglQKn`dz=b-ey@HNJqh2l=5CQ}hS)kU93p7Y_AL zRSq|l;aH^a;xJz{f>jpL(VjOCKi(zuXgszgY0`!Y+K8O7)d%^dhK2^7ULL`xNj6|&@Q_Ds%~h;fNmqn z6k%`;8Fe>ErsL>$0R|$A#(O8Ho4gj(Tp^Cc<7Pom#^;MMpn;6KUX(M9iT5w8EUdsV}7(%V~iG;voq6_Gc!}1 z1~X;zFV)>F-EH!+C&2hSywWD$9Y0z6e?L5H33?&n?!Y6Dp-|_t<87h#l1M1v(Prgh zh=0aUDc%nka(DdH#a3sf?_&ODKj}IpZCQRZIZ46ddD56U-p083=JWnvWK1*yrPtOn zlccl&^VR66I5EYlYbknQCVrx9L>ys*2~XkX#@CCjW-`r!0x-2GH_L}U?XFuIftX#} zdfglo%u3ZCl+`^(689?=;~MJTLl7`lgo3=7qA#_i+UsdO?dTH4A-bqwpBSb*HBJ{< z$Jv7Rt*ir~iE(FJH(stVkKPg!lf8$D`&$SOLjsLDoafhwB7YJRp>GJA(Vi6lDI!cjmO{XJ`%AdJnJ;z4ld(FHsA_eD8L z{Hlj*KqF7mbrExPj77B;+u@v5v5l7c^iw@pfl1#hI2epM+UdHV%e z{N6Egl=2DlZQL7sde7UVJ}JftrlHz>ne`Y32A&<2*-jR~H|ljdY_6n!a{VjCWD@ls zIGO|(MeHJAwzyD zy-Qw2PKl6K|5HncH>jpQMR@~^O)CE?5c5d?>GcnpD4kXPH8l4e&e`v0o_^JTwM3?f z;qCt^;(%9Q8~z$nRe|^a%Nf=$n*J(lidsmDMoL)Rf8&|_dq@bJKTwbxxUledD=-!p zkq$fdRA*L@<*z=(MF{*FgEv4}vu6AI<)29grt@hJgx47XO?>MC^@{YHxp8ywCf z_)K4Fz^aktz{_*B^dp=O(U(BUal9+cX--b-N!Q=(MMoCT#h&0@L($%m)Nr1Y#WB4U z848J$DEVa74OJ#qZ#B64|6hriSpv?YRr&5KxC;GmupA#wVru`4EJsDO1kJ-wo*%L3 zP}uh!Z4BGDiL<{9fphwghIVtOs*w`phz_#x!*tCrmzV@K7Zd7Byws4h@ByqQE=-Yq ze#z$|!*6advAJW7m;g;lJ@OhA4^nj4O~aqWgyhOHu8TF3%Yq(mu$Y~)NTl+az^Vya zfgy8S2*RTtjZ(hqQC`yHHV9L8Da^RFFdK5Jt|IRWU+Qzd6i5ig>`CeFY5qm`falEE zB;y^VGNV2P;(0oaBt2MM;$8MD(`ti~; zLK-PP)Y)Bz-F}VfHo~+*85W7<;mwpCj2OW|a7^92F_@pi>U0m8^Da(t*Yp$Am@>hl8 zM~nfyo|epvtw z+oB*dj%{Qni!^bCGOqqq6YUNXbCjdoUXt`QH{YCk2zjP^ zw!_>c5CZT}lE6KoI$1$7DeB}Q6&hAa;qtKb0FRg2A$niNSjeJuu@afOC+>^pm!#;GMm0_kTRa2eT{2hTqqQ-e!VLE*Dq z#eK|g%f9`p z#2R^WEs!xML>RHYitp(_hYhvRI_8wauhJVkuTG*><)21oPD4Wnbuin8%I<$RP%JF7 zSk3GC6VQJ1S$%~RC2ApMdWl78ag1~K(h~MA!amUwz3w*6unwxo+f-G6gAwuZ8xJ(8EmpQJXtvEB6Ei`DQb%oWg{05^nzb18>LB4i+UkE1uukNp|)mKqS(A zf)tnKNuvMmfF4l9<_0sS-jL* z-&li3RK-?&Hg58pBF7r4@;k+x#7fLv>3Tw6vUJO%j~9KDu0jbl=DY)4e+ z_f+H;(RPT(GXO<57^8V0Y1M-pM39;TiMK409NIW78&2BR6xh<25B zqag|PPjD%1)jTP=BMg2J*PDET8lRyDJBO9qswox*4siVjql@DS4nT|hmucES;(cdx zG0I#${`L;{?wo0Y^+38%x)I`4v6Q^?M7uxd-GNCHPJ>fMWLvhwehG^HSo1kH7N#XqQVhUQ zJ(Ya9qCR&c{1y^^QE@q_L<)tGX+%cNpFPToWsHC8zzxUeiY^p|F=f9)+c&z)K0T_9 z1zl+3ATrnHQv7Pxhx(%*l>!PMVKGl$l;~IB!JP{SuYc#NgF(+Sb%S`bR9V61bn_%t zP2o;O+UaQhX#K^q2Gn5tkRFE?gh`8IRYIrEYxP_~{G$V1=#BEtmvJ!{F^VW%_U+b) zY_jyNUzz*xa{uGkEcKYNEe;KO&o>Jm5$Uz0EWwFli6Z)BvJ`kBolzI#CXby1uf9+* zxd6F)3zT!&h!rSo0c-?ZqI6Q~q8p+pTCDOTjP!UwW=1tlx_W|N&psyjeFVh6J>!QNwmU}nLLSEJ@k=H4b z#qSTOXRj~CI#$Jr2kJ!!9a4BY=oh~iZIsTydk3%DC?zV9K9DcL-0t{m+2~)43XNxh zuUPX+W90cQZ)l+>AT&HbN7kNFU-{0U&r6FfjwrDD<~KK0CW+7XepDv+XlMFOH!p|| z7t(2?*#Cr$<$|%|wxLD@aXm4*WQMR`5c#NQEMLS9=gd$cbEDZu=<&}nFOo^pm^fTI zo5)4bE(?b00<;v1hBTQ^KR99!Z+3*xYn=`pLM)3LBf<)Fs|ffznhRcD<y=vIiiHn6ucpVJXUXnhuN~YwKokqA6gNKa=%CvwRn!Z$uNc=Yp_B>C7*7jbVze8WvcSNvmqF9`HR@;*GVww>2ag={TW zgEo8(!|ozoL}n5#wNhV7dqb6F@%IflK8b5Wms-?Hy+dN!QCUFj-IxRbhg5!+|oYs@i}iofouZ4mYSZT`Y0&E7aa>YmLlO=fcfZ$qg zVx~(MI?Kq2D;c+$N{C62#plWF^L(L5^y5fXVR1bxTNI!5ocd|9slo`vKGK}4SS+_? zAb6~LLw9vu?#CUw5WGu_2heu$x%@)PV%1=yR{*fltEJUX;b40v&96Wd(%=z+l*-pZ z?-?tH5xjL zoy;>0@QXpE=-_ZFRTqPL(>S`gy7_0*CwMq2`-ucWDyEs6D0l>Eldk%d0~Ka8OY&>l z^t7Bwj*$soanofzNLTKkEN^(?(rB+JqN&G=+42HuE4gG= zHoUlms8s0KWKjZy^s}0xxmEtRzHb$EMg&6xZ&GuLS$~h6p{_6iu%08Tq#l0F`8cP< zZx%gS2k}hhRq9eNfSbMAcbCG(>We;R*MJ^WhVBG3`@xr%#f9%iiG0S(QplM-z7y*T z7fEdnd5$I1y(lGMwo8>2w8ynX5x0?-AT5494ZakoQY0#@WKo;pvYI<<(iavIo!gv{ zc@%X0)x>*}!?im-S~?CTM7!x?(t)Sf`psKe3+R0oLtwjCu|2=HIhOO_vHLPGoYPMN z+d)Bw=XokN0sd%kIwZrw&PW{ne1H@5HqQV!tG>b0}TUV1_eN@lYSaO-o z2lAjIONw!Ove+~~#m>C{GSGR%_?E~8B>r_iS8F{@k$6MY^(P%k+zc;4w+P0jwt#6Np&MSg9NMUf`8@Gijv;62wEzYm%FO_;eazX{%#A@j*?#ZewN``62 zR8F$QQXwF0tXLF84OB8fAIyx|yV6<8C3`+Q0g(@Sl7t)&Xdy%x;v^6pPh`MKE-f}N zg16dJvL{JgRbW)rGP&FX@}yR@RJ4zEQtD9IaShqdI$*s||E!i6_3?!W3?@N^bo|v6MQ-%EkOGV_Fv@ZHp7qo!VQBq4!#{&xd^@ zlfr?I`L`AAU@9)B;G)*;obBE;6zxS<2U#eiy*+QKxpk-`L8IEbYwRheo$0pnc%ag- z{@uZN#bP#0PMrO;XpD}d@SR;~0$FC`m@KjUme3$Awz^uaVboxsI}_=Ks@U&X0@X1u zsT*3lQ(fd6e>xnkdvx4RR#@LU9hRd#k=3w-V65S(u73cHoAmr*ty@GeO~64nEMZ=M z%mmVH2;V8y>TsTi)MQ0Vd*t+BW^&j%W0$Sn$COA|Q4qCSemGxO)83kBi3C@lX+#hs zABWjXoyyxHBVsLZ(Zc6gQ53oI-%8R@jm#G%Mg^^U*5$Q&1+SxisLiB7h?CaaY(_#; zUZ4uwj2UQgRBg4h<*_m;_K?kNo3aptZ@$pfyvRdkimzhDOf0kNv%s>fhqrhamG!=E zW(B8BXK_}%jrnagHIg@ii4@PIgK4}$71En15?%~jOwwa1dY3A4)(?7A+W?8b+<(nf zlv51jt0qCO;BmFREevu=DFUJL^F}GiBBmy&37`6gRg=zOs_cid(dO2nY}yTuki)Nq zGXi1}5!I%{SYOu2B%tYI%Oc$C2h6vd9?6ltKa-=~3^tuHtDMyMi53l2xv^|#O`B%ZgH!p(0!PaA zGR8(yN1z8-F8K~&x{u;FCGuqAk=97E<3J`kq*RPh&C8)%Du5hHi0uR~0~-Z@9J|}4 zG-n%r5*EHva)0xIgW%>I*NqNuuDYvWKI)1d( zd=l1cdUbEjQI}vri7B2U^UKftt?8{gwi1f?ey}c)qOdt_S*Hy%0RzquzdFSh-fF4C znBNoVQ|E1u%w(nXI`F1}i!t7FTjiVsX_It3=%8!z6fzL$U;P(eeZ7=?qyfeH? zE`BbT6(a(`mrgsKnBoh zi|>hjVKG@Z+4Q}qdx+ud@nJ^Mb*;fZxvCS>drIj$T>$aBP&d$nBV zt;y-nzAe+emo2V`FSyKVGo*4LmQHIiR!)LYzxv{{zUqCeAH8^{IoC7o7h?O4UDIak zoWue*Z|I(1>Qrp5R(nP?X{8CiPf2KKsU~^8);U{%tNp5tB|*UYqnGfJ8aAPc#PSxA zb3S28HO!RGERK9jPfvHyV$*X{BBZz`Ms|?y*aNnw4b4sG3|OXJl>g#3yySIsV;FrB zi6cBPv(u7;-K+d^J8-Ml9S5?oJPY~ZdC`7l`qpab1Casy*H@7`dSo8HLUCk()lck` zEFadwtSaW1#;KECt>t?XwH_xZ_?spUcGQM8FHi}orO6vRrr|9O9WGJ(LL1J*hKd5VE+UO`g(=eB|X``Ex~lsNJAvG7Q?*$~FE z7xg)-ZU|gx@jRPtu>h{I&ld6?*&Gu2n#{?#!v~VCR8J-5B^%#6-t)hIY9+o|qPT&i zgaQ(A8Pu!QrqBqWJJ7(gPPc6Zjd_EHs ztMpH-?CJlTpOIxXUi-!GpSnY>LFMr zz7a|oB7HkG!cs@wMzV5pVn3SSCg$cIr6z_glA^EF6frRY3Z$qwI3^7y^meWef7odE z9SEAUw#N%#b*W^Aq%@P02k>Z2l-y;6K!=a~Y#RvN-{8_PW=EcWi6EP?Y#ps8D6P*~xle2E>J$UNIwZGhW1)>=X0t8vfj^ zgy~BAy9|D}(k0=id$-Ig777~nJFGAxU-FG)^{(f`*C1e}?wgV3RNSjo5b9X8LnG#= z+#6CtAvY1&rNi(@B8|(uo=xW4>yw{xu!^7L-UjdjJwU>AIF5X^UfAc-VT%yoJi>jJ z^6YF4%-_{DD{4vueYA1h0GWE>uoCqRzi}Qg+cEhx6o1?kxaOPuR=Wb@F*)nk^KJq} zxnK?4j6^)Hw%P7?A{*o3WoDHiG;QRoRUeKpZ1V4deQzX9{H{;T1h3bFJ=dXVs(9HE zoBpS0m>*nYGiiqu$u`v(35-a!vbnps#FI?fFe&%otw^MaCI09}k z+THhNBBo}lm;tdw(85A$!P9}~t-o@8oY$S%!%SR@KoQx+6pF5_prNtd>(?mitDhq! zgK4lGPN&wuO@7vpo({Q^!)lPCm1zI^7l0k%FmA(HJBM)ZL61?qSuonhyJod8Fpj`xEf%W|Wz$px-EqkEo> zuF}K{2C*~bgv}m%0-k-W{sMql7Y8>ay{4aQDLd;`eqkVEgB+ipb#iCi|`xX4+TuMtK?De=aC#fqU7_ z4CcAHxm{qR3`H{W6vESPZf>V1pK&(3?~Wu~uMjhwk1=HJ7Q{x778>)n`!a2cg-0a@ zB9I6akGfZc5wtwS^LgfC0#!mynNh)5hCbr@3CrC+k7B+LbAH#`O$!?)Pq#$oR$>IG z!dF8toYn`4On8c+Ti`Y~F1(rN2?=R|ttpU?&^P`)L*=glB{wCEg1Qm?RpMKYlTu!4 z{9#xx!rUf>i1=1Li%uD8oiarNgxNb!#8YU?EH)lEXsYcwY;eh1#9gv^w#6RjttDe@ zCLLK`uXr9og)->0yB}D3J}0XPM=Ef1&2^)rprCl(?vjMeJblP#5IwJaJi@%n2APSG!p6 za7Kcx+jCvJD(ewI;k^siK-BA8On<*$-D19UH9oc1sJ!dyO=Ovy6iIlfzsT5L!+E#|t=bJ_RoYwKQVc0yLx8`q|H``S4kt6tYGE7FSo_Y#eXYQ%=g z1E*t`jna!~%;E1vt`8>R%B$&S14K7m<$#p-maa{Vs#mftNH zEj9%ZwJRS)DKmJ851*1_*_AWco;O8_CX44+6=VAJ{D>xZdvAJ%maW#=LA)T3t;R}4QHb;n!P`#&17R4Z7l(p*n(Mm){?Iz#l;NEno`$0ydEl+C6WNh&^AF1BJ-Yl**j)JDeOFW)!?IvjEbt`3HSI znQPKPzVxkh(+lp(I#^w*ZQl1)&7)y5x0un_Y?22v*Dhzuie9(RX-k@3rJ;3fP$*?d zOA{NgVukSl^(Dh3Ozi<1p!Zw^k{j0 zmzip+B_t)pPjjBd?>$(_URkO*!F#}>5up6R(`>sn)6EwzV&gwk!Y_s;b3IT<$5X_< z<&n06mjthfFobhlvjKBB$1s zb7+<$M%GKAE{m?Uh6)VRHUfPfm1WB*As)Shw&{xk2;Qdk4s)9V1=HWZMux{oa#iYyj&sVtaB;+ zRT7=#udIGVGrb!};`f={LlP5fA-dap<9M;b^&?4Mx6d|Sj?3^S_&qWe!{e-&i=x|1#G zHQi>aU5#mPAR;l3El9t`F?O}fXS1$nEzXfkPgC9dwFI8-9C`mbQ%fOBzY@i7B?$+8 z$cs%&fjzNgc8NTVN$%=jM@7nmqb*D zeZ+^L4N*Q_%QQMH&G@1YBTr`VBW>oAh8M>aqdwSWwPoQFyUv0vb2B4HlNo}QlQ7-O zf3KEscY9s>b2la-7vylM>Br#Jy-bNNDUkf6CJq1MDPfu(t7X|W+AGaSHwT&3&KXa7 zm2bvyR^-oB<|p8J)^`k~Pb8*5;ZY%^hxoYHM-bMeL|4{d*6t}OPMhbfM)T_Y5QB^c z6Q_ZK&2@_pD4({F?(*K@3a5e1oJ<1Pb6-0#>W_mOuD4QN26%7&AD+%KDvqX$);NP} z(81l^B?NbOm*5`U-Q8V+6I>E3xVt4lkii)U?hbeI-uvD8KfP*IS66rK^Xz>TK)S@# zYaf;nkmGkZ#o`7Rblwu~)EU-Y2GI_7e>iVC(c%#DIufiV*epMY>be;+z|g&1!{Juo z?aT(fwRDi-<6BSVe4GwM^b36If9P~^LWoioG}0}PfM01jK&;KVt;oTkWEiJN$=|az znHQB{{=J~eunvJ9s`%g{UwsHtljM1+Y8+_!izb(xd;1d4dpR?$l<%woaIc`+^E?iL zRXXwB20k*xz~Z$Oi*uDpOUGER6iS?rw<5Rxtu`bNbJKH!3+x&<$Po{`1lF=D_mXmE zJI2aS8R|1*`@*jbZo{!3_@53-UDrP*!W!`X zx8}Af8vW*-rg}_&I)c4jmK(mlee-SM ze+bg->Ivg`dg{&HZ_VJ=eC0B*58K5(vek;Mw(E*7qyOOSql`Wz#&yRvD;!4AOI%X+ z)sCO|lw_uE&ezobyowMT+S6q2Pqq;@4d=~|+QzXl$sAqc*ei64G-mB#q(X;xI`EZ^ zhbGfa{zA+APBEblTrR|GMa4CDIg|)$o;fcs-2CAV9cxJ*!X?qTJNo%AV$yGl{PthO zt^lwM&v0vkNm%9k1fkM4zqEekm}6Wox4nuBkw)avVkIo+OV4P8NG>IC`YliQ@xHgS zmk_Q0t3K+D))r%?-;l44x*@qX19miwKqf_J9JrL8j##5Yk;B#Nbk$N?3-#$YIa>-l z-%S#P^x@`*douY=md}$Z(MWhWWti>;==xmk_axXzN^Z+;gy)`=**W0p7`1BwQR`rT zyObSKpjllqsuX1&4IW?$Vdb6kXM_BwX9R-azBwmd$IyQ-u z$(FMcXf$7`LZ;Vx;TFa1ePGKG-v1;pCTjQ(vo9$+fC6!?89 zZ`|{A_3Oo)juoz!UOpG~qJEh`kV1NTz!Lsa<@mEH0G-m9OCyHG`$m}yYr2`QkUj2t z<$Mc~_J@2{2auH8xa6+$3?7;aF5fCw!U?#r#jLLx`*H${K*_p1;j$I%Mp}gDJaenE zC`I1nNO?E4x%(=XIvOgWH_{dY`kkHy%uz~5PZROGfVRNkDr5f`u3A}xE;8E z|1CT0a)oKMLJXJR>!LeWv^^mrYq!0GzCKaxwVTp?cn7UK^+)>#fs+-rirUOZi>MY} zMRl11xnEEvLAW8(!BDpyzfY%Ftr1ygCS5xjSE9>;!YELbUxTwR0yGQqsdD7BU%n&# zlS_h>&tmCj4{4YL-lq87ISL?xi10@rNyk)&)miTqDh@uyg+XQ#F0H0V&$TSe9d1YN zn`2?X1)tYDNE=~==hLX3Cz`r6!OweP(30BxS3Wb}=5nEbpd=Rz=!$uNO}E%K;>X@6 zf8Ya~A%*FV*!Ac&ej+>R>eLkIJJZFGt;ppA!^Xuxx_hg5T!QVN$v{5xEk6c%uBYx)9{6yBIcjV+%DG6 z(M!SYEti7^DiG$pekW^=Q>kL|<}}JgVE2P-cD#fk3Qy@1?B7QDD9QGS;TzjWnWby1 z1Sq;7&Lw(%e4Fxxf8k3?*w973A!v$7Ka>GFrJSZ@QR$u}$)Hj)E-lFmIO5`g=G+=JIxFK#!z|ERn2vi-Knx(mF*%W)1#doJLBPkVcm7IWoMcL6+L!$yheQksQRweN9zwulASp;KUttU|aI zy_dbGlxciumBEF!dQ5%?Dz2e1O$8u=;sYT>nqn!|8Tl@YPH*gy^5yOfqVv6}uRq!BRCNp58+Y3vg+^v&OFxdY)m zXVzj~L>|Y0MM%nF^3kOzK~tlx>0AXQvz z-Z96NF|c+Vjt#22@Y%U(QyvZZfY|u5y-)BuW&;Fy6Oa{Mb3}p!al?X6yXY6{Bf2hj zdxIpm-^5!FS6~WMPUDR~9@Ds$BMMI}gEwvi)Khgr|7aA@d~-BLc=v34Wc4GR3LM23 zx>&saXm+w|ft7}-w=f(yOP^C?Ea8ns&%`pgioaw&3Knv4nf?UfQpg3k2*UzJ07kQC zM4&9b;$K1jH!>%wPg|{C${IVE+nW)$!LRt$I*5uu{v=qr!QuB;APGWZU+Dm|qpa;F zlD%l)Vj)87$G`as9Ipp)DGT+bde~3R%RSO|Q8QLfQHDp&#TFGa`xi!qtLkJ%NK ze%ZJI(GIo6I;&1(WI$coq|6U=HQN|5z62=Tn?2{B(hF*a~Z7Gp>ls>h;cIGnbu;xRq! zp9{xnbN=MacDeawKj(Vky7D+kc_XkdOx-rkp5%6R%F)}SASc0Yy2Mc*bX;}5A25Hq z?zBaz_ngVE;Hh&T3(C5Y&f@Ac1>IgN9@pFtch6hdzsLP&6ogui;T-r3XR_+WYJxhbvPj^-jFZv#(eXk1Yqj%K3Ox-X$}yC+d% z&!K)1SeD$A^?juFiGTzGrui?-rFBv?p8fXFecsUrzV}^%!mqr*yNhw6QrrFF7HW{= zVBLM8$f2K}i9=uzm*@1RW%Qs5+Y4NUR<>63-RO_eCNHwMoptk4m&d8qtY!0O>owQa zwaV?hwVwW-;b+Vb#1Y0Ce%nxO&~Hrz-N%kluwqS@qQ& z#_q6R(-R_3irFt>ygxt-^{&T+!otf6F`}KkP4I(k>F4=O|8!5XAM%AY-mi79aJt1& zPRMqU%`Kwcd8bXs?Q^m(3YP0wIXSGzSG=L8- zIg(hLTc_Fmau>(@$Roe=wvWu`4om0Jl*~XZ)RWXPVGf*HT)v+@&@6u6C3|b0a_pm) zNW_od>Qa2vE7WLf`}~a7QSvICM^d{ogFmqEa*=i6pYbZ|tch+|EySxNjGmxa6PwRH zrqAf!J}|b5bk>cNWzZ-MOZ|}|@E#Pa4gB)GDHQ1Os%FmM*6e>#8TPOf%Qk261p^3j zA-E0{z`(ET9P3~Ra%%&dFWx8}Fm*SCESkL>@PogHE&CfdE5P{Z{-Q84GODk~5&{HeLg*wHaC+}!tsA6f zRHqSQ?KwU4w7f+8N93_QE#V{$N@K(}r1Y92OBvMb@bSP%2;vCh-c{gW%=!>jIBkz1cqN9GLQrGoA(7|$!y#t~kC7MxzsK;Zo+GHTyzFpqf7N|%^wnOY85#&tICN{i+xcO{f6L`St7j=Fd% zTd<8DcdqL|gtkHJ!DW=gY~947S=7a0H#PeEEmbHhDDHbvx6bMP{>h&ZB^0ZWG%>WE ztqHr`D_WgngNwrgPDTsF%t<;J8X#mHQ3R zsduLy0nzI)E;OVqB&Ha(cc5i74oC5AgeaEg&R_)^*F;WPm^=uZH7)vuBE__dIU%eD zUHehw;Opv(8SI=yy4`iVsD=p4iIatCxsjG>TwdANMcV@Qx1&p1A{Gy7__+5UE0~B1 zg1Xg(T=zf;QdtC>=`n(WkV};WzKr|4u+5xR4}h?MRfGk6#!lLvl?GD`K294bCBv5Zbk;5|xpPYQq(0F_Q6PdxrSn7$yA+kz#{ zTS=3-FJ7r3*yN+%HAldd``m;n>?HGGF);$6eOPz>^B9%bE3uuHDvRsZNpdUZN^*Aq zPIH-k$#pab=eGgZyOMnhzhi{_Krjxk{`Y5e8MM*wMYW@~YYoFqI-vNQvw)g}20jwF zhuu7Kzw;(T7cwEN(07Gv5fWgHfmKc&N~CrGi3S6c!n>FTGb|Ed@at_<<3X=|d?`)F z{8zM|mgHR7!jHi1{t55Vb&><*-Bejmf;H%_o^{Iw#%VU5&*aaQ4meTB@v|9t>A4f2 zicvf~IarD17^8#_rZY5d>}S=k(awa36|iv|6}-oidi`l=H{PMK%$lR^Rd_xZ$52KYW`z6+A zquIpg7ro`+H-K))WiRQTkKtr{aFpuskU&6(L%{>$T^?tdrvi@=a*^>%wb~b|cdxRe zO*RHhDLpyUkLT+(2PNkp0yzGDRXbRj5bi=gSqwbkk^^Sm zbJj^ZvIF`Z7Q1;5b;9PpnJ_gf^t-b>{pJuoq7|?#JU_^1MdfvLQy(vaUPfhqBir0~ zBUuve8gc)mtj~|M#Hcq;Vnzb|EEZXxT`|k3Va?5Ue;z4>K{E1rvL}J+8^GT`D6by8231~mFKIJ%9 zQ)@oY#)HvUIs7$z2LCKvz-Et=O^0CL>Djgeqm;D!MhNp-D7N_9ds`y-bA|-F0MO^Q zrG_a$Z53)Xbof!&VQZZ(T|`hwx)a+{^ss9E*}6O79itA}-5r%y7py2~wGb8dZ#$8E zhk%Q2J^Z8Ym2-mDlN~|ABF>P*mFhOp)eIrY=;lkW_j49b8Jpq=v)5@t$@-w_J^Z=R z#R?)%*_L14$TfXb)S`f>YUcVwrjJyUpWGVO z3=MvBy)B15lD=Et{tUz{HcV`{fWzBp25i3W3px1PQ>`XqcGF}+3&PyJ`j~LxW-W1K zvgy;ScU7AId7V^@6iB682%!=q(4}1?Sw>&`xRJPCr_6^?kOqr%qAo|4>eP8&kyPLW zj6ah2Bx8eM;ACsN#phO!wG-A5~fKvoM5RS&E3>04nI%N*b{OLCO8G|$R zf@YK?#Bg{^GxByo`tyBl{9$(7=(D%AOiTJ*NP_*404Ew)^ul*gx-Mov(FpowRLnla zF+y@!DktT`Ptf&1q3+G7R7HjqogBiNvkF)qf}E>cS!YoTY$Z?po~`9A3jr-JnK4#N zq^f-bRyRS4TKumYi%pMV(^h3LF^Dn2Jl9tuH*Uqf;it3vRk4?|@qC4cKn0a^H+^(Q zDs*6=G*|)~1)a^ZmpQ7+6+K2vr4B}9UZb;|pi&hZ3PB&E7Cxj@%r}2?(6HeIqf25cv?xtNTR{)fyLN2I&=#?AOdQ{{)XMT%6; zpb7iRtvrL?{^n=kKp$&k$~#}Peh%{T@|;&_v&zED{%;c^u;M{lX7Ip?c-dHQF3hB` zjLuZASq~x11R>Q?@wl3*mA9K&&OF&kU>PhoEJ>lvy6Au|slB|(IUhP)iIX0op$z17 z&S|JFtp)^rN4p%amc^Nr=xwQ>{FDfJ_DG{lo0-ZiN9zwg2dcO7v50_h?N2nzhzu;O z*f`c$u_#xHeCU^qsWh`FZWuY+H44M(7SD>C{?|&11%e;4oO)Ze*}o{mD3|*6@1XR_ zgAjf&uDt&(RL@O}L3QedAujT}+tjg4-EYZn>wiMj#9uC`c;-*G{uV3oGdoHZ9VbuJ zsna&V=;9Rq%mKmQP|LVRJ~%vXQ0_J8dGYNkTUWOv;p9aB5>5N({R@_>9TS<>ez|~2 zSOrELv$7Y?qE|7+%;;`s z&jp0yf9lUocge?{V!ykAM)5J$VU(eM_q@+hZK@7t;MqF%*Ud7deYRAr?9G;+`(b78 zZRi|D0PQKIDt0tz+J$?<#8_imLy3Bq^Gz1^{Q0ASkzropyXb#2Kv;8<_p+(BC?Mmh zisS~vq%Y0?QPNRD%csuK_sQ@xrYWDUeAYLElk}uc?O$J?QcX#MYJ8oQj;PB7N?E`8 z8aMD6!@mWR&}xK0bSwc zqh=FnWTlfF770az_BsWjOdw=P%k(ZMdOdEwz~O&YjLjx`}Sm1h1tsR@^*I7W;kJ+X7;b6 z-GADu(HG8BqZ3rP-vCn0JW+XWEYRFT8F|5H=9VD3EAv zOVfNA&4-hgxBzHcSD=gX1c?80Lx)7hiS-SI6tO?NFwKGY2Y|;JCY|2MZ?xkj37(mJ3IgrmR)`AUOpD z_AVNfNnQI)&d_Z%mx<~u4yo__|L{!;nPUB&WC*tbdMV;6qfC+-?H?V-UVX)+i!&Ie zsVw%$DxAqyOC-riFv8=3SLPBqHwhYc#q%Soz=n8cct0Zk|Ar_Bdbgixq2;CjX`v)E)Q} zawEgBLhNF~-u-sV<4Q&k7a{&IHQ93z2Bccim#gQT!-}XJ;Wa`(X$E+* zo{($?CA6IR;JwadpIqk*Kl*s@J-F+IZM)Ph_g=^~#^ie=iNi&d#)wrG{TD1QcKxYO zLq@iR(_zz)#dS=m5x>Du5U&-IPS;%HE|(wpygM=*f}R4JU=BWUeML$fYBXD+;oE{} z-6f9aA+mQ6Cnca8%PemRnsR$_P*zj@TXaDd1gi4ZU`wGsLK?Gn!qa-SZ)HP4` zK@Oeq+C&9QBV_d6y_-eX=2%Neo(0#pl351ucA3GO+P)u3bIV18+6<-kne4G>(U~aE zXr17_@J_Dre;deu0Runn^$=^IT^W`o5Lxv%N%C3Je4o&m)N74kNO%$ zxNb>QEb_=GcJY;1jF{1}o|%d)DIOB$!(TIqVY6EIzXcHGvcg!>Sb{e5O!N#5{fA`f zX5t1^b#Oj6*&W&mir8_-yD zDmJtLT&oWQe?0>^Z$*sY|58b&^ZWbWCmcvL4ryxZIR4Eb%%txEJ&DeFCAYZvkRm z5=$-+Gk8>n1|YDjdH58m9-*x%?aHm|%lEb%59W0$cFN5D$QMeL<9S^o?-L~9Bo%Wh ztOOQ=AK5hpf*#^lR56B4Qh3p z28hpZ7F9-<^bESe|8266`u>K=`N&wxhhsrU&`(0c<8fj|i%%0jtI34%WX`AONOh;N zS2b&76R6mF{B3geGP@LkkLw|WJoj}5F-h-VcvdGVbv+~Hyz&nM9c050;YL@t@`r{- zOEYRoAVg`AH-+GFZA+uY4ElKoDYpn*v$iFLvkWN&MK%>><>SM@KA7gB`Wk;#EG5so zYx1k*BgpSuBz@e5KC)V)()_&5FQtuZHvgFHU-76NFxTwuvj0i7fol#ca3){ChANGt z*}v-;7R0wCtpl63EKW$ds>N})?3HuN0P1)%bg7V2W}1F`ObzwlJ6oeE0wH6KiP5s0AIs37ySM>c($;t ztxe6G1ogOW7UVdNJ8*KX%_xC-IL|lyHoa!M?z_PGer%t*GxCdsS1IORFoOF+Acf5F z?Q{+06cZEgZFo|F?XNF6D7pxWMPj(w!p#V2t=+dzU-X;I;k$Q9&dF_nkf+q{cdU|?#ecX7;TDr@0EzH;_5!)x4!L={0 z;CjF7Fn-owW~nQfi<&k^UU7D>gEH3Jyt+KSAI0AGFboPG;uY)y7tOQF?vgL>RGTjO zuioyYwFbWW>O)`$BhNpUnwq+jd4U}<$~~uhqF8^AY-FHFm)6eKq)lF@bP^mcP()sD z7Sjcq;!}M@vb~MxGqc%m9$s)q?Ym9O|NL4P_q~~yDZi5Y%<{WZ^p@diydhAWOO!h- zwb|ol+9nHW`h7*R@BDT#)9=UJ?Xn5mVD)9@tjK-7|Ll=45yxDLyKJZoT}qk=52u<^Hd?u;)BB|5LxMW(d(t#oydOnReadk(9TP{g<4%(D5t91JoQ)0=X@kY|L3f0}&VeNl+uA|Vu9Txm|dub!)c@1Ea`G=%Vq=e<4+tT_-#`OW4s}=ue$%$ zE_VP$d9~9-F!^E02i7NSG==dmMbNv&U|M>^^ z_t*Q$xSux*3g)xM7vJHz&$;X-Nqx$NT7hOxiD1wzkR`ueHNVvKOVzh;C4^j7b3==p za*MMa@$14boP}9+GUW@4gUy32asx?#FD>T@_*MSHZmpJ+cs>X-sXE3!G%P_D0!B|K z<>|Z}jy8k~U679haz?;|u&93E?UxDX$;u2S9qi~pdwV9OJl+KLiXz!-&~ya~>8FgL zia=D_;9gT0j4sd0vQ#FHm~|w%ip8xdAn~{&dfo^$TZ)h+?eso;-9aySko#POZ=u!8 zP#$r)SJ`>veo(e&H#=G(o4Pk9o3*wBMnvI;wh2s18xB8H9`tryWD7B)T!Fv^UZfiV z_ZzKeh{x^^8}wOlCY4FXuX{D=yOUo>$%695h%k$NE;_KQZ+^Ljvx_Zm$;&2NWHE&p zDpKy{H>eo;BXlgb9HE#zgz_(pv@rMkbNKBz_#O?iVOf>@`uB}J7zdS!qWk!fX}oJv zK}F;?JsDx|@OkrHl*GxdqPo^m4BQvE44nX*N9~u>R(eA;C=FZ^y;1Nz>})x-uC$sj zEou)(ul3cd$nQDp|8SF|^R48jvs6f5O+laxn%6!*PLK#@&q0cg`Rh$8r|;%7NXEm% z<7%Mj{RMoaQS^e_eRZib^wr6|lwH&fBV)g}{t4g1@o%bGMs^?4Hec@}n#e^TEcfwF z&F7LzZ`!0wq?mqrcsTKyN22#g(((A~dQ@DT*&&){EJTs(qYm(J4b^I}Vuh(f{| z(SGND(Wom_TaMQYlkc~jT5qMPq&|CCxjd81zb}%4Uc$~gk6D)(jajexWH&I}sN3M1 zzrr%Sp{=v>ALC2KNw&|tOgXj&cI4%?yt&}`Q5&OSyZr6L%}A`yFb;mVeEe*T?2@IcqND&5K6NsDp_w#vSuABxoL*O;1|JUFPsS zT4$x)q^t;j36UTG9HVpn!}fV!(B~uzZa8wRPcZW=1F6Qk;x(DCVd&}S_zO^1E(bZ* z6ROj_Z+i``+xNunh!c|)v$K}a#Js~A=lH){i~)T}SJMqY<&J|I3t0uuv3((b0JMZs zI;^*+u=c)uk!NNu|C41%m4V2Jv%m6=1BvXL+?OT>W`~sKZTh4M}0Bl7bUJ=3r54$atPw=BSuML^EL^oLi zrp8V%@-bxll2Zvddev+?d4S}q@zZR8+ zl+K3t%-B5~O}-aozMAGdjJPkktBYH~l;h^obkI;e$v`BCL_%n0)wc8MfSbDw=f%GU zIvUyvcpKNpeutGGUu{iWf8{y%$l)8hK>z;Xe$f_KT1$=A9K26htT#ldb4I+^OdwFtG-R|ajsEv_h|<=}lH>U#^`EMW?~;v?nt^z6N72s*ZWT6QcdgFk;}8+bt{ z3I(jlljXw4H(q#nsJA(g?48_ zjtL7DDRaz7Z83qT&m>Em>RT<2qKr`F0iGZhE0T4Qdnly6h-a0M| zWJ_~P!OFylpMs2}q4;))sDmX5Ms!|D9OcjJ?`tk0ZNe)k?>R?Sw=2v5(} z4P|**=Y%(fmk(j{?2StyesP@KX_Y6DKSoenjxkfAN}#SxiQyGUO|e%W2R6qyL5eQv z=`lg=uE=OMxa=gZmX_rXQRpO;NuxNvailUx(3tSd@{wpmXSFReC2)6Y0JQhV*`UwO zm0F4yv}__O2lkj+G>llZXjPK&OBB=9!oYJoZB@M;a>=x)&*pYsOvJ{?NT5cjjY6HG z(q=gv%fcv(fJ2Gb)+KcIOXIwFt#4ZD5NNLE{8n->-j^EO{-18z#m%JcK{FVT;q1K+i3w8yEHI1Ajz zKU1LP;8e(3nlWMBh?PNN^j5KRDNCF81onoQ7G{aQIZeGoqkX_Zlwda)8igL66BLVF zJ+3(-&%?wx-}n@V$ASy<&&ijZ*Zt#PCqglItd$>*dGWb@E>C+W{){5~zzK#t!iOcx zLIhf*hoyNv%!Od*!f9M~1Av)}KhU%!I`aoK{Sag+^yh{fv59(U-f++pJ)SQQD_ zAxIyhL1Hep7yD{T<-*AK`fW&6AH(?J0O5|fb2WRLV%&3U!mXDq-!?j83~Q|-msx3{ zLQ9SG^z?d-4*(J7Q&u&lrC}iNT{OZkF_&vD@$%d9pLbGQEI~M!u#1!IGinRRP_A1_ zzNG8N!JP{?yry;ZBGYS$LWha#$PNnM19^uYVG$@>3RPaL#4RXb@S#;PG0iqU-4r!HSaecaF ztDcqWfoMYtPI8nn+&~KcF&7o*08y+buPYD9$~0BsT?=nXq}y+a>}zN{?QE^9>+9hh zbHcgQYj8ALXeHmK~hca^+3*}MR zF?PotY70RME8yVGT}Al$d5RnTaNoh_i6Yl)80}=t)qVCPab&Rdw4--n$NrA@)4Ps-`tvrBg?X?;do%1=$Oo zRKlrzg?&jEUHX+W6!7a|>=LyLpR1_xmha<}@r1+eG4lsk`$Uw`^f^BSMxb7_F-x#~briFP4*zubvT;qiDf|}( z6V-;`BA-J=(qt_dCN}31`-9$TV!}Zg?vgItk!VB$9fcK`?*x z0rclEKbi}BsD2k-E?}G=S$`8o>a{G}${Bj<%a9`{%gpU(+9we``kj z^OxbSvW&^9`(}tcZIzY+m8oh$Q~naT)Vm?F5z{pwvNo_PX*% zk78iyjc(RsLHX@)`I2VoFF5ke?l39Xh6LD*$*p&RsZ>>3-rSbx;;?9S8e+6HW(S
  • + + + +
  • @@ -664,6 +669,38 @@ + + + +

    #{bundle['dataset.privateurl.tip']}

    +
    +
    + + + +
    + + + +

    #{bundle['dataset.privateurl.cannotCreate']}

    +
    + +
    +
    + + +

    #{bundle['dataset.privateurl.disableConfirmationText']}

    + + +

    #{bundle['file.deleteFileDialog.tip']}

    diff --git a/src/main/webapp/privateurl.xhtml b/src/main/webapp/privateurl.xhtml new file mode 100644 index 00000000000..e2319053f99 --- /dev/null +++ b/src/main/webapp/privateurl.xhtml @@ -0,0 +1,24 @@ + + + + + + + + + + + + + + + + + + + diff --git a/src/test/java/edu/harvard/iq/dataverse/api/DatasetsIT.java b/src/test/java/edu/harvard/iq/dataverse/api/DatasetsIT.java index 7af230e1275..c878882b0b8 100644 --- a/src/test/java/edu/harvard/iq/dataverse/api/DatasetsIT.java +++ b/src/test/java/edu/harvard/iq/dataverse/api/DatasetsIT.java @@ -1,21 +1,32 @@ package edu.harvard.iq.dataverse.api; import com.jayway.restassured.RestAssured; +import static com.jayway.restassured.RestAssured.given; import com.jayway.restassured.response.Response; import java.util.logging.Logger; import org.junit.BeforeClass; import org.junit.Test; -import org.junit.AfterClass; -import static com.jayway.restassured.RestAssured.given; +import com.jayway.restassured.path.json.JsonPath; + +import java.util.List; +import java.util.Map; +import javax.json.JsonObject; +import static javax.ws.rs.core.Response.Status.CREATED; +import static javax.ws.rs.core.Response.Status.FORBIDDEN; +import static javax.ws.rs.core.Response.Status.OK; +import static javax.ws.rs.core.Response.Status.UNAUTHORIZED; +import static javax.ws.rs.core.Response.Status.NOT_FOUND; +import static javax.ws.rs.core.Response.Status.BAD_REQUEST; +import static com.jayway.restassured.path.json.JsonPath.with; +import static edu.harvard.iq.dataverse.api.UtilIT.API_TOKEN_HTTP_HEADER; +import edu.harvard.iq.dataverse.authorization.DataverseRole; +import edu.harvard.iq.dataverse.authorization.users.PrivateUrlUser; +import java.util.UUID; import static junit.framework.Assert.assertEquals; public class DatasetsIT { private static final Logger logger = Logger.getLogger(DatasetsIT.class.getCanonicalName()); - private static String username1; - private static String apiToken1; - private static String dataverseAlias1; - private static Integer datasetId1; @BeforeClass public static void setUpClass() { @@ -25,18 +36,71 @@ public static void setUpClass() { @Test public void testCreateDataset() { - Response createUser1 = UtilIT.createRandomUser(); -// createUser1.prettyPrint(); - username1 = UtilIT.getUsernameFromResponse(createUser1); - apiToken1 = UtilIT.getApiTokenFromResponse(createUser1); + Response createUser = UtilIT.createRandomUser(); + createUser.prettyPrint(); + String username = UtilIT.getUsernameFromResponse(createUser); + String apiToken = UtilIT.getApiTokenFromResponse(createUser); + + Response createDataverseResponse = UtilIT.createRandomDataverse(apiToken); + createDataverseResponse.prettyPrint(); + String dataverseAlias = UtilIT.getAliasFromResponse(createDataverseResponse); + + Response createDatasetResponse = UtilIT.createRandomDatasetViaNativeApi(dataverseAlias, apiToken); + createDatasetResponse.prettyPrint(); + Integer datasetId = UtilIT.getDatasetIdFromResponse(createDatasetResponse); + + Response deleteDatasetResponse = UtilIT.deleteDatasetViaNativeApi(datasetId, apiToken); + deleteDatasetResponse.prettyPrint(); + assertEquals(200, deleteDatasetResponse.getStatusCode()); - Response createDataverse1Response = UtilIT.createRandomDataverse(apiToken1); - createDataverse1Response.prettyPrint(); - dataverseAlias1 = UtilIT.getAliasFromResponse(createDataverse1Response); + Response deleteDataverseResponse = UtilIT.deleteDataverse(dataverseAlias, apiToken); + deleteDataverseResponse.prettyPrint(); + assertEquals(200, deleteDataverseResponse.getStatusCode()); - Response createDataset1Response = UtilIT.createRandomDatasetViaNativeApi(dataverseAlias1, apiToken1); - createDataset1Response.prettyPrint(); - datasetId1 = UtilIT.getDatasetIdFromResponse(createDataset1Response); + Response deleteUserResponse = UtilIT.deleteUser(username); + deleteUserResponse.prettyPrint(); + assertEquals(200, deleteUserResponse.getStatusCode()); + + } + + /** + * This test requires the root dataverse to be published to pass. + */ + @Test + public void testCreatePublishDestroyDataset() { + + Response createUser = UtilIT.createRandomUser(); + createUser.prettyPrint(); + assertEquals(200, createUser.getStatusCode()); + String username = UtilIT.getUsernameFromResponse(createUser); + String apiToken = UtilIT.getApiTokenFromResponse(createUser); + Response makeSuperUser = UtilIT.makeSuperUser(username); + assertEquals(200, makeSuperUser.getStatusCode()); + + Response createDataverseResponse = UtilIT.createRandomDataverse(apiToken); + createDataverseResponse.prettyPrint(); + String dataverseAlias = UtilIT.getAliasFromResponse(createDataverseResponse); + + Response createDatasetResponse = UtilIT.createRandomDatasetViaNativeApi(dataverseAlias, apiToken); + createDatasetResponse.prettyPrint(); + Integer datasetId = JsonPath.from(createDatasetResponse.body().asString()).getInt("data.id"); + + Response publishDataverse = UtilIT.publishDataverseViaSword(dataverseAlias, apiToken); + assertEquals(200, publishDataverse.getStatusCode()); + Response publishDataset = UtilIT.publishDatasetViaNativeApi(datasetId, "major", apiToken); + assertEquals(200, publishDataset.getStatusCode()); + + Response deleteDatasetResponse = UtilIT.destroyDataset(datasetId, apiToken); + deleteDatasetResponse.prettyPrint(); + assertEquals(200, deleteDatasetResponse.getStatusCode()); + + Response deleteDataverseResponse = UtilIT.deleteDataverse(dataverseAlias, apiToken); + deleteDataverseResponse.prettyPrint(); + assertEquals(200, deleteDataverseResponse.getStatusCode()); + + Response deleteUserResponse = UtilIT.deleteUser(username); + deleteUserResponse.prettyPrint(); + assertEquals(200, deleteUserResponse.getStatusCode()); } @@ -67,26 +131,256 @@ private Response getDatasetAsDdiDto(String persistentIdentifier, String apiToken return response; } - @AfterClass - public static void tearDownClass() { - boolean disabled = false; + /** + * This test requires the root dataverse to be published to pass. + */ + @Test + public void testPrivateUrl() { + + Response createUser = UtilIT.createRandomUser(); +// createUser.prettyPrint(); + String username = UtilIT.getUsernameFromResponse(createUser); + String apiToken = UtilIT.getApiTokenFromResponse(createUser); - if (disabled) { - return; - } + Response failToCreateWhenDatasetIdNotFound = UtilIT.privateUrlCreate(Integer.MAX_VALUE, apiToken); + failToCreateWhenDatasetIdNotFound.prettyPrint(); + assertEquals(NOT_FOUND.getStatusCode(), failToCreateWhenDatasetIdNotFound.getStatusCode()); - Response deleteDatasetResponse = UtilIT.deleteDatasetViaNativeApi(datasetId1, apiToken1); - deleteDatasetResponse.prettyPrint(); - assertEquals(200, deleteDatasetResponse.getStatusCode()); + Response createDataverseResponse = UtilIT.createRandomDataverse(apiToken); + createDataverseResponse.prettyPrint(); + String dataverseAlias = UtilIT.getAliasFromResponse(createDataverseResponse); + + Response createDatasetResponse = UtilIT.createRandomDatasetViaNativeApi(dataverseAlias, apiToken); + createDatasetResponse.prettyPrint(); + Integer datasetId = JsonPath.from(createDatasetResponse.body().asString()).getInt("data.id"); + System.out.println("dataset id: " + datasetId); + + Response createContributorResponse = UtilIT.createRandomUser(); + String contributorUsername = UtilIT.getUsernameFromResponse(createContributorResponse); + String contributorApiToken = UtilIT.getApiTokenFromResponse(createContributorResponse); + UtilIT.getRoleAssignmentsOnDataverse(dataverseAlias, apiToken).prettyPrint(); + /** + * dsContributor only has AddDataset per + * scripts/api/data/role-dsContributor.json + */ + Response grantRole = UtilIT.grantRoleOnDataverse(dataverseAlias, DataverseRole.DS_CONTRIBUTOR.toString(), contributorUsername, apiToken); + grantRole.prettyPrint(); + assertEquals(OK.getStatusCode(), grantRole.getStatusCode()); + UtilIT.getRoleAssignmentsOnDataverse(dataverseAlias, apiToken).prettyPrint(); + Response contributorDoesNotHavePermissionToCreatePrivateUrl = UtilIT.privateUrlCreate(datasetId, contributorApiToken); + contributorDoesNotHavePermissionToCreatePrivateUrl.prettyPrint(); + assertEquals(UNAUTHORIZED.getStatusCode(), contributorDoesNotHavePermissionToCreatePrivateUrl.getStatusCode()); + + Response getDatasetJson = UtilIT.nativeGet(datasetId, apiToken); + getDatasetJson.prettyPrint(); + String protocol1 = JsonPath.from(getDatasetJson.getBody().asString()).getString("data.protocol"); + String authority1 = JsonPath.from(getDatasetJson.getBody().asString()).getString("data.authority"); + String identifier1 = JsonPath.from(getDatasetJson.getBody().asString()).getString("data.identifier"); + String dataset1PersistentId = protocol1 + ":" + authority1 + "/" + identifier1; + + Response uploadFileResponse = UtilIT.uploadRandomFile(dataset1PersistentId, apiToken); + uploadFileResponse.prettyPrint(); + assertEquals(CREATED.getStatusCode(), uploadFileResponse.getStatusCode()); + + Response badApiKeyEmptyString = UtilIT.privateUrlGet(datasetId, ""); + badApiKeyEmptyString.prettyPrint(); + assertEquals(UNAUTHORIZED.getStatusCode(), badApiKeyEmptyString.getStatusCode()); + Response badApiKeyDoesNotExist = UtilIT.privateUrlGet(datasetId, "junk"); + badApiKeyDoesNotExist.prettyPrint(); + assertEquals(UNAUTHORIZED.getStatusCode(), badApiKeyDoesNotExist.getStatusCode()); + Response badDatasetId = UtilIT.privateUrlGet(Integer.MAX_VALUE, apiToken); + badDatasetId.prettyPrint(); + assertEquals(NOT_FOUND.getStatusCode(), badDatasetId.getStatusCode()); + Response pristine = UtilIT.privateUrlGet(datasetId, apiToken); + pristine.prettyPrint(); + assertEquals(NOT_FOUND.getStatusCode(), pristine.getStatusCode()); + + Response createPrivateUrl = UtilIT.privateUrlCreate(datasetId, apiToken); + createPrivateUrl.prettyPrint(); + assertEquals(OK.getStatusCode(), createPrivateUrl.getStatusCode()); + + Response userWithNoRoles = UtilIT.createRandomUser(); + String userWithNoRolesApiToken = UtilIT.getApiTokenFromResponse(userWithNoRoles); + Response unAuth = UtilIT.privateUrlGet(datasetId, userWithNoRolesApiToken); + unAuth.prettyPrint(); + assertEquals(UNAUTHORIZED.getStatusCode(), unAuth.getStatusCode()); + Response shouldExist = UtilIT.privateUrlGet(datasetId, apiToken); + shouldExist.prettyPrint(); + assertEquals(OK.getStatusCode(), shouldExist.getStatusCode()); + + String tokenForPrivateUrlUser = JsonPath.from(shouldExist.body().asString()).getString("data.token"); + logger.info("privateUrlToken: " + tokenForPrivateUrlUser); + + String urlWithToken = JsonPath.from(shouldExist.body().asString()).getString("data.link"); + logger.info("URL with token: " + urlWithToken); + + assertEquals(tokenForPrivateUrlUser, urlWithToken.substring(urlWithToken.length() - UUID.randomUUID().toString().length())); + + Response getDatasetAsUserWhoClicksPrivateUrl = given() + .header(API_TOKEN_HTTP_HEADER, apiToken) + .get(urlWithToken); + String title = getDatasetAsUserWhoClicksPrivateUrl.getBody().htmlPath().getString("html.head.title"); + assertEquals("Darwin's Finches - " + dataverseAlias + " Dataverse", title); + assertEquals(OK.getStatusCode(), getDatasetAsUserWhoClicksPrivateUrl.getStatusCode()); + + Response junkPrivateUrlToken = given() + .header(API_TOKEN_HTTP_HEADER, apiToken) + .get("/privateurl.xhtml?token=" + "junk"); + assertEquals("404 Not Found", junkPrivateUrlToken.getBody().htmlPath().getString("html.head.title").substring(0, 13)); + + long roleAssignmentIdFromCreate = JsonPath.from(createPrivateUrl.body().asString()).getLong("data.roleAssignment.id"); + logger.info("roleAssignmentIdFromCreate: " + roleAssignmentIdFromCreate); + + Response badAnonLinkTokenEmptyString = UtilIT.nativeGet(datasetId, ""); + badAnonLinkTokenEmptyString.prettyPrint(); + assertEquals(UNAUTHORIZED.getStatusCode(), badAnonLinkTokenEmptyString.getStatusCode()); + + Response getWithPrivateUrlToken = UtilIT.nativeGet(datasetId, tokenForPrivateUrlUser); + assertEquals(OK.getStatusCode(), getWithPrivateUrlToken.getStatusCode()); +// getWithPrivateUrlToken.prettyPrint(); + logger.info("http://localhost:8080/privateurl.xhtml?token=" + tokenForPrivateUrlUser); + Response swordStatement = UtilIT.getSwordStatement(dataset1PersistentId, apiToken); + assertEquals(OK.getStatusCode(), swordStatement.getStatusCode()); + Integer fileId = UtilIT.getFileIdFromSwordStatementResponse(swordStatement); + Response downloadFile = UtilIT.downloadFile(fileId, tokenForPrivateUrlUser); + assertEquals(OK.getStatusCode(), downloadFile.getStatusCode()); + Response downloadFileBadToken = UtilIT.downloadFile(fileId, "junk"); + assertEquals(FORBIDDEN.getStatusCode(), downloadFileBadToken.getStatusCode()); + Response notPermittedToListRoleAssignment = UtilIT.getRoleAssignmentsOnDataset(datasetId.toString(), null, userWithNoRolesApiToken); + assertEquals(UNAUTHORIZED.getStatusCode(), notPermittedToListRoleAssignment.getStatusCode()); + Response roleAssignments = UtilIT.getRoleAssignmentsOnDataset(datasetId.toString(), null, apiToken); + roleAssignments.prettyPrint(); + assertEquals(OK.getStatusCode(), roleAssignments.getStatusCode()); + List assignments = with(roleAssignments.body().asString()).param("member", "member").getJsonObject("data.findAll { data -> data._roleAlias == member }"); + assertEquals(1, assignments.size()); + PrivateUrlUser privateUrlUser = new PrivateUrlUser(datasetId); + assertEquals("Private URL Enabled", privateUrlUser.getDisplayInfo().getTitle()); + List assigneeShouldExistForPrivateUrlUser = with(roleAssignments.body().asString()).param("assigneeString", privateUrlUser.getIdentifier()).getJsonObject("data.findAll { data -> data.assignee == assigneeString }"); + logger.info(assigneeShouldExistForPrivateUrlUser + " found for " + privateUrlUser.getIdentifier()); + assertEquals(1, assigneeShouldExistForPrivateUrlUser.size()); + Map roleAssignment = assignments.get(0); + int roleAssignmentId = (int) roleAssignment.get("id"); + logger.info("role assignment id: " + roleAssignmentId); + assertEquals(roleAssignmentIdFromCreate, roleAssignmentId); + Response revoke = UtilIT.revokeRole(dataverseAlias, roleAssignmentId, apiToken); + revoke.prettyPrint(); + assertEquals(OK.getStatusCode(), revoke.getStatusCode()); + + Response shouldNoLongerExist = UtilIT.privateUrlGet(datasetId, apiToken); + shouldNoLongerExist.prettyPrint(); + assertEquals(NOT_FOUND.getStatusCode(), shouldNoLongerExist.getStatusCode()); + + Response createPrivateUrlUnauth = UtilIT.privateUrlCreate(datasetId, userWithNoRolesApiToken); + createPrivateUrlUnauth.prettyPrint(); + assertEquals(UNAUTHORIZED.getStatusCode(), createPrivateUrlUnauth.getStatusCode()); + + Response createPrivateUrlAgain = UtilIT.privateUrlCreate(datasetId, apiToken); + createPrivateUrlAgain.prettyPrint(); + assertEquals(OK.getStatusCode(), createPrivateUrlAgain.getStatusCode()); + + Response shouldNotDeletePrivateUrl = UtilIT.privateUrlDelete(datasetId, userWithNoRolesApiToken); + shouldNotDeletePrivateUrl.prettyPrint(); + assertEquals(UNAUTHORIZED.getStatusCode(), shouldNotDeletePrivateUrl.getStatusCode()); + + Response deletePrivateUrlResponse = UtilIT.privateUrlDelete(datasetId, apiToken); + deletePrivateUrlResponse.prettyPrint(); + assertEquals(OK.getStatusCode(), deletePrivateUrlResponse.getStatusCode()); + + Response tryToDeleteAlreadyDeletedPrivateUrl = UtilIT.privateUrlDelete(datasetId, apiToken); + tryToDeleteAlreadyDeletedPrivateUrl.prettyPrint(); + assertEquals(NOT_FOUND.getStatusCode(), tryToDeleteAlreadyDeletedPrivateUrl.getStatusCode()); + + Response createPrivateUrlOnceAgain = UtilIT.privateUrlCreate(datasetId, apiToken); + createPrivateUrlOnceAgain.prettyPrint(); + assertEquals(OK.getStatusCode(), createPrivateUrlOnceAgain.getStatusCode()); + + Response tryToCreatePrivateUrlWhenExisting = UtilIT.privateUrlCreate(datasetId, apiToken); + tryToCreatePrivateUrlWhenExisting.prettyPrint(); + assertEquals(BAD_REQUEST.getStatusCode(), tryToCreatePrivateUrlWhenExisting.getStatusCode()); + + Response publishDataverse = UtilIT.publishDataverseViaSword(dataverseAlias, apiToken); + assertEquals(OK.getStatusCode(), publishDataverse.getStatusCode()); + Response publishDataset = UtilIT.publishDatasetViaSword(dataset1PersistentId, apiToken); + assertEquals(OK.getStatusCode(), publishDataset.getStatusCode()); + Response privateUrlTokenShouldBeDeletedOnPublish = UtilIT.privateUrlGet(datasetId, apiToken); + privateUrlTokenShouldBeDeletedOnPublish.prettyPrint(); + assertEquals(NOT_FOUND.getStatusCode(), privateUrlTokenShouldBeDeletedOnPublish.getStatusCode()); + + Response getRoleAssignmentsOnDatasetShouldFailUnauthorized = UtilIT.getRoleAssignmentsOnDataset(datasetId.toString(), null, userWithNoRolesApiToken); + assertEquals(UNAUTHORIZED.getStatusCode(), getRoleAssignmentsOnDatasetShouldFailUnauthorized.getStatusCode()); + Response publishingShouldHaveRemovedRoleAssignmentForPrivateUrlUser = UtilIT.getRoleAssignmentsOnDataset(datasetId.toString(), null, apiToken); + publishingShouldHaveRemovedRoleAssignmentForPrivateUrlUser.prettyPrint(); + List noAssignmentsForPrivateUrlUser = with(publishingShouldHaveRemovedRoleAssignmentForPrivateUrlUser.body().asString()).param("member", "member").getJsonObject("data.findAll { data -> data._roleAlias == member }"); + assertEquals(0, noAssignmentsForPrivateUrlUser.size()); + + Response tryToCreatePrivateUrlToPublishedVersion = UtilIT.privateUrlCreate(datasetId, apiToken); + tryToCreatePrivateUrlToPublishedVersion.prettyPrint(); + assertEquals(BAD_REQUEST.getStatusCode(), tryToCreatePrivateUrlToPublishedVersion.getStatusCode()); + + String newTitle = "I am changing the title"; + Response updatedMetadataResponse = UtilIT.updateDatasetTitleViaSword(dataset1PersistentId, newTitle, apiToken); + updatedMetadataResponse.prettyPrint(); + assertEquals(OK.getStatusCode(), updatedMetadataResponse.getStatusCode()); + + Response createPrivateUrlForPostVersionOneDraft = UtilIT.privateUrlCreate(datasetId, apiToken); + createPrivateUrlForPostVersionOneDraft.prettyPrint(); + assertEquals(OK.getStatusCode(), createPrivateUrlForPostVersionOneDraft.getStatusCode()); + + /** + * @todo Make this a more explicit delete of the draft version rather + * than the latest version which we happen to know is a draft. + */ + Response deleteDraftVersion = UtilIT.deleteLatestDatasetVersionViaSwordApi(dataset1PersistentId, apiToken); + deleteDraftVersion.prettyPrint(); + assertEquals(OK.getStatusCode(), createPrivateUrlForPostVersionOneDraft.getStatusCode()); + + Response privateUrlRoleAssignmentShouldBeGoneAfterDraftDeleted = UtilIT.getRoleAssignmentsOnDataset(datasetId.toString(), null, apiToken); + privateUrlRoleAssignmentShouldBeGoneAfterDraftDeleted.prettyPrint(); + assertEquals(false, privateUrlRoleAssignmentShouldBeGoneAfterDraftDeleted.body().asString().contains(privateUrlUser.getIdentifier())); + + String newTitleAgain = "I am changing the title again"; + Response draftCreatedAgainPostPub = UtilIT.updateDatasetTitleViaSword(dataset1PersistentId, newTitleAgain, apiToken); + draftCreatedAgainPostPub.prettyPrint(); + assertEquals(OK.getStatusCode(), draftCreatedAgainPostPub.getStatusCode()); + + /** + * Making sure the Private URL is deleted when a dataset is destroyed is + * less of an issue now that a Private URL is now effectively only a + * specialized role assignment which is already known to be deleted when + * a dataset is destroy. Still, we'll keep this test in here in case we + * switch Private URL back to being its own table in the future. + */ + Response createPrivateUrlToMakeSureItIsDeletedWithDestructionOfDataset = UtilIT.privateUrlCreate(datasetId, apiToken); + createPrivateUrlToMakeSureItIsDeletedWithDestructionOfDataset.prettyPrint(); + assertEquals(OK.getStatusCode(), createPrivateUrlToMakeSureItIsDeletedWithDestructionOfDataset.getStatusCode()); + + /** + * @todo What about deaccessioning? We can't test deaccessioning via API + * until https://github.com/IQSS/dataverse/issues/778 is worked on. If + * you deaccession a dataset, is the Private URL deleted? Probably not + * because in order to create a Private URL the dataset version must be + * a draft and for that draft to be deaccessioned it must be published + * first and publishing a version will delete the Private URL. So, we + * shouldn't need to worry about cleaning up Private URLs in the case of + * deaccessioning. + */ + Response makeSuperUser = UtilIT.makeSuperUser(username); + assertEquals(200, makeSuperUser.getStatusCode()); - Response deleteDataverse1Response = UtilIT.deleteDataverse(dataverseAlias1, apiToken1); - deleteDataverse1Response.prettyPrint(); - assertEquals(200, deleteDataverse1Response.getStatusCode()); + Response destroyDatasetResponse = UtilIT.destroyDataset(datasetId, apiToken); + destroyDatasetResponse.prettyPrint(); + assertEquals(200, destroyDatasetResponse.getStatusCode()); - Response deleteUser1Response = UtilIT.deleteUser(username1); - deleteUser1Response.prettyPrint(); - assertEquals(200, deleteUser1Response.getStatusCode()); + Response deleteDataverseResponse = UtilIT.deleteDataverse(dataverseAlias, apiToken); + deleteDataverseResponse.prettyPrint(); + assertEquals(200, deleteDataverseResponse.getStatusCode()); + Response deleteUserResponse = UtilIT.deleteUser(username); + deleteUserResponse.prettyPrint(); + assertEquals(200, deleteUserResponse.getStatusCode()); + /** + * @todo Should the Search API work with the Private URL token? + */ } } diff --git a/src/test/java/edu/harvard/iq/dataverse/api/UtilIT.java b/src/test/java/edu/harvard/iq/dataverse/api/UtilIT.java index 843cebc2e34..06da2ad4e8d 100644 --- a/src/test/java/edu/harvard/iq/dataverse/api/UtilIT.java +++ b/src/test/java/edu/harvard/iq/dataverse/api/UtilIT.java @@ -17,6 +17,7 @@ import edu.harvard.iq.dataverse.api.datadeposit.SwordConfigurationImpl; import com.jayway.restassured.path.xml.XmlPath; import org.junit.Test; +import edu.harvard.iq.dataverse.settings.SettingsServiceBean; import static com.jayway.restassured.RestAssured.given; import static com.jayway.restassured.path.xml.XmlPath.from; import static org.junit.Assert.assertEquals; @@ -363,6 +364,20 @@ static Response publishDatasetViaSword(String persistentId, String apiToken) { .post(swordConfiguration.getBaseUrlPathCurrent() + "/edit/study/" + persistentId); } + static Response publishDatasetViaNativeApi(Integer datasetId, String majorOrMinor, String apiToken) { + /** + * @todo This should be a POST rather than a GET: + * https://github.com/IQSS/dataverse/issues/2431 + * + * @todo Prevent version less than v1.0 to be published (i.e. v0.1): + * https://github.com/IQSS/dataverse/issues/2461 + */ + return given() + .header(API_TOKEN_HTTP_HEADER, apiToken) + .urlEncodingEnabled(false) + .get("/api/datasets/" + datasetId + "/actions/:publish?type=" + majorOrMinor); + } + static Response publishDataverseViaSword(String alias, String apiToken) { return given() .auth().basic(apiToken, EMPTY_STRING) @@ -381,6 +396,100 @@ static Response reindexDataset(String persistentId) { return response; } + static Response nativeGet(Integer datasetId, String apiToken) { + Response response = given() + .header(API_TOKEN_HTTP_HEADER, apiToken) + .get("/api/datasets/" + datasetId); + return response; + } + + static Response privateUrlGet(Integer datasetId, String apiToken) { + Response response = given() + .header(API_TOKEN_HTTP_HEADER, apiToken) + .get("/api/datasets/" + datasetId + "/privateUrl"); + return response; + } + + static Response privateUrlCreate(Integer datasetId, String apiToken) { + Response response = given() + .header(API_TOKEN_HTTP_HEADER, apiToken) + .post("/api/datasets/" + datasetId + "/privateUrl"); + return response; + } + + static Response privateUrlDelete(Integer datasetId, String apiToken) { + Response response = given() + .header(API_TOKEN_HTTP_HEADER, apiToken) + .delete("/api/datasets/" + datasetId + "/privateUrl"); + return response; + } + + static Response search(String query, String apiToken) { + return given() + .header(API_TOKEN_HTTP_HEADER, apiToken) + .get("/api/search?q=" + query); + } + + static Response indexClear() { + return given() + .get("/api/admin/index/clear"); + } + + static Response index() { + return given() + .get("/api/admin/index"); + } + + static Response enableSetting(SettingsServiceBean.Key settingKey) { + Response response = given().body("true").when().put("/api/admin/settings/" + settingKey); + return response; + } + + static Response deleteSetting(SettingsServiceBean.Key settingKey) { + Response response = given().when().delete("/api/admin/settings/" + settingKey); + return response; + } + + static Response getRoleAssignmentsOnDataverse(String dataverseAliasOrId, String apiToken) { + String url = "/api/dataverses/" + dataverseAliasOrId + "/assignments"; + System.out.println("URL: " + url); + return given() + .header(API_TOKEN_HTTP_HEADER, apiToken) + .get(url); + } + + static Response getRoleAssignmentsOnDataset(String datasetId, String persistentId, String apiToken) { + String url = "/api/datasets/" + datasetId + "/assignments"; + System.out.println("URL: " + url); + return given() + .header(API_TOKEN_HTTP_HEADER, apiToken) + .get(url); + } + + static Response grantRoleOnDataverse(String definitionPoint, String role, String roleAssignee, String apiToken) { + JsonObjectBuilder roleBuilder = Json.createObjectBuilder(); + roleBuilder.add("assignee", "@" + roleAssignee); + roleBuilder.add("role", role); + JsonObject roleObject = roleBuilder.build(); + logger.info("Granting role on dataverse \"" + definitionPoint + "\": " + role + "... " + roleObject); + return given() + .body(roleObject.toString()).contentType(ContentType.JSON) + .post("api/dataverses/" + definitionPoint + "/assignments?key=" + apiToken); + } + + static Response grantRoleOnDataset(String definitionPoint, String role, String roleAssignee, String apiToken) { + logger.info("Granting role on dataset \"" + definitionPoint + "\": " + role); + return given() + .body("@" + roleAssignee) + .post("api/datasets/" + definitionPoint + "/assignments?key=" + apiToken); + } + + static Response revokeRole(String definitionPoint, long doomed, String apiToken) { + return given() + .header(API_TOKEN_HTTP_HEADER, apiToken) + .delete("api/dataverses/" + definitionPoint + "/assignments/" + doomed); + } + @Test public void testGetFileIdFromSwordStatementWithNoFiles() { String swordStatementWithNoFiles = "\n" diff --git a/src/test/java/edu/harvard/iq/dataverse/engine/TestCommandContext.java b/src/test/java/edu/harvard/iq/dataverse/engine/TestCommandContext.java index 10a7edfdf3d..63f32f6ebf8 100644 --- a/src/test/java/edu/harvard/iq/dataverse/engine/TestCommandContext.java +++ b/src/test/java/edu/harvard/iq/dataverse/engine/TestCommandContext.java @@ -5,11 +5,13 @@ import edu.harvard.iq.dataverse.authorization.AuthenticationServiceBean; import edu.harvard.iq.dataverse.authorization.groups.impl.explicit.ExplicitGroupServiceBean; import edu.harvard.iq.dataverse.engine.command.CommandContext; +import edu.harvard.iq.dataverse.privateurl.PrivateUrlServiceBean; import edu.harvard.iq.dataverse.search.IndexServiceBean; import edu.harvard.iq.dataverse.search.SearchServiceBean; import edu.harvard.iq.dataverse.search.SolrIndexServiceBean; import edu.harvard.iq.dataverse.search.savedsearch.SavedSearchServiceBean; import edu.harvard.iq.dataverse.settings.SettingsServiceBean; +import edu.harvard.iq.dataverse.util.SystemConfig; import javax.persistence.EntityManager; /** @@ -163,5 +165,15 @@ public RoleAssigneeServiceBean roleAssignees() { public UserNotificationServiceBean notifications() { return null; } + + @Override + public SystemConfig systemConfig() { + return null; + } + + @Override + public PrivateUrlServiceBean privateUrl() { + return null; + } } diff --git a/src/test/java/edu/harvard/iq/dataverse/engine/command/impl/CreatePrivateUrlCommandTest.java b/src/test/java/edu/harvard/iq/dataverse/engine/command/impl/CreatePrivateUrlCommandTest.java new file mode 100644 index 00000000000..a71ad732d8d --- /dev/null +++ b/src/test/java/edu/harvard/iq/dataverse/engine/command/impl/CreatePrivateUrlCommandTest.java @@ -0,0 +1,162 @@ +package edu.harvard.iq.dataverse.engine.command.impl; + +import edu.harvard.iq.dataverse.Dataset; +import edu.harvard.iq.dataverse.DatasetVersion; +import edu.harvard.iq.dataverse.DataverseRoleServiceBean; +import edu.harvard.iq.dataverse.RoleAssignment; +import edu.harvard.iq.dataverse.authorization.DataverseRole; +import edu.harvard.iq.dataverse.authorization.users.PrivateUrlUser; +import edu.harvard.iq.dataverse.engine.TestCommandContext; +import edu.harvard.iq.dataverse.engine.TestDataverseEngine; +import edu.harvard.iq.dataverse.engine.command.exception.CommandException; +import edu.harvard.iq.dataverse.privateurl.PrivateUrl; +import edu.harvard.iq.dataverse.privateurl.PrivateUrlServiceBean; +import edu.harvard.iq.dataverse.util.SystemConfig; +import java.util.ArrayList; +import java.util.List; +import org.junit.Before; +import org.junit.Test; +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertNull; + +public class CreatePrivateUrlCommandTest { + + private TestDataverseEngine testEngine; + private Dataset dataset; + private final Long privateUrlAlreadyExists = 1l; + private final Long latestVersionIsNotDraft = 2l; + private final Long createDatasetLong = 3l; + + @Before + public void setUp() { + dataset = new Dataset(); + testEngine = new TestDataverseEngine(new TestCommandContext() { + @Override + public PrivateUrlServiceBean privateUrl() { + return new PrivateUrlServiceBean() { + + @Override + public PrivateUrl getPrivateUrlFromDatasetId(long datasetId) { + if (datasetId == privateUrlAlreadyExists) { + Dataset dataset = new Dataset(); + dataset.setId(privateUrlAlreadyExists); + String token = null; + PrivateUrlUser privateUrlUser = new PrivateUrlUser(datasetId); + RoleAssignment roleAssignment = new RoleAssignment(null, privateUrlUser, dataset, token); + return new PrivateUrl(roleAssignment, dataset, "FIXME"); + } else if (datasetId == latestVersionIsNotDraft) { + return null; + } else { + return null; + } + } + + }; + } + + @Override + public DataverseRoleServiceBean roles() { + return new DataverseRoleServiceBean() { + + @Override + public DataverseRole findBuiltinRoleByAlias(String alias) { + return new DataverseRole(); + } + + @Override + public RoleAssignment save(RoleAssignment assignment) { + // no-op + return assignment; + } + + }; + } + + @Override + public SystemConfig systemConfig() { + return new SystemConfig() { + + @Override + public String getDataverseSiteUrl() { + return "https://dataverse.example.edu"; + } + + }; + + } + + } + ); + } + + @Test + public void testDatasetNull() { + dataset = null; + String expected = "Can't create Private URL. Dataset is null."; + String actual = null; + PrivateUrl privateUrl = null; + try { + privateUrl = testEngine.submit(new CreatePrivateUrlCommand(null, dataset)); + } catch (CommandException ex) { + actual = ex.getMessage(); + } + assertEquals(expected, actual); + assertNull(privateUrl); + } + + @Test + public void testAlreadyExists() { + dataset.setId(privateUrlAlreadyExists); + String expected = "Private URL already exists for dataset id " + privateUrlAlreadyExists + "."; + String actual = null; + PrivateUrl privateUrl = null; + try { + privateUrl = testEngine.submit(new CreatePrivateUrlCommand(null, dataset)); + } catch (CommandException ex) { + actual = ex.getMessage(); + } + assertEquals(expected, actual); + assertNull(privateUrl); + } + + @Test + public void testAttemptCreatePrivateUrlOnNonDraft() { + dataset = new Dataset(); + List versions = new ArrayList<>(); + DatasetVersion datasetVersion = new DatasetVersion(); + datasetVersion.setVersionState(DatasetVersion.VersionState.RELEASED); + versions.add(datasetVersion); + dataset.setVersions(versions); + dataset.setId(latestVersionIsNotDraft); + String expected = "Can't create Private URL because the latest version of dataset id " + latestVersionIsNotDraft + " is not a draft."; + String actual = null; + PrivateUrl privateUrl = null; + try { + privateUrl = testEngine.submit(new CreatePrivateUrlCommand(null, dataset)); + } catch (CommandException ex) { + actual = ex.getMessage(); + } + assertEquals(expected, actual); + assertNull(privateUrl); + } + + @Test + public void testCreatePrivateUrlSuccessfully() throws CommandException { + dataset = new Dataset(); + dataset.setId(createDatasetLong); + PrivateUrl privateUrl = testEngine.submit(new CreatePrivateUrlCommand(null, dataset)); + assertNotNull(privateUrl); + assertNotNull(privateUrl.getDataset()); + assertNotNull(privateUrl.getRoleAssignment()); + PrivateUrlUser expectedUser = new PrivateUrlUser(dataset.getId()); + assertEquals(expectedUser.getIdentifier(), privateUrl.getRoleAssignment().getAssigneeIdentifier()); + assertEquals(expectedUser.isSuperuser(), false); + assertEquals(expectedUser.isBuiltInUser(), false); + assertEquals(expectedUser.isAuthenticated(), false); + assertEquals(expectedUser.getDisplayInfo().getTitle(), "Private URL Enabled"); + assertNotNull(privateUrl.getToken()); + assertEquals("https://dataverse.example.edu/privateurl.xhtml?token=" + privateUrl.getToken(), privateUrl.getLink()); + } + +} diff --git a/src/test/java/edu/harvard/iq/dataverse/engine/command/impl/DeletePrivateUrlCommandTest.java b/src/test/java/edu/harvard/iq/dataverse/engine/command/impl/DeletePrivateUrlCommandTest.java new file mode 100644 index 00000000000..74c8c269b4b --- /dev/null +++ b/src/test/java/edu/harvard/iq/dataverse/engine/command/impl/DeletePrivateUrlCommandTest.java @@ -0,0 +1,107 @@ +package edu.harvard.iq.dataverse.engine.command.impl; + +import edu.harvard.iq.dataverse.Dataset; +import edu.harvard.iq.dataverse.DataverseRoleServiceBean; +import edu.harvard.iq.dataverse.DvObject; +import edu.harvard.iq.dataverse.RoleAssignment; +import edu.harvard.iq.dataverse.authorization.RoleAssignee; +import edu.harvard.iq.dataverse.authorization.users.PrivateUrlUser; +import edu.harvard.iq.dataverse.engine.TestCommandContext; +import edu.harvard.iq.dataverse.engine.TestDataverseEngine; +import edu.harvard.iq.dataverse.engine.command.exception.CommandException; +import edu.harvard.iq.dataverse.privateurl.PrivateUrl; +import edu.harvard.iq.dataverse.privateurl.PrivateUrlServiceBean; +import java.util.ArrayList; +import java.util.List; +import org.junit.Before; +import org.junit.Test; +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNull; + +public class DeletePrivateUrlCommandTest { + + private TestDataverseEngine testEngine; + Dataset dataset; + private final Long noPrivateUrlToDelete = 1l; + private final Long hasPrivateUrlToDelete = 2l; + + @Before + public void setUp() { + testEngine = new TestDataverseEngine(new TestCommandContext() { + @Override + public PrivateUrlServiceBean privateUrl() { + return new PrivateUrlServiceBean() { + + @Override + public PrivateUrl getPrivateUrlFromDatasetId(long datasetId) { + if (datasetId == noPrivateUrlToDelete) { + return null; + } else if (datasetId == hasPrivateUrlToDelete) { + Dataset dataset = new Dataset(); + dataset.setId(hasPrivateUrlToDelete); + String token = null; + PrivateUrlUser privateUrlUser = new PrivateUrlUser(datasetId); + RoleAssignment roleAssignment = new RoleAssignment(null, privateUrlUser, dataset, token); + return new PrivateUrl(roleAssignment, dataset, "FIXME"); + } else { + return null; + } + } + + }; + } + + @Override + public DataverseRoleServiceBean roles() { + return new DataverseRoleServiceBean() { + @Override + public List directRoleAssignments(RoleAssignee roas, DvObject dvo) { + RoleAssignment roleAssignment = new RoleAssignment(); + List list = new ArrayList<>(); + list.add(roleAssignment); + return list; + } + + @Override + public void revoke(RoleAssignment ra) { + // no-op + } + + }; + } + + }); + } + + @Test + public void testDatasetNull() { + dataset = null; + String expected = "Can't delete Private URL. Dataset is null."; + String actual = null; + try { + testEngine.submit(new DeletePrivateUrlCommand(null, dataset)); + } catch (CommandException ex) { + actual = ex.getMessage(); + } + assertEquals(expected, actual); + } + + @Test + public void testSuccessfulDelete() { + dataset = new Dataset(); + dataset.setId(hasPrivateUrlToDelete); + String actual = null; + try { + testEngine.submit(new DeletePrivateUrlCommand(null, dataset)); + } catch (CommandException ex) { + actual = ex.getMessage(); + } + assertNull(actual); + /** + * @todo How would we confirm that the role assignement is actually + * gone? Really all we're testing above is that there was no + * IllegalCommandException from submitting the command. + */ + } + +} diff --git a/src/test/java/edu/harvard/iq/dataverse/engine/command/impl/GetPrivateUrlCommandTest.java b/src/test/java/edu/harvard/iq/dataverse/engine/command/impl/GetPrivateUrlCommandTest.java new file mode 100644 index 00000000000..b5019807ac1 --- /dev/null +++ b/src/test/java/edu/harvard/iq/dataverse/engine/command/impl/GetPrivateUrlCommandTest.java @@ -0,0 +1,70 @@ +package edu.harvard.iq.dataverse.engine.command.impl; + +import edu.harvard.iq.dataverse.Dataset; +import edu.harvard.iq.dataverse.engine.TestCommandContext; +import edu.harvard.iq.dataverse.engine.TestDataverseEngine; +import edu.harvard.iq.dataverse.privateurl.PrivateUrl; +import edu.harvard.iq.dataverse.privateurl.PrivateUrlServiceBean; +import org.junit.After; +import org.junit.AfterClass; +import org.junit.Assert; +import static org.junit.Assert.assertNull; +import org.junit.Before; +import org.junit.BeforeClass; +import org.junit.Test; + +public class GetPrivateUrlCommandTest { + + private TestDataverseEngine testEngine; + Dataset dataset; + + public GetPrivateUrlCommandTest() { + } + + @BeforeClass + public static void setUpClass() { + } + + @AfterClass + public static void tearDownClass() { + } + + @Before + public void setUp() { + testEngine = new TestDataverseEngine(new TestCommandContext() { + + @Override + public PrivateUrlServiceBean privateUrl() { + return new PrivateUrlServiceBean() { + + @Override + public PrivateUrl getPrivateUrlFromDatasetId(long datasetId) { + return null; + } + + }; + } + + }); + } + + @After + public void tearDown() { + } + + @Test + public void testDatasetWithoutAnId() throws Exception { + dataset = new Dataset(); + PrivateUrl privateUrl = testEngine.submit(new GetPrivateUrlCommand(null, dataset)); + assertNull(privateUrl); + } + + @Test + public void testDatasetWithAnId() throws Exception { + dataset = new Dataset(); + dataset.setId(42l); + PrivateUrl privateUrl = testEngine.submit(new GetPrivateUrlCommand(null, dataset)); + assertNull(privateUrl); + } + +} diff --git a/src/test/java/edu/harvard/iq/dataverse/privateurl/PrivateUrlUtilTest.java b/src/test/java/edu/harvard/iq/dataverse/privateurl/PrivateUrlUtilTest.java new file mode 100644 index 00000000000..0544b66bb64 --- /dev/null +++ b/src/test/java/edu/harvard/iq/dataverse/privateurl/PrivateUrlUtilTest.java @@ -0,0 +1,355 @@ +package edu.harvard.iq.dataverse.privateurl; + +import edu.harvard.iq.dataverse.Dataset; +import edu.harvard.iq.dataverse.DatasetVersion; +import edu.harvard.iq.dataverse.Dataverse; +import edu.harvard.iq.dataverse.DvObject; +import edu.harvard.iq.dataverse.RoleAssignment; +import edu.harvard.iq.dataverse.authorization.DataverseRole; +import edu.harvard.iq.dataverse.authorization.RoleAssignee; +import edu.harvard.iq.dataverse.authorization.users.GuestUser; +import edu.harvard.iq.dataverse.authorization.users.PrivateUrlUser; +import edu.harvard.iq.dataverse.engine.command.exception.CommandException; +import edu.harvard.iq.dataverse.engine.command.impl.CreatePrivateUrlCommand; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.List; +import org.junit.Assert; +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNotNull; +import org.junit.Test; +import static org.junit.Assert.assertNull; +import org.junit.Before; + +public class PrivateUrlUtilTest { + + @Before + public void setUp() { + new PrivateUrlUtil(); + } + + @Test + public void testIdentifier2roleAssignee() { + RoleAssignee returnValueFromEmptyString = null; + try { + returnValueFromEmptyString = PrivateUrlUtil.identifier2roleAssignee(""); + } catch (Exception ex) { + assertEquals(ex.getClass(), IllegalArgumentException.class); + assertEquals(ex.getMessage(), "Could not find dataset id in ''"); + } + assertNull(returnValueFromEmptyString); + + RoleAssignee returnValueFromNonColon = null; + String peteIdentifier = "@pete"; + try { + returnValueFromNonColon = PrivateUrlUtil.identifier2roleAssignee(peteIdentifier); + } catch (Exception ex) { + assertEquals(ex.getClass(), IllegalArgumentException.class); + assertEquals(ex.getMessage(), "Could not find dataset id in '" + peteIdentifier + "'"); + } + assertNull(returnValueFromNonColon); + + RoleAssignee returnValueFromNonNumber = null; + String nonNumberIdentifier = PrivateUrlUser.PREFIX + "nonNumber"; + try { + returnValueFromNonNumber = PrivateUrlUtil.identifier2roleAssignee(nonNumberIdentifier); + } catch (Exception ex) { + assertEquals(ex.getClass(), IllegalArgumentException.class); + assertEquals(ex.getMessage(), "Could not find dataset id in '" + nonNumberIdentifier + "'"); + } + assertNull(returnValueFromNonNumber); + + RoleAssignee returnFromValidIdentifier = null; + String validIdentifier = PrivateUrlUser.PREFIX + 42; + returnFromValidIdentifier = PrivateUrlUtil.identifier2roleAssignee(validIdentifier); + assertNotNull(returnFromValidIdentifier); + assertEquals(":privateUrl42", returnFromValidIdentifier.getIdentifier()); + assertEquals("Private URL Enabled", returnFromValidIdentifier.getDisplayInfo().getTitle()); + Assert.assertTrue(returnFromValidIdentifier instanceof PrivateUrlUser); + PrivateUrlUser privateUrlUser42 = (PrivateUrlUser) returnFromValidIdentifier; + assertEquals(42, privateUrlUser42.getDatasetId()); + + } + + @Test + public void testGetDatasetFromRoleAssignmentNullRoleAssignment() { + assertNull(PrivateUrlUtil.getDatasetFromRoleAssignment(null)); + } + + @Test + public void testGetDatasetFromRoleAssignmentNullDefinitionPoint() { + DataverseRole aRole = null; + PrivateUrlUser privateUrlUser = new PrivateUrlUser(42); + RoleAssignee anAssignee = privateUrlUser; + DvObject nullDefinitionPoint = null; + String privateUrlToken = null; + RoleAssignment ra = new RoleAssignment(aRole, anAssignee, nullDefinitionPoint, privateUrlToken); + assertNull(PrivateUrlUtil.getDatasetFromRoleAssignment(ra)); + } + + @Test + public void testGetDatasetFromRoleAssignmentNonDataset() { + DataverseRole aRole = null; + PrivateUrlUser privateUrlUser = new PrivateUrlUser(42); + RoleAssignee anAssignee = privateUrlUser; + DvObject nonDataset = new Dataverse(); + String privateUrlToken = null; + RoleAssignment ra = new RoleAssignment(aRole, anAssignee, nonDataset, privateUrlToken); + assertNull(PrivateUrlUtil.getDatasetFromRoleAssignment(ra)); + } + + @Test + public void testGetDatasetFromRoleAssignmentSuccess() { + DataverseRole aRole = null; + PrivateUrlUser privateUrlUser = new PrivateUrlUser(42); + RoleAssignee anAssignee = privateUrlUser; + DvObject dataset = new Dataset(); + String privateUrlToken = null; + RoleAssignment ra = new RoleAssignment(aRole, anAssignee, dataset, privateUrlToken); + assertNotNull(PrivateUrlUtil.getDatasetFromRoleAssignment(ra)); + assertEquals(":privateUrl42", ra.getAssigneeIdentifier()); + } + + @Test + public void testGetDraftDatasetVersionFromRoleAssignmentNullRoleAssignement() { + assertNull(PrivateUrlUtil.getDraftDatasetVersionFromRoleAssignment(null)); + } + + @Test + public void testGetDraftDatasetVersionFromRoleAssignmentNullDataset() { + DataverseRole aRole = null; + PrivateUrlUser privateUrlUser = new PrivateUrlUser(42); + RoleAssignee anAssignee = privateUrlUser; + DvObject dataset = null; + String privateUrlToken = null; + RoleAssignment ra = new RoleAssignment(aRole, anAssignee, dataset, privateUrlToken); + DatasetVersion datasetVersion = PrivateUrlUtil.getDraftDatasetVersionFromRoleAssignment(ra); + assertNull(datasetVersion); + } + + @Test + public void testGetDraftDatasetVersionFromRoleAssignmentLastestIsNotDraft() { + DataverseRole aRole = null; + PrivateUrlUser privateUrlUser = new PrivateUrlUser(42); + RoleAssignee anAssignee = privateUrlUser; + Dataset dataset = new Dataset(); + List versions = new ArrayList<>(); + DatasetVersion datasetVersionIn = new DatasetVersion(); + datasetVersionIn.setVersionState(DatasetVersion.VersionState.RELEASED); + versions.add(datasetVersionIn); + dataset.setVersions(versions); + String privateUrlToken = null; + RoleAssignment ra = new RoleAssignment(aRole, anAssignee, dataset, privateUrlToken); + DatasetVersion datasetVersionOut = PrivateUrlUtil.getDraftDatasetVersionFromRoleAssignment(ra); + assertNull(datasetVersionOut); + } + + @Test + public void testGetDraftDatasetVersionFromRoleAssignmentSuccess() { + DataverseRole aRole = null; + PrivateUrlUser privateUrlUser = new PrivateUrlUser(42); + RoleAssignee anAssignee = privateUrlUser; + Dataset dataset = new Dataset(); + List versions = new ArrayList<>(); + DatasetVersion datasetVersionIn = new DatasetVersion(); + datasetVersionIn.setVersionState(DatasetVersion.VersionState.DRAFT); + versions.add(datasetVersionIn); + dataset.setVersions(versions); + String privateUrlToken = null; + RoleAssignment ra = new RoleAssignment(aRole, anAssignee, dataset, privateUrlToken); + DatasetVersion datasetVersionOut = PrivateUrlUtil.getDraftDatasetVersionFromRoleAssignment(ra); + assertNotNull(datasetVersionOut); + assertEquals(":privateUrl42", ra.getAssigneeIdentifier()); + } + + @Test + public void testGetUserFromRoleAssignmentNull() { + assertNull(PrivateUrlUtil.getPrivateUrlUserFromRoleAssignment(null)); + } + + @Test + public void testGetUserFromRoleAssignmentNonDataset() { + DataverseRole aRole = null; + PrivateUrlUser privateUrlUserIn = new PrivateUrlUser(42); + RoleAssignee anAssignee = privateUrlUserIn; + DvObject nonDataset = new Dataverse(); + nonDataset.setId(123l); + String privateUrlToken = null; + RoleAssignment ra = new RoleAssignment(aRole, anAssignee, nonDataset, privateUrlToken); + PrivateUrlUser privateUrlUserOut = PrivateUrlUtil.getPrivateUrlUserFromRoleAssignment(ra); + assertNull(privateUrlUserOut); + } + + @Test + public void testGetUserFromRoleAssignmentSucess() { + DataverseRole aRole = null; + PrivateUrlUser privateUrlUserIn = new PrivateUrlUser(42); + RoleAssignee anAssignee = privateUrlUserIn; + DvObject dataset = new Dataset(); + dataset.setId(123l); + String privateUrlToken = null; + RoleAssignment ra = new RoleAssignment(aRole, anAssignee, dataset, privateUrlToken); + PrivateUrlUser privateUrlUserOut = PrivateUrlUtil.getPrivateUrlUserFromRoleAssignment(ra); + assertNotNull(privateUrlUserOut); + } + + @Test + public void testGetPrivateUrlRedirectDataFail() { + DataverseRole aRole = null; + long datasetId = 42; + PrivateUrlUser privateUrlUser = new PrivateUrlUser(datasetId); + RoleAssignee anAssignee = privateUrlUser; + Dataset dataset = new Dataset(); + String privateUrlToken = null; + RoleAssignment ra = new RoleAssignment(aRole, anAssignee, dataset, privateUrlToken); + ra.setDefinitionPoint(null); + PrivateUrlRedirectData privateUrlRedirectData = null; + privateUrlRedirectData = PrivateUrlUtil.getPrivateUrlRedirectData(ra); + assertNull(privateUrlRedirectData); + } + + @Test + public void testGetPrivateUrlRedirectDataSuccess() { + DataverseRole aRole = null; + long datasetId = 42; + PrivateUrlUser privateUrlUser = new PrivateUrlUser(datasetId); + RoleAssignee anAssignee = privateUrlUser; + Dataset dataset = new Dataset(); + dataset.setProtocol("doi"); + dataset.setAuthority("10.5072/FK2"); + dataset.setIdentifier("3L33T"); + dataset.setId(datasetId); + String privateUrlToken = null; + RoleAssignment ra = new RoleAssignment(aRole, anAssignee, dataset, privateUrlToken); + PrivateUrlRedirectData privateUrlRedirectData = PrivateUrlUtil.getPrivateUrlRedirectData(ra); + assertNotNull(privateUrlRedirectData); + assertEquals("/dataset.xhtml?persistentId=doi:10.5072/FK2/3L33T&version=DRAFT", privateUrlRedirectData.getDraftDatasetPageToBeRedirectedTo()); + assertEquals(privateUrlUser.getIdentifier(), privateUrlRedirectData.getPrivateUrlUser().getIdentifier()); + } + + @Test + public void testGetDraftUrlDraftNull() { + assertEquals("UNKNOWN", PrivateUrlUtil.getDraftUrl(null)); + } + + @Test + public void testGetDraftUrlDatasetNull() { + DatasetVersion draft = new DatasetVersion(); + draft.setDataset(null); + assertEquals("UNKNOWN", PrivateUrlUtil.getDraftUrl(draft)); + } + + @Test + public void testGetDraftUrlNoGlobalId() throws Exception { + DatasetVersion draft = new DatasetVersion(); + Dataset dataset = new Dataset(); + draft.setDataset(dataset); + assertEquals("UNKNOWN", PrivateUrlUtil.getDraftUrl(draft)); + } + + @Test + public void testGetDraftUrlSuccess() throws Exception { + DatasetVersion draft = new DatasetVersion(); + Dataset dataset = new Dataset(); + dataset.setProtocol("doi"); + dataset.setAuthority("10.5072/FK2"); + dataset.setIdentifier("3L33T"); + draft.setDataset(dataset); + assertEquals("/dataset.xhtml?persistentId=doi:10.5072/FK2/3L33T&version=DRAFT", PrivateUrlUtil.getDraftUrl(draft)); + } + + @Test + public void testGetPrivateUrlRedirectDataConstructor() throws Exception { + Exception exception1 = null; + try { + PrivateUrlRedirectData privateUrlRedirectData = new PrivateUrlRedirectData(null, null); + } catch (Exception ex) { + exception1 = ex; + } + assertNotNull(exception1); + Exception exception2 = null; + try { + PrivateUrlUser privateUrlUser = new PrivateUrlUser(42); + PrivateUrlRedirectData privateUrlRedirectData = new PrivateUrlRedirectData(privateUrlUser, null); + } catch (Exception ex) { + exception2 = ex; + } + assertNotNull(exception2); + } + + @Test + public void testGetPrivateUrlFromRoleAssignmentNoSiteUrl() { + String dataverseSiteUrl = null; + RoleAssignment ra = null; + PrivateUrl privateUrl = PrivateUrlUtil.getPrivateUrlFromRoleAssignment(ra, dataverseSiteUrl); + assertNull(privateUrl); + } + + @Test + public void testGetPrivateUrlFromRoleAssignmentDatasetNull() { + String dataverseSiteUrl = "https://dataverse.example.edu"; + DataverseRole aRole = null; + PrivateUrlUser privateUrlUser = new PrivateUrlUser(42); + RoleAssignee anAssignee = privateUrlUser; + DvObject dataset = null; + String privateUrlToken = null; + RoleAssignment ra = new RoleAssignment(aRole, anAssignee, dataset, privateUrlToken); + PrivateUrl privateUrl = PrivateUrlUtil.getPrivateUrlFromRoleAssignment(ra, dataverseSiteUrl); + assertNull(privateUrl); + } + + @Test + public void testGetPrivateUrlFromRoleAssignmentSuccess() { + String dataverseSiteUrl = "https://dataverse.example.edu"; + DataverseRole aRole = null; + PrivateUrlUser privateUrlUser = new PrivateUrlUser(42); + RoleAssignee anAssignee = privateUrlUser; + DvObject dataset = new Dataset(); + dataset.setId(42l); + String privateUrlToken = "cd71e9d7-73a7-4ec8-b890-3d00499e8693"; + RoleAssignment ra = new RoleAssignment(aRole, anAssignee, dataset, privateUrlToken); + PrivateUrl privateUrl = PrivateUrlUtil.getPrivateUrlFromRoleAssignment(ra, dataverseSiteUrl); + assertNotNull(privateUrl); + assertEquals(new Long(42), privateUrl.getDataset().getId()); + assertEquals("https://dataverse.example.edu/privateurl.xhtml?token=cd71e9d7-73a7-4ec8-b890-3d00499e8693", privateUrl.getLink()); + } + + @Test + public void testGetPrivateUrlUserFromRoleAssignmentAndAssigneeNull() { + PrivateUrlUser privateUrl = PrivateUrlUtil.getPrivateUrlUserFromRoleAssignment(null, null); + assertNull(privateUrl); + } + + @Test + public void testGetPrivateUrlUserFromRoleAssignmentAndAssigneeNonPrivateUrlUser() { + DataverseRole aRole = null; + RoleAssignee assignee = GuestUser.get(); + DvObject dataset = new Dataset(); + String privateUrlToken = "cd71e9d7-73a7-4ec8-b890-3d00499e8693"; + RoleAssignment assignment = new RoleAssignment(aRole, assignee, dataset, privateUrlToken); + PrivateUrlUser privateUrl = PrivateUrlUtil.getPrivateUrlUserFromRoleAssignment(assignment, assignee); + assertNull(privateUrl); + } + + @Test + public void testGetPrivateUrlUserFromRoleAssignmentAndAssigneeSuccess() { + DataverseRole aRole = null; + PrivateUrlUser privateUrlUser = new PrivateUrlUser(42); + RoleAssignee assignee = privateUrlUser; + DvObject dataset = new Dataset(); + dataset.setId(42l); + String privateUrlToken = "cd71e9d7-73a7-4ec8-b890-3d00499e8693"; + RoleAssignment assignment = new RoleAssignment(aRole, assignee, dataset, privateUrlToken); + PrivateUrlUser privateUrl = PrivateUrlUtil.getPrivateUrlUserFromRoleAssignment(assignment, assignee); + assertNotNull(privateUrl); + } + + @Test + public void testGetRequiredPermissions() { + CreatePrivateUrlCommand createPrivateUrlCommand = new CreatePrivateUrlCommand(null, null); + CommandException ex = new CommandException(null, createPrivateUrlCommand); + List strings = PrivateUrlUtil.getRequiredPermissions(ex); + assertEquals(Arrays.asList("ManageDatasetPermissions"), strings); + } + +} diff --git a/src/test/java/edu/harvard/iq/dataverse/util/json/JsonPrinterTest.java b/src/test/java/edu/harvard/iq/dataverse/util/json/JsonPrinterTest.java new file mode 100644 index 00000000000..148ac95b4dd --- /dev/null +++ b/src/test/java/edu/harvard/iq/dataverse/util/json/JsonPrinterTest.java @@ -0,0 +1,53 @@ +package edu.harvard.iq.dataverse.util.json; + +import edu.harvard.iq.dataverse.Dataset; +import edu.harvard.iq.dataverse.RoleAssignment; +import edu.harvard.iq.dataverse.authorization.DataverseRole; +import edu.harvard.iq.dataverse.authorization.RoleAssignee; +import edu.harvard.iq.dataverse.authorization.users.PrivateUrlUser; +import edu.harvard.iq.dataverse.privateurl.PrivateUrl; +import javax.json.JsonObject; +import javax.json.JsonObjectBuilder; +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNotNull; +import org.junit.Test; + +public class JsonPrinterTest { + + @Test + public void testJson_RoleAssignment() { + DataverseRole aRole = new DataverseRole(); + PrivateUrlUser privateUrlUserIn = new PrivateUrlUser(42); + RoleAssignee anAssignee = privateUrlUserIn; + Dataset dataset = new Dataset(); + dataset.setId(123l); + String privateUrlToken = "e1d53cf6-794a-457a-9709-7c07629a8267"; + RoleAssignment ra = new RoleAssignment(aRole, anAssignee, dataset, privateUrlToken); + JsonObjectBuilder job = JsonPrinter.json(ra); + assertNotNull(job); + JsonObject jsonObject = job.build(); + assertEquals(":privateUrl42", jsonObject.getString("assignee")); + assertEquals(123, jsonObject.getInt("definitionPointId")); + assertEquals("e1d53cf6-794a-457a-9709-7c07629a8267", jsonObject.getString("privateUrlToken")); + } + + @Test + public void testJson_PrivateUrl() { + DataverseRole aRole = new DataverseRole(); + PrivateUrlUser privateUrlUserIn = new PrivateUrlUser(42); + RoleAssignee anAssignee = privateUrlUserIn; + Dataset dataset = new Dataset(); + String privateUrlToken = "e1d53cf6-794a-457a-9709-7c07629a8267"; + RoleAssignment ra = new RoleAssignment(aRole, anAssignee, dataset, privateUrlToken); + String dataverseSiteUrl = "https://dataverse.example.edu"; + PrivateUrl privateUrl = new PrivateUrl(ra, dataset, dataverseSiteUrl); + JsonObjectBuilder job = JsonPrinter.json(privateUrl); + assertNotNull(job); + JsonObject jsonObject = job.build(); + assertEquals("e1d53cf6-794a-457a-9709-7c07629a8267", jsonObject.getString("token")); + assertEquals("https://dataverse.example.edu/privateurl.xhtml?token=e1d53cf6-794a-457a-9709-7c07629a8267", jsonObject.getString("link")); + assertEquals("e1d53cf6-794a-457a-9709-7c07629a8267", jsonObject.getJsonObject("roleAssignment").getString("privateUrlToken")); + assertEquals(":privateUrl42", jsonObject.getJsonObject("roleAssignment").getString("assignee")); + } + +} From b4def5adba60cac57d2ee99fcb7b7a927bde9855 Mon Sep 17 00:00:00 2001 From: Leonid Andreev Date: Thu, 12 May 2016 23:31:39 -0400 Subject: [PATCH 009/267] built a local versio of xoai-serviceprovider, with a header parser bug fixed; working harvesting of DDI metadata (tested with Odum); added harvesting client management page (still needs work). --- pom.xml | 16 +- src/main/java/Bundle.properties | 42 ++++ .../iq/dataverse/HarvestingClientsPage.java | 176 ++++++++++++++++ .../harvest/client/ClientHarvestRun.java | 18 +- .../harvest/client/FastGetRecord.java | 3 + .../harvest/client/HarvesterServiceBean.java | 45 ++-- src/main/webapp/harvestclients.xhtml | 193 ++++++++++++++++++ 7 files changed, 473 insertions(+), 20 deletions(-) create mode 100644 src/main/java/edu/harvard/iq/dataverse/HarvestingClientsPage.java create mode 100644 src/main/webapp/harvestclients.xhtml diff --git a/pom.xml b/pom.xml index 862d8fffe48..7269c8d3f78 100644 --- a/pom.xml +++ b/pom.xml @@ -380,16 +380,30 @@ + + + + com.lyncode xoai-common 4.1.0 - + + + com.lyncode + xoai-service-provider + 4.1.0-header-patch + diff --git a/src/main/java/Bundle.properties b/src/main/java/Bundle.properties index 5a6d571d06e..6954d652bc0 100755 --- a/src/main/java/Bundle.properties +++ b/src/main/java/Bundle.properties @@ -203,6 +203,48 @@ apitoken.message=Here is your API Token. Check out our {0}API Guide{1} for more apitoken.generateBtn=Generate Token apitoken.regenerateBtn=Regenerate Token +#harvestclients.xhtml +harvestclients.title=Dashboard - Harvesting Clients +harvestclients.tab.header.name=Nickname +harvestclients.tab.header.url=URL +harvestclients.tab.header.lastrun=Last Run +harvestclients.tab.header.lastresults=Last Results +harvestclients.tab.header.action=Actions +harvestclients.tab.header.action.btn.run=harvest +harvestclients.tab.header.action.btn.edit=edit +harvestclients.tab.header.action.btn.delete=delete +harvestclients.tab.header.action.btn.delete.dialog.header=Delete Harvesting Client +harvestclients.tab.header.action.btn.delete.dialog.tip=Are you sure you want to delete this client? You cannot undo a delete. + +harvestclients.newClientDialog.title.new=Create New Harvesting Client +harvestclients.newClientDialog.help=Configure a new client to harvest content from a remote server. (TODO: add some text explaining that we will try to contact the server specified to see that it is working and to validate the settings they are entering, in real time +harvestclients.newClientDialog.nickname=Nickname +harvestclients.newClientDialog.nickname.helptext=Consists of letters, digits, underscores (_) and dashes (-) +harvestclients.newClientDialog.nickname.required=Client nickname cannot be empty +harvestclients.newClientDialog.nickname.invalid=Client nickname can contain only letters, digits, underscores (_) and dashes (-) +harvestclients.newClientDialog.nickname.alreadyused=This nickname is already used + +harvestclients.newClientDialog.type=Server Type +harvestclients.newClientDialog.type.helptext=At the moment, only OAI is supported +harvestclients.newClientDialog.type.OAI=OAI +harvestclients.newClientDialog.type.Nesstar=Nesstar + +harvestclients.newClientDialog.url=Server Url +harvestclients.newClientDialog.url.helptext=URL of a harvesting resource. We will try to establish connection to the server in order to verify that it is working, and to obtain extra information about its capabilities. +harvestclients.newClientDialog.url.required=A valid harvesting server address is required +harvestclients.newClientDialog.url.invalid=Not a valid URL +harvestclients.newClientDialog.url.noresponse=Failed to establish connection to the server +harvestclients.newClientDialog.url.badresponse=Invalid response from the server + + + +harvestclients.newClientDialog.btn.create=Create + + +harvestclients.newClientDialog.title.edit=Edit Group {0} + + + #MailServiceBean.java notification.email.create.dataverse.subject=Dataverse: Your dataverse has been created diff --git a/src/main/java/edu/harvard/iq/dataverse/HarvestingClientsPage.java b/src/main/java/edu/harvard/iq/dataverse/HarvestingClientsPage.java new file mode 100644 index 00000000000..1a12b363c4f --- /dev/null +++ b/src/main/java/edu/harvard/iq/dataverse/HarvestingClientsPage.java @@ -0,0 +1,176 @@ +/* + * To change this license header, choose License Headers in Project Properties. + * To change this template file, choose Tools | Templates + * and open the template in the editor. + */ +package edu.harvard.iq.dataverse; + +import edu.harvard.iq.dataverse.authorization.AuthenticationServiceBean; +import edu.harvard.iq.dataverse.harvest.client.HarvestingClient; +import edu.harvard.iq.dataverse.harvest.client.HarvestingClientServiceBean; +import static edu.harvard.iq.dataverse.util.JsfHelper.JH; +import java.util.List; +import java.util.logging.Logger; +import java.util.regex.Pattern; +import javax.ejb.EJB; +import javax.faces.application.FacesMessage; +import javax.faces.component.UIComponent; +import javax.faces.component.UIInput; +import javax.faces.context.FacesContext; +import javax.faces.event.ActionEvent; +import javax.faces.view.ViewScoped; +import javax.inject.Inject; +import javax.inject.Named; +import org.apache.commons.lang.StringUtils; + +/** + * + * @author Leonid Andreev + */ +@ViewScoped +@Named("HarvestingClientsPage") +public class HarvestingClientsPage implements java.io.Serializable { + + private static final Logger logger = Logger.getLogger(HarvestingClientsPage.class.getCanonicalName()); + + @Inject + DataverseSession session; + @EJB + AuthenticationServiceBean authSvc; + @EJB + DataverseServiceBean dataverseService; + @EJB + HarvestingClientServiceBean harvestingClientService; + + private List configuredHarvestingClients; + private Dataverse dataverse; + private Long dataverseId = null; + private HarvestingClient selectedClient = new HarvestingClient(); + private int harvestTypeRadio = 1; // 1 = OAI; 2 = Nesstar + + UIInput newClientNicknameInputField; + UIInput newClientUrlInputField; + + public void init() { + if (dataverseId != null) { + setDataverse(dataverseService.find(getDataverseId())); + } else { + setDataverse(dataverseService.findRootDataverse()); + } + configuredHarvestingClients = harvestingClientService.getAllHarvestingClients(); + harvestTypeRadio = 1; + } + + public List getConfiguredHarvestingClients() { + return configuredHarvestingClients; + } + + public void setConfiguredHarvestingClients(List configuredClients) { + configuredHarvestingClients = configuredClients; + } + + public Dataverse getDataverse() { + return dataverse; + } + + public void setDataverse(Dataverse dataverse) { + this.dataverse = dataverse; + } + + public Long getDataverseId() { + return dataverseId; + } + + public void setDataverseId(Long dataverseId) { + this.dataverseId = dataverseId; + } + + public void initNewClient(ActionEvent ae) { + //this.selectedClient = new HarvestingClient(); + } + + public void setSelectedClient(HarvestingClient harvestingClient) { + selectedClient = harvestingClient; + } + + public HarvestingClient getSelectedClient() { + return selectedClient; + } + + public void addClient(ActionEvent ae) { + + } + + public void runHarvest(HarvestingClient harvestingClient) { + + } + + public void editClient(HarvestingClient harvestingClient) { + + } + + + public void deleteClient() { + + } + + public void validateNickname(FacesContext context, UIComponent toValidate, Object rawValue) { + String value = (String) rawValue; + UIInput input = (UIInput) toValidate; + input.setValid(true); + + if ( !StringUtils.isEmpty(value) ) { + + if (! Pattern.matches("^[a-zA-Z0-9\\_\\-]+$", value) ) { + input.setValid(false); + context.addMessage(toValidate.getClientId(), + new FacesMessage(FacesMessage.SEVERITY_ERROR, "", JH.localize("harvestclients.newClientDialog.nickname.invalid"))); + + // If it passes the regex test, check + } else if ( harvestingClientService.findByNickname(value) != null ) { + input.setValid(false); + context.addMessage(toValidate.getClientId(), + new FacesMessage(FacesMessage.SEVERITY_ERROR, "", JH.localize("harvestclients.newClientDialog.nickname.alreadyused"))); + } + } + } + + public void testClientUrl(String serverUrl) { + //if (!StringUtils.isEmpty(serverUrl)) { + FacesContext.getCurrentInstance().addMessage(getNewClientUrlInputField().getClientId(), + new FacesMessage(FacesMessage.SEVERITY_ERROR, "", JH.localize("harvestclients.newClientDialog.url.invalid"))); + //} + } + + public void validateClientUrl(FacesContext context, UIComponent toValidate, Object rawValue) { + String value = (String) rawValue; + UIInput input = (UIInput) toValidate; + input.setValid(true); + + // ... + } + + public int getHarvestTypeRadio() { + return this.harvestTypeRadio; + } + + public void setHarvestTypeRadio(int harvestTypeRadio) { + this.harvestTypeRadio = harvestTypeRadio; + } + + public UIInput getNewClientNicknameInputField() { + return newClientNicknameInputField; + } + + public void setNewClientNicknameInputField(UIInput newClientInputField) { + this.newClientNicknameInputField = newClientInputField; + } + + public UIInput getNewClientUrlInputField() { + return newClientUrlInputField; + } + + public void setNewClientUrlInputField(UIInput newClientInputField) { + this.newClientUrlInputField = newClientInputField; + } +} diff --git a/src/main/java/edu/harvard/iq/dataverse/harvest/client/ClientHarvestRun.java b/src/main/java/edu/harvard/iq/dataverse/harvest/client/ClientHarvestRun.java index 7cd8c4e603d..42cc8539ddf 100644 --- a/src/main/java/edu/harvard/iq/dataverse/harvest/client/ClientHarvestRun.java +++ b/src/main/java/edu/harvard/iq/dataverse/harvest/client/ClientHarvestRun.java @@ -29,7 +29,7 @@ public class ClientHarvestRun implements Serializable { private static final long serialVersionUID = 1L; @Id - @GeneratedValue(strategy = GenerationType.AUTO) + @GeneratedValue(strategy = GenerationType.IDENTITY) private Long id; public Long getId() { @@ -74,6 +74,22 @@ public String getResultLabel() { } return null; } + + public String getDetailedResultLabel() { + if (isSuccess()) { + String resultLabel = RESULT_LABEL_SUCCESS; + + resultLabel = resultLabel.concat("; "+harvestedDatasetCount+" harvested, "); + resultLabel = resultLabel.concat(deletedDatasetCount+" deleted, "); + resultLabel = resultLabel.concat(failedDatasetCount+" failed."); + return resultLabel; + } else if (isFailed()) { + return RESULT_LABEL_FAILURE; + } else if (isInProgress()) { + return RESULT_LABEL_INPROGRESS; + } + return null; + } public void setResult(RunResultType harvestResult) { this.harvestResult = harvestResult; diff --git a/src/main/java/edu/harvard/iq/dataverse/harvest/client/FastGetRecord.java b/src/main/java/edu/harvard/iq/dataverse/harvest/client/FastGetRecord.java index f87e182ecf8..19b036d06fd 100644 --- a/src/main/java/edu/harvard/iq/dataverse/harvest/client/FastGetRecord.java +++ b/src/main/java/edu/harvard/iq/dataverse/harvest/client/FastGetRecord.java @@ -370,6 +370,9 @@ public void harvestRecord(String baseURL, String identifier, String metadataPref } if (!(metadataWritten) && !(this.isDeleted())) { + if (oaiResponseHeader.length() > 64) { + oaiResponseHeader = oaiResponseHeader.substring(0, 32) + "..."; + } this.errorMessage = "Failed to parse GetRecord response; "+oaiResponseHeader; //savedMetadataFile.delete(); } diff --git a/src/main/java/edu/harvard/iq/dataverse/harvest/client/HarvesterServiceBean.java b/src/main/java/edu/harvard/iq/dataverse/harvest/client/HarvesterServiceBean.java index 939a9e1e582..26cfd3d6679 100644 --- a/src/main/java/edu/harvard/iq/dataverse/harvest/client/HarvesterServiceBean.java +++ b/src/main/java/edu/harvard/iq/dataverse/harvest/client/HarvesterServiceBean.java @@ -10,12 +10,10 @@ import edu.harvard.iq.dataverse.Dataverse; import edu.harvard.iq.dataverse.DataverseServiceBean; import edu.harvard.iq.dataverse.timer.DataverseTimerServiceBean; -import edu.harvard.iq.dataverse.util.FileUtil; import java.io.File; import java.io.IOException; import java.text.SimpleDateFormat; import java.util.ArrayList; -import java.util.Calendar; import java.util.Date; import java.util.Iterator; import java.util.List; @@ -37,6 +35,7 @@ import javax.xml.parsers.ParserConfigurationException; import javax.xml.transform.TransformerException; import org.apache.commons.lang.mutable.MutableBoolean; +import org.apache.commons.lang.mutable.MutableLong; import org.xml.sax.SAXException; import com.lyncode.xoai.model.oaipmh.Granularity; @@ -165,7 +164,9 @@ private void createHarvestTimer(Dataverse harvestingDataverse) { /** * Run a harvest for an individual harvesting Dataverse - * @param dataverseId + * @param dataverseRequest + * @param harvestingClientId + * @throws IOException */ public void doHarvest(DataverseRequest dataverseRequest, Long harvestingClientId) throws IOException { HarvestingClient harvestingClientConfig = harvestingClientService.find(harvestingClientId); @@ -229,7 +230,7 @@ public void doHarvest(DataverseRequest dataverseRequest, Long harvestingClientId //mailService.sendHarvestNotification(...getSystemEmail(), harvestingDataverse.getName(), logFileName, logTimestamp, harvestErrorOccurred.booleanValue(), harvestedDatasetIds.size(), failedIdentifiers); } catch (Throwable e) { harvestErrorOccurred.setValue(true); - String message = "Exception processing harvest, server= " + harvestingClientConfig.getArchiveUrl() + ",format=" + harvestingClientConfig.getMetadataPrefix() + " " + e.getClass().getName() + " " + e.getMessage(); + String message = "Exception processing harvest, server= " + harvestingClientConfig.getHarvestingUrl() + ",format=" + harvestingClientConfig.getMetadataPrefix() + " " + e.getClass().getName() + " " + e.getMessage(); hdLogger.log(Level.SEVERE, message); logException(e, hdLogger); hdLogger.log(Level.INFO, "HARVEST NOT COMPLETED DUE TO UNEXPECTED ERROR."); @@ -258,8 +259,7 @@ private List harvestOAI(DataverseRequest dataverseRequest, HarvestingClien throws IOException, ParserConfigurationException, SAXException, TransformerException { List harvestedDatasetIds = new ArrayList(); - Long processedSizeThisBatch = 0L; - + MutableLong processedSizeThisBatch = new MutableLong(0L); String baseOaiUrl = harvestingClient.getHarvestingUrl(); String metadataPrefix = harvestingClient.getMetadataPrefix(); @@ -272,38 +272,48 @@ private List harvestOAI(DataverseRequest dataverseRequest, HarvestingClien ListIdentifiersParameters parameters = buildParams(metadataPrefix, set, fromDate); ServiceProvider serviceProvider = getServiceProvider(baseOaiUrl, Granularity.Second); + hdLogger.log(Level.INFO, "Created ListIdentifiers parameters and service provider."); + try { for (Iterator
    idIter = serviceProvider.listIdentifiers(parameters); idIter.hasNext();) { Header h = idIter.next(); + hdLogger.info("retrieved header;"); String identifier = h.getIdentifier(); - hdLogger.fine("identifier: " + identifier); + hdLogger.info("identifier: " + identifier); // Retrieve and process this record with a separate GetRecord call: MutableBoolean getRecordErrorOccurred = new MutableBoolean(false); + Long datasetId = getRecord(dataverseRequest, hdLogger, harvestingClient, identifier, metadataPrefix, getRecordErrorOccurred, processedSizeThisBatch); + + hdLogger.info("Total content processed in this batch so far: "+processedSizeThisBatch); if (datasetId != null) { harvestedDatasetIds.add(datasetId); + + if ( harvestedDatasetIdsThisBatch == null ) { + harvestedDatasetIdsThisBatch = new ArrayList(); + } + harvestedDatasetIdsThisBatch.add(datasetId); + } + if (getRecordErrorOccurred.booleanValue() == true) { failedIdentifiers.add(identifier); } - if ( harvestedDatasetIdsThisBatch == null ) { - harvestedDatasetIdsThisBatch = new ArrayList(); - } - harvestedDatasetIdsThisBatch.add(datasetId); + // reindexing in batches? - this is from DVN 3; // we may not need it anymore. - if ( processedSizeThisBatch > INDEXING_CONTENT_BATCH_SIZE ) { + if ( processedSizeThisBatch.longValue() > INDEXING_CONTENT_BATCH_SIZE ) { hdLogger.log(Level.INFO, "REACHED CONTENT BATCH SIZE LIMIT; calling index ("+ harvestedDatasetIdsThisBatch.size()+" datasets in the batch)."); //indexService.updateIndexList(this.harvestedDatasetIdsThisBatch); hdLogger.log(Level.INFO, "REINDEX DONE."); - processedSizeThisBatch = 0L; + processedSizeThisBatch.setValue(0L); harvestedDatasetIdsThisBatch = null; } @@ -353,11 +363,10 @@ private ListIdentifiersParameters buildParams(String metadataPrefix, String set, @TransactionAttribute(TransactionAttributeType.NOT_SUPPORTED) - public Long getRecord(DataverseRequest dataverseRequest, Logger hdLogger, HarvestingClient harvestingClient, String identifier, String metadataPrefix, MutableBoolean recordErrorOccurred, Long processedSizeThisBatch) { + public Long getRecord(DataverseRequest dataverseRequest, Logger hdLogger, HarvestingClient harvestingClient, String identifier, String metadataPrefix, MutableBoolean recordErrorOccurred, MutableLong processedSizeThisBatch) { String errMessage = null; Dataset harvestedDataset = null; String oaiUrl = harvestingClient.getHarvestingUrl(); - Dataverse parentDataverse = harvestingClient.getDataverse(); try { hdLogger.log(Level.INFO, "Calling GetRecord: oaiUrl =" + oaiUrl + "?verb=GetRecord&identifier=" + identifier + "&metadataPrefix=" + metadataPrefix); @@ -380,11 +389,11 @@ public Long getRecord(DataverseRequest dataverseRequest, Logger hdLogger, Harves } else { hdLogger.log(Level.INFO, "Successfully retrieved GetRecord response."); - harvestedDataset = importService.doImportHarvestedDataset(dataverseRequest, parentDataverse, metadataPrefix, record.getMetadataFile(), null); + ///harvestedDataset = importService.doImportHarvestedDataset(dataverseRequest, parentDataverse, metadataPrefix, record.getMetadataFile(), null); hdLogger.log(Level.INFO, "Harvest Successful for identifier " + identifier); - - processedSizeThisBatch += record.getMetadataFile().length(); + hdLogger.info("Size of this record: " + record.getMetadataFile().length()); + processedSizeThisBatch.add(record.getMetadataFile().length()); } } catch (Throwable e) { errMessage = "Exception processing getRecord(), oaiUrl=" + oaiUrl + ",identifier=" + identifier + " " + e.getClass().getName() + " " + e.getMessage(); diff --git a/src/main/webapp/harvestclients.xhtml b/src/main/webapp/harvestclients.xhtml new file mode 100644 index 00000000000..4229dd6254a --- /dev/null +++ b/src/main/webapp/harvestclients.xhtml @@ -0,0 +1,193 @@ + + + + + + + + + + + + + + + + + + + +
    +
    + + Add Client + +
    +
    + +
    +

    +   + + + +

    +
    + + + + + + + + + + + + + + + +
    +
    + + + + + + + + + + +
    +
    +
    +
    + + + +

    + #{bundle['harvestclients.tab.header.action.btn.delete.dialog.tip']} +

    +
    + + +
    +
    + + + + + +
    + +

    #{bundle['harvestclients.newClientDialog.help']}

    + + +
    + +
    + + + + +

    #{bundle['harvestclients.newClientDialog.nickname.helptext']}

    +
    +
    + + +
    + +
    + + + + + + +

    #{bundle['harvestclients.newClientDialog.type.helptext']}

    +
    +
    + + +
    + +
    + + + + + +

    #{bundle['harvestclients.newClientDialog.url.helptext']}

    +
    +
    + + +
    +
    + + + + +
    +
    +
    + +
    + + +
    +
    +
    + From 04723963dfc6a3dbc8653604e6506c8d76ec1468 Mon Sep 17 00:00:00 2001 From: Leonid Andreev Date: Tue, 17 May 2016 00:31:54 -0400 Subject: [PATCH 010/267] Manage Harvesting Clients page expanded. --- src/main/java/Bundle.properties | 26 +- .../iq/dataverse/HarvestingClientsPage.java | 302 ++++++++++++++++-- .../harvest/client/HarvestingClient.java | 34 +- src/main/webapp/harvestclients.xhtml | 223 ++++++++++--- 4 files changed, 506 insertions(+), 79 deletions(-) diff --git a/src/main/java/Bundle.properties b/src/main/java/Bundle.properties index 6954d652bc0..8a9b5cb06c1 100755 --- a/src/main/java/Bundle.properties +++ b/src/main/java/Bundle.properties @@ -205,26 +205,27 @@ apitoken.regenerateBtn=Regenerate Token #harvestclients.xhtml harvestclients.title=Dashboard - Harvesting Clients +harvestclients.toptip=Harvesting clients gather metadata records from supported harvesting resources (for example, OAI servers, such as, but not limited to, other Dataverse instances). Harvesting can be scheduled to take place on a regular basis. It can also be run on demand, using this web interface, or via the REST API. Once harvested, datasets cannot be edited. Harvested dataset metadata will be searchable locally, but users will be redirected to the original sources to access the data. harvestclients.tab.header.name=Nickname harvestclients.tab.header.url=URL harvestclients.tab.header.lastrun=Last Run harvestclients.tab.header.lastresults=Last Results harvestclients.tab.header.action=Actions harvestclients.tab.header.action.btn.run=harvest -harvestclients.tab.header.action.btn.edit=edit +harvestclients.tab.header.action.btn.edit=view/edit harvestclients.tab.header.action.btn.delete=delete harvestclients.tab.header.action.btn.delete.dialog.header=Delete Harvesting Client harvestclients.tab.header.action.btn.delete.dialog.tip=Are you sure you want to delete this client? You cannot undo a delete. harvestclients.newClientDialog.title.new=Create New Harvesting Client -harvestclients.newClientDialog.help=Configure a new client to harvest content from a remote server. (TODO: add some text explaining that we will try to contact the server specified to see that it is working and to validate the settings they are entering, in real time +harvestclients.newClientDialog.help=Configure a new client to harvest content from a remote server. harvestclients.newClientDialog.nickname=Nickname harvestclients.newClientDialog.nickname.helptext=Consists of letters, digits, underscores (_) and dashes (-) -harvestclients.newClientDialog.nickname.required=Client nickname cannot be empty +harvestclients.newClientDialog.nickname.required=Client nickname cannot be empty! harvestclients.newClientDialog.nickname.invalid=Client nickname can contain only letters, digits, underscores (_) and dashes (-) harvestclients.newClientDialog.nickname.alreadyused=This nickname is already used -harvestclients.newClientDialog.type=Server Type +harvestclients.newClientDialog.type=Server Protocol harvestclients.newClientDialog.type.helptext=At the moment, only OAI is supported harvestclients.newClientDialog.type.OAI=OAI harvestclients.newClientDialog.type.Nesstar=Nesstar @@ -235,8 +236,25 @@ harvestclients.newClientDialog.url.required=A valid harvesting server address is harvestclients.newClientDialog.url.invalid=Not a valid URL harvestclients.newClientDialog.url.noresponse=Failed to establish connection to the server harvestclients.newClientDialog.url.badresponse=Invalid response from the server +harvestclients.newClientDialog.btn.initialsettings.validate=Next +harvestclients.newClientDialog.oaiSets=OAI Set +harvestclients.newClientDialog.oaiSets.noset=none +harvestclients.newClientDialog.oaiSets.helptext=This OAI server offers the following harvesting sets. Selecting "none" will result in harvesting of the content in the default set, as defined by the server. (Often this will be the entire body of content across all the sub-sets.) +harvestclients.newClientDialog.oaiSets.helptext.noset=This OAI server does not support named sets. The entire body of content offered by the server will be harvested. +harvestclients.newClientDialog.oaiMetadataFormat=Metadata Format +harvestclients.newClientDialog.oaiMetadataFormat.helptext=Metadata formats offered by the remote server, for which import is supported in Dataverse. +harvestclients.newClientDialog.oaiMetadataFormat.required=Please select the type of metadata for harvesting from this archive. + +harvestclients.newClientDialog.harvestingStyle=Archive Type +harvestclients.newClientDialog.harvestingStyle.helptext=It is important to correctly identify the type of the remote archive since different formatting rules and styles are used to display metadata harvested from different archival sources. Please select the type that best describes this remote archive. + +harvestclients.newClientDialog.schedule=Schedule +harvestclients.newClientDialog.schedule.none=None +harvestclients.newClientDialog.schedule.daily=Daily +harvestclients.newClientDialog.schedule.weekly=Weekly +harvestclients.newClientDialog.schedule.helptext=Harvesting can be scheduled to run daily or weekly, automatically. If enabled, you will be prompted to choose the day of week and/or time of day to run scheduled harvests. harvestclients.newClientDialog.btn.create=Create diff --git a/src/main/java/edu/harvard/iq/dataverse/HarvestingClientsPage.java b/src/main/java/edu/harvard/iq/dataverse/HarvestingClientsPage.java index 1a12b363c4f..36a732e87c5 100644 --- a/src/main/java/edu/harvard/iq/dataverse/HarvestingClientsPage.java +++ b/src/main/java/edu/harvard/iq/dataverse/HarvestingClientsPage.java @@ -9,6 +9,8 @@ import edu.harvard.iq.dataverse.harvest.client.HarvestingClient; import edu.harvard.iq.dataverse.harvest.client.HarvestingClientServiceBean; import static edu.harvard.iq.dataverse.util.JsfHelper.JH; +import java.util.ArrayList; +import java.util.Arrays; import java.util.List; import java.util.logging.Logger; import java.util.regex.Pattern; @@ -18,6 +20,7 @@ import javax.faces.component.UIInput; import javax.faces.context.FacesContext; import javax.faces.event.ActionEvent; +import javax.faces.model.SelectItem; import javax.faces.view.ViewScoped; import javax.inject.Inject; import javax.inject.Named; @@ -28,7 +31,7 @@ * @author Leonid Andreev */ @ViewScoped -@Named("HarvestingClientsPage") +@Named public class HarvestingClientsPage implements java.io.Serializable { private static final Logger logger = Logger.getLogger(HarvestingClientsPage.class.getCanonicalName()); @@ -45,11 +48,8 @@ public class HarvestingClientsPage implements java.io.Serializable { private List configuredHarvestingClients; private Dataverse dataverse; private Long dataverseId = null; - private HarvestingClient selectedClient = new HarvestingClient(); - private int harvestTypeRadio = 1; // 1 = OAI; 2 = Nesstar + private HarvestingClient selectedClient; - UIInput newClientNicknameInputField; - UIInput newClientUrlInputField; public void init() { if (dataverseId != null) { @@ -58,7 +58,10 @@ public void init() { setDataverse(dataverseService.findRootDataverse()); } configuredHarvestingClients = harvestingClientService.getAllHarvestingClients(); - harvestTypeRadio = 1; + harvestTypeRadio = harvestTypeRadioOAI; + //selectedClient = new HarvestingClient(); + + FacesContext.getCurrentInstance().addMessage(null, new FacesMessage(FacesMessage.SEVERITY_INFO, "Harvesting Client Settings", JH.localize("harvestclients.toptip"))); } public List getConfiguredHarvestingClients() { @@ -85,10 +88,6 @@ public void setDataverseId(Long dataverseId) { this.dataverseId = dataverseId; } - public void initNewClient(ActionEvent ae) { - //this.selectedClient = new HarvestingClient(); - } - public void setSelectedClient(HarvestingClient harvestingClient) { selectedClient = harvestingClient; } @@ -114,34 +113,65 @@ public void deleteClient() { } - public void validateNickname(FacesContext context, UIComponent toValidate, Object rawValue) { - String value = (String) rawValue; - UIInput input = (UIInput) toValidate; - input.setValid(true); + //public void validateNickname(FacesContext context, UIComponent toValidate, Object rawValue) { + public boolean validateNickname() { - if ( !StringUtils.isEmpty(value) ) { + if ( !StringUtils.isEmpty(getNewNickname()) ) { - if (! Pattern.matches("^[a-zA-Z0-9\\_\\-]+$", value) ) { - input.setValid(false); - context.addMessage(toValidate.getClientId(), + if (! Pattern.matches("^[a-zA-Z0-9\\_\\-]+$", getNewNickname()) ) { + //input.setValid(false); + FacesContext.getCurrentInstance().addMessage(getNewClientNicknameInputField().getClientId(), new FacesMessage(FacesMessage.SEVERITY_ERROR, "", JH.localize("harvestclients.newClientDialog.nickname.invalid"))); + return false; // If it passes the regex test, check - } else if ( harvestingClientService.findByNickname(value) != null ) { - input.setValid(false); - context.addMessage(toValidate.getClientId(), + } else if ( harvestingClientService.findByNickname(getNewNickname()) != null ) { + //input.setValid(false); + FacesContext.getCurrentInstance().addMessage(getNewClientNicknameInputField().getClientId(), new FacesMessage(FacesMessage.SEVERITY_ERROR, "", JH.localize("harvestclients.newClientDialog.nickname.alreadyused"))); + return false; } - } + return true; + } + + // Nickname field is empty: + FacesContext.getCurrentInstance().addMessage(getNewClientNicknameInputField().getClientId(), + new FacesMessage(FacesMessage.SEVERITY_ERROR, "", JH.localize("harvestclients.newClientDialog.nickname.required"))); + return false; } - public void testClientUrl(String serverUrl) { - //if (!StringUtils.isEmpty(serverUrl)) { - FacesContext.getCurrentInstance().addMessage(getNewClientUrlInputField().getClientId(), - new FacesMessage(FacesMessage.SEVERITY_ERROR, "", JH.localize("harvestclients.newClientDialog.url.invalid"))); - //} + public boolean validateServerUrlOAI() { + if (!StringUtils.isEmpty(getNewHarvestingUrl())) { + if (false) { + FacesContext.getCurrentInstance().addMessage(getNewClientUrlInputField().getClientId(), + new FacesMessage(FacesMessage.SEVERITY_ERROR, "", getNewHarvestingUrl() + ": " + JH.localize("harvestclients.newClientDialog.url.invalid"))); + return false; + } else { + return true; + } + } + FacesContext.getCurrentInstance().addMessage(getNewClientUrlInputField().getClientId(), + new FacesMessage(FacesMessage.SEVERITY_ERROR, "", getNewHarvestingUrl() + ": " + JH.localize("harvestclients.newClientDialog.url.required"))); + return false; } + public void validateInitialSettings() { + if (isHarvestTypeOAI()) { + boolean nicknameValidated = validateNickname(); + boolean urlValidated = validateServerUrlOAI(); + + if (nicknameValidated && urlValidated) { + // We want to run both validation tests; this is why + // we are not doing "if ((validateNickname() && validateServerUrlOAI())" + // in the line above. -- L.A. 4.4 May 2016. + + setInitialSettingsValidated(true); + } + // (and if not - it stays set to false) + } + } + + /* public void validateClientUrl(FacesContext context, UIComponent toValidate, Object rawValue) { String value = (String) rawValue; UIInput input = (UIInput) toValidate; @@ -149,6 +179,86 @@ public void validateClientUrl(FacesContext context, UIComponent toValidate, Obje // ... } + */ + + /* + * Values and methods for creating a new harvesting client: + */ + + private int harvestTypeRadio; // 1 = OAI; 2 = Nesstar + private static int harvestTypeRadioOAI = 1; + private static int harvestTypeRadioNesstar = 2; + + UIInput newClientNicknameInputField; + UIInput newClientUrlInputField; + private String newNickname = ""; + private String newHarvestingUrl = ""; + private boolean initialSettingsValidated = false; + private String newOaiSet = ""; + private String newMetadataFormat = ""; + private String newHarvestingStyle = ""; + + private int harvestingScheduleRadio; + + private static final int harvestingScheduleRadioNone = 0; + private static final int harvestingScheduleRadioDaily = 1; + private static final int harvestingScheduleRadioWeekly = 2; + + private String newHarvestingScheduleDayOfWeek = ""; + private String newHarvestingScheduleTimeOfDay = ""; + + private int harvestingScheduleRadioAMPM; + private static final int harvestingScheduleRadioAM = 0; + private static final int harvestingScheduleRadioPM = 1; + + + public void initNewClient(ActionEvent ae) { + //this.selectedClient = new HarvestingClient(); + this.newNickname = ""; + this.newHarvestingUrl = ""; + this.initialSettingsValidated = false; + this.newOaiSet = ""; + this.newMetadataFormat = ""; + this.newHarvestingStyle = HarvestingClient.HARVEST_STYLE_DATAVERSE; + + this.harvestTypeRadio = harvestTypeRadioOAI; + this.harvestingScheduleRadio = harvestingScheduleRadioNone; + + this.newHarvestingScheduleDayOfWeek = ""; + this.newHarvestingScheduleTimeOfDay = ""; + + this.harvestingScheduleRadioAMPM = harvestingScheduleRadioAM; + + setOaiSetsSelectItems(new ArrayList<>()); + setOaiMetadataFormatSelectItems(new ArrayList<>()); + getOaiMetadataFormatSelectItems().add(new SelectItem("oai_dc", "oai_dc")); + + } + + public boolean isInitialSettingsValidated() { + return this.initialSettingsValidated; + } + + public void setInitialSettingsValidated(boolean validated) { + this.initialSettingsValidated = validated; + } + + + public String getNewNickname() { + return newNickname; + } + + public void setNewNickname(String newNickname) { + this.newNickname = newNickname; + } + + public String getNewHarvestingUrl() { + return newHarvestingUrl; + } + + public void setNewHarvestingUrl(String newHarvestingUrl) { + this.newHarvestingUrl = newHarvestingUrl; + } public int getHarvestTypeRadio() { return this.harvestTypeRadio; @@ -158,6 +268,87 @@ public void setHarvestTypeRadio(int harvestTypeRadio) { this.harvestTypeRadio = harvestTypeRadio; } + public boolean isHarvestTypeOAI() { + return harvestTypeRadioOAI == harvestTypeRadio; + } + + public boolean isHarvestTypeNesstar() { + return harvestTypeRadioNesstar == harvestTypeRadio; + } + + public String getNewOaiSet() { + return newOaiSet; + } + + public void setNewOaiSet(String newOaiSet) { + this.newOaiSet = newOaiSet; + } + + public String getNewMetadataFormat() { + return newMetadataFormat; + } + + public void setNewMetadataFormat(String newMetadataFormat) { + this.newMetadataFormat = newMetadataFormat; + } + + public String getNewHarvestingStyle() { + return newHarvestingStyle; + } + + public void setNewHarvestingStyle(String newHarvestingStyle) { + this.newHarvestingStyle = newHarvestingStyle; + } + + public int getHarvestingScheduleRadio() { + return this.harvestingScheduleRadio; + } + + public void setHarvestingScheduleRadio(int harvestingScheduleRadio) { + this.harvestingScheduleRadio = harvestingScheduleRadio; + } + + public boolean isNewHarvestingScheduled() { + return this.harvestingScheduleRadio != harvestingScheduleRadioNone; + } + + public boolean isNewHarvestingScheduledWeekly() { + return this.harvestingScheduleRadio == harvestingScheduleRadioWeekly; + } + + public boolean isNewHarvestingScheduledDaily() { + return this.harvestingScheduleRadio == harvestingScheduleRadioDaily; + } + + public String getNewHarvestingScheduleDayOfWeek() { + return newHarvestingScheduleDayOfWeek; + } + + public void setNewHarvestingScheduleDayOfWeek(String newHarvestingScheduleDayOfWeek) { + this.newHarvestingScheduleDayOfWeek = newHarvestingScheduleDayOfWeek; + } + + public String getNewHarvestingScheduleTimeOfDay() { + return newHarvestingScheduleTimeOfDay; + } + + public void setNewHarvestingScheduleTimeOfDay(String newHarvestingScheduleTimeOfDay) { + this.newHarvestingScheduleTimeOfDay = newHarvestingScheduleTimeOfDay; + } + + public int getHarvestingScheduleRadioAMPM() { + return this.harvestingScheduleRadioAMPM; + } + + public void setHarvestingScheduleRadioAMPM(int harvestingScheduleRadioAMPM) { + this.harvestingScheduleRadioAMPM = harvestingScheduleRadioAMPM; + } + + public void toggleNewClientSchedule() { + + } + + public UIInput getNewClientNicknameInputField() { return newClientNicknameInputField; } @@ -173,4 +364,61 @@ public UIInput getNewClientUrlInputField() { public void setNewClientUrlInputField(UIInput newClientInputField) { this.newClientUrlInputField = newClientInputField; } + + private List oaiSetsSelectItems; + + public List getOaiSetsSelectItems() { + return oaiSetsSelectItems; + } + + public void setOaiSetsSelectItems(List oaiSetsSelectItems) { + this.oaiSetsSelectItems = oaiSetsSelectItems; + } + + private List oaiMetadataFormatSelectItems; + + public List getOaiMetadataFormatSelectItems() { + return oaiMetadataFormatSelectItems; + } + + public void setOaiMetadataFormatSelectItems(List oaiMetadataFormatSelectItems) { + this.oaiMetadataFormatSelectItems = oaiMetadataFormatSelectItems; + } + + private List harvestingStylesSelectItems = null; + + public List getHarvestingStylesSelectItems() { + if (this.harvestingStylesSelectItems == null) { + this.harvestingStylesSelectItems = new ArrayList<>(); + for (int i = 0; i < HarvestingClient.HARVEST_STYLE_LIST.size(); i++) { + String style = HarvestingClient.HARVEST_STYLE_LIST.get(i); + this.harvestingStylesSelectItems.add(new SelectItem( + style, + HarvestingClient.HARVEST_STYLE_INFOMAP.get(style))); + } + } + return this.harvestingStylesSelectItems; + } + + public void setHarvestingStylesSelectItems(List harvestingStylesSelectItems) { + this.harvestingStylesSelectItems = harvestingStylesSelectItems; + } + + private List daysOfWeekSelectItems = null; + + public List getDaysOfWeekSelectItems() { + if (this.daysOfWeekSelectItems == null) { + List weekDays = Arrays.asList("Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday", "Sunday"); + this.daysOfWeekSelectItems = new ArrayList<>(); + for (int i = 0; i < weekDays.size(); i++) { + this.daysOfWeekSelectItems.add(new SelectItem(weekDays.get(i), weekDays.get(i))); + } + } + + return this.daysOfWeekSelectItems; + } + + public void setDaysOfWeekSelectItems(List daysOfWeekSelectItems) { + this.daysOfWeekSelectItems = daysOfWeekSelectItems; + } } diff --git a/src/main/java/edu/harvard/iq/dataverse/harvest/client/HarvestingClient.java b/src/main/java/edu/harvard/iq/dataverse/harvest/client/HarvestingClient.java index 2bf32098dbc..127c8feed2a 100644 --- a/src/main/java/edu/harvard/iq/dataverse/harvest/client/HarvestingClient.java +++ b/src/main/java/edu/harvard/iq/dataverse/harvest/client/HarvestingClient.java @@ -8,10 +8,14 @@ import edu.harvard.iq.dataverse.Dataverse; import java.io.Serializable; import java.text.SimpleDateFormat; +import java.util.Arrays; + import java.util.Calendar; import java.util.Date; import java.util.GregorianCalendar; +import java.util.LinkedHashMap; import java.util.List; +import java.util.Map; import javax.persistence.CascadeType; import javax.persistence.Column; import javax.persistence.Entity; @@ -59,10 +63,15 @@ public Long getId() { public void setId(Long id) { this.id = id; } - + public static final String HARVEST_TYPE_OAI="oai"; public static final String HARVEST_TYPE_NESSTAR="nesstar"; + + /* + * Different harvesting "styles". These define how we format and + * display meatada harvested from various remote resources. + */ public static final String HARVEST_STYLE_DATAVERSE="dataverse"; // pre-4.0 remote Dataverse: public static final String HARVEST_STYLE_VDC="vdc"; @@ -71,7 +80,30 @@ public void setId(Long id) { public static final String HARVEST_STYLE_ROPER="roper"; public static final String HARVEST_STYLE_HGL="hgl"; public static final String HARVEST_STYLE_DEFAULT="default"; + + public static final String HARVEST_STYLE_DESCRIPTION_DATAVERSE="Dataverse v4+"; + // pre-4.0 remote Dataverse: + public static final String HARVEST_STYLE_DESCRIPTION_VDC="DVN, v2-3"; + public static final String HARVEST_STYLE_DESCRIPTION_ICPSR="ICPSR"; + public static final String HARVEST_STYLE_DESCRIPTION_NESSTAR="Nesstar archive"; + public static final String HARVEST_STYLE_DESCRIPTION_ROPER="Roper Archive"; + public static final String HARVEST_STYLE_DESCRIPTION_HGL="HGL"; + public static final String HARVEST_STYLE_DESCRIPTION_DEFAULT="Generic OAI resource (DC)"; + + + public static final List HARVEST_STYLE_LIST = Arrays.asList(HARVEST_STYLE_DATAVERSE, HARVEST_STYLE_VDC, HARVEST_STYLE_NESSTAR, HARVEST_STYLE_ROPER, HARVEST_STYLE_HGL, HARVEST_STYLE_DEFAULT); + public static final List HARVEST_STYLE_DESCRIPTION_LIST = Arrays.asList(HARVEST_STYLE_DESCRIPTION_DATAVERSE, HARVEST_STYLE_DESCRIPTION_VDC, HARVEST_STYLE_DESCRIPTION_NESSTAR, HARVEST_STYLE_DESCRIPTION_ROPER, HARVEST_STYLE_DESCRIPTION_HGL, HARVEST_STYLE_DESCRIPTION_DEFAULT); + + public static final Map HARVEST_STYLE_INFOMAP = new LinkedHashMap(); + + static { + for (int i=0; i< HARVEST_STYLE_LIST.size(); i++){ + HARVEST_STYLE_INFOMAP.put(HARVEST_STYLE_LIST.get(i), HARVEST_STYLE_DESCRIPTION_LIST.get(i)); + } + } + + public static final String REMOTE_ARCHIVE_URL_LEVEL_DATAVERSE="dataverse"; public static final String REMOTE_ARCHIVE_URL_LEVEL_DATASET="dataset"; public static final String REMOTE_ARCHIVE_URL_LEVEL_FILE="file"; diff --git a/src/main/webapp/harvestclients.xhtml b/src/main/webapp/harvestclients.xhtml index 4229dd6254a..c8c6885f8e1 100644 --- a/src/main/webapp/harvestclients.xhtml +++ b/src/main/webapp/harvestclients.xhtml @@ -4,19 +4,20 @@ xmlns:f="http://java.sun.com/jsf/core" xmlns:ui="http://java.sun.com/jsf/facelets" xmlns:jsf="http://xmlns.jcp.org/jsf" - xmlns:p="http://primefaces.org/ui"> + xmlns:p="http://primefaces.org/ui" + xmlns:c="http://xmlns.jcp.org/jsp/jstl/core"> - - - + + + - + @@ -25,14 +26,15 @@
    + actionListener="#{harvestingClientsPage.initNewClient}" + update="newHarvestingClientDialog" + oncomplete="PF('newHarvestingClientForm').show();handleResizeDialog('newHarvestingClientDialog');bind_bsui_components();"> Add Client
    -
    +

      @@ -41,41 +43,41 @@

    - - - + + + - + - + - +
    @@ -85,13 +87,13 @@ - +

    - #{bundle['harvestclients.tab.header.action.btn.delete.dialog.tip']} + #{bundle['harvestclients.tab.header.action.btn.delete.dialog.tip']}(#{harvestingClientsPage.getSelectedClient().name})

    - - + +
    @@ -111,12 +113,12 @@
    + value="#{harvestingClientsPage.newNickname}" + + binding="#{harvestingClientsPage.newClientNicknameInputField}"/> +

    #{bundle['harvestclients.newClientDialog.nickname.helptext']}

    @@ -130,7 +132,7 @@
    - + @@ -147,21 +149,146 @@
    + value="#{harvestingClientsPage.newHarvestingUrl}" + binding="#{harvestingClientsPage.newClientUrlInputField}"/> + - +

    #{bundle['harvestclients.newClientDialog.url.helptext']}

    + + +
    +
    + + + + + +
    + +
    + + + + + + + + + +

    #{bundle['harvestclients.newClientDialog.oaiSets.helptext']}

    +

    #{bundle['harvestclients.newClientDialog.oaiSets.helptext.noset']}

    + +
    +
    + + + +
    + +
    + + + + + + + + + +

    #{bundle['harvestclients.newClientDialog.oaiMetadataFormat.helptext']}

    + +
    +
    + + + +
    + +
    + + + + + + + + -

    #{bundle['harvestclients.newClientDialog.url.helptext']}

    + + + + + + + + + + + + + + + + + + + + + +

    #{bundle['harvestclients.newClientDialog.schedule.helptext']}

    + +
    +
    + + + + + +
    + +
    + + + + + + + + +

    #{bundle['harvestclients.newClientDialog.harvestingStyle.helptext']}

    +
    @@ -169,21 +296,23 @@
    - +
    From 165f5e19b63eaef3ffdb055b93bd09bd31c245ab Mon Sep 17 00:00:00 2001 From: Leonid Andreev Date: Wed, 18 May 2016 11:39:37 -0400 Subject: [PATCH 011/267] More code for the harvesting client infrastructure. --- src/main/java/Bundle.properties | 12 +- .../iq/dataverse/HarvestingClientsPage.java | 293 ++++++++++++++++-- .../impl/CreateHarvestingClientCommand.java | 5 +- .../harvest/client/HarvesterServiceBean.java | 2 +- .../harvest/client/HarvestingClient.java | 1 - .../harvest/client/oai/OaiHandler.java | 130 ++++++++ .../client/oai/OaiHandlerException.java | 21 ++ src/main/webapp/dataverse_header.xhtml | 8 +- src/main/webapp/harvestclients.xhtml | 36 ++- 9 files changed, 463 insertions(+), 45 deletions(-) create mode 100644 src/main/java/edu/harvard/iq/dataverse/harvest/client/oai/OaiHandler.java create mode 100644 src/main/java/edu/harvard/iq/dataverse/harvest/client/oai/OaiHandlerException.java diff --git a/src/main/java/Bundle.properties b/src/main/java/Bundle.properties index 8a9b5cb06c1..1317239f5c7 100755 --- a/src/main/java/Bundle.properties +++ b/src/main/java/Bundle.properties @@ -70,6 +70,7 @@ header.guides.api=API Guide header.signUp=Sign Up header.logOut=Log Out header.accountInfo=Account Information +header.dashboard=Dashboard header.user.selectTab.dataRelated=My Data header.user.selectTab.notifications=Notifications header.user.selectTab.groupsAndRoles=Groups + Roles @@ -218,7 +219,7 @@ harvestclients.tab.header.action.btn.delete.dialog.header=Delete Harvesting Clie harvestclients.tab.header.action.btn.delete.dialog.tip=Are you sure you want to delete this client? You cannot undo a delete. harvestclients.newClientDialog.title.new=Create New Harvesting Client -harvestclients.newClientDialog.help=Configure a new client to harvest content from a remote server. +harvestclients.newClientDialog.help=Configure a new client to harvest content from a remote server. You'll be able schedule harvesting to take place on a regular basis, or leave it unscheduled, to be run on demand. harvestclients.newClientDialog.nickname=Nickname harvestclients.newClientDialog.nickname.helptext=Consists of letters, digits, underscores (_) and dashes (-) harvestclients.newClientDialog.nickname.required=Client nickname cannot be empty! @@ -231,9 +232,10 @@ harvestclients.newClientDialog.type.OAI=OAI harvestclients.newClientDialog.type.Nesstar=Nesstar harvestclients.newClientDialog.url=Server Url -harvestclients.newClientDialog.url.helptext=URL of a harvesting resource. We will try to establish connection to the server in order to verify that it is working, and to obtain extra information about its capabilities. +harvestclients.newClientDialog.url.helptext.notvalidated=URL of a harvesting resource. Once you click 'Next', we will try to establish connection to the server in order to verify that it is working, and to obtain extra information about its capabilities. +harvestclients.newClientDialog.url.helptext=URL of a harvesting resource. harvestclients.newClientDialog.url.required=A valid harvesting server address is required -harvestclients.newClientDialog.url.invalid=Not a valid URL +harvestclients.newClientDialog.url.invalid=Invalid URL. Failed to establish connection and receive a valid server response. harvestclients.newClientDialog.url.noresponse=Failed to establish connection to the server harvestclients.newClientDialog.url.badresponse=Invalid response from the server harvestclients.newClientDialog.btn.initialsettings.validate=Next @@ -254,6 +256,10 @@ harvestclients.newClientDialog.schedule=Schedule harvestclients.newClientDialog.schedule.none=None harvestclients.newClientDialog.schedule.daily=Daily harvestclients.newClientDialog.schedule.weekly=Weekly +harvestclients.newClientDialog.schedule.time=Time +harvestclients.newClientDialog.schedule.day=Day +harvestclients.newClientDialog.schedule.time.am=AM +harvestclients.newClientDialog.schedule.time.pm=PM harvestclients.newClientDialog.schedule.helptext=Harvesting can be scheduled to run daily or weekly, automatically. If enabled, you will be prompted to choose the day of week and/or time of day to run scheduled harvests. harvestclients.newClientDialog.btn.create=Create diff --git a/src/main/java/edu/harvard/iq/dataverse/HarvestingClientsPage.java b/src/main/java/edu/harvard/iq/dataverse/HarvestingClientsPage.java index 36a732e87c5..a2bca7ac3ba 100644 --- a/src/main/java/edu/harvard/iq/dataverse/HarvestingClientsPage.java +++ b/src/main/java/edu/harvard/iq/dataverse/HarvestingClientsPage.java @@ -6,12 +6,18 @@ package edu.harvard.iq.dataverse; import edu.harvard.iq.dataverse.authorization.AuthenticationServiceBean; +import edu.harvard.iq.dataverse.engine.command.exception.CommandException; +import edu.harvard.iq.dataverse.engine.command.impl.CreateHarvestingClientCommand; import edu.harvard.iq.dataverse.harvest.client.HarvestingClient; import edu.harvard.iq.dataverse.harvest.client.HarvestingClientServiceBean; +import edu.harvard.iq.dataverse.harvest.client.oai.OaiHandler; +import edu.harvard.iq.dataverse.harvest.client.oai.OaiHandlerException; +import edu.harvard.iq.dataverse.util.JsfHelper; import static edu.harvard.iq.dataverse.util.JsfHelper.JH; import java.util.ArrayList; import java.util.Arrays; import java.util.List; +import java.util.logging.Level; import java.util.logging.Logger; import java.util.regex.Pattern; import javax.ejb.EJB; @@ -44,6 +50,10 @@ public class HarvestingClientsPage implements java.io.Serializable { DataverseServiceBean dataverseService; @EJB HarvestingClientServiceBean harvestingClientService; + @EJB + EjbDataverseEngine engineService; + @Inject + DataverseRequestServiceBean dvRequestService; private List configuredHarvestingClients; private Dataverse dataverse; @@ -51,17 +61,22 @@ public class HarvestingClientsPage implements java.io.Serializable { private HarvestingClient selectedClient; - public void init() { + public String init() { + if (!isSessionUserAuthenticated()) { + return "/loginpage.xhtml" + DataverseHeaderFragment.getRedirectPage(); + } else if (!isSuperUser()) { + return "/403.xhtml"; + } + if (dataverseId != null) { setDataverse(dataverseService.find(getDataverseId())); } else { setDataverse(dataverseService.findRootDataverse()); } configuredHarvestingClients = harvestingClientService.getAllHarvestingClients(); - harvestTypeRadio = harvestTypeRadioOAI; - //selectedClient = new HarvestingClient(); FacesContext.getCurrentInstance().addMessage(null, new FacesMessage(FacesMessage.SEVERITY_INFO, "Harvesting Client Settings", JH.localize("harvestclients.toptip"))); + return null; } public List getConfiguredHarvestingClients() { @@ -96,7 +111,72 @@ public HarvestingClient getSelectedClient() { return selectedClient; } - public void addClient(ActionEvent ae) { + public void createClient(ActionEvent ae) { + //FacesContext.getCurrentInstance().addMessage(getNewClientNicknameInputField().getClientId(), + // new FacesMessage(FacesMessage.SEVERITY_ERROR, "", JH.localize("harvestclients.newClientDialog.nickname.alreadyused"))); + //getNewClientNicknameInputField().setValid(false); + //FacesContext.getCurrentInstance().validationFailed(); + //JsfHelper.JH.addMessage(FacesMessage.SEVERITY_ERROR, + // "Failed to create client.", + // "(for no good reason)"); + + HarvestingClient newHarvestingClient = new HarvestingClient(); // will be set as type OAI by default + + newHarvestingClient.setName(newNickname); + newHarvestingClient.setDataverse(dataverse); + dataverse.setHarvestingClientConfig(newHarvestingClient); + newHarvestingClient.setHarvestingUrl(newHarvestingUrl); + if (!StringUtils.isEmpty(newOaiSet)) { + newHarvestingClient.setHarvestingSet(newOaiSet); + } + newHarvestingClient.setMetadataPrefix(newMetadataFormat); + newHarvestingClient.setHarvestStyle(newHarvestingStyle); + + if (isNewHarvestingScheduled()) { + newHarvestingClient.setScheduled(true); + + if (isNewHarvestingScheduledWeekly()) { + newHarvestingClient.setSchedulePeriod(HarvestingClient.SCHEDULE_PERIOD_WEEKLY); + if (getWeekDayNumber() == null) { + // create a "week day is required..." error message, etc. + // but we may be better off not even giving them an opportunity + // not to leave the field blank - ? + } + newHarvestingClient.setScheduleDayOfWeek(getWeekDayNumber()); + } else { + newHarvestingClient.setSchedulePeriod(HarvestingClient.SCHEDULE_PERIOD_DAILY); + } + + if (getHourOfDay() == null) { + // see the comment above, about the day of week. same here. + } + newHarvestingClient.setScheduleHourOfDay(getHourOfDay()); + } + + newHarvestingClient.setArchiveUrl(getArchiveUrl()); + + // will try to save it now: + + try { + newHarvestingClient = engineService.submit( new CreateHarvestingClientCommand(dvRequestService.getDataverseRequest(), newHarvestingClient)); + + configuredHarvestingClients = harvestingClientService.getAllHarvestingClients(); + + JsfHelper.addSuccessMessage("Succesfully created harvesting client " + newHarvestingClient.getName()); + + } /* catch ( CreateHarvestingClientCommand.NicknameAlreadyExistsException naee ) { + FacesContext.getCurrentInstance().addMessage(newHarvestingClient.getName(), + new FacesMessage( FacesMessage.SEVERITY_ERROR, naee.getMessage(), null)); + + }*/ catch (CommandException ex) { + logger.log(Level.WARNING, "Harvesting client creation command failed", ex); + JsfHelper.JH.addMessage(FacesMessage.SEVERITY_ERROR, + "Harvesting Client creation command failed.", + ex.getMessage()); + } catch (Exception ex) { + JH.addMessage(FacesMessage.SEVERITY_FATAL, "Harvesting client creation failed (reason unknown)."); + logger.log(Level.SEVERE, "Harvesting client creation failed (reason unknown)." + ex.getMessage(), ex); + } } @@ -140,18 +220,60 @@ public boolean validateNickname() { return false; } - public boolean validateServerUrlOAI() { + public boolean validateServerUrlOAI() { if (!StringUtils.isEmpty(getNewHarvestingUrl())) { - if (false) { - FacesContext.getCurrentInstance().addMessage(getNewClientUrlInputField().getClientId(), - new FacesMessage(FacesMessage.SEVERITY_ERROR, "", getNewHarvestingUrl() + ": " + JH.localize("harvestclients.newClientDialog.url.invalid"))); - return false; + + OaiHandler oaiHandler = new OaiHandler(getNewHarvestingUrl()); + boolean success = true; + String message = null; + + // First, we'll try to obtain the list of supported metadata formats: + try { + List formats = oaiHandler.runListMetadataFormats(); + if (formats != null && formats.size() > 0) { + createOaiMetadataFormatSelectItems(formats); + } else { + success = false; + message = "received empty list from ListMetadataFormats"; + } + + // TODO: differentiate between different exceptions/failure scenarios } catch (OaiHandlerException ohee) { + } catch (Exception ex) { + success = false; + message = "Failed to execute listmetadataformats; " + ex.getMessage(); + + } + + if (success) { + logger.info("metadataformats: success"); + logger.info(getOaiMetadataFormatSelectItems().size() + " metadata formats total."); } else { + logger.info("metadataformats: failed"); + } + // And if that worked, the list of sets provided: + + if (success) { + try { + List sets = oaiHandler.runListSets(); + createOaiSetsSelectItems(sets); + } catch (Exception ex) { + //success = false; ok - let's try and live without sets... + message = "Failed to execute ListSets; " + ex.getMessage(); + logger.warning(message); + } + } + + if (success) { return true; } - } + + FacesContext.getCurrentInstance().addMessage(getNewClientUrlInputField().getClientId(), + new FacesMessage(FacesMessage.SEVERITY_ERROR, "", getNewHarvestingUrl() + ": " + JH.localize("harvestclients.newClientDialog.url.invalid"))); + return false; + + } FacesContext.getCurrentInstance().addMessage(getNewClientUrlInputField().getClientId(), - new FacesMessage(FacesMessage.SEVERITY_ERROR, "", getNewHarvestingUrl() + ": " + JH.localize("harvestclients.newClientDialog.url.required"))); + new FacesMessage(FacesMessage.SEVERITY_ERROR, "", getNewHarvestingUrl() + ": " + JH.localize("harvestclients.newClientDialog.url.required"))); return false; } @@ -172,17 +294,7 @@ public void validateInitialSettings() { } /* - public void validateClientUrl(FacesContext context, UIComponent toValidate, Object rawValue) { - String value = (String) rawValue; - UIInput input = (UIInput) toValidate; - input.setValid(true); - - // ... - } - */ - - /* - * Values and methods for creating a new harvesting client: + * Variables and methods for creating a new harvesting client: */ private int harvestTypeRadio; // 1 = OAI; 2 = Nesstar @@ -204,8 +316,8 @@ public void validateClientUrl(FacesContext context, UIComponent toValidate, Obje private static final int harvestingScheduleRadioDaily = 1; private static final int harvestingScheduleRadioWeekly = 2; - private String newHarvestingScheduleDayOfWeek = ""; - private String newHarvestingScheduleTimeOfDay = ""; + private String newHarvestingScheduleDayOfWeek = "Sunday"; + private String newHarvestingScheduleTimeOfDay = "12"; private int harvestingScheduleRadioAMPM; private static final int harvestingScheduleRadioAM = 0; @@ -229,9 +341,9 @@ public void initNewClient(ActionEvent ae) { this.harvestingScheduleRadioAMPM = harvestingScheduleRadioAM; - setOaiSetsSelectItems(new ArrayList<>()); - setOaiMetadataFormatSelectItems(new ArrayList<>()); - getOaiMetadataFormatSelectItems().add(new SelectItem("oai_dc", "oai_dc")); + //setOaiSetsSelectItems(new ArrayList<>()); + //setOaiMetadataFormatSelectItems(new ArrayList<>()); + //getOaiMetadataFormatSelectItems().add(new SelectItem("oai_dc", "oai_dc")); } @@ -344,6 +456,10 @@ public void setHarvestingScheduleRadioAMPM(int harvestingScheduleRadioAMPM) { this.harvestingScheduleRadioAMPM = harvestingScheduleRadioAMPM; } + public boolean isHarvestingScheduleTimeOfDayPM() { + return getHarvestingScheduleRadioAMPM() == harvestingScheduleRadioPM; + } + public void toggleNewClientSchedule() { } @@ -375,6 +491,17 @@ public void setOaiSetsSelectItems(List oaiSetsSelectItems) { this.oaiSetsSelectItems = oaiSetsSelectItems; } + private void createOaiSetsSelectItems(List setNames) { + setOaiSetsSelectItems(new ArrayList<>()); + if (setNames != null) { + for (String set: setNames) { + if (!StringUtils.isEmpty(set)) { + getOaiSetsSelectItems().add(new SelectItem(set, set)); + } + } + } + } + private List oaiMetadataFormatSelectItems; public List getOaiMetadataFormatSelectItems() { @@ -385,6 +512,18 @@ public void setOaiMetadataFormatSelectItems(List oaiMetadataFormatSe this.oaiMetadataFormatSelectItems = oaiMetadataFormatSelectItems; } + private void createOaiMetadataFormatSelectItems(List formats) { + setOaiMetadataFormatSelectItems(new ArrayList<>()); + if (formats != null) { + for (String f: formats) { + if (!StringUtils.isEmpty(f)) { + getOaiMetadataFormatSelectItems().add(new SelectItem(f, f)); + } + } + } + } + + private List harvestingStylesSelectItems = null; public List getHarvestingStylesSelectItems() { @@ -404,11 +543,12 @@ public void setHarvestingStylesSelectItems(List harvestingStylesSele this.harvestingStylesSelectItems = harvestingStylesSelectItems; } + private List weekDays = null; private List daysOfWeekSelectItems = null; public List getDaysOfWeekSelectItems() { if (this.daysOfWeekSelectItems == null) { - List weekDays = Arrays.asList("Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday", "Sunday"); + List weekDays = getWeekDays(); this.daysOfWeekSelectItems = new ArrayList<>(); for (int i = 0; i < weekDays.size(); i++) { this.daysOfWeekSelectItems.add(new SelectItem(weekDays.get(i), weekDays.get(i))); @@ -418,7 +558,104 @@ public List getDaysOfWeekSelectItems() { return this.daysOfWeekSelectItems; } + private List getWeekDays() { + if (weekDays == null) { + weekDays = Arrays.asList("Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday", "Sunday"); + } + return weekDays; + } + + private Integer getWeekDayNumber (String weekDayName) { + List weekDays = getWeekDays(); + int i = 0; + for (String weekDayString: weekDays) { + if (weekDayString.equals(weekDayName)) { + return new Integer(i); + } + i++; + } + return null; + } + + private Integer getWeekDayNumber() { + return getWeekDayNumber(getNewHarvestingScheduleDayOfWeek()); + } + + private Integer getHourOfDay() { + Integer hour = null; + if (getNewHarvestingScheduleTimeOfDay() != null) { + try { + hour = new Integer(getNewHarvestingScheduleTimeOfDay()); + } catch (Exception ex) { + hour = null; + } + } + + if (hour != null) { + if (hour.intValue() == 12) { + hour = 0; + } + if (isHarvestingScheduleTimeOfDayPM()) { + hour = hour + 12; + } + } + + return hour; + } + + private String getArchiveUrl() { + String archiveUrl = null; + + if (getNewHarvestingUrl() != null) { + int k = getNewHarvestingUrl().indexOf('/', 8); + if (k > -1) { + archiveUrl = getNewHarvestingUrl().substring(0, k); + } + } + + return archiveUrl; + } + public void setDaysOfWeekSelectItems(List daysOfWeekSelectItems) { this.daysOfWeekSelectItems = daysOfWeekSelectItems; } + + private List hoursOfDaySelectItems = null; + + public List getHoursOfDaySelectItems() { + if (this.hoursOfDaySelectItems == null) { + this.hoursOfDaySelectItems = new ArrayList<>(); + this.hoursOfDaySelectItems.add(new SelectItem( 12+"", "12:00")); + for (int i = 1; i < 12; i++) { + this.hoursOfDaySelectItems.add(new SelectItem(i+"", i+":00")); + } + } + + return this.hoursOfDaySelectItems; + } + + public void setHoursOfDaySelectItems(List hoursOfDaySelectItems) { + this.hoursOfDaySelectItems = hoursOfDaySelectItems; + } + + public boolean isSessionUserAuthenticated() { + + if (session == null) { + return false; + } + + if (session.getUser() == null) { + return false; + } + + if (session.getUser().isAuthenticated()) { + return true; + } + + return false; + } + + public boolean isSuperUser() { + return session.getUser().isSuperuser(); + } } diff --git a/src/main/java/edu/harvard/iq/dataverse/engine/command/impl/CreateHarvestingClientCommand.java b/src/main/java/edu/harvard/iq/dataverse/engine/command/impl/CreateHarvestingClientCommand.java index 6a5f0d31037..bf06fbd23b4 100644 --- a/src/main/java/edu/harvard/iq/dataverse/engine/command/impl/CreateHarvestingClientCommand.java +++ b/src/main/java/edu/harvard/iq/dataverse/engine/command/impl/CreateHarvestingClientCommand.java @@ -30,7 +30,10 @@ public HarvestingClient execute(CommandContext ctxt) throws CommandException { // TODO: check if the harvesting client config is legit; // and that it is indeed new and unique? // (may not be necessary - as the uniqueness should be enforced by - // the persistence layer... -- L.A. 4.4) + // the persistence layer... - but could still be helpful to have a dedicated + // custom exception for "nickname already taken". see CreateExplicitGroupCommand + // for an example. -- L.A. 4.4) + return ctxt.em().merge(this.harvestingClient); } diff --git a/src/main/java/edu/harvard/iq/dataverse/harvest/client/HarvesterServiceBean.java b/src/main/java/edu/harvard/iq/dataverse/harvest/client/HarvesterServiceBean.java index 26cfd3d6679..acf599266e4 100644 --- a/src/main/java/edu/harvard/iq/dataverse/harvest/client/HarvesterServiceBean.java +++ b/src/main/java/edu/harvard/iq/dataverse/harvest/client/HarvesterServiceBean.java @@ -389,7 +389,7 @@ public Long getRecord(DataverseRequest dataverseRequest, Logger hdLogger, Harves } else { hdLogger.log(Level.INFO, "Successfully retrieved GetRecord response."); - ///harvestedDataset = importService.doImportHarvestedDataset(dataverseRequest, parentDataverse, metadataPrefix, record.getMetadataFile(), null); + harvestedDataset = importService.doImportHarvestedDataset(dataverseRequest, harvestingClient.getDataverse(), metadataPrefix, record.getMetadataFile(), null); hdLogger.log(Level.INFO, "Harvest Successful for identifier " + identifier); hdLogger.info("Size of this record: " + record.getMetadataFile().length()); diff --git a/src/main/java/edu/harvard/iq/dataverse/harvest/client/HarvestingClient.java b/src/main/java/edu/harvard/iq/dataverse/harvest/client/HarvestingClient.java index 127c8feed2a..86dc2a20f89 100644 --- a/src/main/java/edu/harvard/iq/dataverse/harvest/client/HarvestingClient.java +++ b/src/main/java/edu/harvard/iq/dataverse/harvest/client/HarvestingClient.java @@ -154,7 +154,6 @@ public void setHarvestType(String harvestType) { this.harvestType = harvestType; } - public boolean isOai() { return HARVEST_TYPE_OAI.equals(harvestType); } diff --git a/src/main/java/edu/harvard/iq/dataverse/harvest/client/oai/OaiHandler.java b/src/main/java/edu/harvard/iq/dataverse/harvest/client/oai/OaiHandler.java new file mode 100644 index 00000000000..5c7d346e1c3 --- /dev/null +++ b/src/main/java/edu/harvard/iq/dataverse/harvest/client/oai/OaiHandler.java @@ -0,0 +1,130 @@ +/* + * To change this license header, choose License Headers in Project Properties. + * To change this template file, choose Tools | Templates + * and open the template in the editor. + */ +package edu.harvard.iq.dataverse.harvest.client.oai; + +import com.lyncode.xoai.model.oaipmh.Granularity; +import com.lyncode.xoai.model.oaipmh.MetadataFormat; +import com.lyncode.xoai.model.oaipmh.Set; +import com.lyncode.xoai.serviceprovider.ServiceProvider; +import com.lyncode.xoai.serviceprovider.client.HttpOAIClient; +import com.lyncode.xoai.serviceprovider.exceptions.InvalidOAIResponse; +import com.lyncode.xoai.serviceprovider.exceptions.NoSetHierarchyException; +import com.lyncode.xoai.serviceprovider.model.Context; +import com.lyncode.xoai.serviceprovider.parameters.ListIdentifiersParameters; +import java.io.Serializable; +import java.net.URLEncoder; +import java.util.ArrayList; +import java.util.Date; +import java.util.Iterator; +import java.util.List; +import java.util.logging.Level; +import org.codehaus.plexus.util.StringUtils; + +/** + * + * @author Leonid Andreev + */ +public class OaiHandler implements Serializable { + + public OaiHandler() { + + } + + public OaiHandler(String baseOaiUrl) { + this.baseOaiUrl = baseOaiUrl; + } + + public OaiHandler(String baseOaiUrl, String setName) { + this.baseOaiUrl = baseOaiUrl; + this.setName = setName; + } + + private String baseOaiUrl; //= harvestingClient.getHarvestingUrl(); + private String metadataPrefix; // = harvestingClient.getMetadataPrefix(); + private String setName; + + private ServiceProvider serviceProvider; + + private ServiceProvider getServiceProvider() throws OaiHandlerException { + if (serviceProvider == null) { + if (baseOaiUrl == null) { + throw new OaiHandlerException("Could not instantiate Service Provider, missing OAI server URL."); + } + Context context = new Context(); + + context.withBaseUrl(baseOaiUrl); + context.withGranularity(Granularity.Second); + context.withOAIClient(new HttpOAIClient(baseOaiUrl)); + + serviceProvider = new ServiceProvider(context); + } + + return serviceProvider; + } + + public List runListSets() throws OaiHandlerException { + + ServiceProvider sp = getServiceProvider(); + + Iterator setIter; + + try { + setIter = sp.listSets(); + } catch (NoSetHierarchyException nshe) { + return null; + } catch (InvalidOAIResponse ior) { + throw new OaiHandlerException("No valid response received from the OAI server."); + } + + List sets = new ArrayList<>(); + + while ( setIter.hasNext()) { + Set set = setIter.next(); + String setSpec = set.getSpec(); + if (!StringUtils.isEmpty(setSpec)) { + sets.add(setSpec); + } + } + + if (sets.size() < 1) { + return null; + } + return sets; + + } + + public List runListMetadataFormats() throws OaiHandlerException { + ServiceProvider sp = getServiceProvider(); + + Iterator setIter; + + try { + setIter = sp.listMetadataFormats(); + } catch (InvalidOAIResponse ior) { + throw new OaiHandlerException("No valid response received from the OAI server."); + } + + List formats = new ArrayList<>(); + + while ( setIter.hasNext()) { + MetadataFormat format = setIter.next(); + String formatName = format.getMetadataPrefix(); + if (!StringUtils.isEmpty(formatName)) { + formats.add(formatName); + } + } + + if (formats.size() < 1) { + return null; + } + + return formats; + } + + public void runIdentify() { + + } +} diff --git a/src/main/java/edu/harvard/iq/dataverse/harvest/client/oai/OaiHandlerException.java b/src/main/java/edu/harvard/iq/dataverse/harvest/client/oai/OaiHandlerException.java new file mode 100644 index 00000000000..8689da51d37 --- /dev/null +++ b/src/main/java/edu/harvard/iq/dataverse/harvest/client/oai/OaiHandlerException.java @@ -0,0 +1,21 @@ +/* + * To change this license header, choose License Headers in Project Properties. + * To change this template file, choose Tools | Templates + * and open the template in the editor. + */ +package edu.harvard.iq.dataverse.harvest.client.oai; + +/** + * + * @author Leonid Andreev + */ +public class OaiHandlerException extends Exception { + public OaiHandlerException(String message) { + super(message); + } + + public OaiHandlerException(String message, Throwable cause) { + super(message, cause); + } + +} diff --git a/src/main/webapp/dataverse_header.xhtml b/src/main/webapp/dataverse_header.xhtml index e38a66e4c60..51961b19f5b 100644 --- a/src/main/webapp/dataverse_header.xhtml +++ b/src/main/webapp/dataverse_header.xhtml @@ -132,12 +132,18 @@ -
  • +
  • +
  • + + + +
  • +
  • diff --git a/src/main/webapp/harvestclients.xhtml b/src/main/webapp/harvestclients.xhtml index c8c6885f8e1..73f106105b4 100644 --- a/src/main/webapp/harvestclients.xhtml +++ b/src/main/webapp/harvestclients.xhtml @@ -108,12 +108,13 @@
    @@ -144,18 +145,19 @@
    -

    #{bundle['harvestclients.newClientDialog.url.helptext']}

    +

    #{bundle['harvestclients.newClientDialog.url.helptext']}

    + - + + + - + - + + + + + + @@ -298,16 +312,18 @@ +
    - + - From dcb4ba0e96db56330c100e8e7cdd1744fc9cd7f3 Mon Sep 17 00:00:00 2001 From: Danny Brooke Date: Thu, 2 Jun 2016 09:47:31 -0400 Subject: [PATCH 024/267] Add BibTex as a citation type --- doc/sphinx-guides/source/user/find-use-data.rst | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/doc/sphinx-guides/source/user/find-use-data.rst b/doc/sphinx-guides/source/user/find-use-data.rst index bb0c6773dad..557ea21f2f2 100755 --- a/doc/sphinx-guides/source/user/find-use-data.rst +++ b/doc/sphinx-guides/source/user/find-use-data.rst @@ -67,12 +67,12 @@ Once on a dataset page, you will see the title, citation, description, and sever Download Citation -------------------------- -You can find the citation for the dataset at the top of the dataset page in a blue box. Additionally, there is a Download Citation button that offers the option to download the citation as EndNote XML or RIS Format. +You can find the citation for the dataset at the top of the dataset page in a blue box. Additionally, there is a Download Citation button that offers the option to download the citation as EndNote XML, RIS Format, or BibTeX Format. Download Files ----------------- -Within the Files tab on a dataset page, a user can either Explore tabular data files using TwoRavens, Download All File Formats + Information or individually download the Original File Format, Tab Delimited Format, Variable Metadata, Data File Citation (RIS Format or EndNote XML), or Subset (options appear depending on file format). +Within the Files tab on a dataset page, a user can either Explore tabular data files using TwoRavens, Download All File Formats + Information or individually download the Original File Format, Tab Delimited Format, Variable Metadata, Data File Citation (EndNote XML, RIS Format, or BibTeX Format), or Subset (options appear depending on file format). To download more than one file at a time, select the files you would like to download and then click the Download button above the files. The selected files will download in zip format. From 2283d15b9c9a4cfe377c32c6c743ec07e685a17b Mon Sep 17 00:00:00 2001 From: Danny Brooke Date: Thu, 2 Jun 2016 09:57:03 -0400 Subject: [PATCH 025/267] Update button text for Download Citation --- doc/sphinx-guides/source/user/find-use-data.rst | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/doc/sphinx-guides/source/user/find-use-data.rst b/doc/sphinx-guides/source/user/find-use-data.rst index 557ea21f2f2..d8c7c0eacc2 100755 --- a/doc/sphinx-guides/source/user/find-use-data.rst +++ b/doc/sphinx-guides/source/user/find-use-data.rst @@ -67,7 +67,7 @@ Once on a dataset page, you will see the title, citation, description, and sever Download Citation -------------------------- -You can find the citation for the dataset at the top of the dataset page in a blue box. Additionally, there is a Download Citation button that offers the option to download the citation as EndNote XML, RIS Format, or BibTeX Format. +You can find the citation for the dataset at the top of the dataset page in a blue box. Additionally, there is a Cite Data button that offers the option to download the citation as EndNote XML, RIS Format, or BibTeX Format. Download Files ----------------- From 1ca2e9b0e1b64c20e64717707e9225e7b24e73b8 Mon Sep 17 00:00:00 2001 From: tdilauro Date: Mon, 6 Jun 2016 18:38:43 -0400 Subject: [PATCH 026/267] avoid null ids when migrating from DVN 3.6.3 to DV 4.x (#3161) --- scripts/migration/migration_presteps.txt | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/scripts/migration/migration_presteps.txt b/scripts/migration/migration_presteps.txt index 517a967db78..df2be493449 100644 --- a/scripts/migration/migration_presteps.txt +++ b/scripts/migration/migration_presteps.txt @@ -127,13 +127,13 @@ psql -h localhost -U postgres <4.0 database name> -f /tmp/dvn3_data.sql ---------------------------------------------- -- offsets -update _dvn3_vdcnetwork set id = id + (select max(id) from dvobject); -update _dvn3_vdc set id = id + (select max(id) from _dvn3_vdcnetwork); -update _dvn3_vdcrole set vdc_id = vdc_id + (select max(id) from _dvn3_vdcnetwork); -update _dvn3_vdc_usergroup set vdcs_id = vdcs_id + (select max(id) from _dvn3_vdcnetwork); -update _dvn3_vdc_linked_collections set vdc_id = vdc_id + (select max(id) from _dvn3_vdcnetwork); -update _dvn3_study set owner_id = owner_id + (select max(id) from _dvn3_vdcnetwork); -update _dvn3_vdccollection set owner_id = owner_id + (select max(id) from _dvn3_vdcnetwork); +update _dvn3_vdcnetwork set id = id + (select coalesce(max(id), 0) from dvobject); +update _dvn3_vdc set id = id + (select coalesce(max(id), 0) from _dvn3_vdcnetwork); +update _dvn3_vdcrole set vdc_id = vdc_id + (select coalesce(max(id), 0) from _dvn3_vdcnetwork); +update _dvn3_vdc_usergroup set vdcs_id = vdcs_id + (select coalesce(max(id), 0) from _dvn3_vdcnetwork); +update _dvn3_vdc_linked_collections set vdc_id = vdc_id + (select coalesce(max(id), 0) from _dvn3_vdcnetwork); +update _dvn3_study set owner_id = owner_id + (select coalesce(max(id), 0) from _dvn3_vdcnetwork); +update _dvn3_vdccollection set owner_id = owner_id + (select coalesce(max(id), 0) from _dvn3_vdcnetwork); -- note: need to determine what offset to use, based on the file scripts --update _dvn3_studyfile_vdcuser set studyfiles_id = studyfiles_id +100000; From 9b2edc7ce640bc8e780912e44cd791b7a55d2709 Mon Sep 17 00:00:00 2001 From: Leonid Andreev Date: Tue, 7 Jun 2016 13:57:16 -0400 Subject: [PATCH 027/267] A simple fix for the OaiClient constructor; Also, added the dataverse alias to the clients table in harvestclients.xhtml. --- .../harvard/iq/dataverse/harvest/client/oai/OaiHandler.java | 3 +++ src/main/webapp/harvestclients.xhtml | 2 +- 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/src/main/java/edu/harvard/iq/dataverse/harvest/client/oai/OaiHandler.java b/src/main/java/edu/harvard/iq/dataverse/harvest/client/oai/OaiHandler.java index e3d3f510ea5..da7553361e8 100644 --- a/src/main/java/edu/harvard/iq/dataverse/harvest/client/oai/OaiHandler.java +++ b/src/main/java/edu/harvard/iq/dataverse/harvest/client/oai/OaiHandler.java @@ -51,6 +51,9 @@ public OaiHandler(String baseOaiUrl, String metadataPrefix) { } public OaiHandler(HarvestingClient harvestingClient) throws OaiHandlerException { + this.baseOaiUrl = harvestingClient.getHarvestingUrl(); + this.metadataPrefix = harvestingClient.getMetadataPrefix(); + if (StringUtils.isEmpty(baseOaiUrl)) { throw new OaiHandlerException("Valid OAI url is needed to create a handler"); } diff --git a/src/main/webapp/harvestclients.xhtml b/src/main/webapp/harvestclients.xhtml index 18c3fb5850d..6a7e5a2cfd9 100644 --- a/src/main/webapp/harvestclients.xhtml +++ b/src/main/webapp/harvestclients.xhtml @@ -54,7 +54,7 @@ - {harvestClient.dataverseName} + From 63eac27b7a16d8d66960c330d889f1806e3b3039 Mon Sep 17 00:00:00 2001 From: Michael Heppler Date: Wed, 8 Jun 2016 15:41:59 -0400 Subject: [PATCH 028/267] Added initial wireframe for the step-process workflow to Create Harvesting Client popup on the Manage Harvesting Clients pg. [ref #813] --- src/main/webapp/harvestclients.xhtml | 141 +++++++++++++++++++++------ 1 file changed, 113 insertions(+), 28 deletions(-) diff --git a/src/main/webapp/harvestclients.xhtml b/src/main/webapp/harvestclients.xhtml index 6a7e5a2cfd9..66739cf0b7e 100644 --- a/src/main/webapp/harvestclients.xhtml +++ b/src/main/webapp/harvestclients.xhtml @@ -35,12 +35,10 @@
    -
    +

      - - - +

    @@ -54,7 +52,9 @@ - + + + @@ -112,8 +112,15 @@

    #{bundle['harvestclients.newClientDialog.help']}

    #{bundle['harvestclients.viewEditDialog.help']}

    +
    +
    + +
    +

    Step 1 of 4 - Connection

    +
    + @@ -162,7 +169,7 @@
    -
    +
    + + + +
    + +
    + +
    -
    +
    + +
    +

    Step 2 of 4 - Format

    +
    + @@ -202,7 +224,7 @@
    -
    +
    @@ -228,9 +250,35 @@

    #{bundle['harvestclients.newClientDialog.oaiMetadataFormat.helptext']}

    + +
    +
    + + + + + + +
    +
    + +
    + +
    -
    +
    + +
    +

    Step 3 of 4 - Schedule

    +
    + @@ -283,9 +331,35 @@

    #{bundle['harvestclients.newClientDialog.schedule.helptext']}

    + +
    +
    + + + + + + +
    +
    + +
    + +
    -
    +
    + +
    +

    Step 4 of 4 - Display

    +
    + @@ -327,25 +401,36 @@

    #{bundle['harvestclients.viewEditDialog.archiveDescription.helptext']}

    + +
    +
    + + + + + + + + +
    +
    + +
    +
    -
    - - - - - - -
    +
    From b0adedd23bc10d60b2e556bb7d7a8e4494bc2d48 Mon Sep 17 00:00:00 2001 From: Philip Durbin Date: Mon, 13 Jun 2016 10:33:51 -0400 Subject: [PATCH 029/267] Sword Auth Bug: 403/forbidded title changed in develop #1070 --- src/test/java/edu/harvard/iq/dataverse/api/SwordIT.java | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/test/java/edu/harvard/iq/dataverse/api/SwordIT.java b/src/test/java/edu/harvard/iq/dataverse/api/SwordIT.java index 2c7994b70e0..286471b01eb 100644 --- a/src/test/java/edu/harvard/iq/dataverse/api/SwordIT.java +++ b/src/test/java/edu/harvard/iq/dataverse/api/SwordIT.java @@ -231,13 +231,13 @@ public void testCreateDataverseCreateDatasetUploadFileDownloadFileEditTitle() { Response attemptToDownloadUnpublishedFileWithoutApiToken = UtilIT.downloadFile(fileId); attemptToDownloadUnpublishedFileWithoutApiToken.then().assertThat() - .body("html.head.title", equalTo("GlassFish Server Open Source Edition 4.1 - Error report")) + .body("html.head.title", equalTo("403 Not Authorized - Root Dataverse")) .statusCode(FORBIDDEN.getStatusCode()); Response attemptToDownloadUnpublishedFileUnauthApiToken = UtilIT.downloadFile(fileId, apiTokenNoPrivs); attemptToDownloadUnpublishedFileUnauthApiToken.prettyPrint(); attemptToDownloadUnpublishedFileUnauthApiToken.then().assertThat() - .body("html.head.title", equalTo("GlassFish Server Open Source Edition 4.1 - Error report")) + .body("html.head.title", equalTo("403 Not Authorized - Root Dataverse")) .statusCode(FORBIDDEN.getStatusCode()); Response downloadUnpublishedFileWithValidApiToken = UtilIT.downloadFile(fileId, apiToken); From e560a34e89b12a08b0e936e0cc8bd429f7a8c7c5 Mon Sep 17 00:00:00 2001 From: Stephen Kraffmiller Date: Mon, 13 Jun 2016 17:32:37 -0400 Subject: [PATCH 030/267] Initial Check In - Support of Service Providers Exporters Infrastructure --- pom.xml | 9 +- src/main/java/Bundle.properties | 4 + .../iq/dataverse/DatasetFieldConstant.java | 11 +- .../edu/harvard/iq/dataverse/DatasetPage.java | 42 +- .../harvard/iq/dataverse/api/Datasets.java | 55 +- .../iq/dataverse/export/DDIExporter.java | 38 + .../dataverse/export/DublinCoreExporter.java | 38 + .../iq/dataverse/export/ExportService.java | 84 + .../iq/dataverse/export/JSONExporter.java | 49 + .../dataverse/export/ddi/DdiExportUtil.java | 350 +- .../iq/dataverse/export/spi/Exporter.java | 25 + src/main/webapp/dataset.xhtml | 23 +- .../iq/dataverse/export/ddi/codebook.xsd | 8462 +++++++++++++++++ .../dataverse/export/ddi/dataset-finch1.xml | 7 +- .../dataverse/export/ddi/dataset-spruce1.xml | 5 + 15 files changed, 9153 insertions(+), 49 deletions(-) create mode 100644 src/main/java/edu/harvard/iq/dataverse/export/DDIExporter.java create mode 100644 src/main/java/edu/harvard/iq/dataverse/export/DublinCoreExporter.java create mode 100644 src/main/java/edu/harvard/iq/dataverse/export/ExportService.java create mode 100644 src/main/java/edu/harvard/iq/dataverse/export/JSONExporter.java create mode 100644 src/main/java/edu/harvard/iq/dataverse/export/spi/Exporter.java create mode 100644 src/test/java/edu/harvard/iq/dataverse/export/ddi/codebook.xsd diff --git a/pom.xml b/pom.xml index 288f2f7fb62..8f100d5dc11 100644 --- a/pom.xml +++ b/pom.xml @@ -374,7 +374,14 @@ log4j 1.2.17 - + + + com.google.auto.service + auto-service + 1.0-rc2 + true + jar + diff --git a/src/main/java/Bundle.properties b/src/main/java/Bundle.properties index 3c71a61e86e..3a6dfd6f661 100755 --- a/src/main/java/Bundle.properties +++ b/src/main/java/Bundle.properties @@ -799,6 +799,10 @@ dataset.editBtn.itemLabel.widgets=Widgets dataset.editBtn.itemLabel.deleteDataset=Delete Dataset dataset.editBtn.itemLabel.deleteDraft=Delete Draft Version dataset.editBtn.itemLabel.deaccession=Deaccession Dataset +dataset.exportBtn=Export Metadata +dataset.exportBtn.itemLabel.ddi=DDI +dataset.exportBtn.itemLabel.dublinCore=Dublin Core +dataset.exportBtn.itemLabel.json=JSON metrics.title=Metrics metrics.comingsoon=Coming soon... metrics.views=Views diff --git a/src/main/java/edu/harvard/iq/dataverse/DatasetFieldConstant.java b/src/main/java/edu/harvard/iq/dataverse/DatasetFieldConstant.java index f5275873b4c..11efd90455f 100644 --- a/src/main/java/edu/harvard/iq/dataverse/DatasetFieldConstant.java +++ b/src/main/java/edu/harvard/iq/dataverse/DatasetFieldConstant.java @@ -48,7 +48,8 @@ public class DatasetFieldConstant implements java.io.Serializable { public final static String title = "title"; - public final static String subTitle="subTitle"; + public final static String subTitle="subtitle"; //SEK 6-7-2016 to match what is in DB + public final static String alternativeTitle="alternativeTitle"; //missing from class public final static String datasetId = "datasetId"; public final static String authorName ="authorName"; public final static String authorAffiliation = "authorAffiliation"; @@ -86,20 +87,24 @@ public class DatasetFieldConstant implements java.io.Serializable { public final static String datasetVersionValue="datasetVersionValue"; public final static String versionDate="versionDate"; public final static String keywordValue="keywordValue"; - public final static String keywordVocab="keywordVocab"; - public final static String keywordVocabURI="keywordVocabURI"; + public final static String keywordVocab="keywordVocabulary"; //SEK 6/10/2016 to match what is in the db + public final static String keywordVocabURI="keywordVocabularyURI"; //SEK 6/10/2016 to match what is in the db public final static String topicClassValue="topicClassValue"; public final static String topicClassVocab="topicClassVocab"; public final static String topicClassVocabURI="topicClassVocabURI"; public final static String descriptionText="dsDescriptionValue"; public final static String descriptionDate="descriptionDate"; + public final static String timePeriodCovered="timePeriodCovered"; // SEK added 6/13/2016 public final static String timePeriodCoveredStart="timePeriodCoveredStart"; public final static String timePeriodCoveredEnd="timePeriodCoveredEnd"; + public final static String dateOfCollection="dateOfCollection"; // SEK added 6/13/2016 public final static String dateOfCollectionStart="dateOfCollectionStart"; public final static String dateOfCollectionEnd="dateOfCollectionEnd"; public final static String country="country"; public final static String geographicCoverage="geographicCoverage"; public final static String otherGeographicCoverage="otherGeographicCoverage"; + public final static String city="city"; // SEK added 6/13/2016 + public final static String state="state"; // SEK added 6/13/2016 public final static String geographicUnit="geographicUnit"; public final static String westLongitude="westLongitude"; public final static String eastLongitude="eastLongitude"; diff --git a/src/main/java/edu/harvard/iq/dataverse/DatasetPage.java b/src/main/java/edu/harvard/iq/dataverse/DatasetPage.java index e4c44d36b5c..d04b31b2baf 100644 --- a/src/main/java/edu/harvard/iq/dataverse/DatasetPage.java +++ b/src/main/java/edu/harvard/iq/dataverse/DatasetPage.java @@ -18,6 +18,8 @@ import edu.harvard.iq.dataverse.engine.command.impl.PublishDatasetCommand; import edu.harvard.iq.dataverse.engine.command.impl.PublishDataverseCommand; import edu.harvard.iq.dataverse.engine.command.impl.UpdateDatasetCommand; +import edu.harvard.iq.dataverse.export.ExportService; +import edu.harvard.iq.dataverse.export.spi.Exporter; import edu.harvard.iq.dataverse.ingest.IngestRequest; import edu.harvard.iq.dataverse.ingest.IngestServiceBean; import edu.harvard.iq.dataverse.metadataimport.ForeignMetadataImportServiceBean; @@ -3404,7 +3406,7 @@ public String getVariableMetadataURL(Long fileid) { } public String getTabularDataFileURL(Long fileid) { - String myHostURL = getDataverseSiteUrl();; + String myHostURL = getDataverseSiteUrl(); String dataURL = myHostURL + "/api/access/datafile/" + fileid; return dataURL; @@ -3421,6 +3423,44 @@ public String getMetadataAsJsonUrl() { } return null; } + + public List< String[]> getExporters(){ + List retList = new ArrayList(); + String myHostURL = getDataverseSiteUrl(); + for (String [] provider : ExportService.getInstance().getExportersLabels() ){ + String[] temp = new String[2]; + temp[0] = provider[0]; + temp[1] = myHostURL + "/api/datasets/export?exporter=" + provider[1]+ "&persistentId=" + dataset.getGlobalId(); + retList.add(temp); + } + return retList; + } + + public String getExportMetadataDDIUrl() { + if (dataset != null) { + Long datasetId = dataset.getId(); + if (datasetId != null) { + String myHostURL = getDataverseSiteUrl(); + String metadataAsJsonUrl = myHostURL + "/api/datasets/" + datasetId + "/export?exporter=DDI&persistentId=" + dataset.getGlobalId(); + System.out.print(metadataAsJsonUrl); + return metadataAsJsonUrl; + } + } + return null; + } + + public String getExportMetadataDublinCoreUrl() { + if (dataset != null) { + Long datasetId = dataset.getId(); + if (datasetId != null) { + String myHostURL = getDataverseSiteUrl(); + String metadataAsJsonUrl = myHostURL + "/api/datasets/" + datasetId + "/export?exporter=DublinCore&persistentId=" + dataset.getGlobalId(); + System.out.print(metadataAsJsonUrl); + return metadataAsJsonUrl; + } + } + return null; + } private FileMetadata fileMetadataSelected = null; diff --git a/src/main/java/edu/harvard/iq/dataverse/api/Datasets.java b/src/main/java/edu/harvard/iq/dataverse/api/Datasets.java index f61636862c3..1a48fed5d99 100644 --- a/src/main/java/edu/harvard/iq/dataverse/api/Datasets.java +++ b/src/main/java/edu/harvard/iq/dataverse/api/Datasets.java @@ -31,6 +31,7 @@ import edu.harvard.iq.dataverse.engine.command.impl.UpdateDatasetTargetURLCommand; import edu.harvard.iq.dataverse.engine.command.impl.UpdateDatasetVersionCommand; import edu.harvard.iq.dataverse.export.DDIExportServiceBean; +import edu.harvard.iq.dataverse.export.ExportService; import edu.harvard.iq.dataverse.export.ddi.DdiExportUtil; import edu.harvard.iq.dataverse.util.SystemConfig; import edu.harvard.iq.dataverse.util.json.JsonParseException; @@ -76,7 +77,7 @@ public class Datasets extends AbstractApiBean { @EJB DDIExportServiceBean ddiExportService; - + @EJB SystemConfig systemConfig; @@ -93,21 +94,55 @@ private interface DsVersionHandler { @GET @Path("{id}") - public Response getDataset( @PathParam("id") String id) { - + public Response getDataset(@PathParam("id") String id) { + try { final DataverseRequest r = createDataverseRequest(findUserOrDie()); - + Dataset retrieved = execCommand(new GetDatasetCommand(r, findDatasetOrDie(id))); DatasetVersion latest = execCommand(new GetLatestAccessibleDatasetVersionCommand(r, retrieved)); final JsonObjectBuilder jsonbuilder = json(retrieved); - + return okResponse(jsonbuilder.add("latestVersion", (latest != null) ? json(latest) : null)); - } catch ( WrappedResponse ex ) { - return ex.refineResponse( "GETting dataset " + id + " failed." ); - } - + } catch (WrappedResponse ex) { + return ex.refineResponse("GETting dataset " + id + " failed."); + } + } + + + @GET + @Path("/export") + @Produces({"application/xml", "application/json"}) + public Response exportDataset(@QueryParam("persistentId") String persistentId, @QueryParam("exporter") String exporter) { + boolean ddiExportEnabled = systemConfig.isDdiExportEnabled(); + if (!ddiExportEnabled) { + return errorResponse(Response.Status.FORBIDDEN, "Disabled"); + } + try { + Dataset dataset = datasetService.findByGlobalId(persistentId); + if (dataset == null) { + return errorResponse(Response.Status.NOT_FOUND, "A dataset with the persistentId " + persistentId + " could not be found."); + } + + ExportService instance = ExportService.getInstance(); + final JsonObjectBuilder datasetAsJson = jsonAsDatasetDto(dataset.getLatestVersion()); + OutputStream output = instance.getExport(datasetAsJson.build(), exporter); + String xml = output.toString(); + LOGGER.fine("xml to return: " + xml); + String mediaType = MediaType.TEXT_PLAIN; + if (instance.isXMLFormat(exporter)){ + mediaType = MediaType.APPLICATION_XML; + } + return Response.ok() + .entity(xml) + .type(mediaType). + build(); + } catch (Exception wr) { + return errorResponse(Response.Status.FORBIDDEN, "Export Failed"); + } + } + @DELETE @Path("{id}") @@ -534,7 +569,7 @@ public Response getDdi(@QueryParam("id") long id, @QueryParam("persistentId") St return wr.getResponse(); } } - + /** * @todo Make this real. Currently only used for API testing. Copied from * the equivalent API endpoint for dataverses and simplified with values diff --git a/src/main/java/edu/harvard/iq/dataverse/export/DDIExporter.java b/src/main/java/edu/harvard/iq/dataverse/export/DDIExporter.java new file mode 100644 index 00000000000..04add262af4 --- /dev/null +++ b/src/main/java/edu/harvard/iq/dataverse/export/DDIExporter.java @@ -0,0 +1,38 @@ + +package edu.harvard.iq.dataverse.export; + +import com.google.auto.service.AutoService; +import edu.harvard.iq.dataverse.export.ddi.DdiExportUtil; +import edu.harvard.iq.dataverse.export.spi.Exporter; +import edu.harvard.iq.dataverse.util.BundleUtil; +import java.io.OutputStream; +import javax.json.JsonObject; + +/** + * + * @author skraffmi + */ +@AutoService(Exporter.class) +public class DDIExporter implements Exporter { + + @Override + public String getProvider() { + return "DDI"; + } + + @Override + public String getButtonLabel() { + return BundleUtil.getStringFromBundle("dataset.exportBtn.itemLabel.ddi") != null ? BundleUtil.getStringFromBundle("dataset.exportBtn.itemLabel.ddi") : "DDI"; + } + + @Override + public OutputStream exportDataset(JsonObject json) { + return DdiExportUtil.datasetJson2ddi(json); + } + + @Override + public Boolean isXMLFormat() { + return true; + } + +} diff --git a/src/main/java/edu/harvard/iq/dataverse/export/DublinCoreExporter.java b/src/main/java/edu/harvard/iq/dataverse/export/DublinCoreExporter.java new file mode 100644 index 00000000000..e8e84c6fd82 --- /dev/null +++ b/src/main/java/edu/harvard/iq/dataverse/export/DublinCoreExporter.java @@ -0,0 +1,38 @@ + +package edu.harvard.iq.dataverse.export; + +import com.google.auto.service.AutoService; +import edu.harvard.iq.dataverse.export.spi.Exporter; +import edu.harvard.iq.dataverse.util.BundleUtil; +import java.io.OutputStream; +import javax.json.JsonObject; + +/** + * + * @author skraffmi + */ +@AutoService(Exporter.class) +public class DublinCoreExporter implements Exporter { + + + @Override + public String getProvider() { + return "DublinCore"; + } + + @Override + public String getButtonLabel() { + return BundleUtil.getStringFromBundle("dataset.exportBtn.itemLabel.dublinCore") != null ? BundleUtil.getStringFromBundle("dataset.exportBtn.itemLabel.dublinCore") : "Dublin Core"; + } + + @Override + public OutputStream exportDataset(JsonObject json) { + throw new UnsupportedOperationException("Not supported yet."); //To change body of generated methods, choose Tools | Templates. + } + + @Override + public Boolean isXMLFormat() { + throw new UnsupportedOperationException("Not supported yet."); //To change body of generated methods, choose Tools | Templates. + } + +} diff --git a/src/main/java/edu/harvard/iq/dataverse/export/ExportService.java b/src/main/java/edu/harvard/iq/dataverse/export/ExportService.java new file mode 100644 index 00000000000..7ed3879f1c3 --- /dev/null +++ b/src/main/java/edu/harvard/iq/dataverse/export/ExportService.java @@ -0,0 +1,84 @@ + +package edu.harvard.iq.dataverse.export; + +import edu.harvard.iq.dataverse.export.spi.Exporter; +import java.io.OutputStream; +import java.util.ArrayList; +import java.util.Iterator; +import java.util.List; +import java.util.ServiceConfigurationError; +import java.util.ServiceLoader; +import javax.json.JsonObject; + +/** + * + * @author skraffmi + */ +public class ExportService { + + private static ExportService service; + private ServiceLoader loader; + + private ExportService() { + loader = ServiceLoader.load(Exporter.class); + } + + public static synchronized ExportService getInstance() { + if (service == null) { + service = new ExportService(); + } else{ + service.loader.reload(); + } + return service; + } + + public List< String[]> getExportersLabels() { + List retList = new ArrayList(); + Iterator exporters = ExportService.getInstance().loader.iterator(); + while (exporters.hasNext()) { + Exporter e = exporters.next(); + String[] temp = new String[2]; + temp[0] = e.getButtonLabel(); + temp[1] = e.getProvider(); + retList.add(temp); + } + return retList; + } + + public OutputStream getExport(JsonObject json, String provider) { + OutputStream retVal = null; + try { + Iterator exporters = loader.iterator(); + while (retVal == null && exporters.hasNext()) { + Exporter e = exporters.next(); + if (e.getProvider().equals(provider)) { + retVal = e.exportDataset(json); + break; + } + } + } catch (ServiceConfigurationError serviceError) { + retVal = null; + serviceError.printStackTrace(); + } + return retVal; + } + + public Boolean isXMLFormat(String provider){ + Boolean retVal = false; + try { + Iterator exporters = loader.iterator(); + while (exporters.hasNext()) { + Exporter e = exporters.next(); + if (e.getProvider().equals(provider)) { + retVal = e.isXMLFormat(); + break; + } + } + } catch (ServiceConfigurationError serviceError) { + retVal = null; + serviceError.printStackTrace(); + } + return retVal; + } + +} diff --git a/src/main/java/edu/harvard/iq/dataverse/export/JSONExporter.java b/src/main/java/edu/harvard/iq/dataverse/export/JSONExporter.java new file mode 100644 index 00000000000..22993f86796 --- /dev/null +++ b/src/main/java/edu/harvard/iq/dataverse/export/JSONExporter.java @@ -0,0 +1,49 @@ + +package edu.harvard.iq.dataverse.export; + +import com.google.auto.service.AutoService; +import edu.harvard.iq.dataverse.export.spi.Exporter; +import edu.harvard.iq.dataverse.util.BundleUtil; +import java.io.ByteArrayOutputStream; +import java.io.OutputStream; +import java.io.OutputStreamWriter; +import java.io.Writer; +import javax.json.JsonObject; + + +/** + * + * @author skraffmi + */ +@AutoService(Exporter.class) +public class JSONExporter implements Exporter { + + @Override + public String getProvider() { + return "json"; + } + + @Override + public String getButtonLabel() { + return BundleUtil.getStringFromBundle("dataset.exportBtn.itemLabel.json") != null ? BundleUtil.getStringFromBundle("dataset.exportBtn.itemLabel.json") : "JSON"; + } + + @Override + public OutputStream exportDataset(JsonObject json) { + OutputStream outputStream = new ByteArrayOutputStream(); + try{ + Writer w = new OutputStreamWriter(outputStream, "UTF-8"); + w.write(json.toString()); + w.close(); + } catch (Exception e){ + //just return waht we have... + } + return outputStream; + } + + @Override + public Boolean isXMLFormat() { + return false; + } + +} diff --git a/src/main/java/edu/harvard/iq/dataverse/export/ddi/DdiExportUtil.java b/src/main/java/edu/harvard/iq/dataverse/export/ddi/DdiExportUtil.java index 1e71ea35d31..c44ff96d3c7 100644 --- a/src/main/java/edu/harvard/iq/dataverse/export/ddi/DdiExportUtil.java +++ b/src/main/java/edu/harvard/iq/dataverse/export/ddi/DdiExportUtil.java @@ -20,6 +20,7 @@ import java.util.Map; import java.util.logging.Level; import java.util.logging.Logger; +import javax.json.JsonObject; import javax.xml.stream.XMLOutputFactory; import javax.xml.stream.XMLStreamException; import javax.xml.stream.XMLStreamWriter; @@ -39,8 +40,20 @@ public static String datasetDtoAsJson2ddi(String datasetDtoAsJson) { return null; } } - - private static String dto2ddi(DatasetDTO datasetDto) throws XMLStreamException { + + public static OutputStream datasetJson2ddi(JsonObject datasetDtoAsJson) { + logger.fine(JsonUtil.prettyPrint(datasetDtoAsJson.toString())); + Gson gson = new Gson(); + DatasetDTO datasetDto = gson.fromJson(datasetDtoAsJson.toString(), DatasetDTO.class); + try { + return dtoddi(datasetDto); + } catch (XMLStreamException ex) { + Logger.getLogger(DdiExportUtil.class.getName()).log(Level.SEVERE, null, ex); + return null; + } + } + + private static OutputStream dtoddi(DatasetDTO datasetDto) throws XMLStreamException { OutputStream outputStream = new ByteArrayOutputStream(); XMLStreamWriter xmlw = XMLOutputFactory.newInstance().createXMLStreamWriter(outputStream); xmlw.writeStartElement("codeBook"); @@ -50,10 +63,15 @@ private static String dto2ddi(DatasetDTO datasetDto) throws XMLStreamException { createdataDscr(xmlw, datasetDto.getDatasetVersion().getFiles()); xmlw.writeEndElement(); // codeBook xmlw.flush(); + return outputStream; + } + + private static String dto2ddi(DatasetDTO datasetDto) throws XMLStreamException { + OutputStream outputStream = dtoddi(datasetDto); String xml = outputStream.toString(); return XmlPrinter.prettyPrintXml(xml); } - + /** * @todo This is just a stub, copied from DDIExportServiceBean. It should * produce valid DDI based on @@ -65,43 +83,287 @@ private static String dto2ddi(DatasetDTO datasetDto) throws XMLStreamException { * @todo Rename this from "study" to "dataset". */ private static void createStdyDscr(XMLStreamWriter xmlw, DatasetDTO datasetDto) throws XMLStreamException { - String title = dto2title(datasetDto.getDatasetVersion()); - String authors = dto2authors(datasetDto.getDatasetVersion()); + DatasetVersionDTO version = datasetDto.getDatasetVersion(); String persistentAgency = datasetDto.getProtocol(); String persistentAuthority = datasetDto.getAuthority(); String persistentId = datasetDto.getIdentifier(); String citation = datasetDto.getDatasetVersion().getCitation(); + String producer = dto2producers(datasetDto.getDatasetVersion()); xmlw.writeStartElement("stdyDscr"); xmlw.writeStartElement("citation"); - xmlw.writeStartElement("titlStmt"); - - xmlw.writeStartElement("titl"); - xmlw.writeCharacters(title); - xmlw.writeEndElement(); // titl - + + writeFullElement(xmlw, "titl", dto2Primitive(version, DatasetFieldConstant.title)); + xmlw.writeStartElement("IDNo"); writeAttribute(xmlw, "agency", persistentAgency); xmlw.writeCharacters(persistentAuthority + "/" + persistentId); xmlw.writeEndElement(); // IDNo + + writeFullElement(xmlw, "subTitl", dto2Primitive(version, DatasetFieldConstant.subTitle)); + writeFullElement(xmlw, "altTitl", dto2Primitive(version, DatasetFieldConstant.alternativeTitle)); + xmlw.writeEndElement(); // titlStmt - xmlw.writeStartElement("rspStmt"); - - xmlw.writeStartElement("AuthEnty"); - xmlw.writeCharacters(authors); - xmlw.writeEndElement(); // AuthEnty - - xmlw.writeEndElement(); // rspStmt - + writeAuthorsElement(xmlw, version); + + if (producer != null && !producer.isEmpty()) { + xmlw.writeStartElement("rspStmt"); + xmlw.writeStartElement("producer"); + xmlw.writeCharacters(producer); + xmlw.writeEndElement(); // producer + xmlw.writeEndElement(); // rspStmt + } + xmlw.writeStartElement("biblCit"); xmlw.writeCharacters(citation); xmlw.writeEndElement(); // biblCit xmlw.writeEndElement(); // citation + //End Citation Block + + //Start Study Info Block + // Study Info + xmlw.writeStartElement("stdyInfo"); + + writeSubjectElement(xmlw, version); + writeFullElement(xmlw, "abstract", dto2ChildVal(version, DatasetFieldConstant.description, DatasetFieldConstant.descriptionText)); + + writeSummaryDescriptionElement(xmlw, version); + + xmlw.writeEndElement(); // stdyInfo + // End Info Block + xmlw.writeEndElement(); // stdyDscr } + + private static void writeSummaryDescriptionElement(XMLStreamWriter xmlw, DatasetVersionDTO datasetVersionDTO) throws XMLStreamException { + xmlw.writeStartElement("sumDscr"); + for (Map.Entry entry : datasetVersionDTO.getMetadataBlocks().entrySet()) { + String key = entry.getKey(); + MetadataBlockDTO value = entry.getValue(); + if ("citation".equals(key)) { + Integer per = 0; + Integer coll = 0; + for (FieldDTO fieldDTO : value.getFields()) { + if (DatasetFieldConstant.timePeriodCovered.equals(fieldDTO.getTypeName())) { + String dateValStart = ""; + String dateValEnd = ""; + for (HashSet foo : fieldDTO.getMultipleCompound()) { + per++; + for (Iterator iterator = foo.iterator(); iterator.hasNext();) { + FieldDTO next = iterator.next(); + if (DatasetFieldConstant.timePeriodCoveredStart.equals(next.getTypeName())) { + dateValStart = next.getSinglePrimitive(); + } + if (DatasetFieldConstant.timePeriodCoveredEnd.equals(next.getTypeName())) { + dateValEnd = next.getSinglePrimitive(); + } + } + if (!dateValStart.isEmpty()) { + writeDateElement(xmlw, "timePrd", "P"+ per.toString(), "start", dateValStart ); + } + if (!dateValEnd.isEmpty()) { + writeDateElement(xmlw, "timePrd", "P"+ per.toString(), "end", dateValEnd ); + } + } + } + if (DatasetFieldConstant.dateOfCollection.equals(fieldDTO.getTypeName())) { + String dateValStart = ""; + String dateValEnd = ""; + for (HashSet foo : fieldDTO.getMultipleCompound()) { + coll++; + for (Iterator iterator = foo.iterator(); iterator.hasNext();) { + FieldDTO next = iterator.next(); + if (DatasetFieldConstant.dateOfCollectionStart.equals(next.getTypeName())) { + dateValStart = next.getSinglePrimitive(); + } + if (DatasetFieldConstant.dateOfCollectionEnd.equals(next.getTypeName())) { + dateValEnd = next.getSinglePrimitive(); + } + } + if (!dateValStart.isEmpty()) { + writeDateElement(xmlw, "collDate", "P"+ coll.toString(), "start", dateValStart ); + } + if (!dateValEnd.isEmpty()) { + writeDateElement(xmlw, "collDate", "P"+ coll.toString(), "end", dateValEnd ); + } + } + } + if (DatasetFieldConstant.kindOfData.equals(fieldDTO.getTypeName())) { + writeMultipleElement(xmlw, "dataKind", fieldDTO); + } + } + } + + if("geospatial".equals(key)){ + for (FieldDTO fieldDTO : value.getFields()) { + if (DatasetFieldConstant.geographicCoverage.equals(fieldDTO.getTypeName())) { + for (HashSet foo : fieldDTO.getMultipleCompound()) { + for (Iterator iterator = foo.iterator(); iterator.hasNext();) { + FieldDTO next = iterator.next(); + if (DatasetFieldConstant.country.equals(next.getTypeName())) { + writeFullElement(xmlw, "nation", next.getSinglePrimitive()); + } + if (DatasetFieldConstant.city.equals(next.getTypeName())) { + writeFullElement(xmlw, "georgCover", next.getSinglePrimitive()); + } + if (DatasetFieldConstant.state.equals(next.getTypeName())) { + writeFullElement(xmlw, "georgCover", next.getSinglePrimitive()); + } + if (DatasetFieldConstant.otherGeographicCoverage.equals(next.getTypeName())) { + writeFullElement(xmlw, "georgCover", next.getSinglePrimitive()); + } + } + } + } + } + } + + if("socialscience".equals(key)){ + for (FieldDTO fieldDTO : value.getFields()) { + if (DatasetFieldConstant.universe.equals(fieldDTO.getTypeName())) { + writeMultipleElement(xmlw, "universe", fieldDTO); + } + if (DatasetFieldConstant.unitOfAnalysis.equals(fieldDTO.getTypeName())) { + writeMultipleElement(xmlw, "anlyUnit", fieldDTO); + } + } + } + } + xmlw.writeEndElement(); //sumDscr + } + + private static void writeMultipleElement(XMLStreamWriter xmlw, String element, FieldDTO fieldDTO) throws XMLStreamException { + for (String value : fieldDTO.getMultiplePrimitive()) { + writeFullElement(xmlw, element, value); + } + } + + private static void writeDateElement(XMLStreamWriter xmlw, String element, String cycle, String event, String dateIn) throws XMLStreamException { + + xmlw.writeStartElement(element); + writeAttribute(xmlw, "cycle", cycle); + writeAttribute(xmlw, "event", event); + writeAttribute(xmlw, "date", dateIn); + xmlw.writeCharacters(dateIn); + xmlw.writeEndElement(); + + } + + private static void writeSubjectElement(XMLStreamWriter xmlw, DatasetVersionDTO datasetVersionDTO) throws XMLStreamException{ + + //Key Words and Topic Classification + + xmlw.writeStartElement("subject"); + for (Map.Entry entry : datasetVersionDTO.getMetadataBlocks().entrySet()) { + String key = entry.getKey(); + MetadataBlockDTO value = entry.getValue(); + if ("citation".equals(key)) { + for (FieldDTO fieldDTO : value.getFields()) { + if (DatasetFieldConstant.keyword.equals(fieldDTO.getTypeName())) { + for (HashSet foo : fieldDTO.getMultipleCompound()) { + String keywordValue = ""; + String keywordVocab = ""; + String keywordURI = ""; + for (Iterator iterator = foo.iterator(); iterator.hasNext();) { + FieldDTO next = iterator.next(); + if (DatasetFieldConstant.keywordValue.equals(next.getTypeName())) { + keywordValue = next.getSinglePrimitive(); + } + if (DatasetFieldConstant.keywordVocab.equals(next.getTypeName())) { + keywordVocab = next.getSinglePrimitive(); + } + if (DatasetFieldConstant.keywordVocabURI.equals(next.getTypeName())) { + keywordURI = next.getSinglePrimitive(); + } + } + if (!keywordValue.isEmpty()){ + xmlw.writeStartElement("keyword"); + if(!keywordVocab.isEmpty()){ + writeAttribute(xmlw,"vocab",keywordVocab); + } + if(!keywordURI.isEmpty()){ + writeAttribute(xmlw,"URI",keywordURI); + } + xmlw.writeCharacters(keywordValue); + xmlw.writeEndElement(); //Keyword + } + + } + } + if (DatasetFieldConstant.topicClassification.equals(fieldDTO.getTypeName())) { + for (HashSet foo : fieldDTO.getMultipleCompound()) { + String topicClassificationValue = ""; + String topicClassificationVocab = ""; + String topicClassificationURI = ""; + for (Iterator iterator = foo.iterator(); iterator.hasNext();) { + FieldDTO next = iterator.next(); + if (DatasetFieldConstant.topicClassValue.equals(next.getTypeName())) { + topicClassificationValue = next.getSinglePrimitive(); + } + if (DatasetFieldConstant.topicClassVocab.equals(next.getTypeName())) { + topicClassificationVocab = next.getSinglePrimitive(); + } + if (DatasetFieldConstant.topicClassVocabURI.equals(next.getTypeName())) { + topicClassificationURI = next.getSinglePrimitive(); + } + } + if (!topicClassificationValue.isEmpty()){ + xmlw.writeStartElement("topcClas"); + if(!topicClassificationVocab.isEmpty()){ + writeAttribute(xmlw,"vocab",topicClassificationVocab); + } + if(!topicClassificationURI.isEmpty()){ + writeAttribute(xmlw,"URI",topicClassificationURI); + } + xmlw.writeCharacters(topicClassificationValue); + xmlw.writeEndElement(); //topcClas + } + } + } + } + } + } + xmlw.writeEndElement(); // subject + } + + private static void writeAuthorsElement(XMLStreamWriter xmlw, DatasetVersionDTO datasetVersionDTO) throws XMLStreamException { + xmlw.writeStartElement("rspStmt"); + for (Map.Entry entry : datasetVersionDTO.getMetadataBlocks().entrySet()) { + String key = entry.getKey(); + MetadataBlockDTO value = entry.getValue(); + if ("citation".equals(key)) { + for (FieldDTO fieldDTO : value.getFields()) { + if (DatasetFieldConstant.author.equals(fieldDTO.getTypeName())) { + String authorName = ""; + String authorAffiliation = ""; + for (HashSet foo : fieldDTO.getMultipleCompound()) { + for (Iterator iterator = foo.iterator(); iterator.hasNext();) { + FieldDTO next = iterator.next(); + if (DatasetFieldConstant.authorName.equals(next.getTypeName())) { + authorName = next.getSinglePrimitive(); + } + if (DatasetFieldConstant.authorAffiliation.equals(next.getTypeName())) { + authorAffiliation = next.getSinglePrimitive(); + } + } + if (!authorName.isEmpty()){ + xmlw.writeStartElement("AuthEnty"); + if(!authorAffiliation.isEmpty()){ + writeAttribute(xmlw,"affiliation",authorAffiliation); + } + xmlw.writeCharacters(authorName); + xmlw.writeEndElement(); //AuthEnty + } + } + } + } + } + } + xmlw.writeEndElement(); //rspStmt + } /** * @todo Create a full dataDscr and otherMat sections of the DDI. This stub @@ -133,33 +395,55 @@ private static void writeFileDescription(XMLStreamWriter xmlw, FileDTO fileDTo) } xmlw.writeEndElement(); // txt } - - private static String dto2title(DatasetVersionDTO datasetVersionDTO) { + + private static String dto2Primitive(DatasetVersionDTO datasetVersionDTO, String datasetFieldTypeName) { for (Map.Entry entry : datasetVersionDTO.getMetadataBlocks().entrySet()) { String key = entry.getKey(); MetadataBlockDTO value = entry.getValue(); - if ("citation".equals(key)) { + // if ("citation".equals(key)) { for (FieldDTO fieldDTO : value.getFields()) { - if (DatasetFieldConstant.title.equals(fieldDTO.getTypeName())) { + if (datasetFieldTypeName.equals(fieldDTO.getTypeName())) { return fieldDTO.getSinglePrimitive(); } } - } + // } } return null; } - - private static String dto2authors(DatasetVersionDTO datasetVersionDTO) { + + private static String dto2ChildVal(DatasetVersionDTO datasetVersionDTO, String parentDatasetFieldTypeName, String childDatasetFieldTypeName) { + for (Map.Entry entry : datasetVersionDTO.getMetadataBlocks().entrySet()) { + String key = entry.getKey(); + MetadataBlockDTO value = entry.getValue(); + // if ("citation".equals(key)) { + for (FieldDTO fieldDTO : value.getFields()) { + if (parentDatasetFieldTypeName.equals(fieldDTO.getTypeName())) { + for (HashSet foo : fieldDTO.getMultipleCompound()) { + for (Iterator iterator = foo.iterator(); iterator.hasNext();) { + FieldDTO next = iterator.next(); + if (childDatasetFieldTypeName.equals(next.getTypeName())) { + return next.getSinglePrimitive(); + } + } + } + } + } + // } + } + return null; + } + + private static String dto2producers(DatasetVersionDTO datasetVersionDTO) { for (Map.Entry entry : datasetVersionDTO.getMetadataBlocks().entrySet()) { String key = entry.getKey(); MetadataBlockDTO value = entry.getValue(); if ("citation".equals(key)) { for (FieldDTO fieldDTO : value.getFields()) { - if (DatasetFieldConstant.author.equals(fieldDTO.getTypeName())) { + if (DatasetFieldConstant.producer.equals(fieldDTO.getTypeName())) { for (HashSet foo : fieldDTO.getMultipleCompound()) { for (Iterator iterator = foo.iterator(); iterator.hasNext();) { FieldDTO next = iterator.next(); - if (DatasetFieldConstant.authorName.equals(next.getTypeName())) { + if (DatasetFieldConstant.producerName.equals(next.getTypeName())) { return next.getSinglePrimitive(); } } @@ -170,6 +454,16 @@ private static String dto2authors(DatasetVersionDTO datasetVersionDTO) { } return null; } + + private static void writeFullElement (XMLStreamWriter xmlw, String name, String value) throws XMLStreamException { + //For the simplest Elements we can + if (!StringUtilisEmpty(value)) { + xmlw.writeStartElement(name); + xmlw.writeCharacters(value); + xmlw.writeEndElement(); // labl + } + + } private static void writeAttribute(XMLStreamWriter xmlw, String name, String value) throws XMLStreamException { if (!StringUtilisEmpty(value)) { diff --git a/src/main/java/edu/harvard/iq/dataverse/export/spi/Exporter.java b/src/main/java/edu/harvard/iq/dataverse/export/spi/Exporter.java new file mode 100644 index 00000000000..7ee9980f879 --- /dev/null +++ b/src/main/java/edu/harvard/iq/dataverse/export/spi/Exporter.java @@ -0,0 +1,25 @@ +/* + * To change this license header, choose License Headers in Project Properties. + * To change this template file, choose Tools | Templates + * and open the template in the editor. + */ +package edu.harvard.iq.dataverse.export.spi; + +import java.io.OutputStream; +import javax.json.JsonObject; + +/** + * + * @author skraffmi + */ +public interface Exporter { + + public OutputStream exportDataset(JsonObject json); + + public String getProvider(); + + public String getButtonLabel(); + + public Boolean isXMLFormat(); + +} diff --git a/src/main/webapp/dataset.xhtml b/src/main/webapp/dataset.xhtml index 7f7761cffd8..c9d3e4d01fc 100755 --- a/src/main/webapp/dataset.xhtml +++ b/src/main/webapp/dataset.xhtml @@ -515,15 +515,28 @@ -
    - +
    +
    + + +
    +
    + #{bundle['file.dataFilesTab.metadata.addBtn']}
    - diff --git a/src/test/java/edu/harvard/iq/dataverse/export/ddi/codebook.xsd b/src/test/java/edu/harvard/iq/dataverse/export/ddi/codebook.xsd new file mode 100644 index 00000000000..89249a45a84 --- /dev/null +++ b/src/test/java/edu/harvard/iq/dataverse/export/ddi/codebook.xsd @@ -0,0 +1,8462 @@ + + + + + This is a w3c Schema "Technical Implementation" of the DDI Conceptual Specification. + This schema is intended for use in producing electronic versions of codebooks for quantitative social science data. + Please note that the attribute xml-lang in the a.globals group is an error that was persisted to retain backward compatibility. DO NOT USE THIS ATTRIBUTE. If this attribute has been used, transfer the content to xml:lang. + + + + + + + + + + + + + + DO NOT USE THIS ATTRIBUTE. Its inclusion is an error that was persisted to retain backward compatibility. If this attribute has been used, transfer the content to xml:lang. + + + + + + + + + + + + + + Captures version of the element + + + + + Indicates version date for the element. Use YYYY-MM-DD, YYYY-MM, or YYYY formats. + + + + + Used to capture the DDI-Lifecycle type URN for the element. This may be captured during translation from DDI-Lifecycle to DDI-Codebook structure or in preparation for transferring to a DDI-Lifecycle structure. + + + + + Used to capture the DDI-Codebook type URN for the element. This is used to assign a DDI-Codebook specific URN to the element, according the format prescribed by the DDI-Codebook standard. + + + + + + + + + Base Element Type + + Description + This type forms the basis for all elements. Every element may contain the attributes defined the GLOBALS attribute group. + + + + + + + + + + + + Abstract Text Type + + Description + This type forms the basis for all textual elements. Textual elements may contain text or a mix of select elements. This type is abstract and is refined by more specific types which will limit the allowable elements and attributes. Any textual element will be a subset of this type and can be processed as such. + + + + + + + + + + + + + + + + + + + Simple Text Type + + Description + This type forms the basis of most textual elements. Elements using this type may have mixed content (text and child elements). The child elements are from the PHRASE, FORM, and xhtml:BlkNoForm.mix (a specific subset of XHTML) content groups. Note that if elements from the PHRASE and FORM groups must not be used with elements from the xhtml:BlkNoForm.mix group; one can use either elements from xhtml:BlkNoForm.mix or elements from the PHRASE and FORM groups. This type is extended in some cases to include additional attributes. + + + + + + + + + + + + + + + + + + + + + + Conceptual Text Type + + Description + This type forms this basis for a textual element which may also provide for a conceptual (see concept) description of the element a longer description (see txt). If the concept and/or txt elements are used, then the element should contain no other child elements or text. Note that if elements from the PHRASE and FORM groups must not be used with elements from the xhtml:BlkNoForm.mix group; one can use either elements from xhtml:BlkNoForm.mix or elements from the PHRASE and FORM groups. + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + Phrase Type + + Description + This type restricts the simpleTextType to allow for only child elements from the PHRASE content group. It still allows for mixed content (text and child elements). + + + + + + + + + + + + + + + + + + + + String Type + + Description + This type restricts the base abstractTextType to only allow for text (i.e. no child elements). + + + + + + + + + + + + + + + + + + Integer Type + + Description + This type restricts the base abstractTextType to only allow for an integer as text content. No child elements are allowed. + + + + + + + + + + + + + + + + + + Date Simple Type + + Description + This simple type is a union of the various XML Schema date formats. Using this type, a date can be expressed as a year (YYYY), a year and month (YYYY-MM), a date (YYYY-MM-DD) or a complete date and time (YYYY-MM-DDThh:mm:ss). All of these formats allow for an optional timezone offset to be specified. + + + + + + + + + + + + Date Type + + Description + This type restricts the base abstractTextType to allow for only the union of types defined in dateSimpleType as text content. No child elements are allowed. + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + External Link + + Description + This element permits encoders to provide links from any arbitrary element containing ExtLink as a subelement to electronic resources outside the codebook. + + + + + + + + + + + + + + + + + + + + + Link + + Description + This element permits encoders to provide links from any arbitrary element containing Link as a subelement to other elements in the codebook. + + + + + + + + + + + + + + + + + + + + + + + + Form Type + + Description + This type defines the basis for all elements in the FORM content group. This is derived from the abstractTextType. The content may still be mixed (text and child elements), but the child elements are restricted to be those from the PHRASE and FORM content groups, or the itm and label elements. Further, the possible attributes are restricted. This type is abstract, so specific form elements will further refine this type, but all elements in the FORM content group will conform to this structure and may be processed as such. + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + Division + + Description + Formatting element: marks a subdivision in a text. + + + + + + + + + + + + + + + + + + + + + + + + + Emphasis + + Description + Formatting element: marks words or phrases that are emphasized for rhetorical effect. + + + + + + + + + + + + + + + + + + + + + + + + + + Head + + Description + Formatting element: marks off a heading to a division, list, etc. + + + + + + + + + + + + + + + + + + + + + + + + + Highlight + + Description + Formatting element: marks a word or phrase as graphically distinct from the surrounding text, while making no claim for the reasons. + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + List + + Description + Formatting element: contains any sequence of items (entries) organized as a list. + + + + + + + + + + + + + + + + + + + + + + + + + + + + + Item + + Description + Formatting element: marks entries (items) in a list. + + + + + + + + + + + + + + + + + + + + + + + + + + Label + + Description + Formatting element: contains the label associated with an item in a list; in glossaries, marks the term being defined. + + + + + + Label + + Description + A short description of the parent element. Attribute "level" indicates the level to which the element applies (variable group, nCube group, variable, etc.). The "vendor" attribute allows for specification of different labels for use with different vendors' software. Attribute "country" allows specification of a different label by country for the same element to which it applies. Attribute "sdatrefs" allows pointing to specific dates, universes, or other information encoded in the study description. The attributes "country" and "sdatrefs" are intended to cover instances of comparative data, by retaining consistency in some elements over time and geography, but altering, as appropriate, information pertaining to date, language, and/or location. + + + Example + + + Person (A) Record + + ]]> + + Study Procedure Information + + ]]> + + Political Involvement and National Goals + + ]]> + + Household Variable Section + + ]]> + + Sex by Work Experience in 1999 by Income in 1999 + + ]]> + + Tenure by Age of Householder + + ]]> + + Why No Holiday-No Money + + ]]> + + Other Agricultural and Related Occupations + + ]]> + + Better + + ]]> + + About the same + + ]]> + + Inap. + + ]]> + + Age by Sex by Poverty Status + + ]]> + + SAS Data Definition Statements for ICPSR 6837 + + ]]> + + + + + + + + + + + + + + + + + + + + + + + + + + + + Paragraph + + Description + Marks a paragraph. + + + + + + + + + + + + + + + + + + + + + + + + + + + + + Abstract + + Description + An unformatted summary describing the purpose, nature, and scope of the data collection, special characteristics of its contents, major subject areas covered, and what questions the PIs attempted to answer when they conducted the study. A listing of major variables in the study is important here. In cases where a codebook contains more than one abstract (for example, one might be supplied by the data producer and another prepared by the data archive where the data are deposited), the "source" and "date" attributes may be used to distinguish the abstract versions. Maps to Dublin Core Description element. Inclusion of this element in the codebook is recommended. The "date" attribute should follow ISO convention of YYYY-MM-DD. The contentType attribute provides forward-compatibility with DDI 3 by describing where the content fits in that structure, or if is mixed in terms of what is contained. + + + Example + + Data on labor force activity for the week prior to the survey are supplied in this collection. Information is available on the employment status, occupation, and industry of persons 15 years old and over. Demographic variables such as age, sex, race, marital status, veteran status, household relationship, educational background, and Hispanic origin are included. In addition to providing these core data, the May survey also contains a supplement on work schedules for all applicable persons aged 15 years and older who were employed at the time of the survey. This supplement focuses on shift work, flexible hours, and work at home for both main and second jobs. + ]]> + + + + + + + + + + + + + + + + + + + + Location of Data Collection + + Description + Location where the data collection is currently stored. Use the URI attribute to provide a URN or URL for the storage site or the actual address from which the data may be downloaded. + + + + + + + + + + + Actions to Minimize Losses + + Description + Summary of actions taken to minimize data loss. Includes information on actions such as follow-up visits, supervisory checks, historical matching, estimation, etc. + + + Example + + To minimize the number of unresolved cases and reduce the potential nonresponse bias, four follow-up contacts were made with agencies that had not responded by various stages of the data collection process. + ]]> + + + + + + + + + + + + Alternative Title + + Description + A title by which the work is commonly referred, or an abbreviation of the title. + + + + + + + + + + + + + + + + + + + + + + + Data Appraisal + + Description + Information on data appraisal. + + + + + + + + + + + + + + + + + + + Unit of Analysis + + Description + Basic unit of analysis or observation that the file describes: individuals, families/households, groups, institutions/organizations, administrative units, etc. The "unit" attribute is included to permit the development of a controlled vocabulary for this element. + + + Example + + individuals + ]]> + + + + + + + + + + + + Analysis Unit + + Description + Provides information regarding whom or what the variable/nCube describes. The element may be repeated only to support multiple language expressions of the content. + + + Example + + + This variable reports election returns at the constituency level. + + ]]> + + Household + + ]]> + + + + + + + + + + + + + + + + + + + + Authoring Entity/Primary Investigator + + Description + + The person, corporate body, or agency responsible for the work's substantive and intellectual content. Repeat the element for each author, and use "affiliation" attribute if available. Invert first and last name and use commas. Author of data collection (codeBook/stdyDscr/citation/rspStmt/AuthEnty) maps to Dublin Core Creator element. Inclusion of this element in codebook is recommended. + The "author" in the Document Description should be the individual(s) or organization(s) directly responsible for the intellectual content of the DDI version, as distinct from the person(s) or organization(s) responsible for the intellectual content of the earlier paper or electronic edition from which the DDI edition may have been derived. + + + + Example + + United States Department of Commerce. Bureau of the Census + ]]> + Rabier, Jacques-Rene + ]]> + + + + + + + + + + + + Availability Status + + Description + Statement of collection availability. An archive may need to indicate that a collection is unavailable because it is embargoed for a period of time, because it has been superseded, because a new edition is imminent, etc. It is anticipated that a controlled vocabulary will be developed for this element. + + + Example + + This collection is superseded by CENSUS OF POPULATION, 1880 [UNITED STATES]: PUBLIC USE SAMPLE (ICPSR 6460). + ]]> + + + + + + + + + + + + + + + + + + + + Backflow + + Description + Contains a reference to IDs of possible preceding questions. The "qstn" IDREFS may be used to specify the question IDs. + + + Example + + + + For responses on a similar topic, see questions 12-15. + + + ]]> + + + + + + ]]> + + + + + + + + + + + + + + + + + + + + Bibliographic Citation + + Description + Complete bibliographic reference containing all of the standard elements of a citation that can be used to cite the work. The "format" attribute is provided to enable specification of the particular citation style used, e.g., APA, MLA, Chicago, etc. + + + Example + + Rabier, Jacques-Rene, and Ronald Inglehart. EURO-BAROMETER 11: YEAR OF THE CHILD IN EUROPE, APRIL 1979 [Codebook file]. Conducted by Institut Francais D'Opinion Publique (IFOP), Paris, et al. ICPSR ed. Ann Arbor, MI: Inter-university Consortium for Political and Social Resarch [producer and distributor], 1981. + ]]> + + + + + + + + + + + + + + + + + + + + + + Geographic Bounding Polygon + + Description + + This field allows the creation of multiple polygons to describe in a more detailed manner the geographic area covered by the dataset. It should only be used to define the outer boundaries of a covered area. For example, in the United States, such polygons can be created to define boundaries for Hawaii, Alaska, and the continental United States, but not interior boundaries for the contiguous states. This field is used to refine a coordinate-based search, not to actually map an area. + If the boundPoly element is used, then geoBndBox MUST be present, and all points enclosed by the boundPoly MUST be contained within the geoBndBox. Elements westBL, eastBL, southBL, and northBL of the geoBndBox should each be represented in at least one point of the boundPoly description. + + + + Example + + Nevada State + ]]> + + + + 42.002207 + -120.005729004 + + + 42.002207 + -114.039663 + + + 35.9 + -114.039663 + + + 36.080 + -114.544 + + + 35.133 + -114.542 + + + 35.00208499998 + -114.63288 + + + 35.00208499998 + -114.63323 + + + 38.999 + -120.005729004 + + + 42.002207 + -120.005729004 + + + + ]]> + Norway + ]]> + + + + 80.76416 + 33.637497 + + + 80.76416 + 10.2 + + + 62.48395 + 4.789583 + + + 57.987915 + 4.789583 + + + 57.987915 + 11.8 + + + 61.27794 + 13.2336 + + + 63.19012 + 13.2336 + + + 67.28615 + 17.24580 + + + 68.14297 + 21.38362 + + + 68.14297 + 25.50054 + + + 69.39685 + 27.38137 + + + 68.76991 + 28.84424 + + + 68.76991 + 31.31021 + + + 71.42 + 31.31021 + + + 71.42 + 33.637497 + + + 80.76416 + 33.637497 + + + + ]]> + + + + + + + + + + + + Number of cases / Record Quantity + + Description + Number of cases or observations. + + + Example + + 1011 + ]]> + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + Category Level Statistic + + Description + May include frequencies, percentages, or crosstabulation results. This field can contain one of the following: 1. textual information (e.g., PCDATA), or 2. non-parseable character data (e.g., the statistics), or 3. some other form of external information (table, image, etc.) In case 1, the tag can be used to mark up character data; tables can also be included in the actual markup. In cases 2 or 3, the element can be left empty and the "URI" attribute used to refer to the external object containing the information. The attribute "type" indicates the type of statistics presented - frequency, percent, or crosstabulation. If a value of "other" is used for this attribute, the "otherType" attribute should take a value from a controlled vocabulary. This option should only be used when applying a controlled vocabulary to this attribute. Use the complex element controlledVocabUsed to identify the controlled vocabulary to which the selected term belongs. + + + Example + + + + 256 + + + ]]> + + + + + + + + + + + + Category Value + + Description + The explicit response. + + + Example + + + + 9 + + + ]]> + + + + + + + + + + + + + + + + + + + + + Category Level + + Description + Used to describe the levels of the category hierarchy. Note that we do not indicate nesting levels or roll-up structures here. This is done to be able to support ragged hierarchies. A category level may be linked to one or more maps of the variable content. This id done by referencing the IDs of the appropriate geoMap elements in the attribute geoMap. + + + Example + + + ]]> + + ]]> + + ]]> + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + Category + + Description + + A description of a particular response. + The attribute "missing" indicates whether this category group contains missing data or not. + The attribute "missType" is used to specify the type of missing data, e.g., inap., don't know, no answer, etc. + The attribute "country" allows for the denotation of country-specific category values. + The "sdatrefs" attribute records the ID values of all elements within the summary data description that apply to this category. + The exclusiveness attribute ("excls") should be set to "false" if the category can appear in more than one place in the classification hierarchy. + The attribute "catgry" is an IDREF referencing any child categories of this category element. Used to capture nested hierarchies of categories. + The attribute "level" is an IDREF referencing the catLevel ID in which this category exists. + + + + Example + + + ]]> + + ]]> + + ]]> + + + 0 + Management, professional and related occupations + + ]]> + + 01 + Management occupations + + ]]> + + 011 + Top executives + + ]]> + + 012 + Financial managers + + ]]> + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + Category Group + + Description + A description of response categories that might be grouped together. The attribute "missing" indicates whether this category group contains missing data or not. The attribute "missType" is used to specify the type of missing data, e.g., inap., don't know, no answer, etc. The attribute catGrp is used to indicate all the subsidiary category groups which nest underneath the current category group. This allows for the encoding of a hierarchical structure of category groups. The "levelno" attribute allows the addition of a level number, and "levelnm" allows the addition of a level name to the category group. The completeness attribute ("compl") should be set to "false" if the category group is incomplete (not a complete aggregate of all sub-nodes or children). The exclusiveness attribute ("excls") should be set to "false" if the category group can appear in more than one place in the classification hierarchy. + + + + + + + + + + + Citation Requirement + + Description + Text of requirement that a data collection should be cited properly in articles or other publications that are based on analysis of the data. + + + Example + + Publications based on ICPSR data collections should acknowledge those sources by means of bibliographic citations. To ensure that such source attributions are captured for social science bibliographic utilities, citations must appear in footnotes or in the reference section of publications. + ]]> + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + Bibliographic Citation + + Description + + This element encodes the bibliographic information for the work at the level specified: (1) Document Description, Citation (of Marked-up Document), (2) Document Description, Citation (of Marked-up Document Source), (3) Study Description, Citation (of Study), (4) Study Description, Other Material, and (5) Other Material for the study itself. Bibliographic information includes title information, statement of responsibility, production and distribution information, series and version information, text of a preferred bibliographic citation, and notes (if any). + A MARCURI attribute is provided to link to the MARC record for the citation. + + + + + + + + + + + + + + + + + + + + Cleaning Operations + + Description + Methods used to "clean" the data collection, e.g., consistency checking, wild code checking, etc. The "agency" attribute permits specification of the agency doing the data cleaning. + + + Example + + Checks for undocumented codes were performed, and data were subsequently revised in consultation with the principal investigator. + ]]> + + + + + + + + + + + + Coder Instructions + + Description + Any special instructions to those who converted information from one form to another for a particular variable. This might include the reordering of numeric information into another form or the conversion of textual information into numeric information. + + + Example + + + Use the standard classification tables to present responses to the question: What is your occupation? into numeric codes. + + ]]> + + + + + + + + + + + + + + + + + + This should be used for materials that are primarily descriptions of the content and use of the study, such as appendices, sampling information, weighting details, methodological and technical details, publications based upon the study content, related studies or collection of studies, etc. This section is intended to include or to link to materials used in the production of the study or useful in the analysis of the study. + + + + + + + + + + + + + + Codebook + + Description + + Every element in the DDI DTD/Schema has the following attributes: + ID - This uniquely identifies each element. + xml-lang - Use of this attribute is deprecated, and it will no longer be supported in the next major version of the DDI specification. For newly created XML documents, please use xml:lang. + xml:lang - This attribute specifies the language used in the contents and attribute values of any element in the XML document. Use of ISO (www.iso.org) language codes is recommended. + source - This attribute identifies the source that provided information in the element. If the documentation contains two differing sets of information on Sampling Procedure -- one provided by the data producer and one by the archive where the data is deposited -- this information can be distinguished through the use of the source attribute. + Note also that the DDI contains a linking mechanism permitting arbitrary links between internal elements (See Link) and from internal elements to external sources (See ExtLink). + The top-level element, codeBook, also includes a version attribute to specify the version number of the DDI specification. + codeBookAgency - This attribute holds the agency name of the creator or maintainer of the codeBook instance as a whole, and is designed to support forward compatibility with DDI-Lifecycle. Recommend the agency name as filed with the DDI Agency ID Registry with optional additional sub-agency extensions. + + + + + + + + + + + + + + + + + + + + + + + + Cohort + + Description + The element cohort is used when the nCube contains a limited number of categories from a particular variable, as opposed to the full range of categories. The attribute "catRef" is an IDREF to the actual category being used. The attribute "value" indicates the actual value attached to the category that is being used. + + + Example + + + + + ]]> + + + + + + + + + + + + + + + + + + + + + + + + + + + + + Date of Collection + + Description + Contains the date(s) when the data were collected. Use the event attribute to specify "start", "end", or "single" for each date entered. The ISO standard for dates (YYYY-MM-DD) is recommended for use with the "date" attribute. The "cycle" attribute permits specification of the relevant cycle, wave, or round of data. Maps to Dublin Core Coverage element. Inclusion of this element in the codebook is recommended. + + + Example + + 10 November 1998 + ]]> + + + + + + + + + + + + Mode of Data Collection + + Description + The method used to collect the data; instrumentation characteristics. XHTML formatting may be used in the txt element for forward-compatibility with DDI 3. + + + Example + + telephone interviews + ]]> + face-to-face interviews + ]]> + mail questionnaires + ]]> + computer-aided telephone interviews (CATI) + ]]> + + + + + + + + + + + + Characteristics of Data Collection Situation + + Description + Description of noteworthy aspects of the data collection situation. Includes information on factors such as cooperativeness of respondents, duration of interviews, number of call-backs, etc. + + + Example + + There were 1,194 respondents who answered questions in face-to-face interviews lasting approximately 75 minutes each. + ]]> + + + + + + + + + + + + Extent of Collection + + Description + Summarizes the number of physical files that exist in a collection, recording the number of files that contain data and noting whether the collection contains machine-readable documentation and/or other supplementary files and information such as data dictionaries, data definition statements, or data collection instruments. + + + Example + + 1 data file + machine-readable documentation (PDF) + SAS data definition statements + ]]> + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + Column Specification + + + + + + + + + + Completeness of Study Stored + + Description + This item indicates the relationship of the data collected to the amount of data coded and stored in the data collection. Information as to why certain items of collected information were not included in the data file stored by the archive should be provided. + + + Example + + Because of embargo provisions, data values for some variables have been masked. Users should consult the data definition statements to see which variables are under embargo. A new version of the collection will be released by ICPSR after embargoes are lifted. + ]]> + + + + + + + + + + + + + + + + + + + + + Concept + + Description + The general subject to which the parent element may be seen as pertaining. This element serves the same purpose as the keywords and topic classification elements, but at the data description level. The "vocab" attribute is provided to indicate the controlled vocabulary, if any, used in the element, e.g., LCSH (Library of Congress Subject Headings), MeSH (Medical Subject Headings), etc. The "vocabURI" attribute specifies the location for the full controlled vocabulary. + + + Example + + + Income + + ]]> + + more experience + + ]]> + + Income + + ]]> + + SF: 311-312 draft horses + + ]]> + + + + + + + + + + + + Conditions + + Description + Indicates any additional information that will assist the user in understanding the access and use conditions of the data collection. + + + Example + + The data are available without restriction. Potential users of these datasets are advised, however, to contact the original principal investigator Dr. J. Smith (Institute for Social Research, The University of Michigan, Box 1248, Ann Arbor, MI 48106), about their intended uses of the data. Dr. Smith would also appreciate receiving copies of reports based on the datasets. + ]]> + + + + + + + + + + + + + + + + + + + + + + + + + + + + + Confidentiality Declaration + + Description + This element is used to determine if signing of a confidentiality declaration is needed to access a resource. The "required" attribute is used to aid machine processing of this element, and the default specification is "yes". The "formNo" attribute indicates the number or ID of the form that the user must fill out. The "URI" attribute may be used to provide a URN or URL for online access to a confidentiality declaration form. + + + Example + + To download this dataset, the user must sign a declaration of confidentiality. + ]]> + To obtain this dataset, the user must complete a Restricted Data Use Agreement. + ]]> + + + + + + + + + + + + + + + + + + + + + + Contact Persons + + Description + Names and addresses of individuals responsible for the work. Individuals listed as contact persons will be used as resource persons regarding problems or questions raised by the user community. The URI attribute should be used to indicate a URN or URL for the homepage of the contact individual. The email attribute is used to indicate an email address for the contact individual. + + + Example + + Jane Smith + ]]> + + + + + + + + + + + + + + + + + + + + Control Operations + + Description + Methods to facilitate data control performed by the primary investigator or by the data archive. Specify any special programs used for such operations. The "agency" attribute maybe used to refer to the agency that performed the control operation. + + + Example + + Ten percent of data entry forms were reentered to check for accuracy. + ]]> + + + + + + + + + + + + + + + + + + + + + + + + + + + + Controlled Vocabulary Used + + Description + Provides a code value, as well as a reference to the code list from which the value is taken. Note that the CodeValue can be restricted to reference an enumeration. + + + + + + + + + + + Code List ID + + Description + Identifies the code list that the value is taken from. + + + Example + + TimeMethod +]]> + + + + + + + + + + + + Code List Name + + Description + Identifies the code list that the value is taken from with a human-readable name. + + + Example + + Time Method]]> + + + + + + + + + + + + + Code List Agency Name + + Description + Agency maintaining the code list. + + + Example + + DDI Alliance]]> + + + + + + + + + + + + + Code List Version ID + + Description + Version of the code list. (Default value is 1.0) + + + Example + + 1.1]]> + + + + + + + + + + + + + Code List URN + + Description + Identifies the code list that the value is taken from with a URN. + + + Example + + urn:ddi-cv:TimeMethod:1.1]]> + + + + + + + + + + + + + Code List Scheme URN + + Description + Identifies the code list scheme using a URN. + + + Example + + http://www.ddialliance.org/Specification/DDI-CV/TimeMethod_1.1_Genericode1.0_DDI-CVProfile1.0.xml]]> + + + + + + + + + + + + Usage + + Description + Defines where in the instance the controlled vocabulary which is identified is utilized. A controlled vocabulary may occur either in the content of an element or in an attribute on an element. The usage can either point to a collection of elements using an XPath via the selector element or point to a more specific collection of elements via their identifier using the specificElements element. If the controlled vocabulary occurs in an attribute within the element, the attribute element identifies the specific attribute. When specific elements are specified, an authorized code value may also be provided. If the current value of the element or attribute identified is not in the controlled vocabulary or is not identical to a code value, the authorized code value identifies a valid code value corresponding to the meaning of the content in the element or attribute. + + + + + + + + + + + + + + + + + + + + + + + + + + + Selector + + Description + Identifies a collection of elements in which a controlled vocabulary is used. This is a simplified XPath which must correspond to the actual instance in which it occurs, which is to say that the fully qualified element names here must correspond to those in the instance. This XPath can only identify elements and does not allow for any predicates. The XPath must either be rooted or deep. + + Example + + /codeBook/stdyDscr/method/dataColl/timeMeth]]> + + + + + + + + + + + + + + + + + + Specific Elements + + Description + Identifies a collection of specific elements via their identifiers in the refs attribute, which allows for a tokenized list of identifier values which must correspond to identifiers which exist in the instance. The authorizedCodeValue attribute can be used to provide a valid code value corresponding to the meaning of the content in the element or attribute when the identified element or attribute does not use an actual valid value from the controlled vocabulary. + + + Example + + ]]> + + + + + + + + + + + + + + + + + + Attribute + + Description + Identifies an attribute within the element(s) identified by the selector or specificElements in which the controlled vocabulary is used. The fully qualified name used here must correspond to that in the instance, which is to say that if the attribute is namespace qualified, the prefix used here must match that which is defined in the instance. + + +Example + + +type]]> + + + + + + + + + + + + + Copyright + + Description + Copyright statement for the work at the appropriate level. Copyright for data collection (codeBook/stdyDscr/citation/prodStmt/copyright) maps to Dublin Core Rights. Inclusion of this element is recommended. + + + Example + + Copyright(c) ICPSR, 2000 + ]]> + + + + + + + + + + + + + + + + + + + + + + + + Description + This is an empty element containing only the attributes listed below. It is used to identify the coordinates of the data item within a logical nCube describing aggregate data. CubeCoord is repeated for each dimension of the nCube giving the coordinate number ("coordNo") and coordinate value ("coordVal"). Coordinate value reference ("cordValRef") is an ID reference to the variable that carries the coordinate value. The attributes provide a complete coordinate location of a cell within the nCube. + + + Example + + + ]]> + + ]]> + + ]]> + + + + + + + + + + + + + + + + + + + + + + + + Data Access + + Description + This section describes access conditions and terms of use for the data collection. In cases where access conditions differ across individual files or variables, multiple access conditions can be specified. The access conditions applying to a study, file, variable group, or variable can be indicated by an IDREF attribute on the study, file, variable group, or variable elements called "access". + + + + + + + + + + + + + + + + + + + Other Forms of Data Appraisal + + Description + Other issues pertaining to data appraisal. Describe here issues such as response variance, nonresponse rate and testing for bias, interviewer and response bias, confidence levels, question bias, etc. Attribute type allows for optional typing of data appraisal processes and option for controlled vocabulary. + + + Example + + These data files were obtained from the United States House of Representatives, who received them from the Census Bureau accompanied by the following caveats: "The numbers contained herein are not official 1990 decennial Census counts. The numbers represent estimates of the population based on a statistical adjustment method applied to the official 1990 Census figures using a sample survey intended to measure overcount or undercount in the Census results. On July 15, 1991, the Secretary of Commerce decided not to adjust the official 1990 decennial Census counts (see 56 Fed. Reg. 33582, July 22, 1991). In reaching his decision, the Secretary determined that there was not sufficient evidence that the adjustment method accurately distributed the population across and within states. The numbers contained in these tapes, which had to be produced prior to the Secretary's decision, are now known to be biased. Moreover, the tapes do not satisfy standards for the publication of Federal statistics, as established in Statistical Policy Directive No. 2, 1978, Office of Federal Statistical Policy and Standards. Accordingly, the Department of Commerce deems that these numbers cannot be used for any purpose that legally requires use of data from the decennial Census and assumes no responsibility for the accuracy of the data for any purpose whatsoever. The Department will provide no assistance in interpretation or use of these numbers." + ]]> + + + + + + + + + + + + Extent of Processing Checks + + Description + Indicate here, at the file level, the types of checks and operations performed on the data file. A controlled vocabulary may be developed for this element in the future. The following examples are based on ICPSR's Extent of Processing scheme: + + + Example + + The archive produced a codebook for this collection. + ]]> + Consistency checks were performed by Data Producer/ Principal Investigator. + ]]> + Consistency checks performed by the archive. + ]]> + The archive generated SAS and/or SPSS data definition statements for this collection. + ]]> + Frequencies were provided by Data Producer/Principal Investigator. + ]]> + Frequencies provided by the archive. + ]]> + Missing data codes were standardized by Data Producer/ Principal Investigator. + ]]> + Missing data codes were standardized by the archive. + ]]> + The archive performed recodes and/or calculated derived variables. + ]]> + Data were reformatted by the archive. + ]]> + Checks for undocumented codes were performed by Data Producer/Principal Investigator. + ]]> + Checks for undocumented codes were performed by the archive. + ]]> + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + Data Collection Methdology + + Description + Information about the methodology employed in a data collection. + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + Sample Frame + + Description + Sample frame describes the sampling frame used for identifying the population from which the sample was taken. For example, a telephone book may be a sample frame for a phone survey. In addition to the name, label and text describing the sample frame, this structure lists who maintains the sample frame, the period for which it is valid, a use statement, the universe covered, the type of unit contained in the frame as well as the number of units available, the reference period of the frame and procedures used to update the frame. Use multiple use statements to provide different uses under different conditions. Repeat elements within the use statement to support multiple languages. + + + + + + + + + + + + Sample Frame Name + + + Description + Name of the sample frame. + + + + Example + + City of St. Paul Directory]]> + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +Valid Period + + +Description +Defines a time period for the validity of the sampling frame. Enter dates in YYYY-MM-DD format. + + + +Example + +2009-07-01 +2011-06-30 +]]> + + + + + + + + + + + + + + Reference Period + + + Description + Indicates the period of time in which the sampling frame was actually used for the study in question. Use ISO 8601 date/time formats to enter the relevant date(s). + + + + Example + + 2009-06-01 +]]> + + + + + + + + + + + + + + + + + + + + + + + + + + Frame Unit + + + Description + Provides information about the sampling frame unit. The attribute "isPrimary" is boolean, indicating whether the unit is primary or not. + + + + Example + + Primary listed owners of published phone numbers in the City of St. Paul +]]> + + + + + + + + + + + + + + + + + + + + + + Unit Type + + + Description + Describes the type of sampling frame unit. The attribute "numberOfUnits" provides the number of units in the sampling frame. + + + + Example + + Primary listed owners of published phone numbers in the City of St. Paul +]]> + + + + + + + + + + + + + + + + + + + + + + + + Target Sample Size + + Description + Provides both the target size of the sample (this is the number in the original sample, not the number of respondents) as well as the formula used for determining the sample size. + + + + + + + + + + + + Sample Size + + + Description + This element provides the targeted sample size in integer format. + + + + Example + + 385 +]]> + + + + + + + + + + + + + + Sample Size Formula + + + Description + This element includes the formula that was used to determine the sample size. + + + + Example + + n0=Z2pq/e2=(1.96)2(.5)(.5)/(.05)2=385 individuals +]]> + + + + + + + + + + + + + + + + + + + + Instrument Development + + Description + Describe any development work on the data collection instrument. Type attribute allows for the optional use of a defined development type with or without use of a controlled vocabulary. + + + Example + + The questionnaire was pre-tested with split-panel tests, as well as an analysis of non-response rates for individual items, and response distributions. +]]> + + + + + + + + + + + + + Instrument Development + + Description + Description of how and with what frequency the sample frame is updated. + + + Example + + Changes are collected as they occur through registration and loss of phone number from the specified geographic area. Data are compiled for the date June 1st of odd numbered years, and published on July 1st for the following two-year period. +]]> + + + + + + + + + + + + + + + + + + + + + + Custodian + + Description + Custodian identifies the agency or individual who is responsible for creating or maintaining the sample frame. Attribute affiliation provides the affiliation of the custodian with an agency or organization. Attribute abbr provides an abbreviation for the custodian. + + + Example + + DEX Publications +]]> + + + + + + + + + + + + + + + + + + + + + Collector Training + + Description + Describes the training provided to data collectors including internviewer training, process testing, compliance with standards etc. This is repeatable for language and to capture different aspects of the training process. The type attribute allows specification of the type of training being described. + + + Example + + Describe research project, describe population and sample, suggest methods and language for approaching subjects, explain questions and key terms of survey instrument. +]]> + + + + + + + + + + + + + + + + + + + + + + + Data Collector + + Description + The entity (individual, agency, or institution) responsible for administering the questionnaire or interview or compiling the data. This refers to the entity collecting the data, not to the entity producing the documentation. Attribute "abbr" may be used to list common abbreviations given to agencies, etc. Attribute "affiliation" may be used to record affiliation of the data collector. The role attribute specifies the role of person in the data collection process. + + + Example + + Survey Research Center + ]]> + + + + + + + + + + + + + + + + + + + + + + + + + + Variable Description + + Description + Description of variables. + + + + + + + + + + + + + + + + + + + + + + + + + + Description + Identifies a physical storage location for an individual data entry, serving as a link between the physical location and the logical content description of each data item. The attribute "varRef" is an IDREF that points to a discrete variable description. If the data item is located within an nCube (aggregate data), use the attribute "nCubeRef" (IDREF) to point to the appropriate nCube and the element CubeCoord to identify the coordinates of the data item within the nCube. + + + + + + + + + + + + + + + + + + + Kind of Data + + Description + The type of data included in the file: survey data, census/enumeration data, aggregate data, clinical data, event/transaction data, program source code, machine-readable text, administrative records data, experimental data, psychological test, textual data, coded textual, coded documents, time budget diaries, observation data/ratings, process-produced data, etc. This element maps to Dublin Core Type element. The type attribute can be used for forward-compatibility with DDI 3, by providing a type for use of controlled vocabulary, as this is descriptive in DDI 2 and CodeValue in DDI 3. + + + Example + + survey data + ]]> + + + + + + + + + + + + Missing Data + + Description + This element can be used to give general information about missing data, e.g., that missing data have been standardized across the collection, missing data are present because of merging, etc. + + + Example + + Missing data are represented by blanks. + ]]> + The codes "-1" and "-2" are used to represent missing data. + ]]> + + + + + + + + + + + + Data Sources + + Description + Used to list the book(s), article(s), serial(s), and/or machine-readable data file(s)--if any--that served as the source(s) of the data collection. + + + Example + + "Voting Scores." CONGRESSIONAL QUARTERLY ALMANAC 33 (1977), 487-498. + ]]> + United States Internal Revenue Service Quarterly Payroll File + ]]> + + + + + + + + + + + + Definition + + Description + Rationale for why the group was constituted in this way. + + + Example + + + The following eight variables were only asked in Ghana. + + ]]> + + The following four nCubes form a single presentation table. + + ]]> + + + + + + + + + + + + Date of Deposit + + Description + The date that the work was deposited with the archive that originally received it. The ISO standard for dates (YYYY-MM-DD) is recommended for use with the "date" attribute. + + + Example + + January 25, 1999 + ]]> + + + + + + + + + + + + Deposit Requirement + + Description + Information regarding user responsibility for informing archives of their use of data through providing citations to the published work or providing copies of the manuscripts. + + + Example + + To provide funding agencies with essential information about use of archival resources and to facilitate the exchange of information about ICPSR participants' research activities, users of ICPSR data are requested to send to ICPSR bibliographic citations for, or copies of, each completed manuscript or thesis abstract. Please indicate in a cover letter which data were used. + ]]> + + + + + + + + + + + + + + + + + + + + + Depositor + + Description + The name of the person (or institution) who provided this work to the archive storing it. + + + Example + + Bureau of Justice Statistics + ]]> + + + + + + + + + + + + + + + + + + + + + + + + Derivation + + Description + Used only in the case of a derived variable, this element provides both a description of how the derivation was performed and the command used to generate the derived variable, as well as a specification of the other variables in the study used to generate the derivation. The "var" attribute provides the ID values of the other variables in the study used to generate this derived variable. + + + + + + + + + + + Major Deviations from the Sample Design + + Description + Information indicating correspondence as well as discrepancies between the sampled units (obtained) and available statistics for the population (age, sex-ratio, marital status, etc.) as a whole. XHTML formatting may be used in this element for forward-compatibility with DDI 3. + + + Example + + The suitability of Ohio as a research site reflected its similarity to the United States as a whole. The evidence extended by Tuchfarber (1988) shows that Ohio is representative of the United States in several ways: percent urban and rural, percent of the population that is African American, median age, per capita income, percent living below the poverty level, and unemployment rate. Although results generated from an Ohio sample are not empirically generalizable to the United States, they may be suggestive of what might be expected nationally. + ]]> + + + + + + + + + + + + + + + + + + + + + + + + + + + + Data Fingerprint + + Description + Allows for assigning a hash value (digital fingerprint) to the data or data file. Set the attribute flag to "data" when the hash value provides a digital fingerprint to the data contained in the file regardless of the storage format (ASCII, SAS, binary, etc.). One approach to compute a data fingerprint is the Universal Numerical Fingerprint (UNF). Set the attribute flag to "dataFile" if the digital fingerprint is only for the data file in its current storage format. Provide the digital fingerprint in digitalFingerprintValue and identify the algorithm specification used (add version as a separate entry if it is not part of the specification entry). + + + Example + + UNF:3:DaYlT6QSX9r0D50ye+tXpA== +UNF v5.0 Calculation Producture [http://thedata.org/book/unf-version-5-0]UNF V5 + +]]> + + + + + + + + + + + + + + + + + + + + + + + + + + + File Dimensions + + Description + Dimensions of the overall file. + + + + + + + + + + + Disclaimer + + Description + Information regarding responsibility for uses of the data collection. This element may be repeated to support multiple language expressions of the content. + + + Example + + The original collector of the data, ICPSR, and the relevant funding agency bear no responsibility for uses of this collection or for interpretations or inferences based upon such uses. + ]]> + + + + + + + + + + + + Date of Distribution + + Description + Date that the work was made available for distribution/presentation. The ISO standard for dates (YYYY-MM-DD) is recommended for use with the "date" attribute. If using a text entry in the element content, the element may be repeated to support multiple language expressions. + + + Example + + January 25, 1999 + ]]> + + + + + + + + + + + + + + + + + + + + + + + + + + Distributor Statement + + Description + Distribution statement for the work at the appropriate level: marked-up document; marked-up document source; study; study description, other material; other material for study. + + + + + + + + + + + + + + + + + + + + + Distributor + + Description + The organization designated by the author or producer to generate copies of the particular work including any necessary editions or revisions. Names and addresses may be specified and other archives may be co-distributors. A URI attribute is included to provide an URN or URL to the ordering service or download facility on a Web site. + + + Example + + Ann Arbor, MI: Inter-university Consortium for Political and Social Research + ]]> + + + + + + + + + + + + + + + + + + + + + + + + Dimension + + Description + This element defines a variable as a dimension of the nCube, and should be repeated to describe each of the cube's dimensions. The attribute "rank" is used to define the coordinate order (rank="1", rank="2", etc.) The attribute "varRef" is an IDREF that points to the variable that makes up this dimension of the nCube. + + + + + + + + + + + + + + + + + + + + + + + + + + Document Description + + Description + The Document Description consists of bibliographic information describing the DDI-compliant document itself as a whole. This Document Description can be considered the wrapper or header whose elements uniquely describe the full contents of the compliant DDI file. Since the Document Description section is used to identify the DDI-compliant file within an electronic resource discovery environment, this section should be as complete as possible. The author in the Document Description should be the individual(s) or organization(s) directly responsible for the intellectual content of the DDI version, as distinct from the person(s) or organization(s) responsible for the intellectual content of the earlier paper or electronic edition from which the DDI edition may have been derived. The producer in the Document Description should be the agency or person that prepared the marked-up document. Note that the Document Description section contains a Documentation Source subsection consisting of information about the source of the DDI-compliant file-- that is, the hardcopy or electronic codebook that served as the source for the marked-up codebook. These sections allow the creator of the DDI file to produce version, responsibility, and other descriptions relating to both the creation of that DDI file as a separate and reformatted version of source materials (either print or electronic) and the original source materials themselves. + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + Documentation Source + + Description + Citation for the source document. This element encodes the bibliographic information describing the source codebook, including title information, statement of responsibility, production and distribution information, series and version information, text of a preferred bibliographic citation, and notes (if any). Information for this section should be taken directly from the source document whenever possible. If additional information is obtained and entered in the elements within this section, the source of this information should be noted in the source attribute of the particular element tag. A MARCURI attribute is provided to link to the MARC record for this citation. + + + + + + + + + + + Documentation Status + + Description + Use this field to indicate if the documentation is being presented/distributed before it has been finalized. Some data producers and social science data archives employ data processing strategies that provide for release of data and documentation at various stages of processing. The element may be repeated to support multiple language expressions of the content. + + + Example + + This marked-up document includes a provisional data dictionary and brief citation only for the purpose of providing basic access to the data file. A complete codebook will be published at a later date. + ]]> + + + + + + + + + + + + + + + + + + + + Derivation Command + + Description + The actual command used to generate the derived variable. The "syntax" attribute is used to indicate the command language employed (e.g., SPSS, SAS, Fortran, etc.). The element may be repeated to support multiple language expressions of the content. + + + Example + + + + RECODE V1 TO V3 (0=1) (1=0) (2=-1) INTO DEFENSE WELFAREHEALTH. + + + ]]> + + + + + + + + + + + + Derivation Description + + Description + A textual description of the way in which this variable was derived. The element may be repeated to support multiple language expressions of the content. + + + Example + + + + VAR215.01 "Outcome of first pregnancy" (1988 NSFG=VAR611 PREGOUT1) If R has never been pregnant (VAR203 PREGNUM EQ 0) then OUTCOM01 is blank/inapplicable. Else, OUTCOM01 is transferred from VAR225 OUTCOME for R's 1st pregnancy. + + + ]]> + + + + + + + + + + + + East Bounding Longitude + + Description + The easternmost coordinate delimiting the geographic extent of the dataset. A valid range of values, expressed in decimal degrees (positive east and positive north), is: -180,0 <= East Bounding Longitude Value <= 180,0 + + + + + + + + + + + + + + + + + + + + + + + + + + + Embargo + + Description + + Provides information on variables/nCubes which are not currently available because of policies established by the principal investigators and/or data producers. The ISO standard for dates (YYYY-MM-DD) is recommended for use with the "date" attribute. An "event" attribute is provided to specify "notBefore" or "notAfter" ("notBefore" is the default). A "format" attribute is provided to ensure that this information will be machine-processable, and specifies a format for the embargo element. + The "format" attribute could be used to specify other conventions for the way that information within the embargo element is set out, if conventions for encoding embargo information were established in the future. + This element may be repeated to support multiple language expressions of the content. + + + + Example + + + The data associated with this variable/nCube will not become available until September 30, 2001, because of embargo provisions established by the data producers. + + ]]> + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + Table Entry + + + + + + + + + + Estimates of Sampling Error + + Description + Measure of how precisely one can estimate a population value from a given sample. + + + Example + + To assist NES analysts, the PC SUDAAN program was used to compute sampling errors for a wide-ranging example set of proportions estimated from the 1996 NES Pre-election Survey dataset. For each estimate, sampling errors were computed for the total sample and for twenty demographic and political affiliation subclasses of the 1996 NES Pre-election Survey sample. The results of these sampling error computations were then summarized and translated into the general usage sampling error table provided in Table 11. The mean value of deft, the square root of the design effect, was found to be 1.346. The design effect was primarily due to weighting effects (Kish, 1965) and did not vary significantly by subclass size. Therefore the generalized variance table is produced by multiplying the simple random sampling standard error for each proportion and sample size by the average deft for the set of sampling error computations. + ]]> + + + + + + + + + + + + Contents of Files + + Description + Abstract or description of the file. A summary describing the purpose, nature, and scope of the data file, special characteristics of its contents, major subject areas covered, and what questions the PIs attempted to answer when they created the file. A listing of major variables in the file is important here. In the case of multi-file collections, this uniquely describes the contents of each file. + + + Example + + Part 1 contains both edited and constructed variables describing demographic and family relationships, income, disability, employment, health insurance status, and utilization data for all of 1987. + ]]> + + + + + + + + + + + + + + + + + + + + + + + + + + + + + Data Files Description + + Description + + Information about the data file(s) that comprises a collection. This section can be repeated for collections with multiple files. + The "URI" attribute may be a URN or a URL that can be used to retrieve the file. The "sdatrefs" are summary data description references that record the ID values of all elements within the summary data description section of the Study Description that might apply to the file. These elements include: time period covered, date of collection, nation or country, geographic coverage, geographic unit, unit of analysis, universe, and kind of data. The "methrefs" are methodology and processing references that record the ID values of all elements within the study methodology and processing section of the Study Description that might apply to the file. These elements include information on data collection and data appraisal (e.g., sampling, sources, weighting, data cleaning, response rates, and sampling error estimates). The "pubrefs" attribute provides a link to publication/citation references and records the ID values of all citations elements within Other Study Description Materials or Other Study-Related Materials that pertain to this file. "Access" records the ID values of all elements in the Data Access section that describe access conditions for this file. + Remarks: When a codebook documents two different physical instantiations of a data file, e.g., logical record length (or OSIRIS) and card-image version, the Data File Description should be repeated to describe the two separate files. An ID should be assigned to each file so that in the Variable section the location of each variable on the two files can be distinguished using the unique file IDs. + + + + Example + + + ]]> + + ]]> + + + + + + + + + + + + File Name + + Description + Contains a short title that will be used to distinguish a particular file/part from other files/parts in the data collection. The element may be repeated to support multiple language expressions of the content. + + + Example + + Second-Generation Children Data + ]]> + + + + + + + + + + + + Place of File Production + + Description + Indicates whether the file was produced at an archive or produced elsewhere. + + + Example + + Washington, DC: United States Department of Commerce, Bureau of the Census + ]]> + + + + + + + + + + + + Number of Files + + Description + Total number of physical files associated with a collection. + + + Example + + 5 files + ]]> + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + File Structure + + Description + Type of file structure. The attribute "type" is used to indicate hierarchical, rectangular, relational, or nested (the default is rectangular). If the file is rectangular, the next relevant element is File Dimensions. If the "other" value is used for the type attribute, then the otherType attribute should have a value specifying the other type.The otherType attribute should only be used when applying a controlled vocabulary to this attribute. Use the complex element controlledVocabUsed to identify the controlled vocabulary to which the selected term belongs. The fileStrcRef attribute allows for multiple data files with different coverage but the same file structure to share a single fileStrc. The file structure is fully described in the first fileTxt within the fileDscr and then the fileStrc in subsequent fileTxt descriptions would reference the first fileStrcRef rather than repeat the details. + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + File-by-File Description + + Description + Provides descriptive information about the data file. A file name and a full bibliographic citation for the file may be entered, as well as a data fingerprint, if available. Information about the physical properties of the data file is also supported. Make sure to fill out topcClass for the study as these can be used by the data file. Note coverage constraints in fileCont. + + + + + + + + + + + + File Citation + + + Description + The complex element fileCitation provides for a full bibliographic citation option for each data file described in fileDscr. To support accurate citation of a data file the minimum element set includes: titl, IDNo, authEnty, producer, and prodDate. If a DOI is available for the data file enter this in the IDNo (this element is repeatable). If a hash value (digital fingerprint) has been created for the data file enter the information regarding its value and algorithm specification in digitalFingerprint. + + + + Example + + + +ABC News/Washington Post Monthly Poll, December 2010 +http://dx.doi.org/10.3886/ICPSR32547.v1 + + +ABC News +The Washington Post + + +ABC News +2011 + + +]]> + + + + + + + + + + + + + + + + + + + + + Type of File + + Description + Types of data files include raw data (ASCII, EBCDIC, etc.) and software-dependent files such as SAS datasets, SPSS export files, etc. If the data are of mixed types (e.g., ASCII and packed decimal), state that here. Note that the element varFormat permits specification of the data format at the variable level. The "charset" attribute allows one to specify the character set used in the file, e.g., US-ASCII, EBCDIC, UNICODE UTF-8, etc. The element may be repeated to support multiple language expressions of the content. + + + Example + + ASCII data file + ]]> + + + + + + + + + + + + Data Format + + Description + Physical format of the data file: Logical record length format, card-image format (i.e., data with multiple records per case), delimited format, free format, etc. The element may be repeated to support multiple language expressions of the content. + + + Example + + comma-delimited + ]]> + + + + + + + + + + + + + + + + + + + + Forward Progression + + Description + Contains a reference to IDs of possible following questions. The "qstn" IDREFS may be used to specify the question IDs. + + + Example + + + + If yes, please ask questions 120-124. + + + ]]> + + + + + + + + + + + + + + + + + + + + Frequency of Data Collection + + Description + For data collected at more than one point in time, the frequency with which the data were collected. The "freq" attribute is included to permit the development of a controlled vocabulary for this element. + + + Example + + monthly + ]]> + quarterly + ]]> + + + + + + + + + + + + + + + + + + + + + Funding Agency/Sponsor + + Description + The source(s) of funds for production of the work. If different funding agencies sponsored different stages of the production process, use the "role" attribute to distinguish them. + + + Example + + National Science Foundation + ]]> + Sun Microsystems + ]]> + + + + + + + + + + + + + + + + + + + + + + + + + Geographic Bounding Box + + Description + The fundamental geometric description for any dataset that models geography. GeoBndBox is the minimum box, defined by west and east longitudes and north and south latitudes, that includes the largest geographic extent of the dataset's geographic coverage. This element is used in the first pass of a coordinate-based search. If the boundPoly element is included, then the geoBndBox element MUST be included. + + + Example + + Nevada State + ]]> + + -120.005729004 + -114.039663 + 35.00208499998 + 42.002207 + + ]]> + Norway + ]]> + + 4.789583 + 33.637497 + 57.987915 + 80.76416 + + ]]> + + + + + + + + + + + + + + + + + + + + + + Geographic Map + + Description + This element is used to point, using a "URI" attribute, to an external map that displays the geography in question. The "levelno" attribute indicates the level of the geographic hierarchy relayed in the map. The "mapformat" attribute indicates the format of the map. + + + + + + + + + + + Geographic Coverage + + Description + Information on the geographic coverage of the data. Includes the total geographic scope of the data, and any additional levels of geographic coding provided in the variables. Maps to Dublin Core Coverage element. Inclusion of this element in the codebook is recommended. Fpor forward-compatibility, DDI 3 XHTML tags may be used in this element. + + + Example + + State of California + ]]> + + + + + + + + + + + + Geographic Unit + + Description + Lowest level of geographic aggregation covered by the data. + + + Example + + state + ]]> + + + + + + + + + + + + + + + + + + + + + Grant Number + + Description + The grant/contract number of the project that sponsored the effort. If more than one, indicate the appropriate agency using the "agency" attribute. If different funding agencies sponsored different stages of the production process, use the "role" attribute to distinguish the grant numbers. + + + Example + + J-LEAA-018-77 + ]]> + + + + + + + + + + + + G-Ring Latitude + + Description + Latitude (y coordinate) of a point. Valid range expressed in decimal degrees is as follows: -90,0 to 90,0 degrees (latitude) + + + + + + + + + + + G-Ring Longitude + + Description + Longitude (x coordinate) of a point. Valid range expressed in decimal degrees is as follows: -180,0 to 180,0 degrees (longitude) + + + + + + + + + + + Guide to Codebook + + Description + List of terms and definitions used in the documentation. Provided to assist users in using the document correctly. This element was intended to reflect the section in OSIRIS codebooks that assisted users in reading and interpreting a codebook. Each OSIRIS codebook contained a sample codebook page that defined the codebook conventions. The element may be repeated to support multiple language expressions of the content. + + + + + + + + + + + + + + + + + + + + + + Holdings Information + + Description + Information concerning either the physical or electronic holdings of the cited work. Attributes include: location--The physical location where a copy is held; callno--The call number for a work at the location specified; and URI--A URN or URL for accessing the electronic copy of the cited work. + + + Example + + Marked-up Codebook for Current Population Survey, 1999: Annual Demographic File + ]]> + Codebook for Current Population Survey, 1999: Annual Demographic File + ]]> + + + + + + + + + + + + + + + + + + + + + + + + + + + + + Identification Number + + Description + Unique string or number (producer's or archive's number). An "agency" attribute is supplied. Identification Number of data collection maps to Dublin Core Identifier element. + + + Example + + 6678 + ]]> + 2010 + ]]> + + + + + + + + + + + + Imputation + + Description + According to the Statistical Terminology glossary maintained by the National Science Foundation, this is "the process by which one estimates missing values for items that a survey respondent failed to provide," and if applicable in this context, it refers to the type of procedure used. When applied to an nCube, imputation takes into consideration all of the dimensions that are part of that nCube. This element may be repeated to support multiple language expressions of the content. + + + Example + + + This variable contains values that were derived by substitution. + + ]]> + + + + + + + + + + + + + + + + + + + + + + + + + + + Range of Invalid Data Values + + Description + Values for a particular variable that represent missing data, not applicable responses, etc. + + + Example + + + + + 98 DK + 99 Inappropriate + + + ]]> + + + + + + + + + + + + + + + + + + + + + + + + + + + + Value Item + + Description + The counterpart to Range; used to encode individual values. This is an empty element consisting only of its attributes. The "UNITS" attribute permits the specification of integer/real numbers. The "VALUE" attribute specifies the actual value. + + + Example + + + + + + + ]]> + + + + + + + + + + + + Interviewer Instructions + + Description + Specific instructions to the individual conducting an interview. + + + Example + + + + Please prompt the respondent if they are reticent to answer this question. + + + ]]> + + + + + + + + + + + + Range Key + + Description + This element permits a listing of the category values and labels. While this information is coded separately in the Category element, there may be some value in having this information in proximity to the range of valid and invalid values. A table is permissible in this element. + + + Example + + + + + 05 (PSU) Parti Socialiste Unifie et extreme gauche (Lutte Ouvriere) [United Socialists and extreme left (Workers Struggle)] + 50 Les Verts [Green Party] + 80 (FN) Front National et extreme droite [National Front and extreme right] + + + ]]> + + + + + + + + + + + + + + + + + + + + + Keywords + + Description + Words or phrases that describe salient aspects of a data collection's content. Can be used for building keyword indexes and for classification and retrieval purposes. A controlled vocabulary can be employed. Maps to Dublin Core Subject element. The "vocab" attribute is provided for specification of the controlled vocabulary in use, e.g., LCSH, MeSH, etc. The "vocabURI" attribute specifies the location for the full controlled vocabulary. + + + Example + + quality of life + ]]> + family + ]]> + career goals + ]]> + + + + + + + + + + + + + + + + + + + + + + + Label + + Description + A short description of the parent element. In the variable label, the length of this phrase may depend on the statistical analysis system used (e.g., some versions of SAS permit 40-character labels, while some versions of SPSS permit 120 characters), although the DDI itself imposes no restrictions on the number of characters allowed. A "level" attribute is included to permit coding of the level to which the label applies, i.e. record group, variable group, variable, category group, category, nCube group, nCube, or other study-related materials. The "vendor" attribute was provided to allow for specification of different labels for use with different vendors' software. The attribute "country" allows for the denotation of country-specific labels. The "sdatrefs" attribute records the ID values of all elements within the Summary Data Description section of the Study Description that might apply to the label. These elements include: time period covered, date of collection, nation or country, geographic coverage, geographic unit, unit of analysis, universe, and kind of data. + + + + + + + + + + + + + + + + + + + + + Location Map + + Description + This element maps individual data entries to one or more physical storage locations. It is used to describe the physical location of aggregate/tabular data in cases where the nCube model is employed. + + + + + + + + + + + + + + + + + + + + + + + + Location + + Description + This is an empty element containing only the attributes listed below. Attributes include "StartPos" (starting position of variable), "EndPos" (ending position of variable), "width" (number of columns the variable occupies), "RecSegNo" (the record segment number, deck or card number the variable is located on), and "fileid", an IDREF link to the fileDscr element for the file that this location is within (this is necessary in cases where the same variable may be coded in two different files, e.g., a logical record length type file and a card image type file). Note that if there is no width or ending position, then the starting position should be the ordinal position in the file, and the file would be described as free-format. The attribute "locMap" is an IDREF to the element locMap and serves as a pointer to indicate that the location information for the nCube's cells (aggregate data) is located in that section. + + + Example + + + + + + ]]> + + + + ]]> + + + + + + + + + + + + Logical Record Length + + Description + Logical record length, i.e., number of characters of data in the record. + + + Example + + 27 + ]]> + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + Measure + + Description + The element measure indicates the measurement features of the cell content: type of aggregation used, measurement unit, and measurement scale. An origin point is recorded for anchored scales, to be used in determining relative movement along the scale. Additivity indicates whether an aggregate is a stock (like the population at a given point in time) or a flow (like the number of births or deaths over a certain period of time). The non-additive flag is to be used for measures that for logical reasons cannot be aggregated to a higher level - for instance, data that only make sense at a certain level of aggregation, like a classification. Two nCubes may be identical except for their measure - for example, a count of persons by age and percent of persons by age. Measure is an empty element that includes the following attributes: "varRef" is an IDREF; "aggrMeth" indicates the type of aggregation method used, for example 'sum', 'average', 'count'; "measUnit" records the measurement unit, for example 'km', 'miles', etc.; "scale" records unit of scale, for example 'x1', 'x1000'; "origin" records the point of origin for anchored scales;"additivity" records type of additivity such as 'stock', 'flow', 'non-additive'. If a value of "other" is used for the aggrMeth attribute, a term from a controlled vocabulary should be placed in the "otherAggrMeth" attribute, and a the complex element controlledVocabUsed should be used to specify the controlled vocabulary. + + + + + + + + + + + + + + + + + + + + + + + + + + Methodology and Processing + + Description + This section describes the methodology and processing involved in a data collection. + + + + + + + + + + + + + + + + + + + + + + + + Coding Instructions + + Description + Describe specific coding instructions used in data processing, cleaning, assession, or tabulation. Element relatedProcesses allows linking a coding instruction to one or more processes such as dataProcessing, dataAppr, cleanOps, etc. Use the txt element to describe instructions in a human readable form. + + + Example + + +recode undocumented/wild codes to missing, i.e., 0. +RECODE V1 TO V100 (10 THROUGH HIGH = 0) + +]]> + + + + + . + + + + + + + + + + + + + + + + Command + + Description + Provide command code for the coding instruction. The formalLanguage attribute identifies the language of the command code. + + + Example + + RECODE V1 TO V100 (10 THROUGH HIGH = 0) +]]> + + + + + + + + + + + + + + + + + + + + + Data Processing + + Description + Describes various data processing procedures not captured elsewhere in the documentation, such as topcoding, recoding, suppression, tabulation, etc. The "type" attribute supports better classification of this activity, including the optional use of a controlled vocabulary. + + + Example + + The income variables in this study (RESP_INC, HHD_INC, and SS_INC) were topcoded to protect confidentiality. +]]> + + + + + + + + + + + + + + + + + + + + + Mathematical Identifier + + Description + Token element containing the smallest unit in the mrow that carries meaning. + + + + + + + + + + + + + + + + + + + + + Mathematical Row + + Description + This element is a wrapper containing the presentation expression mi. It creates a single string without spaces consisting of the individual elements described within it. It can be used to create a single variable by concatenating other variables into a single string. It is used to create linking variables composed of multiple non-contiguous parts, or to define unique strings for various category values of a single variable. + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + nCube + + Description + + Describes the logical structure of an n-dimensional array, in which each coordinate intersects with every other dimension at a single point. The nCube has been designed for use in the markup of aggregate data. Repetition of the following elements is provided to support multi-language content: anlysUnit, embargo, imputation, purpose, respUnit, and security. This element includes the following attributes: + The attribute "name" includes a short label for the nCube. Following the rules of many statistical analysis systems such as SAS and SPSS, names are usually up to eight characters long. + The "sdatrefs" are summary data description references which record the ID values of all elements within the summary data description section of the Study Description which might apply to the nCube. These elements include: time period covered, date of collection, nation or country, geographic coverage, geographic unit, unit of analysis, universe, and kind of data. + The "methrefs" are methodology and processing references which record the ID values of all elements within the study methodology and processing section of the Study Description which might apply to the nCube. These elements include information on data collection and data appraisal (e.g., sampling, sources, weighting, data cleaning, response rates, and sampling error estimates). + The "pubrefs" attribute provides a link to publication/citation references and records the ID values of all citations elements in Other Study Description Materials or Other Study-Related Materials that pertain to this nCube. + The "access" attribute records the ID values of all elements in the Data Access section that describe access conditions for this nCube. The "dmnsQnty" attribute notes the number of dimensions in the nCube. The "cellQnty" attribute indicates the total number of cells in the nCube. + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + nCube Group + + Description + + A group of nCubes that may share a common subject, arise from the interpretation of a single question, or are linked by some other factor. This element makes it possible to identify all nCubes derived from a simple presentation table, and to provide the original table title and universe, as well as reference the source. Specific nesting patterns can be described using the attribute nCubeGrp. + nCube groups are also created this way in order to permit nCubes to belong to multiple groups, including multiple subject groups, without causing overlapping groups. nCubes that are linked by the same use of the same variable need not be identified by an nCubeGrp element because they are already linked by a common variable element. Note that as a result of the strict sequencing required by XML, all nCube Groups must be marked up before the Variable element is opened. That is, the mark-up author cannot mark up a nCube Group, then mark up its constituent nCubes, then mark up another nCube Group. + The "type" attribute refers to the general type of grouping of the nCubes. Specific nCube Groups, included within the 'type' attribute, are: + Display: nCubes that are part of the same presentation table. + Subject: nCubes that address a common topic or subject, e.g., income, poverty, children. + Iteration: nCubes that appear in different sections of the data file measuring a common subject in different ways, e.g., using different universes, units of measurement, etc. + Pragmatic: An nCube group without shared properties. + Record: nCubes from a single record in a hierarchical file. + File: nCube from a single file in a multifile study. + Other: nCubes that do not fit easily into any of the categories listed above, e.g., a group of nCubes whose documentation is in another language. A term from a controlled vocabulary may be placed into the otherType attribute if this value is used. + The otherType attribute should only be used when applying a controlled vocabulary, and when the type attribute has been given a value of "other". Use the complex element controlledVocabUsed to identify the controlled vocabulary to which the selected term belongs. + The "nCube" attribute is used to reference all the IDs of the nCubes belonging to the group. + The "nCubeGrp" attribute is used to reference all the subsidiary nCube groups which nest underneath the current nCubeGrp. This allows for encoding of a hierarchical structure of nCube groups. + The attribute "name" provides a name, or short label, for the group. + The "sdatrefs" are summary data description references that record the ID values of all elements within the summary data description section of the Study Description that might apply to the group. These elements include: time period covered, date of collection, nation or country, geographic coverage, geographic unit, unit of analysis, universe, and kind of data. + The "methrefs" are methodology and processing references which record the ID values of all elements within the study methodology and processing section of the Study Description which might apply to the group. These elements include information on data collection and data appraisal (e.g., sampling, sources, weighting, data cleaning, response rates, and sampling error estimates). + The "pubrefs" attribute provides a link to publication/citation references and records the ID values of all citations elements within Section codeBook/stdyDscr/othrStdyMat or codeBook/otherMat that pertain to this nCube group. + The "access" attribute records the ID values of all elements in codeBook/stdyDscr/dataAccs of the document that describe access conditions for this nCube group. + + + + + + + + + + + + + + + + + + + + Country + + Description + Indicates the country or countries covered in the file. Attribute "abbr" may be used to list common abbreviations; use of ISO country codes is recommended. Maps to Dublin Core Coverage element. Inclusion of this element is recommended. For forward-compatibility, DDI 3 XHTML tags may be used in this element. + + + Example + + United Kingdom + ]]> + + + + + + + + + + + + North Bounding Latitude + + Description + The northernmost coordinate delimiting the geographic extent of the dataset. A valid range of values, expressed in decimal degrees (positive east and positive north), is: -90,0 <= North Bounding Latitude Value <= 90,0 ; North Bounding Latitude Value = South Bounding Latitude Value + + + + + + + + + + + + + + + + + + + + + + + + + Notes and comments + + Description + + For clarifying information/annotation regarding the parent element. + The attributes for notes permit a controlled vocabulary to be developed ("type" and "subject"), indicate the "level" of the DDI to which the note applies (study, file, variable, etc.), and identify the author of the note ("resp"). + The parent attribute is used to support capturing information obtained while preparing files for translation to DDI 3. It provides the ID(s) of the element this note is related to. + The sameNote attribute is used to support capturing information obtained while preparing files for translation to DDI 3. If the same note is used multiple times all the parent IDs can be captured in a single note and all duplicate notes can reference the note containing the related to references in the attribute sameNote. + + + + Example + + + + Additional information on derived variables has been added to this marked-up version of the documentation. + + + ]]> + + + This citation was prepared by the archive based on information received from the markup authors. + + + ]]> + + + The source codebook was produced from original hardcopy materials using Optical Character Recognition (OCR). + + + ]]> + + A machine-readable version of the source codebook was supplied by the Zentralarchiv + + ]]> + + This Document Description, or header information, can be used within an electronic resource discovery environment. + + ]]> + + + Data for 1998 have been added to this version of the data collection. + + + ]]> + + + This citation was sent to ICPSR by the agency depositing the data. + + + ]]> + + Data on employment and income refer to the preceding year, although demographic data refer to the time of the survey. + + ]]> + + Undocumented codes were found in this data collection. Missing data are represented by blanks. + + ]]> + + For this collection, which focuses on employment, unemployment, and gender equality, data from EUROBAROMETER 44.3: HEALTH CARE ISSUES AND PUBLIC SECURITY, FEBRUARY-APRIL 1996 (ICPSR 6752) were merged with an oversample. + + ]]> + + Data from the Bureau of Labor Statistics used in the analyses for the final report are not provided as part of this collection. + + ]]> + + Users should note that this is a beta version of the data. The investigators therefore request that users who encounter any problems with the dataset contact them at the above address. + + ]]> + + The number of arrest records for an individual is dependent on the number of arrests an offender had. + + ]]> + + + Data for all previously-embargoed variables are now available in this version of the file. + + + ]]> + + There is a restricted version of this file containing confidential information, access to which is controlled by the principal investigator. + + ]]> + + This variable group was created for the purpose of combining all derived variables. + + ]]> + + This variable group and all other variable groups in this data file were organized according to a schema developed by the adhoc advisory committee. + + ]]> + + This nCube Group was created for the purpose of presenting a cross-tabulation between variables "Tenure" and "Age of householder." + + ]]> + + Starting with Euro-Barometer 2 the coding of this variable has been standardized following an approximate ordering of each country's political parties along a "left" to "right" continuum in the first digit of the codes. Parties coded 01-39 are generally considered on the "left", those coded 40-49 in the "center", and those coded 60-89 on the "right" of the political spectrum. Parties coded 50-59 cannot be readily located in the traditional meaning of "left" and "right". The second digit of the codes is not significant to the "left-right" ordering. Codes 90-99 contain the response "other party" and various missing data responses. Users may modify these codings or part of these codings in order to suit their specific needs. + + ]]> + + Codes 90-99 contain the response "other party" and various missing data responses. + + ]]> + + + The labels for categories 01 and 02 for this variable, were inadvertently switched in the first version of this variable and have now been corrected. + + + ]]> + + This variable was created by recoding location of residence to Census regions. + + ]]> + + + The labels for categories 01 and 02 in dimension 1 were inadvertently switched in the first version of the cube, and have now been corrected. + + + ]]> + + This nCube was created to meet the needs of local low income programs in determining eligibility for federal funds. + + ]]> + + The variables in this study are identical to earlier waves. + + ]]> + + Users should be aware that this questionnaire was modified during the CAI process. + + ]]> + + + + + + + + + + + + Archive Where Study Originally Stored + + Description + Archive from which the data collection was obtained; the originating archive. + + + Example + + Zentralarchiv fuer empirische Sozialforschung + ]]> + + + + + + + + + + + + + + + + + + + + + + Other Identifications /Acknowledgments + + Description + Statements of responsibility not recorded in the title and statement of responsibility areas. Indicate here the persons or bodies connected with the work, or significant persons or bodies connected with previous editions and not already named in the description. For example, the name of the person who edited the marked-up documentation might be cited in codeBook/docDscr/rspStmt/othId, using the "role" and "affiliation" attributes. Other identifications/acknowledgments for data collection (codeBook/stdyDscr/citation/rspStmt/othId) maps to Dublin Core Contributor element. + + + Example + + Jane Smith + ]]> + + + + + + + + + + + + + + + + + + + + + + + + + + + Other References Notes + + Description + Indicates other pertinent references. Can take the form of bibliographic citations. + + + Example + + Part II of the documentation, the Field Representative's Manual, is provided in hardcopy form only. + ]]> + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + Other Study-Related Materials + + Description + + This section allows for the inclusion of other materials that are related to the study as identified and labeled by the DTD/Schema users (encoders). The' materials may be entered as PCDATA (ASCII text) directly into the document (through use of the "txt" element). This ection may also serve as a "container" for other electronic materials such as setup files by providing a brief description of the study-related materials accompanied by the attributes "type" and "level" defining the material further. The "URI" attribute may be used to indicate the location of the other study-related materials. + Other Study-Related Materials may include: questionnaires, coding notes, SPSS/SAS/Stata setup files (and others), user manuals, continuity guides, sample computer software programs, glossaries of terms, interviewer/project instructions, maps, database schema, data dictionaries, show cards, coding information, interview schedules, missing values information, frequency files, variable maps, etc. + The "level" attribute is used to clarify the relationship of the other materials to components of the study. Suggested values for level include specifications of the item level to which the element applies: e.g., level= data; level=datafile; level=studydsc; level=study. The URI attribute need not be used in every case; it is intended for capturing references to other materials separate from the codebook itself. In Section 5, Other Material is recursively defined. + + + + + + + + + + + + + + + + + + + + + + + + + Other Study Description Materials + + Description + Other materials relating to the study description. This section describes other materials that are related to the study description that are primarily descriptions of the content and use of the study, such as appendices, sampling information, weighting details, methodological and technical details, publications based upon the study content, related studies or collections of studies, etc. This section may point to other materials related to the description of the study through use of the generic citation element, which is available for each element in this section. This maps to Dublin Core Relation element. Note that codeBook/otherMat (Other Study-Related Materials), should be used for materials used in the production of the study or useful in the analysis of the study. The materials in codeBook/otherMat may be entered as PCDATA (ASCII text) directly into the document (through use of the txt element). That section may also serve as a "container" for other electronic materials by providing a brief description of the study-related materials accompanied by the "type" and "level" attributes further defining the materials. Other Study-Related Materials in codeBook/otherMat may include: questionnaires, coding notes, SPSS/SAS/Stata setup files (and others), user manuals, continuity guides, sample computer software programs, glossaries of terms, interviewer/project instructions, maps, database schema, data dictionaries, show cards, coding information, interview schedules, missing values information, frequency files, variable maps, etc. + + + + + + + + + + + Parallel Title + + Description + Title translated into another language. + + + Example + + Politbarometer West [Germany], Partial Accumulation, 1977-1995 + ]]> + Politbarometer, 1977-1995: Partielle Kumulation + ]]> + + + + + + + + + + + + + + + + + + + + + + + + + + Description + + This is an empty element containing only the attributes listed below. Attributes include "type" (type of file structure: rectangular, hierarchical, two-dimensional, relational), "recRef" (IDREF link to the appropriate file or recGrp element within a file), "startPos" (starting position of variable or data item), "endPos" (ending position of variable or data item), "width" (number of columns the variable/data item occupies), "RecSegNo" (the record segment number, deck or card number the variable or data item is located on), and "fileid" (an IDREF link to the fileDscr element for the file that includes this physical location). + Remarks: Where the same variable is coded in two different files, e.g., a fixed format file and a relational database file, simply repeat the physLoc element with the alternative location information. Note that if there is no width or ending position, then the starting position should be the ordinal position in the file, and the file would be described as free-format. New attributes will be added as other storage formats are described within the DDI. + + + + Example + + + ]]> + + ]]> + + + + + + + + + + + + + + + + + + + + + + + Point + + Description + 0-dimensional geometric primitive, representing a position, but not having extent. In this declaration, point is limited to a longitude/latitude coordinate system. + + + + + + + + + + + + + + + + + + + + + Polygon + + Description + The minimum polygon that covers a geographical area, and is delimited by at least 4 points (3 sides), in which the last point coincides with the first point. + + + + + + + + + + + PostQuestion Text + + Description + Text describing what occurs after the literal question has been asked. + + + Example + + + + The next set of questions will ask about your financial situation. + + + ]]> + + + + + + + + + + + + PreQuestion Text + + Description + Text describing a set of conditions under which a question might be asked. + + + Example + + + + For those who did not go away on a holiday of four days or more in 1985... + + + ]]> + + + + + + + + + + + + Processing Status + + Description + Processing status of the file. Some data producers and social science data archives employ data processing strategies that provide for release of data and documentation at various stages of processing. + + + Example + + Available from the DDA. Being processed. + ]]> + The principal investigator notes that the data in Public Use Tape 5 are released prior to final cleaning and editing, in order to provide prompt access to the NMES data by the research and policy community. + ]]> + + + + + + + + + + + + Date of Production + + Description + Date when the marked-up document/marked-up document source/data collection/other material(s) were produced (not distributed or archived). The ISO standard for dates (YYYY-MM-DD) is recommended for use with the date attribute. Production date for data collection (codeBook/stdyDscr/citation/prodStmt/prodDate) maps to Dublin Core Date element. + + + Example + + January 25, 1999 + ]]> + + + + + + + + + + + + Place of Production + + Description + Address of the archive or organization that produced the work. + + + Example + + Ann Arbor, MI: Inter-university Consortium for Political and Social Research + ]]> + + + + + + + + + + + + + + + + + + + + + + + + + + + + Production Statement + + Description + Production statement for the work at the appropriate level: marked-up document; marked-up document source; study; study description, other material; other material for study. + + + + + + + + + + + + + + + + + + + + + Producer + + Description + The producer is the person or organization with the financial or administrative responsibility for the physical processes whereby the document was brought into existence. Use the "role" attribute to distinguish different stages of involvement in the production process, such as original producer. Producer of data collection (codeBook/stdyDscr/citation/prodStmt/producer) maps to Dublin Core Publisher element. The "producer" in the Document Description should be the agency or person that prepared the marked-up document. + + + Example + + Inter-university Consortium for Political and Social Research + ]]> + Star Tribune Minnesota Poll + ]]> + Machine Readable Data Center + ]]> + + + + + + + + + + + + + + + + + + + + + + + + + Description + Explains the purpose for which a particular nCube was created. + + + Example + + + Meets reporting requirements for the Federal Reserve Board + + ]]> + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + Question + + Description + The question element may have mixed content. The element itself may contain text for the question, with the subelements being used to provide further information about the question. Alternatively, the question element may be empty and only the subelements used. The element has a unique question ID attribute which can be used to link a variable with other variables where the same question has been asked. This would allow searching for all variables that share the same question ID, perhaps because the questions was asked several times in a panel design. The "ID" attribute contains a unique identifier for the question. "Var" references the ID(s) of the variable(s) relating to the question. The attribute "seqNo" refers to the sequence number of the question. The attribute "sdatrefs" may be used to reference elements in the summary data description section of the Study Description which might apply to this question. These elements include: time period covered, date of collection, nation or country, geographic coverage, geographic unit, unit of analysis, universe, and kind of data. The responseDomainType attribute was added to capture the specific DDI 3 response domain type to facilitate translation between DDI 2 and DDI 3. If this is given a value of "other" then a term from a controlled vocabulary should be put into the "otherResponseDomainType" attribute. + + + Example + + + When you get together with your friends, would you say you discuss political matters frequently, occasionally, or never? + + ]]> + + + + + + + + + + + + + + + + + + + + Literal Question + + Description + Text of the actual, literal question asked. + + + Example + + + + Why didn't you go away in 1985? + + + ]]> + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + Value Range + + Description + This is the actual range of values. The "UNITS" attribute permits the specification of integer/real numbers. The "min" and "max" attributes specify the lowest and highest values that are part of the range. The "minExclusive" and "maxExclusive" attributes specify values that are immediately outside the range. This is an empty element consisting only of its attributes. + + + Example + For example, x < 1 or 10 <= x < 20 would be expressed as: + + ]]> + + ]]> + + + + + + + + + + + + + + + + + + + + + + + + + Dimensions (of record) + + Description + Information about the physical characteristics of the record. The "level" attribute on this element should be set to "record". + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + Record or Record Group + + Description + Used to describe record groupings if the file is hierarchical or relational. The attribute "recGrp" allows a record group to indicate subsidiary record groups which nest underneath; this allows for the encoding of a hierarchical structure of record groups. The attribute "rectype" indicates the type of record, e.g., "A records" or "Household records." The attribute "keyvar" is an IDREF that provides the link to other record types. In a hierarchical study consisting of individual and household records, the "keyvar" on the person record will indicate the household to which it belongs. The attribute "rtypeloc" indicates the starting column location of the record type indicator variable on each record of the data file. The attribute "rtypewidth" specifies the width, for files with many different record types. The attribute "rtypevtype" specifies the type of the indicator variable. The "recidvar" indicates the variable that identifies the record group. + + + Example + + + + CPS 1999 Person-Level Record + + 133 + 1500 + 852 + + + + ]]> + + + + + + + + + + + + Overall Number of Records + + Description + Overall record count in file. Particularly helpful in instances such as files with multiple cards/decks or records per case. + + + Example + + + 2400 + + ]]> + + + + + + + + + + + + Records per Case + + Description + Records per case in the file. This element should be used for card-image data or other files in which there are multiple records per case. + + + Example + + + 5 + + ]]> + + + + + + + Records per Case + + Description + Records per case in the file. This element should be used for card-image data or other files in which there are multiple records per case. + + + Example + + + 5 + + ]]> + + + + + + + + + + + + + + + + + + + + + + + Related Materials + + Description + Describes materials related to the study description, such as appendices, additional information on sampling found in other documents, etc. Can take the form of bibliographic citations. This element can contain either PCDATA or a citation or both, and there can be multiple occurrences of both the citation and PCDATA within a single element. May consist of a single URI or a series of URIs comprising a series of citations/references to external materials which can be objects as a whole (journal articles) or parts of objects (chapters or appendices in articles or documents). + + + Example + + Full details on the research design and procedures, sampling methodology, content areas, and questionnaire design, as well as percentage distributions by respondent's sex, race, region, college plans, and drug use, appear in the annual ISR volumes MONITORING THE FUTURE: QUESTIONNAIRE RESPONSES FROM THE NATION'S HIGH SCHOOL SENIORS. + ]]> + Current Population Survey, March 1999: Technical Documentation includes an abstract, pertinent information about the file, a glossary, code lists, and a data dictionary. One copy accompanies each file order. When ordered separately, it is available from Marketing Services Office, Customer Service Center, Bureau of the Census, Washington, D.C. 20233. + ]]> + A more precise explanation regarding the CPS sample design is provided in Technical Paper 40, The Current Population Survey: Design and Methodology. Chapter 5 of this paper provides documentation on the weighting procedures for the CPS both with and without supplement questions. + ]]> + + + + + + + + + + + + Related Publications + + Description + Bibliographic and access information aboutvarticles and reports based on the data in this collection. Can take the formbof bibliographic citations. + + + Example + + Economic Behavior Program Staff. SURVEYS OF CONSUMER FINANCES. Annual volumes 1960 through 1970. Ann Arbor, MI: Institute for Social Research. + ]]> + Data from the March Current Population Survey are published most frequently in the Current Population Reports P- 20 and P- 60 series. These reports are available from the Superintendent of Documents, U. S. Government Printing Office, Washington, DC 20402. They also are available on the INTERNET at http://www. census. gov. Forthcoming reports will be cited in Census and You, the Monthly Product Announcement (MPA), and the Bureau of the Census Catalog and Guide. + ]]> + + + + + + + + + + + + Related Studies + + Description + Information on the relationship of the current data collection to others (e.g., predecessors, successors, other waves or rounds) or to other editions of the same file. This would include the names of additional data collections generated from the same data collection vehicle plus other collections directed at the same general topic. Can take the form of bibliographic citations. + + + Example + + ICPSR distributes a companion study to this collection titled FEMALE LABOR FORCE PARTICIPATION AND MARITAL INSTABILITY, 1980: [UNITED STATES] (ICPSR 9199). + ]]> + + + + + + + + + + + + + + + + + + + + Type of Research Instrument + + Description + The type of data collection instrument used. "Structured" indicates an instrument in which all respondents are asked the same questions/tests, possibly with precoded answers. If a small portion of such a questionnaire includes open-ended questions, provide appropriate comments. "Semi-structured" indicates that the research instrument contains mainly open-ended questions. "Unstructured" indicates that in-depth interviews were conducted. The "type" attribute is included to permit the development of a controlled vocabulary for this element. + + + Example + + structured + ]]> + + + + + + + + + + + + Response Rate + + Description + The percentage of sample members who provided information. This may include a broader description of stratified response rates, information affecting resonse rates etc. + + + Example + + For 1993, the estimated inclusion rate for TEDS-eligible providers was 91 percent, with the inclusion rate for all treatment providers estimated at 76 percent (including privately and publicly funded providers). + ]]> + The overall response rate was 82%, although retail firms with an annual sales volume of more than $5,000,000 were somewhat less likely to respond. + ]]> + + + + + + + + + + + + Response Unit + + Description + Provides information regarding who provided the information contained within the variable/nCube, e.g., respondent, proxy, interviewer. This element may be repeated only to support multiple language expressions of the content. + + + Example + + + Head of household + + ]]> + + Head of household + + ]]> + + + + + + + + + + + + Restrictions + + Description + Any restrictions on access to or use of the collection such as privacy certification or distribution restrictions should be indicated here. These can be restrictions applied by the author, producer, or disseminator of the data collection. If the data are restricted to only a certain class of user, specify which type. + + + Example + + In preparing the data file(s) for this collection, the National Center for Health Statistics (NCHS) has removed direct identifiers and characteristics that might lead to identification of data subjects. As an additional precaution NCHS requires, under Section 308(d) of the Public Health Service Act (42 U.S.C. 242m), that data collected by NCHS not be used for any purpose other than statistical analysis and reporting. NCHS further requires that analysts not use the data to learn the identity of any persons or establishments and that the director of NCHS be notified if any identities are inadvertently discovered. ICPSR member institutions and other users ordering data from ICPSR are expected to adhere to these restrictions. + ]]> + ICPSR obtained these data from the World Bank under the terms of a contract which states that the data are for the sole use of ICPSR and may not be sold or provided to third parties outside of ICPSR membership. Individuals at institutions that are not members of the ICPSR may obtain these data directly from the World Bank. + ]]> + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + Table Row + + + + + + + + + + + + + + + + + + + + + Responsibility Statement + + Description + Responsibility for the creation of the work at the appropriate level: marked-up document; marked-up document source; study; study description, other material; other material for study. + + + + + + + + + + + Sampling Procedure + + Description + The type of sample and sample design used to select the survey respondents to represent the population. May include reference to the target sample size and the sampling fraction. + + + Example + + National multistage area probability sample + ]]> + Simple random sample + ]]> + Stratified random sample + ]]> + Quota sample + ]]> + The 8,450 women interviewed for the NSFG, Cycle IV, were drawn from households in which someone had been interviewed for the National Health Interview Survey (NHIS), between October 1985 and March 1987. + ]]> + Samples sufficient to produce approximately 2,000 families with completed interviews were drawn in each state. Families containing one or more Medicaid or uninsured persons were oversampled. XHTML content may be used for formatting. + ]]> + + + + + + + + + + + + Security + + Description + Provides information regarding levels of access, e.g., public, subscriber, need to know. The ISO standard for dates (YYYY-MM-DD) is recommended for use with the date attribute. + + + Example + + + This variable has been recoded for reasons of confidentiality. Users should contact the archive for information on obtaining access. + + ]]> + + Variable(s) within this nCube have been recoded for reasons of confidentiality. Users should contact the archive for information on obtaining access. + + ]]> + + + + + + + + + + + + Series Information + + Description + Contains a history of the series and a summary of those features that apply to the series as a whole. + + + Example + + The Current Population Survey (CPS) is a household sample survey conducted monthly by the Census Bureau to provide estimates of employment, unemployment, and other characteristics of the general labor force, estimates of the population as a whole, and estimates of various subgroups in the population. The entire non-institutionalized population of the United States is sampled to obtain the respondents for this survey series. + ]]> + + + + + + + + + + + + + + + + + + + + Series Name + + Description + The name of the series to which the work belongs. + + + Example + + Current Population Survey Series + ]]> + + + + + + + + + + + + + + + + + + + + + + + + Series Statement + + Description + Series statement for the work at the appropriate level: marked-up document; marked-up document source; study; study description, other material; other material for study. The URI attribute is provided to point to a central Internet repository of series information. Repeat this field if the study is part of more than one series. Repetition of the internal content should be used to support multiple languages only. + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + Data Set Availability + + Description + Information on availability and storage of the collection. The "media" attribute may be used in combination with any of the subelements. See Location of Data Collection. + + + + + + + + + + + + + + + + + + + Software used in Production + + Description + Software used to produce the work. A "version" attribute permits specification of the software version number. The "date" attribute is provided to enable specification of the date (if any) for the software release. The ISO standard for dates (YYYY-MM-DD) is recommended for use with the date attribute. + + + Example + + + + + MRDC Codebook Authoring Tool + + + + ]]> + + + + Arbortext Adept Editor + + + + ]]> + + + + PageMaker + + + + ]]> + + + + SAS + + + + ]]> + + The SAS transport file was generated by the SAS CPORT procedure. + + ]]> + + + + + + + + + + + + + + + + + + + + + + + + + + + + + Sources Statement + + Description + Description of sources used for the data collection. The element is nestable so that the sources statement might encompass a series of discrete source statements, each of which could contain the facts about an individual source. This element maps to Dublin Core Source element. + + + + + + + + + + + + Source Citation + + + Description + This complex element allows the inclusion of a standard citation for the sources used in collecting and creating the dataset. + + + + Example + + + +Tenth Decennial Census of the United States, 1880. Volume I. Statistics of the Population of the United States at the Tenth Census. + + +United States Census Bureau + + +Government Printing Office +1883 + + +]]> + + + + + + + + + + + + + South Bounding Latitude + + Description + The southernmost coordinate delimiting the geographic extent of the dataset. A valid range of values, expressed in decimal degrees (positive east and positive north), is: -90,0 <=South Bounding Latitude Value <= 90,0 ; South Bounding Latitude Value <= North Bounding Latitude Value + + + + + + + + + + + + + + + + + + + + + + + + + + + + Special Permissions + + Description + This element is used to determine if any special permissions are required to access a resource. The "required" attribute is used to aid machine processing of this element, and the default specification is "yes". The "formNo" attribute indicates the number or ID of the form that the user must fill out. The "URI" attribute may be used to provide a URN or URL for online access to a special permissions form. + + + Example + + The user must apply for special permission to use this dataset locally and must complete a confidentiality form. + ]]> + + + + + + + + + + + + Characteristics of Source Noted + + Description + Assessment of characteristics and quality of source material. May not be relevant to survey data. This element may be repeated to support multiple language expressions of the content. + + + + + + + + + + + Documentation and Access to Sources + + Description + Level of documentation of the original sources. May not be relevant to survey data. This element may be repeated to support multiple language expressions of the content. + + + + + + + + + + + Origins of Sources + + Description + For historical materials, information about the origin(s) of the sources and the rules followed in establishing the sources should be specified. May not be relevant to survey data. This element may be repeated to support multiple language expressions of the content. + + + + + + + + + + + + + + + + + + + Standard Categories + + Description + Standard category codes used in the variable, like industry codes, employment codes, or social class codes. The attribute "date" is provided to indicate the version of the code in place at the time of the study. The attribute "URI" is provided to indicate a URN or URL that can be used to obtain an electronic list of the category codes. + + + Example + + + U. S. Census of Population and Housing, Classified Index of Industries and Occupations + + ]]> + + + + + + + + + + + + + + + + + + + + Class of the Study + + Description + Generally used to give the data archive's class or study status number, which indicates the processing status of the study. May also be used as a text field to describe processing status. This element may be repeated to support multiple language expressions of the content. + + + Example + + ICPSR Class II + ]]> + DDA Class C + ]]> + Available from the DDA. Being processed. + ]]> + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + Study Description + + Description + The Study Description consists of information about the data collection, study, or compilation that the DDI-compliant documentation file describes. This section includes information about how the study should be cited, who collected or compiled the data, who distributes the data, keywords about the content of the data, summary (abstract) of the content of the data, data collection methods and processing, etc. Note that some content of the Study Description's Citation -- e.g., Responsibility Statement -- may be identical to that of the Documentation Citation. This is usually the case when the producer of a data collection also produced the print or electronic codebook for that data collection. + + + + + + + + + + + + + + + + + + + + + Study Development + + Description + Describe the process of study development as a series of development activities. These activities can be typed using a controlled vocabulary. Describe the activity, listing participants with their role and affiliation, resources used (sources of information), and the outcome of the development activity. + + + Example + This would allow you to provide inputs for a number of development activities you wanted to capture using separate entry screens and tagged storage of developmentActivity using the type attribute. For example if there was an activity related to data availability the developmentActivity might be as follows: + + + A number of potential sources were evaluated for content, consistency and quality + John Doe + + Study S + Collected in 1970 using unknown sampling method + Information incomplete missing X province + + Due to quality issues this was determined not to be a viable source of data for the study + + ]]> + This generic structure would allow you to designate additional design activities etc. + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + Study Authorization + + Description + Provides structured information on the agency that authorized the study, the date of authorization, and an authorization statement. + + + Example + + +Human Subjects Office +Statement of authorization issued bu OUHS on 2010-11-04 +]]> + + + + + + + + + + + + + + + + + + + + + + Authorizing Agency + + Description + Name of the agent or agency that authorized the study. The "affiliation" attribute indicates the institutional affiliation of the authorizing agent or agency. The "abbr" attribute holds the abbreviation of the authorizing agent's or agency's name. + + + Example + + Office for Use of Human Subjects +]]> + + + + + + + + + + + + + Authorization Statement + + Description + The text of the authorization. Use XHTML to capture significant structure in the document. + + + Example + + Required documentation covering the study purpose, disclosure information, questionnaire content, and consent statements was delivered to the OUHS on 2010-10-01 and was reviewed by the compliance officer. Statement of authorization for the described study was issued on 2010-11-04 +]]> + + + + + + + + + + + + + + + + + + + + + + + + + + + + + Study Scope + + Description + This section contains information about the data collection's scope across several dimensions, including substantive content, geography, and time. + + + + + + + + + + + + + + + + + + + + + + Quality Statement + + Description + This structure consists of two parts, standardsCompliance and otherQualityStatements. In standardsCompliance list all specific standards complied with during the execution of this study. Note the standard name and producer and how the study complied with the standard. Enter any additional quality statements in otherQualityStatements. + + + + + + + + + + + + + + + + + + + + + + Standards Compliance + + Description + This section lists all specific standards complied with during the execution of this study. Specify the standard(s)' name(s) and producer(s) and describe how the study complied with each standard in complianceDescription. Enter any additional quality statements in otherQualityStatement. + + + Example + + +Data Documentation Initiative +DDI Alliance +Study metadata was created in compliance with the Data Documentation Initiative (DDI) standard + +]]> + + + + + + + + + + + + + + + + + + + + + + + + + Standard + + Description + Describes a standard with which the study complies. + + + + + + + + + + + + + + + + + + + + + Standard Name + + Description + Contains the name of the standard with which the study complies. The "date" attribute specifies the date when the standard was published, the "version" attribute includes the specific version of the standard with which the study is compliant, and the "URI" attribute includes the URI for the actual standard. + + + Example + + Data Documentation Initiative +]]> + + + + + + + + + + + + + + + + + + + + + + + + + + + Post Evaluation Procedures + + Description + Use this section to describe evaluation procedures not address in data evaluation processes. These may include issues such as timing of the study, sequencing issues, cost/budget issues, relevance, instituional or legal arrangments etc. of the study. The completionDate attribute holds the date the evaluation was completed. The type attribute is an optional type to identify the type of evaluation with or without the use of a controlled vocabulary. + + + Example + + +United Nations Statistical Division +In-depth review of pre-collection and collection procedures +The following steps were highly effective in increasing response rates, and should be repeated in the next collection cycle... + +]]> + + + + + + + + + + + + + + + + + + + + + + + Evaluator Type + + Description + The evaluator element identifies persons or organizations involved in the evaluation. The affiliation attribute contains the affiliation of the individual or organization. The abbr attribute holds an abbreviation for the individual or organization. The role attribute indicates the role played by the individual or organization in the evaluation process. + + + Example + + United Nations Statistical Division +]]> + + + + + + + + + + + + + Evaluation Process + + Description + Describes the evaluation process followed. + + + + + + + + + + + Evaluation Outcomes + + Description + Describe the outcomes of the evaluation. + + + Example + + The following steps were highly effective in increasing response rates, and should be repeated in the next collection cycle... +]]> + + + + + + + + + + + + + Study Budget + + Description + Describe the budget of the project in as much detail as needed. Use XHTML structure elements to identify discrete pieces of information in a way that facilitates direct transfer of information on the study budget between DDI 2 and DDI 3 structures. + + + Example + + The budget for the study covers a 5 year award period distributed between direct and indirect costs including: Staff, ... +]]> + + + + + + + + + + + + + Subtitle + + Description + A secondary title used to amplify or state certain limitations on the main title. It may repeat information already in the main title. + + + Example + + Monitoring the Future: A Continuing Study of American Youth, 1995 + ]]> + A Continuing Study of American Youth, 1995 + ]]> + Census of Population, 1950 [United States]: Public Use Microdata Sample + ]]> + Public Use Microdata Sample + ]]> + + + + + + + + + + + + + + + + + + + + + + + Subject Information + + Description + Subject information describing the data collection's intellectual content. + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + Summary Data Description + + Description + Information about the and geographic coverage of the study and unit of analysis. + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + Summary Statistics + + Description + + One or more statistical measures that describe the responses to a particular variable and may include one or more standard summaries, e.g., minimum and maximum values, median, mode, etc. The attribute "wgtd" indicates whether the statistics are weighted or not. The "weight" attribute is an IDREF(S) to the weight element(s) in the study description. + The attribute "type" denotes the type of statistics being shown: mean, median, mode, valid cases, invalid cases, minimum, maximum, or standard deviation. If a value of "other" is used here, a value taken from a controlled vocabulary should be put in the "otherType" attribute. This option should only be used when applying a controlled vocabulary to this attribute. Use the complex element controlledVocabUsed to identify the controlled vocabulary to which the selected term belongs. + + + + Example + + + 0 + + ]]> + + 9 + + ]]> + + 4 + + ]]> + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + Table + + + + + + + + + + + + + + + + + + + + + + + + + + + + + Table Body + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + Table Group + + + + + + + + + + + + + + + + + + + + + + + + + + + + + Table Head + + + + + + + + + + + + + + + + + + Time Method + + Description + The time method or time dimension of the data collection. The "method" attribute is included to permit the development of a controlled vocabulary for this element. For forward-compatibility, DDI 3 XHTML tags may be used in this element. + + + Example + + panel survey + ]]> + cross-section + ]]> + trend study + ]]> + time-series + ]]> + + + + + + + + + + + + + + + + + + + + + + + + + + + + + Time Period Covered + + Description + The time period to which the data refer. This item reflects the time period covered by the data, not the dates of coding or making documents machine-readable or the dates the data were collected. Also known as span. Use the event attribute to specify "start", "end", or "single" for each date entered. The ISO standard for dates (YYYY-MM-DD) is recommended for use with the "date" attribute. The "cycle" attribute permits specification of the relevant cycle, wave, or round of data. Maps to Dublin Core Coverage element. Inclusion of this element is recommended. + + + Example + + May 1, 1998 + ]]> + May 31, 1998 + ]]> + + + + + + + + + + + + Title + + Description + Full authoritative title for the work at the appropriate level: marked-up document; marked-up document source; study; other material(s) related to study description; other material(s) related to study. The study title will in most cases be identical to the title for the marked-up document. A full title should indicate the geographic scope of the data collection as well as the time period covered. Title of data collection (codeBook/stdyDscr/citation/titlStmt/titl) maps to Dublin Core Title element. This element is required in the Study Description citation. + + + Example + + Domestic Violence Experience in Omaha, Nebraska, 1986-1987 + ]]> + Census of Population, 1950 [United States]: Public Use Microdata Sample + ]]> + Monitoring the Future: A Continuing Study of American Youth, 1995 + ]]> + + + + + + + + + + + + + + + + + + + + + + + + + + Title Statement + + Description + Title statement for the work at the appropriate level: marked-up document; marked-up document source; study; study description, other materials; other materials for study. + + + + + + + + + + + + + + + + + + + + Topic Classification + + Description + The classification field indicates the broad substantive topic(s) that the data cover. Library of Congress subject terms may be used here. The "vocab" attribute is provided for specification of the controlled vocabulary in use, e.g., LCSH, MeSH, etc. The "vocabURI" attribute specifies the location for the full controlled vocabulary. Maps to Dublin Core Subject element. Inclusion of this element in the codebook is recommended. + + + Example + + Public opinion -- California -- Statistics + ]]> + Elections -- California + ]]> + + + + + + + + + + + + Total Responses + + Description + The number of responses to this variable. This element might be used if the number of responses does not match added case counts. It may also be used to sum the frequencies for variable categories. + + + Example + + + 1,056 + + ]]> + + There are only 725 responses to this question since it was not asked in Tanzania. + + ]]> + + + + + + + + + + + + + + + + + + + + + Descriptive Text + + Description + Lengthier description of the parent element. The attribute "level" indicates the level to which the element applies. The attribute "sdatrefs" allows pointing to specific dates, universes, or other information encoded in the study description. + + + Example + + + The following five variables refer to respondent attitudes toward national environmental policies: air pollution, urban sprawl, noise abatement, carbon dioxide emissions, and nuclear waste. + + ]]> + + The following four nCubes are grouped to present a cross tabulation of the variables Sex, Work experience in 1999, and Income in 1999. + + ]]> + + Total population for the agency for the year reported. + + ]]> + + When the respondent indicated his political party reference, his response was coded on a scale of 1-99 with parties with a left-wing orientation coded on the low end of the scale and parties with a right-wing orientation coded on the high end of the scale. Categories 90-99 were reserved miscellaneous responses. + + ]]> + + Inap., question not asked in Ireland, Northern Ireland, and Luxembourg. + + ]]> + + Detailed poverty status for age cohorts over a period of five years, to be used in determining program eligibility + + ]]> + + This is a PDF version of the original questionnaire provided by the principal investigator. + + ]]> + + Glossary of Terms. Below are terms that may prove useful in working with the technical documentation for this study.. + + ]]> + + This is a PDF version of the original questionnaire provided by the principal investigator. + + ]]> + + + + + + + + + + + + List of Undocumented Codes + + Description + Values whose meaning is unknown. + + + Example + + + Responses for categories 9 and 10 are unavailable. + + ]]> + + + + + + + + + + + + + + + + + + + + + + + + + + + + Universe + + Description + The group of persons or other elements that are the object of research and to which any analytic results refer. Age,nationality, and residence commonly help to delineate a given universe, but any of a number of factors may be involved, such as sex, race, income, veteran status, criminal convictions, etc. The universe may consist of elements other than persons, such as housing units, court cases, deaths, countries, etc. In general, it should be possible to tell from the description of the universe whether a given individual or element (hypothetical or real) is a member of the population under study. A "level" attribute is included to permit coding of the level to which universe applies, i.e., the study level, the file level (if different from study), the record group, the variable group, the nCube group, the variable, or the nCube level. The "clusion" attribute provides for specification of groups included (I) in or excluded (E) from the universe. If all the variables/nCubes described in the data documentation relate to the same population, e.g., the same set of survey respondents, this element would be unnecessary at data description level. In this case, universe can be fully described at the study level. For forward-compatibility, DDI 3 XHTML tags may be used in this element. This element may be repeated only to support multiple language expressions of the content. + + + Example + + Individuals 15-19 years of age. + ]]> + Individuals younger than 15 and older than 19 years of age. + ]]> + + + + + + + + + + + + + + + + + + + + + + + + + + + + + Use Statement + + Description + Information on terms of use for the data collection. This element may be repeated only to support multiple language expressions of the content. + + + + + + + + + + + + + + + + + + + + + + + + + + Range of Valid Data Values + + Description + Values for a particular variable that represent legitimate responses. + + + Example + + + + + ]]> + + + + + + ]]> + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + Variable + + Description + + This element describes all of the features of a single variable in a social science data file. The following elements are repeatable to support multi-language content: anlysUnit, embargo, imputation, respUnit, security, TotlResp. It includes the following attributes: + The attribute "name" usually contains the so-called "short label" for the variable, limited to eight characters in many statistical analysis systems such as SAS or SPSS. + The attribute "wgt" indicates whether the variable is a weight. + The attribute "wgt-var" references the weight variable(s) for this variable. + The attribute "qstn" is a reference to the question ID for the variable. + The attribute "files" is the IDREF identifying the file(s) to which the variable belongs. + The attribute "vendor" is the origin of the proprietary format and includes SAS, SPSS, ANSI, and ISO. + The attribute "dcml" refers to the number of decimal points in the variable. + The attribute "intrvl" indicates the interval type; options are discrete or continuous. + The "rectype" attribute refers to the record type to which the variable belongs. + The "sdatrefs" are summary data description references which record the ID values of all elements within the summary data description section of the Study Description which might apply to the variable. These elements include: time period covered, date of collection, nation or country, geographic coverage, geographic unit, unit of analysis, universe, and kind of data. + The "methrefs" are methodology and processing references which record the ID values of all elements within the study methodology and processing section of the Study Description which might apply to the variable. These elements include information on data collection and data appraisal (e.g., sampling, sources, weighting, data cleaning, response rates, and sampling error estimates). + The "pubrefs" attribute provides a link to publication/citation references and records the ID values of all citations elements within Other Study Description Materials or Other Study-Related Materials that pertain to this variable. + The attribute "access" records the ID values of all elements in the Data Access section that describe access conditions for this variable. + The "aggrMeth" attribute indicates the type of aggregation method used, for example 'sum', 'average', 'count'. If a value of "other" is given a term from a controlled vocabulary should be used in the "otherAggrMeth" attribute. + The "otherAggrMeth" attribute holds a value from a controlled vocabulary when the aggrMeth attribute has a value of "other".This option should only be used when applying a controlled vocabulary to this attribute. Use the complex element controlledVocabUsed to identify the controlled vocabulary to which the selected term belongs. + The attribute "measUnit" records the measurement unit, for example 'km', 'miles', etc. + The "scale" attribute records unit of scale, for example 'x1', 'x1000', etc. + The attribute "origin" records the point of origin for anchored scales. + The "nature" attribute records the nature of the variable, whether it is 'nominal', 'ordinal', 'interval', 'ratio', or 'percent'. If the 'other' value is used, a value from a controlled vocabulary should be put into the otherNature attribute. + The "otherNature" attribute should be used when the nature attribute has a value of "other". This option should only be used when applying a controlled vocabulary to this attribute. Use the complex element controlledVocabUsed to identify the controlled vocabulary to which the selected term belongs. + The attribute "additivity" records type of additivity, such as 'stock', 'flow', 'non-additive'. When the "other" value is used, a value from a controlled vocabulary should be put into the "otherAdditivity" attribute. + The "otherAdditivity" attribute is used only when the "additivity" attribute has a value of "other". This option should only be used when applying a controlled vocabulary to this attribute. Use the complex element controlledVocabUsed to identify the controlled vocabulary to which the selected term belongs. + The attribute "temporal" indicates whether the variable relays time-related information. + The "geog" attribute indicates whether the variable relays geographic information. + The attribute "geoVocab" records the coding scheme used in the variable. + The attribute "catQnty" records the number of categories found in the variable, and is used primarily for aggregate data files for verifying cell counts in nCubes. + The "representationType" attribute was added to capture the specific DDI 3 representation type to facilitate translation between DDI 2 and DDI 3. If the "other" value is used, a term from a controlled vocabulary may be supplied in the otherRepresentationType attribute. + The "otherRepresentationType" attribute should be used when the representationType attribute has a value of "other". This option should only be used when applying a controlled vocabulary to this attribute. Use the complex element controlledVocabUsed to identify the controlled vocabulary to which the selected term belongs. + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + Variable Format + + Description + The technical format of the variable in question. Attributes for this element include: "type," which indicates if the variable is character or numeric; "formatname," which in some cases may provide the name of the particular, proprietary format actually used; "schema," which identifies the vendor or standards body that defined the format (acceptable choices are SAS, SPSS, IBM, ANSI, ISO, XML-data or other); "category," which describes what kind of data the format represents, and includes date, time, currency, or "other" conceptual possibilities; and "URI," which supplies a network identifier for the format definition. If the "other" value is used for the schema attribute, a value from a controlled vocabulary must be used with the "otherSchema" attribute, and the complex element controlledVocabUsed should be used to identify the controlled vocabulary to which the selected term belongs. For the category attribute, a value from a controlled vocabulary may be provided if the "other" value is chosen. In this case, the term from the controlled vocabulary should be placed in the "othercategory" attribute, and the controlledVocabUsed element should also be filled in. + + + Example + + + The number in this variable is stored in the form 'ddmmmyy' in SAS format. + + ]]> + + 19541022 + + ]]> + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + Variable Group + + Description + + A group of variables that may share a common subject, arise from the interpretation of a single question, or are linked by some other factor. + Variable groups are created this way in order to permit variables to belong to multiple groups, including multiple subject groups such as a group of variables on sex and income, or to a subject and a multiple response group, without causing overlapping groups. Variables that are linked by use of the same question need not be identified by a Variable Group element because they are linked by a common unique question identifier in the Variable element. Note that as a result of the strict sequencing required by XML, all Variable Groups must be marked up before the Variable element is opened. That is, the mark-up author cannot mark up a Variable Group, then mark up its constituent variables, then mark up another Variable Group. + The "type" attribute refers to the general type of grouping of the variables, e.g., subject, multiple response. Use the value of "other" if the value is to come from an external controlled vocabulary, and place the term into the otherType attribute. + The "otherType" attribute is used when the "type" attribute has a value of "other". This option should only be used when applying a controlled vocabulary to this attribute. Use the complex element controlledVocabUsed to identify the controlled vocabulary to which the selected term belongs. + Specific variable groups, included within the "type" attribute, are: + Section: Questions which derive from the same section of the questionnaire, e.g., all variables located in Section C. + Multiple response: Questions where the respondent has the opportunity to select more than one answer from a variety of choices, e.g., what newspapers have you read in the past month (with the respondent able to select up to five choices). + Grid: Sub-questions of an introductory or main question but which do not constitute a multiple response group, e.g., I am going to read you some events in the news lately and you tell me for each one whether you are very interested in the event, fairly interested in the fact, or not interested in the event. + Display: Questions which appear on the same interview screen (CAI) together or are presented to the interviewer or respondent as a group. + Repetition: The same variable (or group of variables) which are repeated for different groups of respondents or for the same respondent at a different time. + Subject: Questions which address a common topic or subject, e.g., income, poverty, children. + Version: Variables, often appearing in pairs, which represent different aspects of the same question, e.g., pairs of variables (or groups) which are adjusted/unadjusted for inflation or season or whatever, pairs of variables with/without missing data imputed, and versions of the same basic question. + Iteration: Questions that appear in different sections of the data file measuring a common subject in different ways, e.g., a set of variables which report the progression of respondent income over the life course. + Analysis: Variables combined into the same index, e.g., the components of a calculation, such as the numerator and the denominator of an economic statistic. + Pragmatic: A variable group without shared properties. + Record: Variable from a single record in a hierarchical file. + File: Variable from a single file in a multifile study. + Randomized: Variables generated by CAI surveys produced by one or more random number variables together with a response variable, e.g, random variable X which could equal 1 or 2 (at random) which in turn would control whether Q.23 is worded "men" or "women", e.g., would you favor helping [men/women] laid off from a factory obtain training for a new job? + Other: Variables which do not fit easily into any of the categories listed above, e.g., a group of variables whose documentation is in another language. + The "var" attribute is used to reference all the constituent variable IDs in the group. + The "varGrp" attribute is used to reference all the subsidiary variable groups which nest underneath the current varGrp. This allows for encoding of a hierarchical structure of variable groups. + The attribute "name" provides a name, or short label, for the group. + The "sdatrefs" are summary data description references that record the ID values of all elements within the summary data description section of the Study Description that might apply to the group. These elements include: time period covered, date of collection, nation or country, geographic coverage, geographic unit, unit of analysis, universe, and kind of data. + The "methrefs" are methodology and processing references which record the ID values of all elements within the study methodology and processing section of the Study Description which might apply to the group. These elements include information on data collection and data appraisal (e.g., sampling, sources, weighting, data cleaning, response rates, and sampling error estimates). + The "pubrefs" attribute provides a link to publication/citation references and records the ID values of all citations elements within codeBook/stdyDscr/othrStdyMat or codeBook/otherMat that pertain to this variable group. + The "access" attribute records the ID values of all elements in codeBook/stdyDscr/dataAccs of the document that describe access conditions for this variable group. + The attribute "nCube" was included in 2.0 and subsequent versions in ERROR. DO NOT USE THIS ATTRIBUTE. It is retained only for purposes of backward-compatibility. + + + + + + + + + + + + Overall Variable Count + + Description + Number of variables. + + + Example + + 27 + ]]> + + + + + + + + + + + + + + + + + + + + Version Responsibility Statement + + Description + The organization or person responsible for the version of the work. + + + Example + + Zentralarchiv fuer Empirische Sozialforschung + ]]> + Inter-university Consortium for Political and Social Research + ]]> + + + Zentralarchiv fuer Empirische Sozialforschung + + + ]]> + + + Zentralarchiv fuer Empirische Sozialforschung + + + ]]> + + + + + + + + + + + + + + + + + + + + + + + + Version Statement + + Description + Version statement for the work at the appropriate level: marked-up document; marked-up document source; study; study description, other material; other material for study. A version statement may also be included for a data file, a variable, or an nCube. + + + Example + + + Second version + + ]]> + + + + + + + + + + + + + + + + + + + + Version + + Description + Also known as release or edition. If there have been substantive changes in the data/documentation since their creation, this statement should be used at the appropriate level. The ISO standard for dates (YYYY-MM-DD) is recommended for use with the "date" attribute. + + + Example + + Second ICPSR Edition + ]]> + + + Second version of V25 + + + ]]> + + + Second version of N25 + + + ]]> + + + + + + + + + + + + Weighting + + Description + The use of sampling procedures may make it necessary to apply weights to produce accurate statistical results. Describe here the criteria for using weights in analysis of a collection. If a weighting formula or coefficient was developed, provide this formula, define its elements, and indicate how the formula is applied to data. + + + Example + + The 1996 NES dataset includes two final person-level analysis weights which incorporate sampling, nonresponse, and post-stratification factors. One weight (variable #4) is for longitudinal micro-level analysis using the 1996 NES Panel. The other weight (variable #3) is for analysis of the 1996 NES combined sample (Panel component cases plus Cross-section supplement cases). In addition, a Time Series Weight (variable #5) which corrects for Panel attrition was constructed. This weight should be used in analyses which compare the 1996 NES to earlier unweighted National Election Study data collections. + ]]> + + + + + + + + + + + + West Bounding Longitude + + Description + The westernmost coordinate delimiting the geographic extent of the dataset. A valid range of values, expressed in decimal degrees (positive east and positive north), is: -180,0 <=West Bounding Longitude Value <= 180,0 + + + + + + + \ No newline at end of file diff --git a/src/test/java/edu/harvard/iq/dataverse/export/ddi/dataset-finch1.xml b/src/test/java/edu/harvard/iq/dataverse/export/ddi/dataset-finch1.xml index 6336bf0b0e5..d26c80ce884 100644 --- a/src/test/java/edu/harvard/iq/dataverse/export/ddi/dataset-finch1.xml +++ b/src/test/java/edu/harvard/iq/dataverse/export/ddi/dataset-finch1.xml @@ -7,9 +7,14 @@ 10.5072/FK2/PCA2E3 - Finch, Fiona + Finch, Fiona Finch, Fiona, 2015, "Darwin's Finches", http://dx.doi.org/10.5072/FK2/PCA2E3, Root Dataverse, V1 + + + Darwin's finches (also known as the Galápagos finches) are a group of about fifteen species of passerine birds. + + diff --git a/src/test/java/edu/harvard/iq/dataverse/export/ddi/dataset-spruce1.xml b/src/test/java/edu/harvard/iq/dataverse/export/ddi/dataset-spruce1.xml index c051b31eab9..b0a9442ba98 100644 --- a/src/test/java/edu/harvard/iq/dataverse/export/ddi/dataset-spruce1.xml +++ b/src/test/java/edu/harvard/iq/dataverse/export/ddi/dataset-spruce1.xml @@ -11,6 +11,11 @@ Spruce, Sabrina, 2015, "Spruce Goose", http://dx.doi.org/10.5072/FK2/OZDVMO, Root Dataverse, V1 + + + What the Spruce Goose was really made of. + + From 2c99fef0202f072c3fc204624d8c90d5a783f3ce Mon Sep 17 00:00:00 2001 From: Michael Heppler Date: Tue, 14 Jun 2016 16:41:07 -0400 Subject: [PATCH 031/267] Cleaned up styling and layout of the Export Metadata button in the Metadata tab of the dataset pg. [ref #2579] --- src/main/webapp/dataset.xhtml | 25 ++++++++++----------- src/main/webapp/filesFragment.xhtml | 2 +- src/main/webapp/resources/css/structure.css | 2 +- 3 files changed, 14 insertions(+), 15 deletions(-) diff --git a/src/main/webapp/dataset.xhtml b/src/main/webapp/dataset.xhtml index c9d3e4d01fc..781737b1104 100755 --- a/src/main/webapp/dataset.xhtml +++ b/src/main/webapp/dataset.xhtml @@ -515,12 +515,18 @@ -
    -
    - -
  • - +
  • From 504dccd35df64ce879fa501ddb7fc15576f98e6c Mon Sep 17 00:00:00 2001 From: Leonid Andreev Date: Tue, 28 Jun 2016 00:42:46 -0400 Subject: [PATCH 065/267] More fixes to the harvesting code. New page for managing OAI server sets. --- src/main/java/Bundle.properties | 44 +++ .../harvard/iq/dataverse/DataCitation.java | 2 + .../iq/dataverse/DatasetServiceBean.java | 2 +- .../iq/dataverse/DataverseServiceBean.java | 2 +- .../iq/dataverse/DvObjectServiceBean.java | 2 +- .../iq/dataverse/HarvestingClientsPage.java | 20 +- .../iq/dataverse/HarvestingSetsPage.java | 332 ++++++++++++++++++ .../iq/dataverse/harvest/server/OAISet.java | 12 +- .../harvest/server/web/XOAIItem.java | 2 +- .../server/web/servlet/OAIServlet.java | 48 ++- .../settings/SettingsServiceBean.java | 6 +- .../iq/dataverse/util/SystemConfig.java | 12 + src/main/webapp/dashboard.xhtml | 17 +- src/main/webapp/harvestclients.xhtml | 52 +-- src/main/webapp/harvestsets.xhtml | 247 +++++++++++++ src/main/webapp/search-include-fragment.xhtml | 3 + 16 files changed, 752 insertions(+), 51 deletions(-) create mode 100644 src/main/java/edu/harvard/iq/dataverse/HarvestingSetsPage.java create mode 100644 src/main/webapp/harvestsets.xhtml diff --git a/src/main/java/Bundle.properties b/src/main/java/Bundle.properties index e80b37f2a2a..074c2ffc360 100755 --- a/src/main/java/Bundle.properties +++ b/src/main/java/Bundle.properties @@ -227,6 +227,7 @@ apitoken.regenerateBtn=Recreate Token #dashboard.xhtml dashboard.title=Dashboard + #harvestclients.xhtml harvestclients.title=Manage Harvesting Clients harvestclients.toptip= - Metadata records are gathered from supported resources (e.g. OAI servers, such as, but not limited to, other Dataverse instances). Harvesting can be scheduled to run on a regular basis, or on demand, here or via the REST API. Once harvested, datasets cannot be edited. The metadata are searchable, but to access the data users will be redirected to the original source. @@ -298,6 +299,49 @@ harvestclients.viewEditDialog.archiveDescription.default.generic=This Dataset is harvestclients.viewEditDialog.btn.save=Save harvestclients.newClientDialog.title.edit=Edit Group {0} +#harvestset.xhtml +harvestsets.title=Manage Harvesting (OAI) Server and OAI Sets +harvestsets.toptip= Define sets of local Datasets that will be available for harvesting by remote clients. +harvestsets.service.enabled=Enable OAI Server +harvestsets.service.disabled=Disable OAI Server +harvestsets.tab.header.spec=OAI Set Spec +harvestsets.tab.header.description=Description +harvestsets.tab.header.definition=Definition Query +harvestsets.tab.header.stats=Set statistics +harvestsets.tab.header.action=Actions +harvestsets.tab.header.action.btn.export=Run Export +harvestsets.tab.header.action.btn.edit=Edit +harvestsets.tab.header.action.btn.delete=Delete +harvestsets.tab.header.action.btn.delete.dialog.header=Delete Harvesting Set +harvestsets.tab.header.action.btn.delete.dialog.tip=Are you sure you want to delete this set? You cannot undo a delete. + +harvestsets.newSetDialog.title.new=Create Harvesting Set +harvestsets.newSetDialog.help=Create a new Harveting Set + +harvestsets.newSetDialog.setspec=Set Name ("setspec") +harvestsets.newSetDialog.setspec.helptext=A unique name ("OAI setspec") identifying this set; consists of letters, digits, underscores (_) and dashes (-). +harvestsets.newSetDialog.setspec.required=Set name ("OAI setspec") cannot be empty! +harvestsets.newSetDialog.setspec.invalid=Set name ("OAI setspec") can contain only letters, digits, underscores (_) and dashes (-). +harvestsets.newSetDialog.setspec.alreadyused=This Set name ("OAI setspec") is already used. +harvestsets.newSetDialog.btn.validatesetspec=Next + +harvestsets.newSetDialog.setdescription=Set Description +harvestsets.newSetDialog.setdescription.helptext=Provide a brief description for this OAI set (for example: publicly available datasets by prof. King) +harvestsets.newSetDialog.setdescription.required=Set description cannot be empty! + +harvestsets.newSetDialog.setquery=Definition Query +harvestsets.newSetDialog.setquery.helptext=Search query that defines the content of the dataset (for example: authorName:king) +harvestsets.newSetDialog.setquery.required=Search query cannot be left empty! + + +harvestsets.newSetDialog.btn.create=Create Set + +harvestsets.viewEditDialog.title=OAI set configuration +harvestsets.viewEditDialog.help=Use this form to view and edit the settings for this OAI server set. + +harvestsets.viewEditDialog.btn.save=Save +harvestsets.newSetDialog.title.edit=Edit Group {0} + #MailServiceBean.java notification.email.create.dataverse.subject=Dataverse: Your dataverse has been created diff --git a/src/main/java/edu/harvard/iq/dataverse/DataCitation.java b/src/main/java/edu/harvard/iq/dataverse/DataCitation.java index 6a0e25c691c..4248b710401 100644 --- a/src/main/java/edu/harvard/iq/dataverse/DataCitation.java +++ b/src/main/java/edu/harvard/iq/dataverse/DataCitation.java @@ -58,6 +58,8 @@ public DataCitation(DatasetVersion dsv) { year = sdf.format(sdf.parse(dsv.getDistributionDate())); } catch (ParseException ex) { // ignore + } catch (Exception ex) { + // ignore } } diff --git a/src/main/java/edu/harvard/iq/dataverse/DatasetServiceBean.java b/src/main/java/edu/harvard/iq/dataverse/DatasetServiceBean.java index 7ff6faee598..74f69b24143 100644 --- a/src/main/java/edu/harvard/iq/dataverse/DatasetServiceBean.java +++ b/src/main/java/edu/harvard/iq/dataverse/DatasetServiceBean.java @@ -554,7 +554,7 @@ public Map getHarvestingDescriptionsForHarvestedDatasets(Set String datasetIdStr = Strings.join(datasetIds, ", "); - String qstr = "SELECT d.id, h.archiveDescription FROM harvestingDataverseConfig h, dataset d, dvobject o WHERE d.id = o.id AND h.dataverse_id = o.owner_id AND d.id IN (" + datasetIdStr + ")"; + String qstr = "SELECT d.id, h.archiveDescription FROM harvestingClient h, dataset d, dvobject o WHERE d.id = o.id AND h.dataverse_id = o.owner_id AND d.id IN (" + datasetIdStr + ")"; List searchResults = null; try { diff --git a/src/main/java/edu/harvard/iq/dataverse/DataverseServiceBean.java b/src/main/java/edu/harvard/iq/dataverse/DataverseServiceBean.java index b5f5d78f716..e2f9f81446b 100644 --- a/src/main/java/edu/harvard/iq/dataverse/DataverseServiceBean.java +++ b/src/main/java/edu/harvard/iq/dataverse/DataverseServiceBean.java @@ -417,7 +417,7 @@ public List findDataversesThatLinkToThisDatasetId(long datasetId) { */ public Map getAllHarvestedDataverseDescriptions(){ - String qstr = "SELECT dataverse_id, archiveDescription FROM harvestingDataverseConfig;"; + String qstr = "SELECT dataverse_id, archiveDescription FROM harvestingClient;"; List searchResults = null; try { diff --git a/src/main/java/edu/harvard/iq/dataverse/DvObjectServiceBean.java b/src/main/java/edu/harvard/iq/dataverse/DvObjectServiceBean.java index 5c59452cbed..2e58945a672 100644 --- a/src/main/java/edu/harvard/iq/dataverse/DvObjectServiceBean.java +++ b/src/main/java/edu/harvard/iq/dataverse/DvObjectServiceBean.java @@ -174,7 +174,7 @@ public List getDvObjectInfoByParentIdForMyData(List dvObjectPare */ public List getAllHarvestedDataverseIds(){ - String qstr = "SELECT h.dataverse_id FROM harvestingdataverseconfig h;"; + String qstr = "SELECT h.dataverse_id FROM harvestingclient h;"; return em.createNativeQuery(qstr) .getResultList(); diff --git a/src/main/java/edu/harvard/iq/dataverse/HarvestingClientsPage.java b/src/main/java/edu/harvard/iq/dataverse/HarvestingClientsPage.java index faa07e6ec65..8fc0fa799b3 100644 --- a/src/main/java/edu/harvard/iq/dataverse/HarvestingClientsPage.java +++ b/src/main/java/edu/harvard/iq/dataverse/HarvestingClientsPage.java @@ -57,6 +57,8 @@ public class HarvestingClientsPage implements java.io.Serializable { EjbDataverseEngine engineService; @Inject DataverseRequestServiceBean dvRequestService; + @Inject + NavigationWrapper navigationWrapper; private List configuredHarvestingClients; private Dataverse dataverse; @@ -71,17 +73,25 @@ public enum PageMode { public String init() { if (!isSessionUserAuthenticated()) { - return "/loginpage.xhtml"; // + DataverseHeaderFragment.getRedirectPage(); + return "/loginpage.xhtml" + navigationWrapper.getRedirectPage(); } else if (!isSuperUser()) { return "/403.xhtml"; } if (dataverseId != null) { - setDataverse(dataverseService.find(getDataverseId())); + setDataverse(dataverseService.find(getDataverseId())); + if (getDataverse() == null) { + return navigationWrapper.notFound(); + } + configuredHarvestingClients = new ArrayList<>(); + if (dataverse.getHarvestingClientConfig() != null) { + configuredHarvestingClients.add(dataverse.getHarvestingClientConfig()); + } } else { - setDataverse(dataverseService.findRootDataverse()); + //setDataverse(dataverseService.findRootDataverse()); + configuredHarvestingClients = harvestingClientService.getAllHarvestingClients(); } - configuredHarvestingClients = harvestingClientService.getAllHarvestingClients(); + pageMode = PageMode.VIEW; FacesContext.getCurrentInstance().addMessage(null, new FacesMessage(FacesMessage.SEVERITY_INFO, "Harvesting Clients", JH.localize("harvestclients.toptip"))); @@ -425,7 +435,7 @@ public boolean validateServerUrlOAI() { logger.info("metadataformats: success"); logger.info(getOaiMetadataFormatSelectItems().size() + " metadata formats total."); } else { - logger.info("metadataformats: failed"); + logger.info("metadataformats: failed;"+message); } // And if that worked, the list of sets provided: diff --git a/src/main/java/edu/harvard/iq/dataverse/HarvestingSetsPage.java b/src/main/java/edu/harvard/iq/dataverse/HarvestingSetsPage.java new file mode 100644 index 00000000000..54faa36badb --- /dev/null +++ b/src/main/java/edu/harvard/iq/dataverse/HarvestingSetsPage.java @@ -0,0 +1,332 @@ +/* + * To change this license header, choose License Headers in Project Properties. + * To change this template file, choose Tools | Templates + * and open the template in the editor. + */ +package edu.harvard.iq.dataverse; + +import edu.harvard.iq.dataverse.authorization.AuthenticationServiceBean; +import edu.harvard.iq.dataverse.engine.command.exception.CommandException; +import edu.harvard.iq.dataverse.engine.command.impl.CreateHarvestingClientCommand; +import edu.harvard.iq.dataverse.engine.command.impl.UpdateHarvestingClientCommand; +import edu.harvard.iq.dataverse.harvest.client.HarvestingClient; +import edu.harvard.iq.dataverse.harvest.client.HarvestingClientServiceBean; +import edu.harvard.iq.dataverse.harvest.server.OAIRecord; +import edu.harvard.iq.dataverse.harvest.server.OAIRecordServiceBean; +import edu.harvard.iq.dataverse.harvest.server.OAISet; +import edu.harvard.iq.dataverse.harvest.server.OAISetServiceBean; +import edu.harvard.iq.dataverse.util.JsfHelper; +import static edu.harvard.iq.dataverse.util.JsfHelper.JH; +import edu.harvard.iq.dataverse.util.SystemConfig; +import java.util.List; +import java.util.logging.Level; +import java.util.logging.Logger; +import java.util.regex.Pattern; +import javax.ejb.EJB; +import javax.faces.application.FacesMessage; +import javax.faces.component.UIInput; +import javax.faces.context.FacesContext; +import javax.faces.event.ActionEvent; +import javax.faces.view.ViewScoped; +import javax.inject.Inject; +import javax.inject.Named; +import org.apache.commons.lang.StringUtils; + +/** + * + * @author Leonid Andreev + */ +@ViewScoped +@Named +public class HarvestingSetsPage implements java.io.Serializable { + + private static final Logger logger = Logger.getLogger(HarvestingSetsPage.class.getCanonicalName()); + + @Inject + DataverseSession session; + @EJB + AuthenticationServiceBean authSvc; + @EJB + DataverseServiceBean dataverseService; + @EJB + OAISetServiceBean oaiSetService; + @EJB + OAIRecordServiceBean oaiRecordService; + + @EJB + EjbDataverseEngine engineService; + @EJB + SystemConfig systemConfig; + + @Inject + DataverseRequestServiceBean dvRequestService; + @Inject + NavigationWrapper navigationWrapper; + + private List configuredHarvestingSets; + private OAISet selectedSet; + private boolean setSpecValidated = false; + + public enum PageMode { + + VIEW, CREATE, EDIT + } + private PageMode pageMode = PageMode.VIEW; + + private int oaiServerStatusRadio; + + private static final int oaiServerStatusRadioDisabled = 0; + private static final int oaiServerStatusRadioEnabled = 1; + private UIInput newSetSpecInputField; + private String newSetSpec = ""; + private String newSetDescription = ""; + private String newSetQuery = ""; + + public String getNewSetSpec() { + return newSetSpec; + } + + public void setNewSetSpec(String newSetSpec) { + this.newSetSpec = newSetSpec; + } + + public String getNewSetDescription() { + return newSetDescription; + } + + public void setNewSetDescription(String newSetDescription) { + this.newSetDescription = newSetDescription; + } + + public String getNewSetQuery() { + return newSetQuery; + } + + public void setNewSetQuery(String newSetQuery) { + this.newSetQuery = newSetQuery; + } + + public int getOaiServerStatusRadio() { + return this.oaiServerStatusRadio; + } + + public void setOaiServerStatusRadio(int oaiServerStatusRadio) { + this.oaiServerStatusRadio = oaiServerStatusRadio; + } + + public String init() { + if (!isSessionUserAuthenticated()) { + return "/loginpage.xhtml" + navigationWrapper.getRedirectPage(); + } else if (!isSuperUser()) { + return "/403.xhtml"; + } + + + configuredHarvestingSets = oaiSetService.findAll(); + pageMode = PageMode.VIEW; + + if (isHarvestingServerEnabled()) { + oaiServerStatusRadio = oaiServerStatusRadioEnabled; + } else { + oaiServerStatusRadio = oaiServerStatusRadioDisabled; + } + + FacesContext.getCurrentInstance().addMessage(null, new FacesMessage(FacesMessage.SEVERITY_INFO, "Harvesting Sets", JH.localize("harvestsets.toptip"))); + return null; + } + + public List getConfiguredOAISets() { + return configuredHarvestingSets; + } + + public void setConfiguredOAISets(List oaiSets) { + configuredHarvestingSets = oaiSets; + } + + public boolean isHarvestingServerEnabled() { + return systemConfig.isOAIServerEnabled(); + } + + public void toggleHarvestingServerStatus() { + if (isHarvestingServerEnabled()) { + systemConfig.disableOAIServer(); + } else { + systemConfig.enableOAIServer(); + } + } + + public UIInput getNewSetSpecInputField() { + return newSetSpecInputField; + } + + public void setNewSetSpecInputField(UIInput newSetSpecInputField) { + this.newSetSpecInputField = newSetSpecInputField; + } + + public void disableHarvestingServer() { + systemConfig.disableOAIServer(); + } + + public void setSelectedSet(OAISet oaiSet) { + selectedSet = oaiSet; + } + + public OAISet getSelectedSet() { + return selectedSet; + } + + public void initNewSet(ActionEvent ae) { + + this.newSetSpec = ""; + this.newSetDescription = ""; + this.newSetQuery = ""; + + this.pageMode = PageMode.CREATE; + this.setSpecValidated = false; + + } + + public void createSet(ActionEvent ae) { + + OAISet newOaiSet = new OAISet(); + + + newOaiSet.setSpec(getNewSetSpec()); + newOaiSet.setName(getNewSetSpec()); + newOaiSet.setDescription(getNewSetDescription()); + newOaiSet.setDefinition(getNewSetQuery()); + + + try { + oaiSetService.save(newOaiSet); + configuredHarvestingSets = oaiSetService.findAll(); + JsfHelper.addSuccessMessage("Succesfully created OAI set " + newOaiSet.getSpec()); + + } catch (Exception ex) { + JH.addMessage(FacesMessage.SEVERITY_FATAL, "Failed to create OAI set"); + logger.log(Level.SEVERE, "Failed to create OAI set" + ex.getMessage(), ex); + } + + setPageMode(HarvestingSetsPage.PageMode.VIEW); + } + + // this saves an existing set that the user has edited: + + public void saveSet(ActionEvent ae) { + + OAISet oaiSet = getSelectedSet(); + + if (oaiSet == null) { + // TODO: + // tell the user somehow that the set cannot be saved, and advise + // them to save the settings they have entered. + } + + + // will try to save it now: + + try { + oaiSetService.save(oaiSet); + configuredHarvestingSets = oaiSetService.findAll(); + + JsfHelper.addSuccessMessage("Succesfully updated OAI set " + oaiSet.getSpec()); + + } catch (Exception ex) { + JH.addMessage(FacesMessage.SEVERITY_FATAL, "Failed to update OAI set."); + logger.log(Level.SEVERE, "Failed to update OAI set." + ex.getMessage(), ex); + } + setPageMode(HarvestingSetsPage.PageMode.VIEW); + + + } + + public boolean isSetSpecValidated() { + return this.setSpecValidated; + } + + public void setSetSpecValidated(boolean validated) { + this.setSpecValidated = validated; + } + + public PageMode getPageMode() { + return this.pageMode; + } + + public void setPageMode(PageMode pageMode) { + this.pageMode = pageMode; + } + + public boolean isCreateMode() { + return PageMode.CREATE == this.pageMode; + } + + public boolean isEditMode() { + return PageMode.EDIT == this.pageMode; + } + + public boolean isViewMode() { + return PageMode.VIEW == this.pageMode; + } + + public boolean isSessionUserAuthenticated() { + + if (session == null) { + return false; + } + + if (session.getUser() == null) { + return false; + } + + if (session.getUser().isAuthenticated()) { + return true; + } + + return false; + } + + public String getOAISetStats(OAISet oaiSet) { + List records = oaiRecordService.findOaiRecordsBySetName(oaiSet.getSpec()); + + if (records == null || records.isEmpty()) { + return "No records (empty set)"; + } + + return records.size() + " records exported"; + + } + + public void validateSetSpec() { + + if ( !StringUtils.isEmpty(getNewSetSpec()) ) { + + if (! Pattern.matches("^[a-zA-Z0-9\\_\\-]+$", getNewSetSpec()) ) { + //input.setValid(false); + FacesContext.getCurrentInstance().addMessage(getNewSetSpecInputField().getClientId(), + new FacesMessage(FacesMessage.SEVERITY_ERROR, "", JH.localize("harvestsets.newSetDialog.setspec.invalid"))); + setSetSpecValidated(false); + return; + + // If it passes the regex test, check + } else if ( oaiSetService.findBySpec(getNewSetSpec()) != null ) { + //input.setValid(false); + FacesContext.getCurrentInstance().addMessage(getNewSetSpecInputField().getClientId(), + new FacesMessage(FacesMessage.SEVERITY_ERROR, "", JH.localize("harvestsets.newSetDialog.setspec.alreadyused"))); + setSetSpecValidated(false); + return; + } + setSetSpecValidated(true); + return; + } + + // Nickname field is empty: + FacesContext.getCurrentInstance().addMessage(getNewSetSpecInputField().getClientId(), + new FacesMessage(FacesMessage.SEVERITY_ERROR, "", JH.localize("harvestsets.newSetDialog.setspec.required"))); + setSetSpecValidated(false); + return; + } + + public boolean isSuperUser() { + return session.getUser().isSuperuser(); + } + } \ No newline at end of file diff --git a/src/main/java/edu/harvard/iq/dataverse/harvest/server/OAISet.java b/src/main/java/edu/harvard/iq/dataverse/harvest/server/OAISet.java index 5f0530e8e96..d831f2d7912 100644 --- a/src/main/java/edu/harvard/iq/dataverse/harvest/server/OAISet.java +++ b/src/main/java/edu/harvard/iq/dataverse/harvest/server/OAISet.java @@ -28,6 +28,8 @@ import javax.persistence.JoinColumn; import javax.persistence.OneToOne; import javax.persistence.Version; +import javax.validation.constraints.Pattern; +import javax.validation.constraints.Size; /** * @@ -58,12 +60,16 @@ public void setId(Long id) { @Column(columnDefinition="TEXT") private String name; - @Column(columnDefinition="TEXT") + @Column(columnDefinition="TEXT", nullable = false, unique=true) + @Size(max = 30, message = "Setspec must be at most 30 characters.") + @Pattern.List({@Pattern(regexp = "[a-zA-Z0-9\\_\\-]*", message = "Found an illegal character(s). Valid characters are a-Z, 0-9, '_', and '-'."), + @Pattern(regexp=".*\\D.*", message="Setspec should not be a number")}) private String spec; - @Column(columnDefinition="TEXT") + + @Column(columnDefinition="TEXT", nullable = false) private String definition; - @Column(columnDefinition="TEXT") + @Column(columnDefinition="TEXT", nullable = false) private String description; @Version diff --git a/src/main/java/edu/harvard/iq/dataverse/harvest/server/web/XOAIItem.java b/src/main/java/edu/harvard/iq/dataverse/harvest/server/web/XOAIItem.java index 2994c325cb6..21fa382d0b7 100644 --- a/src/main/java/edu/harvard/iq/dataverse/harvest/server/web/XOAIItem.java +++ b/src/main/java/edu/harvard/iq/dataverse/harvest/server/web/XOAIItem.java @@ -67,7 +67,7 @@ public xMetadata getMetadata() { try { String filesRootDirectory = System.getProperty("dataverse.files.directory"); - String identifier = getIdentifier().replaceFirst("doi:", "/"); + String identifier = getIdentifier().replaceFirst("^[^:]*:", "/"); InputStream inputStream = new FileInputStream(new File(filesRootDirectory + identifier + "/export_ddi.xml")); return new xMetadata(inputStream); } catch (Exception e) { diff --git a/src/main/java/edu/harvard/iq/dataverse/harvest/server/web/servlet/OAIServlet.java b/src/main/java/edu/harvard/iq/dataverse/harvest/server/web/servlet/OAIServlet.java index 48e9cc64c10..3e2c1b3eaac 100644 --- a/src/main/java/edu/harvard/iq/dataverse/harvest/server/web/servlet/OAIServlet.java +++ b/src/main/java/edu/harvard/iq/dataverse/harvest/server/web/servlet/OAIServlet.java @@ -16,6 +16,7 @@ import com.lyncode.xoai.dataprovider.repository.Repository; import com.lyncode.xoai.dataprovider.repository.RepositoryConfiguration; import com.lyncode.xoai.dataprovider.model.Context; +import com.lyncode.xoai.dataprovider.model.MetadataFormat; import com.lyncode.xoai.services.impl.SimpleResumptionTokenFormat; import static com.lyncode.xoai.dataprovider.model.MetadataFormat.identity; import com.lyncode.xoai.dataprovider.parameters.OAICompiledRequest; @@ -89,11 +90,40 @@ public class OAIServlet extends HttpServlet { public void init(ServletConfig config) throws ServletException { super.init(config); - xoaiContext = new Context().withMetadataFormat("ddi", identity()); + xoaiContext = createContext(); setRepository = new XOAISetRepository(setService); itemRepository = new XOAIItemRepository(recordService); + + repositoryConfiguration = createRepositoryConfiguration(); + + xoaiRepository = new Repository() + .withSetRepository(setRepository) + .withItemRepository(itemRepository) + .withResumptionTokenFormatter(new SimpleResumptionTokenFormat()) + .withConfiguration(repositoryConfiguration); + + dataProvider = new DataProvider(getXoaiContext(), getXoaiRepository()); + } + + private Context createContext() { + // This is a stub. The list of available metadata formats will be + // populated dynamically, depending on the available exporters. + + MetadataFormat dcMetadataFormat = MetadataFormat.metadataFormat("oai_dc"); + dcMetadataFormat.withNamespace("http://www.openarchives.org/OAI/2.0/oai_dc/"); + dcMetadataFormat.withSchemaLocation("http://www.openarchives.org/OAI/2.0/oai_dc.xsd"); + + MetadataFormat ddiMetadataFormat = MetadataFormat.metadataFormat("ddi"); + ddiMetadataFormat.withNamespace("http://www.icpsr.umich.edu/DDI"); + ddiMetadataFormat.withSchemaLocation("http://www.icpsr.umich.edu/DDI/Version2-0.xsd"); + Context context = new Context().withMetadataFormat(dcMetadataFormat).withMetadataFormat(ddiMetadataFormat); + + return context; + } + + private RepositoryConfiguration createRepositoryConfiguration() { // TODO: // some of the settings below - such as the max list numbers - // need to be configurable! @@ -102,11 +132,11 @@ public void init(ServletConfig config) throws ServletException { String repositoryName = StringUtils.isEmpty(dataverseName) || "Root".equals(dataverseName) ? "Test Dataverse OAI Archive" : dataverseName + " Dataverse OAI Archive"; - repositoryConfiguration = new RepositoryConfiguration() + RepositoryConfiguration repositoryConfiguration = new RepositoryConfiguration() .withRepositoryName(repositoryName) .withBaseUrl(systemConfig.getDataverseSiteUrl()+"/oai") - .withCompression("gzip") - .withCompression("deflate") + .withCompression("gzip") // ? + .withCompression("deflate") // ? .withAdminEmail(settingsService.getValueForKey(SettingsServiceBean.Key.SystemEmail)) .withDeleteMethod(DeletedRecord.TRANSIENT) .withGranularity(Granularity.Second) @@ -114,17 +144,11 @@ public void init(ServletConfig config) throws ServletException { .withMaxListRecords(100) .withMaxListSets(100) .withEarliestDate(new Date()); - - xoaiRepository = new Repository() - .withSetRepository(setRepository) - .withItemRepository(itemRepository) - .withResumptionTokenFormatter(new SimpleResumptionTokenFormat()) - .withConfiguration(repositoryConfiguration); - dataProvider = new DataProvider(getXoaiContext(), getXoaiRepository()); + return repositoryConfiguration; } - + /** * Handles the HTTP GET method. * diff --git a/src/main/java/edu/harvard/iq/dataverse/settings/SettingsServiceBean.java b/src/main/java/edu/harvard/iq/dataverse/settings/SettingsServiceBean.java index 2856df3a613..65c3278eb8b 100644 --- a/src/main/java/edu/harvard/iq/dataverse/settings/SettingsServiceBean.java +++ b/src/main/java/edu/harvard/iq/dataverse/settings/SettingsServiceBean.java @@ -217,7 +217,11 @@ public enum Key { /* Whether to display the publish text for every published version */ - DatasetPublishPopupCustomTextOnAllVersions; + DatasetPublishPopupCustomTextOnAllVersions, + /* + Whether Harvesting (OAI) service is enabled + */ + OAIServerEnabled; @Override public String toString() { diff --git a/src/main/java/edu/harvard/iq/dataverse/util/SystemConfig.java b/src/main/java/edu/harvard/iq/dataverse/util/SystemConfig.java index ab762a16daf..55f6fde791e 100644 --- a/src/main/java/edu/harvard/iq/dataverse/util/SystemConfig.java +++ b/src/main/java/edu/harvard/iq/dataverse/util/SystemConfig.java @@ -487,4 +487,16 @@ public long getTabularIngestSizeLimit(String formatName) { return getTabularIngestSizeLimit(); } + public boolean isOAIServerEnabled() { + boolean defaultResponse = false; + return settingsService.isTrueForKey(SettingsServiceBean.Key.OAIServerEnabled, defaultResponse); + } + + public void enableOAIServer() { + settingsService.setValueForKey(SettingsServiceBean.Key.OAIServerEnabled, "true"); + } + + public void disableOAIServer() { + settingsService.deleteValueForKey(SettingsServiceBean.Key.OAIServerEnabled); + } } diff --git a/src/main/webapp/dashboard.xhtml b/src/main/webapp/dashboard.xhtml index 526eb2cb1ee..780002a1d13 100644 --- a/src/main/webapp/dashboard.xhtml +++ b/src/main/webapp/dashboard.xhtml @@ -40,7 +40,7 @@
    -

    Harvesting

    +

    Manage Configured Harvesting Clients

    #,### @@ -54,7 +54,8 @@ @@ -62,7 +63,7 @@
    -

    Title

    +

    Manage Harvesting (OAI) server

    #,### @@ -73,11 +74,19 @@

    Label

    +
    +
    +

    + + Manage OAI Server +

    +
    +
    -

    Title

    +

    Metadata Export

    #,### diff --git a/src/main/webapp/harvestclients.xhtml b/src/main/webapp/harvestclients.xhtml index f49a6579311..cc19ab5156f 100644 --- a/src/main/webapp/harvestclients.xhtml +++ b/src/main/webapp/harvestclients.xhtml @@ -10,6 +10,7 @@ + @@ -28,6 +29,7 @@
    @@ -119,9 +121,9 @@
    -
    +

    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 literal 0 HcmV?d00001 diff --git a/src/test/java/edu/harvard/iq/dataverse/engine/TestDataverseEngine.java b/src/test/java/edu/harvard/iq/dataverse/engine/TestDataverseEngine.java index 17aebc179c2..4a83c9dcf95 100644 --- a/src/test/java/edu/harvard/iq/dataverse/engine/TestDataverseEngine.java +++ b/src/test/java/edu/harvard/iq/dataverse/engine/TestDataverseEngine.java @@ -16,7 +16,7 @@ public class TestDataverseEngine implements DataverseEngine { private final TestCommandContext ctxt; - private Map> reqiredPermissionsForObjects = new HashMap<>(); + private final Map> reqiredPermissionsForObjects = new HashMap<>(); public TestDataverseEngine(TestCommandContext ctxt) { this.ctxt = ctxt; diff --git a/src/test/java/edu/harvard/iq/dataverse/engine/command/impl/CreateDatasetVersionCommandTest.java b/src/test/java/edu/harvard/iq/dataverse/engine/command/impl/CreateDatasetVersionCommandTest.java index 84811e006b7..47b0b5b57a6 100644 --- a/src/test/java/edu/harvard/iq/dataverse/engine/command/impl/CreateDatasetVersionCommandTest.java +++ b/src/test/java/edu/harvard/iq/dataverse/engine/command/impl/CreateDatasetVersionCommandTest.java @@ -1,6 +1,3 @@ -/* - * (C) Michael Bar-Sinai - */ package edu.harvard.iq.dataverse.engine.command.impl; import edu.harvard.iq.dataverse.Dataset; From 3ece051a18bb26dcdfbff322098d43c63b9249b0 Mon Sep 17 00:00:00 2001 From: Michael Bar-Sinai Date: Thu, 7 Jan 2016 12:56:43 +0200 Subject: [PATCH 005/267] Updated the testable command document --- .../TheTestableCommand-outline.md | 0 .../TheTestableCommand.md | 12 ++++++++++-- .../non-testable-container.png | Bin .../non-testable-ut.png | Bin .../testable-container.png | Bin .../testable-ut.png | Bin 6 files changed, 10 insertions(+), 2 deletions(-) rename doc/{theTestibleCommand => theTestableCommand}/TheTestableCommand-outline.md (100%) rename doc/{theTestibleCommand => theTestableCommand}/TheTestableCommand.md (86%) rename doc/{theTestibleCommand => theTestableCommand}/non-testable-container.png (100%) rename doc/{theTestibleCommand => theTestableCommand}/non-testable-ut.png (100%) rename doc/{theTestibleCommand => theTestableCommand}/testable-container.png (100%) rename doc/{theTestibleCommand => theTestableCommand}/testable-ut.png (100%) diff --git a/doc/theTestibleCommand/TheTestableCommand-outline.md b/doc/theTestableCommand/TheTestableCommand-outline.md similarity index 100% rename from doc/theTestibleCommand/TheTestableCommand-outline.md rename to doc/theTestableCommand/TheTestableCommand-outline.md diff --git a/doc/theTestibleCommand/TheTestableCommand.md b/doc/theTestableCommand/TheTestableCommand.md similarity index 86% rename from doc/theTestibleCommand/TheTestableCommand.md rename to doc/theTestableCommand/TheTestableCommand.md index 4acbe1759fc..695c6fe01a4 100644 --- a/doc/theTestibleCommand/TheTestableCommand.md +++ b/doc/theTestableCommand/TheTestableCommand.md @@ -1,6 +1,10 @@ # The Testable Command -_2016-01-03_ +> This document was started as a result of [Issue #2746 - Improve automated testing](https://github.com/IQSS/dataverse/issues/2746), +> started by @pdurbin. + +* _2016-01-07_ `v2` Added references to CI and code coverage. Limited scope to `DvObject`s. +* _2016-01-03_ `v1` Initial Version _Michael Bar-Sinai_ @@ -16,9 +20,11 @@ While they can't replace end-to-end tests, unit tests are a great way to validat Because unit tests are easy to create (Java only, no configuration needed) and quick to run, it is possible to write many of them, such that many aspects of the code are tested. Normally, a single unit test would test a single use case of the unit. This way, when a unit test fails, the failure describes exactly what part stopped functioning. Other unit tests are not blocked by the failure, and so by running the entire test suite, the developer can get a good overview of which parts are broken and which parts are functioning well. +Because unit tests are easy to execute, it is recommended to get in the habit of running them prior to committing code changes to the repository. These tests are also integrated into Dataverse's automatic build processes (on [Travis-ci](https://travis-ci.org/IQSS/dataverse)). A failed test halts the build. Dataverse's build process also collects data about code coverage during the unit tests, using [Coveralls](https://coveralls.io/github/IQSS/dataverse). While code coverage is a problematic measure for Java EE applications (and has some inherent problems as well), generally speaking larger coverage means better testing. + Unit Testing of application logic in Java EE applications is normally hard to do, as the application logic lives in the service beans, which rely on dependency injections. Writing unit tests for service beans is possible, but as it involves a test container, and a persistent context (read: in-memory database) these unit tests are not very unit-y. -Luckily for Dataverse, most of the application logic lives in sub-classes of `Command`. As these classes are plain old Java classes that get their service beans through another plain old Java class, `CommandContext`, unit testing them is pretty straightforward. That is, if we write them to be testable. +Luckily for Dataverse, most of the application logic regarding `DvObject`s lives in sub-classes of `Command`. As these classes are plain old Java classes that get their service beans through another plain old Java class, `CommandContext`, unit testing them is pretty straightforward. That is, if we write them to be testable. ## Writing Testable Commands @@ -101,6 +107,8 @@ Numerous blogs, books and tweets have been written about creating good unit test ```` +* Unit tests for Dataverse Commands live [here](/src/test/java/edu/harvard/iq/dataverse/engine/command/impl). + Happy Testing! -- Michael diff --git a/doc/theTestibleCommand/non-testable-container.png b/doc/theTestableCommand/non-testable-container.png similarity index 100% rename from doc/theTestibleCommand/non-testable-container.png rename to doc/theTestableCommand/non-testable-container.png diff --git a/doc/theTestibleCommand/non-testable-ut.png b/doc/theTestableCommand/non-testable-ut.png similarity index 100% rename from doc/theTestibleCommand/non-testable-ut.png rename to doc/theTestableCommand/non-testable-ut.png diff --git a/doc/theTestibleCommand/testable-container.png b/doc/theTestableCommand/testable-container.png similarity index 100% rename from doc/theTestibleCommand/testable-container.png rename to doc/theTestableCommand/testable-container.png diff --git a/doc/theTestibleCommand/testable-ut.png b/doc/theTestableCommand/testable-ut.png similarity index 100% rename from doc/theTestibleCommand/testable-ut.png rename to doc/theTestableCommand/testable-ut.png From 21a279f43e1be23d0f6626559f2565ea796024ff Mon Sep 17 00:00:00 2001 From: Michael Bar-Sinai Date: Thu, 7 Jan 2016 23:32:02 +0200 Subject: [PATCH 006/267] Added tests for createDataverseCommand (part of #2746) --- .../dataverse/DataverseFacetServiceBean.java | 25 +- .../harvard/iq/dataverse/api/Dataverses.java | 2 +- .../dataverse/authorization/users/User.java | 2 +- .../command/impl/CreateDataverseCommand.java | 13 +- .../impl/UpdatePermissionRootCommand.java | 16 +- .../iq/dataverse/engine/MocksFactory.java | 61 +++- .../impl/CreateDatasetVersionCommandTest.java | 18 +- .../impl/CreateDataverseCommandTest.java | 317 ++++++++++++++++++ ...estPublishedDatasetVersionCommandTest.java | 71 ++++ .../impl/UpdatePermissionRootCommandTest.java | 100 ++++++ 10 files changed, 590 insertions(+), 35 deletions(-) create mode 100644 src/test/java/edu/harvard/iq/dataverse/engine/command/impl/CreateDataverseCommandTest.java create mode 100644 src/test/java/edu/harvard/iq/dataverse/engine/command/impl/GetLatestPublishedDatasetVersionCommandTest.java create mode 100644 src/test/java/edu/harvard/iq/dataverse/engine/command/impl/UpdatePermissionRootCommandTest.java diff --git a/src/main/java/edu/harvard/iq/dataverse/DataverseFacetServiceBean.java b/src/main/java/edu/harvard/iq/dataverse/DataverseFacetServiceBean.java index 019bc429374..7e50c9a4148 100644 --- a/src/main/java/edu/harvard/iq/dataverse/DataverseFacetServiceBean.java +++ b/src/main/java/edu/harvard/iq/dataverse/DataverseFacetServiceBean.java @@ -2,6 +2,7 @@ import edu.harvard.iq.dataverse.util.LruCache; import java.util.List; +import javax.ejb.EJB; import javax.ejb.Stateless; import javax.inject.Named; import javax.persistence.EntityManager; @@ -22,6 +23,9 @@ public class DataverseFacetServiceBean implements java.io.Serializable { @PersistenceContext(unitName = "VDCNet-ejbPU") private EntityManager em; + @EJB + DataverseServiceBean dataverses; + public List findByDataverseId(Long dataverseId) { List res = cache.get(dataverseId); @@ -48,19 +52,22 @@ public void deleteFacetsFor( Dataverse d ) { } - public void create(int diplayOrder, Long datasetFieldId, Long dataverseId) { + public DataverseFacet create(int displayOrder, DatasetFieldType fieldType, Dataverse ownerDv) { DataverseFacet dataverseFacet = new DataverseFacet(); - dataverseFacet.setDisplayOrder(diplayOrder); - - DatasetFieldType dsfType = (DatasetFieldType)em.find(DatasetFieldType.class,datasetFieldId); - dataverseFacet.setDatasetFieldType(dsfType); - - Dataverse dataverse = (Dataverse)em.find(Dataverse.class,dataverseId); - dataverseFacet.setDataverse(dataverse); + dataverseFacet.setDisplayOrder(displayOrder); + dataverseFacet.setDatasetFieldType(fieldType); + dataverseFacet.setDataverse(ownerDv); - dataverse.getDataverseFacets().add(dataverseFacet); + ownerDv.getDataverseFacets().add(dataverseFacet); em.persist(dataverseFacet); + return dataverseFacet; + } + + public DataverseFacet create(int displayOrder, Long datasetFieldTypeId, Long dataverseId) { + return create( displayOrder, + (DatasetFieldType)em.find(DatasetFieldType.class,datasetFieldTypeId), + dataverses.find(dataverseId) ); } } diff --git a/src/main/java/edu/harvard/iq/dataverse/api/Dataverses.java b/src/main/java/edu/harvard/iq/dataverse/api/Dataverses.java index a787f3e26ee..080bf39a151 100644 --- a/src/main/java/edu/harvard/iq/dataverse/api/Dataverses.java +++ b/src/main/java/edu/harvard/iq/dataverse/api/Dataverses.java @@ -311,7 +311,7 @@ public Response setMetadataRoot( @PathParam("identifier")String dvIdtf, String b try { Dataverse dataverse = findDataverseOrDie(dvIdtf); execute(new UpdateDataverseMetadataBlocksCommand.SetRoot(createDataverseRequest(findUserOrDie()), dataverse, root)); - return okResponseWithValue("Dataverse " + dataverse.getName() + " is now a metadata root"); + return okResponseWithValue("Dataverse " + dataverse.getName() + " is now a metadata " + (root? "" : "non-") + "root"); } catch (WrappedResponse wr) { return wr.getResponse(); } diff --git a/src/main/java/edu/harvard/iq/dataverse/authorization/users/User.java b/src/main/java/edu/harvard/iq/dataverse/authorization/users/User.java index 3a5203d414a..21038ce6a21 100644 --- a/src/main/java/edu/harvard/iq/dataverse/authorization/users/User.java +++ b/src/main/java/edu/harvard/iq/dataverse/authorization/users/User.java @@ -11,7 +11,7 @@ public interface User extends RoleAssignee, Serializable { public boolean isAuthenticated(); - // TODO remove this, should be handles in a more generic fashion, + // TODO remove this, should be handled in a more generic fashion, // e.g. getUserProvider and get the provider's URL from there. This // would allow Shib-based editing as well. public boolean isBuiltInUser(); diff --git a/src/main/java/edu/harvard/iq/dataverse/engine/command/impl/CreateDataverseCommand.java b/src/main/java/edu/harvard/iq/dataverse/engine/command/impl/CreateDataverseCommand.java index c3234d2e1a8..254e612f2e0 100644 --- a/src/main/java/edu/harvard/iq/dataverse/engine/command/impl/CreateDataverseCommand.java +++ b/src/main/java/edu/harvard/iq/dataverse/engine/command/impl/CreateDataverseCommand.java @@ -7,6 +7,7 @@ import edu.harvard.iq.dataverse.RoleAssignment; import edu.harvard.iq.dataverse.authorization.Permission; import edu.harvard.iq.dataverse.authorization.users.AuthenticatedUser; +import edu.harvard.iq.dataverse.authorization.users.User; import edu.harvard.iq.dataverse.engine.command.AbstractCommand; import edu.harvard.iq.dataverse.engine.command.CommandContext; import edu.harvard.iq.dataverse.engine.command.DataverseRequest; @@ -52,7 +53,7 @@ public Dataverse execute(CommandContext ctxt) throws CommandException { if (created.getOwner() == null) { if (ctxt.dataverses().isRootDataverseExists()) { - throw new CommandException("Root Dataverse already exists. Cannot create another one", this); + throw new IllegalCommandException("Root Dataverse already exists. Cannot create another one", this); } } @@ -61,7 +62,12 @@ public Dataverse execute(CommandContext ctxt) throws CommandException { } if (created.getCreator() == null) { - created.setCreator((AuthenticatedUser) getRequest().getUser()); + final User user = getRequest().getUser(); + if ( user.isAuthenticated() ) { + created.setCreator((AuthenticatedUser) user); + } else { + throw new IllegalCommandException("Guest users cannot create a Dataverse.", this); + } } if (created.getDataverseType() == null) { @@ -96,12 +102,11 @@ public Dataverse execute(CommandContext ctxt) throws CommandException { ctxt.facets().deleteFacetsFor(managedDv); int i = 0; for (DatasetFieldType df : facetList) { - ctxt.facets().create(i++, df.getId(), managedDv.getId()); + ctxt.facets().create(i++, df, managedDv); } } if (inputLevelList != null) { - ctxt.fieldTypeInputLevels().deleteFacetsFor(managedDv); for (DataverseFieldTypeInputLevel obj : inputLevelList) { obj.setDataverse(managedDv); diff --git a/src/main/java/edu/harvard/iq/dataverse/engine/command/impl/UpdatePermissionRootCommand.java b/src/main/java/edu/harvard/iq/dataverse/engine/command/impl/UpdatePermissionRootCommand.java index a7e1122b789..0b1c8523b92 100644 --- a/src/main/java/edu/harvard/iq/dataverse/engine/command/impl/UpdatePermissionRootCommand.java +++ b/src/main/java/edu/harvard/iq/dataverse/engine/command/impl/UpdatePermissionRootCommand.java @@ -5,16 +5,14 @@ import edu.harvard.iq.dataverse.engine.command.AbstractCommand; import edu.harvard.iq.dataverse.engine.command.CommandContext; import edu.harvard.iq.dataverse.engine.command.DataverseRequest; +import edu.harvard.iq.dataverse.engine.command.RequiredPermissions; import edu.harvard.iq.dataverse.engine.command.exception.CommandException; -import java.util.Collections; -import java.util.Map; -import java.util.Set; /** - * Updates the permission root-ness of a DvObjectContainer. + * Updates the permission root-ness of a {@link Dataverse}. * @author michael */ -// no annotations here, since permissions are dynamically decided +@RequiredPermissions(Permission.ManageDataversePermissions) public class UpdatePermissionRootCommand extends AbstractCommand { private final boolean newValue; @@ -36,13 +34,5 @@ public Dataverse execute( final CommandContext ctxt) throws CommandException { return ctxt.dataverses().save(dvoc); } } - - @Override - public Map> getRequiredPermissions() { - // for data file check permission on owning dataset - return Collections.singletonMap("", - dvoc instanceof Dataverse ? Collections.singleton(Permission.ManageDataversePermissions) - : Collections.singleton(Permission.ManageDatasetPermissions)); - } } diff --git a/src/test/java/edu/harvard/iq/dataverse/engine/MocksFactory.java b/src/test/java/edu/harvard/iq/dataverse/engine/MocksFactory.java index 32f7527c201..c2583c93bfd 100644 --- a/src/test/java/edu/harvard/iq/dataverse/engine/MocksFactory.java +++ b/src/test/java/edu/harvard/iq/dataverse/engine/MocksFactory.java @@ -8,8 +8,11 @@ import edu.harvard.iq.dataverse.DatasetFieldType.FieldType; import edu.harvard.iq.dataverse.DatasetVersion; import edu.harvard.iq.dataverse.Dataverse; +import edu.harvard.iq.dataverse.DataverseFieldTypeInputLevel; import edu.harvard.iq.dataverse.FileMetadata; import edu.harvard.iq.dataverse.MetadataBlock; +import edu.harvard.iq.dataverse.authorization.DataverseRole; +import edu.harvard.iq.dataverse.authorization.Permission; import edu.harvard.iq.dataverse.authorization.groups.impl.ipaddress.ip.IpAddress; import edu.harvard.iq.dataverse.authorization.users.AuthenticatedUser; import edu.harvard.iq.dataverse.engine.command.DataverseRequest; @@ -82,7 +85,7 @@ public static FileMetadata addFileMetadata( DataFile df ) { return fmd; } - public static AuthenticatedUser makeAuthentiucatedUser( String firstName, String lastName ) { + public static AuthenticatedUser makeAuthenticatedUser( String firstName, String lastName ) { AuthenticatedUser user = new AuthenticatedUser(); user.setId( nextId() ); user.setAffiliation("UnitTester"); @@ -94,8 +97,8 @@ public static AuthenticatedUser makeAuthentiucatedUser( String firstName, String return user; } - public static DataverseRequest makeDatasetRequest() { - return new DataverseRequest( makeAuthentiucatedUser("Jane", "Doe"), IpAddress.valueOf("215.0.2.17") ); + public static DataverseRequest makeRequest() { + return new DataverseRequest( makeAuthenticatedUser("Jane", "Doe"), IpAddress.valueOf("215.0.2.17") ); } public static Dataverse makeDataverse() { @@ -151,10 +154,58 @@ public static Dataset makeDataset() { return ds; } + public static DatasetVersion makeDatasetVersion(List categories) { + final DatasetVersion retVal = new DatasetVersion(); + final List files = makeFiles(10); + final List metadatas = new ArrayList<>(10); + Random rand = new Random(); + for ( DataFile df : files ) { + df.getFileMetadata().addCategory(categories.get(rand.nextInt(categories.size()))); + metadatas.add( df.getFileMetadata() ); + } + retVal.setFileMetadatas(metadatas); + + List fields = new ArrayList<>(); + DatasetField field = new DatasetField(); + field.setId(nextId()); + field.setSingleValue("Sample Field Value"); + field.setDatasetFieldType( makeDatasetFieldType() ); + fields.add( field ); + retVal.setDatasetFields(fields); + + return retVal; + } + public static DatasetFieldType makeDatasetFieldType() { - DatasetFieldType retVal = new DatasetFieldType("SampleType", FieldType.TEXT, false); - retVal.setId( nextId() ); + final Long id = nextId(); + DatasetFieldType retVal = new DatasetFieldType("SampleType-"+id, FieldType.TEXT, false); + retVal.setId(id); return retVal; } + public static DataverseRole makeRole( String name ) { + DataverseRole dvr = new DataverseRole(); + + dvr.setId( nextId() ); + dvr.setAlias( name ); + dvr.setName( name ); + dvr.setDescription( name + " " + name + " " + name ); + + dvr.addPermission(Permission.ManageDatasetPermissions); + dvr.addPermission(Permission.EditDataset); + dvr.addPermission(Permission.PublishDataset); + dvr.addPermission(Permission.ViewUnpublishedDataset); + + return dvr; + } + + public static DataverseFieldTypeInputLevel makeDataverseFieldTypeInputLevel( DatasetFieldType fieldType ) { + DataverseFieldTypeInputLevel retVal = new DataverseFieldTypeInputLevel(); + + retVal.setId(nextId()); + retVal.setInclude(true); + retVal.setDatasetFieldType( fieldType ); + + return retVal; + } } diff --git a/src/test/java/edu/harvard/iq/dataverse/engine/command/impl/CreateDatasetVersionCommandTest.java b/src/test/java/edu/harvard/iq/dataverse/engine/command/impl/CreateDatasetVersionCommandTest.java index 47b0b5b57a6..77b74eddf1d 100644 --- a/src/test/java/edu/harvard/iq/dataverse/engine/command/impl/CreateDatasetVersionCommandTest.java +++ b/src/test/java/edu/harvard/iq/dataverse/engine/command/impl/CreateDatasetVersionCommandTest.java @@ -23,6 +23,20 @@ import org.junit.Before; import org.junit.BeforeClass; import org.junit.Test; +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertTrue; +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertTrue; +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertTrue; +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertTrue; +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertTrue; +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertTrue; +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertTrue; /** * @@ -70,7 +84,7 @@ public void testSimpleVersionAddition() throws Exception { dsvNew.setVersionState(DatasetVersion.VersionState.DRAFT); // Execute - CreateDatasetVersionCommand sut = new CreateDatasetVersionCommand( makeDatasetRequest(), ds, dsvNew ); + CreateDatasetVersionCommand sut = new CreateDatasetVersionCommand( makeRequest(), ds, dsvNew ); final MockDatasetServiceBean serviceBean = new MockDatasetServiceBean(); TestDataverseEngine testEngine = new TestDataverseEngine( new TestCommandContext(){ @@ -97,7 +111,7 @@ public void testCantCreateTwoDraftVersions() throws Exception { dsvNew.setVersionState(DatasetVersion.VersionState.DRAFT); // Execute - CreateDatasetVersionCommand sut = new CreateDatasetVersionCommand( makeDatasetRequest(), makeDataset(), dsvNew ); + CreateDatasetVersionCommand sut = new CreateDatasetVersionCommand( makeRequest(), makeDataset(), dsvNew ); TestDataverseEngine testEngine = new TestDataverseEngine( new TestCommandContext() ); diff --git a/src/test/java/edu/harvard/iq/dataverse/engine/command/impl/CreateDataverseCommandTest.java b/src/test/java/edu/harvard/iq/dataverse/engine/command/impl/CreateDataverseCommandTest.java new file mode 100644 index 00000000000..74b6f72016b --- /dev/null +++ b/src/test/java/edu/harvard/iq/dataverse/engine/command/impl/CreateDataverseCommandTest.java @@ -0,0 +1,317 @@ +package edu.harvard.iq.dataverse.engine.command.impl; + +import edu.harvard.iq.dataverse.DatasetFieldType; +import edu.harvard.iq.dataverse.Dataverse; +import edu.harvard.iq.dataverse.DataverseFacet; +import edu.harvard.iq.dataverse.DataverseFacetServiceBean; +import edu.harvard.iq.dataverse.DataverseFieldTypeInputLevel; +import edu.harvard.iq.dataverse.DataverseFieldTypeInputLevelServiceBean; +import edu.harvard.iq.dataverse.DataverseRoleServiceBean; +import edu.harvard.iq.dataverse.DataverseServiceBean; +import edu.harvard.iq.dataverse.DvObject; +import edu.harvard.iq.dataverse.RoleAssignment; +import edu.harvard.iq.dataverse.authorization.DataverseRole; +import edu.harvard.iq.dataverse.authorization.groups.impl.ipaddress.ip.IpAddress; +import edu.harvard.iq.dataverse.authorization.users.AuthenticatedUser; +import edu.harvard.iq.dataverse.authorization.users.GuestUser; +import edu.harvard.iq.dataverse.search.IndexServiceBean; +import org.junit.Before; +import org.junit.Test; +import static edu.harvard.iq.dataverse.engine.MocksFactory.*; +import edu.harvard.iq.dataverse.engine.TestCommandContext; +import edu.harvard.iq.dataverse.engine.TestDataverseEngine; +import edu.harvard.iq.dataverse.engine.command.DataverseRequest; +import edu.harvard.iq.dataverse.engine.command.exception.CommandException; +import edu.harvard.iq.dataverse.engine.command.exception.IllegalCommandException; +import java.sql.Timestamp; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.HashMap; +import java.util.LinkedList; +import java.util.List; +import java.util.Map; +import java.util.concurrent.Future; +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertTrue; + +/** + * + * @author michael + */ +public class CreateDataverseCommandTest { + + boolean indexCalled = false; + Map dvByAliasStore = new HashMap<>(); + Map dvStore = new HashMap<>(); + boolean isRootDvExists; + boolean facetsDeleted; + boolean dftilsDeleted; + List createdDftils; + List createdFacets; + + DataverseServiceBean dataverses = new DataverseServiceBean(){ + @Override + public boolean isRootDataverseExists() { + return isRootDvExists; + } + + @Override + public Dataverse findByAlias(String anAlias) { + return dvByAliasStore.get(anAlias); + } + + @Override + public Dataverse save(Dataverse dataverse) { + if ( dataverse.getId() == null ) { + dataverse.setId( nextId() ); + } + dvStore.put( dataverse.getId(), dataverse); + if ( dataverse.getAlias() != null ) { + dvByAliasStore.put( dataverse.getAlias(), dataverse); + } + return dataverse; + } + + }; + + DataverseRoleServiceBean roles = new DataverseRoleServiceBean(){ + + List assignments = new LinkedList<>(); + + Map builtInRoles; + + { + builtInRoles = new HashMap<>(); + builtInRoles.put( DataverseRole.EDITOR, makeRole("default-editor")); + builtInRoles.put( DataverseRole.ADMIN, makeRole("default-admin")); + builtInRoles.put( DataverseRole.MANAGER, makeRole("default-manager")); + } + + @Override + public DataverseRole findBuiltinRoleByAlias(String alias) { + return builtInRoles.get(alias); + } + + @Override + public RoleAssignment save(RoleAssignment assignment) { + assignment.setId( nextId() ); + assignments.add(assignment); + return assignment; + } + + @Override + public List directRoleAssignments(DvObject dvo) { + // works since there's only one dataverse involved in the context + // of this unit test. + return assignments; + } + + + + }; + + IndexServiceBean index = new IndexServiceBean(){ + @Override + public Future indexDataverse(Dataverse dataverse) { + indexCalled = true; + return null; + } + }; + + DataverseFieldTypeInputLevelServiceBean dfils = new DataverseFieldTypeInputLevelServiceBean(){ + @Override + public void create(DataverseFieldTypeInputLevel dataverseFieldTypeInputLevel) { + createdDftils.add( dataverseFieldTypeInputLevel ); + } + + @Override + public void deleteFacetsFor(Dataverse d) { + dftilsDeleted = true; + } + }; + + DataverseFacetServiceBean facets = new DataverseFacetServiceBean() { + @Override + public DataverseFacet create(int displayOrder, DatasetFieldType fieldType, Dataverse ownerDv) { + DataverseFacet df = new DataverseFacet(); + df.setDatasetFieldType(fieldType); + df.setDataverse(ownerDv); + df.setDisplayOrder(displayOrder); + createdFacets.add(df); + return df; + } + + + @Override + public void deleteFacetsFor(Dataverse d) { + facetsDeleted = true; + } + + }; + + TestDataverseEngine engine; + + + @Before + public void setUp() { + indexCalled = false; + dvStore.clear(); + dvByAliasStore.clear(); + isRootDvExists = true; + facetsDeleted = false; + createdDftils = new ArrayList<>(); + createdFacets = new ArrayList<>(); + + engine = new TestDataverseEngine( new TestCommandContext(){ + @Override + public IndexServiceBean index() { + return index; + } + + @Override + public DataverseRoleServiceBean roles() { + return roles; + } + + @Override + public DataverseServiceBean dataverses() { + return dataverses; + } + + @Override + public DataverseFacetServiceBean facets() { + return facets; + } + + @Override + public DataverseFieldTypeInputLevelServiceBean fieldTypeInputLevels() { + return dfils; + } + + } ); + } + + + @Test + public void testDefaultOptions() throws CommandException { + Dataverse dv = makeDataverse(); + dv.setCreateDate(null); + dv.setId(null); + dv.setCreator(null); + dv.setDefaultContributorRole(null); + dv.setOwner( makeDataverse() ); + final DataverseRequest request = makeRequest(); + + CreateDataverseCommand sut = new CreateDataverseCommand(dv, request, null, null); + Dataverse result = engine.submit(sut); + + assertNotNull( result.getCreateDate() ); + assertNotNull( result.getId() ); + + assertEquals( result.getCreator(), request.getUser() ); + assertEquals( Dataverse.DataverseType.UNCATEGORIZED, result.getDataverseType() ); + assertEquals( roles.findBuiltinRoleByAlias(DataverseRole.EDITOR), result.getDefaultContributorRole() ); + + // Assert that the creator is admin. + final RoleAssignment roleAssignment = roles.directRoleAssignments(dv).get(0); + assertEquals( roles.findBuiltinRoleByAlias(DataverseRole.ADMIN), roleAssignment.getRole() ); + assertEquals( dv, roleAssignment.getDefinitionPoint() ); + assertEquals( roleAssignment.getAssigneeIdentifier(), request.getUser().getIdentifier() ); + + // The following is a pretty wierd way to test that the create date defaults to + // now, but it works across date changes. + assertTrue( "When the supplied creation date is null, date shuld default to command execution time", + Math.abs(System.currentTimeMillis() - result.getCreateDate().toInstant().toEpochMilli()) < 1000 ); + + assertTrue( result.isPermissionRoot() ); + assertTrue( result.isThemeRoot() ); + assertTrue( indexCalled ); + } + + @Test + public void testCustomOptions() throws CommandException { + Dataverse dv = makeDataverse(); + + Timestamp creation = timestamp(1990,12,12); + AuthenticatedUser creator = makeAuthenticatedUser("Joe", "Walsh"); + + dv.setCreateDate(creation); + + dv.setId(null); + dv.setCreator(creator); + dv.setDefaultContributorRole(null); + dv.setOwner( makeDataverse() ); + dv.setDataverseType(Dataverse.DataverseType.JOURNALS); + dv.setDefaultContributorRole( roles.findBuiltinRoleByAlias(DataverseRole.MANAGER) ); + + final DataverseRequest request = makeRequest(); + List facets = Arrays.asList( makeDatasetFieldType(), makeDatasetFieldType(), makeDatasetFieldType()); + List dftils = Arrays.asList( makeDataverseFieldTypeInputLevel(makeDatasetFieldType()), + makeDataverseFieldTypeInputLevel(makeDatasetFieldType()), + makeDataverseFieldTypeInputLevel(makeDatasetFieldType())); + + CreateDataverseCommand sut = new CreateDataverseCommand(dv, request, new LinkedList(facets), new LinkedList(dftils) ); + Dataverse result = engine.submit(sut); + + assertEquals( creation, result.getCreateDate() ); + assertNotNull( result.getId() ); + + assertEquals( creator, result.getCreator() ); + assertEquals( Dataverse.DataverseType.JOURNALS, result.getDataverseType() ); + assertEquals( roles.findBuiltinRoleByAlias(DataverseRole.MANAGER), result.getDefaultContributorRole() ); + + // Assert that the creator is admin. + final RoleAssignment roleAssignment = roles.directRoleAssignments(dv).get(0); + assertEquals( roles.findBuiltinRoleByAlias(DataverseRole.ADMIN), roleAssignment.getRole() ); + assertEquals( dv, roleAssignment.getDefinitionPoint() ); + assertEquals( roleAssignment.getAssigneeIdentifier(), request.getUser().getIdentifier() ); + + assertTrue( result.isPermissionRoot() ); + assertTrue( result.isThemeRoot() ); + assertTrue( indexCalled ); + + assertTrue( facetsDeleted ); + int i=0; + for ( DataverseFacet df : createdFacets ) { + assertEquals( i, df.getDisplayOrder() ); + assertEquals( result, df.getDataverse() ); + assertEquals( facets.get(i), df.getDatasetFieldType() ); + + i++; + } + + assertTrue( dftilsDeleted ); + for ( DataverseFieldTypeInputLevel dftil : createdDftils ) { + assertEquals( result, dftil.getDataverse() ); + } + } + + @Test( expected=IllegalCommandException.class ) + public void testCantCreateAdditionalRoot() throws Exception { + engine.submit( new CreateDataverseCommand(makeDataverse(), makeRequest(), null, null) ); + } + + @Test( expected=IllegalCommandException.class ) + public void testGuestCantCreateDataverse() throws Exception { + final DataverseRequest request = new DataverseRequest( GuestUser.get(), IpAddress.valueOf("::") ); + isRootDvExists = false; + engine.submit(new CreateDataverseCommand(makeDataverse(), request, null, null) ); + } + + @Test( expected=IllegalCommandException.class ) + public void testCantCreateAnotherWithSameAlias() throws Exception { + + String alias = "alias"; + final Dataverse dvFirst = makeDataverse(); + dvFirst.setAlias(alias); + dvFirst.setOwner( makeDataverse() ); + engine.submit(new CreateDataverseCommand(dvFirst, makeRequest(), null, null) ); + + final Dataverse dv = makeDataverse(); + dv.setOwner( makeDataverse() ); + dv.setAlias(alias); + engine.submit(new CreateDataverseCommand(dv, makeRequest(), null, null) ); + } + +} diff --git a/src/test/java/edu/harvard/iq/dataverse/engine/command/impl/GetLatestPublishedDatasetVersionCommandTest.java b/src/test/java/edu/harvard/iq/dataverse/engine/command/impl/GetLatestPublishedDatasetVersionCommandTest.java new file mode 100644 index 00000000000..5c6bc9a521b --- /dev/null +++ b/src/test/java/edu/harvard/iq/dataverse/engine/command/impl/GetLatestPublishedDatasetVersionCommandTest.java @@ -0,0 +1,71 @@ +package edu.harvard.iq.dataverse.engine.command.impl; + +import edu.harvard.iq.dataverse.Dataset; +import edu.harvard.iq.dataverse.DatasetVersion; +import edu.harvard.iq.dataverse.engine.MocksFactory; +import static edu.harvard.iq.dataverse.engine.MocksFactory.makeRequest; +import edu.harvard.iq.dataverse.engine.TestCommandContext; +import edu.harvard.iq.dataverse.engine.TestDataverseEngine; +import edu.harvard.iq.dataverse.engine.command.exception.CommandException; +import java.util.ArrayList; +import java.util.List; +import org.junit.Test; +import static org.junit.Assert.*; + +/** + * + * @author michael + */ +public class GetLatestPublishedDatasetVersionCommandTest { + + TestDataverseEngine engine = new TestDataverseEngine( new TestCommandContext() ); + + @Test + public void testLatestPublishedNoDraft() throws CommandException { + + Dataset ds = MocksFactory.makeDataset(); + List versions = make10Versions(ds); + ds.setVersions(versions); + + assertEquals( 10l, engine.submit(new GetLatestPublishedDatasetVersionCommand(makeRequest(), ds)).getVersionNumber().longValue() ); + assertTrue( "Published datasets should require no permissions to view", + engine.getReqiredPermissionsForObjects().get(ds).isEmpty() ); + } + + @Test + public void testLatestPublishedWithDraft() throws CommandException { + + Dataset ds = MocksFactory.makeDataset(); + List versions = make10Versions(ds); + versions.add( MocksFactory.makeDatasetVersion(ds.getCategories()) ); + ds.setVersions(versions); + + assertEquals( 10l, engine.submit(new GetLatestPublishedDatasetVersionCommand(makeRequest(), ds)).getVersionNumber().longValue() ); + assertTrue( "Published datasets should require no permissions to view", + engine.getReqiredPermissionsForObjects().get(ds).isEmpty() ); + } + + @Test + public void testLatestNonePublished() throws CommandException { + + Dataset ds = MocksFactory.makeDataset(); + + assertNull( engine.submit(new GetLatestPublishedDatasetVersionCommand(makeRequest(), ds)) ); + } + + private List make10Versions(Dataset ds) { + // setup: make 10 versions. + List versions = new ArrayList<>(10); + for ( int i=10; i>0; i-- ) { + DatasetVersion v = MocksFactory.makeDatasetVersion(ds.getCategories()); + v.setVersionNumber((long)i); + v.setMinorVersionNumber(0l); + v.setReleaseTime( MocksFactory.date(1990, i, 1) ); + v.setVersionState(DatasetVersion.VersionState.RELEASED); + versions.add(v); + } + return versions; + } + + +} diff --git a/src/test/java/edu/harvard/iq/dataverse/engine/command/impl/UpdatePermissionRootCommandTest.java b/src/test/java/edu/harvard/iq/dataverse/engine/command/impl/UpdatePermissionRootCommandTest.java new file mode 100644 index 00000000000..ab100ff30de --- /dev/null +++ b/src/test/java/edu/harvard/iq/dataverse/engine/command/impl/UpdatePermissionRootCommandTest.java @@ -0,0 +1,100 @@ +package edu.harvard.iq.dataverse.engine.command.impl; + +import edu.harvard.iq.dataverse.Dataverse; +import edu.harvard.iq.dataverse.DataverseServiceBean; +import edu.harvard.iq.dataverse.engine.DataverseEngine; +import edu.harvard.iq.dataverse.engine.MocksFactory; +import edu.harvard.iq.dataverse.engine.TestCommandContext; +import edu.harvard.iq.dataverse.engine.TestDataverseEngine; +import edu.harvard.iq.dataverse.engine.command.exception.CommandException; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertTrue; +import org.junit.Before; +import org.junit.Test; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertTrue; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertTrue; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertTrue; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertTrue; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertTrue; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertTrue; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertTrue; + +/** + * + * @author michael + */ +public class UpdatePermissionRootCommandTest { + + private DataverseServiceBean mockBean; + TestCommandContext testCommandContext; + boolean serviceBeanCalled; + + @Before + public void setUp() { + mockBean = new DataverseServiceBean() { + @Override + public Dataverse save( Dataverse dv ) { + serviceBeanCalled = true; + return dv; + } + }; + testCommandContext = new TestCommandContext() { + @Override + public DataverseServiceBean dataverses() { + return mockBean; + } + }; + serviceBeanCalled = false; + } + + @Test + public void testNoChange() throws CommandException { + Dataverse dv = MocksFactory.makeDataverse(); + DataverseEngine ngn = new TestDataverseEngine(testCommandContext); + dv.setPermissionRoot( false ); + + UpdatePermissionRootCommand sut = new UpdatePermissionRootCommand(false, MocksFactory.makeRequest(), dv); + Dataverse result = ngn.submit(sut); + + assertFalse( result.isPermissionRoot() ); + assertFalse( serviceBeanCalled ); + + dv.setPermissionRoot( true ); + + sut = new UpdatePermissionRootCommand( true, MocksFactory.makeRequest(), dv ); + result = ngn.submit(sut); + + assertTrue( result.isPermissionRoot() ); + assertFalse( serviceBeanCalled ); + } + + @Test + public void testChange() throws CommandException { + Dataverse dv = MocksFactory.makeDataverse(); + DataverseEngine ngn = new TestDataverseEngine(testCommandContext); + dv.setPermissionRoot( false ); + + UpdatePermissionRootCommand sut = new UpdatePermissionRootCommand(true, MocksFactory.makeRequest(), dv); + Dataverse result = ngn.submit(sut); + + assertTrue(result.isPermissionRoot() ); + assertTrue(serviceBeanCalled ); + + dv.setPermissionRoot( true ); + + sut = new UpdatePermissionRootCommand( false, MocksFactory.makeRequest(), dv ); + result = ngn.submit(sut); + + assertFalse( result.isPermissionRoot() ); + assertTrue(serviceBeanCalled ); + } + + +} From 1d7eadcb89ccebb4990c62757c84ec705f13853d Mon Sep 17 00:00:00 2001 From: Michael Bar-Sinai Date: Thu, 7 Jan 2016 23:48:49 +0200 Subject: [PATCH 007/267] Updated the doc --- doc/theTestableCommand/TheTestableCommand.md | 8 ++++++++ .../engine/command/impl/CreateDataverseCommandTest.java | 6 +++--- 2 files changed, 11 insertions(+), 3 deletions(-) diff --git a/doc/theTestableCommand/TheTestableCommand.md b/doc/theTestableCommand/TheTestableCommand.md index 695c6fe01a4..e60faa313f0 100644 --- a/doc/theTestableCommand/TheTestableCommand.md +++ b/doc/theTestableCommand/TheTestableCommand.md @@ -3,6 +3,7 @@ > This document was started as a result of [Issue #2746 - Improve automated testing](https://github.com/IQSS/dataverse/issues/2746), > started by @pdurbin. +* _2016-01-07_ `v3` More tips. * _2016-01-07_ `v2` Added references to CI and code coverage. Limited scope to `DvObject`s. * _2016-01-03_ `v1` Initial Version @@ -107,6 +108,13 @@ Numerous blogs, books and tweets have been written about creating good unit test ```` +* The notion of *now* is an issue. Assume that a test needs to validate that the `creationTime` field on some `DvObject` is set to the time it is created. The naïve approach would be storing the time just before the execution of the `Create` command, and then testing that the stored time is equal to the value in the `creationTime`. This approach will fail, seemingly at random, when the command is executed at a different millisecond. The solution is to test for a reasonable delta: + + ````Java + assertTrue( Math.abs(System.currentTimeMillis() + - result.getCreateDate().toInstant().toEpochMilli()) < 1000 ); + ```` + * Unit tests for Dataverse Commands live [here](/src/test/java/edu/harvard/iq/dataverse/engine/command/impl). Happy Testing! diff --git a/src/test/java/edu/harvard/iq/dataverse/engine/command/impl/CreateDataverseCommandTest.java b/src/test/java/edu/harvard/iq/dataverse/engine/command/impl/CreateDataverseCommandTest.java index 74b6f72016b..b14570b7499 100644 --- a/src/test/java/edu/harvard/iq/dataverse/engine/command/impl/CreateDataverseCommandTest.java +++ b/src/test/java/edu/harvard/iq/dataverse/engine/command/impl/CreateDataverseCommandTest.java @@ -246,12 +246,12 @@ public void testCustomOptions() throws CommandException { dv.setDefaultContributorRole( roles.findBuiltinRoleByAlias(DataverseRole.MANAGER) ); final DataverseRequest request = makeRequest(); - List facets = Arrays.asList( makeDatasetFieldType(), makeDatasetFieldType(), makeDatasetFieldType()); + List expectedFacets = Arrays.asList( makeDatasetFieldType(), makeDatasetFieldType(), makeDatasetFieldType()); List dftils = Arrays.asList( makeDataverseFieldTypeInputLevel(makeDatasetFieldType()), makeDataverseFieldTypeInputLevel(makeDatasetFieldType()), makeDataverseFieldTypeInputLevel(makeDatasetFieldType())); - CreateDataverseCommand sut = new CreateDataverseCommand(dv, request, new LinkedList(facets), new LinkedList(dftils) ); + CreateDataverseCommand sut = new CreateDataverseCommand(dv, request, new LinkedList(expectedFacets), new LinkedList(dftils) ); Dataverse result = engine.submit(sut); assertEquals( creation, result.getCreateDate() ); @@ -276,7 +276,7 @@ public void testCustomOptions() throws CommandException { for ( DataverseFacet df : createdFacets ) { assertEquals( i, df.getDisplayOrder() ); assertEquals( result, df.getDataverse() ); - assertEquals( facets.get(i), df.getDatasetFieldType() ); + assertEquals( expectedFacets.get(i), df.getDatasetFieldType() ); i++; } From b62959884ad8deb06a7135e485517672eb3c6fdf Mon Sep 17 00:00:00 2001 From: Philip Durbin Date: Tue, 10 May 2016 16:05:53 -0400 Subject: [PATCH 008/267] add Private URL feature #1012 Requires scripts/database/upgrades/privateurl.sql Design doc at doc/Architecture/privateurl.md New API endpoints: doc/sphinx-guides/source/api/native-api.rst --- doc/Architecture/privateurl.md | 34 ++ doc/sphinx-guides/source/api/native-api.rst | 15 + scripts/database/upgrades/privateurl.sql | 2 + src/main/java/Bundle.properties | 15 + .../edu/harvard/iq/dataverse/DatasetPage.java | 81 +++- .../iq/dataverse/DataverseSession.java | 2 +- .../iq/dataverse/EjbDataverseEngine.java | 22 +- .../dataverse/ManageFilePermissionsPage.java | 3 +- .../iq/dataverse/ManagePermissionsPage.java | 3 +- .../iq/dataverse/RoleAssigneeServiceBean.java | 21 +- .../harvard/iq/dataverse/RoleAssignment.java | 14 +- .../iq/dataverse/RolePermissionFragment.java | 3 +- .../iq/dataverse/api/AbstractApiBean.java | 16 +- .../edu/harvard/iq/dataverse/api/Access.java | 50 ++- .../harvard/iq/dataverse/api/Datasets.java | 68 +++- .../harvard/iq/dataverse/api/Dataverses.java | 3 +- .../authorization/DataverseRole.java | 1 + .../authorization/users/PrivateUrlUser.java | 64 ++++ .../engine/command/CommandContext.java | 6 + .../command/impl/AssignRoleCommand.java | 7 +- .../command/impl/CreateDatasetCommand.java | 3 +- .../command/impl/CreateDataverseCommand.java | 3 +- .../command/impl/CreatePrivateUrlCommand.java | 68 ++++ .../impl/DeleteDatasetVersionCommand.java | 9 + .../command/impl/DeletePrivateUrlCommand.java | 50 +++ .../command/impl/GetPrivateUrlCommand.java | 36 ++ .../command/impl/ListRoleAssignments.java | 7 +- .../command/impl/PublishDatasetCommand.java | 10 + .../iq/dataverse/privateurl/PrivateUrl.java | 54 +++ .../dataverse/privateurl/PrivateUrlPage.java | 51 +++ .../privateurl/PrivateUrlRedirectData.java | 39 ++ .../privateurl/PrivateUrlServiceBean.java | 108 ++++++ .../dataverse/privateurl/PrivateUrlUtil.java | 198 ++++++++++ .../dataverse/search/SearchServiceBean.java | 5 + .../iq/dataverse/util/json/JsonPrinter.java | 12 +- src/main/webapp/dataset.xhtml | 37 ++ src/main/webapp/privateurl.xhtml | 24 ++ .../harvard/iq/dataverse/api/DatasetsIT.java | 356 ++++++++++++++++-- .../edu/harvard/iq/dataverse/api/UtilIT.java | 109 ++++++ .../dataverse/engine/TestCommandContext.java | 12 + .../impl/CreatePrivateUrlCommandTest.java | 162 ++++++++ .../impl/DeletePrivateUrlCommandTest.java | 107 ++++++ .../impl/GetPrivateUrlCommandTest.java | 70 ++++ .../privateurl/PrivateUrlUtilTest.java | 355 +++++++++++++++++ .../dataverse/util/json/JsonPrinterTest.java | 53 +++ 45 files changed, 2293 insertions(+), 75 deletions(-) create mode 100644 doc/Architecture/privateurl.md create mode 100644 scripts/database/upgrades/privateurl.sql create mode 100644 src/main/java/edu/harvard/iq/dataverse/authorization/users/PrivateUrlUser.java create mode 100644 src/main/java/edu/harvard/iq/dataverse/engine/command/impl/CreatePrivateUrlCommand.java create mode 100644 src/main/java/edu/harvard/iq/dataverse/engine/command/impl/DeletePrivateUrlCommand.java create mode 100644 src/main/java/edu/harvard/iq/dataverse/engine/command/impl/GetPrivateUrlCommand.java create mode 100644 src/main/java/edu/harvard/iq/dataverse/privateurl/PrivateUrl.java create mode 100644 src/main/java/edu/harvard/iq/dataverse/privateurl/PrivateUrlPage.java create mode 100644 src/main/java/edu/harvard/iq/dataverse/privateurl/PrivateUrlRedirectData.java create mode 100644 src/main/java/edu/harvard/iq/dataverse/privateurl/PrivateUrlServiceBean.java create mode 100644 src/main/java/edu/harvard/iq/dataverse/privateurl/PrivateUrlUtil.java create mode 100644 src/main/webapp/privateurl.xhtml create mode 100644 src/test/java/edu/harvard/iq/dataverse/engine/command/impl/CreatePrivateUrlCommandTest.java create mode 100644 src/test/java/edu/harvard/iq/dataverse/engine/command/impl/DeletePrivateUrlCommandTest.java create mode 100644 src/test/java/edu/harvard/iq/dataverse/engine/command/impl/GetPrivateUrlCommandTest.java create mode 100644 src/test/java/edu/harvard/iq/dataverse/privateurl/PrivateUrlUtilTest.java create mode 100644 src/test/java/edu/harvard/iq/dataverse/util/json/JsonPrinterTest.java diff --git a/doc/Architecture/privateurl.md b/doc/Architecture/privateurl.md new file mode 100644 index 00000000000..fdec655b63f --- /dev/null +++ b/doc/Architecture/privateurl.md @@ -0,0 +1,34 @@ +Private URL Design Doc +---------------------- + +The Private URL feature has been implemented as a specialized role assignment with an associated token that permits read-only access to the metadata and all files (regardless of if the files are restricted or not) of a draft version of a dataset. + +The primary use case for a Private URL is for journal editors to send a link to reviewers of a dataset before publication. In most cases, these journal editors do not permit depositors to publish on their own, which is to say they only allow depositors to have the "Contributor" role on the datasets they create. With only the "Contributor" role, depositors are unable to grant even read-only access to any user within the Dataverse installation and must contact the journal editor to make any adjustments to permissions, which they can't even see. This is all by design because it is the journal editor, not the depositor, who is in charge of both the security of the dataset and the timing of when the dataset is published. + +A secondary use case for a Private URL is for depositors who have the ability to manage permissions on their dataset (depositors who have the "Curator" or "Admin" role, which grants much more power than the "Contributor" role) to send a link to coauthors or other trusted parties to preview the dataset before the depositors publish the dataset on their own. For better security, these depositors could ask their coauthors to create Dataverse accounts and assign roles to them directly, rather than using a Private URL which requires no username or password. + +The token associated with the Private URL role assignment that can be used either in the GUI or via the API to elevate privileges beyond what a "Guest" can see. The ability to use a Private URL token via API was added mostly to facilitate automated testing of the feature but the far more common case is expected to be use of the Private URL token in a link that is clicked to open a browser, similar to links shared via Dropbox, Google, etc. + +When reviewers click a Private URL their browser sessions are set to the "PrivateUrlUser" that has the "Member" role only on the dataset in question and redirected to that dataset, where they will see an indication in blue at the top of the page that they are viewing an unpublished dataset. If the reviewer happens to be logged into Dataverse already, clicking the link will log them out because the review is meant to be blind. Because the dataset is always in draft when a Private URL is in effect, no downloads or any other activity by the reviewer are logged to the guestbook. All reviewers click the same Private URL containing the same token, and with the exception of an IP address being logged, it should be impossible to trace which reviewers have clicked a Private URL. If the reviewer navigates to the home page, the session is set to the Guest user and they will see what a Guest would see. + +The "Member" role is used because it contains the necessary read-only permissions, which are ViewUnpublishedDataset and DownloadFile. (Technically, the "Member" role also has the ViewUnpublishedDataverse permission but because the role is assigned at the dataset level and dataverses cannot be children of datasets, this permission has no effect.) Reusing the "Member" role helps contain the list of roles available at the dataset level to a reasonable number (five). + +Because the PrivateUrlUser has the "Member" role, all the same permissions apply. This means that the PrivateUrlUser (the reviewer, typically) can download all files, even if they have been restricted, across any dataset version. A Member can also download restricted files that have been deleted from previously published versions. + +Likewise, when a Private URL token is used via API, commands are executed using the "PrivateUrlUser" that has the "Member" role only on the dataset in question. This means that read-only operations such as downloads of the dataset's files are permitted. The Search API does not respect the Private URL token but you can download unpublished metadata using the Native API and download files using the Access API. + +A Private URL cannot be created for a published version of a dataset. In the GUI, you will be reminded of this fact with a popup. The API will explain this as well. + +If a draft dataset containing a Private URL is published, the Private URL is deleted. This means that reviewers who click the link after publication will see a 404. + +If a post-publication draft containing a Private URL is deleted, the Private URL is deleted. This is to ensure that if a new draft is created in the future, a new token will be used. + +The creation and deletion of a Private URL are limited to the "Curator" and "Admin" roles because only those roles have the permission called "ManageDatasetPermissions", which is the permission used by the "AssignRoleCommand" and "RevokeRoleCommand" commands. If you have the permission to create or delete a Private URL, the fact that a Private URL is enabled for a dataset will be indicated in blue at the top of the page. Success messages are shown at the top of the page when you create or delete a Private URL. In the GUI, deleting a Private URL is called "disabling" and you will be prompted for a confirmation. No matter what you call it the role is revoked. You can also delete a Private URL by revoking the role. + +A "Contributor" does not have the "ManageDatasetPermissions" permission and cannot see "Permissions" nor "Private URL" under the "Edit" menu of their dataset. When a Curator or Admin has enabled a Private URL on a Contributor's dataset, the Contributor does not see a visual indication that a Private URL has been enabled for their dataset. + +There is no way for an "Admin" or "Curator" to see when a Private URL was created or deleted for a dataset but someone who has access to the database can see that the following commands are logged to the "actionlogrecord" database table: CreatePrivateUrlCommand, DeletePrivateUrlCommand, and GetPrivateUrlCommand. + +See also + +* Private URL To Unpublished Dataset BRD: https://docs.google.com/document/d/1FT47QkZKcmjSgRnePaJO2g1nzcotLyN3Yb2ORvBr6cs/edit?usp=sharing diff --git a/doc/sphinx-guides/source/api/native-api.rst b/doc/sphinx-guides/source/api/native-api.rst index 9fb67fc73ee..c0b0b3a7344 100644 --- a/doc/sphinx-guides/source/api/native-api.rst +++ b/doc/sphinx-guides/source/api/native-api.rst @@ -162,6 +162,21 @@ Restores the default logic of the field type to be used as the citation date. Sa DELETE http://$SERVER/api/datasets/$id/citationdate?key=$apiKey +List all the role assignments at the given dataset:: + + GET http://$SERVER/api/datasets/$id/assignments?key=$apiKey + +Create a Private URL (must be able to manage dataset permissions):: + + POST http://$SERVER/api/datasets/$id/privateUrl?key=$apiKey + +Get a Private URL from a dataset (if available):: + + GET http://$SERVER/api/datasets/$id/privateUrl?key=$apiKey + +Delete a Private URL from a dataset (if it exists):: + + DELETE http://$SERVER/api/datasets/$id/privateUrl?key=$apiKey Builtin Users ~~~~~ diff --git a/scripts/database/upgrades/privateurl.sql b/scripts/database/upgrades/privateurl.sql new file mode 100644 index 00000000000..67caf1f30b1 --- /dev/null +++ b/scripts/database/upgrades/privateurl.sql @@ -0,0 +1,2 @@ +-- A Private URL is a specialized role assignment with a token. +ALTER TABLE roleassignment ADD COLUMN privateurltoken character varying(255); diff --git a/src/main/java/Bundle.properties b/src/main/java/Bundle.properties index 5a6d571d06e..af870e7c3ab 100755 --- a/src/main/java/Bundle.properties +++ b/src/main/java/Bundle.properties @@ -750,6 +750,7 @@ dataset.editBtn.itemLabel.upload=Files (Upload) dataset.editBtn.itemLabel.metadata=Metadata dataset.editBtn.itemLabel.terms=Terms dataset.editBtn.itemLabel.permissions=Permissions +dataset.editBtn.itemLabel.privateUrl=Private URL dataset.editBtn.itemLabel.deleteDataset=Delete Dataset dataset.editBtn.itemLabel.deleteDraft=Delete Draft Version dataset.editBtn.itemLabel.deaccession=Deaccession Dataset @@ -862,6 +863,20 @@ dataset.mixedSelectedFilesForDownload=The restricted file(s) selected may not be dataset.downloadUnrestricted=Click Continue to download the files you have access to download. dataset.requestAccessToRestrictedFiles=You may request access to the restricted file(s) by clicking the Request Access button. +dataset.privateurl.infoMessageAuthor=Private URL is in use for this dataset: {0} +dataset.privateurl.infoMessageReviewer=This is an unpublished draft of a dataset that has been shared privately. +dataset.privateurl.header=Private URL +dataset.privateurl.tip=Use a Private URL to allow those without Dataverse accounts to access your unpublished dataset. +dataset.privateurl.absent=Private URL has not been created. +dataset.privateurl.createPrivateUrl=Create Private URL +dataset.privateurl.disablePrivateUrl=Disable Private URL +dataset.privateurl.disableConfirmationTitle=Confirm Disable Private URL +dataset.privateurl.disableConfirmationText=If you have sent the Private URL to others they will no longer be able to use it to access your unpublished dataset. +dataset.privateurl.cannotCreate=Private URL can only be used with unpublished versions of datasets. +dataset.privateurl.roleassigeeTitle=Private URL Enabled +dataset.privateurl.createdSuccess=Private URL created: {0} +dataset.privateurl.disabledSuccess=Private URL disabled. +dataset.privateurl.noPermToCreate=To create a Private URL you must have the following permissions: {0}. file.count={0} {0, choice, 0#Files|1#File|2#Files} file.count.selected={0} {0, choice, 0#Files Selected|1#File Selected|2#Files Selected} file.selectToAddBtn=Select Files to Add diff --git a/src/main/java/edu/harvard/iq/dataverse/DatasetPage.java b/src/main/java/edu/harvard/iq/dataverse/DatasetPage.java index 2c460fcb486..396cdbbeed4 100644 --- a/src/main/java/edu/harvard/iq/dataverse/DatasetPage.java +++ b/src/main/java/edu/harvard/iq/dataverse/DatasetPage.java @@ -6,14 +6,19 @@ import edu.harvard.iq.dataverse.authorization.users.ApiToken; import edu.harvard.iq.dataverse.authorization.users.User; import edu.harvard.iq.dataverse.authorization.users.AuthenticatedUser; +import edu.harvard.iq.dataverse.authorization.users.PrivateUrlUser; +import edu.harvard.iq.dataverse.authorization.users.GuestUser; import edu.harvard.iq.dataverse.datavariable.VariableServiceBean; import edu.harvard.iq.dataverse.engine.command.Command; import edu.harvard.iq.dataverse.engine.command.exception.CommandException; import edu.harvard.iq.dataverse.engine.command.impl.CreateDatasetCommand; import edu.harvard.iq.dataverse.engine.command.impl.CreateGuestbookResponseCommand; +import edu.harvard.iq.dataverse.engine.command.impl.CreatePrivateUrlCommand; import edu.harvard.iq.dataverse.engine.command.impl.DeaccessionDatasetVersionCommand; import edu.harvard.iq.dataverse.engine.command.impl.DeleteDatasetVersionCommand; +import edu.harvard.iq.dataverse.engine.command.impl.DeletePrivateUrlCommand; import edu.harvard.iq.dataverse.engine.command.impl.DestroyDatasetCommand; +import edu.harvard.iq.dataverse.engine.command.impl.GetPrivateUrlCommand; import edu.harvard.iq.dataverse.engine.command.impl.LinkDatasetCommand; import edu.harvard.iq.dataverse.engine.command.impl.PublishDatasetCommand; import edu.harvard.iq.dataverse.engine.command.impl.PublishDataverseCommand; @@ -21,6 +26,9 @@ import edu.harvard.iq.dataverse.ingest.IngestRequest; import edu.harvard.iq.dataverse.ingest.IngestServiceBean; import edu.harvard.iq.dataverse.metadataimport.ForeignMetadataImportServiceBean; +import edu.harvard.iq.dataverse.privateurl.PrivateUrl; +import edu.harvard.iq.dataverse.privateurl.PrivateUrlServiceBean; +import edu.harvard.iq.dataverse.privateurl.PrivateUrlUtil; import edu.harvard.iq.dataverse.search.SearchFilesServiceBean; import edu.harvard.iq.dataverse.search.SortBy; import edu.harvard.iq.dataverse.settings.SettingsServiceBean; @@ -142,6 +150,10 @@ public enum DisplayMode { DatasetLinkingServiceBean dsLinkingService; @EJB SearchFilesServiceBean searchFilesService; + @EJB + DataverseRoleServiceBean dataverseRoleService; + @EJB + PrivateUrlServiceBean privateUrlService; @Inject DataverseRequestServiceBean dvRequestService; @Inject @@ -486,10 +498,19 @@ public boolean canDownloadFile(FileMetadata fileMetadata){ // -------------------------------------------------------------------- // -------------------------------------------------------------------- - // (2) Is user authenticated? - // No? Then no button... + // (2) In Dataverse 4.3 and earlier we required that users be authenticated + // to download files, but in developing the Private URL feature, we have + // added a new subclass of "User" called "PrivateUrlUser" that returns false + // for isAuthenticated but that should be able to download restricted files + // when given the Member role (which includes the DownloadFile permission). + // This is consistent with how Builtin and Shib users (both are + // AuthenticatedUsers) can download restricted files when they are granted + // the Member role. For this reason condition 2 has been changed. Previously, + // we required isSessionUserAuthenticated to return true. Now we require + // that the User is not an instance of GuestUser, which is similar in + // spirit to the previous check. // -------------------------------------------------------------------- - if (!(isSessionUserAuthenticated())){ + if (session.getUser() instanceof GuestUser){ this.fileDownloadPermissionMap.put(fid, false); return false; } @@ -1555,6 +1576,20 @@ public String init() { return "/404.xhtml"; } + try { + privateUrl = commandEngine.submit(new GetPrivateUrlCommand(dvRequestService.getDataverseRequest(), dataset)); + if (privateUrl != null) { + JH.addMessage(FacesMessage.SEVERITY_INFO, BundleUtil.getStringFromBundle("dataset.privateurl.infoMessageAuthor", Arrays.asList(getPrivateUrlLink(privateUrl)))); + } + } catch (CommandException ex) { + // No big deal. The user simply doesn't have access to create or delete a Private URL. + } + if (session.getUser() instanceof PrivateUrlUser) { + PrivateUrlUser privateUrlUser = (PrivateUrlUser) session.getUser(); + if (dataset != null && dataset.getId().equals(privateUrlUser.getDatasetId())) { + JH.addMessage(FacesMessage.SEVERITY_INFO, BundleUtil.getStringFromBundle("dataset.privateurl.infoMessageReviewer")); + } + } return null; } @@ -4282,4 +4317,44 @@ public String getSortByDescending() { return SortBy.DESCENDING; } + PrivateUrl privateUrl; + + public PrivateUrl getPrivateUrl() { + return privateUrl; + } + + public void setPrivateUrl(PrivateUrl privateUrl) { + this.privateUrl = privateUrl; + } + + public void createPrivateUrl() { + try { + PrivateUrl createdPrivateUrl = commandEngine.submit(new CreatePrivateUrlCommand(dvRequestService.getDataverseRequest(), dataset)); + privateUrl = createdPrivateUrl; + JH.addSuccessMessage(BundleUtil.getStringFromBundle("dataset.privateurl.createdSuccess", Arrays.asList(getPrivateUrlLink(privateUrl)))); + } catch (CommandException ex) { + String msg = BundleUtil.getStringFromBundle("dataset.privateurl.noPermToCreate", PrivateUrlUtil.getRequiredPermissions(ex)); + logger.info("Unable to create a Private URL for dataset id " + dataset.getId() + ". Message to user: " + msg + " Exception: " + ex); + JH.addErrorMessage(msg); + } + } + + public void disablePrivateUrl() { + try { + commandEngine.submit(new DeletePrivateUrlCommand(dvRequestService.getDataverseRequest(), dataset)); + privateUrl = null; + JH.addSuccessMessage(BundleUtil.getStringFromBundle("dataset.privateurl.disabledSuccess")); + } catch (CommandException ex) { + logger.info("CommandException caught calling DeletePrivateUrlCommand: " + ex); + } + } + + public boolean isUserCanCreatePrivateURL() { + return dataset.getLatestVersion().isDraft(); + } + + public String getPrivateUrlLink(PrivateUrl privateUrl) { + return privateUrl.getLink(); + } + } diff --git a/src/main/java/edu/harvard/iq/dataverse/DataverseSession.java b/src/main/java/edu/harvard/iq/dataverse/DataverseSession.java index edec5fbe008..3770c54750a 100644 --- a/src/main/java/edu/harvard/iq/dataverse/DataverseSession.java +++ b/src/main/java/edu/harvard/iq/dataverse/DataverseSession.java @@ -39,7 +39,7 @@ public User getUser() { return user; } - public void setUser(AuthenticatedUser aUser) { + public void setUser(User aUser) { logSvc.log( new ActionLogRecord(ActionLogRecord.ActionType.SessionManagement,(aUser==null) ? "logout" : "login") .setUserIdentifier((aUser!=null) ? aUser.getIdentifier() : (user!=null ? user.getIdentifier() : "") )); diff --git a/src/main/java/edu/harvard/iq/dataverse/EjbDataverseEngine.java b/src/main/java/edu/harvard/iq/dataverse/EjbDataverseEngine.java index baac2a13037..3c78a9435aa 100644 --- a/src/main/java/edu/harvard/iq/dataverse/EjbDataverseEngine.java +++ b/src/main/java/edu/harvard/iq/dataverse/EjbDataverseEngine.java @@ -12,6 +12,7 @@ import edu.harvard.iq.dataverse.engine.command.DataverseRequest; import edu.harvard.iq.dataverse.engine.command.exception.CommandException; import edu.harvard.iq.dataverse.engine.command.exception.PermissionException; +import edu.harvard.iq.dataverse.privateurl.PrivateUrlServiceBean; import edu.harvard.iq.dataverse.search.IndexServiceBean; import edu.harvard.iq.dataverse.search.SearchServiceBean; import java.util.Map; @@ -23,6 +24,7 @@ import edu.harvard.iq.dataverse.search.SolrIndexServiceBean; import edu.harvard.iq.dataverse.search.savedsearch.SavedSearchServiceBean; import edu.harvard.iq.dataverse.settings.SettingsServiceBean; +import edu.harvard.iq.dataverse.util.SystemConfig; import java.util.EnumSet; import java.util.logging.Level; import java.util.logging.Logger; @@ -127,7 +129,13 @@ public class EjbDataverseEngine { @EJB AuthenticationServiceBean authentication; - + + @EJB + SystemConfig systemConfig; + + @EJB + PrivateUrlServiceBean privateUrlService; + @PersistenceContext(unitName = "VDCNet-ejbPU") private EntityManager em; @@ -371,7 +379,17 @@ public UserNotificationServiceBean notifications() { public AuthenticationServiceBean authentication() { return authentication; } - + + @Override + public SystemConfig systemConfig() { + return systemConfig; + } + + @Override + public PrivateUrlServiceBean privateUrl() { + return privateUrlService; + } + }; } diff --git a/src/main/java/edu/harvard/iq/dataverse/ManageFilePermissionsPage.java b/src/main/java/edu/harvard/iq/dataverse/ManageFilePermissionsPage.java index 4c67c5b1342..e9cbe611656 100644 --- a/src/main/java/edu/harvard/iq/dataverse/ManageFilePermissionsPage.java +++ b/src/main/java/edu/harvard/iq/dataverse/ManageFilePermissionsPage.java @@ -399,7 +399,8 @@ private void rejectAccessToRequests(AuthenticatedUser au, List files) private boolean assignRole(RoleAssignee ra, DataFile file, DataverseRole r) { try { - commandEngine.submit(new AssignRoleCommand(ra, r, file, dvRequestService.getDataverseRequest())); + String privateUrlToken = null; + commandEngine.submit(new AssignRoleCommand(ra, r, file, dvRequestService.getDataverseRequest(), privateUrlToken)); JsfHelper.addSuccessMessage(r.getName() + " role assigned to " + ra.getDisplayInfo().getTitle() + " for " + file.getDisplayName() + "."); } catch (PermissionException ex) { JH.addMessage(FacesMessage.SEVERITY_ERROR, "The role was not able to be assigned.", "Permissions " + ex.getRequiredPermissions().toString() + " missing."); diff --git a/src/main/java/edu/harvard/iq/dataverse/ManagePermissionsPage.java b/src/main/java/edu/harvard/iq/dataverse/ManagePermissionsPage.java index 36902669816..6c8e745e794 100644 --- a/src/main/java/edu/harvard/iq/dataverse/ManagePermissionsPage.java +++ b/src/main/java/edu/harvard/iq/dataverse/ManagePermissionsPage.java @@ -395,7 +395,8 @@ private void notifyRoleChange(RoleAssignee ra, UserNotification.Type type) { private void assignRole(RoleAssignee ra, DataverseRole r) { try { - commandEngine.submit(new AssignRoleCommand(ra, r, dvObject, dvRequestService.getDataverseRequest())); + String privateUrlToken = null; + commandEngine.submit(new AssignRoleCommand(ra, r, dvObject, dvRequestService.getDataverseRequest(), privateUrlToken)); JsfHelper.addSuccessMessage(r.getName() + " role assigned to " + ra.getDisplayInfo().getTitle() + " for " + dvObject.getDisplayName() + "."); // don't notify if role = file downloader and object is not released if (!(r.getAlias().equals(DataverseRole.FILE_DOWNLOADER) && !dvObject.isReleased()) ){ diff --git a/src/main/java/edu/harvard/iq/dataverse/RoleAssigneeServiceBean.java b/src/main/java/edu/harvard/iq/dataverse/RoleAssigneeServiceBean.java index de286c1dec1..955a7050ac5 100644 --- a/src/main/java/edu/harvard/iq/dataverse/RoleAssigneeServiceBean.java +++ b/src/main/java/edu/harvard/iq/dataverse/RoleAssigneeServiceBean.java @@ -10,7 +10,9 @@ import edu.harvard.iq.dataverse.authorization.groups.impl.explicit.ExplicitGroup; import edu.harvard.iq.dataverse.authorization.groups.impl.explicit.ExplicitGroupServiceBean; import edu.harvard.iq.dataverse.authorization.users.AuthenticatedUser; +import edu.harvard.iq.dataverse.authorization.users.PrivateUrlUser; import edu.harvard.iq.dataverse.authorization.users.GuestUser; +import edu.harvard.iq.dataverse.privateurl.PrivateUrlUtil; import java.util.ArrayList; import java.util.List; import java.util.Map; @@ -63,7 +65,24 @@ void setup() { public RoleAssignee getRoleAssignee(String identifier) { switch (identifier.charAt(0)) { case ':': - return predefinedRoleAssignees.get(identifier); + /** + * This "startsWith" code in identifier2roleAssignee is here to + * support a functional requirement to display the Private URL + * role assignment when looking at permissions at the dataset + * level in the GUI and allow for revoking the role from that + * page. Interestingly, if you remove the "startsWith" code, + * null will be returned for Private URL but the assignment is + * still visible from the API. When null is returned + * ManagePermissionsPage cannot list the assignment. + * + * "startsWith" is the moral equivalent of + * "identifier.charAt(0)". :) + */ + if (identifier.startsWith(PrivateUrlUser.PREFIX)) { + return PrivateUrlUtil.identifier2roleAssignee(identifier); + } else { + return predefinedRoleAssignees.get(identifier); + } case '@': return authSvc.getAuthenticatedUser(identifier.substring(1)); case '&': diff --git a/src/main/java/edu/harvard/iq/dataverse/RoleAssignment.java b/src/main/java/edu/harvard/iq/dataverse/RoleAssignment.java index be3759e61d2..3d23bbb54c1 100644 --- a/src/main/java/edu/harvard/iq/dataverse/RoleAssignment.java +++ b/src/main/java/edu/harvard/iq/dataverse/RoleAssignment.java @@ -39,6 +39,8 @@ query = "SELECT r FROM RoleAssignment r WHERE r.definitionPoint.id=:definitionPointId" ), @NamedQuery( name = "RoleAssignment.listByRoleId", query = "SELECT r FROM RoleAssignment r WHERE r.role=:roleId" ), + @NamedQuery( name = "RoleAssignment.listByPrivateUrlToken", + query = "SELECT r FROM RoleAssignment r WHERE r.privateUrlToken=:privateUrlToken" ), @NamedQuery( name = "RoleAssignment.deleteByAssigneeIdentifier_RoleIdDefinition_PointId", query = "DELETE FROM RoleAssignment r WHERE r.assigneeIdentifier=:userId AND r.role.id=:roleId AND r.definitionPoint.id=:definitionPointId"), }) @@ -57,13 +59,17 @@ public class RoleAssignment implements java.io.Serializable { @ManyToOne( cascade = CascadeType.MERGE ) @JoinColumn( nullable=false ) private DvObject definitionPoint; + + @Column(nullable = true) + private String privateUrlToken; public RoleAssignment() {} - public RoleAssignment(DataverseRole aRole, RoleAssignee anAssignee, DvObject aDefinitionPoint) { + public RoleAssignment(DataverseRole aRole, RoleAssignee anAssignee, DvObject aDefinitionPoint, String privateUrlToken) { role = aRole; assigneeIdentifier = anAssignee.getIdentifier(); definitionPoint = aDefinitionPoint; + this.privateUrlToken = privateUrlToken; } public Long getId() { @@ -97,7 +103,11 @@ public DvObject getDefinitionPoint() { public void setDefinitionPoint(DvObject definitionPoint) { this.definitionPoint = definitionPoint; } - + + public String getPrivateUrlToken() { + return privateUrlToken; + } + @Override public int hashCode() { int hash = 7; diff --git a/src/main/java/edu/harvard/iq/dataverse/RolePermissionFragment.java b/src/main/java/edu/harvard/iq/dataverse/RolePermissionFragment.java index c420c4692f2..89827530b33 100644 --- a/src/main/java/edu/harvard/iq/dataverse/RolePermissionFragment.java +++ b/src/main/java/edu/harvard/iq/dataverse/RolePermissionFragment.java @@ -186,7 +186,8 @@ public void assignRole(ActionEvent evt) { private void assignRole(RoleAssignee ra, DataverseRole r) { try { - commandEngine.submit(new AssignRoleCommand(ra, r, dvObject, dvRequestService.getDataverseRequest())); + String privateUrlToken = null; + commandEngine.submit(new AssignRoleCommand(ra, r, dvObject, dvRequestService.getDataverseRequest(), privateUrlToken)); JH.addMessage(FacesMessage.SEVERITY_INFO, "Role " + r.getName() + " assigned to " + ra.getDisplayInfo().getTitle() + " on " + dvObject.getDisplayName()); } catch (CommandException ex) { JH.addMessage(FacesMessage.SEVERITY_ERROR, "Can't assign role: " + ex.getMessage()); diff --git a/src/main/java/edu/harvard/iq/dataverse/api/AbstractApiBean.java b/src/main/java/edu/harvard/iq/dataverse/api/AbstractApiBean.java index 169921a5a39..2343a680564 100644 --- a/src/main/java/edu/harvard/iq/dataverse/api/AbstractApiBean.java +++ b/src/main/java/edu/harvard/iq/dataverse/api/AbstractApiBean.java @@ -20,12 +20,14 @@ import edu.harvard.iq.dataverse.authorization.groups.GroupServiceBean; import edu.harvard.iq.dataverse.authorization.users.AuthenticatedUser; import edu.harvard.iq.dataverse.authorization.users.GuestUser; +import edu.harvard.iq.dataverse.authorization.users.PrivateUrlUser; import edu.harvard.iq.dataverse.authorization.users.User; import edu.harvard.iq.dataverse.engine.command.Command; import edu.harvard.iq.dataverse.engine.command.DataverseRequest; import edu.harvard.iq.dataverse.engine.command.exception.CommandException; import edu.harvard.iq.dataverse.engine.command.exception.IllegalCommandException; import edu.harvard.iq.dataverse.engine.command.exception.PermissionException; +import edu.harvard.iq.dataverse.privateurl.PrivateUrlServiceBean; import edu.harvard.iq.dataverse.search.savedsearch.SavedSearchServiceBean; import edu.harvard.iq.dataverse.settings.SettingsServiceBean; import edu.harvard.iq.dataverse.util.json.JsonParser; @@ -169,6 +171,9 @@ String getWrappedMessageWhenJson() { @EJB protected SavedSearchServiceBean savedSearchSvc; + @EJB + protected PrivateUrlServiceBean privateUrlSvc; + @PersistenceContext(unitName = "VDCNet-ejbPU") protected EntityManager em; @@ -227,9 +232,14 @@ protected String getRequestApiKey() { */ protected User findUserOrDie() throws WrappedResponse { final String requestApiKey = getRequestApiKey(); - return ( requestApiKey == null ) - ? GuestUser.get() - : findAuthenticatedUserOrDie(requestApiKey); + if (requestApiKey == null) { + return GuestUser.get(); + } + PrivateUrlUser privateUrlUser = privateUrlSvc.getPrivateUrlUserFromToken(requestApiKey); + if (privateUrlUser != null) { + return privateUrlUser; + } + return findAuthenticatedUserOrDie(requestApiKey); } /** diff --git a/src/main/java/edu/harvard/iq/dataverse/api/Access.java b/src/main/java/edu/harvard/iq/dataverse/api/Access.java index 522a28362f7..cc43e3b2e9c 100644 --- a/src/main/java/edu/harvard/iq/dataverse/api/Access.java +++ b/src/main/java/edu/harvard/iq/dataverse/api/Access.java @@ -20,7 +20,9 @@ import edu.harvard.iq.dataverse.PermissionServiceBean; import edu.harvard.iq.dataverse.authorization.Permission; import edu.harvard.iq.dataverse.authorization.users.AuthenticatedUser; +import edu.harvard.iq.dataverse.authorization.users.PrivateUrlUser; import edu.harvard.iq.dataverse.authorization.users.GuestUser; +import edu.harvard.iq.dataverse.authorization.users.User; import edu.harvard.iq.dataverse.dataaccess.DataFileIO; import edu.harvard.iq.dataverse.dataaccess.DataFileZipper; import edu.harvard.iq.dataverse.dataaccess.FileAccessIO; @@ -945,7 +947,7 @@ private boolean isAccessAuthorized(DataFile df, String apiToken) { } } - AuthenticatedUser user = null; + User user = null; /** * Authentication/authorization: @@ -963,7 +965,11 @@ private boolean isAccessAuthorized(DataFile df, String apiToken) { if (session.getUser().isAuthenticated()) { user = (AuthenticatedUser) session.getUser(); } else { - logger.fine("User associated with the session is not an authenticated user. (Guest access will be assumed)."); + logger.fine("User associated with the session is not an authenticated user."); + if (session.getUser() instanceof PrivateUrlUser) { + logger.fine("User associated with the session is a PrivateUrlUser user."); + user = session.getUser(); + } if (session.getUser() instanceof GuestUser) { logger.fine("User associated with the session is indeed a guest user."); } @@ -975,13 +981,18 @@ private boolean isAccessAuthorized(DataFile df, String apiToken) { logger.fine("Session is null."); } - AuthenticatedUser apiTokenUser = null; + User apiTokenUser = null; if ((apiToken != null)&&(apiToken.length()!=64)) { // We'll also try to obtain the user information from the API token, // if supplied: - apiTokenUser = findUserByApiToken(apiToken); + try { + logger.fine("calling apiTokenUser = findUserOrDie()..."); + apiTokenUser = findUserOrDie(); + } catch (WrappedResponse wr) { + logger.fine("Message from findUserOrDie(): " + wr.getMessage()); + } if (apiTokenUser == null) { logger.warning("API token-based auth: Unable to find a user with the API token provided."); @@ -1000,14 +1011,14 @@ private boolean isAccessAuthorized(DataFile df, String apiToken) { if (user != null) { // it's not unthinkable, that a null user (i.e., guest user) could be given // the ViewUnpublished permission! - logger.fine("Session-based auth: user "+user.getName()+" has access rights on the non-restricted, unpublished datafile."); + logger.fine("Session-based auth: user " + user.getIdentifier() + " has access rights on the non-restricted, unpublished datafile."); } return true; } if (apiTokenUser != null) { if (permissionService.userOn(apiTokenUser, df.getOwner()).has(Permission.ViewUnpublishedDataset)) { - logger.fine("Session-based auth: user "+apiTokenUser.getName()+" has access rights on the non-restricted, unpublished datafile."); + logger.fine("Session-based auth: user " + apiTokenUser.getIdentifier() + " has access rights on the non-restricted, unpublished datafile."); return true; } } @@ -1036,12 +1047,12 @@ private boolean isAccessAuthorized(DataFile df, String apiToken) { if (published) { if (hasAccessToRestrictedBySession) { if (user != null) { - logger.fine("Session-based auth: user "+user.getName()+" is granted access to the restricted, published datafile."); + logger.fine("Session-based auth: user " + user.getIdentifier() + " is granted access to the restricted, published datafile."); } else { logger.fine("Session-based auth: guest user is granted access to the restricted, published datafile."); } } else { - logger.fine("Token-based auth: user "+apiTokenUser.getName()+" is granted access to the restricted, published datafile."); + logger.fine("Token-based auth: user " + apiTokenUser.getIdentifier() + " is granted access to the restricted, published datafile."); } return true; } else { @@ -1054,7 +1065,7 @@ private boolean isAccessAuthorized(DataFile df, String apiToken) { if (hasAccessToRestrictedBySession) { if (permissionService.on(df.getOwner()).has(Permission.ViewUnpublishedDataset)) { if (user != null) { - logger.fine("Session-based auth: user " + user.getName() + " is granted access to the restricted, unpublished datafile."); + logger.fine("Session-based auth: user " + user.getIdentifier() + " is granted access to the restricted, unpublished datafile."); } else { logger.fine("Session-based auth: guest user is granted access to the restricted, unpublished datafile."); } @@ -1062,7 +1073,7 @@ private boolean isAccessAuthorized(DataFile df, String apiToken) { } } else { if (apiTokenUser != null && permissionService.userOn(apiTokenUser, df.getOwner()).has(Permission.ViewUnpublishedDataset)) { - logger.fine("Token-based auth: user " + apiTokenUser.getName() + " is granted access to the restricted, unpublished datafile."); + logger.fine("Token-based auth: user " + apiTokenUser.getIdentifier() + " is granted access to the restricted, unpublished datafile."); } } } @@ -1095,7 +1106,12 @@ private boolean isAccessAuthorized(DataFile df, String apiToken) { // Will try to obtain the user information from the API token, // if supplied: - user = findUserByApiToken(apiToken); + try { + logger.fine("calling user = findUserOrDie()..."); + user = findUserOrDie(); + } catch (WrappedResponse wr) { + logger.fine("Message from findUserOrDie(): " + wr.getMessage()); + } if (user == null) { logger.warning("API token-based auth: Unable to find a user with the API token provided."); @@ -1104,32 +1120,32 @@ private boolean isAccessAuthorized(DataFile df, String apiToken) { if (permissionService.userOn(user, df).has(Permission.DownloadFile)) { if (published) { - logger.fine("API token-based auth: User "+user.getName()+" has rights to access the datafile."); + logger.fine("API token-based auth: User " + user.getIdentifier() + " has rights to access the datafile."); return true; } else { // if the file is NOT published, we will let them download the // file ONLY if they also have the permission to view // unpublished verions: if (permissionService.userOn(user, df.getOwner()).has(Permission.ViewUnpublishedDataset)) { - logger.fine("API token-based auth: User "+user.getName()+" has rights to access the (unpublished) datafile."); + logger.fine("API token-based auth: User " + user.getIdentifier() + " has rights to access the (unpublished) datafile."); return true; } else { - logger.fine("API token-based auth: User "+user.getName()+" is not authorized to access the (unpublished) datafile."); + logger.fine("API token-based auth: User " + user.getIdentifier() + " is not authorized to access the (unpublished) datafile."); } } } else { - logger.fine("API token-based auth: User "+user.getName()+" is not authorized to access the datafile."); + logger.fine("API token-based auth: User " + user.getIdentifier() + " is not authorized to access the datafile."); } return false; } if (user != null) { - logger.fine("Session-based auth: user " + user.getName() + " has NO access rights on the requested datafile."); + logger.fine("Session-based auth: user " + user.getIdentifier() + " has NO access rights on the requested datafile."); } if (apiTokenUser != null) { - logger.fine("Token-based auth: user " + apiTokenUser.getName() + " has NO access rights on the requested datafile."); + logger.fine("Token-based auth: user " + apiTokenUser.getIdentifier() + " has NO access rights on the requested datafile."); } if (user == null && apiTokenUser == null) { diff --git a/src/main/java/edu/harvard/iq/dataverse/api/Datasets.java b/src/main/java/edu/harvard/iq/dataverse/api/Datasets.java index 7f5f8a9533c..82c4dcdea9d 100644 --- a/src/main/java/edu/harvard/iq/dataverse/api/Datasets.java +++ b/src/main/java/edu/harvard/iq/dataverse/api/Datasets.java @@ -9,7 +9,7 @@ import edu.harvard.iq.dataverse.Dataverse; import edu.harvard.iq.dataverse.DataverseServiceBean; import edu.harvard.iq.dataverse.MetadataBlock; -import static edu.harvard.iq.dataverse.api.AbstractApiBean.errorResponse; +import edu.harvard.iq.dataverse.RoleAssignment; import edu.harvard.iq.dataverse.authorization.DataverseRole; import edu.harvard.iq.dataverse.authorization.RoleAssignee; import edu.harvard.iq.dataverse.authorization.users.User; @@ -17,14 +17,18 @@ import edu.harvard.iq.dataverse.engine.command.DataverseRequest; import edu.harvard.iq.dataverse.engine.command.impl.AssignRoleCommand; import edu.harvard.iq.dataverse.engine.command.impl.CreateDatasetVersionCommand; +import edu.harvard.iq.dataverse.engine.command.impl.CreatePrivateUrlCommand; import edu.harvard.iq.dataverse.engine.command.impl.DeleteDatasetCommand; import edu.harvard.iq.dataverse.engine.command.impl.DeleteDatasetVersionCommand; +import edu.harvard.iq.dataverse.engine.command.impl.DeletePrivateUrlCommand; import edu.harvard.iq.dataverse.engine.command.impl.DestroyDatasetCommand; import edu.harvard.iq.dataverse.engine.command.impl.GetDatasetCommand; import edu.harvard.iq.dataverse.engine.command.impl.GetSpecificPublishedDatasetVersionCommand; import edu.harvard.iq.dataverse.engine.command.impl.GetDraftDatasetVersionCommand; import edu.harvard.iq.dataverse.engine.command.impl.GetLatestAccessibleDatasetVersionCommand; import edu.harvard.iq.dataverse.engine.command.impl.GetLatestPublishedDatasetVersionCommand; +import edu.harvard.iq.dataverse.engine.command.impl.GetPrivateUrlCommand; +import edu.harvard.iq.dataverse.engine.command.impl.ListRoleAssignments; import edu.harvard.iq.dataverse.engine.command.impl.ListVersionsCommand; import edu.harvard.iq.dataverse.engine.command.impl.PublishDatasetCommand; import edu.harvard.iq.dataverse.engine.command.impl.SetDatasetCitationDateCommand; @@ -32,6 +36,7 @@ import edu.harvard.iq.dataverse.engine.command.impl.UpdateDatasetVersionCommand; import edu.harvard.iq.dataverse.export.DDIExportServiceBean; import edu.harvard.iq.dataverse.export.ddi.DdiExportUtil; +import edu.harvard.iq.dataverse.privateurl.PrivateUrl; import edu.harvard.iq.dataverse.util.SystemConfig; import edu.harvard.iq.dataverse.util.json.JsonParseException; import static edu.harvard.iq.dataverse.util.json.JsonPrinter.*; @@ -537,12 +542,71 @@ public Response createAssignment(String userOrGroup, @PathParam("identifier") St return errorResponse(Response.Status.BAD_REQUEST, "Assignee not found"); } DataverseRole theRole = rolesSvc.findBuiltinRoleByAlias("admin"); + String privateUrlToken = null; return okResponse( - json(execCommand(new AssignRoleCommand(assignee, theRole, dataset, createDataverseRequest(findUserOrDie()))))); + json(execCommand(new AssignRoleCommand(assignee, theRole, dataset, createDataverseRequest(findUserOrDie()), privateUrlToken)))); } catch (WrappedResponse ex) { LOGGER.log(Level.WARNING, "Can''t create assignment: {0}", ex.getMessage()); return ex.getResponse(); } } + @GET + @Path("{identifier}/assignments") + public Response getAssignments(@PathParam("identifier") String id) { + try { + JsonArrayBuilder jab = Json.createArrayBuilder(); + for (RoleAssignment ra : execCommand(new ListRoleAssignments(createDataverseRequest(findUserOrDie()), findDatasetOrDie(id)))) { + jab.add(json(ra)); + } + return okResponse(jab); + } catch (WrappedResponse ex) { + LOGGER.log(Level.WARNING, "Can't list assignments: {0}", ex.getMessage()); + return ex.getResponse(); + } + } + + @GET + @Path("{id}/privateUrl") + public Response getPrivateUrlData(@PathParam("id") String idSupplied) { + try { + PrivateUrl privateUrl = execCommand(new GetPrivateUrlCommand(createDataverseRequest(findUserOrDie()), findDatasetOrDie(idSupplied))); + if (privateUrl != null) { + return okResponse(json(privateUrl)); + } else { + return errorResponse(Response.Status.NOT_FOUND, "Private URL not found."); + } + } catch (WrappedResponse wr) { + return wr.getResponse(); + } + } + + @POST + @Path("{id}/privateUrl") + public Response createPrivateUrl(@PathParam("id") String idSupplied) { + try { + return okResponse(json(execCommand(new CreatePrivateUrlCommand(createDataverseRequest(findUserOrDie()), findDatasetOrDie(idSupplied))))); + } catch (WrappedResponse wr) { + return wr.getResponse(); + } + } + + @DELETE + @Path("{id}/privateUrl") + public Response deletePrivateUrl(@PathParam("id") String idSupplied) { + try { + User user = findUserOrDie(); + Dataset dataset = findDatasetOrDie(idSupplied); + PrivateUrl privateUrl = execCommand(new GetPrivateUrlCommand(createDataverseRequest(user), dataset)); + if (privateUrl != null) { + execCommand(new DeletePrivateUrlCommand(createDataverseRequest(user), dataset)); + return okResponse("Private URL deleted."); + } else { + return errorResponse(Response.Status.NOT_FOUND, "No Private URL to delete."); + } + } catch (WrappedResponse wr) { + return wr.getResponse(); + } + } + } diff --git a/src/main/java/edu/harvard/iq/dataverse/api/Dataverses.java b/src/main/java/edu/harvard/iq/dataverse/api/Dataverses.java index a787f3e26ee..d0e60d7fd04 100644 --- a/src/main/java/edu/harvard/iq/dataverse/api/Dataverses.java +++ b/src/main/java/edu/harvard/iq/dataverse/api/Dataverses.java @@ -462,10 +462,11 @@ public Response createAssignment( RoleAssignmentDTO ra, @PathParam("identifier") if ( theRole == null ) { return errorResponse( Status.BAD_REQUEST, "Can't find role named '" + ra.getRole() + "' in dataverse " + dataverse); } + String privateUrlToken = null; return okResponse( json( - execCommand( new AssignRoleCommand(assignee, theRole, dataverse, createDataverseRequest(findUserOrDie()))))); + execCommand(new AssignRoleCommand(assignee, theRole, dataverse, createDataverseRequest(findUserOrDie()), privateUrlToken)))); } catch (WrappedResponse ex) { LOGGER.log(Level.WARNING, "Can''t create assignment: {0}", ex.getMessage()); diff --git a/src/main/java/edu/harvard/iq/dataverse/authorization/DataverseRole.java b/src/main/java/edu/harvard/iq/dataverse/authorization/DataverseRole.java index cbb5a7149ac..357280aae7c 100644 --- a/src/main/java/edu/harvard/iq/dataverse/authorization/DataverseRole.java +++ b/src/main/java/edu/harvard/iq/dataverse/authorization/DataverseRole.java @@ -56,6 +56,7 @@ public class DataverseRole implements Serializable { public static final String EDITOR = "editor"; public static final String MANAGER = "manager"; public static final String CURATOR = "curator"; + public static final String MEMBER = "member"; public static final Comparator CMP_BY_NAME = new Comparator(){ diff --git a/src/main/java/edu/harvard/iq/dataverse/authorization/users/PrivateUrlUser.java b/src/main/java/edu/harvard/iq/dataverse/authorization/users/PrivateUrlUser.java new file mode 100644 index 00000000000..147a61bac7c --- /dev/null +++ b/src/main/java/edu/harvard/iq/dataverse/authorization/users/PrivateUrlUser.java @@ -0,0 +1,64 @@ +package edu.harvard.iq.dataverse.authorization.users; + +import edu.harvard.iq.dataverse.authorization.RoleAssigneeDisplayInfo; +import edu.harvard.iq.dataverse.util.BundleUtil; + +/** + * A PrivateUrlUser is virtual in the sense that it does not have a row in the + * authenticateduser table. It exists so when a Private URL is enabled for a + * dataset, we can assign a read-only role ("member") to the identifier for the + * PrivateUrlUser. (We will make no attempt to internationalize the identifier, + * which is stored in the roleassignment table.) + */ +public class PrivateUrlUser implements User { + + public static final String PREFIX = ":privateUrl"; + + /** + * In the future, this could probably be dvObjectId rather than datasetId, + * if necessary. It's really just roleAssignment.getDefinitionPoint(), which + * is a DvObject. + */ + private final long datasetId; + + public PrivateUrlUser(long datasetId) { + this.datasetId = datasetId; + } + + public long getDatasetId() { + return datasetId; + } + + /** + * @return By always returning false for isAuthenticated(), we prevent a + * name from appearing in the corner as well as preventing an account page + * and MyData from being accessible. The user can still navigate to the home + * page but can only see published datasets. + */ + @Override + public boolean isAuthenticated() { + return false; + } + + @Override + public boolean isBuiltInUser() { + return false; + } + + @Override + public boolean isSuperuser() { + return false; + } + + @Override + public String getIdentifier() { + return PREFIX + datasetId; + } + + @Override + public RoleAssigneeDisplayInfo getDisplayInfo() { + String title = BundleUtil.getStringFromBundle("dataset.privateurl.roleassigeeTitle"); + return new RoleAssigneeDisplayInfo(title, null); + } + +} diff --git a/src/main/java/edu/harvard/iq/dataverse/engine/command/CommandContext.java b/src/main/java/edu/harvard/iq/dataverse/engine/command/CommandContext.java index 2bf5e719c5f..d08d51d906f 100644 --- a/src/main/java/edu/harvard/iq/dataverse/engine/command/CommandContext.java +++ b/src/main/java/edu/harvard/iq/dataverse/engine/command/CommandContext.java @@ -25,9 +25,11 @@ import edu.harvard.iq.dataverse.authorization.AuthenticationServiceBean; import edu.harvard.iq.dataverse.authorization.groups.impl.explicit.ExplicitGroupServiceBean; import edu.harvard.iq.dataverse.engine.DataverseEngine; +import edu.harvard.iq.dataverse.privateurl.PrivateUrlServiceBean; import edu.harvard.iq.dataverse.search.SolrIndexServiceBean; import edu.harvard.iq.dataverse.search.savedsearch.SavedSearchServiceBean; import edu.harvard.iq.dataverse.settings.SettingsServiceBean; +import edu.harvard.iq.dataverse.util.SystemConfig; import javax.persistence.EntityManager; /** @@ -95,4 +97,8 @@ public interface CommandContext { public UserNotificationServiceBean notifications(); public AuthenticationServiceBean authentication(); + + public SystemConfig systemConfig(); + + public PrivateUrlServiceBean privateUrl(); } diff --git a/src/main/java/edu/harvard/iq/dataverse/engine/command/impl/AssignRoleCommand.java b/src/main/java/edu/harvard/iq/dataverse/engine/command/impl/AssignRoleCommand.java index e02eb7d01be..767bee92619 100644 --- a/src/main/java/edu/harvard/iq/dataverse/engine/command/impl/AssignRoleCommand.java +++ b/src/main/java/edu/harvard/iq/dataverse/engine/command/impl/AssignRoleCommand.java @@ -29,25 +29,28 @@ public class AssignRoleCommand extends AbstractCommand { private final DataverseRole role; private final RoleAssignee grantee; private final DvObject defPoint; + private final String privateUrlToken; /** * @param anAssignee The user being granted the role * @param aRole the role being granted to the user * @param assignmentPoint the dataverse on which the role is granted. * @param aRequest + * @param privateUrlToken An optional token used by the Private Url feature. */ - public AssignRoleCommand(RoleAssignee anAssignee, DataverseRole aRole, DvObject assignmentPoint, DataverseRequest aRequest) { + public AssignRoleCommand(RoleAssignee anAssignee, DataverseRole aRole, DvObject assignmentPoint, DataverseRequest aRequest, String privateUrlToken) { // for data file check permission on owning dataset super(aRequest, assignmentPoint instanceof DataFile ? assignmentPoint.getOwner() : assignmentPoint); role = aRole; grantee = anAssignee; defPoint = assignmentPoint; + this.privateUrlToken = privateUrlToken; } @Override public RoleAssignment execute(CommandContext ctxt) throws CommandException { // TODO make sure the role is defined on the dataverse. - RoleAssignment roleAssignment = new RoleAssignment(role, grantee, defPoint); + RoleAssignment roleAssignment = new RoleAssignment(role, grantee, defPoint, privateUrlToken); return ctxt.roles().save(roleAssignment); } diff --git a/src/main/java/edu/harvard/iq/dataverse/engine/command/impl/CreateDatasetCommand.java b/src/main/java/edu/harvard/iq/dataverse/engine/command/impl/CreateDatasetCommand.java index 8684338bc7b..8f99e97d64b 100644 --- a/src/main/java/edu/harvard/iq/dataverse/engine/command/impl/CreateDatasetCommand.java +++ b/src/main/java/edu/harvard/iq/dataverse/engine/command/impl/CreateDatasetCommand.java @@ -175,7 +175,8 @@ public Dataset execute(CommandContext ctxt) throws CommandException { logger.log(Level.FINE, "after db update {0}", formatter.format(new Date().getTime())); // set the role to be default contributor role for its dataverse if (importType==null || importType.equals(ImportType.NEW)) { - ctxt.roles().save(new RoleAssignment(savedDataset.getOwner().getDefaultContributorRole(), getRequest().getUser(), savedDataset)); + String privateUrlToken = null; + ctxt.roles().save(new RoleAssignment(savedDataset.getOwner().getDefaultContributorRole(), getRequest().getUser(), savedDataset, privateUrlToken)); } savedDataset.setPermissionModificationTime(new Timestamp(new Date().getTime())); diff --git a/src/main/java/edu/harvard/iq/dataverse/engine/command/impl/CreateDataverseCommand.java b/src/main/java/edu/harvard/iq/dataverse/engine/command/impl/CreateDataverseCommand.java index c3234d2e1a8..612953623c7 100644 --- a/src/main/java/edu/harvard/iq/dataverse/engine/command/impl/CreateDataverseCommand.java +++ b/src/main/java/edu/harvard/iq/dataverse/engine/command/impl/CreateDataverseCommand.java @@ -86,7 +86,8 @@ public Dataverse execute(CommandContext ctxt) throws CommandException { // Find the built in admin role (currently by alias) DataverseRole adminRole = ctxt.roles().findBuiltinRoleByAlias(DataverseRole.ADMIN); - ctxt.roles().save(new RoleAssignment(adminRole, getRequest().getUser(), managedDv)); + String privateUrlToken = null; + ctxt.roles().save(new RoleAssignment(adminRole, getRequest().getUser(), managedDv, privateUrlToken)); managedDv.setPermissionModificationTime(new Timestamp(new Date().getTime())); managedDv = ctxt.dataverses().save(managedDv); diff --git a/src/main/java/edu/harvard/iq/dataverse/engine/command/impl/CreatePrivateUrlCommand.java b/src/main/java/edu/harvard/iq/dataverse/engine/command/impl/CreatePrivateUrlCommand.java new file mode 100644 index 00000000000..cc1adbc984a --- /dev/null +++ b/src/main/java/edu/harvard/iq/dataverse/engine/command/impl/CreatePrivateUrlCommand.java @@ -0,0 +1,68 @@ +package edu.harvard.iq.dataverse.engine.command.impl; + +import edu.harvard.iq.dataverse.Dataset; +import edu.harvard.iq.dataverse.DatasetVersion; +import edu.harvard.iq.dataverse.RoleAssignment; +import edu.harvard.iq.dataverse.authorization.DataverseRole; +import edu.harvard.iq.dataverse.authorization.Permission; +import edu.harvard.iq.dataverse.authorization.users.PrivateUrlUser; +import edu.harvard.iq.dataverse.engine.command.AbstractCommand; +import edu.harvard.iq.dataverse.engine.command.CommandContext; +import edu.harvard.iq.dataverse.engine.command.DataverseRequest; +import edu.harvard.iq.dataverse.engine.command.RequiredPermissions; +import edu.harvard.iq.dataverse.engine.command.exception.CommandException; +import edu.harvard.iq.dataverse.engine.command.exception.IllegalCommandException; +import edu.harvard.iq.dataverse.privateurl.PrivateUrl; +import java.util.UUID; +import java.util.logging.Logger; + +@RequiredPermissions(Permission.ManageDatasetPermissions) +public class CreatePrivateUrlCommand extends AbstractCommand { + + private static final Logger logger = Logger.getLogger(CreatePrivateUrlCommand.class.getCanonicalName()); + + final Dataset dataset; + + public CreatePrivateUrlCommand(DataverseRequest dataverseRequest, Dataset theDataset) { + super(dataverseRequest, theDataset); + dataset = theDataset; + } + + @Override + public PrivateUrl execute(CommandContext ctxt) throws CommandException { + logger.fine("Executing CreatePrivateUrlCommand..."); + if (dataset == null) { + /** + * @todo Internationalize this. + */ + String message = "Can't create Private URL. Dataset is null."; + logger.info(message); + throw new IllegalCommandException(message, this); + } + PrivateUrl existing = ctxt.privateUrl().getPrivateUrlFromDatasetId(dataset.getId()); + if (existing != null) { + /** + * @todo Internationalize this. + */ + String message = "Private URL already exists for dataset id " + dataset.getId() + "."; + logger.info(message); + throw new IllegalCommandException(message, this); + } + DatasetVersion latestVersion = dataset.getLatestVersion(); + if (!latestVersion.isDraft()) { + /** + * @todo Internationalize this. + */ + String message = "Can't create Private URL because the latest version of dataset id " + dataset.getId() + " is not a draft."; + logger.info(message); + throw new IllegalCommandException(message, this); + } + PrivateUrlUser privateUrlUser = new PrivateUrlUser(dataset.getId()); + DataverseRole memberRole = ctxt.roles().findBuiltinRoleByAlias(DataverseRole.MEMBER); + final String privateUrlToken = UUID.randomUUID().toString(); + RoleAssignment roleAssignment = ctxt.engine().submit(new AssignRoleCommand(privateUrlUser, memberRole, dataset, getRequest(), privateUrlToken)); + PrivateUrl privateUrl = new PrivateUrl(roleAssignment, dataset, ctxt.systemConfig().getDataverseSiteUrl()); + return privateUrl; + } + +} diff --git a/src/main/java/edu/harvard/iq/dataverse/engine/command/impl/DeleteDatasetVersionCommand.java b/src/main/java/edu/harvard/iq/dataverse/engine/command/impl/DeleteDatasetVersionCommand.java index c2bbbefac86..353766a2994 100644 --- a/src/main/java/edu/harvard/iq/dataverse/engine/command/impl/DeleteDatasetVersionCommand.java +++ b/src/main/java/edu/harvard/iq/dataverse/engine/command/impl/DeleteDatasetVersionCommand.java @@ -11,7 +11,9 @@ import edu.harvard.iq.dataverse.engine.command.RequiredPermissions; import edu.harvard.iq.dataverse.engine.command.exception.CommandException; import edu.harvard.iq.dataverse.engine.command.exception.IllegalCommandException; +import edu.harvard.iq.dataverse.privateurl.PrivateUrl; import java.util.Iterator; +import java.util.logging.Logger; /** * @@ -20,6 +22,8 @@ @RequiredPermissions(Permission.DeleteDatasetDraft) public class DeleteDatasetVersionCommand extends AbstractVoidCommand { + private static final Logger logger = Logger.getLogger(DeleteDatasetVersionCommand.class.getCanonicalName()); + private final Dataset doomed; public DeleteDatasetVersionCommand(DataverseRequest aRequest, Dataset dataset) { @@ -64,6 +68,11 @@ protected void executeImpl(CommandContext ctxt) throws CommandException { dvIt.remove(); } } + PrivateUrl privateUrl = ctxt.engine().submit(new GetPrivateUrlCommand(getRequest(), doomed)); + if (privateUrl != null) { + logger.fine("Deleting Private URL for dataset id " + doomed.getId()); + ctxt.engine().submit(new DeletePrivateUrlCommand(getRequest(), doomed)); + } boolean doNormalSolrDocCleanUp = true; ctxt.index().indexDataset(doomed, doNormalSolrDocCleanUp); return; diff --git a/src/main/java/edu/harvard/iq/dataverse/engine/command/impl/DeletePrivateUrlCommand.java b/src/main/java/edu/harvard/iq/dataverse/engine/command/impl/DeletePrivateUrlCommand.java new file mode 100644 index 00000000000..4715100ad8f --- /dev/null +++ b/src/main/java/edu/harvard/iq/dataverse/engine/command/impl/DeletePrivateUrlCommand.java @@ -0,0 +1,50 @@ +package edu.harvard.iq.dataverse.engine.command.impl; + +import edu.harvard.iq.dataverse.Dataset; +import edu.harvard.iq.dataverse.RoleAssignment; +import edu.harvard.iq.dataverse.authorization.Permission; +import edu.harvard.iq.dataverse.authorization.users.PrivateUrlUser; +import edu.harvard.iq.dataverse.engine.command.AbstractVoidCommand; +import edu.harvard.iq.dataverse.engine.command.CommandContext; +import edu.harvard.iq.dataverse.engine.command.DataverseRequest; +import edu.harvard.iq.dataverse.engine.command.RequiredPermissions; +import edu.harvard.iq.dataverse.engine.command.exception.CommandException; +import edu.harvard.iq.dataverse.engine.command.exception.IllegalCommandException; +import java.util.List; +import java.util.logging.Logger; + +/** + * @todo If RevokeRoleCommand ever returns anything other than void (a boolean + * perhaps) pass that value upstream. + */ +@RequiredPermissions(Permission.ManageDatasetPermissions) +public class DeletePrivateUrlCommand extends AbstractVoidCommand { + + private static final Logger logger = Logger.getLogger(DeletePrivateUrlCommand.class.getCanonicalName()); + + final Dataset dataset; + + public DeletePrivateUrlCommand(DataverseRequest aRequest, Dataset theDataset) { + super(aRequest, theDataset); + dataset = theDataset; + } + + @Override + protected void executeImpl(CommandContext ctxt) throws CommandException { + logger.fine("Executing DeletePrivateUrlCommand...."); + if (dataset == null) { + /** + * @todo Internationalize this. + */ + String message = "Can't delete Private URL. Dataset is null."; + logger.info(message); + throw new IllegalCommandException(message, this); + } + PrivateUrlUser privateUrlUser = new PrivateUrlUser(dataset.getId()); + List roleAssignments = ctxt.roles().directRoleAssignments(privateUrlUser, dataset); + for (RoleAssignment roleAssignment : roleAssignments) { + ctxt.engine().submit(new RevokeRoleCommand(roleAssignment, getRequest())); + } + } + +} diff --git a/src/main/java/edu/harvard/iq/dataverse/engine/command/impl/GetPrivateUrlCommand.java b/src/main/java/edu/harvard/iq/dataverse/engine/command/impl/GetPrivateUrlCommand.java new file mode 100644 index 00000000000..5e698dcf1b9 --- /dev/null +++ b/src/main/java/edu/harvard/iq/dataverse/engine/command/impl/GetPrivateUrlCommand.java @@ -0,0 +1,36 @@ +package edu.harvard.iq.dataverse.engine.command.impl; + +import edu.harvard.iq.dataverse.Dataset; +import edu.harvard.iq.dataverse.authorization.Permission; +import edu.harvard.iq.dataverse.engine.command.AbstractCommand; +import edu.harvard.iq.dataverse.engine.command.CommandContext; +import edu.harvard.iq.dataverse.engine.command.DataverseRequest; +import edu.harvard.iq.dataverse.engine.command.RequiredPermissions; +import edu.harvard.iq.dataverse.engine.command.exception.CommandException; +import edu.harvard.iq.dataverse.privateurl.PrivateUrl; +import java.util.logging.Logger; + +@RequiredPermissions(Permission.ManageDatasetPermissions) +public class GetPrivateUrlCommand extends AbstractCommand { + + private static final Logger logger = Logger.getLogger(GetPrivateUrlCommand.class.getCanonicalName()); + + private final Dataset dataset; + + public GetPrivateUrlCommand(DataverseRequest aRequest, Dataset theDataset) { + super(aRequest, theDataset); + dataset = theDataset; + } + + @Override + public PrivateUrl execute(CommandContext ctxt) throws CommandException { + logger.fine("GetPrivateUrlCommand called"); + Long datasetId = dataset.getId(); + if (datasetId == null) { + // Perhaps a dataset is being created in the GUI. + return null; + } + return ctxt.privateUrl().getPrivateUrlFromDatasetId(datasetId); + } + +} diff --git a/src/main/java/edu/harvard/iq/dataverse/engine/command/impl/ListRoleAssignments.java b/src/main/java/edu/harvard/iq/dataverse/engine/command/impl/ListRoleAssignments.java index b5493b1e024..ed438bc3815 100644 --- a/src/main/java/edu/harvard/iq/dataverse/engine/command/impl/ListRoleAssignments.java +++ b/src/main/java/edu/harvard/iq/dataverse/engine/command/impl/ListRoleAssignments.java @@ -1,6 +1,6 @@ package edu.harvard.iq.dataverse.engine.command.impl; -import edu.harvard.iq.dataverse.Dataverse; +import edu.harvard.iq.dataverse.DvObject; import edu.harvard.iq.dataverse.RoleAssignment; import edu.harvard.iq.dataverse.authorization.Permission; import edu.harvard.iq.dataverse.engine.command.AbstractCommand; @@ -14,12 +14,11 @@ * * @author michael */ -//@todo should this command exist for other dvObjects @RequiredPermissions( Permission.ManageDataversePermissions ) public class ListRoleAssignments extends AbstractCommand> { - private final Dataverse definitionPoint; - public ListRoleAssignments(DataverseRequest aRequest, Dataverse aDefinitionPoint) { + private final DvObject definitionPoint; + public ListRoleAssignments(DataverseRequest aRequest, DvObject aDefinitionPoint) { super(aRequest, aDefinitionPoint); definitionPoint = aDefinitionPoint; } diff --git a/src/main/java/edu/harvard/iq/dataverse/engine/command/impl/PublishDatasetCommand.java b/src/main/java/edu/harvard/iq/dataverse/engine/command/impl/PublishDatasetCommand.java index 36792f20054..0d33fcbbfb6 100644 --- a/src/main/java/edu/harvard/iq/dataverse/engine/command/impl/PublishDatasetCommand.java +++ b/src/main/java/edu/harvard/iq/dataverse/engine/command/impl/PublishDatasetCommand.java @@ -22,6 +22,7 @@ import edu.harvard.iq.dataverse.engine.command.RequiredPermissions; import edu.harvard.iq.dataverse.engine.command.exception.CommandException; import edu.harvard.iq.dataverse.engine.command.exception.IllegalCommandException; +import edu.harvard.iq.dataverse.privateurl.PrivateUrl; import edu.harvard.iq.dataverse.search.IndexResponse; import edu.harvard.iq.dataverse.settings.SettingsServiceBean; import java.io.IOException; @@ -29,6 +30,7 @@ import java.util.Date; import java.util.List; import java.util.ResourceBundle; +import java.util.logging.Logger; /** * @@ -37,6 +39,8 @@ @RequiredPermissions(Permission.PublishDataset) public class PublishDatasetCommand extends AbstractCommand { + private static final Logger logger = Logger.getLogger(PublishDatasetCommand.class.getCanonicalName()); + boolean minorRelease = false; Dataset theDataset; @@ -228,6 +232,12 @@ public Dataset execute(CommandContext ctxt) throws CommandException { } } + PrivateUrl privateUrl = ctxt.engine().submit(new GetPrivateUrlCommand(getRequest(), savedDataset)); + if (privateUrl != null) { + logger.fine("Deleting Private URL for dataset id " + savedDataset.getId()); + ctxt.engine().submit(new DeletePrivateUrlCommand(getRequest(), savedDataset)); + } + /* MoveIndexing to after DOI update so that if command exception is thrown the re-index will not diff --git a/src/main/java/edu/harvard/iq/dataverse/privateurl/PrivateUrl.java b/src/main/java/edu/harvard/iq/dataverse/privateurl/PrivateUrl.java new file mode 100644 index 00000000000..ff3a14ec72e --- /dev/null +++ b/src/main/java/edu/harvard/iq/dataverse/privateurl/PrivateUrl.java @@ -0,0 +1,54 @@ +package edu.harvard.iq.dataverse.privateurl; + +import edu.harvard.iq.dataverse.Dataset; +import edu.harvard.iq.dataverse.RoleAssignment; + +/** + * Dataset authors can create and send a Private URL to a reviewer to see the + * lasted draft of their dataset (even if the dataset has never been published) + * without having to create an account. When the dataset is published, the + * Private URL is deleted. + */ +public class PrivateUrl { + + private final Dataset dataset; + private final RoleAssignment roleAssignment; + /** + * The unique string of characters in the Private URL that associates it + * (the link) with a particular dataset. + * + * The token is also available at roleAssignment.getPrivateUrlToken(). + */ + private final String token; + /** + * This is the link that the reviewer will click. + * + * @todo This link should probably be some sort of URL object rather than a + * String. + */ + private final String link; + + public PrivateUrl(RoleAssignment roleAssignment, Dataset dataset, String dataverseSiteUrl) { + this.token = roleAssignment.getPrivateUrlToken(); + this.link = dataverseSiteUrl + "/privateurl.xhtml?token=" + token; + this.dataset = dataset; + this.roleAssignment = roleAssignment; + } + + public Dataset getDataset() { + return dataset; + } + + public RoleAssignment getRoleAssignment() { + return roleAssignment; + } + + public String getToken() { + return token; + } + + public String getLink() { + return link; + } + +} diff --git a/src/main/java/edu/harvard/iq/dataverse/privateurl/PrivateUrlPage.java b/src/main/java/edu/harvard/iq/dataverse/privateurl/PrivateUrlPage.java new file mode 100644 index 00000000000..fc28f6ed6a8 --- /dev/null +++ b/src/main/java/edu/harvard/iq/dataverse/privateurl/PrivateUrlPage.java @@ -0,0 +1,51 @@ +package edu.harvard.iq.dataverse.privateurl; + +import edu.harvard.iq.dataverse.DataverseSession; +import edu.harvard.iq.dataverse.authorization.users.PrivateUrlUser; +import java.io.Serializable; +import java.util.logging.Logger; +import javax.ejb.EJB; +import javax.faces.view.ViewScoped; +import javax.inject.Inject; +import javax.inject.Named; + +@ViewScoped +@Named("PrivateUrlPage") +public class PrivateUrlPage implements Serializable { + + private static final Logger logger = Logger.getLogger(PrivateUrlPage.class.getCanonicalName()); + + @EJB + PrivateUrlServiceBean privateUrlService; + @Inject + DataverseSession session; + + /** + * The unique string used to look up a PrivateUrlUser and the associated + * draft dataset version to redirect the user to. + */ + String token; + + public String init() { + try { + PrivateUrlRedirectData privateUrlRedirectData = privateUrlService.getPrivateUrlRedirectDataFromToken(token); + String draftDatasetPageToBeRedirectedTo = privateUrlRedirectData.getDraftDatasetPageToBeRedirectedTo() + "&faces-redirect=true"; + PrivateUrlUser privateUrlUser = privateUrlRedirectData.getPrivateUrlUser(); + session.setUser(privateUrlUser); + logger.info("Redirecting PrivateUrlUser '" + privateUrlUser.getIdentifier() + "' to " + draftDatasetPageToBeRedirectedTo); + return draftDatasetPageToBeRedirectedTo; + } catch (Exception ex) { + logger.info("Exception processing Private URL token '" + token + "':" + ex); + return "/404.xhtml"; + } + } + + public String getToken() { + return token; + } + + public void setToken(String token) { + this.token = token; + } + +} diff --git a/src/main/java/edu/harvard/iq/dataverse/privateurl/PrivateUrlRedirectData.java b/src/main/java/edu/harvard/iq/dataverse/privateurl/PrivateUrlRedirectData.java new file mode 100644 index 00000000000..cd891a99c21 --- /dev/null +++ b/src/main/java/edu/harvard/iq/dataverse/privateurl/PrivateUrlRedirectData.java @@ -0,0 +1,39 @@ +package edu.harvard.iq.dataverse.privateurl; + +import edu.harvard.iq.dataverse.authorization.users.PrivateUrlUser; + +/** + * PrivateUrlRedirectData is for the person clicking the Private URL link, who + * is often a reviewer. In a browser, we need to set the session to the + * PrivateUrlUser (who has ViewUnpublishedDataset and related permission on the + * dataset and then redirect that user to the draft version of the dataset. + */ +public class PrivateUrlRedirectData { + + private final PrivateUrlUser privateUrlUser; + private final String draftDatasetPageToBeRedirectedTo; + + /** + * @throws java.lang.Exception The reason why a PrivateUrlRedirectData + * object could not be instantiated. + */ + public PrivateUrlRedirectData(PrivateUrlUser privateUrlUser, String draftDatasetPageToBeRedirectedTo) throws Exception { + if (privateUrlUser == null) { + throw new Exception("PrivateUrlUser cannot be null"); + } + if (draftDatasetPageToBeRedirectedTo == null) { + throw new Exception("draftDatasetPageToBeRedirectedTo cannot be null"); + } + this.privateUrlUser = privateUrlUser; + this.draftDatasetPageToBeRedirectedTo = draftDatasetPageToBeRedirectedTo; + } + + public PrivateUrlUser getPrivateUrlUser() { + return privateUrlUser; + } + + public String getDraftDatasetPageToBeRedirectedTo() { + return draftDatasetPageToBeRedirectedTo; + } + +} diff --git a/src/main/java/edu/harvard/iq/dataverse/privateurl/PrivateUrlServiceBean.java b/src/main/java/edu/harvard/iq/dataverse/privateurl/PrivateUrlServiceBean.java new file mode 100644 index 00000000000..efe64052c4a --- /dev/null +++ b/src/main/java/edu/harvard/iq/dataverse/privateurl/PrivateUrlServiceBean.java @@ -0,0 +1,108 @@ +package edu.harvard.iq.dataverse.privateurl; + +import edu.harvard.iq.dataverse.Dataset; +import edu.harvard.iq.dataverse.DatasetServiceBean; +import edu.harvard.iq.dataverse.RoleAssignment; +import edu.harvard.iq.dataverse.authorization.users.PrivateUrlUser; +import edu.harvard.iq.dataverse.util.SystemConfig; +import java.io.Serializable; +import java.util.logging.Logger; +import javax.ejb.EJB; +import javax.ejb.Stateless; +import javax.inject.Named; +import javax.persistence.EntityManager; +import javax.persistence.NoResultException; +import javax.persistence.NonUniqueResultException; +import javax.persistence.PersistenceContext; +import javax.persistence.TypedQuery; + +/** + * + * PrivateUrlServiceBean depends on Glassfish and Postgres being available and + * it is tested with API tests in DatasetIT. Code that can execute without any + * runtime dependencies should be put in PrivateUrlUtil so it can be unit + * tested. + */ +@Stateless +@Named +public class PrivateUrlServiceBean implements Serializable { + + private static final Logger logger = Logger.getLogger(PrivateUrlServiceBean.class.getCanonicalName()); + + @PersistenceContext(unitName = "VDCNet-ejbPU") + private EntityManager em; + + @EJB + DatasetServiceBean datasetServiceBean; + + @EJB + SystemConfig systemConfig; + + /** + * @return A PrivateUrl if the dataset has one or null. + */ + public PrivateUrl getPrivateUrlFromDatasetId(long datasetId) { + RoleAssignment roleAssignment = getPrivateUrlRoleAssignmentFromDataset(datasetServiceBean.find(datasetId)); + return PrivateUrlUtil.getPrivateUrlFromRoleAssignment(roleAssignment, systemConfig.getDataverseSiteUrl()); + } + + /** + * @return A PrivateUrlUser if one can be found using the token or null. + */ + public PrivateUrlUser getPrivateUrlUserFromToken(String token) { + return PrivateUrlUtil.getPrivateUrlUserFromRoleAssignment(getRoleAssignmentFromPrivateUrlToken(token)); + } + + /** + * @return PrivateUrlRedirectData if it can be found using the token or + * null. + */ + public PrivateUrlRedirectData getPrivateUrlRedirectDataFromToken(String token) { + return PrivateUrlUtil.getPrivateUrlRedirectData(getRoleAssignmentFromPrivateUrlToken(token)); + } + + /** + * @return A RoleAssignment or null. + * + * @todo This might be a good place for Optional. + */ + private RoleAssignment getRoleAssignmentFromPrivateUrlToken(String privateUrlToken) { + if (privateUrlToken == null) { + return null; + } + TypedQuery query = em.createNamedQuery( + "RoleAssignment.listByPrivateUrlToken", + RoleAssignment.class); + query.setParameter("privateUrlToken", privateUrlToken); + try { + RoleAssignment roleAssignment = query.getSingleResult(); + return roleAssignment; + } catch (NoResultException | NonUniqueResultException ex) { + return null; + } + } + + /** + * @param dataset A non-null dataset; + * @return A role assignment for a Private URL, if found, or null. + * + * @todo This might be a good place for Optional. + */ + private RoleAssignment getPrivateUrlRoleAssignmentFromDataset(Dataset dataset) { + if (dataset == null) { + return null; + } + TypedQuery query = em.createNamedQuery( + "RoleAssignment.listByAssigneeIdentifier_DefinitionPointId", + RoleAssignment.class); + PrivateUrlUser privateUrlUser = new PrivateUrlUser(dataset.getId()); + query.setParameter("assigneeIdentifier", privateUrlUser.getIdentifier()); + query.setParameter("definitionPointId", dataset.getId()); + try { + return query.getSingleResult(); + } catch (NoResultException | NonUniqueResultException ex) { + return null; + } + } + +} diff --git a/src/main/java/edu/harvard/iq/dataverse/privateurl/PrivateUrlUtil.java b/src/main/java/edu/harvard/iq/dataverse/privateurl/PrivateUrlUtil.java new file mode 100644 index 00000000000..15a5bdd1993 --- /dev/null +++ b/src/main/java/edu/harvard/iq/dataverse/privateurl/PrivateUrlUtil.java @@ -0,0 +1,198 @@ +package edu.harvard.iq.dataverse.privateurl; + +import edu.harvard.iq.dataverse.Dataset; +import edu.harvard.iq.dataverse.DatasetVersion; +import edu.harvard.iq.dataverse.DvObject; +import edu.harvard.iq.dataverse.RoleAssignment; +import edu.harvard.iq.dataverse.authorization.Permission; +import edu.harvard.iq.dataverse.authorization.RoleAssignee; +import edu.harvard.iq.dataverse.authorization.users.PrivateUrlUser; +import edu.harvard.iq.dataverse.engine.command.exception.CommandException; +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.logging.Logger; + +public class PrivateUrlUtil { + + private static final Logger logger = Logger.getLogger(PrivateUrlUtil.class.getCanonicalName()); + + /** + * Use of this method should be limited to + * RoleAssigneeServiceBean.getRoleAssignee, which is the centralized place + * to return a RoleAssignee (which can be either a User or a Group) when all + * you have is the string that is their identifier. + * + * @todo Consider using a new character, something other than ":" as a + * namespace for PrivateUrlUser rather than ":" which is for a short list of + * unchanging "predefinedRoleAssignees" which consists of + * :authenticated-users, :AllUsers, and :guest. A PrivateUrlUser is + * something of a different animal in that its identifier will vary based on + * the dataset that it is associated with. The number at the end of the + * identifier will vary. + * + * @param identifier The identifier is expected to start with the + * PrivateUrlUser.PREFIX and end with a number for a dataset, + * ":privateUrl42", for example. The ":" indicates that this is a User + * rather than a Group (groups start with "&"). The number at the end of the + * identifier of a PrivateUrlUser is all we have to associate the role + * assignee identifier with a dataset. If we had the role assignment itself + * in our hands, we would simply get the dataset id from + * RoleAssignment.getDefinitionPoint and then use it to instantiate a + * PrivateUrlUser. + * + * @return A valid PrivateUrlUser (which like any User or Group is a + * RoleAssignee) if a valid identifer is provided or null. + */ + public static RoleAssignee identifier2roleAssignee(String identifier) { + String[] parts = identifier.split(PrivateUrlUser.PREFIX); + long datasetId; + try { + datasetId = new Long(parts[1]); + } catch (ArrayIndexOutOfBoundsException | NumberFormatException ex) { + logger.fine("Could not find dataset id in '" + identifier + "': " + ex); + return null; + } + return new PrivateUrlUser(datasetId); + } + + /** + * @todo If there is a use case for this outside the context of Private URL, + * move this method to somewhere more centralized. + */ + static Dataset getDatasetFromRoleAssignment(RoleAssignment roleAssignment) { + if (roleAssignment == null) { + return null; + } + DvObject dvObject = roleAssignment.getDefinitionPoint(); + if (dvObject == null) { + return null; + } + if (dvObject instanceof Dataset) { + return (Dataset) roleAssignment.getDefinitionPoint(); + } else { + return null; + } + } + + /** + * @return DatasetVersion if a draft or null. + * + * @todo If there is a use case for this outside the context of Private URL, + * move this method to somewhere more centralized. + */ + static public DatasetVersion getDraftDatasetVersionFromRoleAssignment(RoleAssignment roleAssignment) { + if (roleAssignment == null) { + return null; + } + Dataset dataset = getDatasetFromRoleAssignment(roleAssignment); + if (dataset != null) { + DatasetVersion latestVersion = dataset.getLatestVersion(); + if (latestVersion.isDraft()) { + return latestVersion; + } + } + logger.fine("Couldn't find draft, returning null"); + return null; + } + + static public PrivateUrlUser getPrivateUrlUserFromRoleAssignment(RoleAssignment roleAssignment) { + if (roleAssignment == null) { + return null; + } + Dataset dataset = getDatasetFromRoleAssignment(roleAssignment); + if (dataset != null) { + PrivateUrlUser privateUrlUser = new PrivateUrlUser(dataset.getId()); + return privateUrlUser; + } + return null; + } + + /** + * @return PrivateUrlRedirectData or null. + * + * @todo Show the Exception to the user? + */ + public static PrivateUrlRedirectData getPrivateUrlRedirectData(RoleAssignment roleAssignment) { + PrivateUrlUser privateUrlUser = PrivateUrlUtil.getPrivateUrlUserFromRoleAssignment(roleAssignment); + String draftDatasetPageToBeRedirectedTo = PrivateUrlUtil.getDraftDatasetPageToBeRedirectedTo(roleAssignment); + try { + return new PrivateUrlRedirectData(privateUrlUser, draftDatasetPageToBeRedirectedTo); + } catch (Exception ex) { + logger.info("Exception caught trying to instantiate PrivateUrlRedirectData: " + ex); + return null; + } + } + + /** + * Returns a relative URL or "UNKNOWN." + */ + static String getDraftDatasetPageToBeRedirectedTo(RoleAssignment roleAssignment) { + DatasetVersion datasetVersion = getDraftDatasetVersionFromRoleAssignment(roleAssignment); + return getDraftUrl(datasetVersion); + } + + /** + * Returns a relative URL or "UNKNOWN." + */ + static String getDraftUrl(DatasetVersion draft) { + if (draft != null) { + Dataset dataset = draft.getDataset(); + if (dataset != null) { + String persistentId = dataset.getGlobalId(); + /** + * @todo Investigate why dataset.getGlobalId() yields the String + * "null:null/null" when I expect null value. This smells like a + * bug. + */ + if (!"null:null/null".equals(persistentId)) { + String relativeUrl = "/dataset.xhtml?persistentId=" + persistentId + "&version=DRAFT"; + return relativeUrl; + } + } + } + return "UNKNOWN"; + } + + static PrivateUrl getPrivateUrlFromRoleAssignment(RoleAssignment roleAssignment, String dataverseSiteUrl) { + if (dataverseSiteUrl == null) { + logger.info("dataverseSiteUrl was null. Can not instantiate a PrivateUrl object."); + return null; + } + Dataset dataset = PrivateUrlUtil.getDatasetFromRoleAssignment(roleAssignment); + if (dataset != null) { + PrivateUrl privateUrl = new PrivateUrl(roleAssignment, dataset, dataverseSiteUrl); + return privateUrl; + } else { + return null; + } + } + + static PrivateUrlUser getPrivateUrlUserFromRoleAssignment(RoleAssignment roleAssignment, RoleAssignee roleAssignee) { + if (roleAssignment != null) { + if (roleAssignee instanceof PrivateUrlUser) { + return (PrivateUrlUser) roleAssignee; + } + } + return null; + } + + /** + * @return A list of the CamelCase "names" of required permissions, not the + * human-readable equivalents. + * + * @todo Move this to somewhere more central. + */ + public static List getRequiredPermissions(CommandException ex) { + List stringsToReturn = new ArrayList<>(); + Map> map = ex.getFailedCommand().getRequiredPermissions(); + map.entrySet().stream().forEach((entry) -> { + entry.getValue().stream().forEach((permission) -> { + stringsToReturn.add(permission.name()); + }); + }); + return stringsToReturn; + } + +} diff --git a/src/main/java/edu/harvard/iq/dataverse/search/SearchServiceBean.java b/src/main/java/edu/harvard/iq/dataverse/search/SearchServiceBean.java index 94a28859f18..0d254ad068c 100644 --- a/src/main/java/edu/harvard/iq/dataverse/search/SearchServiceBean.java +++ b/src/main/java/edu/harvard/iq/dataverse/search/SearchServiceBean.java @@ -14,6 +14,7 @@ import edu.harvard.iq.dataverse.authorization.groups.Group; import edu.harvard.iq.dataverse.authorization.groups.GroupServiceBean; import edu.harvard.iq.dataverse.authorization.users.AuthenticatedUser; +import edu.harvard.iq.dataverse.authorization.users.PrivateUrlUser; import edu.harvard.iq.dataverse.authorization.users.GuestUser; import edu.harvard.iq.dataverse.authorization.users.User; import edu.harvard.iq.dataverse.util.JsfHelper; @@ -748,6 +749,10 @@ private String getPermissionFilterQuery(User user, SolrQuery solrQuery, Datavers // initialize to public only to be safe String dangerZoneNoSolrJoin = null; + if (user instanceof PrivateUrlUser) { + user = GuestUser.get(); + } + // ---------------------------------------------------- // (1) Is this a GuestUser? // Yes, all set, give back "publicOnly" filter string diff --git a/src/main/java/edu/harvard/iq/dataverse/util/json/JsonPrinter.java b/src/main/java/edu/harvard/iq/dataverse/util/json/JsonPrinter.java index 57d9e7d8d5e..ccd56173edf 100644 --- a/src/main/java/edu/harvard/iq/dataverse/util/json/JsonPrinter.java +++ b/src/main/java/edu/harvard/iq/dataverse/util/json/JsonPrinter.java @@ -27,6 +27,7 @@ import edu.harvard.iq.dataverse.authorization.providers.AuthenticationProviderRow; import edu.harvard.iq.dataverse.authorization.users.AuthenticatedUser; import edu.harvard.iq.dataverse.authorization.users.User; +import edu.harvard.iq.dataverse.privateurl.PrivateUrl; import edu.harvard.iq.dataverse.util.DatasetFieldWalker; import java.util.Set; import javax.json.Json; @@ -88,6 +89,7 @@ public static JsonObjectBuilder json( RoleAssignment ra ) { .add("assignee", ra.getAssigneeIdentifier() ) .add("roleId", ra.getRole().getId() ) .add("_roleAlias", ra.getRole().getAlias()) + .add("privateUrlToken", ra.getPrivateUrlToken()) .add("definitionPointId", ra.getDefinitionPoint().getId() ); } @@ -472,7 +474,15 @@ public static JsonObjectBuilder json( AuthenticationProviderRow aRow ) { .add("enabled", aRow.isEnabled()) ; } - + + public static JsonObjectBuilder json(PrivateUrl privateUrl) { + return jsonObjectBuilder() + // We provide the token here as a convenience even though it is also in the role assignment. + .add("token", privateUrl.getToken()) + .add("link", privateUrl.getLink()) + .add("roleAssignment", json(privateUrl.getRoleAssignment())); + } + public static JsonObjectBuilder json(T j ) { if (j instanceof ExplicitGroup) { ExplicitGroup eg = (ExplicitGroup) j; diff --git a/src/main/webapp/dataset.xhtml b/src/main/webapp/dataset.xhtml index 0642a52fe21..228f7984657 100755 --- a/src/main/webapp/dataset.xhtml +++ b/src/main/webapp/dataset.xhtml @@ -85,6 +85,11 @@ +