Skip to content

Commit 50b1ddc

Browse files
authored
Merge pull request #1312 from jjones287/feature/encounter-export
Add backend endpoint for exporting ZIP files of photographs
2 parents 3612f16 + 6852f75 commit 50b1ddc

File tree

16 files changed

+1785
-502
lines changed

16 files changed

+1785
-502
lines changed

.husky/java/pre-commit-uncrustify

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -39,7 +39,9 @@ fi
3939
# NOTE: just hard-coding this rather than using $CONFIG, cuz it was
4040
# not working with relative path
4141
UNCRUST_CONFIG="$SCRIPTPATH/uncrustify-style.cfg"
42-
UNCRUSTIFY="$SCRIPTPATH/uncrustify"
42+
43+
# If the user has uncrustify on the PATH use that, otherwise use the one in the repo
44+
UNCRUSTIFY=$(command -v uncrustify || echo "$SCRIPTPATH/uncrustify")
4345

4446
# check whether the given file matches any of the set extensions
4547
matches_extension() {

pom.xml

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,9 @@
1818
<slf4j.version>1.7.12</slf4j.version>
1919
<junit.version>5.11.0</junit.version>
2020
<mockito.version>4.11.0</mockito.version>
21+
<testcontainers.version>1.19.3</testcontainers.version>
22+
<rest-assured.version>5.4.0</rest-assured.version>
23+
<wiremock.version>3.3.1</wiremock.version>
2124
</properties>
2225

2326
<repositories>
@@ -403,6 +406,56 @@
403406
<scope>test</scope>
404407
</dependency>
405408

409+
<!-- Testcontainers for integration testing -->
410+
<dependency>
411+
<groupId>org.testcontainers</groupId>
412+
<artifactId>testcontainers</artifactId>
413+
<version>${testcontainers.version}</version>
414+
<scope>test</scope>
415+
</dependency>
416+
<dependency>
417+
<groupId>org.testcontainers</groupId>
418+
<artifactId>postgresql</artifactId>
419+
<version>${testcontainers.version}</version>
420+
<scope>test</scope>
421+
</dependency>
422+
<dependency>
423+
<groupId>org.testcontainers</groupId>
424+
<artifactId>junit-jupiter</artifactId>
425+
<version>${testcontainers.version}</version>
426+
<scope>test</scope>
427+
</dependency>
428+
429+
<!-- REST Assured for API testing -->
430+
<dependency>
431+
<groupId>io.rest-assured</groupId>
432+
<artifactId>rest-assured</artifactId>
433+
<version>${rest-assured.version}</version>
434+
<scope>test</scope>
435+
</dependency>
436+
437+
<dependency>
438+
<groupId>org.eclipse.jetty</groupId>
439+
<artifactId>jetty-server</artifactId>
440+
<version>9.4.58.v20250814</version>
441+
<scope>test</scope>
442+
</dependency>
443+
<dependency>
444+
<groupId>org.eclipse.jetty</groupId>
445+
<artifactId>jetty-servlet</artifactId>
446+
<version>9.4.58.v20250814</version>
447+
<scope>test</scope>
448+
</dependency>
449+
450+
<!-- Apache Commons CSV for proper CSV parsing in tests -->
451+
<dependency>
452+
<groupId>org.apache.commons</groupId>
453+
<artifactId>commons-csv</artifactId>
454+
<version>1.11.0</version>
455+
<scope>test</scope>
456+
</dependency>
457+
458+
406459
<dependency>
407460
<groupId>org.geotools</groupId>
408461
<artifactId>gt-shapefile</artifactId>

src/main/java/org/ecocean/Annotation.java

Lines changed: 6 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -5,14 +5,7 @@
55
import java.awt.Rectangle;
66
import java.io.File;
77
import java.io.IOException;
8-
import java.util.ArrayList;
9-
import java.util.Arrays;
10-
import java.util.Collection;
11-
import java.util.Collections;
12-
import java.util.Iterator;
13-
import java.util.List;
14-
import java.util.Set;
15-
import java.util.TreeSet;
8+
import java.util.*;
169
import javax.jdo.Query;
1710
import javax.servlet.http.HttpServletRequest;
1811
import org.apache.commons.codec.digest.DigestUtils;
@@ -651,7 +644,9 @@ public void setIdentificationStatus(String status) {
651644

652645
// if this cannot determine a bounding box, then we return null
653646
public int[] getBbox() {
654-
if (getMediaAsset() == null) return null;
647+
MediaAsset ma = getMediaAsset();
648+
649+
if (ma == null) return null;
655650
Feature found = null;
656651
for (Feature ft : getFeatures()) {
657652
if (ft.isUnity() || ft.isType("org.ecocean.boundingBox")) {
@@ -664,8 +659,8 @@ public int[] getBbox() {
664659
if (found.isUnity()) {
665660
bbox[0] = 0;
666661
bbox[1] = 0;
667-
bbox[2] = (int)getMediaAsset().getWidth();
668-
bbox[3] = (int)getMediaAsset().getHeight();
662+
bbox[2] = (int)ma.getWidth();
663+
bbox[3] = (int)ma.getHeight();
669664
} else {
670665
// guess we derive from feature!
671666
if (found.getParameters() == null) return null;

src/main/java/org/ecocean/CommonConfiguration.java

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -30,8 +30,13 @@ public class CommonConfiguration {
3030

3131
private static Map<String, Properties> contextToPropsCache = new HashMap<String, Properties>();
3232

33+
public static void initialize(String context, Properties overrideProps) {
34+
contextToPropsCache.put(context, overrideProps);
35+
}
36+
3337
private static Properties initialize(String context) {
34-
// if (contextToPropsCache.containsKey(context)) return contextToPropsCache.get(context);
38+
// todo: fix caching for the rest
39+
if (contextToPropsCache.containsKey(context)) return contextToPropsCache.get(context);
3540
Properties props = loadProps(context);
3641

3742
contextToPropsCache.put(context, props);

src/main/java/org/ecocean/OpenSearch.java

Lines changed: 44 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -117,6 +117,10 @@ public TlsDetails create(final SSLEngine sslEngine) {
117117

118118
/////final OpenSearchTransport transport = builder.build();
119119
///final RestClient restClient = RestClient.builder(host).build();
120+
initializeClient(host);
121+
}
122+
123+
public static void initializeClient(HttpHost host) {
120124
restClient = RestClient.builder(host).build();
121125
final OpenSearchTransport transport = new RestClientTransport(restClient,
122126
new JacksonJsonpMapper());
@@ -140,48 +144,15 @@ public static void backgroundStartup(String context) {
140144
final ScheduledFuture schedFutureIndexing = schedExec.scheduleWithFixedDelay(
141145
new Runnable() {
142146
public void run() {
143-
Shepherd myShepherd = new Shepherd(context);
144-
myShepherd.setAction("OpenSearch.backgroundIndexing");
145-
try {
146-
myShepherd.beginDBTransaction();
147-
System.out.println("OpenSearch background indexing running...");
148-
Base.opensearchSyncIndex(myShepherd, Encounter.class,
149-
BACKGROUND_SLICE_SIZE);
150-
Base.opensearchSyncIndex(myShepherd, Annotation.class,
151-
BACKGROUND_SLICE_SIZE);
152-
Base.opensearchSyncIndex(myShepherd, MarkedIndividual.class,
153-
BACKGROUND_SLICE_SIZE);
154-
Base.opensearchSyncIndex(myShepherd, Occurrence.class,
155-
BACKGROUND_SLICE_SIZE);
156-
Base.opensearchSyncIndex(myShepherd, MediaAsset.class,
157-
BACKGROUND_SLICE_SIZE);
158-
System.out.println("OpenSearch background indexing finished.");
159-
} catch (Exception ex) {
160-
ex.printStackTrace();
161-
} finally {
162-
myShepherd.rollbackAndClose();
163-
unsetActiveIndexingBackground();
164-
}
147+
updateEncounterIndexes(context);
165148
}
166149
}, 2, // initial delay
167150
BACKGROUND_DELAY_MINUTES, // period delay *after* execution finishes
168151
TimeUnit.MINUTES); // unit of delays above
169152
final ScheduledFuture schedFuturePermissions = schedExec.scheduleWithFixedDelay(
170153
new Runnable() {
171154
public void run() {
172-
Shepherd myShepherd = new Shepherd(context);
173-
myShepherd.setAction("OpenSearch.backgroundPermissions");
174-
try {
175-
myShepherd.beginDBTransaction();
176-
System.out.println("OpenSearch background permissions running...");
177-
Encounter.opensearchIndexPermissionsBackground(myShepherd);
178-
System.out.println("OpenSearch background permissions finished.");
179-
myShepherd.commitDBTransaction(); // need commit since we might have changed SystemValues
180-
myShepherd.closeDBTransaction();
181-
} catch (Exception ex) {
182-
ex.printStackTrace();
183-
myShepherd.rollbackAndClose();
184-
}
155+
updatePermissionsIndex(context);
185156
}
186157
}, 8, // initial delay
187158
BACKGROUND_PERMISSIONS_MINUTES, TimeUnit.MINUTES); // unit of delays above
@@ -195,6 +166,44 @@ public void run() {
195166
System.out.println("OpenSearch.backgroundStartup(" + context + ") backgrounded");
196167
}
197168

169+
private static void updatePermissionsIndex(String context) {
170+
Shepherd myShepherd = new Shepherd(context);
171+
172+
myShepherd.setAction("OpenSearch.backgroundPermissions");
173+
try {
174+
myShepherd.beginDBTransaction();
175+
System.out.println("OpenSearch background permissions running...");
176+
Encounter.opensearchIndexPermissionsBackground(myShepherd);
177+
System.out.println("OpenSearch background permissions finished.");
178+
myShepherd.commitDBTransaction(); // need commit since we might have changed SystemValues
179+
myShepherd.closeDBTransaction();
180+
} catch (Exception ex) {
181+
ex.printStackTrace();
182+
myShepherd.rollbackAndClose();
183+
}
184+
}
185+
186+
public static void updateEncounterIndexes(String context) {
187+
Shepherd myShepherd = new Shepherd(context);
188+
189+
myShepherd.setAction("OpenSearch.backgroundIndexing");
190+
try {
191+
myShepherd.beginDBTransaction();
192+
System.out.println("OpenSearch background indexing running...");
193+
Base.opensearchSyncIndex(myShepherd, Encounter.class, BACKGROUND_SLICE_SIZE);
194+
Base.opensearchSyncIndex(myShepherd, Annotation.class, BACKGROUND_SLICE_SIZE);
195+
Base.opensearchSyncIndex(myShepherd, MarkedIndividual.class, BACKGROUND_SLICE_SIZE);
196+
Base.opensearchSyncIndex(myShepherd, Occurrence.class, BACKGROUND_SLICE_SIZE);
197+
Base.opensearchSyncIndex(myShepherd, MediaAsset.class, BACKGROUND_SLICE_SIZE);
198+
System.out.println("OpenSearch background indexing finished.");
199+
} catch (Exception ex) {
200+
ex.printStackTrace();
201+
} finally {
202+
myShepherd.rollbackAndClose();
203+
unsetActiveIndexingBackground();
204+
}
205+
}
206+
198207
public void createIndex(String indexName, JSONObject mapping)
199208
throws IOException {
200209
if (!isValidIndexName(indexName)) throw new IOException("invalid index name: " + indexName);
Lines changed: 146 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,146 @@
1+
package org.ecocean.api;
2+
3+
import org.apache.commons.lang3.StringUtils;
4+
import org.ecocean.*;
5+
import org.ecocean.export.EncounterAnnotationExportFile;
6+
import org.ecocean.export.EncounterImageExportFile;
7+
import org.ecocean.servlet.ServletUtilities;
8+
import org.ecocean.shepherd.core.Shepherd;
9+
import org.joda.time.Instant;
10+
11+
import java.io.ByteArrayOutputStream;
12+
import java.io.IOException;
13+
import java.lang.reflect.InvocationTargetException;
14+
import java.util.*;
15+
import java.util.zip.ZipEntry;
16+
import java.util.zip.ZipOutputStream;
17+
import javax.jdo.PersistenceManager;
18+
import javax.jdo.PersistenceManagerFactory;
19+
import javax.jdo.Query;
20+
import javax.servlet.http.HttpServletRequest;
21+
import javax.servlet.http.HttpServletResponse;
22+
import javax.servlet.ServletException;
23+
24+
public class EncounterExport extends ApiBase {
25+
protected void doPost(HttpServletRequest request, HttpServletResponse response)
26+
throws ServletException, IOException {
27+
String context = ServletUtilities.getContext(request);
28+
Shepherd myShepherd = new Shepherd(context);
29+
30+
response.setContentType("application/zip");
31+
response.setHeader("Content-Disposition",
32+
"attachment;filename=encounter_export_" + Instant.now().getMillis() + ".zip");
33+
34+
myShepherd.beginDBTransaction();
35+
try (ZipOutputStream outputStream = new ZipOutputStream(response.getOutputStream())) {
36+
if (Objects.equals(request.getParameter("includeMetadata"), "true")) {
37+
writeMetadataFile(request, myShepherd, outputStream);
38+
}
39+
EncounterQueryResult queryResult = EncounterQueryProcessor.processQuery(myShepherd,
40+
request, "year descending, month descending, day descending");
41+
List<Encounter> encounters = queryResult.getResult();
42+
43+
// Build map of encounter ID -> individual using join table relationship
44+
Map<String,
45+
MarkedIndividual> encounterToIndividual = buildEncounterIndividualMap(myShepherd,
46+
encounters);
47+
EnumSet<EncounterImageExportFile.ExportOptions> flags = EnumSet.noneOf(
48+
EncounterImageExportFile.ExportOptions.class);
49+
if (Objects.equals(request.getParameter("unidentifiedEncounters"), "true")) {
50+
flags = EnumSet.of(
51+
EncounterImageExportFile.ExportOptions.IncludeUnidentifiedEncounters);
52+
}
53+
int numAnnotationsPerId = -1;
54+
if (StringUtils.isNumeric(request.getParameter("numAnnotationsPerId"))) {
55+
numAnnotationsPerId = Integer.parseInt(request.getParameter("numAnnotationsPerId"));
56+
}
57+
EncounterImageExportFile imagesExport = new EncounterImageExportFile(encounters,
58+
encounterToIndividual, numAnnotationsPerId, flags);
59+
60+
imagesExport.writeTo(outputStream);
61+
} catch (Exception e) {
62+
// todo: make this more specific
63+
e.printStackTrace();
64+
throw new RuntimeException("Unable to export data", e);
65+
} finally {
66+
myShepherd.rollbackDBTransaction();
67+
myShepherd.closeDBTransaction();
68+
}
69+
}
70+
71+
/**
72+
* Builds a map of encounter catalog number to MarkedIndividual.
73+
* This handles both the old direct foreign key and new join table relationships
74+
* in a single efficient query.
75+
*/
76+
private static Map<String, MarkedIndividual> buildEncounterIndividualMap(Shepherd myShepherd,
77+
List<Encounter> encounters)
78+
throws Exception {
79+
Map<String, MarkedIndividual> map = new HashMap<>();
80+
81+
if (encounters.isEmpty()) {
82+
return map;
83+
}
84+
// First, handle direct relationships (e.getIndividual() != null)
85+
for (Encounter e : encounters) {
86+
if (e.getIndividual() != null) {
87+
map.put(e.getCatalogNumber(), e.getIndividual());
88+
}
89+
}
90+
// Build list of encounter IDs that still need individuals
91+
List<String> encountersNeedingIndividuals = new ArrayList<>();
92+
for (Encounter e : encounters) {
93+
if (!map.containsKey(e.getCatalogNumber())) {
94+
encountersNeedingIndividuals.add(e.getCatalogNumber());
95+
}
96+
}
97+
if (encountersNeedingIndividuals.isEmpty()) {
98+
return map;
99+
}
100+
// Query MarkedIndividuals that have these encounters (via join table)
101+
// Using fetch groups to eagerly load encounters and names
102+
PersistenceManager pm = myShepherd.getPM();
103+
PersistenceManagerFactory pmf = pm.getPersistenceManagerFactory();
104+
105+
javax.jdo.FetchGroup indvGrp = pmf.getFetchGroup(MarkedIndividual.class,
106+
"individualWithEncounters");
107+
indvGrp.addMember("individualID").addMember("names").addMember("encounters");
108+
pm.getFetchPlan().addGroup("individualWithEncounters");
109+
110+
try (Query query = pm.newQuery(MarkedIndividual.class)) {
111+
query.setFilter(
112+
"encounters.contains(enc) && :catalogNumbers.contains(enc.catalogNumber)");
113+
query.declareVariables("org.ecocean.Encounter enc");
114+
115+
@SuppressWarnings("unchecked") List<MarkedIndividual> individuals = (List<MarkedIndividual>)query.execute(encountersNeedingIndividuals);
116+
// Map encounters to individuals
117+
for (MarkedIndividual individual : individuals) {
118+
for (Encounter enc : individual.getEncounters()) {
119+
if (encountersNeedingIndividuals.contains(enc.getCatalogNumber())) {
120+
map.put(enc.getCatalogNumber(), individual);
121+
}
122+
}
123+
}
124+
}
125+
return map;
126+
}
127+
128+
private static void writeMetadataFile(HttpServletRequest request, Shepherd myShepherd,
129+
ZipOutputStream outputStream)
130+
throws IOException, NoSuchMethodException, ClassNotFoundException, InvocationTargetException,
131+
IllegalAccessException {
132+
EncounterAnnotationExportFile exportFile = new EncounterAnnotationExportFile(request,
133+
myShepherd);
134+
135+
// Write Excel file to ByteArrayOutputStream first to avoid nested ZIP issues
136+
try (ByteArrayOutputStream excelBytes = new ByteArrayOutputStream()) {
137+
exportFile.writeToStream(excelBytes);
138+
139+
// Now write the complete Excel file as a single ZIP entry
140+
ZipEntry metadataFile = new ZipEntry("metadata.xlsx");
141+
outputStream.putNextEntry(metadataFile);
142+
outputStream.write(excelBytes.toByteArray());
143+
outputStream.closeEntry();
144+
}
145+
}
146+
}

0 commit comments

Comments
 (0)