diff --git a/agiledancers-be/src/main/java/nl/agilecc/agiledancers/model/DanceSchool.java b/agiledancers-be/src/main/java/nl/agilecc/agiledancers/model/DanceSchool.java index b5b5dae..2358db3 100644 --- a/agiledancers-be/src/main/java/nl/agilecc/agiledancers/model/DanceSchool.java +++ b/agiledancers-be/src/main/java/nl/agilecc/agiledancers/model/DanceSchool.java @@ -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 diff --git a/agiledancers-be/src/main/java/nl/agilecc/agiledancers/model/DayPart.java b/agiledancers-be/src/main/java/nl/agilecc/agiledancers/model/DayPart.java index 6503d82..1cef8c1 100644 --- a/agiledancers-be/src/main/java/nl/agilecc/agiledancers/model/DayPart.java +++ b/agiledancers-be/src/main/java/nl/agilecc/agiledancers/model/DayPart.java @@ -13,5 +13,9 @@ public class DayPart extends AbstractEntity { @ToString.Include private final DayPartType type; @ToString.Include - private final List danceGroups; + private List danceGroups; + + public void removeDanceGroup(DanceGroup group) { + danceGroups = danceGroups.stream().filter(value -> value != group).toList(); + } } diff --git a/agiledancers-be/src/main/java/nl/agilecc/agiledancers/repository/AbstractRepository.java b/agiledancers-be/src/main/java/nl/agilecc/agiledancers/repository/AbstractRepository.java index bd16561..5c26353 100644 --- a/agiledancers-be/src/main/java/nl/agilecc/agiledancers/repository/AbstractRepository.java +++ b/agiledancers-be/src/main/java/nl/agilecc/agiledancers/repository/AbstractRepository.java @@ -9,9 +9,13 @@ public abstract class AbstractRepository { protected Map 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; diff --git a/agiledancers-be/src/main/java/nl/agilecc/agiledancers/rest/SchoolSnapshotResource.java b/agiledancers-be/src/main/java/nl/agilecc/agiledancers/rest/SchoolSnapshotResource.java new file mode 100644 index 0000000..e797eb0 --- /dev/null +++ b/agiledancers-be/src/main/java/nl/agilecc/agiledancers/rest/SchoolSnapshotResource.java @@ -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()); + } + } +} diff --git a/agiledancers-be/src/main/java/nl/agilecc/agiledancers/service/SchoolManagementService.java b/agiledancers-be/src/main/java/nl/agilecc/agiledancers/service/SchoolManagementService.java index 1dd8a17..f6401f1 100644 --- a/agiledancers-be/src/main/java/nl/agilecc/agiledancers/service/SchoolManagementService.java +++ b/agiledancers-be/src/main/java/nl/agilecc/agiledancers/service/SchoolManagementService.java @@ -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 importedDancers = new ArrayList<>(staged.getDancers()); + List importedGroups = new ArrayList<>(staged.getDanceGroups()); + List importedShows = new ArrayList<>(staged.getShows()); + Map> 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 rebuiltGroups = new ArrayList<>(); + Map 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 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"); diff --git a/agiledancers-be/src/main/java/nl/agilecc/agiledancers/service/SchoolSnapshot.java b/agiledancers-be/src/main/java/nl/agilecc/agiledancers/service/SchoolSnapshot.java new file mode 100644 index 0000000..37d1755 --- /dev/null +++ b/agiledancers-be/src/main/java/nl/agilecc/agiledancers/service/SchoolSnapshot.java @@ -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 dancers, + List coaches, List groups, List 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 styles, List dancerIds, String coachId) {} + public record Part(DayPartType type, List groupIds) {} + public record Performance(String id, String name, LocalDate date, List dayParts) {} + + public static SchoolSnapshot of(DanceSchool s) { + Map dancers = ids(s.getDancers()); + Map coaches = ids(s.getCoaches()); + Map groups = ids(s.getDanceGroups()); + Map 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 Map ids(Collection values) { + Map 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 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 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 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 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 parts = new ArrayList<>(); Set 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 List list(List value, String field) { check(value != null, field + " ontbreekt"); return value; } + private static List unique(List 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 void put(Map map, String id, T value) { text(id, "id"); check(map.putIfAbsent(id, value) == null, "Dubbele id " + id); } + private static T ref(Map 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); } +} diff --git a/agiledancers-be/src/test/java/nl/agilecc/agiledancers/SchoolSnapshotResourceTest.java b/agiledancers-be/src/test/java/nl/agilecc/agiledancers/SchoolSnapshotResourceTest.java new file mode 100644 index 0000000..0530fff --- /dev/null +++ b/agiledancers-be/src/test/java/nl/agilecc/agiledancers/SchoolSnapshotResourceTest.java @@ -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); } + } +} diff --git a/agiledancers-be/src/test/java/nl/agilecc/agiledancers/SchoolSnapshotTest.java b/agiledancers-be/src/test/java/nl/agilecc/agiledancers/SchoolSnapshotTest.java new file mode 100644 index 0000000..5da1f07 --- /dev/null +++ b/agiledancers-be/src/test/java/nl/agilecc/agiledancers/SchoolSnapshotTest.java @@ -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())); + } +} diff --git a/agiledancers-fe/src/app/schools/school-page.html b/agiledancers-fe/src/app/schools/school-page.html index 5af2e39..2736626 100644 --- a/agiledancers-fe/src/app/schools/school-page.html +++ b/agiledancers-fe/src/app/schools/school-page.html @@ -27,6 +27,7 @@ School bewerken +