import export functionality added

This commit is contained in:
2026-10-28 08:07:42 +01:00
parent bd497aefd5
commit e3e362c2ac
16 changed files with 609 additions and 4 deletions

View File

@@ -10,6 +10,8 @@ import java.util.LinkedHashSet;
@Builder
@ToString(onlyExplicitlyIncluded = true)
public class DanceSchool extends AbstractEntity {
@Builder.Default
private final java.util.UUID key = java.util.UUID.randomUUID();
@Setter
private String name;
@Builder.Default

View File

@@ -13,5 +13,9 @@ public class DayPart extends AbstractEntity {
@ToString.Include
private final DayPartType type;
@ToString.Include
private final List<DanceGroup> danceGroups;
private List<DanceGroup> danceGroups;
public void removeDanceGroup(DanceGroup group) {
danceGroups = danceGroups.stream().filter(value -> value != group).toList();
}
}

View File

@@ -9,9 +9,13 @@ public abstract class AbstractRepository<T extends AbstractEntity> {
protected Map<Long, T> entities = new LinkedHashMap<>();
public long nextId() {
return sequence++;
}
public T add(T entity) {
if (entity.getId() == null) {
entity.setId(sequence++);
entity.setId(nextId());
}
entities.put(entity.getId(), entity);
return entity;

View File

@@ -0,0 +1,43 @@
package nl.agilecc.agiledancers.rest;
import com.fasterxml.jackson.databind.*;
import com.fasterxml.jackson.core.JsonParser;
import jakarta.inject.Inject;
import jakarta.ws.rs.*;
import jakarta.ws.rs.core.*;
import nl.agilecc.agiledancers.service.*;
import nl.agilecc.agiledancers.rest.apimodel.SchoolView;
@Path("/api/dance-schools")
@Produces(MediaType.APPLICATION_JSON)
public class SchoolSnapshotResource {
@Inject SchoolManagementService service;
@Inject ObjectMapper mapper;
@GET @Path("/{id}/export")
public Response export(@PathParam("id") long id) throws Exception {
String json = mapper.writerWithDefaultPrettyPrinter().writeValueAsString(service.exportSchool(id));
return Response.ok(json).header("Content-Disposition", "attachment; filename=school-" + id + ".json").build();
}
@POST @Path("/import/preview") @Consumes(MediaType.APPLICATION_JSON)
public SchoolManagementService.ImportPreview preview(String json) { return service.preview(parse(json)); }
@POST @Path("/import") @Consumes(MediaType.APPLICATION_JSON)
public SchoolView apply(String json, @QueryParam("replaceId") Long replaceId) {
return SchoolView.of(service.importSchool(parse(json), replaceId));
}
private SchoolSnapshot parse(String json) {
try {
SchoolSnapshot snapshot = mapper.copy().enable(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES)
.enable(DeserializationFeature.FAIL_ON_TRAILING_TOKENS)
.disable(MapperFeature.ALLOW_COERCION_OF_SCALARS)
.disable(DeserializationFeature.ACCEPT_FLOAT_AS_INT)
.enable(JsonParser.Feature.STRICT_DUPLICATE_DETECTION)
.readValue(json, SchoolSnapshot.class);
if (snapshot == null) throw new IllegalArgumentException("Het bestand bevat geen school");
return snapshot;
} catch (Exception e) {
throw new ManagementException(ManagementException.Kind.INVALID_INPUT, "Ongeldig JSON-bestand: " + e.getMessage());
}
}
}

View File

@@ -106,6 +106,7 @@ public class SchoolManagementService {
public synchronized void deleteGroup(long schoolId, long groupId) {
DanceGroup group = group(schoolId, groupId);
school(schoolId).getShows().forEach(show -> show.getDayParts().forEach(part -> part.removeDanceGroup(group)));
school(schoolId).removeDanceGroup(group);
groups.delete(groupId);
}
@@ -150,6 +151,60 @@ public class SchoolManagementService {
shows.delete(showId);
}
public record ImportPreview(String name, int dancers, int groups, int shows, Long existingId, String existingName) {}
public synchronized SchoolSnapshot exportSchool(long id) {
return SchoolSnapshot.of(school(id));
}
public synchronized ImportPreview preview(SchoolSnapshot snapshot) {
snapshot.restore();
DanceSchool existing = schools.findAll().stream().filter(s -> s.getKey().equals(snapshot.key())).findFirst().orElse(null);
return new ImportPreview(snapshot.name(), snapshot.dancers().size(), snapshot.groups().size(), snapshot.shows().size(),
existing == null ? null : existing.getId(), existing == null ? null : existing.getName());
}
public synchronized DanceSchool importSchool(SchoolSnapshot snapshot, Long replaceId) {
DanceSchool staged = snapshot.restore();
ImportPreview preview = preview(snapshot);
if (!Objects.equals(preview.existingId(), replaceId)) {
throw new ManagementException(INVALID_INPUT, "De bestaande school is gewijzigd. Bekijk de import opnieuw.");
}
// All validation and construction finish before changing repository state.
// Rebuild sets after assigning runtime IDs: entity hash codes include the ID.
List<Dancer> importedDancers = new ArrayList<>(staged.getDancers());
List<DanceGroup> importedGroups = new ArrayList<>(staged.getDanceGroups());
List<Show> importedShows = new ArrayList<>(staged.getShows());
Map<DanceGroup, List<Dancer>> memberships = new IdentityHashMap<>();
importedGroups.forEach(g -> memberships.put(g, new ArrayList<>(g.getDancers())));
importedDancers.forEach(d -> d.setId(dancers.nextId()));
importedGroups.forEach(g -> {
g.setId(groups.nextId());
});
List<DanceGroup> rebuiltGroups = new ArrayList<>();
Map<DanceGroup, DanceGroup> replacements = new IdentityHashMap<>();
for (DanceGroup g : importedGroups) {
DanceGroup rebuilt = DanceGroup.builder().category(g.getCategory()).type(g.getType())
.startAge(g.getStartAge()).endAge(g.getEndAge()).styles(new LinkedHashSet<>(g.getStyles()))
.coach(g.getCoach()).dancers(new LinkedHashSet<>(memberships.get(g))).build();
rebuilt.setId(g.getId()); rebuiltGroups.add(rebuilt); replacements.put(g, rebuilt);
}
List<Show> rebuiltShows = new ArrayList<>();
for (Show show : importedShows) {
Show rebuilt = Show.builder().name(show.getName()).date(show.getDate()).dayParts(show.getDayParts().stream()
.map(p -> DayPart.builder().type(p.getType()).danceGroups(p.getDanceGroups().stream().map(replacements::get).toList()).build()).toList()).build();
rebuilt.setId(shows.nextId()); rebuiltShows.add(rebuilt);
}
DanceSchool imported = DanceSchool.builder().key(staged.getKey()).name(staged.getName())
.dancers(new LinkedHashSet<>(importedDancers)).danceGroups(new LinkedHashSet<>(rebuiltGroups))
.shows(new LinkedHashSet<>(rebuiltShows)).coaches(new LinkedHashSet<>(staged.getCoaches())).build();
if (replaceId != null) { deleteSchool(replaceId); imported.setId(replaceId); }
importedDancers.forEach(dancers::add);
rebuiltGroups.forEach(groups::add);
rebuiltShows.forEach(shows::add);
return schools.add(imported);
}
private static void validateAgeRange(int startAge, int endAge) {
if (startAge > endAge) {
throw new ManagementException(INVALID_INPUT, "startAge must not exceed endAge");

View File

@@ -0,0 +1,96 @@
package nl.agilecc.agiledancers.service;
import nl.agilecc.agiledancers.model.*;
import nl.agilecc.agiledancers.model.specifications.*;
import java.time.LocalDate;
import java.util.*;
/** Version 1 file format. IDs are local to the file, never server identifiers. */
public record SchoolSnapshot(Integer formatVersion, UUID key, String name, List<Person> dancers,
List<Trainer> coaches, List<Group> groups, List<Performance> shows) {
public record Person(String id, String name, LocalDate birthDate) {}
public record Trainer(String id, String name) {}
public record Group(String id, String category, DanceGroupType type, Integer startAge, Integer endAge,
List<DanceGroupStyle> styles, List<String> dancerIds, String coachId) {}
public record Part(DayPartType type, List<String> groupIds) {}
public record Performance(String id, String name, LocalDate date, List<Part> dayParts) {}
public static SchoolSnapshot of(DanceSchool s) {
Map<Dancer, String> dancers = ids(s.getDancers());
Map<Coach, String> coaches = ids(s.getCoaches());
Map<DanceGroup, String> groups = ids(s.getDanceGroups());
Map<Show, String> shows = ids(s.getShows());
return new SchoolSnapshot(1, s.getKey(), s.getName(),
s.getDancers().stream().map(d -> new Person(dancers.get(d), d.getName(), d.getBirthDate())).toList(),
s.getCoaches().stream().map(c -> new Trainer(coaches.get(c), c.getName())).toList(),
s.getDanceGroups().stream().map(g -> new Group(groups.get(g), g.getCategory(), g.getType(),
g.getStartAge(), g.getEndAge(), List.copyOf(g.getStyles()),
g.getDancers().stream().map(dancers::get).toList(),
g.getCoach() == null ? null : coaches.get(g.getCoach()))).toList(),
s.getShows().stream().map(show -> new Performance(shows.get(show), show.getName(), show.getDate(),
show.getDayParts().stream().map(p -> new Part(p.getType(),
p.getDanceGroups().stream().map(groups::get).toList())).toList())).toList());
}
private static <T> Map<T, String> ids(Collection<T> values) {
Map<T, String> result = new IdentityHashMap<>();
for (T value : values) result.put(value, Integer.toString(result.size() + 1));
return result;
}
public DanceSchool restore() {
check(Objects.equals(formatVersion, 1), "Niet-ondersteunde formatVersion: " + formatVersion);
check(key != null, "key ontbreekt"); text(name, "schoolnaam");
DanceSchool school = DanceSchool.builder().key(key).name(name).build();
Map<String, Dancer> dancerMap = new LinkedHashMap<>();
for (Person d : list(dancers, "dancers")) {
check(d != null, "Lege danser"); text(d.name, "dansernaam");
check(d.birthDate != null && !d.birthDate.isAfter(LocalDate.now()), "Ongeldige geboortedatum voor " + d.name);
Dancer dancer = Dancer.builder().name(d.name).birthDate(d.birthDate).build();
// Assign temporary distinct IDs before inserting entities into sets.
dancer.setId((long) dancerMap.size());
put(dancerMap, d.id, dancer); school.addDancer(dancer);
}
Map<String, Coach> coachMap = new LinkedHashMap<>();
for (Trainer c : list(coaches, "coaches")) {
check(c != null, "Lege coach"); text(c.name, "coachnaam");
Coach coach = Coach.builder().name(c.name).build(); coach.setId((long) coachMap.size());
put(coachMap, c.id, coach); school.addCoach(coach);
}
Map<String, DanceGroup> groupMap = new LinkedHashMap<>();
for (Group g : list(groups, "groups")) {
check(g != null, "Lege groep"); text(g.category, "groepsnaam");
check(g.type != null && g.startAge != null && g.endAge != null && g.startAge >= 0 && g.endAge >= g.startAge,
"Ongeldig type of leeftijdsbereik voor groep " + g.category);
DanceGroup group = DanceGroup.builder().category(g.category).type(g.type).startAge(g.startAge).endAge(g.endAge)
.styles(new LinkedHashSet<>(list(g.styles, "styles"))).coach(g.coachId == null ? null : ref(coachMap, g.coachId, "groep " + g.category + " verwijst naar een onbekende coach" )).build();
check(!group.getStyles().contains(null), "Ongeldige dansstijl");
group.setId((long) groupMap.size());
for (String id : unique(g.dancerIds, "dancerIds")) group.addDancer(ref(dancerMap, id, "groep " + g.category + " verwijst naar een onbekende danser"));
put(groupMap, g.id, group); school.addDanceGroup(group);
}
Set<String> showIds = new HashSet<>();
for (Performance show : list(shows, "shows")) {
check(show != null, "Lege show"); text(show.id, "show-id");
check(showIds.add(show.id), "Dubbele show-id " + show.id); text(show.name, "shownaam");
check(show.date != null, "Showdatum ontbreekt voor " + show.name);
List<DayPart> parts = new ArrayList<>(); Set<DayPartType> types = new HashSet<>();
for (Part part : list(show.dayParts, "dayParts")) {
check(part != null && part.type != null && types.add(part.type), "Ongeldig of dubbel dagdeel voor " + show.name);
parts.add(DayPart.builder().type(part.type).danceGroups(unique(part.groupIds, "groupIds").stream()
.map(id -> ref(groupMap, id, "Show " + show.name + " verwijst naar een onbekende groep")).toList()).build());
}
Show entity = Show.builder().name(show.name).date(show.date).dayParts(parts).build();
entity.setId((long) showIds.size()); school.addShow(entity);
}
return school;
}
private static void text(String value, String field) { check(value != null && !value.isBlank(), field + " ontbreekt"); }
private static <T> List<T> list(List<T> value, String field) { check(value != null, field + " ontbreekt"); return value; }
private static List<String> unique(List<String> value, String field) {
list(value, field); check(value.stream().noneMatch(Objects::isNull) && new HashSet<>(value).size() == value.size(), "Ongeldige of dubbele verwijzing in " + field); return value;
}
private static <T> void put(Map<String, T> map, String id, T value) { text(id, "id"); check(map.putIfAbsent(id, value) == null, "Dubbele id " + id); }
private static <T> T ref(Map<String,T> map, String id, String error) { T value = map.get(id); check(value != null, error); return value; }
private static void check(boolean valid, String message) { if (!valid) throw new ManagementException(ManagementException.Kind.INVALID_INPUT, message); }
}

View File

@@ -0,0 +1,32 @@
package nl.agilecc.agiledancers;
import io.quarkus.test.junit.QuarkusTest;
import org.junit.jupiter.api.Test;
import static io.restassured.RestAssured.given;
import static org.hamcrest.Matchers.*;
@QuarkusTest
class SchoolSnapshotResourceTest {
private static final String BASE = "/api/dance-schools";
@Test void downloadPreviewRestoreAndRejectInvalidFiles() {
long id = given().contentType("application/json").body("{\"name\":\"JSON school\"}").post(BASE)
.then().statusCode(201).extract().jsonPath().getLong("id");
String json = given().get(BASE + "/" + id + "/export").then().statusCode(200)
.header("Content-Disposition", containsString(".json")).body("formatVersion", equalTo(1)).extract().asString();
try {
given().contentType("application/json").body(json).post(BASE + "/import/preview").then().statusCode(200)
.body("existingId", equalTo((int) id)).body("dancers", equalTo(0));
for (String bad : new String[]{"{", "null", json.replace("\"formatVersion\" : 1", "\"formatVersion\" : 2"),
json.replace("\"name\" :", "\"unknown\" :"), json + " {}",
json.replace("\"dancers\" : [ ]", "\"dancers\" : null")}) {
given().contentType("application/json").body(bad).post(BASE + "/import?replaceId=" + id)
.then().statusCode(400).body("message", not(emptyOrNullString()));
}
given().get(BASE + "/" + id + "/export").then().body(equalTo(json));
given().delete(BASE + "/" + id).then().statusCode(204);
id = given().contentType("application/json").body(json).post(BASE + "/import")
.then().statusCode(200).extract().jsonPath().getLong("id");
given().get(BASE + "/" + id + "/export").then().body(equalTo(json));
} finally { given().delete(BASE + "/" + id); }
}
}

View File

@@ -0,0 +1,70 @@
package nl.agilecc.agiledancers;
import nl.agilecc.agiledancers.service.*;
import nl.agilecc.agiledancers.model.specifications.*;
import org.junit.jupiter.api.Test;
import java.time.LocalDate;
import java.util.*;
import static org.junit.jupiter.api.Assertions.*;
class SchoolSnapshotTest {
private SchoolSnapshot complete() {
return new SchoolSnapshot(1, UUID.randomUUID(), "Studio", List.of(new SchoolSnapshot.Person("a", "Sam", LocalDate.of(2010,1,1))),
List.of(new SchoolSnapshot.Trainer("c", "Alex")),
List.of(new SchoolSnapshot.Group("g", "Hiphop", DanceGroupType.REGULAR, 0, 99, List.of(DanceGroupStyle.HIPHOP), List.of("a"), "c"),
new SchoolSnapshot.Group("h", "Jazz", DanceGroupType.REGULAR, 0, 99, List.of(), List.of("a"), "c")),
List.of(new SchoolSnapshot.Performance("s", "Show", LocalDate.of(2027,1,1),
List.of(new SchoolSnapshot.Part(DayPartType.MORNING, List.of("g", "h"))))));
}
@Test void roundTripAfterRestartPreservesAllFieldsAndSharedRelationships() {
var snapshot = complete();
var service = new SchoolManagementService();
var existing = service.createSchool("Other");
service.createDancer(existing.getId(), "Other dancer", LocalDate.of(2000,1,1));
var imported = service.importSchool(snapshot, null);
assertEquals(2, imported.getDanceGroups().size());
var dancer = imported.getDancers().iterator().next();
imported.getDanceGroups().forEach(g -> assertSame(dancer, g.getDancers().iterator().next()));
assertEquals(SchoolSnapshot.of(snapshot.restore()), service.exportSchool(imported.getId()));
var restarted = new SchoolManagementService();
var restored = restarted.importSchool(service.exportSchool(imported.getId()), null);
assertEquals(SchoolSnapshot.of(snapshot.restore()), restarted.exportSchool(restored.getId()));
// Membership removal must work after remapping hash-based sets.
service.deleteDancer(imported.getId(), dancer.getId());
imported.getDanceGroups().forEach(g -> assertTrue(g.getDancers().isEmpty()));
assertEquals(1, existing.getDancers().size());
var group = imported.getDanceGroups().iterator().next();
service.deleteGroup(imported.getId(), group.getId());
assertEquals(1, imported.getShows().iterator().next().getDayParts().getFirst().getDanceGroups().size());
assertDoesNotThrow(() -> service.exportSchool(imported.getId()).restore());
}
@Test void previewIsReadOnlyAndReplacementRemovesPreviousConfiguration() {
var service = new SchoolManagementService(); var snapshot = complete();
assertNull(service.preview(snapshot).existingId()); assertTrue(service.schools().isEmpty());
var original = service.importSchool(snapshot, null);
service.createDancer(original.getId(), "Extra", LocalDate.of(2000,1,1));
var other = service.createSchool(snapshot.name());
assertEquals(original.getId(), service.preview(snapshot).existingId());
assertThrows(ManagementException.class, () -> service.importSchool(snapshot, null));
assertEquals(2, original.getDancers().size());
var replaced = service.importSchool(snapshot, original.getId());
assertEquals(SchoolSnapshot.of(snapshot.restore()), service.exportSchool(replaced.getId()));
assertSame(other, service.school(other.getId()));
service.deleteSchool(replaced.getId());
assertEquals(SchoolSnapshot.of(snapshot.restore()), service.exportSchool(service.importSchool(snapshot, null).getId()));
}
@Test void unresolvedReferenceLeavesExistingAndOtherSchoolsIntact() {
var service = new SchoolManagementService(); var valid = complete();
var original = service.importSchool(valid, null);
var bad = new SchoolSnapshot(1, valid.key(), valid.name(), List.of(), valid.coaches(), valid.groups(), valid.shows());
assertThrows(ManagementException.class, () -> service.preview(bad));
assertThrows(ManagementException.class, () -> service.importSchool(bad, original.getId()));
assertEquals(SchoolSnapshot.of(valid.restore()), service.exportSchool(original.getId()));
}
@Test void emptySchoolRoundTrips() {
var service = new SchoolManagementService();
var empty = service.createSchool("Empty"); var snapshot = service.exportSchool(empty.getId());
service.deleteSchool(empty.getId());
assertEquals(SchoolSnapshot.of(snapshot.restore()), service.exportSchool(service.importSchool(snapshot, null).getId()));
}
}