Compare commits
46 Commits
4cfd1d71de
...
feature/S1
| Author | SHA1 | Date | |
|---|---|---|---|
| 7750819d73 | |||
| 77b7159f0a | |||
| c6a2b70919 | |||
| c6a495a93c | |||
| bd898e92d2 | |||
| d2d4713c51 | |||
| 8546f222fe | |||
| 3c3ca1949c | |||
| 7603b3339b | |||
| 98d53e9cb5 | |||
| 36f4b15516 | |||
| 0f1c5dcd92 | |||
| 6acd005f98 | |||
| 01506ef837 | |||
| 7a358ae623 | |||
| 2d8822c1a4 | |||
| 694bf3fa86 | |||
| f59cad115a | |||
| a3cff48a92 | |||
| c241392132 | |||
| f1785ed174 | |||
| b92fac1d1a | |||
| 95430691e4 | |||
| 95aa8c6302 | |||
| 3369b9a81c | |||
| b0d6592761 | |||
| 60496de8bb | |||
| 525e9f9335 | |||
| c9db528756 | |||
| b1544357bf | |||
| 3a5203eb47 | |||
| a7f7730ea0 | |||
| c965f9b41d | |||
| 8fc430537b | |||
| fc307de64e | |||
| f281200f47 | |||
| ff6a0ae0a9 | |||
| aae5a97200 | |||
| f910f1e535 | |||
| 9a69981025 | |||
| 35123e51f6 | |||
| de42bbbbf0 | |||
| 5ec2dd83f3 | |||
| 7e4fd2dab7 | |||
| 7e8b3a2cc7 | |||
| 327a365550 |
@@ -1,7 +1,6 @@
|
||||
**/target
|
||||
.git
|
||||
.idea
|
||||
.mvn
|
||||
*.iml
|
||||
|
||||
**/node_modules
|
||||
@@ -16,3 +15,9 @@ agiledancers-be/target/*
|
||||
!agiledancers-be/target/quarkus-app
|
||||
|
||||
**/unit-results
|
||||
|
||||
# CI sources are build inputs; runner credentials and local state are not.
|
||||
ci/runner/.env
|
||||
ci/runner/.runner
|
||||
ci/runner/data
|
||||
ci/runner/local
|
||||
|
||||
@@ -5,7 +5,7 @@ on:
|
||||
|
||||
jobs:
|
||||
verify:
|
||||
runs-on: agiledancers-mini-pc
|
||||
runs-on: mini-pc
|
||||
env:
|
||||
CI: 'true'
|
||||
TARGET_PLATFORM: ${{ vars.TARGET_PLATFORM }}
|
||||
@@ -19,21 +19,23 @@ jobs:
|
||||
uses: https://github.com/actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683
|
||||
with:
|
||||
persist-credentials: false
|
||||
- name: Verify pinned engineering resources
|
||||
run: python3 scripts/engineering.py --check
|
||||
- name: Identify workflow attempt
|
||||
# Gitea 1.25 does not populate gitea.run_attempt. Use a unique value for
|
||||
# container names and diagnostic artifacts, including reruns of one SHA.
|
||||
run: java ci/Ci.java attempt
|
||||
run: java generated/ci/Ci.java attempt
|
||||
- name: Build and test both images
|
||||
run: java ci/Ci.java verify
|
||||
run: java generated/ci/Ci.java verify
|
||||
- name: Publish verified main images
|
||||
if: success() && gitea.ref == 'refs/heads/main'
|
||||
env:
|
||||
REGISTRY_USER: ${{ secrets.REGISTRY_USER }}
|
||||
REGISTRY_TOKEN: ${{ secrets.REGISTRY_TOKEN }}
|
||||
run: java ci/Ci.java publish
|
||||
run: java generated/ci/Ci.java publish
|
||||
- name: Collect diagnostics and clean up test containers
|
||||
if: always()
|
||||
run: java ci/Ci.java cleanup
|
||||
run: java generated/ci/Ci.java cleanup
|
||||
- name: Retain diagnostics for fourteen days
|
||||
if: always()
|
||||
uses: https://github.com/actions/upload-artifact@ff15f0306b3f739f7b6fd43fb5d26cd321bd4de5
|
||||
|
||||
70
.gitea/workflows/deploy.yaml
Normal file
70
.gitea/workflows/deploy.yaml
Normal file
@@ -0,0 +1,70 @@
|
||||
name: Deploy production
|
||||
on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
mode:
|
||||
description: 'Release mode — export legacy data before the first S16 deployment'
|
||||
required: true
|
||||
type: choice
|
||||
default: new
|
||||
options:
|
||||
- new
|
||||
- retry
|
||||
increment:
|
||||
description: 'New release only: semantic version increment (first release is v1.0.0)'
|
||||
required: true
|
||||
type: choice
|
||||
default: patch
|
||||
options:
|
||||
- patch
|
||||
- minor
|
||||
- major
|
||||
version:
|
||||
description: 'Retry only: existing version tag, e.g. v1.2.3; leave empty for new release'
|
||||
required: false
|
||||
type: string
|
||||
|
||||
# Gitea 1.25 ignores concurrency. The helper acquires a NAS-wide mkdir lock.
|
||||
jobs:
|
||||
deploy:
|
||||
if: gitea.ref == 'refs/heads/main'
|
||||
runs-on: mini-pc
|
||||
env:
|
||||
CI: 'true'
|
||||
GITEA_SERVER_URL: ${{ gitea.server_url }}
|
||||
CI_REPOSITORY: ${{ gitea.repository }}
|
||||
CI_COMMIT: ${{ gitea.sha }}
|
||||
CI_REF: ${{ gitea.ref }}
|
||||
CI_EVENT: ${{ gitea.event_name }}
|
||||
CI_RUN_ID: ${{ gitea.run_id }}
|
||||
steps:
|
||||
- name: Check out trusted main workflow revision
|
||||
uses: https://github.com/actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683
|
||||
with:
|
||||
persist-credentials: false
|
||||
- name: Verify pinned engineering resources
|
||||
run: python3 scripts/engineering.py --check
|
||||
- name: Identify workflow attempt
|
||||
run: java generated/ci/Ci.java attempt
|
||||
- name: Release verified images — persistent PostgreSQL storage
|
||||
env:
|
||||
RELEASE_MODE: ${{ inputs.mode }}
|
||||
RELEASE_INCREMENT: ${{ inputs.increment }}
|
||||
RELEASE_VERSION: ${{ inputs.version }}
|
||||
RELEASE_TOKEN: ${{ secrets.RELEASE_TOKEN }}
|
||||
DEPLOY_HOST: ${{ vars.DEPLOY_HOST }}
|
||||
DEPLOY_USER: ${{ vars.DEPLOY_USER }}
|
||||
DEPLOY_SSH_PORT: ${{ vars.DEPLOY_SSH_PORT }}
|
||||
DEPLOY_DIRECTORY: ${{ vars.DEPLOY_DIRECTORY }}
|
||||
DEPLOY_REGISTRY: ${{ vars.DEPLOY_REGISTRY }}
|
||||
DEPLOY_SSH_KEY: ${{ secrets.DEPLOY_SSH_KEY }}
|
||||
DEPLOY_KNOWN_HOSTS: ${{ secrets.DEPLOY_KNOWN_HOSTS }}
|
||||
run: java generated/ci/Ci.java deploy
|
||||
- name: Retain deployment diagnostics for fourteen days
|
||||
if: always()
|
||||
uses: https://github.com/actions/upload-artifact@ff15f0306b3f739f7b6fd43fb5d26cd321bd4de5
|
||||
with:
|
||||
name: deployment-${{ gitea.run_id }}-${{ env.CI_RUN_ATTEMPT }}
|
||||
path: ci/artifacts/deployment/
|
||||
if-no-files-found: warn
|
||||
retention-days: 14
|
||||
44
.gitea/workflows/rollback.yaml
Normal file
44
.gitea/workflows/rollback.yaml
Normal file
@@ -0,0 +1,44 @@
|
||||
name: Rollback production
|
||||
on:
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
rollback:
|
||||
if: gitea.ref == 'refs/heads/main'
|
||||
runs-on: mini-pc
|
||||
env:
|
||||
CI: 'true'
|
||||
GITEA_SERVER_URL: ${{ gitea.server_url }}
|
||||
CI_REPOSITORY: ${{ gitea.repository }}
|
||||
CI_COMMIT: ${{ gitea.sha }}
|
||||
CI_REF: ${{ gitea.ref }}
|
||||
CI_EVENT: ${{ gitea.event_name }}
|
||||
CI_RUN_ID: ${{ gitea.run_id }}
|
||||
steps:
|
||||
- name: Check out trusted main workflow revision
|
||||
uses: https://github.com/actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683
|
||||
with:
|
||||
persist-credentials: false
|
||||
- name: Verify pinned engineering resources
|
||||
run: python3 scripts/engineering.py --check
|
||||
- name: Identify workflow attempt
|
||||
run: java generated/ci/Ci.java attempt
|
||||
- name: Restore previous successful release — requires a compatible database schema
|
||||
env:
|
||||
RELEASE_TOKEN: ${{ secrets.RELEASE_TOKEN }}
|
||||
DEPLOY_HOST: ${{ vars.DEPLOY_HOST }}
|
||||
DEPLOY_USER: ${{ vars.DEPLOY_USER }}
|
||||
DEPLOY_SSH_PORT: ${{ vars.DEPLOY_SSH_PORT }}
|
||||
DEPLOY_DIRECTORY: ${{ vars.DEPLOY_DIRECTORY }}
|
||||
DEPLOY_REGISTRY: ${{ vars.DEPLOY_REGISTRY }}
|
||||
DEPLOY_SSH_KEY: ${{ secrets.DEPLOY_SSH_KEY }}
|
||||
DEPLOY_KNOWN_HOSTS: ${{ secrets.DEPLOY_KNOWN_HOSTS }}
|
||||
run: java generated/ci/Ci.java rollback
|
||||
- name: Retain deployment diagnostics for fourteen days
|
||||
if: always()
|
||||
uses: https://github.com/actions/upload-artifact@ff15f0306b3f739f7b6fd43fb5d26cd321bd4de5
|
||||
with:
|
||||
name: rollback-${{ gitea.run_id }}-${{ env.CI_RUN_ATTEMPT }}
|
||||
path: ci/artifacts/deployment/
|
||||
if-no-files-found: warn
|
||||
retention-days: 14
|
||||
38
AGENTS.md
38
AGENTS.md
@@ -1,14 +1,26 @@
|
||||
# Project skills and specifications
|
||||
# Shared skills and specifications
|
||||
|
||||
- Project skills are stored in `skills/`; specifications belong in `specs/`.
|
||||
- For code reviews, read and follow `skills/code-review/SKILL.md`. If no
|
||||
dedicated Skill tool is available, load the file directly.
|
||||
- Use `grill-me` only when the user explicitly requests it, for example
|
||||
`Use grill-me to review this plan` or `Gebruik grill-me voor dit ontwerp`.
|
||||
- Before using it, read `skills/grill-me/SKILL.md` and its dependency
|
||||
`skills/grilling/SKILL.md`. If no dedicated Skill tool is available, load these
|
||||
files directly and follow their instructions within the available tools.
|
||||
- Installing or mentioning a skill does not start an interview. Do not apply
|
||||
`grilling` automatically to ordinary development requests.
|
||||
- Keep the upstream skill files unchanged. Source revision and license are
|
||||
recorded in `skills/upstream.json` and `skills/LICENSE.mattpocock`.
|
||||
- Shared agent workflows live in [landscape skills](http://192.168.2.28:3000/wanjakrah/landscape/src/branch/main/skills/README.md).
|
||||
Read the requested SKILL.md there before applying it. A sibling checkout is optional.
|
||||
- For code reviews use landscape's skills/code-review/SKILL.md with docs/backend-review.md.
|
||||
- Use grill-me only when explicitly requested, with its skills/grilling/SKILL.md dependency.
|
||||
Installing or mentioning a skill starts no interview.
|
||||
- Keep upstream skills unchanged; licenses and revision records live in landscape/skills/.
|
||||
- Specifications and ADRs belong to landscape. Engineering owns pinned build resources.
|
||||
|
||||
# Backend changes and reviews
|
||||
|
||||
- Before changing backend Java, read `CODING_STANDARDS.md`.
|
||||
- For reviews, use landscape's `skills/code-review/SKILL.md` together with
|
||||
`docs/backend-review.md`. Keep upstream skills unchanged.
|
||||
- Obtain maintainer agreement before adding any standards waiver; record its
|
||||
narrow scope and reason alongside the affected code or configuration.
|
||||
|
||||
# Landscape and engineering
|
||||
|
||||
- Read current [landscape guidance](http://192.168.2.28:3000/wanjakrah/landscape),
|
||||
including applicable ADRs and specifications. This repository owns its implementation.
|
||||
- Engineering resources are pinned by `engineering.lock.json`. Do not edit
|
||||
`generated/`; change engineering and deliberately adopt a released version.
|
||||
- Regenerate with `mvn -N -Pregenerate-engineering validate`; check with
|
||||
`python3 scripts/engineering.py --check`. No sibling checkout is needed.
|
||||
|
||||
11
CODING_STANDARDS.md
Normal file
11
CODING_STANDARDS.md
Normal file
@@ -0,0 +1,11 @@
|
||||
# Agile Dancers coding standards
|
||||
|
||||
Read [the pinned engineering standards](generated/CODING_STANDARDS.md) before
|
||||
changing Java. Their backend-module placeholder means `agiledancers-be` here.
|
||||
Frontend conventions remain in README.md and agiledancers-fe.
|
||||
|
||||
The existing maintainer-approved exception for Group's eight-parameter canonical
|
||||
constructor remains in `tooling/standards/waivers.tsv`. No new waiver is introduced
|
||||
by S19. For reviews also read [the local companion](docs/backend-review.md).
|
||||
|
||||
Validation evidence: [S7](docs/validation/S7-validation.md).
|
||||
11
Dockerfile
11
Dockerfile
@@ -1,9 +1,18 @@
|
||||
FROM maven:3.9-eclipse-temurin-25 AS build
|
||||
WORKDIR /workspace
|
||||
COPY pom.xml ./
|
||||
COPY .mvn .mvn
|
||||
COPY generated generated
|
||||
COPY tooling tooling
|
||||
COPY engineering.properties engineering.properties
|
||||
COPY agiledancers-be/pom.xml agiledancers-be/pom.xml
|
||||
COPY agiledancers-be/src agiledancers-be/src
|
||||
RUN mvn -B -ntp clean verify
|
||||
# Source builds run the same PostgreSQL tests as local/CI verification.
|
||||
# Supply connection configuration as BuildKit secrets, which are not retained in image layers.
|
||||
RUN --mount=type=secret,id=database_url,env=QUARKUS_DATASOURCE_JDBC_URL,required=true \
|
||||
--mount=type=secret,id=database_user,env=QUARKUS_DATASOURCE_USERNAME,required=true \
|
||||
--mount=type=secret,id=database_password,env=QUARKUS_DATASOURCE_PASSWORD,required=true \
|
||||
mvn -B -ntp clean verify
|
||||
|
||||
FROM eclipse-temurin:25-jre AS runtime
|
||||
WORKDIR /app
|
||||
|
||||
177
README.md
177
README.md
@@ -1,3 +1,18 @@
|
||||
# Landscape and shared engineering
|
||||
|
||||
Architecture decisions, specifications and project onboarding are maintained in
|
||||
[landscape](http://192.168.2.28:3000/wanjakrah/landscape). Shared agent skills are maintained in landscape/skills/. Reusable tooling,
|
||||
standards and the project generator are maintained in
|
||||
[engineering](http://192.168.2.28:3000/wanjakrah/engineering).
|
||||
|
||||
This project commits the engineering version recorded in `engineering.lock.json`
|
||||
under `generated/`. Ordinary builds need no other repository checkout. Regenerate
|
||||
with `mvn -N -Pregenerate-engineering validate`; CI checks the selected tag and
|
||||
commit with `python3 scripts/engineering.py --check`. To upgrade, update both tag
|
||||
and commit, follow engineering's UPGRADING.md, regenerate and review the diff.
|
||||
Project configuration in `engineering.properties`, workflows and `deploy/` remains
|
||||
application-owned. Read CODING_STANDARDS.md and AGENTS.md for project guidance.
|
||||
|
||||
# agiledancers
|
||||
|
||||
To map dance groups to performance days. The backend runs on Quarkus with Java 25.
|
||||
@@ -19,14 +34,19 @@ mvn -pl agiledancers-be quarkus:dev
|
||||
Run the packaged application after building:
|
||||
|
||||
```sh
|
||||
# Set DATABASE_URL, DATABASE_USER and DATABASE_PASSWORD first.
|
||||
java -jar agiledancers-be/target/quarkus-app/quarkus-run.jar
|
||||
```
|
||||
|
||||
Build and run a Docker container from the repository root (only Docker is required):
|
||||
Build a Docker container against an isolated test database (see [connection setup](docs/persistent-storage.md)):
|
||||
|
||||
```sh
|
||||
docker build -t agiledancers .
|
||||
docker run --rm --name agiledancers -p 8080:8080 agiledancers
|
||||
docker build --network=host \
|
||||
--secret id=database_url,env=TEST_DATABASE_URL \
|
||||
--secret id=database_user,env=TEST_DATABASE_USER \
|
||||
--secret id=database_password,env=TEST_DATABASE_PASSWORD -t agiledancers .
|
||||
docker run --rm --name agiledancers -p 8080:8080 \
|
||||
-e DATABASE_URL -e DATABASE_USER -e DATABASE_PASSWORD agiledancers
|
||||
```
|
||||
|
||||
The Docker build compiles the application and runs the tests in a Maven build stage,
|
||||
@@ -42,7 +62,13 @@ curl --fail http://localhost:8080/q/health/ready
|
||||
Quarkus configuration can be overridden with environment variables, for example
|
||||
`docker run --rm -e QUARKUS_HTTP_PORT=9090 -p 9090:9090 agiledancers`.
|
||||
|
||||
The REST API uses shared in-memory storage; data is lost when the process stops.
|
||||
PostgreSQL stores all school data, planning state and the latest scheduling run
|
||||
through JPA/Hibernate ORM. Persistence entities remain in the repository layer.
|
||||
Flyway initializes and versions the schema. Production requires `DATABASE_URL`
|
||||
(a JDBC PostgreSQL URL), `DATABASE_USER` and `DATABASE_PASSWORD`; missing credentials,
|
||||
connection failures and migration failures prevent startup. See
|
||||
[database setup and transition](docs/persistent-storage.md) before replacing an
|
||||
in-memory release: export every school before stopping it.
|
||||
|
||||
Architecture checks run with the normal test suite, or separately with:
|
||||
|
||||
@@ -56,7 +82,7 @@ mvn -pl agiledancers-be -Dtest=ArchitectureTest test
|
||||
| --- | --- | --- |
|
||||
| `model` | Domain entities and specifications | Model |
|
||||
| `service` | Application use cases and future ports | Model, repositories |
|
||||
| `repository` | Current in-memory persistence | Model |
|
||||
| `repository` | JPA/Hibernate entities, repositories and transaction boundaries | Model |
|
||||
| `rest` | Future REST services | Services, model |
|
||||
| `solver` | Future OR-Tools calculations | Services (ports), model |
|
||||
|
||||
@@ -83,7 +109,8 @@ the solver live in `model.scheduling`. Each group requirement combines its group
|
||||
ID and appearance count in one record.
|
||||
|
||||
`service.port.SchedulingEngine` is the application-owned interface for solving a
|
||||
scheduling problem. `SchedulingService` calls this interface; dependency injection
|
||||
scheduling problem. `SchedulingRuns` calls this interface for background runs;
|
||||
`SchedulingService` retains the synchronous service entry point. Dependency injection
|
||||
supplies `solver.OrToolsSchedulingEngine`. The port accepts and returns domain
|
||||
types, so neither the caller nor the contract depends on OR-Tools or the concrete
|
||||
school-management service. Architecture tests enforce the port's location and
|
||||
@@ -154,23 +181,47 @@ dancers; deleting a school also deletes its dancers, groups and shows.
|
||||
|
||||
Shows have a name, zero to six dated dayparts (`MORNING`, `AFTERNOON`, `EVENING`),
|
||||
with unique date/type combinations. The displayed date range comes from these
|
||||
dayparts. Each show stores `appearances` for every group type. Omitting counts
|
||||
initializes defaults on creation or the initial daypart configuration; later
|
||||
edits preserve saved counts and reject counts outside 1..N. With no dayparts,
|
||||
shows remain unscheduled and saved counts are retained.
|
||||
dayparts. Each show stores `preferredDayParts`, defaulting to two or one for
|
||||
one daypart. Ordinary groups, including PRE_EDUCATION, perform in exactly preferredDayParts
|
||||
dayparts; preschool groups once; showcases every daypart. See
|
||||
[show-scheduling-rules.md](show-scheduling-rules.md) for the full hard constraints,
|
||||
coach exceptions, ordering and optimization rules.
|
||||
|
||||
`POST /api/dance-schools/{schoolId}/shows/{showId}/schedule` uses OR-Tools to
|
||||
assign every current school group to its required number of distinct dayparts,
|
||||
minimizing the busiest/quietest appearance gap. It replaces assignments only on
|
||||
a valid, current result. The response distinguishes optimal balance from a valid
|
||||
result whose optimality is unproven. Concurrent runs return `409`; changed inputs
|
||||
also reject the result with `409`. Failed runs preserve existing assignments.
|
||||
Scheduling runs synchronously with a 30-minute solver limit; development and
|
||||
production proxies allow 31 minutes. The UI warns before edits that may invalidate
|
||||
existing schedules and offers explicit scheduling again afterward.
|
||||
`POST /api/dance-schools/{schoolId}/shows/{showId}/schedule` returns `202` with a
|
||||
run identifier and starts an ordered draft calculation in the background using
|
||||
OR-Tools with a total thirty-minute time limit. Duplicate starts for the same
|
||||
show return `409`; different shows may run concurrently. A feasible result is
|
||||
accepted without claiming optimality. Stale results and failures retain existing
|
||||
assignments and appear as distinct terminal run states.
|
||||
|
||||
School JSON exports use format version **2**, including dated dayparts, assignments,
|
||||
and all appearance counts. Version 1 files are intentionally unsupported.
|
||||
`GET .../shows/{showId}/schedule` returns the latest run (`204` if none exists).
|
||||
`GET /api/scheduling/events` streams SSE JSON objects containing a `runs` snapshot
|
||||
on connection and changes, or `runs: null` as a fifteen-second heartbeat. Clients
|
||||
recover current state on reconnection rather than requiring event replay. The
|
||||
production proxy disables buffering. The UI shows phase, elapsed time, solution
|
||||
discovery and final objective scores, and reports completion in an application-wide
|
||||
snackbar. Reloading recovers the latest state without replaying old notifications.
|
||||
The latest run and saved assignments survive restart. Startup marks formerly
|
||||
running calculations `INTERRUPTED`, preserves the previous assignments, and allows
|
||||
a new run. Results and successful status commit in one database transaction.
|
||||
|
||||
`PUT .../shows/{showId}/assignments` accepts `{"assignments":[[1,2],[3]]}`, an
|
||||
ordered list of group IDs per daypart. Incomplete or invalid drafts can be saved;
|
||||
unknown IDs and malformed lists are rejected. `POST .../shows/{showId}/finalize`
|
||||
checks every hard rule before marking a schedule final. Show responses include
|
||||
`preferredDayParts`, `finalized` and `violations`. Relevant edits invalidate final
|
||||
status. The UI supports ordering and moving performances, and displays violations.
|
||||
|
||||
`GET .../coaches` lists coach registrations. `POST .../coaches` with
|
||||
`{"dancerId":1}` registers a dancer as a coach. `PUT .../coaches/{coachId}` uses
|
||||
the same body to link an existing coach. `DELETE .../coaches/{coachId}/dancer`
|
||||
removes the identity link. The dancers screen supports maintaining these links.
|
||||
|
||||
School JSON exports use format version **3**, including preferredDayParts, schedule status and
|
||||
coach-to-dancer links. Version 2 remains importable; its schedules migrate to
|
||||
drafts with default preferredDayParts. Version 1 is unsupported. Legacy `appearances` fields are
|
||||
retained for file compatibility; S18 scheduling uses preferredDayParts and fixed type exceptions.
|
||||
The REST `appearances` input is deprecated; use `preferredDayParts`.
|
||||
|
||||
Run the HTTP integration tests with:
|
||||
|
||||
@@ -253,3 +304,87 @@ Only successful `main` runs publish images to the Synology registry. See
|
||||
[the CI management guide](docs/gitea-ci.md) for runner setup, registry secrets,
|
||||
image names, diagnostics and recovery, and [validation evidence](docs/s4-validation.md)
|
||||
for the current operational status.
|
||||
|
||||
Production releases and rollback use separate manual Gitea workflows. See the
|
||||
[deployment runbook](docs/production-deployment.md) for Synology provisioning,
|
||||
semantic version tags, release/retry/rollback and the accepted data-loss limitation
|
||||
during the one-time transition from an in-memory release, and [S6 evidence](docs/s6-validation.md) for deployment readiness.
|
||||
|
||||
### Java formatting (S8)
|
||||
|
||||
Maven is the authoritative formatting path. The root build runs the actual
|
||||
Palantir Java Format **2.101.0**, Palantir style, through a custom OpenRewrite
|
||||
**8.63.4** text recipe in `generated/tooling/format/Format.java`. The pinned
|
||||
`exec-maven-plugin` **3.6.3** launches that helper with JDK 25. Native import
|
||||
ordering and unused-import removal are enabled; extra Javadoc formatting is off.
|
||||
No OpenRewrite AutoFormat or additional refactoring recipes run.
|
||||
|
||||
From the repository root:
|
||||
|
||||
```sh
|
||||
mvn -N exec:exec@format # apply, without application compilation
|
||||
mvn -N exec:exec@format -Dformat.mode=check # check, without application compilation
|
||||
mvn compile # applies during validate, before compilation
|
||||
mvn -Dformat.mode=check clean verify # CI: fails before compilation on differences
|
||||
```
|
||||
|
||||
Root `compile`, `test`, `verify` and `install` may rewrite maintained Java files
|
||||
in the validate phase. CI's Java orchestration passes `-Dformat.mode=check` and
|
||||
never repairs source files. On failure, the output lists paths and the apply
|
||||
command. Apply locally, inspect and commit the result, then repeat the check.
|
||||
Run root Maven commands; selecting only the backend with `-pl agiledancers-be`
|
||||
bypasses the root formatting execution.
|
||||
|
||||
The scope is every `.java` file recursively under `agiledancers-be/src/main/java`,
|
||||
`agiledancers-be/src/test/java`, `ci` (including the runner helper), and the
|
||||
formatter helper itself. Generated `target` files and frontend files are excluded.
|
||||
The adapter keeps paths and UTF-8 content, computes changes before writing, and
|
||||
uses atomic per-file replacements. Unsupported syntax fails with its path/cause.
|
||||
The source launcher compiles the adapter directly in memory from a clean checkout;
|
||||
no custom recipe jar needs compiling or installing beforehand. Maven downloads
|
||||
only pinned external tooling dependencies on first use.
|
||||
|
||||
Successfully checked/formatted content hashes are cached in
|
||||
`target/palantir-cache.properties`; new or changed files are always processed.
|
||||
The root POM, adapter source, dependency classpath and JDK version invalidate the cache. `clean` removes
|
||||
it. Check and apply share the engine, options, scope and successful-content cache;
|
||||
a failing check does not write sources or update the cache. For controlled timing
|
||||
comparisons only, `-Dformat.skip=true` disables the execution.
|
||||
|
||||
Editors are optional. Palantir supplies an IntelliJ plugin (see the
|
||||
[upstream instructions](https://github.com/palantir/palantir-java-format#intellij-plugin)).
|
||||
Use it only with the same engine version/style and Javadoc setting; if the editor
|
||||
cannot match them, use Maven to format instead. Editor import optimization or
|
||||
format-on-save must not override Maven's result. Other editors can invoke the
|
||||
standalone Maven apply command as an external tool.
|
||||
|
||||
Verification and measurements are recorded in
|
||||
[the S8 evidence](docs/validation/S8-formatting-evidence.md). Reproduce the isolated failure,
|
||||
UTF-8, import, cache and idempotence checks with
|
||||
the formatter integration test in engineering (`python3 tests/format_integration.py`), and the sequential end-to-end build measurements
|
||||
with `python3 tooling/format/benchmark.py` (requires the ordinary backend test
|
||||
prerequisites). Results are written under `target/`.
|
||||
|
||||
## Backend coding standards (S7)
|
||||
|
||||
Read [CODING_STANDARDS.md](CODING_STANDARDS.md) before changing backend Java.
|
||||
It defines the agreed rules, exact metric limits, manual review duties, pinned
|
||||
analysis tools, coverage policy and standalone commands. `mvn verify` runs standards
|
||||
checks and requires at least 80% module-wide backend line coverage. CI also collects
|
||||
and merges coverage from Playwright before publication. Reports are retained with
|
||||
CI diagnostics. See [S7 validation](docs/validation/S7-validation.md) for current migration status.
|
||||
|
||||
|
||||
Existing-school mutations require `X-School-Revision` with the revision loaded by
|
||||
the editor. `GET /api/dance-schools/{id}/data` returns the school and all its editor
|
||||
data from one consistent snapshot; `school.revision` identifies that snapshot.
|
||||
Missing or stale revisions receive `409` with a reload instruction. The import
|
||||
preview returns `existingRevision` for replacement confirmation. Creation of a
|
||||
new school does not need a revision. The frontend never automatically retries a
|
||||
conflicting edit with a newer revision.
|
||||
|
||||
|
||||
Domain-to-view projection is generated by MapStruct in `rest.mapping.DomainViewMapper`.
|
||||
It is a CDI bean with `unmappedTargetPolicy = ERROR`, so unmatched target fields fail
|
||||
compilation. REST resources supply calculated validation results explicitly. The
|
||||
entity-to-domain graph mappings remain handwritten in the repository layer.
|
||||
|
||||
@@ -14,6 +14,11 @@
|
||||
<packaging>jar</packaging>
|
||||
|
||||
<dependencies>
|
||||
<dependency><groupId>org.mapstruct</groupId><artifactId>mapstruct</artifactId><version>${mapstruct.version}</version></dependency>
|
||||
<dependency><groupId>io.quarkus</groupId><artifactId>quarkus-hibernate-orm</artifactId></dependency>
|
||||
<dependency><groupId>io.quarkus</groupId><artifactId>quarkus-jdbc-postgresql</artifactId></dependency>
|
||||
<dependency><groupId>io.quarkus</groupId><artifactId>quarkus-flyway</artifactId></dependency>
|
||||
<dependency><groupId>org.flywaydb</groupId><artifactId>flyway-database-postgresql</artifactId></dependency>
|
||||
<dependency>
|
||||
<groupId>io.quarkus</groupId>
|
||||
<artifactId>quarkus-rest-jackson</artifactId>
|
||||
@@ -32,6 +37,7 @@
|
||||
</dependency>
|
||||
|
||||
<!-- test dependencies -->
|
||||
<dependency><groupId>io.quarkus</groupId><artifactId>quarkus-jacoco</artifactId><scope>test</scope></dependency>
|
||||
<dependency>
|
||||
<groupId>com.tngtech.archunit</groupId>
|
||||
<artifactId>archunit-junit5</artifactId>
|
||||
@@ -62,6 +68,70 @@
|
||||
|
||||
<build>
|
||||
<plugins>
|
||||
<plugin>
|
||||
<groupId>org.apache.maven.plugins</groupId><artifactId>maven-compiler-plugin</artifactId>
|
||||
<configuration>
|
||||
<annotationProcessorPaths combine.children="append">
|
||||
<path><groupId>org.mapstruct</groupId><artifactId>mapstruct-processor</artifactId><version>${mapstruct.version}</version></path>
|
||||
<path><groupId>org.projectlombok</groupId><artifactId>lombok-mapstruct-binding</artifactId><version>${lombok-mapstruct-binding.version}</version></path>
|
||||
</annotationProcessorPaths>
|
||||
<compilerArgs><arg>-Amapstruct.suppressGeneratorTimestamp=true</arg></compilerArgs>
|
||||
</configuration>
|
||||
</plugin>
|
||||
<plugin>
|
||||
<groupId>org.apache.maven.plugins</groupId><artifactId>maven-dependency-plugin</artifactId><version>3.8.1</version>
|
||||
<executions><execution><id>standards-classpath</id><phase>prepare-package</phase><goals><goal>build-classpath</goal></goals>
|
||||
<configuration><outputFile>${project.build.directory}/standards-classpath.txt</outputFile></configuration>
|
||||
</execution>
|
||||
<execution><id>coverage-tools</id><phase>prepare-package</phase><goals><goal>copy</goal></goals>
|
||||
<configuration><artifactItems>
|
||||
<artifactItem><groupId>org.jacoco</groupId><artifactId>org.jacoco.agent</artifactId><version>0.8.14</version><classifier>runtime</classifier><destFileName>agent.jar</destFileName></artifactItem>
|
||||
<artifactItem><groupId>org.jacoco</groupId><artifactId>org.jacoco.cli</artifactId><version>0.8.14</version><classifier>nodeps</classifier><destFileName>cli.jar</destFileName></artifactItem>
|
||||
</artifactItems><outputDirectory>${project.build.directory}/coverage-tools</outputDirectory></configuration>
|
||||
</execution></executions>
|
||||
</plugin>
|
||||
<plugin>
|
||||
<groupId>org.codehaus.mojo</groupId><artifactId>exec-maven-plugin</artifactId><version>3.6.3</version>
|
||||
<executions><execution><id>standards</id><phase>verify</phase><goals><goal>exec</goal></goals>
|
||||
<configuration>
|
||||
<executable>${maven.home}/bin/mvn</executable>
|
||||
<workingDirectory>${maven.multiModuleProjectDirectory}</workingDirectory>
|
||||
<arguments><argument>-B</argument><argument>-ntp</argument><argument>-f</argument>
|
||||
<argument>${maven.multiModuleProjectDirectory}/generated/tooling/standards/pom.xml</argument>
|
||||
<argument>test</argument><argument>exec:java</argument>
|
||||
</arguments>
|
||||
</configuration>
|
||||
</execution>
|
||||
<execution><id>coverage</id><phase>verify</phase><goals><goal>exec</goal></goals>
|
||||
<configuration><executable>${java.home}/bin/java</executable><arguments>
|
||||
<argument>${maven.multiModuleProjectDirectory}/generated/tooling/coverage/Coverage.java</argument>
|
||||
<argument>${project.build.directory}/site/jacoco/jacoco.xml</argument>
|
||||
</arguments></configuration>
|
||||
</execution>
|
||||
<execution><id>combined-coverage</id><phase>none</phase><goals><goal>exec</goal></goals>
|
||||
<configuration><executable>${java.home}/bin/java</executable><arguments>
|
||||
<argument>${maven.multiModuleProjectDirectory}/generated/tooling/coverage/Coverage.java</argument>
|
||||
<argument>${project.build.directory}/site/jacoco-combined/jacoco.xml</argument>
|
||||
</arguments></configuration>
|
||||
</execution></executions>
|
||||
</plugin>
|
||||
<plugin>
|
||||
<groupId>org.jacoco</groupId><artifactId>jacoco-maven-plugin</artifactId><version>0.8.14</version>
|
||||
<executions>
|
||||
<execution><id>agent</id><goals><goal>prepare-agent</goal></goals><configuration>
|
||||
<exclClassLoaders>*QuarkusClassLoader</exclClassLoaders><destFile>${project.build.directory}/jacoco.exec</destFile><append>true</append>
|
||||
</configuration></execution>
|
||||
<execution><id>report</id><phase>post-integration-test</phase><goals><goal>report</goal></goals></execution>
|
||||
<execution><id>combined</id><phase>none</phase><goals><goal>merge</goal></goals><configuration>
|
||||
<fileSets><fileSet><directory>${project.build.directory}</directory><includes><include>jacoco.exec</include><include>playwright.exec</include></includes></fileSet></fileSets>
|
||||
<destFile>${project.build.directory}/jacoco-combined.exec</destFile>
|
||||
</configuration></execution>
|
||||
<execution><id>combined-report</id><phase>none</phase><goals><goal>report</goal></goals><configuration>
|
||||
<dataFile>${project.build.directory}/jacoco-combined.exec</dataFile>
|
||||
<outputDirectory>${project.build.directory}/site/jacoco-combined</outputDirectory>
|
||||
</configuration></execution>
|
||||
</executions>
|
||||
</plugin>
|
||||
<plugin>
|
||||
<groupId>io.quarkus</groupId>
|
||||
<artifactId>quarkus-maven-plugin</artifactId>
|
||||
|
||||
@@ -1,10 +1,13 @@
|
||||
package nl.agilecc.agiledancers.model;
|
||||
|
||||
import lombok.*;
|
||||
|
||||
import java.util.Collections;
|
||||
import java.util.HashSet;
|
||||
import java.util.Set;
|
||||
import lombok.EqualsAndHashCode;
|
||||
import lombok.Getter;
|
||||
import lombok.NoArgsConstructor;
|
||||
import lombok.Setter;
|
||||
import lombok.ToString;
|
||||
|
||||
@Getter
|
||||
@Setter
|
||||
@@ -14,17 +17,15 @@ import java.util.Set;
|
||||
public abstract class AbstractEntity {
|
||||
private Long id;
|
||||
|
||||
protected <T> void addNullSafe(Set<T> set, T t) {
|
||||
protected <T> void addNullSafe(final Set<T> set, final T t) {
|
||||
emptyIfNull(set).add(t);
|
||||
}
|
||||
|
||||
private <T> Set<T> emptyIfNull(Set<T> set) {
|
||||
return set == null ?
|
||||
new HashSet<>() :
|
||||
set;
|
||||
private <T> Set<T> emptyIfNull(final Set<T> set) {
|
||||
return set == null ? new HashSet<>() : set;
|
||||
}
|
||||
|
||||
protected <T> Set<T> unmodifiableNullSafe(Set<T> set) {
|
||||
protected <T> Set<T> unmodifiableNullSafe(final Set<T> set) {
|
||||
return set == null ? Collections.emptySet() : Collections.unmodifiableSet(set);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,11 @@
|
||||
package nl.agilecc.agiledancers.model;
|
||||
|
||||
import lombok.*;
|
||||
import lombok.AccessLevel;
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.Builder;
|
||||
import lombok.Getter;
|
||||
import lombok.Setter;
|
||||
import lombok.ToString;
|
||||
|
||||
@Getter
|
||||
@AllArgsConstructor(access = AccessLevel.PRIVATE)
|
||||
@@ -9,4 +14,7 @@ import lombok.*;
|
||||
public class Coach extends AbstractEntity {
|
||||
@ToString.Include
|
||||
private final String name;
|
||||
|
||||
@Setter
|
||||
private Dancer dancer;
|
||||
}
|
||||
|
||||
@@ -1,12 +1,16 @@
|
||||
package nl.agilecc.agiledancers.model;
|
||||
|
||||
import lombok.*;
|
||||
import java.util.LinkedHashSet;
|
||||
import java.util.Set;
|
||||
import lombok.AccessLevel;
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.Builder;
|
||||
import lombok.Getter;
|
||||
import lombok.Setter;
|
||||
import lombok.ToString;
|
||||
import nl.agilecc.agiledancers.model.specifications.DanceGroupStyle;
|
||||
import nl.agilecc.agiledancers.model.specifications.DanceGroupType;
|
||||
|
||||
import java.util.Set;
|
||||
import java.util.LinkedHashSet;
|
||||
|
||||
@Getter
|
||||
@AllArgsConstructor(access = AccessLevel.PRIVATE)
|
||||
@Builder
|
||||
@@ -15,21 +19,27 @@ public class DanceGroup extends AbstractEntity {
|
||||
@ToString.Include
|
||||
@Setter
|
||||
private String category;
|
||||
|
||||
@ToString.Include
|
||||
@Setter
|
||||
private DanceGroupType type;
|
||||
|
||||
@ToString.Include
|
||||
@Setter
|
||||
private int startAge;
|
||||
|
||||
@ToString.Include
|
||||
@Setter
|
||||
private int endAge;
|
||||
|
||||
@ToString.Include
|
||||
@Builder.Default
|
||||
private final Set<Dancer> dancers = new LinkedHashSet<>();
|
||||
|
||||
@ToString.Include
|
||||
@Builder.Default
|
||||
private final Set<DanceGroupStyle> styles = new LinkedHashSet<>();
|
||||
|
||||
@ToString.Include
|
||||
private Coach coach;
|
||||
|
||||
@@ -37,7 +47,7 @@ public class DanceGroup extends AbstractEntity {
|
||||
return super.unmodifiableNullSafe(dancers);
|
||||
}
|
||||
|
||||
public void addDancer(Dancer dancer) {
|
||||
public void addDancer(final Dancer dancer) {
|
||||
super.addNullSafe(dancers, dancer);
|
||||
}
|
||||
|
||||
@@ -45,15 +55,15 @@ public class DanceGroup extends AbstractEntity {
|
||||
return super.unmodifiableNullSafe(styles);
|
||||
}
|
||||
|
||||
public void addStyle(DanceGroupStyle style) {
|
||||
public void addStyle(final DanceGroupStyle style) {
|
||||
super.addNullSafe(styles, style);
|
||||
}
|
||||
|
||||
public void removeDancer(Dancer dancer) {
|
||||
public void removeDancer(final Dancer dancer) {
|
||||
dancers.remove(dancer);
|
||||
}
|
||||
|
||||
public void replaceStyles(Set<DanceGroupStyle> styles) {
|
||||
public void replaceStyles(final Set<DanceGroupStyle> styles) {
|
||||
this.styles.clear();
|
||||
this.styles.addAll(styles);
|
||||
}
|
||||
|
||||
@@ -1,9 +1,13 @@
|
||||
package nl.agilecc.agiledancers.model;
|
||||
|
||||
import lombok.*;
|
||||
|
||||
import java.util.Set;
|
||||
import java.util.LinkedHashSet;
|
||||
import java.util.Set;
|
||||
import lombok.AccessLevel;
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.Builder;
|
||||
import lombok.Getter;
|
||||
import lombok.Setter;
|
||||
import lombok.ToString;
|
||||
|
||||
@Getter
|
||||
@AllArgsConstructor(access = AccessLevel.PRIVATE)
|
||||
@@ -12,14 +16,22 @@ import java.util.LinkedHashSet;
|
||||
public class DanceSchool extends AbstractEntity {
|
||||
@Builder.Default
|
||||
private final java.util.UUID key = java.util.UUID.randomUUID();
|
||||
|
||||
@Setter
|
||||
private String name;
|
||||
|
||||
@Setter
|
||||
private long revision;
|
||||
|
||||
@Builder.Default
|
||||
private final Set<Dancer> dancers = new LinkedHashSet<>();
|
||||
|
||||
@Builder.Default
|
||||
private final Set<DanceGroup> danceGroups = new LinkedHashSet<>();
|
||||
|
||||
@Builder.Default
|
||||
private final Set<Show> shows = new LinkedHashSet<>();
|
||||
|
||||
@Builder.Default
|
||||
private final Set<Coach> coaches = new LinkedHashSet<>();
|
||||
|
||||
@@ -39,31 +51,31 @@ public class DanceSchool extends AbstractEntity {
|
||||
return super.unmodifiableNullSafe(coaches);
|
||||
}
|
||||
|
||||
public void addDancer(Dancer dancer) {
|
||||
public void addDancer(final Dancer dancer) {
|
||||
super.addNullSafe(dancers, dancer);
|
||||
}
|
||||
|
||||
public void addDanceGroup(DanceGroup danceGroup) {
|
||||
public void addDanceGroup(final DanceGroup danceGroup) {
|
||||
super.addNullSafe(danceGroups, danceGroup);
|
||||
}
|
||||
|
||||
public void addShow(Show show) {
|
||||
public void addShow(final Show show) {
|
||||
super.addNullSafe(shows, show);
|
||||
}
|
||||
|
||||
public void addCoach(Coach coach) {
|
||||
public void addCoach(final Coach coach) {
|
||||
super.addNullSafe(coaches, coach);
|
||||
}
|
||||
|
||||
public void removeDancer(Dancer entity) {
|
||||
public void removeDancer(final Dancer entity) {
|
||||
dancers.remove(entity);
|
||||
}
|
||||
|
||||
public void removeDanceGroup(DanceGroup entity) {
|
||||
public void removeDanceGroup(final DanceGroup entity) {
|
||||
danceGroups.remove(entity);
|
||||
}
|
||||
|
||||
public void removeShow(Show entity) {
|
||||
public void removeShow(final Show entity) {
|
||||
shows.remove(entity);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,9 +1,13 @@
|
||||
package nl.agilecc.agiledancers.model;
|
||||
|
||||
import lombok.*;
|
||||
|
||||
import java.time.LocalDate;
|
||||
import java.time.Period;
|
||||
import lombok.AccessLevel;
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.Builder;
|
||||
import lombok.Getter;
|
||||
import lombok.Setter;
|
||||
import lombok.ToString;
|
||||
|
||||
@Getter
|
||||
@AllArgsConstructor(access = AccessLevel.PRIVATE)
|
||||
@@ -13,6 +17,7 @@ public class Dancer extends AbstractEntity {
|
||||
@ToString.Include
|
||||
@Setter
|
||||
private String name;
|
||||
|
||||
@ToString.Include
|
||||
@Setter
|
||||
private LocalDate birthDate;
|
||||
|
||||
@@ -1,10 +1,14 @@
|
||||
package nl.agilecc.agiledancers.model;
|
||||
|
||||
import lombok.*;
|
||||
import nl.agilecc.agiledancers.model.specifications.DayPartType;
|
||||
|
||||
import java.util.List;
|
||||
import java.time.LocalDate;
|
||||
import java.util.List;
|
||||
import lombok.AccessLevel;
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.Builder;
|
||||
import lombok.Getter;
|
||||
import lombok.Setter;
|
||||
import lombok.ToString;
|
||||
import nl.agilecc.agiledancers.model.specifications.DayPartType;
|
||||
|
||||
@Getter
|
||||
@AllArgsConstructor(access = AccessLevel.PRIVATE)
|
||||
@@ -13,12 +17,16 @@ import java.time.LocalDate;
|
||||
public class DayPart extends AbstractEntity {
|
||||
@ToString.Include
|
||||
private final DayPartType type;
|
||||
|
||||
private final LocalDate date;
|
||||
|
||||
@ToString.Include
|
||||
@Setter
|
||||
private List<DanceGroup> danceGroups;
|
||||
|
||||
public void removeDanceGroup(DanceGroup group) {
|
||||
danceGroups = danceGroups.stream().filter(value -> value != group).toList();
|
||||
public void removeDanceGroup(final DanceGroup group) {
|
||||
danceGroups = danceGroups.stream()
|
||||
.filter((final DanceGroup value) -> value != group)
|
||||
.toList();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,9 +1,13 @@
|
||||
package nl.agilecc.agiledancers.model;
|
||||
|
||||
import lombok.*;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import lombok.AccessLevel;
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.Builder;
|
||||
import lombok.Getter;
|
||||
import lombok.Setter;
|
||||
import lombok.ToString;
|
||||
import nl.agilecc.agiledancers.model.specifications.DanceGroupType;
|
||||
|
||||
@Getter
|
||||
@@ -13,8 +17,16 @@ import nl.agilecc.agiledancers.model.specifications.DanceGroupType;
|
||||
public class Show extends AbstractEntity {
|
||||
@Setter
|
||||
private String name;
|
||||
|
||||
@Setter
|
||||
private Integer preferredDayParts;
|
||||
|
||||
@Setter
|
||||
private boolean finalized;
|
||||
|
||||
@Setter
|
||||
private Map<DanceGroupType, Integer> appearances;
|
||||
|
||||
@ToString.Include
|
||||
@Setter
|
||||
private List<DayPart> dayParts;
|
||||
|
||||
@@ -1,3 +1,21 @@
|
||||
package nl.agilecc.agiledancers.model.scheduling;
|
||||
|
||||
public record GroupRequirement(long groupId, int appearances) {}
|
||||
import java.util.Set;
|
||||
import nl.agilecc.agiledancers.model.specifications.DanceGroupStyle;
|
||||
|
||||
public record GroupRequirement(
|
||||
long groupId,
|
||||
int appearances,
|
||||
boolean showcase,
|
||||
Set<Long> dancerIds,
|
||||
Set<DanceGroupStyle> styles,
|
||||
double averageAge) {
|
||||
public GroupRequirement {
|
||||
dancerIds = Set.copyOf(dancerIds);
|
||||
styles = Set.copyOf(styles);
|
||||
}
|
||||
|
||||
public GroupRequirement(final long groupId, final int appearances) {
|
||||
this(groupId, appearances, false, Set.of(), Set.of(), 0);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,117 @@
|
||||
package nl.agilecc.agiledancers.model.scheduling;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.HashMap;
|
||||
import java.util.HashSet;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
|
||||
/** Shared hard-rule validation for solver results and manually edited schedules. */
|
||||
public final class ScheduleRules {
|
||||
private ScheduleRules() {}
|
||||
|
||||
public static List<String> violations(final SchedulingInput input, final List<List<Long>> parts) {
|
||||
final List<String> errors = new ArrayList<>();
|
||||
if (input.dayParts() == 0 || input.preferredDayParts() < 1 || input.preferredDayParts() > input.dayParts()) {
|
||||
errors.add("Configure dayparts and preferredDayParts between 1 and the number of dayparts.");
|
||||
}
|
||||
if (parts == null || parts.size() != input.dayParts() || parts.stream().anyMatch(java.util.Objects::isNull)) {
|
||||
errors.add("Assignments must contain one list for every daypart.");
|
||||
return List.copyOf(errors);
|
||||
}
|
||||
final Map<Long, GroupRequirement> groups = new HashMap<>();
|
||||
input.groups().forEach((final GroupRequirement g) -> groups.put(g.groupId(), g));
|
||||
for (int p = 0; p < parts.size(); p++) {
|
||||
validatePart(parts.get(p), groups, p, errors);
|
||||
}
|
||||
appearances(input, parts, errors);
|
||||
attendance(input, parts, errors);
|
||||
final int min = parts.stream().mapToInt(List::size).min().orElse(0);
|
||||
final int max = parts.stream().mapToInt(List::size).max().orElse(0);
|
||||
if (max - min > 2) {
|
||||
errors.add("Daypart performance counts differ by more than two.");
|
||||
}
|
||||
return List.copyOf(errors);
|
||||
}
|
||||
|
||||
private static void validatePart(
|
||||
final List<Long> ids, final Map<Long, GroupRequirement> groups, final int part, final List<String> errors) {
|
||||
if (new HashSet<>(ids).size() != ids.size() || !groups.keySet().containsAll(ids)) {
|
||||
errors.add("Daypart " + (part + 1) + " contains duplicate, unknown or empty groups.");
|
||||
return;
|
||||
}
|
||||
final List<GroupRequirement> ordered = ids.stream().map(groups::get).toList();
|
||||
showcases(ordered, part, errors);
|
||||
gaps(ordered, part, errors);
|
||||
}
|
||||
|
||||
private static void showcases(final List<GroupRequirement> groups, final int part, final List<String> errors) {
|
||||
final long count = groups.stream().filter(GroupRequirement::showcase).count();
|
||||
if (count == 0) {
|
||||
return;
|
||||
}
|
||||
final boolean first = groups.getFirst().showcase();
|
||||
final boolean last = groups.getLast().showcase();
|
||||
if ((count == 1 && !first && !last) || (count >= 2 && (!first || !last))) {
|
||||
errors.add("Daypart " + (part + 1) + " must place showcases at its opening/closing.");
|
||||
}
|
||||
}
|
||||
|
||||
private static void gaps(final List<GroupRequirement> groups, final int part, final List<String> errors) {
|
||||
final Map<Long, Integer> last = new HashMap<>();
|
||||
for (int i = 0; i < groups.size(); i++) {
|
||||
for (final long dancer : groups.get(i).dancerIds()) {
|
||||
final Integer previous = last.put(dancer, i);
|
||||
if (previous != null && i - previous < 3) {
|
||||
errors.add("Daypart " + (part + 1) + ": dancer " + dancer
|
||||
+ " needs two intervening performances before group "
|
||||
+ groups.get(i).groupId() + ".");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static void appearances(
|
||||
final SchedulingInput input, final List<List<Long>> parts, final List<String> errors) {
|
||||
for (final GroupRequirement group : input.groups()) {
|
||||
if (parts.stream()
|
||||
.filter((final List<Long> ids) -> ids.contains(group.groupId()))
|
||||
.count()
|
||||
!= group.appearances()) {
|
||||
errors.add("Group " + group.groupId() + " requires " + group.appearances() + " dayparts.");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static void attendance(
|
||||
final SchedulingInput input, final List<List<Long>> parts, final List<String> errors) {
|
||||
final Set<Long> exempt = new HashSet<>(input.coachDancerIds());
|
||||
input.groups().stream()
|
||||
.filter(GroupRequirement::showcase)
|
||||
.forEach((final GroupRequirement g) -> exempt.addAll(g.dancerIds()));
|
||||
final Map<Long, Set<Integer>> attendance = new HashMap<>();
|
||||
for (final GroupRequirement group : input.groups()) {
|
||||
recordAttendance(group, parts, attendance);
|
||||
}
|
||||
attendance.forEach((final Long dancer, final Set<Integer> dayparts) -> {
|
||||
if (!exempt.contains(dancer) && dayparts.size() > input.preferredDayParts()) {
|
||||
errors.add(
|
||||
"Dancer " + dancer + " exceeds preferredDayParts=" + input.preferredDayParts() + " dayparts.");
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private static void recordAttendance(
|
||||
final GroupRequirement group, final List<List<Long>> parts, final Map<Long, Set<Integer>> attendance) {
|
||||
for (int p = 0; p < parts.size(); p++) {
|
||||
if (parts.get(p).contains(group.groupId())) {
|
||||
for (final long dancer : group.dancerIds()) {
|
||||
attendance
|
||||
.computeIfAbsent(dancer, (final Long id) -> new HashSet<>())
|
||||
.add(p);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
package nl.agilecc.agiledancers.model.scheduling;
|
||||
|
||||
public enum SchedulingFailureKind {
|
||||
INFEASIBLE,
|
||||
NO_SOLUTION,
|
||||
SOLVER_FAILED
|
||||
}
|
||||
@@ -1,9 +1,27 @@
|
||||
package nl.agilecc.agiledancers.model.scheduling;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Set;
|
||||
|
||||
public record SchedulingInput(long showId, long revision, int dayParts, List<GroupRequirement> groups) {
|
||||
/**
|
||||
* Immutable solver snapshot.
|
||||
*
|
||||
* @param coachDancerIds dancer registration IDs linked to coaches, used for participation exemptions
|
||||
*/
|
||||
public record SchedulingInput(
|
||||
long showId,
|
||||
long revision,
|
||||
int dayParts,
|
||||
List<GroupRequirement> groups,
|
||||
int preferredDayParts,
|
||||
Set<Long> coachDancerIds) {
|
||||
public SchedulingInput {
|
||||
groups = List.copyOf(groups);
|
||||
coachDancerIds = Set.copyOf(coachDancerIds);
|
||||
}
|
||||
|
||||
public SchedulingInput(
|
||||
final long showId, final long revision, final int dayParts, final List<GroupRequirement> groups) {
|
||||
this(showId, revision, dayParts, groups, Math.min(2, dayParts), Set.of());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
package nl.agilecc.agiledancers.model.scheduling;
|
||||
|
||||
public enum SchedulingPhase {
|
||||
PREPARING,
|
||||
ATTENDANCE,
|
||||
SPREAD,
|
||||
VARIETY
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
package nl.agilecc.agiledancers.model.scheduling;
|
||||
|
||||
/** Progress is observational; intermediate assignments are never published or applied. */
|
||||
public record SchedulingProgress(SchedulingPhase phase, boolean feasible) {}
|
||||
@@ -1,5 +1,16 @@
|
||||
package nl.agilecc.agiledancers.model.scheduling;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.Collections;
|
||||
import java.util.List;
|
||||
|
||||
public record SchedulingResult(boolean optimal, List<List<Long>> assignments) {}
|
||||
public record SchedulingResult(boolean optimal, List<List<Long>> assignments) {
|
||||
public SchedulingResult {
|
||||
assignments = assignments == null
|
||||
? null
|
||||
: assignments.stream()
|
||||
.map((final List<Long> part) ->
|
||||
part == null ? null : Collections.unmodifiableList(new ArrayList<>(part)))
|
||||
.toList();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
package nl.agilecc.agiledancers.model.scheduling;
|
||||
|
||||
import java.time.Instant;
|
||||
import java.util.UUID;
|
||||
|
||||
/** Latest immutable state for one show's calculation; runId identifies notifications across reconnects. */
|
||||
public record SchedulingRun(
|
||||
UUID runId,
|
||||
long schoolId,
|
||||
long showId,
|
||||
String showName,
|
||||
Instant startedAt,
|
||||
Instant finishedAt,
|
||||
SchedulingState state,
|
||||
SchedulingProgress progress,
|
||||
String message,
|
||||
SchedulingSummary summary) {
|
||||
public static SchedulingRun started(final long schoolId, final long showId, final String name) {
|
||||
return new SchedulingRun(
|
||||
UUID.randomUUID(),
|
||||
schoolId,
|
||||
showId,
|
||||
name,
|
||||
Instant.now().truncatedTo(java.time.temporal.ChronoUnit.MICROS),
|
||||
null,
|
||||
SchedulingState.RUNNING,
|
||||
new SchedulingProgress(SchedulingPhase.PREPARING, false),
|
||||
"Berekening gestart.",
|
||||
null);
|
||||
}
|
||||
|
||||
public SchedulingRun progressed(final SchedulingProgress update) {
|
||||
return new SchedulingRun(
|
||||
runId,
|
||||
schoolId,
|
||||
showId,
|
||||
showName,
|
||||
startedAt,
|
||||
null,
|
||||
state,
|
||||
new SchedulingProgress(update.phase(), progress.feasible() || update.feasible()),
|
||||
message,
|
||||
null);
|
||||
}
|
||||
|
||||
public SchedulingRun completed(
|
||||
final SchedulingState outcome, final String explanation, final SchedulingSummary scores) {
|
||||
return new SchedulingRun(
|
||||
runId,
|
||||
schoolId,
|
||||
showId,
|
||||
showName,
|
||||
startedAt,
|
||||
Instant.now().truncatedTo(java.time.temporal.ChronoUnit.MICROS),
|
||||
outcome,
|
||||
new SchedulingProgress(progress.phase(), progress.feasible() || scores != null),
|
||||
explanation,
|
||||
scores);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
package nl.agilecc.agiledancers.model.scheduling;
|
||||
|
||||
public enum SchedulingState {
|
||||
RUNNING,
|
||||
INTERRUPTED,
|
||||
OPTIMAL,
|
||||
VALID,
|
||||
STALE,
|
||||
INVALID_INPUT,
|
||||
INFEASIBLE,
|
||||
NO_SOLUTION,
|
||||
SOLVER_FAILED
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
package nl.agilecc.agiledancers.model.scheduling;
|
||||
|
||||
import java.util.HashMap;
|
||||
import java.util.HashSet;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
|
||||
/** Scores match the solver objectives; lower values are better. */
|
||||
public record SchedulingSummary(long attendanceExcess, int performanceSpread, long varietyPenalty) {
|
||||
public static SchedulingSummary of(final SchedulingInput input, final SchedulingResult result) {
|
||||
final List<List<Long>> parts = result.assignments();
|
||||
final Map<Long, GroupRequirement> groups = new HashMap<>();
|
||||
input.groups().forEach((final GroupRequirement group) -> groups.put(group.groupId(), group));
|
||||
final int min = parts.stream().mapToInt(List::size).min().orElse(0);
|
||||
final int max = parts.stream().mapToInt(List::size).max().orElse(0);
|
||||
return new SchedulingSummary(attendance(input, parts), max - min, variety(parts, groups));
|
||||
}
|
||||
|
||||
private static long attendance(final SchedulingInput input, final List<List<Long>> parts) {
|
||||
final Set<Long> showcases = new HashSet<>();
|
||||
final Map<Long, Set<Integer>> attendance = new HashMap<>();
|
||||
for (final GroupRequirement group : input.groups()) {
|
||||
if (group.showcase()) {
|
||||
showcases.addAll(group.dancerIds());
|
||||
} else {
|
||||
recordAttendance(group, parts, attendance);
|
||||
}
|
||||
}
|
||||
showcases.removeAll(input.coachDancerIds());
|
||||
return showcases.stream()
|
||||
.mapToLong((final Long dancer) ->
|
||||
Math.max(0, attendance.getOrDefault(dancer, Set.of()).size() - input.preferredDayParts()))
|
||||
.sum();
|
||||
}
|
||||
|
||||
private static void recordAttendance(
|
||||
final GroupRequirement group, final List<List<Long>> parts, final Map<Long, Set<Integer>> attendance) {
|
||||
for (int part = 0; part < parts.size(); part++) {
|
||||
if (parts.get(part).contains(group.groupId())) {
|
||||
for (final long dancer : group.dancerIds()) {
|
||||
attendance
|
||||
.computeIfAbsent(dancer, (final Long id) -> new HashSet<>())
|
||||
.add(part);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static long variety(final List<List<Long>> parts, final Map<Long, GroupRequirement> groups) {
|
||||
long penalty = 0;
|
||||
for (final List<Long> part : parts) {
|
||||
for (int index = 1; index < part.size(); index++) {
|
||||
penalty += VarietyScore.penalty(groups.get(part.get(index - 1)), groups.get(part.get(index)));
|
||||
}
|
||||
}
|
||||
return penalty;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
package nl.agilecc.agiledancers.model.scheduling;
|
||||
|
||||
import java.util.HashSet;
|
||||
import java.util.Set;
|
||||
import nl.agilecc.agiledancers.model.specifications.DanceGroupStyle;
|
||||
|
||||
public final class VarietyScore {
|
||||
private VarietyScore() {}
|
||||
|
||||
/** Integer CP-SAT score: each equally weighted component ranges from zero to one million. */
|
||||
public static long penalty(final GroupRequirement a, final GroupRequirement b) {
|
||||
final Set<DanceGroupStyle> union = new HashSet<>(a.styles());
|
||||
union.addAll(b.styles());
|
||||
final Set<DanceGroupStyle> shared = new HashSet<>(a.styles());
|
||||
shared.retainAll(b.styles());
|
||||
final double style = union.isEmpty() ? 0 : (double) shared.size() / union.size();
|
||||
final double age = Math.max(0, 1 - Math.abs(a.averageAge() - b.averageAge()) / 5);
|
||||
return Math.round(1_000_000 * style) + Math.round(1_000_000 * age);
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,10 @@
|
||||
package nl.agilecc.agiledancers.model.specifications;
|
||||
|
||||
public enum DanceGroupStyle {
|
||||
HIPHOP, JAZZ, MODERN, STREETDANCE, CLASSIC, BREAKDANCE
|
||||
HIPHOP,
|
||||
JAZZ,
|
||||
MODERN,
|
||||
STREETDANCE,
|
||||
CLASSIC,
|
||||
BREAKDANCE
|
||||
}
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
package nl.agilecc.agiledancers.model.specifications;
|
||||
|
||||
public enum DanceGroupType {
|
||||
SHOW_CASE, PRE_EDUCATION, SELECTION, REGULAR, PRE_SCHOOLER
|
||||
SHOW_CASE,
|
||||
PRE_EDUCATION,
|
||||
SELECTION,
|
||||
REGULAR,
|
||||
PRE_SCHOOLER
|
||||
}
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
package nl.agilecc.agiledancers.model.specifications;
|
||||
|
||||
public enum DayPartType {
|
||||
MORNING, AFTERNOON, EVENING;
|
||||
MORNING,
|
||||
AFTERNOON,
|
||||
EVENING;
|
||||
}
|
||||
|
||||
@@ -1,19 +1,28 @@
|
||||
package nl.agilecc.agiledancers.repository;
|
||||
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import nl.agilecc.agiledancers.model.AbstractEntity;
|
||||
|
||||
import java.util.*;
|
||||
|
||||
public abstract class AbstractRepository<T extends AbstractEntity> {
|
||||
private long sequence = 0L;
|
||||
private java.util.function.LongSupplier identifiers;
|
||||
|
||||
protected Map<Long, T> entities = new LinkedHashMap<>();
|
||||
protected final Map<Long, T> entities = new LinkedHashMap<>();
|
||||
|
||||
public long nextId() {
|
||||
return sequence++;
|
||||
return identifiers == null ? sequence++ : identifiers.getAsLong();
|
||||
}
|
||||
|
||||
public T add(T entity) {
|
||||
/** Replaces detached transaction state; production identifiers come from PostgreSQL. */
|
||||
public void load(final java.util.Collection<T> values, final java.util.function.LongSupplier identifiers) {
|
||||
entities.clear();
|
||||
values.forEach((final T entity) -> entities.put(entity.getId(), entity));
|
||||
this.identifiers = identifiers;
|
||||
}
|
||||
|
||||
public T add(final T entity) {
|
||||
if (entity.getId() == null) {
|
||||
entity.setId(nextId());
|
||||
}
|
||||
@@ -25,10 +34,11 @@ public abstract class AbstractRepository<T extends AbstractEntity> {
|
||||
return List.copyOf(entities.values());
|
||||
}
|
||||
|
||||
public T findById(long id) {
|
||||
public T findById(final long id) {
|
||||
return entities.get(id);
|
||||
}
|
||||
public void delete(long id) {
|
||||
|
||||
public void delete(final long id) {
|
||||
entities.remove(id);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,6 +2,4 @@ package nl.agilecc.agiledancers.repository;
|
||||
|
||||
import nl.agilecc.agiledancers.model.Coach;
|
||||
|
||||
public class CoachRepository extends AbstractRepository<Coach> {
|
||||
|
||||
}
|
||||
public class CoachRepository extends AbstractRepository<Coach> {}
|
||||
|
||||
@@ -2,5 +2,4 @@ package nl.agilecc.agiledancers.repository;
|
||||
|
||||
import nl.agilecc.agiledancers.model.DanceGroup;
|
||||
|
||||
public class DanceGroupRepository extends AbstractRepository<DanceGroup> {
|
||||
}
|
||||
public class DanceGroupRepository extends AbstractRepository<DanceGroup> {}
|
||||
|
||||
@@ -2,5 +2,4 @@ package nl.agilecc.agiledancers.repository;
|
||||
|
||||
import nl.agilecc.agiledancers.model.DanceSchool;
|
||||
|
||||
public class DanceSchoolRepository extends AbstractRepository<DanceSchool> {
|
||||
}
|
||||
public class DanceSchoolRepository extends AbstractRepository<DanceSchool> {}
|
||||
|
||||
@@ -2,5 +2,4 @@ package nl.agilecc.agiledancers.repository;
|
||||
|
||||
import nl.agilecc.agiledancers.model.Dancer;
|
||||
|
||||
public class DancerRepository extends AbstractRepository<Dancer> {
|
||||
}
|
||||
public class DancerRepository extends AbstractRepository<Dancer> {}
|
||||
|
||||
@@ -0,0 +1,113 @@
|
||||
package nl.agilecc.agiledancers.repository;
|
||||
|
||||
import jakarta.inject.Inject;
|
||||
import jakarta.inject.Singleton;
|
||||
import jakarta.persistence.EntityManager;
|
||||
import jakarta.persistence.LockModeType;
|
||||
import jakarta.persistence.OptimisticLockException;
|
||||
import jakarta.persistence.PersistenceException;
|
||||
import jakarta.transaction.Status;
|
||||
import jakarta.transaction.TransactionManager;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.concurrent.Callable;
|
||||
import nl.agilecc.agiledancers.repository.entity.EntityIdentifiers;
|
||||
|
||||
/** Owns short JTA units of work; Hibernate flushes before commit and publication follows durable commit. */
|
||||
@Singleton
|
||||
public class Database {
|
||||
private final EntityManager entities;
|
||||
private final TransactionManager manager;
|
||||
private final ThreadLocal<List<Runnable>> committed = new ThreadLocal<>();
|
||||
private final ThreadLocal<List<Runnable>> rolledBack = new ThreadLocal<>();
|
||||
|
||||
@Inject
|
||||
public Database(final EntityManager entities, final TransactionManager manager) {
|
||||
this.entities = entities;
|
||||
this.manager = manager;
|
||||
}
|
||||
|
||||
/**
|
||||
* Runs work in a new transaction, or joins the current JTA transaction. Callback registration is supported only
|
||||
* inside transactions started by this instance. Commit callbacks run in registration order after durable commit;
|
||||
* their exceptions propagate to the caller without rolling back committed work or running remaining callbacks.
|
||||
* Rollback callbacks run before the original failure is rethrown; a callback exception replaces that failure.
|
||||
*/
|
||||
public <T> T transaction(final Callable<T> work) throws Exception {
|
||||
try {
|
||||
if (manager.getStatus() != Status.STATUS_NO_TRANSACTION) {
|
||||
return work.call();
|
||||
}
|
||||
return outerTransaction(work);
|
||||
} catch (final OptimisticLockException failure) {
|
||||
throw new RevisionConflictException();
|
||||
} catch (final PersistenceException
|
||||
| jakarta.transaction.SystemException
|
||||
| jakarta.transaction.RollbackException
|
||||
| jakarta.transaction.HeuristicMixedException
|
||||
| jakarta.transaction.HeuristicRollbackException
|
||||
| jakarta.transaction.NotSupportedException failure) {
|
||||
if (optimisticConflict(failure)) {
|
||||
throw new RevisionConflictException();
|
||||
}
|
||||
System.getLogger(Database.class.getName())
|
||||
.log(System.Logger.Level.WARNING, "JPA transaction failed", failure);
|
||||
throw new StorageException(failure);
|
||||
}
|
||||
}
|
||||
|
||||
private static boolean optimisticConflict(final Throwable failure) {
|
||||
for (Throwable cause = failure; cause != null; cause = cause.getCause()) {
|
||||
if (cause instanceof OptimisticLockException) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
private <T> T outerTransaction(final Callable<T> work) throws Exception {
|
||||
final List<Runnable> publications = new ArrayList<>();
|
||||
manager.begin();
|
||||
committed.set(publications);
|
||||
rolledBack.set(new ArrayList<>());
|
||||
final T result;
|
||||
try {
|
||||
result = work.call();
|
||||
manager.commit();
|
||||
} catch (final Exception | Error failure) {
|
||||
rollback(failure);
|
||||
rolledBack.get().forEach(Runnable::run);
|
||||
throw failure;
|
||||
} finally {
|
||||
committed.remove();
|
||||
rolledBack.remove();
|
||||
}
|
||||
publications.forEach(Runnable::run);
|
||||
return result;
|
||||
}
|
||||
|
||||
private void rollback(final Throwable failure) {
|
||||
try {
|
||||
if (manager.getStatus() != Status.STATUS_NO_TRANSACTION) {
|
||||
manager.rollback();
|
||||
}
|
||||
} catch (final jakarta.transaction.SystemException rollbackFailure) {
|
||||
failure.addSuppressed(rollbackFailure);
|
||||
}
|
||||
}
|
||||
|
||||
/** Registers a callback on this thread's transaction owned by this instance; see {@link #transaction(Callable)}. */
|
||||
public void afterCommit(final Runnable action) {
|
||||
committed.get().add(action);
|
||||
}
|
||||
|
||||
/** Registers a rollback callback under the same ownership requirement as {@link #afterCommit(Runnable)}. */
|
||||
public void afterRollback(final Runnable action) {
|
||||
rolledBack.get().add(action);
|
||||
}
|
||||
|
||||
public long nextId() {
|
||||
return entities.find(EntityIdentifiers.class, 1, LockModeType.PESSIMISTIC_WRITE)
|
||||
.claim();
|
||||
}
|
||||
}
|
||||
@@ -2,5 +2,4 @@ package nl.agilecc.agiledancers.repository;
|
||||
|
||||
import nl.agilecc.agiledancers.model.DayPart;
|
||||
|
||||
public class DayPartRepository extends AbstractRepository<DayPart> {
|
||||
}
|
||||
public class DayPartRepository extends AbstractRepository<DayPart> {}
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
package nl.agilecc.agiledancers.repository;
|
||||
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.LinkedHashSet;
|
||||
import java.util.Map;
|
||||
import nl.agilecc.agiledancers.model.Coach;
|
||||
import nl.agilecc.agiledancers.model.DanceGroup;
|
||||
import nl.agilecc.agiledancers.model.DanceSchool;
|
||||
import nl.agilecc.agiledancers.model.Dancer;
|
||||
import nl.agilecc.agiledancers.repository.entity.CoachEntity;
|
||||
import nl.agilecc.agiledancers.repository.entity.GroupEntity;
|
||||
import nl.agilecc.agiledancers.repository.entity.SchoolEntity;
|
||||
import nl.agilecc.agiledancers.repository.entity.SchoolMemberId;
|
||||
|
||||
public class GroupMapping {
|
||||
public static Map<Long, DanceGroup> groups(
|
||||
final SchoolEntity stored,
|
||||
final DanceSchool school,
|
||||
final Map<Long, Coach> coaches,
|
||||
final Map<Long, Dancer> dancers) {
|
||||
final Map<Long, DanceGroup> result = new LinkedHashMap<>();
|
||||
for (final GroupEntity entity : stored.getGroups()) {
|
||||
final DanceGroup group = DanceGroup.builder()
|
||||
.category(entity.getCategory())
|
||||
.type(entity.getType())
|
||||
.startAge(entity.getStartAge())
|
||||
.endAge(entity.getEndAge())
|
||||
.coach(
|
||||
entity.getCoach() == null
|
||||
? null
|
||||
: coaches.get(entity.getCoach().getId().getLocalId()))
|
||||
.styles(new LinkedHashSet<>(entity.getStyles()))
|
||||
.build();
|
||||
group.setId(entity.getId().getLocalId());
|
||||
entity.getDancerIds().stream().sorted().forEach((final Long id) -> group.addDancer(dancers.get(id)));
|
||||
school.addDanceGroup(group);
|
||||
result.put(group.getId(), group);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
public static GroupEntity group(final SchoolEntity school, final DanceGroup group) {
|
||||
final GroupEntity entity = new GroupEntity();
|
||||
entity.setId(new SchoolMemberId(school.getId(), group.getId()));
|
||||
entity.setSchool(school);
|
||||
entity.setCategory(group.getCategory());
|
||||
entity.setType(group.getType());
|
||||
entity.setStartAge(group.getStartAge());
|
||||
entity.setEndAge(group.getEndAge());
|
||||
if (group.getCoach() != null) {
|
||||
entity.setCoach(school.getCoaches().stream()
|
||||
.filter((final CoachEntity coach) ->
|
||||
coach.getId().getLocalId() == group.getCoach().getId())
|
||||
.findFirst()
|
||||
.orElseThrow());
|
||||
}
|
||||
entity.setStyles(new LinkedHashSet<>(group.getStyles()));
|
||||
group.getDancers()
|
||||
.forEach((final Dancer dancer) -> entity.getDancerIds().add(dancer.getId()));
|
||||
return entity;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
package nl.agilecc.agiledancers.repository;
|
||||
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.Map;
|
||||
import nl.agilecc.agiledancers.model.Coach;
|
||||
import nl.agilecc.agiledancers.model.DanceSchool;
|
||||
import nl.agilecc.agiledancers.model.Dancer;
|
||||
import nl.agilecc.agiledancers.repository.entity.CoachEntity;
|
||||
import nl.agilecc.agiledancers.repository.entity.DancerEntity;
|
||||
import nl.agilecc.agiledancers.repository.entity.SchoolEntity;
|
||||
import nl.agilecc.agiledancers.repository.entity.SchoolMemberId;
|
||||
|
||||
/** Converts people while preserving the shared dancer identity used by coach links. */
|
||||
public class PeopleMapping {
|
||||
public static Map<Long, Dancer> dancers(final SchoolEntity stored, final DanceSchool school) {
|
||||
final Map<Long, Dancer> result = new LinkedHashMap<>();
|
||||
for (final DancerEntity person : stored.getDancers()) {
|
||||
final Dancer dancer = Dancer.builder()
|
||||
.name(person.getName())
|
||||
.birthDate(person.getBirthDate())
|
||||
.build();
|
||||
dancer.setId(person.getId().getLocalId());
|
||||
school.addDancer(dancer);
|
||||
result.put(dancer.getId(), dancer);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
public static Map<Long, Coach> coaches(
|
||||
final SchoolEntity stored, final DanceSchool school, final Map<Long, Dancer> dancers) {
|
||||
final Map<Long, Coach> result = new LinkedHashMap<>();
|
||||
for (final CoachEntity trainer : stored.getCoaches()) {
|
||||
final Coach coach = Coach.builder()
|
||||
.name(trainer.getName())
|
||||
.dancer(
|
||||
trainer.getDancer() == null
|
||||
? null
|
||||
: dancers.get(trainer.getDancer().getId().getLocalId()))
|
||||
.build();
|
||||
coach.setId(trainer.getId().getLocalId());
|
||||
school.addCoach(coach);
|
||||
result.put(coach.getId(), coach);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
public static DancerEntity dancer(final SchoolEntity school, final Dancer dancer) {
|
||||
final DancerEntity entity = new DancerEntity();
|
||||
entity.setId(new SchoolMemberId(school.getId(), dancer.getId()));
|
||||
entity.setSchool(school);
|
||||
entity.setName(dancer.getName());
|
||||
entity.setBirthDate(dancer.getBirthDate());
|
||||
return entity;
|
||||
}
|
||||
|
||||
public static CoachEntity coach(final SchoolEntity school, final Coach coach) {
|
||||
final CoachEntity entity = new CoachEntity();
|
||||
entity.setId(new SchoolMemberId(school.getId(), coach.getId()));
|
||||
entity.setSchool(school);
|
||||
entity.setName(coach.getName());
|
||||
if (coach.getDancer() != null) {
|
||||
entity.setDancer(school.getDancers().stream()
|
||||
.filter((final DancerEntity dancer) ->
|
||||
dancer.getId().getLocalId() == coach.getDancer().getId())
|
||||
.findFirst()
|
||||
.orElseThrow());
|
||||
}
|
||||
return entity;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
package nl.agilecc.agiledancers.repository;
|
||||
|
||||
/** An optimistic persistence conflict requires a new editor snapshot, never an automatic retry. */
|
||||
public class RevisionConflictException extends RuntimeException {
|
||||
public RevisionConflictException() {
|
||||
super("Deze school is gewijzigd. Herlaad de gegevens en probeer opnieuw.");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
package nl.agilecc.agiledancers.repository;
|
||||
|
||||
import jakarta.inject.Inject;
|
||||
import jakarta.inject.Singleton;
|
||||
import jakarta.persistence.EntityManager;
|
||||
import java.util.List;
|
||||
import nl.agilecc.agiledancers.model.scheduling.SchedulingProgress;
|
||||
import nl.agilecc.agiledancers.model.scheduling.SchedulingRun;
|
||||
import nl.agilecc.agiledancers.model.scheduling.SchedulingState;
|
||||
import nl.agilecc.agiledancers.model.scheduling.SchedulingSummary;
|
||||
import nl.agilecc.agiledancers.repository.entity.RunEntity;
|
||||
import nl.agilecc.agiledancers.repository.entity.SchoolMemberId;
|
||||
|
||||
@Singleton
|
||||
public class RunRepository {
|
||||
private final EntityManager entities;
|
||||
|
||||
@Inject
|
||||
public RunRepository(final EntityManager entities) {
|
||||
this.entities = entities;
|
||||
}
|
||||
|
||||
public List<SchedulingRun> load() {
|
||||
return entities
|
||||
.createQuery("from StoredRun run order by run.id.schoolId,run.id.localId", RunEntity.class)
|
||||
.getResultList()
|
||||
.stream()
|
||||
.map(this::read)
|
||||
.toList();
|
||||
}
|
||||
|
||||
public void recover() {
|
||||
load().stream()
|
||||
.filter((final SchedulingRun run) -> run.state() == SchedulingState.RUNNING)
|
||||
.forEach((final SchedulingRun run) -> save(run.completed(
|
||||
SchedulingState.INTERRUPTED,
|
||||
"Berekening onderbroken door een backendherstart. Geen nieuw resultaat toegepast; bestaande"
|
||||
+ " indeling behouden. Start de berekening opnieuw.",
|
||||
null)));
|
||||
}
|
||||
|
||||
public void save(final SchedulingRun run) {
|
||||
final RunEntity entity = new RunEntity();
|
||||
entity.setId(new SchoolMemberId(run.schoolId(), run.showId()));
|
||||
entity.setRunId(run.runId());
|
||||
entity.setShowName(run.showName());
|
||||
entity.setStartedAt(run.startedAt());
|
||||
entity.setFinishedAt(run.finishedAt());
|
||||
entity.setState(run.state());
|
||||
entity.setPhase(run.progress().phase());
|
||||
entity.setFeasible(run.progress().feasible());
|
||||
entity.setMessage(run.message());
|
||||
if (run.summary() != null) {
|
||||
entity.setAttendanceExcess(run.summary().attendanceExcess());
|
||||
entity.setPerformanceSpread(run.summary().performanceSpread());
|
||||
entity.setVarietyPenalty(run.summary().varietyPenalty());
|
||||
}
|
||||
entities.merge(entity);
|
||||
}
|
||||
|
||||
private SchedulingRun read(final RunEntity entity) {
|
||||
final SchedulingSummary summary = entity.getAttendanceExcess() == null
|
||||
? null
|
||||
: new SchedulingSummary(
|
||||
entity.getAttendanceExcess(), entity.getPerformanceSpread(), entity.getVarietyPenalty());
|
||||
return new SchedulingRun(
|
||||
entity.getRunId(),
|
||||
entity.getId().getSchoolId(),
|
||||
entity.getId().getLocalId(),
|
||||
entity.getShowName(),
|
||||
entity.getStartedAt(),
|
||||
entity.getFinishedAt(),
|
||||
entity.getState(),
|
||||
new SchedulingProgress(entity.getPhase(), entity.isFeasible()),
|
||||
entity.getMessage(),
|
||||
summary);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
package nl.agilecc.agiledancers.repository;
|
||||
|
||||
import jakarta.inject.Inject;
|
||||
import jakarta.inject.Singleton;
|
||||
import jakarta.persistence.EntityManager;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import nl.agilecc.agiledancers.model.Coach;
|
||||
import nl.agilecc.agiledancers.model.DanceGroup;
|
||||
import nl.agilecc.agiledancers.model.DanceSchool;
|
||||
import nl.agilecc.agiledancers.model.Dancer;
|
||||
import nl.agilecc.agiledancers.repository.entity.SchoolEntity;
|
||||
import nl.agilecc.agiledancers.repository.entity.ShowEntity;
|
||||
|
||||
/** Exposes detached domain snapshots, keeping Hibernate sessions and lazy collections inside the adapter. */
|
||||
@Singleton
|
||||
public class SchoolReader {
|
||||
private final EntityManager entities;
|
||||
|
||||
@Inject
|
||||
public SchoolReader(final EntityManager entities) {
|
||||
this.entities = entities;
|
||||
}
|
||||
|
||||
public List<DanceSchool> load() {
|
||||
return entities
|
||||
.createQuery("from StoredSchool school order by school.id", SchoolEntity.class)
|
||||
.getResultList()
|
||||
.stream()
|
||||
.map(this::school)
|
||||
.toList();
|
||||
}
|
||||
|
||||
private DanceSchool school(final SchoolEntity entity) {
|
||||
final DanceSchool school = DanceSchool.builder()
|
||||
.key(entity.getKey())
|
||||
.name(entity.getName())
|
||||
.revision(entity.getRevision())
|
||||
.build();
|
||||
school.setId(entity.getId());
|
||||
final Map<Long, Dancer> dancers = PeopleMapping.dancers(entity, school);
|
||||
final Map<Long, Coach> coaches = PeopleMapping.coaches(entity, school, dancers);
|
||||
final Map<Long, DanceGroup> groups = GroupMapping.groups(entity, school, coaches, dancers);
|
||||
entity.getShows().forEach((final ShowEntity show) -> school.addShow(ShowMapping.show(show, groups)));
|
||||
return school;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
package nl.agilecc.agiledancers.repository;
|
||||
|
||||
import jakarta.inject.Inject;
|
||||
import jakarta.inject.Singleton;
|
||||
import jakarta.persistence.EntityManager;
|
||||
import jakarta.persistence.LockModeType;
|
||||
import java.util.List;
|
||||
import nl.agilecc.agiledancers.model.Coach;
|
||||
import nl.agilecc.agiledancers.model.DanceGroup;
|
||||
import nl.agilecc.agiledancers.model.DanceSchool;
|
||||
import nl.agilecc.agiledancers.model.Dancer;
|
||||
import nl.agilecc.agiledancers.model.Show;
|
||||
import nl.agilecc.agiledancers.repository.entity.RunEntity;
|
||||
import nl.agilecc.agiledancers.repository.entity.SchoolEntity;
|
||||
|
||||
/** Hibernate merges the aggregate graph and handles child differences, orphan removal and optimistic versioning. */
|
||||
@Singleton
|
||||
public class SchoolWriter {
|
||||
private final EntityManager entities;
|
||||
|
||||
@Inject
|
||||
public SchoolWriter(final EntityManager entities) {
|
||||
this.entities = entities;
|
||||
}
|
||||
|
||||
public void checkRevision(final long schoolId, final long expected) {
|
||||
final SchoolEntity school = entities.find(SchoolEntity.class, schoolId, LockModeType.OPTIMISTIC);
|
||||
if (school == null || school.getRevision() != expected) {
|
||||
throw new RevisionConflictException();
|
||||
}
|
||||
}
|
||||
|
||||
public void save(final DanceSchool school) {
|
||||
final SchoolEntity existing = entities.find(SchoolEntity.class, school.getId());
|
||||
final SchoolEntity graph = graph(school);
|
||||
if (existing == null) {
|
||||
entities.persist(graph);
|
||||
} else {
|
||||
removeDeletedRuns(school);
|
||||
graph.setRevision(existing.getRevision());
|
||||
graph.setChangeStamp(existing.getChangeStamp() + 1);
|
||||
entities.merge(graph);
|
||||
}
|
||||
entities.flush();
|
||||
}
|
||||
|
||||
private SchoolEntity graph(final DanceSchool school) {
|
||||
final SchoolEntity graph = new SchoolEntity();
|
||||
graph.setId(school.getId());
|
||||
graph.setKey(school.getKey());
|
||||
graph.setName(school.getName());
|
||||
school.getDancers()
|
||||
.forEach((final Dancer dancer) -> graph.getDancers().add(PeopleMapping.dancer(graph, dancer)));
|
||||
school.getCoaches().forEach((final Coach coach) -> graph.getCoaches().add(PeopleMapping.coach(graph, coach)));
|
||||
school.getDanceGroups()
|
||||
.forEach((final DanceGroup group) -> graph.getGroups().add(GroupMapping.group(graph, group)));
|
||||
school.getShows().forEach((final Show show) -> graph.getShows().add(ShowMapping.show(graph, show)));
|
||||
return graph;
|
||||
}
|
||||
|
||||
private void removeDeletedRuns(final DanceSchool school) {
|
||||
final List<Long> shows = school.getShows().stream().map(Show::getId).toList();
|
||||
entities
|
||||
.createQuery("from StoredRun run where run.id.schoolId=:school", RunEntity.class)
|
||||
.setParameter("school", school.getId())
|
||||
.getResultList()
|
||||
.stream()
|
||||
.filter((final RunEntity run) -> !shows.contains(run.getId().getLocalId()))
|
||||
.forEach(entities::remove);
|
||||
}
|
||||
|
||||
public void delete(final long schoolId) {
|
||||
entities.createQuery("from StoredRun run where run.id.schoolId=:school", RunEntity.class)
|
||||
.setParameter("school", schoolId)
|
||||
.getResultList()
|
||||
.forEach(entities::remove);
|
||||
final SchoolEntity school = entities.find(SchoolEntity.class, schoolId);
|
||||
if (school != null) {
|
||||
entities.remove(school);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
package nl.agilecc.agiledancers.repository;
|
||||
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.Map;
|
||||
import nl.agilecc.agiledancers.model.DanceGroup;
|
||||
import nl.agilecc.agiledancers.model.DayPart;
|
||||
import nl.agilecc.agiledancers.model.Show;
|
||||
import nl.agilecc.agiledancers.repository.entity.DayPartEntity;
|
||||
import nl.agilecc.agiledancers.repository.entity.DayPartId;
|
||||
import nl.agilecc.agiledancers.repository.entity.SchoolEntity;
|
||||
import nl.agilecc.agiledancers.repository.entity.SchoolMemberId;
|
||||
import nl.agilecc.agiledancers.repository.entity.ShowEntity;
|
||||
|
||||
public class ShowMapping {
|
||||
public static Show show(final ShowEntity entity, final Map<Long, DanceGroup> groups) {
|
||||
final Show show = Show.builder()
|
||||
.name(entity.getName())
|
||||
.preferredDayParts(entity.getPreferredDayParts())
|
||||
.finalized(entity.isFinalized())
|
||||
.appearances(Map.copyOf(entity.getAppearances()))
|
||||
.dayParts(entity.getDayParts().stream()
|
||||
.map((final DayPartEntity part) -> DayPart.builder()
|
||||
.date(part.getDate())
|
||||
.type(part.getType())
|
||||
.danceGroups(part.getGroupIds().stream()
|
||||
.map(groups::get)
|
||||
.toList())
|
||||
.build())
|
||||
.toList())
|
||||
.build();
|
||||
show.setId(entity.getId().getLocalId());
|
||||
return show;
|
||||
}
|
||||
|
||||
public static ShowEntity show(final SchoolEntity school, final Show show) {
|
||||
final ShowEntity entity = new ShowEntity();
|
||||
entity.setId(new SchoolMemberId(school.getId(), show.getId()));
|
||||
entity.setSchool(school);
|
||||
entity.setName(show.getName());
|
||||
entity.setPreferredDayParts(show.getPreferredDayParts());
|
||||
entity.setFinalized(show.isFinalized());
|
||||
entity.setAppearances(new LinkedHashMap<>(show.getAppearances()));
|
||||
for (int index = 0; index < show.getDayParts().size(); index++) {
|
||||
entity.getDayParts().add(part(entity, index, show.getDayParts().get(index)));
|
||||
}
|
||||
return entity;
|
||||
}
|
||||
|
||||
private static DayPartEntity part(final ShowEntity show, final int position, final DayPart part) {
|
||||
final DayPartEntity entity = new DayPartEntity();
|
||||
entity.setId(new DayPartId(show.getId().getSchoolId(), show.getId().getLocalId(), position));
|
||||
entity.setShow(show);
|
||||
entity.setDate(part.getDate());
|
||||
entity.setType(part.getType());
|
||||
part.getDanceGroups()
|
||||
.forEach((final DanceGroup group) -> entity.getGroupIds().add(group.getId()));
|
||||
return entity;
|
||||
}
|
||||
}
|
||||
@@ -2,5 +2,4 @@ package nl.agilecc.agiledancers.repository;
|
||||
|
||||
import nl.agilecc.agiledancers.model.Show;
|
||||
|
||||
public class ShowRepository extends AbstractRepository<Show> {
|
||||
}
|
||||
public class ShowRepository extends AbstractRepository<Show> {}
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
package nl.agilecc.agiledancers.repository;
|
||||
|
||||
/** Storage failures never authorize an in-memory fallback. */
|
||||
public class StorageException extends RuntimeException {
|
||||
public StorageException(final Exception cause) {
|
||||
super("Database niet beschikbaar. De wijziging is niet opgeslagen; herlaad en probeer opnieuw.", cause);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
package nl.agilecc.agiledancers.repository.entity;
|
||||
|
||||
import jakarta.persistence.CascadeType;
|
||||
import jakarta.persistence.Column;
|
||||
import jakarta.persistence.Entity;
|
||||
import jakarta.persistence.FetchType;
|
||||
import jakarta.persistence.JoinColumn;
|
||||
import jakarta.persistence.ManyToOne;
|
||||
import jakarta.persistence.Table;
|
||||
import lombok.Getter;
|
||||
import lombok.Setter;
|
||||
|
||||
@Getter
|
||||
@Setter
|
||||
@Entity(name = "StoredCoach")
|
||||
@Table(name = "coach")
|
||||
public class CoachEntity extends SchoolMemberEntity {
|
||||
@Column(nullable = false)
|
||||
private String name;
|
||||
|
||||
// The shared school key is read from the owning row; only the linked member ID is writable.
|
||||
@ManyToOne(
|
||||
fetch = FetchType.LAZY,
|
||||
cascade = {CascadeType.MERGE, CascadeType.PERSIST})
|
||||
@org.hibernate.annotations.JoinColumnsOrFormulas({
|
||||
@org.hibernate.annotations.JoinColumnOrFormula(
|
||||
column = @JoinColumn(name = "dancer_id", referencedColumnName = "id")),
|
||||
@org.hibernate.annotations.JoinColumnOrFormula(
|
||||
formula =
|
||||
@org.hibernate.annotations.JoinFormula(value = "school_id", referencedColumnName = "school_id"))
|
||||
})
|
||||
private DancerEntity dancer;
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
package nl.agilecc.agiledancers.repository.entity;
|
||||
|
||||
import jakarta.persistence.Column;
|
||||
import jakarta.persistence.Entity;
|
||||
import jakarta.persistence.Table;
|
||||
import java.time.LocalDate;
|
||||
import lombok.Getter;
|
||||
import lombok.Setter;
|
||||
|
||||
@Getter
|
||||
@Setter
|
||||
@Entity(name = "StoredDancer")
|
||||
@Table(name = "dancer")
|
||||
public class DancerEntity extends SchoolMemberEntity {
|
||||
@Column(nullable = false)
|
||||
private String name;
|
||||
|
||||
@Column(name = "birth_date", nullable = false)
|
||||
private LocalDate birthDate;
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
package nl.agilecc.agiledancers.repository.entity;
|
||||
|
||||
import jakarta.persistence.CollectionTable;
|
||||
import jakarta.persistence.Column;
|
||||
import jakarta.persistence.ElementCollection;
|
||||
import jakarta.persistence.EmbeddedId;
|
||||
import jakarta.persistence.Entity;
|
||||
import jakarta.persistence.EnumType;
|
||||
import jakarta.persistence.Enumerated;
|
||||
import jakarta.persistence.FetchType;
|
||||
import jakarta.persistence.JoinColumn;
|
||||
import jakarta.persistence.JoinColumns;
|
||||
import jakarta.persistence.ManyToOne;
|
||||
import jakarta.persistence.OrderColumn;
|
||||
import jakarta.persistence.Table;
|
||||
import java.time.LocalDate;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import lombok.Getter;
|
||||
import lombok.Setter;
|
||||
import nl.agilecc.agiledancers.model.specifications.DayPartType;
|
||||
|
||||
@Getter
|
||||
@Setter
|
||||
@Entity(name = "StoredDayPart")
|
||||
@Table(name = "day_part")
|
||||
public class DayPartEntity {
|
||||
@EmbeddedId
|
||||
private DayPartId id;
|
||||
|
||||
@ManyToOne(fetch = FetchType.LAZY)
|
||||
@JoinColumns({
|
||||
@JoinColumn(name = "school_id", referencedColumnName = "school_id", insertable = false, updatable = false),
|
||||
@JoinColumn(name = "show_id", referencedColumnName = "id", insertable = false, updatable = false)
|
||||
})
|
||||
private ShowEntity show;
|
||||
|
||||
@Column(name = "day", nullable = false)
|
||||
private LocalDate date;
|
||||
|
||||
@Enumerated(EnumType.STRING)
|
||||
@Column(name = "part_type", nullable = false)
|
||||
private DayPartType type;
|
||||
|
||||
@ElementCollection
|
||||
@CollectionTable(
|
||||
name = "assignment",
|
||||
joinColumns = {
|
||||
@JoinColumn(name = "school_id", referencedColumnName = "school_id"),
|
||||
@JoinColumn(name = "show_id", referencedColumnName = "show_id"),
|
||||
@JoinColumn(name = "part_position", referencedColumnName = "position")
|
||||
})
|
||||
@OrderColumn(name = "position")
|
||||
@Column(name = "group_id", nullable = false)
|
||||
private List<Long> groupIds = new ArrayList<>();
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
package nl.agilecc.agiledancers.repository.entity;
|
||||
|
||||
import jakarta.persistence.Embeddable;
|
||||
import java.io.Serializable;
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.EqualsAndHashCode;
|
||||
import lombok.Getter;
|
||||
import lombok.NoArgsConstructor;
|
||||
import lombok.Setter;
|
||||
|
||||
@Getter
|
||||
@Setter
|
||||
@Embeddable
|
||||
@AllArgsConstructor
|
||||
@NoArgsConstructor
|
||||
@EqualsAndHashCode
|
||||
public class DayPartId implements Serializable {
|
||||
@jakarta.persistence.Column(name = "school_id")
|
||||
private long schoolId;
|
||||
|
||||
@jakarta.persistence.Column(name = "show_id")
|
||||
private long showId;
|
||||
|
||||
@jakarta.persistence.Column(name = "position")
|
||||
private int position;
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
package nl.agilecc.agiledancers.repository.entity;
|
||||
|
||||
import jakarta.persistence.Column;
|
||||
import jakarta.persistence.Entity;
|
||||
import jakarta.persistence.Id;
|
||||
import jakarta.persistence.Table;
|
||||
import lombok.Getter;
|
||||
import lombok.Setter;
|
||||
|
||||
@Getter
|
||||
@Setter
|
||||
@Entity(name = "EntityIdentifiers")
|
||||
@Table(name = "entity_identifiers")
|
||||
public class EntityIdentifiers {
|
||||
@Id
|
||||
private int id;
|
||||
|
||||
@Column(name = "next_value", nullable = false)
|
||||
private long nextValue;
|
||||
|
||||
public long claim() {
|
||||
return nextValue++;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
package nl.agilecc.agiledancers.repository.entity;
|
||||
|
||||
import jakarta.persistence.CascadeType;
|
||||
import jakarta.persistence.CollectionTable;
|
||||
import jakarta.persistence.Column;
|
||||
import jakarta.persistence.ElementCollection;
|
||||
import jakarta.persistence.Entity;
|
||||
import jakarta.persistence.EnumType;
|
||||
import jakarta.persistence.Enumerated;
|
||||
import jakarta.persistence.FetchType;
|
||||
import jakarta.persistence.JoinColumn;
|
||||
import jakarta.persistence.ManyToOne;
|
||||
import jakarta.persistence.Table;
|
||||
import java.util.LinkedHashSet;
|
||||
import java.util.Set;
|
||||
import lombok.Getter;
|
||||
import lombok.Setter;
|
||||
import nl.agilecc.agiledancers.model.specifications.DanceGroupStyle;
|
||||
import nl.agilecc.agiledancers.model.specifications.DanceGroupType;
|
||||
|
||||
@Getter
|
||||
@Setter
|
||||
@Entity(name = "StoredGroup")
|
||||
@Table(name = "dance_group")
|
||||
public class GroupEntity extends SchoolMemberEntity {
|
||||
@Column(nullable = false)
|
||||
private String category;
|
||||
|
||||
@Enumerated(EnumType.STRING)
|
||||
@Column(name = "group_type", nullable = false)
|
||||
private DanceGroupType type;
|
||||
|
||||
@Column(name = "start_age", nullable = false)
|
||||
private int startAge;
|
||||
|
||||
@Column(name = "end_age", nullable = false)
|
||||
private int endAge;
|
||||
|
||||
// The shared school key is read from the owning row; only the linked member ID is writable.
|
||||
@ManyToOne(
|
||||
fetch = FetchType.LAZY,
|
||||
cascade = {CascadeType.MERGE, CascadeType.PERSIST})
|
||||
@org.hibernate.annotations.JoinColumnsOrFormulas({
|
||||
@org.hibernate.annotations.JoinColumnOrFormula(
|
||||
column = @JoinColumn(name = "coach_id", referencedColumnName = "id")),
|
||||
@org.hibernate.annotations.JoinColumnOrFormula(
|
||||
formula =
|
||||
@org.hibernate.annotations.JoinFormula(value = "school_id", referencedColumnName = "school_id"))
|
||||
})
|
||||
private CoachEntity coach;
|
||||
|
||||
@ElementCollection
|
||||
@CollectionTable(
|
||||
name = "group_style",
|
||||
joinColumns = {
|
||||
@JoinColumn(name = "school_id", referencedColumnName = "school_id"),
|
||||
@JoinColumn(name = "group_id", referencedColumnName = "id")
|
||||
})
|
||||
@Enumerated(EnumType.STRING)
|
||||
@Column(name = "style", nullable = false)
|
||||
private Set<DanceGroupStyle> styles = new LinkedHashSet<>();
|
||||
|
||||
@ElementCollection
|
||||
@CollectionTable(
|
||||
name = "group_dancer",
|
||||
joinColumns = {
|
||||
@JoinColumn(name = "school_id", referencedColumnName = "school_id"),
|
||||
@JoinColumn(name = "group_id", referencedColumnName = "id")
|
||||
})
|
||||
@Column(name = "dancer_id", nullable = false)
|
||||
private Set<Long> dancerIds = new LinkedHashSet<>();
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
package nl.agilecc.agiledancers.repository.entity;
|
||||
|
||||
import jakarta.persistence.AttributeOverride;
|
||||
import jakarta.persistence.Column;
|
||||
import jakarta.persistence.EmbeddedId;
|
||||
import jakarta.persistence.Entity;
|
||||
import jakarta.persistence.EnumType;
|
||||
import jakarta.persistence.Enumerated;
|
||||
import jakarta.persistence.Table;
|
||||
import java.time.Instant;
|
||||
import java.util.UUID;
|
||||
import lombok.Getter;
|
||||
import lombok.Setter;
|
||||
import nl.agilecc.agiledancers.model.scheduling.SchedulingPhase;
|
||||
import nl.agilecc.agiledancers.model.scheduling.SchedulingState;
|
||||
|
||||
@Getter
|
||||
@Setter
|
||||
@Entity(name = "StoredRun")
|
||||
@Table(name = "scheduling_run")
|
||||
public class RunEntity {
|
||||
@EmbeddedId
|
||||
@AttributeOverride(name = "localId", column = @Column(name = "show_id"))
|
||||
private SchoolMemberId id;
|
||||
|
||||
@Column(name = "run_id", nullable = false)
|
||||
private UUID runId;
|
||||
|
||||
@Column(name = "show_name", nullable = false)
|
||||
private String showName;
|
||||
|
||||
@Column(name = "started_at", nullable = false)
|
||||
private Instant startedAt;
|
||||
|
||||
@Column(name = "finished_at")
|
||||
private Instant finishedAt;
|
||||
|
||||
@Enumerated(EnumType.STRING)
|
||||
@Column(nullable = false)
|
||||
private SchedulingState state;
|
||||
|
||||
@Enumerated(EnumType.STRING)
|
||||
@Column(nullable = false)
|
||||
private SchedulingPhase phase;
|
||||
|
||||
private boolean feasible;
|
||||
|
||||
@Column(nullable = false)
|
||||
private String message;
|
||||
|
||||
@Column(name = "attendance_excess")
|
||||
private Long attendanceExcess;
|
||||
|
||||
@Column(name = "performance_spread")
|
||||
private Integer performanceSpread;
|
||||
|
||||
@Column(name = "variety_penalty")
|
||||
private Long varietyPenalty;
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
package nl.agilecc.agiledancers.repository.entity;
|
||||
|
||||
import jakarta.persistence.CascadeType;
|
||||
import jakarta.persistence.Column;
|
||||
import jakarta.persistence.Entity;
|
||||
import jakarta.persistence.Id;
|
||||
import jakarta.persistence.OneToMany;
|
||||
import jakarta.persistence.OrderBy;
|
||||
import jakarta.persistence.Table;
|
||||
import jakarta.persistence.Version;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.UUID;
|
||||
import lombok.Getter;
|
||||
import lombok.Setter;
|
||||
|
||||
@Getter
|
||||
@Setter
|
||||
@Entity(name = "StoredSchool")
|
||||
@Table(name = "school")
|
||||
public class SchoolEntity {
|
||||
@Id
|
||||
private long id;
|
||||
|
||||
@Column(name = "school_key", nullable = false)
|
||||
private UUID key;
|
||||
|
||||
@Column(nullable = false)
|
||||
private String name;
|
||||
|
||||
@Version
|
||||
private long revision;
|
||||
// Marks child-only changes so Hibernate advances the aggregate version exactly once.
|
||||
@Column(name = "change_stamp", nullable = false)
|
||||
private long changeStamp;
|
||||
|
||||
@OneToMany(mappedBy = "school", cascade = CascadeType.ALL, orphanRemoval = true)
|
||||
@OrderBy("id.localId")
|
||||
private List<DancerEntity> dancers = new ArrayList<>();
|
||||
|
||||
@OneToMany(mappedBy = "school", cascade = CascadeType.ALL, orphanRemoval = true)
|
||||
@OrderBy("id.localId")
|
||||
private List<CoachEntity> coaches = new ArrayList<>();
|
||||
|
||||
@OneToMany(mappedBy = "school", cascade = CascadeType.ALL, orphanRemoval = true)
|
||||
@OrderBy("id.localId")
|
||||
private List<GroupEntity> groups = new ArrayList<>();
|
||||
|
||||
@OneToMany(mappedBy = "school", cascade = CascadeType.ALL, orphanRemoval = true)
|
||||
@OrderBy("id.localId")
|
||||
private List<ShowEntity> shows = new ArrayList<>();
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
package nl.agilecc.agiledancers.repository.entity;
|
||||
|
||||
import jakarta.persistence.EmbeddedId;
|
||||
import jakarta.persistence.FetchType;
|
||||
import jakarta.persistence.JoinColumn;
|
||||
import jakarta.persistence.ManyToOne;
|
||||
import jakarta.persistence.MappedSuperclass;
|
||||
import lombok.Getter;
|
||||
import lombok.Setter;
|
||||
|
||||
@Getter
|
||||
@Setter
|
||||
@MappedSuperclass
|
||||
public abstract class SchoolMemberEntity {
|
||||
@EmbeddedId
|
||||
private SchoolMemberId id;
|
||||
|
||||
@ManyToOne(fetch = FetchType.LAZY)
|
||||
@JoinColumn(name = "school_id", insertable = false, updatable = false)
|
||||
private SchoolEntity school;
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
package nl.agilecc.agiledancers.repository.entity;
|
||||
|
||||
import jakarta.persistence.Embeddable;
|
||||
import java.io.Serializable;
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.EqualsAndHashCode;
|
||||
import lombok.Getter;
|
||||
import lombok.NoArgsConstructor;
|
||||
import lombok.Setter;
|
||||
|
||||
@Getter
|
||||
@Setter
|
||||
@Embeddable
|
||||
@AllArgsConstructor
|
||||
@NoArgsConstructor
|
||||
@EqualsAndHashCode
|
||||
public class SchoolMemberId implements Serializable {
|
||||
@jakarta.persistence.Column(name = "school_id")
|
||||
private long schoolId;
|
||||
|
||||
@jakarta.persistence.Column(name = "id")
|
||||
private long localId;
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
package nl.agilecc.agiledancers.repository.entity;
|
||||
|
||||
import jakarta.persistence.CascadeType;
|
||||
import jakarta.persistence.CollectionTable;
|
||||
import jakarta.persistence.Column;
|
||||
import jakarta.persistence.ElementCollection;
|
||||
import jakarta.persistence.Entity;
|
||||
import jakarta.persistence.EnumType;
|
||||
import jakarta.persistence.JoinColumn;
|
||||
import jakarta.persistence.MapKeyColumn;
|
||||
import jakarta.persistence.MapKeyEnumerated;
|
||||
import jakarta.persistence.OneToMany;
|
||||
import jakarta.persistence.OrderBy;
|
||||
import jakarta.persistence.Table;
|
||||
import java.util.ArrayList;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import lombok.Getter;
|
||||
import lombok.Setter;
|
||||
import nl.agilecc.agiledancers.model.specifications.DanceGroupType;
|
||||
|
||||
@Getter
|
||||
@Setter
|
||||
@Entity(name = "StoredShow")
|
||||
@Table(name = "show")
|
||||
public class ShowEntity extends SchoolMemberEntity {
|
||||
@Column(nullable = false)
|
||||
private String name;
|
||||
|
||||
@Column(name = "preferred_day_parts", nullable = false)
|
||||
private int preferredDayParts;
|
||||
|
||||
private boolean finalized;
|
||||
|
||||
@ElementCollection
|
||||
@CollectionTable(
|
||||
name = "show_appearance",
|
||||
joinColumns = {
|
||||
@JoinColumn(name = "school_id", referencedColumnName = "school_id"),
|
||||
@JoinColumn(name = "show_id", referencedColumnName = "id")
|
||||
})
|
||||
@MapKeyEnumerated(EnumType.STRING)
|
||||
@MapKeyColumn(name = "group_type")
|
||||
@Column(name = "appearances", nullable = false)
|
||||
private Map<DanceGroupType, Integer> appearances = new LinkedHashMap<>();
|
||||
|
||||
@OneToMany(mappedBy = "show", cascade = CascadeType.ALL, orphanRemoval = true)
|
||||
@OrderBy("id.position")
|
||||
private List<DayPartEntity> dayParts = new ArrayList<>();
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
package nl.agilecc.agiledancers.rest;
|
||||
|
||||
import jakarta.inject.Inject;
|
||||
import jakarta.validation.Valid;
|
||||
import jakarta.validation.constraints.NotNull;
|
||||
import jakarta.ws.rs.Consumes;
|
||||
import jakarta.ws.rs.DELETE;
|
||||
import jakarta.ws.rs.GET;
|
||||
import jakarta.ws.rs.POST;
|
||||
import jakarta.ws.rs.PUT;
|
||||
import jakarta.ws.rs.Path;
|
||||
import jakarta.ws.rs.PathParam;
|
||||
import jakarta.ws.rs.Produces;
|
||||
import jakarta.ws.rs.core.MediaType;
|
||||
import java.util.List;
|
||||
import nl.agilecc.agiledancers.rest.apimodel.CoachRequest;
|
||||
import nl.agilecc.agiledancers.rest.apimodel.CoachView;
|
||||
import nl.agilecc.agiledancers.rest.mapping.DomainViewMapper;
|
||||
import nl.agilecc.agiledancers.service.CoachLinks;
|
||||
|
||||
@SchoolTransactionBoundary
|
||||
@Path("/api/dance-schools/{schoolId}/coaches")
|
||||
@Produces(MediaType.APPLICATION_JSON)
|
||||
@Consumes(MediaType.APPLICATION_JSON)
|
||||
public class CoachResource {
|
||||
private @Inject DomainViewMapper views;
|
||||
|
||||
private @Inject CoachLinks links;
|
||||
private @PathParam("schoolId") long schoolId;
|
||||
|
||||
@GET
|
||||
public List<CoachView> list() {
|
||||
return links.list(schoolId).stream().map(views::coach).toList();
|
||||
}
|
||||
|
||||
@POST
|
||||
public CoachView create(final @NotNull @Valid CoachRequest request) {
|
||||
return views.coach(links.link(schoolId, null, request.dancerId()));
|
||||
}
|
||||
|
||||
@PUT
|
||||
@Path("/{id}")
|
||||
public CoachView link(final @PathParam("id") long id, final @NotNull @Valid CoachRequest request) {
|
||||
return views.coach(links.link(schoolId, id, request.dancerId()));
|
||||
}
|
||||
|
||||
@DELETE
|
||||
@Path("/{id}/dancer")
|
||||
public void unlink(final @PathParam("id") long id) {
|
||||
links.unlink(schoolId, id);
|
||||
}
|
||||
}
|
||||
@@ -3,66 +3,88 @@ package nl.agilecc.agiledancers.rest;
|
||||
import jakarta.inject.Inject;
|
||||
import jakarta.validation.Valid;
|
||||
import jakarta.validation.constraints.NotNull;
|
||||
import jakarta.ws.rs.*;
|
||||
import jakarta.ws.rs.core.*;
|
||||
import nl.agilecc.agiledancers.service.SchoolManagementService;
|
||||
|
||||
import jakarta.ws.rs.Consumes;
|
||||
import jakarta.ws.rs.DELETE;
|
||||
import jakarta.ws.rs.GET;
|
||||
import jakarta.ws.rs.POST;
|
||||
import jakarta.ws.rs.PUT;
|
||||
import jakarta.ws.rs.Path;
|
||||
import jakarta.ws.rs.PathParam;
|
||||
import jakarta.ws.rs.Produces;
|
||||
import jakarta.ws.rs.core.Context;
|
||||
import jakarta.ws.rs.core.MediaType;
|
||||
import jakarta.ws.rs.core.Response;
|
||||
import jakarta.ws.rs.core.UriInfo;
|
||||
import java.util.List;
|
||||
|
||||
import nl.agilecc.agiledancers.model.DanceGroup;
|
||||
import nl.agilecc.agiledancers.rest.apimodel.GroupRequest;
|
||||
import nl.agilecc.agiledancers.rest.apimodel.GroupView;
|
||||
import nl.agilecc.agiledancers.rest.mapping.DomainViewMapper;
|
||||
import nl.agilecc.agiledancers.service.SchoolManagementService;
|
||||
import nl.agilecc.agiledancers.service.model.GroupDetails;
|
||||
|
||||
@SchoolTransactionBoundary
|
||||
@Path("/api/dance-schools/{schoolId}/dance-groups")
|
||||
@Produces(MediaType.APPLICATION_JSON)
|
||||
@Consumes(MediaType.APPLICATION_JSON)
|
||||
public class DanceGroupResource {
|
||||
@Inject
|
||||
SchoolManagementService service;
|
||||
private @Inject DomainViewMapper views;
|
||||
|
||||
@PathParam("schoolId")
|
||||
long schoolId;
|
||||
private @Inject SchoolManagementService service;
|
||||
|
||||
private @PathParam("schoolId") long schoolId;
|
||||
|
||||
@GET
|
||||
public List<GroupView> list() {
|
||||
return service.groups(schoolId).stream().map(GroupView::of).toList();
|
||||
return service.groups(schoolId).stream().map(views::group).toList();
|
||||
}
|
||||
|
||||
@GET
|
||||
@Path("/{id}")
|
||||
public GroupView get(@PathParam("id") long id) {
|
||||
return GroupView.of(service.group(schoolId, id));
|
||||
public GroupView get(final @PathParam("id") long id) {
|
||||
return views.group(service.group(schoolId, id));
|
||||
}
|
||||
|
||||
@POST
|
||||
public Response create(@NotNull @Valid GroupRequest request, @Context UriInfo uriInfo) {
|
||||
var entity = service.createGroup(schoolId, request.category(), request.type(), request.startAge(), request.endAge(), request.styles());
|
||||
return Response.created(uriInfo.getAbsolutePathBuilder().path(entity.getId().toString()).build())
|
||||
.entity(GroupView.of(entity)).build();
|
||||
public Response create(final @NotNull @Valid GroupRequest request, final @Context UriInfo uriInfo) {
|
||||
final DanceGroup entity = service.createGroup(
|
||||
schoolId,
|
||||
new GroupDetails(
|
||||
request.category(), request.type(), request.startAge(), request.endAge(), request.styles()));
|
||||
return Response.created(uriInfo.getAbsolutePathBuilder()
|
||||
.path(entity.getId().toString())
|
||||
.build())
|
||||
.entity(views.group(entity))
|
||||
.build();
|
||||
}
|
||||
|
||||
@PUT
|
||||
@Path("/{id}")
|
||||
public GroupView update(@PathParam("id") long id, @NotNull @Valid GroupRequest request) {
|
||||
return GroupView.of(service.updateGroup(schoolId, id, request.category(), request.type(), request.startAge(), request.endAge(), request.styles()));
|
||||
public GroupView update(final @PathParam("id") long id, final @NotNull @Valid GroupRequest request) {
|
||||
return views.group(service.updateGroup(
|
||||
schoolId,
|
||||
id,
|
||||
new GroupDetails(
|
||||
request.category(), request.type(), request.startAge(), request.endAge(), request.styles())));
|
||||
}
|
||||
|
||||
@DELETE
|
||||
@Path("/{id}")
|
||||
public Response delete(@PathParam("id") long id) {
|
||||
public Response delete(final @PathParam("id") long id) {
|
||||
service.deleteGroup(schoolId, id);
|
||||
return Response.noContent().build();
|
||||
}
|
||||
|
||||
@PUT
|
||||
@Path("/{id}/dancers/{dancerId}")
|
||||
public Response addDancer(@PathParam("id") long id, @PathParam("dancerId") long dancerId) {
|
||||
public Response addDancer(final @PathParam("id") long id, final @PathParam("dancerId") long dancerId) {
|
||||
service.addGroupDancer(schoolId, id, dancerId);
|
||||
return Response.noContent().build();
|
||||
}
|
||||
|
||||
@DELETE
|
||||
@Path("/{id}/dancers/{dancerId}")
|
||||
public Response removeDancer(@PathParam("id") long id, @PathParam("dancerId") long dancerId) {
|
||||
public Response removeDancer(final @PathParam("id") long id, final @PathParam("dancerId") long dancerId) {
|
||||
service.removeGroupDancer(schoolId, id, dancerId);
|
||||
return Response.noContent().build();
|
||||
}
|
||||
|
||||
@@ -3,49 +3,81 @@ package nl.agilecc.agiledancers.rest;
|
||||
import jakarta.inject.Inject;
|
||||
import jakarta.validation.Valid;
|
||||
import jakarta.validation.constraints.NotNull;
|
||||
import jakarta.ws.rs.*;
|
||||
import jakarta.ws.rs.core.*;
|
||||
import nl.agilecc.agiledancers.service.SchoolManagementService;
|
||||
|
||||
import jakarta.ws.rs.Consumes;
|
||||
import jakarta.ws.rs.DELETE;
|
||||
import jakarta.ws.rs.GET;
|
||||
import jakarta.ws.rs.POST;
|
||||
import jakarta.ws.rs.PUT;
|
||||
import jakarta.ws.rs.Path;
|
||||
import jakarta.ws.rs.PathParam;
|
||||
import jakarta.ws.rs.Produces;
|
||||
import jakarta.ws.rs.core.Context;
|
||||
import jakarta.ws.rs.core.MediaType;
|
||||
import jakarta.ws.rs.core.Response;
|
||||
import jakarta.ws.rs.core.UriInfo;
|
||||
import java.util.List;
|
||||
|
||||
import nl.agilecc.agiledancers.model.DanceSchool;
|
||||
import nl.agilecc.agiledancers.rest.apimodel.SchoolRequest;
|
||||
import nl.agilecc.agiledancers.rest.apimodel.SchoolView;
|
||||
import nl.agilecc.agiledancers.rest.mapping.DomainViewMapper;
|
||||
import nl.agilecc.agiledancers.service.SchoolManagementService;
|
||||
|
||||
@SchoolTransactionBoundary
|
||||
@Path("/api/dance-schools")
|
||||
@Produces(MediaType.APPLICATION_JSON)
|
||||
@Consumes(MediaType.APPLICATION_JSON)
|
||||
public class DanceSchoolResource {
|
||||
@Inject
|
||||
SchoolManagementService service;
|
||||
private @Inject DomainViewMapper views;
|
||||
|
||||
private @Inject SchoolManagementService service;
|
||||
|
||||
private @Inject nl.agilecc.agiledancers.service.ShowSchedules schedules;
|
||||
|
||||
@GET
|
||||
@Path("/{id}/data")
|
||||
public nl.agilecc.agiledancers.rest.apimodel.SchoolDataView data(final @PathParam("id") long id) {
|
||||
final DanceSchool school = service.school(id);
|
||||
return new nl.agilecc.agiledancers.rest.apimodel.SchoolDataView(
|
||||
views.school(school),
|
||||
school.getDancers().stream().map(views::dancer).toList(),
|
||||
school.getDanceGroups().stream().map(views::group).toList(),
|
||||
school.getShows().stream()
|
||||
.map((final nl.agilecc.agiledancers.model.Show show) ->
|
||||
views.show(show, schedules.violations(id, show.getId())))
|
||||
.toList(),
|
||||
school.getCoaches().stream().map(views::coach).toList());
|
||||
}
|
||||
|
||||
@GET
|
||||
public List<SchoolView> list() {
|
||||
return service.schools().stream().map(SchoolView::of).toList();
|
||||
return service.schools().stream().map(views::school).toList();
|
||||
}
|
||||
|
||||
@GET
|
||||
@Path("/{id}")
|
||||
public SchoolView get(@PathParam("id") long id) {
|
||||
return SchoolView.of(service.school(id));
|
||||
public SchoolView get(final @PathParam("id") long id) {
|
||||
return views.school(service.school(id));
|
||||
}
|
||||
|
||||
@POST
|
||||
public Response create(@NotNull @Valid SchoolRequest request, @Context UriInfo uriInfo) {
|
||||
var entity = service.createSchool(request.name());
|
||||
return Response.created(uriInfo.getAbsolutePathBuilder().path(entity.getId().toString()).build())
|
||||
.entity(SchoolView.of(entity)).build();
|
||||
public Response create(final @NotNull @Valid SchoolRequest request, final @Context UriInfo uriInfo) {
|
||||
final DanceSchool entity = service.createSchool(request.name());
|
||||
return Response.created(uriInfo.getAbsolutePathBuilder()
|
||||
.path(entity.getId().toString())
|
||||
.build())
|
||||
.entity(views.school(entity))
|
||||
.build();
|
||||
}
|
||||
|
||||
@PUT
|
||||
@Path("/{id}")
|
||||
public SchoolView update(@PathParam("id") long id, @NotNull @Valid SchoolRequest request) {
|
||||
return SchoolView.of(service.updateSchool(id, request.name()));
|
||||
public SchoolView update(final @PathParam("id") long id, final @NotNull @Valid SchoolRequest request) {
|
||||
return views.school(service.updateSchool(id, request.name()));
|
||||
}
|
||||
|
||||
@DELETE
|
||||
@Path("/{id}")
|
||||
public Response delete(@PathParam("id") long id) {
|
||||
public Response delete(final @PathParam("id") long id) {
|
||||
service.deleteSchool(id);
|
||||
return Response.noContent().build();
|
||||
}
|
||||
|
||||
@@ -3,52 +3,66 @@ package nl.agilecc.agiledancers.rest;
|
||||
import jakarta.inject.Inject;
|
||||
import jakarta.validation.Valid;
|
||||
import jakarta.validation.constraints.NotNull;
|
||||
import jakarta.ws.rs.*;
|
||||
import jakarta.ws.rs.core.*;
|
||||
import nl.agilecc.agiledancers.service.SchoolManagementService;
|
||||
|
||||
import jakarta.ws.rs.Consumes;
|
||||
import jakarta.ws.rs.DELETE;
|
||||
import jakarta.ws.rs.GET;
|
||||
import jakarta.ws.rs.POST;
|
||||
import jakarta.ws.rs.PUT;
|
||||
import jakarta.ws.rs.Path;
|
||||
import jakarta.ws.rs.PathParam;
|
||||
import jakarta.ws.rs.Produces;
|
||||
import jakarta.ws.rs.core.Context;
|
||||
import jakarta.ws.rs.core.MediaType;
|
||||
import jakarta.ws.rs.core.Response;
|
||||
import jakarta.ws.rs.core.UriInfo;
|
||||
import java.util.List;
|
||||
|
||||
import nl.agilecc.agiledancers.model.Dancer;
|
||||
import nl.agilecc.agiledancers.rest.apimodel.DancerRequest;
|
||||
import nl.agilecc.agiledancers.rest.apimodel.DancerView;
|
||||
import nl.agilecc.agiledancers.rest.mapping.DomainViewMapper;
|
||||
import nl.agilecc.agiledancers.service.SchoolManagementService;
|
||||
|
||||
@SchoolTransactionBoundary
|
||||
@Path("/api/dance-schools/{schoolId}/dancers")
|
||||
@Produces(MediaType.APPLICATION_JSON)
|
||||
@Consumes(MediaType.APPLICATION_JSON)
|
||||
public class DancerResource {
|
||||
@Inject
|
||||
SchoolManagementService service;
|
||||
private @Inject DomainViewMapper views;
|
||||
|
||||
@PathParam("schoolId")
|
||||
long schoolId;
|
||||
private @Inject SchoolManagementService service;
|
||||
|
||||
private @PathParam("schoolId") long schoolId;
|
||||
|
||||
@GET
|
||||
public List<DancerView> list() {
|
||||
return service.dancers(schoolId).stream().map(DancerView::of).toList();
|
||||
return service.dancers(schoolId).stream().map(views::dancer).toList();
|
||||
}
|
||||
|
||||
@GET
|
||||
@Path("/{id}")
|
||||
public DancerView get(@PathParam("id") long id) {
|
||||
return DancerView.of(service.dancer(schoolId, id));
|
||||
public DancerView get(final @PathParam("id") long id) {
|
||||
return views.dancer(service.dancer(schoolId, id));
|
||||
}
|
||||
|
||||
@POST
|
||||
public Response create(@NotNull @Valid DancerRequest request, @Context UriInfo uriInfo) {
|
||||
var entity = service.createDancer(schoolId, request.name(), request.birthDate());
|
||||
return Response.created(uriInfo.getAbsolutePathBuilder().path(entity.getId().toString()).build())
|
||||
.entity(DancerView.of(entity)).build();
|
||||
public Response create(final @NotNull @Valid DancerRequest request, final @Context UriInfo uriInfo) {
|
||||
final Dancer entity = service.createDancer(schoolId, request.name(), request.birthDate());
|
||||
return Response.created(uriInfo.getAbsolutePathBuilder()
|
||||
.path(entity.getId().toString())
|
||||
.build())
|
||||
.entity(views.dancer(entity))
|
||||
.build();
|
||||
}
|
||||
|
||||
@PUT
|
||||
@Path("/{id}")
|
||||
public DancerView update(@PathParam("id") long id, @NotNull @Valid DancerRequest request) {
|
||||
return DancerView.of(service.updateDancer(schoolId, id, request.name(), request.birthDate()));
|
||||
public DancerView update(final @PathParam("id") long id, final @NotNull @Valid DancerRequest request) {
|
||||
return views.dancer(service.updateDancer(schoolId, id, request.name(), request.birthDate()));
|
||||
}
|
||||
|
||||
@DELETE
|
||||
@Path("/{id}")
|
||||
public Response delete(@PathParam("id") long id) {
|
||||
public Response delete(final @PathParam("id") long id) {
|
||||
service.deleteDancer(schoolId, id);
|
||||
return Response.noContent().build();
|
||||
}
|
||||
|
||||
@@ -8,7 +8,7 @@ import jakarta.inject.Singleton;
|
||||
@Singleton
|
||||
public class JacksonConfiguration implements ObjectMapperCustomizer {
|
||||
@Override
|
||||
public void customize(ObjectMapper mapper) {
|
||||
public void customize(final ObjectMapper mapper) {
|
||||
mapper.disable(DeserializationFeature.ACCEPT_FLOAT_AS_INT);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,19 +4,22 @@ import jakarta.ws.rs.core.MediaType;
|
||||
import jakarta.ws.rs.core.Response;
|
||||
import jakarta.ws.rs.ext.ExceptionMapper;
|
||||
import jakarta.ws.rs.ext.Provider;
|
||||
import nl.agilecc.agiledancers.service.ManagementException;
|
||||
import nl.agilecc.agiledancers.rest.apimodel.ErrorView;
|
||||
import nl.agilecc.agiledancers.service.ManagementException;
|
||||
|
||||
@Provider
|
||||
public class ManagementExceptionMapper implements ExceptionMapper<ManagementException> {
|
||||
@Override
|
||||
public Response toResponse(ManagementException exception) {
|
||||
int status = switch (exception.getKind()) {
|
||||
public Response toResponse(final ManagementException exception) {
|
||||
final int status = switch (exception.getKind()) {
|
||||
case NOT_FOUND -> 404;
|
||||
case STORAGE_UNAVAILABLE -> 503;
|
||||
case INVALID_INPUT -> 400;
|
||||
case CONFLICT -> 409;
|
||||
};
|
||||
return Response.status(status).type(MediaType.APPLICATION_JSON_TYPE)
|
||||
.entity(new ErrorView(exception.getMessage())).build();
|
||||
return Response.status(status)
|
||||
.type(MediaType.APPLICATION_JSON_TYPE)
|
||||
.entity(new ErrorView(exception.getMessage()))
|
||||
.build();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
package nl.agilecc.agiledancers.rest;
|
||||
|
||||
import io.smallrye.mutiny.Multi;
|
||||
import io.smallrye.mutiny.subscription.BackPressureStrategy;
|
||||
import jakarta.inject.Inject;
|
||||
import jakarta.ws.rs.GET;
|
||||
import jakarta.ws.rs.Path;
|
||||
import jakarta.ws.rs.Produces;
|
||||
import jakarta.ws.rs.core.MediaType;
|
||||
import java.time.Duration;
|
||||
import java.util.List;
|
||||
import nl.agilecc.agiledancers.model.scheduling.SchedulingRun;
|
||||
import nl.agilecc.agiledancers.rest.apimodel.SchedulingEventView;
|
||||
import nl.agilecc.agiledancers.service.SchedulingRuns;
|
||||
import org.jboss.resteasy.reactive.RestStreamElementType;
|
||||
|
||||
@Path("/api/scheduling/events")
|
||||
public class SchedulingEventsResource {
|
||||
private @Inject SchedulingRuns scheduling;
|
||||
|
||||
@GET
|
||||
@Produces(MediaType.SERVER_SENT_EVENTS)
|
||||
@RestStreamElementType(MediaType.APPLICATION_JSON)
|
||||
public Multi<SchedulingEventView> events() {
|
||||
final Multi<SchedulingEventView> updates = Multi.createFrom()
|
||||
.emitter(
|
||||
(final io.smallrye.mutiny.subscription.MultiEmitter<? super SchedulingEventView> emitter) -> {
|
||||
final Runnable unsubscribe = scheduling.subscribe(
|
||||
(final List<SchedulingRun> runs) -> emitter.emit(new SchedulingEventView(runs)));
|
||||
emitter.onTermination(unsubscribe);
|
||||
},
|
||||
BackPressureStrategy.LATEST);
|
||||
// Heartbeats keep idle proxy connections alive without changing the current snapshot.
|
||||
final Multi<SchedulingEventView> heartbeat = Multi.createFrom()
|
||||
.ticks()
|
||||
.every(Duration.ofSeconds(15))
|
||||
.onItem()
|
||||
.transform((final Long tick) -> new SchedulingEventView(null));
|
||||
return Multi.createBy()
|
||||
.merging()
|
||||
.streams(updates, heartbeat)
|
||||
.runSubscriptionOn(io.smallrye.mutiny.infrastructure.Infrastructure.getDefaultWorkerPool());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
package nl.agilecc.agiledancers.rest;
|
||||
|
||||
import io.vertx.ext.web.RoutingContext;
|
||||
import jakarta.annotation.Priority;
|
||||
import jakarta.inject.Inject;
|
||||
import jakarta.interceptor.AroundInvoke;
|
||||
import jakarta.interceptor.Interceptor;
|
||||
import jakarta.interceptor.InvocationContext;
|
||||
import java.util.regex.Matcher;
|
||||
import java.util.regex.Pattern;
|
||||
import nl.agilecc.agiledancers.service.ManagementException;
|
||||
import nl.agilecc.agiledancers.service.ManagementExceptionKind;
|
||||
import nl.agilecc.agiledancers.service.SchoolTransactions;
|
||||
import nl.agilecc.agiledancers.service.model.SchoolChange;
|
||||
|
||||
@SchoolTransactionBoundary
|
||||
@Interceptor
|
||||
@Priority(Interceptor.Priority.APPLICATION)
|
||||
public class SchoolRequestInterceptor {
|
||||
private static final Pattern SCHOOL_PATH = Pattern.compile("/api/dance-schools/(\\d+)(?:/|$)");
|
||||
private @Inject RoutingContext context;
|
||||
private @Inject SchoolTransactions transactions;
|
||||
|
||||
@AroundInvoke
|
||||
public Object invoke(final InvocationContext invocation) throws Exception {
|
||||
final String path = context.request().path();
|
||||
final String method = context.request().method().name();
|
||||
final boolean mutation = !method.equals("GET") && !path.endsWith("/preview");
|
||||
final Matcher matcher = SCHOOL_PATH.matcher(path);
|
||||
final Long schoolId = matcher.find()
|
||||
? Long.valueOf(matcher.group(1))
|
||||
: number(context.request().getParam("replaceId"));
|
||||
final Long revision = mutation ? number(context.request().getHeader("X-School-Revision")) : null;
|
||||
return transactions.request(
|
||||
new SchoolChange(schoolId, revision, mutation, mutation && !path.endsWith("/schedule")),
|
||||
invocation::proceed);
|
||||
}
|
||||
|
||||
private static Long number(final String value) {
|
||||
if (value == null) {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
return Long.valueOf(value);
|
||||
} catch (final NumberFormatException failure) {
|
||||
throw new ManagementException(
|
||||
ManagementExceptionKind.INVALID_INPUT, "Ongeldige schoolversie of school-ID.");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,44 +1,76 @@
|
||||
package nl.agilecc.agiledancers.rest;
|
||||
|
||||
import nl.agilecc.agiledancers.service.model.*;
|
||||
import com.fasterxml.jackson.databind.*;
|
||||
import com.fasterxml.jackson.core.JsonParser;
|
||||
import com.fasterxml.jackson.databind.DeserializationFeature;
|
||||
import com.fasterxml.jackson.databind.MapperFeature;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import jakarta.inject.Inject;
|
||||
import jakarta.ws.rs.*;
|
||||
import jakarta.ws.rs.core.*;
|
||||
import nl.agilecc.agiledancers.service.*;
|
||||
import jakarta.ws.rs.Consumes;
|
||||
import jakarta.ws.rs.GET;
|
||||
import jakarta.ws.rs.POST;
|
||||
import jakarta.ws.rs.Path;
|
||||
import jakarta.ws.rs.PathParam;
|
||||
import jakarta.ws.rs.Produces;
|
||||
import jakarta.ws.rs.QueryParam;
|
||||
import jakarta.ws.rs.core.MediaType;
|
||||
import jakarta.ws.rs.core.Response;
|
||||
import nl.agilecc.agiledancers.rest.apimodel.SchoolView;
|
||||
import nl.agilecc.agiledancers.rest.mapping.DomainViewMapper;
|
||||
import nl.agilecc.agiledancers.service.ManagementException;
|
||||
import nl.agilecc.agiledancers.service.ManagementExceptionKind;
|
||||
import nl.agilecc.agiledancers.service.SchoolManagementService;
|
||||
import nl.agilecc.agiledancers.service.model.ImportPreview;
|
||||
import nl.agilecc.agiledancers.service.model.SchoolSnapshot;
|
||||
|
||||
@SchoolTransactionBoundary
|
||||
@Path("/api/dance-schools")
|
||||
@Produces(MediaType.APPLICATION_JSON)
|
||||
public class SchoolSnapshotResource {
|
||||
@Inject SchoolManagementService service;
|
||||
@Inject ObjectMapper mapper;
|
||||
private @Inject DomainViewMapper views;
|
||||
|
||||
@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 ImportPreview preview(String json) { return service.preview(parse(json)); }
|
||||
private @Inject SchoolManagementService service;
|
||||
|
||||
@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 @Inject ObjectMapper mapper;
|
||||
|
||||
@GET
|
||||
@Path("/{id}/export")
|
||||
public Response export(final @PathParam("id") long id) throws Exception {
|
||||
final String json = mapper.writerWithDefaultPrettyPrinter().writeValueAsString(service.exportSchool(id));
|
||||
return Response.ok(json)
|
||||
.header("Content-Disposition", "attachment; filename=school-" + id + ".json")
|
||||
.build();
|
||||
}
|
||||
private SchoolSnapshot parse(String json) {
|
||||
|
||||
@POST
|
||||
@Path("/import/preview")
|
||||
@Consumes(MediaType.APPLICATION_JSON)
|
||||
public ImportPreview preview(final String json) {
|
||||
return service.preview(parse(json));
|
||||
}
|
||||
|
||||
@POST
|
||||
@Path("/import")
|
||||
@Consumes(MediaType.APPLICATION_JSON)
|
||||
public SchoolView apply(final String json, final @QueryParam("replaceId") Long replaceId) {
|
||||
return views.school(service.importSchool(parse(json), replaceId));
|
||||
}
|
||||
|
||||
private SchoolSnapshot parse(final String json) {
|
||||
try {
|
||||
SchoolSnapshot snapshot = mapper.copy().enable(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES)
|
||||
final 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");
|
||||
if (snapshot == null) {
|
||||
throw new IllegalArgumentException("Het bestand bevat geen school");
|
||||
}
|
||||
return snapshot;
|
||||
} catch (Exception e) {
|
||||
throw new ManagementException(ManagementExceptionKind.INVALID_INPUT, "Ongeldig JSON-bestand: " + e.getMessage());
|
||||
} catch (final Exception e) {
|
||||
throw new ManagementException(
|
||||
ManagementExceptionKind.INVALID_INPUT, "Ongeldig JSON-bestand: " + e.getMessage());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
package nl.agilecc.agiledancers.rest;
|
||||
|
||||
import jakarta.interceptor.InterceptorBinding;
|
||||
import java.lang.annotation.ElementType;
|
||||
import java.lang.annotation.Retention;
|
||||
import java.lang.annotation.RetentionPolicy;
|
||||
import java.lang.annotation.Target;
|
||||
|
||||
/** Bounds school HTTP operations to one durable transaction and optimistic revision check. */
|
||||
@InterceptorBinding
|
||||
@Retention(RetentionPolicy.RUNTIME)
|
||||
@Target({ElementType.TYPE, ElementType.METHOD})
|
||||
public @interface SchoolTransactionBoundary {}
|
||||
@@ -3,72 +3,124 @@ package nl.agilecc.agiledancers.rest;
|
||||
import jakarta.inject.Inject;
|
||||
import jakarta.validation.Valid;
|
||||
import jakarta.validation.constraints.NotNull;
|
||||
import jakarta.ws.rs.*;
|
||||
import jakarta.ws.rs.core.*;
|
||||
import nl.agilecc.agiledancers.service.SchoolManagementService;
|
||||
import nl.agilecc.agiledancers.service.SchedulingService;
|
||||
import nl.agilecc.agiledancers.model.DayPart;
|
||||
import nl.agilecc.agiledancers.rest.apimodel.SchedulingView;
|
||||
|
||||
import jakarta.ws.rs.Consumes;
|
||||
import jakarta.ws.rs.DELETE;
|
||||
import jakarta.ws.rs.GET;
|
||||
import jakarta.ws.rs.POST;
|
||||
import jakarta.ws.rs.PUT;
|
||||
import jakarta.ws.rs.Path;
|
||||
import jakarta.ws.rs.PathParam;
|
||||
import jakarta.ws.rs.Produces;
|
||||
import jakarta.ws.rs.core.Context;
|
||||
import jakarta.ws.rs.core.MediaType;
|
||||
import jakarta.ws.rs.core.Response;
|
||||
import jakarta.ws.rs.core.UriInfo;
|
||||
import java.util.List;
|
||||
|
||||
import nl.agilecc.agiledancers.model.DayPart;
|
||||
import nl.agilecc.agiledancers.model.Show;
|
||||
import nl.agilecc.agiledancers.model.scheduling.SchedulingRun;
|
||||
import nl.agilecc.agiledancers.rest.apimodel.DayPartRequest;
|
||||
import nl.agilecc.agiledancers.rest.apimodel.ScheduleRequest;
|
||||
import nl.agilecc.agiledancers.rest.apimodel.ShowRequest;
|
||||
import nl.agilecc.agiledancers.rest.apimodel.ShowView;
|
||||
import nl.agilecc.agiledancers.rest.mapping.DomainViewMapper;
|
||||
import nl.agilecc.agiledancers.service.SchedulingRuns;
|
||||
import nl.agilecc.agiledancers.service.SchoolManagementService;
|
||||
import nl.agilecc.agiledancers.service.ShowSchedules;
|
||||
|
||||
@SchoolTransactionBoundary
|
||||
@Path("/api/dance-schools/{schoolId}/shows")
|
||||
@Produces(MediaType.APPLICATION_JSON)
|
||||
@Consumes(MediaType.APPLICATION_JSON)
|
||||
public class ShowResource {
|
||||
@Inject
|
||||
SchoolManagementService service;
|
||||
private @Inject DomainViewMapper views;
|
||||
|
||||
@Inject
|
||||
SchedulingService scheduling;
|
||||
private @Inject SchoolManagementService service;
|
||||
|
||||
@PathParam("schoolId")
|
||||
long schoolId;
|
||||
private @Inject SchedulingRuns scheduling;
|
||||
|
||||
private @Inject ShowSchedules schedules;
|
||||
|
||||
private @PathParam("schoolId") long schoolId;
|
||||
|
||||
@GET
|
||||
public List<ShowView> list() {
|
||||
return service.shows(schoolId).stream().map(ShowView::of).toList();
|
||||
return service.shows(schoolId).stream().map(this::view).toList();
|
||||
}
|
||||
|
||||
@GET
|
||||
@Path("/{id}")
|
||||
public ShowView get(@PathParam("id") long id) {
|
||||
return ShowView.of(service.show(schoolId, id));
|
||||
public ShowView get(final @PathParam("id") long id) {
|
||||
return view(service.show(schoolId, id));
|
||||
}
|
||||
|
||||
@POST
|
||||
public Response create(@NotNull @Valid ShowRequest request, @Context UriInfo uriInfo) {
|
||||
var entity = service.createShow(schoolId, request.name(), parts(request), request.appearances());
|
||||
return Response.created(uriInfo.getAbsolutePathBuilder().path(entity.getId().toString()).build())
|
||||
.entity(ShowView.of(entity)).build();
|
||||
public Response create(final @NotNull @Valid ShowRequest request, final @Context UriInfo uriInfo) {
|
||||
final Show entity = schedules.configure(schoolId, null, settings(request));
|
||||
return Response.created(uriInfo.getAbsolutePathBuilder()
|
||||
.path(entity.getId().toString())
|
||||
.build())
|
||||
.entity(view(entity))
|
||||
.build();
|
||||
}
|
||||
|
||||
@PUT
|
||||
@Path("/{id}")
|
||||
public ShowView update(@PathParam("id") long id, @NotNull @Valid ShowRequest request) {
|
||||
return ShowView.of(service.updateShow(schoolId, id, request.name(), parts(request), request.appearances()));
|
||||
public ShowView update(final @PathParam("id") long id, final @NotNull @Valid ShowRequest request) {
|
||||
return view(schedules.configure(schoolId, id, settings(request)));
|
||||
}
|
||||
|
||||
private static List<DayPart> parts(ShowRequest request) {
|
||||
return request.dayParts().stream().map(p -> DayPart.builder()
|
||||
.date(p.date()).type(p.type()).danceGroups(List.of()).build()).toList();
|
||||
private ShowView view(final Show show) {
|
||||
return views.show(show, schedules.violations(schoolId, show.getId()));
|
||||
}
|
||||
|
||||
private static Show settings(final ShowRequest request) {
|
||||
return Show.builder()
|
||||
.name(request.name())
|
||||
.preferredDayParts(request.preferredDayParts())
|
||||
.dayParts(parts(request))
|
||||
.build();
|
||||
}
|
||||
|
||||
@PUT
|
||||
@Path("/{id}/assignments")
|
||||
public ShowView assignments(final @PathParam("id") long id, final @NotNull @Valid ScheduleRequest request) {
|
||||
return view(schedules.saveDraft(schoolId, id, request.assignments()));
|
||||
}
|
||||
|
||||
@POST
|
||||
@Path("/{id}/finalize")
|
||||
@Consumes(MediaType.WILDCARD)
|
||||
public ShowView finalizeSchedule(final @PathParam("id") long id) {
|
||||
return view(schedules.finalizeSchedule(schoolId, id));
|
||||
}
|
||||
|
||||
private static List<DayPart> parts(final ShowRequest request) {
|
||||
return request.dayParts().stream()
|
||||
.map((final DayPartRequest p) -> DayPart.builder()
|
||||
.date(p.date())
|
||||
.type(p.type())
|
||||
.danceGroups(List.of())
|
||||
.build())
|
||||
.toList();
|
||||
}
|
||||
|
||||
@POST
|
||||
@Path("/{id}/schedule")
|
||||
@Consumes(MediaType.WILDCARD)
|
||||
public SchedulingView schedule(@PathParam("id") long id) {
|
||||
var result = scheduling.schedule(schoolId, id);
|
||||
return new SchedulingView(result.optimal(), result.optimal()
|
||||
? "Optimal balance" : "Valid result; optimal balance not proven");
|
||||
public Response schedule(final @PathParam("id") long id) {
|
||||
return Response.accepted(scheduling.start(schoolId, id)).build();
|
||||
}
|
||||
|
||||
@GET
|
||||
@Path("/{id}/schedule")
|
||||
public SchedulingRun schedulingStatus(final @PathParam("id") long id) {
|
||||
return scheduling.status(schoolId, id);
|
||||
}
|
||||
|
||||
@DELETE
|
||||
@Path("/{id}")
|
||||
public Response delete(@PathParam("id") long id) {
|
||||
public Response delete(final @PathParam("id") long id) {
|
||||
service.deleteShow(schoolId, id);
|
||||
return Response.noContent().build();
|
||||
}
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
package nl.agilecc.agiledancers.rest.apimodel;
|
||||
|
||||
import jakarta.validation.constraints.NotNull;
|
||||
|
||||
public record CoachRequest(@NotNull Long dancerId) {}
|
||||
@@ -0,0 +1,3 @@
|
||||
package nl.agilecc.agiledancers.rest.apimodel;
|
||||
|
||||
public record CoachView(long id, String name, Long dancerId) {}
|
||||
@@ -5,4 +5,5 @@ import jakarta.validation.constraints.NotNull;
|
||||
import jakarta.validation.constraints.PastOrPresent;
|
||||
import java.time.LocalDate;
|
||||
|
||||
public record DancerRequest(@NotBlank String name, @NotNull @PastOrPresent LocalDate birthDate) {}
|
||||
public record DancerRequest(
|
||||
@NotBlank String name, @NotNull @PastOrPresent LocalDate birthDate) {}
|
||||
|
||||
@@ -1,10 +1,5 @@
|
||||
package nl.agilecc.agiledancers.rest.apimodel;
|
||||
|
||||
import nl.agilecc.agiledancers.model.Dancer;
|
||||
import java.time.LocalDate;
|
||||
|
||||
public record DancerView(long id, String name, LocalDate birthDate) {
|
||||
public static DancerView of(Dancer dancer) {
|
||||
return new DancerView(dancer.getId(), dancer.getName(), dancer.getBirthDate());
|
||||
}
|
||||
}
|
||||
public record DancerView(long id, String name, LocalDate birthDate) {}
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
package nl.agilecc.agiledancers.rest.apimodel;
|
||||
import java.time.LocalDate;
|
||||
|
||||
import jakarta.validation.constraints.NotNull;
|
||||
import java.time.LocalDate;
|
||||
import nl.agilecc.agiledancers.model.specifications.DayPartType;
|
||||
public record DayPartRequest(@NotNull LocalDate date, @NotNull DayPartType type) {}
|
||||
|
||||
public record DayPartRequest(
|
||||
@NotNull LocalDate date, @NotNull DayPartType type) {}
|
||||
|
||||
@@ -1,5 +1,13 @@
|
||||
package nl.agilecc.agiledancers.rest.apimodel;
|
||||
import nl.agilecc.agiledancers.model.specifications.DayPartType;
|
||||
import java.util.List;
|
||||
|
||||
import java.time.LocalDate;
|
||||
public record DayPartView(LocalDate date, DayPartType type, List<Long> danceGroupIds) {}
|
||||
import java.util.ArrayList;
|
||||
import java.util.Collections;
|
||||
import java.util.List;
|
||||
import nl.agilecc.agiledancers.model.specifications.DayPartType;
|
||||
|
||||
public record DayPartView(LocalDate date, DayPartType type, List<Long> danceGroupIds) {
|
||||
public DayPartView {
|
||||
danceGroupIds = danceGroupIds == null ? null : Collections.unmodifiableList(new ArrayList<>(danceGroupIds));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,10 +3,19 @@ package nl.agilecc.agiledancers.rest.apimodel;
|
||||
import jakarta.validation.constraints.Min;
|
||||
import jakarta.validation.constraints.NotBlank;
|
||||
import jakarta.validation.constraints.NotNull;
|
||||
import nl.agilecc.agiledancers.model.specifications.DanceGroupType;
|
||||
import nl.agilecc.agiledancers.model.specifications.DanceGroupStyle;
|
||||
import java.util.Collections;
|
||||
import java.util.LinkedHashSet;
|
||||
import java.util.Set;
|
||||
import nl.agilecc.agiledancers.model.specifications.DanceGroupStyle;
|
||||
import nl.agilecc.agiledancers.model.specifications.DanceGroupType;
|
||||
|
||||
public record GroupRequest(@NotBlank String category, @NotNull DanceGroupType type,
|
||||
@NotNull @Min(0) Integer startAge, @NotNull @Min(0) Integer endAge,
|
||||
@NotNull Set<@NotNull DanceGroupStyle> styles) {}
|
||||
public record GroupRequest(
|
||||
@NotBlank String category,
|
||||
@NotNull DanceGroupType type,
|
||||
@NotNull @Min(0) Integer startAge,
|
||||
@NotNull @Min(0) Integer endAge,
|
||||
@NotNull Set<@NotNull DanceGroupStyle> styles) {
|
||||
public GroupRequest {
|
||||
styles = styles == null ? null : Collections.unmodifiableSet(new LinkedHashSet<>(styles));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,17 +1,23 @@
|
||||
package nl.agilecc.agiledancers.rest.apimodel;
|
||||
|
||||
import nl.agilecc.agiledancers.model.DanceGroup;
|
||||
import nl.agilecc.agiledancers.model.Dancer;
|
||||
import nl.agilecc.agiledancers.model.specifications.DanceGroupType;
|
||||
import nl.agilecc.agiledancers.model.specifications.DanceGroupStyle;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Collections;
|
||||
import java.util.LinkedHashSet;
|
||||
import java.util.List;
|
||||
import java.util.Set;
|
||||
import nl.agilecc.agiledancers.model.specifications.DanceGroupStyle;
|
||||
import nl.agilecc.agiledancers.model.specifications.DanceGroupType;
|
||||
|
||||
public record GroupView(long id, String category, DanceGroupType type, int startAge, int endAge,
|
||||
Set<DanceGroupStyle> styles, List<Long> dancerIds) {
|
||||
public static GroupView of(DanceGroup group) {
|
||||
return new GroupView(group.getId(), group.getCategory(), group.getType(), group.getStartAge(),
|
||||
group.getEndAge(), Set.copyOf(group.getStyles()),
|
||||
group.getDancers().stream().map(Dancer::getId).toList());
|
||||
public record GroupView(
|
||||
long id,
|
||||
String category,
|
||||
DanceGroupType type,
|
||||
int startAge,
|
||||
int endAge,
|
||||
Set<DanceGroupStyle> styles,
|
||||
List<Long> dancerIds) {
|
||||
public GroupView {
|
||||
styles = styles == null ? null : Collections.unmodifiableSet(new LinkedHashSet<>(styles));
|
||||
dancerIds = dancerIds == null ? null : Collections.unmodifiableList(new ArrayList<>(dancerIds));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
package nl.agilecc.agiledancers.rest.apimodel;
|
||||
|
||||
import jakarta.validation.constraints.NotNull;
|
||||
import java.util.List;
|
||||
|
||||
public record ScheduleRequest(@NotNull List<List<Long>> assignments) {
|
||||
public ScheduleRequest {
|
||||
assignments = assignments == null
|
||||
? null
|
||||
: assignments.stream()
|
||||
.map((final List<Long> p) ->
|
||||
p == null ? null : java.util.Collections.unmodifiableList(new java.util.ArrayList<>(p)))
|
||||
.toList();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
package nl.agilecc.agiledancers.rest.apimodel;
|
||||
|
||||
import java.util.List;
|
||||
import nl.agilecc.agiledancers.model.scheduling.SchedulingRun;
|
||||
|
||||
/** A null runs field is a heartbeat, not a replacement of the current snapshot. */
|
||||
public record SchedulingEventView(List<SchedulingRun> runs) {
|
||||
public SchedulingEventView {
|
||||
runs = runs == null ? null : List.copyOf(runs);
|
||||
}
|
||||
}
|
||||
@@ -1,2 +1,3 @@
|
||||
package nl.agilecc.agiledancers.rest.apimodel;
|
||||
|
||||
public record SchedulingView(boolean optimal, String message) {}
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
package nl.agilecc.agiledancers.rest.apimodel;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
/** One school and all editor data from the same revision and database snapshot. */
|
||||
public record SchoolDataView(
|
||||
SchoolView school,
|
||||
List<DancerView> dancers,
|
||||
List<GroupView> groups,
|
||||
List<ShowView> shows,
|
||||
List<CoachView> coaches) {
|
||||
public SchoolDataView {
|
||||
dancers = List.copyOf(dancers);
|
||||
groups = List.copyOf(groups);
|
||||
shows = List.copyOf(shows);
|
||||
coaches = List.copyOf(coaches);
|
||||
}
|
||||
}
|
||||
@@ -1,17 +1,14 @@
|
||||
package nl.agilecc.agiledancers.rest.apimodel;
|
||||
|
||||
import nl.agilecc.agiledancers.model.DanceSchool;
|
||||
import nl.agilecc.agiledancers.model.Dancer;
|
||||
import nl.agilecc.agiledancers.model.DanceGroup;
|
||||
import nl.agilecc.agiledancers.model.Show;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Collections;
|
||||
import java.util.List;
|
||||
|
||||
public record SchoolView(long id, String name, List<Long> dancerIds,
|
||||
List<Long> danceGroupIds, List<Long> showIds) {
|
||||
public static SchoolView of(DanceSchool school) {
|
||||
return new SchoolView(school.getId(), school.getName(),
|
||||
school.getDancers().stream().map(Dancer::getId).toList(),
|
||||
school.getDanceGroups().stream().map(DanceGroup::getId).toList(),
|
||||
school.getShows().stream().map(Show::getId).toList());
|
||||
public record SchoolView(
|
||||
long id, long revision, String name, List<Long> dancerIds, List<Long> danceGroupIds, List<Long> showIds) {
|
||||
public SchoolView {
|
||||
dancerIds = dancerIds == null ? null : Collections.unmodifiableList(new ArrayList<>(dancerIds));
|
||||
danceGroupIds = danceGroupIds == null ? null : Collections.unmodifiableList(new ArrayList<>(danceGroupIds));
|
||||
showIds = showIds == null ? null : Collections.unmodifiableList(new ArrayList<>(showIds));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,22 @@
|
||||
package nl.agilecc.agiledancers.rest.apimodel;
|
||||
|
||||
import jakarta.validation.Valid;
|
||||
import jakarta.validation.constraints.*;
|
||||
import java.util.*;
|
||||
import jakarta.validation.constraints.NotBlank;
|
||||
import jakarta.validation.constraints.NotNull;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Collections;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import nl.agilecc.agiledancers.model.specifications.DanceGroupType;
|
||||
public record ShowRequest(@NotBlank String name, @NotNull List<@NotNull @Valid DayPartRequest> dayParts,
|
||||
Map<DanceGroupType, Integer> appearances) {}
|
||||
|
||||
public record ShowRequest(
|
||||
@NotBlank String name,
|
||||
@NotNull List<@NotNull @Valid DayPartRequest> dayParts,
|
||||
Map<DanceGroupType, Integer> appearances,
|
||||
Integer preferredDayParts) {
|
||||
public ShowRequest {
|
||||
dayParts = dayParts == null ? null : Collections.unmodifiableList(new ArrayList<>(dayParts));
|
||||
appearances = appearances == null ? null : Collections.unmodifiableMap(new LinkedHashMap<>(appearances));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,10 +1,23 @@
|
||||
package nl.agilecc.agiledancers.rest.apimodel;
|
||||
import nl.agilecc.agiledancers.model.*;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.Collections;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import nl.agilecc.agiledancers.model.specifications.DanceGroupType;
|
||||
import java.util.*;
|
||||
public record ShowView(long id, String name, List<DayPartView> dayParts, Map<DanceGroupType, Integer> appearances) {
|
||||
public static ShowView of(Show show) {
|
||||
return new ShowView(show.getId(), show.getName(), show.getDayParts().stream()
|
||||
.map(p -> new DayPartView(p.getDate(), p.getType(), p.getDanceGroups().stream().map(DanceGroup::getId).toList())).toList(), show.getAppearances());
|
||||
|
||||
public record ShowView(
|
||||
long id,
|
||||
String name,
|
||||
List<DayPartView> dayParts,
|
||||
Map<DanceGroupType, Integer> appearances,
|
||||
int preferredDayParts,
|
||||
boolean finalized,
|
||||
List<String> violations) {
|
||||
public ShowView {
|
||||
violations = List.copyOf(violations);
|
||||
dayParts = dayParts == null ? null : Collections.unmodifiableList(new ArrayList<>(dayParts));
|
||||
appearances = appearances == null ? null : Collections.unmodifiableMap(new LinkedHashMap<>(appearances));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
package nl.agilecc.agiledancers.rest.mapping;
|
||||
|
||||
import java.util.List;
|
||||
import nl.agilecc.agiledancers.model.AbstractEntity;
|
||||
import nl.agilecc.agiledancers.model.Coach;
|
||||
import nl.agilecc.agiledancers.model.DanceGroup;
|
||||
import nl.agilecc.agiledancers.model.DanceSchool;
|
||||
import nl.agilecc.agiledancers.model.Dancer;
|
||||
import nl.agilecc.agiledancers.model.DayPart;
|
||||
import nl.agilecc.agiledancers.model.Show;
|
||||
import nl.agilecc.agiledancers.rest.apimodel.CoachView;
|
||||
import nl.agilecc.agiledancers.rest.apimodel.DancerView;
|
||||
import nl.agilecc.agiledancers.rest.apimodel.DayPartView;
|
||||
import nl.agilecc.agiledancers.rest.apimodel.GroupView;
|
||||
import nl.agilecc.agiledancers.rest.apimodel.SchoolView;
|
||||
import nl.agilecc.agiledancers.rest.apimodel.ShowView;
|
||||
import nl.agilecc.agiledancers.service.SchedulingInputs;
|
||||
import org.mapstruct.AnnotateWith;
|
||||
import org.mapstruct.Mapper;
|
||||
import org.mapstruct.Mapping;
|
||||
import org.mapstruct.MappingConstants;
|
||||
import org.mapstruct.Named;
|
||||
import org.mapstruct.ReportingPolicy;
|
||||
|
||||
/** Generates API projections only; domain graph identity and validation remain application-owned. */
|
||||
// Mark generated implementations so coverage and architecture checks retain their handwritten-code scope.
|
||||
@AnnotateWith(lombok.Generated.class)
|
||||
@Mapper(componentModel = MappingConstants.ComponentModel.JAKARTA_CDI, unmappedTargetPolicy = ReportingPolicy.ERROR)
|
||||
public interface DomainViewMapper {
|
||||
@Mapping(target = "dancerIds", source = "dancers")
|
||||
@Mapping(target = "danceGroupIds", source = "danceGroups")
|
||||
@Mapping(target = "showIds", source = "shows")
|
||||
public SchoolView school(final DanceSchool school);
|
||||
|
||||
public DancerView dancer(final Dancer dancer);
|
||||
|
||||
@Mapping(target = "dancerIds", source = "dancers")
|
||||
public GroupView group(final DanceGroup group);
|
||||
|
||||
@Mapping(target = "dancerId", source = "dancer")
|
||||
public CoachView coach(final Coach coach);
|
||||
|
||||
@Mapping(target = "danceGroupIds", source = "danceGroups")
|
||||
public DayPartView dayPart(final DayPart part);
|
||||
|
||||
/** Violations are calculated by the use case, never inferred by the mapper. */
|
||||
@Mapping(target = "violations", source = "violations")
|
||||
@Mapping(target = "preferredDayParts", source = "show", qualifiedByName = "preferredDayParts")
|
||||
public ShowView show(final Show show, final List<String> violations);
|
||||
|
||||
public default Long id(final AbstractEntity entity) {
|
||||
return entity == null ? null : entity.getId();
|
||||
}
|
||||
|
||||
@Named("preferredDayParts")
|
||||
public default int preferredDayParts(final Show show) {
|
||||
return SchedulingInputs.preferred(show);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
package nl.agilecc.agiledancers.service;
|
||||
|
||||
import jakarta.inject.Inject;
|
||||
import jakarta.inject.Singleton;
|
||||
import java.util.List;
|
||||
import nl.agilecc.agiledancers.model.Coach;
|
||||
import nl.agilecc.agiledancers.model.DanceSchool;
|
||||
import nl.agilecc.agiledancers.model.Dancer;
|
||||
|
||||
@Singleton
|
||||
public class CoachLinks {
|
||||
@Inject
|
||||
private SchoolManagementService schools;
|
||||
|
||||
public List<Coach> list(final long schoolId) {
|
||||
return List.copyOf(schools.school(schoolId).getCoaches());
|
||||
}
|
||||
|
||||
public Coach link(final long schoolId, final Long coachId, final long dancerId) {
|
||||
synchronized (schools) {
|
||||
final DanceSchool school = schools.school(schoolId);
|
||||
final Dancer dancer = schools.dancer(schoolId, dancerId);
|
||||
final Coach coach = coachId == null ? create(school, dancer) : required(school, coachId);
|
||||
coach.setDancer(dancer);
|
||||
school.getShows().forEach(schools::touch);
|
||||
return coach;
|
||||
}
|
||||
}
|
||||
|
||||
private static Coach create(final DanceSchool school, final Dancer dancer) {
|
||||
final Coach coach = Coach.builder().name(dancer.getName()).build();
|
||||
coach.setId(school.getCoaches().stream().mapToLong(Coach::getId).max().orElse(0) + 1);
|
||||
school.addCoach(coach);
|
||||
return coach;
|
||||
}
|
||||
|
||||
private static Coach required(final DanceSchool school, final long id) {
|
||||
return school.getCoaches().stream()
|
||||
.filter((final Coach c) -> c.getId() == id)
|
||||
.findFirst()
|
||||
.orElseThrow(() -> new ManagementException(ManagementExceptionKind.NOT_FOUND, "Unknown coach"));
|
||||
}
|
||||
|
||||
public void unlink(final long schoolId, final long id) {
|
||||
synchronized (schools) {
|
||||
final DanceSchool school = schools.school(schoolId);
|
||||
required(school, id).setDancer(null);
|
||||
school.getShows().forEach(schools::touch);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -4,9 +4,9 @@ import nl.agilecc.agiledancers.model.Coach;
|
||||
import nl.agilecc.agiledancers.repository.CoachRepository;
|
||||
|
||||
public class CoachService {
|
||||
private CoachRepository coachRepository = new CoachRepository();
|
||||
private final CoachRepository coachRepository = new CoachRepository();
|
||||
|
||||
public Coach addCoach(String name, Coach coach) {
|
||||
public Coach addCoach(final String name, final Coach coach) {
|
||||
return coachRepository.add(Coach.builder().name(name).build());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,26 +1,26 @@
|
||||
package nl.agilecc.agiledancers.service;
|
||||
|
||||
import java.util.LinkedHashSet;
|
||||
import java.util.Set;
|
||||
import nl.agilecc.agiledancers.model.Coach;
|
||||
import nl.agilecc.agiledancers.model.DanceGroup;
|
||||
import nl.agilecc.agiledancers.model.Dancer;
|
||||
import nl.agilecc.agiledancers.model.specifications.DanceGroupStyle;
|
||||
import nl.agilecc.agiledancers.model.specifications.DanceGroupType;
|
||||
import nl.agilecc.agiledancers.repository.DanceGroupRepository;
|
||||
|
||||
import java.util.Set;
|
||||
import nl.agilecc.agiledancers.service.model.GroupDetails;
|
||||
|
||||
public class DanceGroupService {
|
||||
private final DanceGroupRepository danceGroupRepository = new DanceGroupRepository();
|
||||
|
||||
public DanceGroup add(String category, DanceGroupType type, int startAge, int endAge, Set<Dancer> dancers, Set<DanceGroupStyle> styles, Coach coach) {
|
||||
DanceGroup danceGroup = DanceGroup.builder()
|
||||
.category(category)
|
||||
.type(type)
|
||||
.startAge(startAge)
|
||||
.endAge(endAge)
|
||||
public DanceGroup add(final GroupDetails details, final Set<Dancer> dancers, final Coach coach) {
|
||||
final DanceGroup danceGroup = DanceGroup.builder()
|
||||
.category(details.category())
|
||||
.type(details.type())
|
||||
.startAge(details.startAge())
|
||||
.endAge(details.endAge())
|
||||
.dancers(dancers)
|
||||
.styles(styles)
|
||||
.coach(coach).build();
|
||||
.styles(details.styles() == null ? null : new LinkedHashSet<>(details.styles()))
|
||||
.coach(coach)
|
||||
.build();
|
||||
|
||||
return danceGroupRepository.add(danceGroup);
|
||||
}
|
||||
|
||||
@@ -1,29 +1,33 @@
|
||||
package nl.agilecc.agiledancers.service;
|
||||
|
||||
import nl.agilecc.agiledancers.model.*;
|
||||
import nl.agilecc.agiledancers.model.Coach;
|
||||
import nl.agilecc.agiledancers.model.DanceGroup;
|
||||
import nl.agilecc.agiledancers.model.DanceSchool;
|
||||
import nl.agilecc.agiledancers.model.Dancer;
|
||||
import nl.agilecc.agiledancers.model.Show;
|
||||
import nl.agilecc.agiledancers.repository.DanceSchoolRepository;
|
||||
|
||||
public class DanceSchoolService {
|
||||
private final DanceSchoolRepository danceSchoolRepository = new DanceSchoolRepository();
|
||||
|
||||
public DanceSchool add(String name) {
|
||||
DanceSchool danceSchool = DanceSchool.builder().name(name).build();
|
||||
public DanceSchool add(final String name) {
|
||||
final DanceSchool danceSchool = DanceSchool.builder().name(name).build();
|
||||
return danceSchoolRepository.add(danceSchool);
|
||||
}
|
||||
|
||||
public void addDancer(Long danceSchoolId, Dancer dancer) {
|
||||
public void addDancer(final Long danceSchoolId, final Dancer dancer) {
|
||||
danceSchoolRepository.findById(danceSchoolId).addDancer(dancer);
|
||||
}
|
||||
|
||||
public void addDanceGroup(Long danceSchoolId, DanceGroup danceGroup) {
|
||||
public void addDanceGroup(final Long danceSchoolId, final DanceGroup danceGroup) {
|
||||
danceSchoolRepository.findById(danceSchoolId).addDanceGroup(danceGroup);
|
||||
}
|
||||
|
||||
public void addCoach(Long danceSchoolId, Coach coach) {
|
||||
public void addCoach(final Long danceSchoolId, final Coach coach) {
|
||||
danceSchoolRepository.findById(danceSchoolId).addCoach(coach);
|
||||
}
|
||||
|
||||
public void addShow(Long danceSchoolId, Show show) {
|
||||
public void addShow(final Long danceSchoolId, final Show show) {
|
||||
danceSchoolRepository.findById(danceSchoolId).addShow(show);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,15 +1,14 @@
|
||||
package nl.agilecc.agiledancers.service;
|
||||
|
||||
import java.time.LocalDate;
|
||||
import nl.agilecc.agiledancers.model.Dancer;
|
||||
import nl.agilecc.agiledancers.repository.DancerRepository;
|
||||
|
||||
import java.time.LocalDate;
|
||||
|
||||
public class DancerService {
|
||||
private final DancerRepository dancerRepository = new DancerRepository();
|
||||
|
||||
public Dancer addDancer(String name, LocalDate birthDate) {
|
||||
Dancer dancer = Dancer.builder().name(name).birthDate(birthDate).build();
|
||||
public Dancer addDancer(final String name, final LocalDate birthDate) {
|
||||
final Dancer dancer = Dancer.builder().name(name).birthDate(birthDate).build();
|
||||
return dancerRepository.add(dancer);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,7 +4,7 @@ public class ManagementException extends RuntimeException {
|
||||
|
||||
private final ManagementExceptionKind kind;
|
||||
|
||||
public ManagementException(ManagementExceptionKind kind, String message) {
|
||||
public ManagementException(final ManagementExceptionKind kind, final String message) {
|
||||
super(message);
|
||||
this.kind = kind;
|
||||
}
|
||||
|
||||
@@ -1,3 +1,8 @@
|
||||
package nl.agilecc.agiledancers.service;
|
||||
|
||||
public enum ManagementExceptionKind { NOT_FOUND, INVALID_INPUT, CONFLICT }
|
||||
public enum ManagementExceptionKind {
|
||||
NOT_FOUND,
|
||||
STORAGE_UNAVAILABLE,
|
||||
INVALID_INPUT,
|
||||
CONFLICT
|
||||
}
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
package nl.agilecc.agiledancers.service;
|
||||
|
||||
import nl.agilecc.agiledancers.model.scheduling.SchedulingFailureKind;
|
||||
|
||||
/** Carries a stable calculation outcome independently of its human-readable explanation. */
|
||||
public class SchedulingFailure extends ManagementException {
|
||||
private final SchedulingFailureKind outcome;
|
||||
|
||||
public SchedulingFailure(final SchedulingFailureKind outcome, final String message) {
|
||||
super(ManagementExceptionKind.INVALID_INPUT, message);
|
||||
this.outcome = outcome;
|
||||
}
|
||||
|
||||
public SchedulingFailureKind getOutcome() {
|
||||
return outcome;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
package nl.agilecc.agiledancers.service;
|
||||
|
||||
import java.time.LocalDate;
|
||||
import java.time.Period;
|
||||
import java.util.List;
|
||||
import java.util.Set;
|
||||
import java.util.stream.Collectors;
|
||||
import nl.agilecc.agiledancers.model.Coach;
|
||||
import nl.agilecc.agiledancers.model.DanceGroup;
|
||||
import nl.agilecc.agiledancers.model.DanceSchool;
|
||||
import nl.agilecc.agiledancers.model.Dancer;
|
||||
import nl.agilecc.agiledancers.model.DayPart;
|
||||
import nl.agilecc.agiledancers.model.Show;
|
||||
import nl.agilecc.agiledancers.model.scheduling.GroupRequirement;
|
||||
import nl.agilecc.agiledancers.model.scheduling.SchedulingInput;
|
||||
import nl.agilecc.agiledancers.model.specifications.DanceGroupType;
|
||||
|
||||
public final class SchedulingInputs {
|
||||
private SchedulingInputs() {}
|
||||
|
||||
public static int preferred(final Show show) {
|
||||
if (show.getPreferredDayParts() != null) {
|
||||
return show.getPreferredDayParts();
|
||||
}
|
||||
return (show.getDayParts().size() == 1 ? 1 : 2);
|
||||
}
|
||||
|
||||
public static SchedulingInput of(final DanceSchool school, final Show show, final long revision) {
|
||||
final int preferredDayParts = preferred(show);
|
||||
final LocalDate date = show.getDayParts().stream()
|
||||
.map(DayPart::getDate)
|
||||
.min(LocalDate::compareTo)
|
||||
.orElse(LocalDate.now());
|
||||
final List<GroupRequirement> groups = school.getDanceGroups().stream()
|
||||
.filter((final DanceGroup g) -> !g.getDancers().isEmpty())
|
||||
.map((final DanceGroup g) -> requirement(g, show, date))
|
||||
.toList();
|
||||
final Set<Long> coaches = school.getCoaches().stream()
|
||||
.map(Coach::getDancer)
|
||||
.filter(java.util.Objects::nonNull)
|
||||
.map(Dancer::getId)
|
||||
.collect(Collectors.toSet());
|
||||
return new SchedulingInput(
|
||||
show.getId(), revision, show.getDayParts().size(), groups, preferredDayParts, coaches);
|
||||
}
|
||||
|
||||
private static GroupRequirement requirement(final DanceGroup group, final Show show, final LocalDate date) {
|
||||
final int count = switch (group.getType()) {
|
||||
case PRE_SCHOOLER -> 1;
|
||||
case SHOW_CASE -> show.getDayParts().size();
|
||||
default -> preferred(show);
|
||||
};
|
||||
final double age = group.getDancers().stream()
|
||||
.mapToInt((final Dancer d) ->
|
||||
Period.between(d.getBirthDate(), date).getYears())
|
||||
.average()
|
||||
.orElse(0);
|
||||
return new GroupRequirement(
|
||||
group.getId(),
|
||||
count,
|
||||
group.getType() == DanceGroupType.SHOW_CASE,
|
||||
group.getDancers().stream().map(Dancer::getId).collect(Collectors.toSet()),
|
||||
group.getStyles(),
|
||||
age);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
package nl.agilecc.agiledancers.service;
|
||||
|
||||
import jakarta.inject.Inject;
|
||||
import jakarta.inject.Singleton;
|
||||
import java.util.List;
|
||||
import java.util.concurrent.Callable;
|
||||
import nl.agilecc.agiledancers.model.scheduling.SchedulingRun;
|
||||
import nl.agilecc.agiledancers.repository.Database;
|
||||
import nl.agilecc.agiledancers.repository.RunRepository;
|
||||
|
||||
/** Coordinates scheduling status with the same unit of work as domain changes. */
|
||||
@Singleton
|
||||
public class SchedulingPersistence {
|
||||
private final SchoolTransactions transactions;
|
||||
private final Database database;
|
||||
private final RunRepository runs;
|
||||
|
||||
@Inject
|
||||
public SchedulingPersistence(
|
||||
final SchoolTransactions transactions, final Database database, final RunRepository runs) {
|
||||
this.transactions = transactions;
|
||||
this.database = database;
|
||||
this.runs = runs;
|
||||
}
|
||||
|
||||
public List<SchedulingRun> recover() {
|
||||
return transactions.background(() -> {
|
||||
runs.recover();
|
||||
return runs.load();
|
||||
});
|
||||
}
|
||||
|
||||
public List<SchedulingRun> load() {
|
||||
return transactions.background(runs::load);
|
||||
}
|
||||
|
||||
public <T> T work(final Callable<T> action) {
|
||||
return transactions.background(action);
|
||||
}
|
||||
|
||||
public void save(final SchedulingRun run, final Runnable publication) {
|
||||
runs.save(run);
|
||||
database.afterCommit(publication);
|
||||
}
|
||||
|
||||
public void accepted(final SchedulingRun run, final Runnable launch, final Runnable rollback) {
|
||||
database.afterRollback(rollback);
|
||||
save(run, launch);
|
||||
}
|
||||
|
||||
public void result(final long schoolId) {
|
||||
transactions.saveResult(schoolId);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,240 @@
|
||||
package nl.agilecc.agiledancers.service;
|
||||
|
||||
import io.quarkus.runtime.Startup;
|
||||
import jakarta.annotation.PostConstruct;
|
||||
import jakarta.annotation.PreDestroy;
|
||||
import jakarta.inject.Inject;
|
||||
import jakarta.inject.Singleton;
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.concurrent.Callable;
|
||||
import java.util.concurrent.ExecutorService;
|
||||
import java.util.concurrent.Executors;
|
||||
import java.util.function.Consumer;
|
||||
import nl.agilecc.agiledancers.model.scheduling.SchedulingInput;
|
||||
import nl.agilecc.agiledancers.model.scheduling.SchedulingProgress;
|
||||
import nl.agilecc.agiledancers.model.scheduling.SchedulingResult;
|
||||
import nl.agilecc.agiledancers.model.scheduling.SchedulingRun;
|
||||
import nl.agilecc.agiledancers.model.scheduling.SchedulingState;
|
||||
import nl.agilecc.agiledancers.model.scheduling.SchedulingSummary;
|
||||
import nl.agilecc.agiledancers.repository.StorageException;
|
||||
import nl.agilecc.agiledancers.service.port.SchedulingEngine;
|
||||
|
||||
/** Owns background tasks; the school monitor orders short commits and publication, never solver computation. */
|
||||
@Startup
|
||||
@Singleton
|
||||
public class SchedulingRuns {
|
||||
private final SchoolManagementService schools;
|
||||
private final SchedulingEngine engine;
|
||||
private final ExecutorService executor = Executors.newVirtualThreadPerTaskExecutor();
|
||||
private final Map<Long, SchedulingRun> runs = new HashMap<>();
|
||||
private final Map<Object, Consumer<List<SchedulingRun>>> listeners = new HashMap<>();
|
||||
private @Inject SchedulingPersistence persistence;
|
||||
|
||||
@Inject
|
||||
public SchedulingRuns(final SchoolManagementService schools, final SchedulingEngine engine) {
|
||||
this.schools = schools;
|
||||
this.engine = engine;
|
||||
}
|
||||
|
||||
@PostConstruct
|
||||
public void recover() {
|
||||
persistence.recover().forEach((final SchedulingRun run) -> runs.put(run.showId(), run));
|
||||
}
|
||||
|
||||
public SchedulingRun start(final long schoolId, final long showId) {
|
||||
synchronized (schools) {
|
||||
final SchedulingInput input = schools.beginScheduling(schoolId, showId);
|
||||
final SchedulingRun run = SchedulingRun.started(
|
||||
schoolId, showId, schools.show(schoolId, showId).getName());
|
||||
final Runnable launch = () -> launch(run, input);
|
||||
if (persistence == null) {
|
||||
launch.run();
|
||||
} else {
|
||||
persistence.accepted(run, launch, () -> schools.endScheduling(showId));
|
||||
}
|
||||
return run;
|
||||
}
|
||||
}
|
||||
|
||||
private void launch(final SchedulingRun run, final SchedulingInput input) {
|
||||
publish(run);
|
||||
try {
|
||||
executor.submit(() -> calculate(run, input));
|
||||
} catch (final RuntimeException failure) {
|
||||
finish(run.completed(
|
||||
SchedulingState.SOLVER_FAILED, "Berekening kon niet starten. Bestaande indeling behouden.", null));
|
||||
if (persistence == null) {
|
||||
throw failure;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public SchedulingRun status(final long schoolId, final long showId) {
|
||||
synchronized (schools) {
|
||||
schools.show(schoolId, showId);
|
||||
return runs.get(showId);
|
||||
}
|
||||
}
|
||||
|
||||
/** Registration and initial snapshot share the publication lock, preventing a missed update. */
|
||||
public Runnable subscribe(final Consumer<List<SchedulingRun>> listener) {
|
||||
synchronized (schools) {
|
||||
if (persistence != null) {
|
||||
runs.clear();
|
||||
persistence.load().forEach((final SchedulingRun run) -> runs.put(run.showId(), run));
|
||||
}
|
||||
final Object key = new Object();
|
||||
listeners.put(key, listener);
|
||||
listener.accept(List.copyOf(runs.values()));
|
||||
return () -> unsubscribe(key);
|
||||
}
|
||||
}
|
||||
|
||||
private void unsubscribe(final Object key) {
|
||||
synchronized (schools) {
|
||||
listeners.remove(key);
|
||||
}
|
||||
}
|
||||
|
||||
private void publish(final SchedulingRun run) {
|
||||
synchronized (schools) {
|
||||
runs.put(run.showId(), run);
|
||||
final List<SchedulingRun> snapshot = List.copyOf(runs.values());
|
||||
for (final Consumer<List<SchedulingRun>> listener : List.copyOf(listeners.values())) {
|
||||
notifyListener(listener, snapshot);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void notifyListener(final Consumer<List<SchedulingRun>> listener, final List<SchedulingRun> snapshot) {
|
||||
try {
|
||||
listener.accept(snapshot);
|
||||
} catch (final RuntimeException disconnected) {
|
||||
// A disconnected transport must not turn a committed solver outcome into a failure.
|
||||
listeners.values().remove(listener);
|
||||
}
|
||||
}
|
||||
|
||||
private void progress(final SchedulingRun run, final SchedulingProgress update) {
|
||||
work(() -> {
|
||||
save(latest(run).progressed(update));
|
||||
return null;
|
||||
});
|
||||
}
|
||||
|
||||
private void calculate(final SchedulingRun run, final SchedulingInput input) {
|
||||
try {
|
||||
final SchedulingResult result =
|
||||
engine.solve(input, (final SchedulingProgress update) -> progress(run, update));
|
||||
final SchedulingSummary summary = SchedulingSummary.of(input, result);
|
||||
work(() -> {
|
||||
schools.finishScheduling(run.schoolId(), input, result);
|
||||
if (persistence != null) {
|
||||
persistence.result(run.schoolId());
|
||||
}
|
||||
save(latest(run)
|
||||
.completed(
|
||||
result.optimal() ? SchedulingState.OPTIMAL : SchedulingState.VALID,
|
||||
result.optimal()
|
||||
? "Optimaal conceptrooster."
|
||||
: "Geldig conceptrooster; optimaliteit niet bewezen.",
|
||||
summary));
|
||||
return null;
|
||||
});
|
||||
schools.endScheduling(run.showId());
|
||||
} catch (final ManagementException failure) {
|
||||
finish(failed(run, failure));
|
||||
} catch (final RuntimeException | LinkageError failure) {
|
||||
finish(latest(run)
|
||||
.completed(
|
||||
SchedulingState.SOLVER_FAILED, "Berekening mislukt. Bestaande indeling behouden.", null));
|
||||
}
|
||||
}
|
||||
|
||||
private SchedulingRun failed(final SchedulingRun run, final ManagementException failure) {
|
||||
final SchedulingState outcome = failure instanceof final SchedulingFailure scheduling
|
||||
? switch (scheduling.getOutcome()) {
|
||||
case INFEASIBLE -> SchedulingState.INFEASIBLE;
|
||||
case NO_SOLUTION -> SchedulingState.NO_SOLUTION;
|
||||
case SOLVER_FAILED -> SchedulingState.SOLVER_FAILED;
|
||||
}
|
||||
: (failure.getKind() == ManagementExceptionKind.CONFLICT
|
||||
? SchedulingState.STALE
|
||||
: SchedulingState.INVALID_INPUT);
|
||||
return latest(run).completed(outcome, failure.getMessage() + " Bestaande indeling behouden.", null);
|
||||
}
|
||||
|
||||
private SchedulingRun latest(final SchedulingRun run) {
|
||||
synchronized (schools) {
|
||||
return runs.get(run.showId());
|
||||
}
|
||||
}
|
||||
|
||||
private void finish(final SchedulingRun completed) {
|
||||
try {
|
||||
while (true) {
|
||||
try {
|
||||
work(() -> {
|
||||
schools.show(completed.schoolId(), completed.showId());
|
||||
save(completed);
|
||||
return null;
|
||||
});
|
||||
return;
|
||||
} catch (final StorageException unavailable) {
|
||||
// Keep the run guard and terminal outcome until storage recovers; never publish an uncommitted
|
||||
// status.
|
||||
if (executor.isShutdown() || !waitForStorage()) {
|
||||
return;
|
||||
}
|
||||
} catch (final ManagementException removed) {
|
||||
if (removed.getKind() != ManagementExceptionKind.NOT_FOUND) {
|
||||
throw removed;
|
||||
}
|
||||
return;
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
schools.endScheduling(completed.showId());
|
||||
}
|
||||
}
|
||||
|
||||
private boolean waitForStorage() {
|
||||
try {
|
||||
Thread.sleep(1000);
|
||||
return true;
|
||||
} catch (final InterruptedException stopping) {
|
||||
Thread.currentThread().interrupt();
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private void save(final SchedulingRun run) {
|
||||
if (persistence == null) {
|
||||
publish(run);
|
||||
} else {
|
||||
persistence.save(run, () -> publish(run));
|
||||
}
|
||||
}
|
||||
|
||||
private <T> T work(final Callable<T> action) {
|
||||
synchronized (schools) {
|
||||
if (persistence != null) {
|
||||
return persistence.work(action);
|
||||
}
|
||||
try {
|
||||
return action.call();
|
||||
} catch (final RuntimeException failure) {
|
||||
throw failure;
|
||||
} catch (final Exception failure) {
|
||||
throw new IllegalStateException(failure);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@PreDestroy
|
||||
public void close() {
|
||||
executor.shutdownNow();
|
||||
}
|
||||
}
|
||||
@@ -1,22 +1,41 @@
|
||||
package nl.agilecc.agiledancers.service;
|
||||
|
||||
import nl.agilecc.agiledancers.model.scheduling.SchedulingResult;
|
||||
import nl.agilecc.agiledancers.service.port.SchedulingEngine;
|
||||
import jakarta.inject.Inject;
|
||||
import jakarta.inject.Singleton;
|
||||
import nl.agilecc.agiledancers.model.scheduling.SchedulingInput;
|
||||
import nl.agilecc.agiledancers.model.scheduling.SchedulingResult;
|
||||
import nl.agilecc.agiledancers.service.port.SchedulingEngine;
|
||||
|
||||
@Singleton
|
||||
public class SchedulingService {
|
||||
@Inject SchoolManagementService schools;
|
||||
@Inject SchedulingEngine engine;
|
||||
public SchedulingResult schedule(long schoolId, long showId) {
|
||||
var input = schools.beginScheduling(schoolId, showId);
|
||||
@Inject
|
||||
private SchoolManagementService schools;
|
||||
|
||||
@Inject
|
||||
private SchedulingEngine engine;
|
||||
|
||||
public SchedulingService() {}
|
||||
|
||||
/** Supplies collaborators directly for callers outside the CDI lifecycle. */
|
||||
public SchedulingService(final SchoolManagementService schools, final SchedulingEngine engine) {
|
||||
this.schools = schools;
|
||||
this.engine = engine;
|
||||
}
|
||||
|
||||
public SchedulingResult schedule(final long schoolId, final long showId) {
|
||||
final SchedulingInput input = schools.beginScheduling(schoolId, showId);
|
||||
try {
|
||||
var result = engine.solve(input);
|
||||
final SchedulingResult result = engine.solve(input);
|
||||
schools.finishScheduling(schoolId, input, result);
|
||||
return result;
|
||||
} catch (ManagementException e) { throw e; }
|
||||
catch (RuntimeException | LinkageError e) {
|
||||
throw new ManagementException(ManagementExceptionKind.INVALID_INPUT, "Solver failed. Existing assignments were preserved. Please run scheduling again.");
|
||||
} finally { schools.endScheduling(showId); }
|
||||
} catch (final ManagementException e) {
|
||||
throw e;
|
||||
} catch (final RuntimeException | LinkageError e) {
|
||||
throw new ManagementException(
|
||||
ManagementExceptionKind.INVALID_INPUT,
|
||||
"Solver failed. Existing assignments were preserved. Please run scheduling again.");
|
||||
} finally {
|
||||
schools.endScheduling(showId);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,18 +1,36 @@
|
||||
package nl.agilecc.agiledancers.service;
|
||||
|
||||
import nl.agilecc.agiledancers.model.scheduling.GroupRequirement;
|
||||
import jakarta.inject.Singleton;
|
||||
import java.time.LocalDate;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Collection;
|
||||
import java.util.HashMap;
|
||||
import java.util.HashSet;
|
||||
import java.util.IdentityHashMap;
|
||||
import java.util.LinkedHashSet;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Objects;
|
||||
import java.util.Set;
|
||||
import nl.agilecc.agiledancers.model.AbstractEntity;
|
||||
import nl.agilecc.agiledancers.model.DanceGroup;
|
||||
import nl.agilecc.agiledancers.model.DanceSchool;
|
||||
import nl.agilecc.agiledancers.model.Dancer;
|
||||
import nl.agilecc.agiledancers.model.DayPart;
|
||||
import nl.agilecc.agiledancers.model.Show;
|
||||
import nl.agilecc.agiledancers.model.scheduling.ScheduleRules;
|
||||
import nl.agilecc.agiledancers.model.scheduling.SchedulingInput;
|
||||
import nl.agilecc.agiledancers.model.scheduling.SchedulingResult;
|
||||
import nl.agilecc.agiledancers.service.model.*;
|
||||
import jakarta.inject.Singleton;
|
||||
import nl.agilecc.agiledancers.model.*;
|
||||
import nl.agilecc.agiledancers.model.specifications.*;
|
||||
import nl.agilecc.agiledancers.repository.*;
|
||||
import java.time.LocalDate;
|
||||
import java.util.*;
|
||||
import static nl.agilecc.agiledancers.service.ManagementExceptionKind.*;
|
||||
import nl.agilecc.agiledancers.model.specifications.DanceGroupType;
|
||||
import nl.agilecc.agiledancers.repository.DanceGroupRepository;
|
||||
import nl.agilecc.agiledancers.repository.DanceSchoolRepository;
|
||||
import nl.agilecc.agiledancers.repository.DancerRepository;
|
||||
import nl.agilecc.agiledancers.repository.ShowRepository;
|
||||
import nl.agilecc.agiledancers.service.model.GroupDetails;
|
||||
import nl.agilecc.agiledancers.service.model.ImportPreview;
|
||||
import nl.agilecc.agiledancers.service.model.SchoolSnapshot;
|
||||
|
||||
/** Owns the in-memory school aggregates; all writes go through this shared service. */
|
||||
/** Owns detached school aggregates within the shared transaction monitor; all domain writes pass through here. */
|
||||
@Singleton
|
||||
public class SchoolManagementService {
|
||||
private final DanceSchoolRepository schools = new DanceSchoolRepository();
|
||||
@@ -22,263 +40,417 @@ public class SchoolManagementService {
|
||||
|
||||
private final Map<Long, Long> revisions = new HashMap<>();
|
||||
private final Set<Long> running = new HashSet<>();
|
||||
private void touch(Show show) { revisions.merge(show.getId(), 1L, Long::sum); }
|
||||
|
||||
public synchronized SchedulingInput beginScheduling(long schoolId, long showId) {
|
||||
Show show = show(schoolId, showId);
|
||||
if (running.contains(showId)) throw new ManagementException(CONFLICT, "Scheduling is already running for this show.");
|
||||
List<DanceGroup> current = groups(schoolId);
|
||||
if (current.isEmpty() || show.getDayParts().isEmpty()) throw new ManagementException(INVALID_INPUT, "Scheduling requires at least one school group and one daypart.");
|
||||
ShowConfiguration.validate(show.getDayParts(), show.getAppearances());
|
||||
running.add(showId);
|
||||
return new SchedulingInput(showId, revisions.getOrDefault(showId, 0L), show.getDayParts().size(),
|
||||
current.stream().map(g -> new GroupRequirement(g.getId(), show.getAppearances().get(g.getType()))).toList());
|
||||
/** Installs a detached relational snapshot under the service's existing aggregate lock. */
|
||||
public synchronized void load(final List<DanceSchool> state, final java.util.function.LongSupplier identifiers) {
|
||||
revisions.clear();
|
||||
schools.load(state, identifiers);
|
||||
dancers.load(
|
||||
state.stream()
|
||||
.flatMap((final DanceSchool school) -> school.getDancers().stream())
|
||||
.toList(),
|
||||
identifiers);
|
||||
groups.load(
|
||||
state.stream()
|
||||
.flatMap((final DanceSchool school) -> school.getDanceGroups().stream())
|
||||
.toList(),
|
||||
identifiers);
|
||||
shows.load(
|
||||
state.stream()
|
||||
.flatMap((final DanceSchool school) -> school.getShows().stream())
|
||||
.toList(),
|
||||
identifiers);
|
||||
}
|
||||
public synchronized void finishScheduling(long schoolId, SchedulingInput input, SchedulingResult result) {
|
||||
Show show;
|
||||
try { show = show(schoolId, input.showId()); }
|
||||
catch (ManagementException e) { throw new ManagementException(CONFLICT, "Scheduling inputs changed. Run scheduling again."); }
|
||||
if (revisions.getOrDefault(input.showId(), 0L) != input.revision())
|
||||
throw new ManagementException(CONFLICT, "Scheduling inputs changed. Run scheduling again.");
|
||||
// Validate the complete solver output before mutating any assignments.
|
||||
if (result == null || result.assignments() == null || result.assignments().size() != input.dayParts())
|
||||
throw new ManagementException(INVALID_INPUT, "Solver returned an invalid result. Existing assignments were preserved.");
|
||||
Map<Long, DanceGroup> current = new HashMap<>();
|
||||
groups(schoolId).forEach(g -> current.put(g.getId(), g));
|
||||
for (var part : result.assignments()) {
|
||||
if (part == null || new HashSet<>(part).size() != part.size() || !current.keySet().containsAll(part))
|
||||
throw new ManagementException(INVALID_INPUT, "Solver returned invalid group assignments.");
|
||||
|
||||
public synchronized void touch(final Show show) {
|
||||
show.setFinalized(false);
|
||||
revisions.merge(show.getId(), 1L, Long::sum);
|
||||
}
|
||||
|
||||
/** Reserves this show's run slot and snapshots its inputs; always release it with endScheduling. */
|
||||
public synchronized SchedulingInput beginScheduling(final long schoolId, final long showId) {
|
||||
final Show show = show(schoolId, showId);
|
||||
if (running.contains(showId)) {
|
||||
throw new ManagementException(
|
||||
ManagementExceptionKind.CONFLICT, "Scheduling is already running for this show.");
|
||||
}
|
||||
for (GroupRequirement group : input.groups()) {
|
||||
long id = group.groupId();
|
||||
if (result.assignments().stream().filter(p -> p.contains(id)).count() != group.appearances())
|
||||
throw new ManagementException(INVALID_INPUT, "Solver did not satisfy every required appearance.");
|
||||
final List<DanceGroup> current = groups(schoolId);
|
||||
if (current.isEmpty() || show.getDayParts().isEmpty()) {
|
||||
throw new ManagementException(
|
||||
ManagementExceptionKind.INVALID_INPUT,
|
||||
"Scheduling requires at least one school group and one daypart.");
|
||||
}
|
||||
List<DayPart> replacement = new ArrayList<>();
|
||||
if (SchedulingInputs.preferred(show) > show.getDayParts().size()) {
|
||||
throw new ManagementException(
|
||||
ManagementExceptionKind.INVALID_INPUT, "Correct preferredDayParts before scheduling.");
|
||||
}
|
||||
running.add(showId);
|
||||
return SchedulingInputs.of(
|
||||
school(schoolId), show, school(schoolId).getRevision() + revisions.getOrDefault(showId, 0L));
|
||||
}
|
||||
|
||||
/** Validates the complete result and input revision before replacing any saved assignments. */
|
||||
public synchronized void finishScheduling(
|
||||
final long schoolId, final SchedulingInput input, final SchedulingResult result) {
|
||||
final Show show;
|
||||
try {
|
||||
show = show(schoolId, input.showId());
|
||||
} catch (final ManagementException e) {
|
||||
throw new ManagementException(
|
||||
ManagementExceptionKind.CONFLICT, "Scheduling inputs changed. Run scheduling again.");
|
||||
}
|
||||
if (school(schoolId).getRevision() + revisions.getOrDefault(input.showId(), 0L) != input.revision()) {
|
||||
throw new ManagementException(
|
||||
ManagementExceptionKind.CONFLICT, "Scheduling inputs changed. Run scheduling again.");
|
||||
}
|
||||
final Map<Long, DanceGroup> current = validateAssignments(schoolId, input, result);
|
||||
final List<DayPart> replacement = new ArrayList<>();
|
||||
for (int p = 0; p < input.dayParts(); p++) {
|
||||
DayPart old = show.getDayParts().get(p);
|
||||
replacement.add(DayPart.builder().date(old.getDate()).type(old.getType())
|
||||
.danceGroups(result.assignments().get(p).stream().map(current::get).toList()).build());
|
||||
final DayPart old = show.getDayParts().get(p);
|
||||
replacement.add(DayPart.builder()
|
||||
.date(old.getDate())
|
||||
.type(old.getType())
|
||||
.danceGroups(result.assignments().get(p).stream()
|
||||
.map(current::get)
|
||||
.toList())
|
||||
.build());
|
||||
}
|
||||
show.setDayParts(List.copyOf(replacement));
|
||||
touch(show);
|
||||
}
|
||||
|
||||
private Map<Long, DanceGroup> validateAssignments(
|
||||
final long schoolId, final SchedulingInput input, final SchedulingResult result) {
|
||||
// Validate the complete solver output before mutating any assignments.
|
||||
if (result == null
|
||||
|| result.assignments() == null
|
||||
|| result.assignments().size() != input.dayParts()) {
|
||||
throw new ManagementException(
|
||||
ManagementExceptionKind.INVALID_INPUT,
|
||||
"Solver returned an invalid result. Existing assignments were preserved.");
|
||||
}
|
||||
final Map<Long, DanceGroup> current = new HashMap<>();
|
||||
groups(schoolId).forEach((final DanceGroup g) -> current.put(g.getId(), g));
|
||||
final List<String> violations = ScheduleRules.violations(input, result.assignments());
|
||||
if (!violations.isEmpty()) {
|
||||
throw new ManagementException(ManagementExceptionKind.INVALID_INPUT, String.join(" ", violations));
|
||||
}
|
||||
return current;
|
||||
}
|
||||
|
||||
/** Releases the run slot after success or failure, without changing saved assignments. */
|
||||
public synchronized void endScheduling(final long showId) {
|
||||
running.remove(showId);
|
||||
}
|
||||
public synchronized void endScheduling(long showId) { running.remove(showId); }
|
||||
|
||||
public synchronized List<DanceSchool> schools() {
|
||||
return schools.findAll();
|
||||
}
|
||||
|
||||
public synchronized DanceSchool school(long schoolId) {
|
||||
public synchronized DanceSchool school(final long schoolId) {
|
||||
return required(schools.findById(schoolId), "Dance school", schoolId);
|
||||
}
|
||||
|
||||
public synchronized DanceSchool createSchool(String name) {
|
||||
public synchronized DanceSchool createSchool(final String name) {
|
||||
return schools.add(DanceSchool.builder().name(name).build());
|
||||
}
|
||||
|
||||
public synchronized DanceSchool updateSchool(long schoolId, String name) {
|
||||
DanceSchool school = school(schoolId);
|
||||
public synchronized DanceSchool updateSchool(final long schoolId, final String name) {
|
||||
final DanceSchool school = school(schoolId);
|
||||
school.setName(name);
|
||||
return school;
|
||||
}
|
||||
|
||||
public synchronized void deleteSchool(long schoolId) {
|
||||
DanceSchool school = school(schoolId);
|
||||
school.getDanceGroups().forEach(group -> groups.delete(group.getId()));
|
||||
school.getDancers().forEach(dancer -> dancers.delete(dancer.getId()));
|
||||
school.getShows().forEach(show -> shows.delete(show.getId()));
|
||||
public synchronized void deleteSchool(final long schoolId) {
|
||||
final DanceSchool school = school(schoolId);
|
||||
school.getDanceGroups().forEach((final DanceGroup group) -> groups.delete(group.getId()));
|
||||
school.getDancers().forEach((final Dancer dancer) -> dancers.delete(dancer.getId()));
|
||||
school.getShows().forEach((final Show show) -> shows.delete(show.getId()));
|
||||
schools.delete(schoolId);
|
||||
}
|
||||
|
||||
public synchronized List<Dancer> dancers(long schoolId) {
|
||||
public synchronized List<Dancer> dancers(final long schoolId) {
|
||||
return List.copyOf(school(schoolId).getDancers());
|
||||
}
|
||||
|
||||
public synchronized Dancer dancer(long schoolId, long dancerId) {
|
||||
public synchronized Dancer dancer(final long schoolId, final long dancerId) {
|
||||
return member(school(schoolId).getDancers(), dancerId, "Dancer");
|
||||
}
|
||||
|
||||
public synchronized Dancer createDancer(long schoolId, String name, LocalDate birthDate) {
|
||||
DanceSchool school = school(schoolId);
|
||||
Dancer dancer = dancers.add(Dancer.builder().name(name).birthDate(birthDate).build());
|
||||
public synchronized Dancer createDancer(final long schoolId, final String name, final LocalDate birthDate) {
|
||||
final DanceSchool school = school(schoolId);
|
||||
final Dancer dancer =
|
||||
dancers.add(Dancer.builder().name(name).birthDate(birthDate).build());
|
||||
school.addDancer(dancer);
|
||||
return dancer;
|
||||
}
|
||||
|
||||
public synchronized Dancer updateDancer(long schoolId, long dancerId, String name, LocalDate birthDate) {
|
||||
Dancer dancer = dancer(schoolId, dancerId);
|
||||
public synchronized Dancer updateDancer(
|
||||
final long schoolId, final long dancerId, final String name, final LocalDate birthDate) {
|
||||
final Dancer dancer = dancer(schoolId, dancerId);
|
||||
dancer.setName(name);
|
||||
dancer.setBirthDate(birthDate);
|
||||
school(schoolId).getShows().forEach(this::touch);
|
||||
return dancer;
|
||||
}
|
||||
|
||||
public synchronized void deleteDancer(long schoolId, long dancerId) {
|
||||
DanceSchool school = school(schoolId);
|
||||
Dancer dancer = dancer(schoolId, dancerId);
|
||||
school.getDanceGroups().forEach(group -> group.removeDancer(dancer));
|
||||
public synchronized void deleteDancer(final long schoolId, final long dancerId) {
|
||||
final DanceSchool school = school(schoolId);
|
||||
final Dancer dancer = dancer(schoolId, dancerId);
|
||||
school.getDanceGroups().forEach((final DanceGroup group) -> group.removeDancer(dancer));
|
||||
school.getCoaches().stream()
|
||||
.filter((final nl.agilecc.agiledancers.model.Coach c) -> c.getDancer() == dancer)
|
||||
.forEach((final nl.agilecc.agiledancers.model.Coach c) -> c.setDancer(null));
|
||||
school.getShows().forEach(this::touch);
|
||||
school.removeDancer(dancer);
|
||||
dancers.delete(dancerId);
|
||||
}
|
||||
|
||||
public synchronized List<DanceGroup> groups(long schoolId) {
|
||||
public synchronized List<DanceGroup> groups(final long schoolId) {
|
||||
return List.copyOf(school(schoolId).getDanceGroups());
|
||||
}
|
||||
|
||||
public synchronized DanceGroup group(long schoolId, long groupId) {
|
||||
public synchronized DanceGroup group(final long schoolId, final long groupId) {
|
||||
return member(school(schoolId).getDanceGroups(), groupId, "Dance group");
|
||||
}
|
||||
|
||||
public synchronized DanceGroup createGroup(long schoolId, String category, DanceGroupType type,
|
||||
int startAge, int endAge, Set<DanceGroupStyle> styles) {
|
||||
DanceSchool school = school(schoolId);
|
||||
validateAgeRange(startAge, endAge);
|
||||
DanceGroup group = groups.add(DanceGroup.builder().category(category).type(type)
|
||||
.startAge(startAge).endAge(endAge).styles(new LinkedHashSet<>(styles)).build());
|
||||
public synchronized DanceGroup createGroup(final long schoolId, final GroupDetails details) {
|
||||
final DanceSchool school = school(schoolId);
|
||||
validateAgeRange(details.startAge(), details.endAge());
|
||||
final DanceGroup group = groups.add(DanceGroup.builder()
|
||||
.category(details.category())
|
||||
.type(details.type())
|
||||
.startAge(details.startAge())
|
||||
.endAge(details.endAge())
|
||||
.styles(new LinkedHashSet<>(details.styles()))
|
||||
.build());
|
||||
school.addDanceGroup(group);
|
||||
school.getShows().forEach(this::touch);
|
||||
return group;
|
||||
}
|
||||
|
||||
public synchronized DanceGroup updateGroup(long schoolId, long groupId, String category, DanceGroupType type,
|
||||
int startAge, int endAge, Set<DanceGroupStyle> styles) {
|
||||
DanceGroup group = group(schoolId, groupId);
|
||||
validateAgeRange(startAge, endAge);
|
||||
group.setCategory(category);
|
||||
if (group.getType() != type) school(schoolId).getShows().forEach(this::touch);
|
||||
group.setType(type);
|
||||
group.setStartAge(startAge);
|
||||
group.setEndAge(endAge);
|
||||
group.replaceStyles(styles);
|
||||
public synchronized DanceGroup updateGroup(final long schoolId, final long groupId, final GroupDetails details) {
|
||||
final DanceGroup group = group(schoolId, groupId);
|
||||
validateAgeRange(details.startAge(), details.endAge());
|
||||
group.setCategory(details.category());
|
||||
if (group.getType() != details.type() || !group.getStyles().equals(new HashSet<>(details.styles()))) {
|
||||
school(schoolId).getShows().forEach(this::touch);
|
||||
}
|
||||
group.setType(details.type());
|
||||
group.setStartAge(details.startAge());
|
||||
group.setEndAge(details.endAge());
|
||||
group.replaceStyles(details.styles());
|
||||
return group;
|
||||
}
|
||||
|
||||
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)));
|
||||
public synchronized void deleteGroup(final long schoolId, final long groupId) {
|
||||
final DanceGroup group = group(schoolId, groupId);
|
||||
school(schoolId)
|
||||
.getShows()
|
||||
.forEach((final Show show) ->
|
||||
show.getDayParts().forEach((final DayPart part) -> part.removeDanceGroup(group)));
|
||||
school(schoolId).getShows().forEach(this::touch);
|
||||
school(schoolId).removeDanceGroup(group);
|
||||
groups.delete(groupId);
|
||||
}
|
||||
|
||||
public synchronized void addGroupDancer(long schoolId, long groupId, long dancerId) {
|
||||
DanceGroup group = group(schoolId, groupId);
|
||||
public synchronized void addGroupDancer(final long schoolId, final long groupId, final long dancerId) {
|
||||
final DanceGroup group = group(schoolId, groupId);
|
||||
group.addDancer(dancer(schoolId, dancerId));
|
||||
school(schoolId).getShows().forEach(this::touch);
|
||||
}
|
||||
|
||||
public synchronized void removeGroupDancer(long schoolId, long groupId, long dancerId) {
|
||||
DanceGroup group = group(schoolId, groupId);
|
||||
public synchronized void removeGroupDancer(final long schoolId, final long groupId, final long dancerId) {
|
||||
final DanceGroup group = group(schoolId, groupId);
|
||||
group.removeDancer(dancer(schoolId, dancerId));
|
||||
school(schoolId).getShows().forEach(this::touch);
|
||||
}
|
||||
|
||||
public synchronized List<Show> shows(long schoolId) {
|
||||
public synchronized List<Show> shows(final long schoolId) {
|
||||
return List.copyOf(school(schoolId).getShows());
|
||||
}
|
||||
|
||||
public synchronized Show show(long schoolId, long showId) {
|
||||
public synchronized Show show(final long schoolId, final long showId) {
|
||||
return member(school(schoolId).getShows(), showId, "Show");
|
||||
}
|
||||
|
||||
public synchronized Show createShow(long schoolId, String name, List<DayPart> parts, Map<DanceGroupType, Integer> counts) {
|
||||
DanceSchool school = school(schoolId);
|
||||
if (counts == null) counts = ShowConfiguration.defaults(parts.size());
|
||||
public synchronized Show createShow(
|
||||
final long schoolId, final String name, final List<DayPart> parts, Map<DanceGroupType, Integer> counts) {
|
||||
final DanceSchool school = school(schoolId);
|
||||
if (counts == null) {
|
||||
counts = ShowConfiguration.defaults(parts.size());
|
||||
}
|
||||
ShowConfiguration.validate(parts, counts);
|
||||
Show show = shows.add(Show.builder().name(name).dayParts(List.copyOf(parts)).appearances(Map.copyOf(counts)).build());
|
||||
final Show show = shows.add(Show.builder()
|
||||
.name(name)
|
||||
.dayParts(List.copyOf(parts))
|
||||
.preferredDayParts((parts.size() == 1 ? 1 : 2))
|
||||
.appearances(Map.copyOf(counts))
|
||||
.build());
|
||||
school.addShow(show);
|
||||
return show;
|
||||
}
|
||||
|
||||
public synchronized Show updateShow(long schoolId, long showId, String name, List<DayPart> parts, Map<DanceGroupType, Integer> counts) {
|
||||
Show show = show(schoolId, showId);
|
||||
if (counts == null) counts = show.getAppearances().isEmpty() ? ShowConfiguration.defaults(parts.size()) : show.getAppearances();
|
||||
public synchronized Show updateShow(
|
||||
final long schoolId,
|
||||
final long showId,
|
||||
final String name,
|
||||
final List<DayPart> parts,
|
||||
Map<DanceGroupType, Integer> counts) {
|
||||
final Show show = show(schoolId, showId);
|
||||
if (counts == null) {
|
||||
counts = show.getAppearances().isEmpty() ? ShowConfiguration.defaults(parts.size()) : show.getAppearances();
|
||||
}
|
||||
ShowConfiguration.validate(parts, counts);
|
||||
boolean changed = !counts.equals(show.getAppearances()) || !partKeys(parts).equals(partKeys(show.getDayParts()));
|
||||
List<DayPart> retained = parts.stream().map(p -> DayPart.builder().date(p.getDate()).type(p.getType())
|
||||
.danceGroups(show.getDayParts().stream().filter(old -> old.getDate().equals(p.getDate()) && old.getType() == p.getType())
|
||||
.findFirst().map(DayPart::getDanceGroups).orElse(List.of())).build()).toList();
|
||||
final boolean changed =
|
||||
!counts.equals(show.getAppearances()) || !partKeys(parts).equals(partKeys(show.getDayParts()));
|
||||
final List<DayPart> retained = ShowConfiguration.retainAssignments(show, parts);
|
||||
show.setName(name);
|
||||
show.setDayParts(retained);
|
||||
show.setAppearances(Map.copyOf(counts));
|
||||
if (changed) touch(show);
|
||||
if (changed) {
|
||||
touch(show);
|
||||
}
|
||||
return show;
|
||||
}
|
||||
|
||||
private static List<String> partKeys(List<DayPart> parts) {
|
||||
return parts.stream().map(p -> p.getDate() + ":" + p.getType()).toList();
|
||||
private static List<String> partKeys(final List<DayPart> parts) {
|
||||
return parts.stream()
|
||||
.map((final DayPart p) -> p.getDate() + ":" + p.getType())
|
||||
.toList();
|
||||
}
|
||||
|
||||
public synchronized void deleteShow(long schoolId, long showId) {
|
||||
Show show = show(schoolId, showId);
|
||||
public synchronized void deleteShow(final long schoolId, final long showId) {
|
||||
final Show show = show(schoolId, showId);
|
||||
school(schoolId).removeShow(show);
|
||||
shows.delete(showId);
|
||||
}
|
||||
|
||||
|
||||
public synchronized SchoolSnapshot exportSchool(long id) {
|
||||
public synchronized SchoolSnapshot exportSchool(final long id) {
|
||||
return SchoolSnapshot.of(school(id));
|
||||
}
|
||||
|
||||
public synchronized ImportPreview preview(SchoolSnapshot snapshot) {
|
||||
public synchronized ImportPreview preview(final 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());
|
||||
final DanceSchool existing = schools.findAll().stream()
|
||||
.filter((final DanceSchool 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(),
|
||||
existing == null ? null : existing.getRevision());
|
||||
}
|
||||
|
||||
public synchronized DanceSchool importSchool(SchoolSnapshot snapshot, Long replaceId) {
|
||||
DanceSchool staged = snapshot.restore();
|
||||
ImportPreview preview = preview(snapshot);
|
||||
/** Validates and stages the complete graph before replacing repositories; file IDs become runtime IDs. */
|
||||
public synchronized DanceSchool importSchool(final SchoolSnapshot snapshot, final Long replaceId) {
|
||||
final DanceSchool staged = snapshot.restore();
|
||||
final long revision = replaceId == null ? 0 : school(replaceId).getRevision();
|
||||
final ImportPreview preview = preview(snapshot);
|
||||
if (!Objects.equals(preview.existingId(), replaceId)) {
|
||||
throw new ManagementException(INVALID_INPUT, "De bestaande school is gewijzigd. Bekijk de import opnieuw.");
|
||||
throw new ManagementException(
|
||||
ManagementExceptionKind.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 -> {
|
||||
final List<Dancer> importedDancers = new ArrayList<>(staged.getDancers());
|
||||
final List<DanceGroup> importedGroups = new ArrayList<>(staged.getDanceGroups());
|
||||
final List<Show> importedShows = new ArrayList<>(staged.getShows());
|
||||
final Map<DanceGroup, List<Dancer>> memberships = new IdentityHashMap<>();
|
||||
importedGroups.forEach((final DanceGroup g) -> memberships.put(g, new ArrayList<>(g.getDancers())));
|
||||
importedDancers.forEach((final Dancer d) -> d.setId(dancers.nextId()));
|
||||
importedGroups.forEach((final DanceGroup 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);
|
||||
final Map<DanceGroup, DanceGroup> replacements = new IdentityHashMap<>();
|
||||
final List<DanceGroup> rebuiltGroups = rebuildGroups(importedGroups, memberships, replacements);
|
||||
final List<Show> rebuiltShows = rebuildShows(importedShows, replacements);
|
||||
final DanceSchool imported = DanceSchool.builder()
|
||||
.revision(revision)
|
||||
.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);
|
||||
}
|
||||
List<Show> rebuiltShows = new ArrayList<>();
|
||||
for (Show show : importedShows) {
|
||||
Show rebuilt = Show.builder().name(show.getName()).appearances(show.getAppearances()).dayParts(show.getDayParts().stream()
|
||||
.map(p -> DayPart.builder().date(p.getDate()).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) {
|
||||
private static List<DanceGroup> rebuildGroups(
|
||||
final List<DanceGroup> importedGroups,
|
||||
final Map<DanceGroup, List<Dancer>> memberships,
|
||||
final Map<DanceGroup, DanceGroup> replacements) {
|
||||
final List<DanceGroup> rebuiltGroups = new ArrayList<>();
|
||||
for (final DanceGroup g : importedGroups) {
|
||||
final 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);
|
||||
}
|
||||
return rebuiltGroups;
|
||||
}
|
||||
|
||||
private List<Show> rebuildShows(final List<Show> importedShows, final Map<DanceGroup, DanceGroup> replacements) {
|
||||
final List<Show> rebuiltShows = new ArrayList<>();
|
||||
for (final Show show : importedShows) {
|
||||
final Show rebuilt = Show.builder()
|
||||
.name(show.getName())
|
||||
.appearances(show.getAppearances())
|
||||
.preferredDayParts(show.getPreferredDayParts())
|
||||
.dayParts(show.getDayParts().stream()
|
||||
.map((final DayPart p) -> DayPart.builder()
|
||||
.date(p.getDate())
|
||||
.type(p.getType())
|
||||
.danceGroups(p.getDanceGroups().stream()
|
||||
.map(replacements::get)
|
||||
.toList())
|
||||
.build())
|
||||
.toList())
|
||||
.build();
|
||||
rebuilt.setId(shows.nextId());
|
||||
rebuiltShows.add(rebuilt);
|
||||
}
|
||||
return rebuiltShows;
|
||||
}
|
||||
|
||||
private static void validateAgeRange(final int startAge, final int endAge) {
|
||||
if (startAge > endAge) {
|
||||
throw new ManagementException(INVALID_INPUT, "startAge must not exceed endAge");
|
||||
throw new ManagementException(ManagementExceptionKind.INVALID_INPUT, "startAge must not exceed endAge");
|
||||
}
|
||||
}
|
||||
|
||||
private static <T extends AbstractEntity> T member(Collection<T> entities, long id, String type) {
|
||||
return entities.stream().filter(entity -> entity.getId() == id).findFirst()
|
||||
.orElseThrow(() -> new ManagementException(NOT_FOUND, type + " " + id + " not found in this school"));
|
||||
private static <T extends AbstractEntity> T member(final Collection<T> entities, final long id, final String type) {
|
||||
return entities.stream()
|
||||
.filter((final T entity) -> entity.getId() == id)
|
||||
.findFirst()
|
||||
.orElseThrow(() -> new ManagementException(
|
||||
ManagementExceptionKind.NOT_FOUND, type + " " + id + " not found in this school"));
|
||||
}
|
||||
|
||||
private static <T> T required(T value, String type, long id) {
|
||||
private static <T> T required(final T value, final String type, final long id) {
|
||||
if (value == null) {
|
||||
throw new ManagementException(NOT_FOUND, type + " " + id + " not found");
|
||||
throw new ManagementException(ManagementExceptionKind.NOT_FOUND, type + " " + id + " not found");
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,118 @@
|
||||
package nl.agilecc.agiledancers.service;
|
||||
|
||||
import jakarta.inject.Inject;
|
||||
import jakarta.inject.Singleton;
|
||||
import java.util.List;
|
||||
import java.util.concurrent.Callable;
|
||||
import nl.agilecc.agiledancers.model.DanceSchool;
|
||||
import nl.agilecc.agiledancers.repository.Database;
|
||||
import nl.agilecc.agiledancers.repository.RevisionConflictException;
|
||||
import nl.agilecc.agiledancers.repository.SchoolReader;
|
||||
import nl.agilecc.agiledancers.repository.SchoolWriter;
|
||||
import nl.agilecc.agiledancers.repository.StorageException;
|
||||
import nl.agilecc.agiledancers.service.model.SchoolChange;
|
||||
|
||||
/** Serializes short domain units of work; solver computation never holds this lock or a database connection. */
|
||||
@Singleton
|
||||
public class SchoolTransactions {
|
||||
private final SchoolManagementService schools;
|
||||
private final Database database;
|
||||
private final SchoolReader reader;
|
||||
private final SchoolWriter writer;
|
||||
|
||||
@Inject
|
||||
public SchoolTransactions(
|
||||
final SchoolManagementService schools,
|
||||
final Database database,
|
||||
final SchoolReader reader,
|
||||
final SchoolWriter writer) {
|
||||
this.schools = schools;
|
||||
this.database = database;
|
||||
this.reader = reader;
|
||||
this.writer = writer;
|
||||
}
|
||||
|
||||
/** An expected revision is mandatory for existing-school writes; null school identifies a new-school operation. */
|
||||
public Object request(final Long schoolId, final Long revision, final boolean mutation, final Callable<Object> work)
|
||||
throws Exception {
|
||||
return request(new SchoolChange(schoolId, revision, mutation, mutation), work);
|
||||
}
|
||||
|
||||
public Object request(final SchoolChange change, final Callable<Object> work) throws Exception {
|
||||
synchronized (schools) {
|
||||
try {
|
||||
return database.transaction(() -> {
|
||||
final List<DanceSchool> before = reader.load();
|
||||
schools.load(before, database::nextId);
|
||||
if (change.mutation() && change.schoolId() != null) {
|
||||
check(change);
|
||||
schools.school(change.schoolId())
|
||||
.setRevision(change.revision() + (change.changesInputs() ? 1 : 0));
|
||||
}
|
||||
final Object result = work.call();
|
||||
if (change.changesInputs()) {
|
||||
store(before, change.schoolId());
|
||||
}
|
||||
return result;
|
||||
});
|
||||
} catch (final RevisionConflictException failure) {
|
||||
throw new ManagementException(ManagementExceptionKind.CONFLICT, failure.getMessage());
|
||||
} catch (final StorageException failure) {
|
||||
throw new ManagementException(ManagementExceptionKind.STORAGE_UNAVAILABLE, failure.getMessage());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void check(final SchoolChange change) {
|
||||
final long schoolId = change.schoolId();
|
||||
final Long expected = change.revision();
|
||||
final long actual = schools.school(schoolId).getRevision();
|
||||
if (expected == null || actual != expected) {
|
||||
throw new ManagementException(
|
||||
ManagementExceptionKind.CONFLICT,
|
||||
"Deze school is gewijzigd of de schoolversie ontbreekt. Herlaad de gegevens en probeer opnieuw.");
|
||||
}
|
||||
writer.checkRevision(schoolId, expected);
|
||||
}
|
||||
|
||||
private void store(final List<DanceSchool> before, final Long schoolId) {
|
||||
final List<DanceSchool> after = schools.schools();
|
||||
if (schoolId != null) {
|
||||
final DanceSchool retained = after.stream()
|
||||
.filter((final DanceSchool school) -> school.getId().equals(schoolId))
|
||||
.findFirst()
|
||||
.orElse(null);
|
||||
if (retained == null) {
|
||||
writer.delete(schoolId);
|
||||
} else {
|
||||
writer.save(retained);
|
||||
}
|
||||
}
|
||||
final List<Long> known = before.stream().map(DanceSchool::getId).toList();
|
||||
after.stream()
|
||||
.filter((final DanceSchool school) -> !known.contains(school.getId()))
|
||||
.forEach(writer::save);
|
||||
}
|
||||
|
||||
/** Refreshes current domain state and commits a worker's atomic result/status operation. */
|
||||
public <T> T background(final Callable<T> work) {
|
||||
synchronized (schools) {
|
||||
try {
|
||||
return database.transaction(() -> {
|
||||
schools.load(reader.load(), database::nextId);
|
||||
return work.call();
|
||||
});
|
||||
} catch (final RuntimeException failure) {
|
||||
throw failure;
|
||||
} catch (final Exception failure) {
|
||||
throw new IllegalStateException(failure);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public void saveResult(final long schoolId) {
|
||||
final DanceSchool school = schools.school(schoolId);
|
||||
school.setRevision(school.getRevision() + 1);
|
||||
writer.save(school);
|
||||
}
|
||||
}
|
||||
@@ -1,33 +1,83 @@
|
||||
package nl.agilecc.agiledancers.service;
|
||||
|
||||
import java.util.Arrays;
|
||||
import java.util.EnumMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import nl.agilecc.agiledancers.model.DayPart;
|
||||
import nl.agilecc.agiledancers.model.Show;
|
||||
import nl.agilecc.agiledancers.model.specifications.DanceGroupType;
|
||||
import java.util.*;
|
||||
|
||||
public final class ShowConfiguration {
|
||||
private ShowConfiguration() {}
|
||||
public static Map<DanceGroupType, Integer> defaults(int n) {
|
||||
Map<DanceGroupType, Integer> counts = new EnumMap<>(DanceGroupType.class);
|
||||
if (n > 0) for (var type : DanceGroupType.values()) counts.put(type, switch (type) {
|
||||
case PRE_SCHOOLER -> 1;
|
||||
case SHOW_CASE, PRE_EDUCATION -> n;
|
||||
default -> Math.min(2, n);
|
||||
});
|
||||
|
||||
public static List<DayPart> retainAssignments(final Show show, final List<DayPart> parts) {
|
||||
return parts.stream()
|
||||
.map((final DayPart p) -> DayPart.builder()
|
||||
.date(p.getDate())
|
||||
.type(p.getType())
|
||||
.danceGroups(show.getDayParts().stream()
|
||||
.filter((final DayPart old) ->
|
||||
old.getDate().equals(p.getDate()) && old.getType() == p.getType())
|
||||
.findFirst()
|
||||
.map(DayPart::getDanceGroups)
|
||||
.orElse(List.of()))
|
||||
.build())
|
||||
.toList();
|
||||
}
|
||||
|
||||
public static Map<DanceGroupType, Integer> defaults(final int n) {
|
||||
final Map<DanceGroupType, Integer> counts = new EnumMap<>(DanceGroupType.class);
|
||||
if (n > 0) {
|
||||
for (final DanceGroupType type : DanceGroupType.values()) {
|
||||
counts.put(
|
||||
type,
|
||||
switch (type) {
|
||||
case PRE_SCHOOLER -> 1;
|
||||
case SHOW_CASE -> n;
|
||||
default -> Math.min(2, n);
|
||||
});
|
||||
}
|
||||
}
|
||||
return counts;
|
||||
}
|
||||
public static void validate(List<DayPart> parts, Map<DanceGroupType, Integer> counts) {
|
||||
if (parts == null || parts.size() > 6 || parts.stream().anyMatch(p -> p == null || p.getDate() == null || p.getType() == null)
|
||||
|| parts.stream().map(p -> p.getDate() + ":" + p.getType()).distinct().count() != parts.size())
|
||||
throw new ManagementException(ManagementExceptionKind.INVALID_INPUT, "Configure zero to six dayparts with unique date/type combinations.");
|
||||
if (counts == null) invalidCounts();
|
||||
if (parts.isEmpty() && counts.isEmpty()) return;
|
||||
|
||||
public static void validate(final List<DayPart> parts, final Map<DanceGroupType, Integer> counts) {
|
||||
validateParts(parts);
|
||||
if (counts == null) {
|
||||
invalidCounts();
|
||||
}
|
||||
if (parts.isEmpty() && counts.isEmpty()) {
|
||||
return;
|
||||
}
|
||||
if (counts.size() != DanceGroupType.values().length
|
||||
|| Arrays.stream(DanceGroupType.values()).anyMatch(t -> counts.get(t) == null || counts.get(t) < 1
|
||||
|| (!parts.isEmpty() && counts.get(t) > parts.size()))) invalidCounts();
|
||||
|| Arrays.stream(DanceGroupType.values())
|
||||
.anyMatch((final DanceGroupType t) -> counts.get(t) == null
|
||||
|| counts.get(t) < 1
|
||||
|| (!parts.isEmpty() && counts.get(t) > parts.size()))) {
|
||||
invalidCounts();
|
||||
}
|
||||
}
|
||||
|
||||
public static void validateParts(final List<DayPart> parts) {
|
||||
if (parts == null
|
||||
|| parts.size() > 6
|
||||
|| parts.stream().anyMatch((final DayPart p) -> p == null || p.getDate() == null || p.getType() == null)
|
||||
|| parts.stream()
|
||||
.map((final DayPart p) -> p.getDate() + ":" + p.getType())
|
||||
.distinct()
|
||||
.count()
|
||||
!= parts.size()) {
|
||||
throw new ManagementException(
|
||||
ManagementExceptionKind.INVALID_INPUT,
|
||||
"Configure zero to six dayparts with unique date/type combinations.");
|
||||
}
|
||||
}
|
||||
|
||||
private static void invalidCounts() {
|
||||
throw new ManagementException(ManagementExceptionKind.INVALID_INPUT,
|
||||
"Every type needs an integer appearance count between 1 and the number of dayparts. Saved counts are retained when dayparts are removed.");
|
||||
throw new ManagementException(
|
||||
ManagementExceptionKind.INVALID_INPUT,
|
||||
"Every type needs an integer appearance count between 1 and the number of dayparts. Saved counts are"
|
||||
+ " retained when dayparts are removed.");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,125 @@
|
||||
package nl.agilecc.agiledancers.service;
|
||||
|
||||
import jakarta.inject.Inject;
|
||||
import jakarta.inject.Singleton;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import nl.agilecc.agiledancers.model.DanceGroup;
|
||||
import nl.agilecc.agiledancers.model.DayPart;
|
||||
import nl.agilecc.agiledancers.model.Show;
|
||||
import nl.agilecc.agiledancers.model.scheduling.ScheduleRules;
|
||||
import nl.agilecc.agiledancers.model.scheduling.SchedulingInput;
|
||||
|
||||
@Singleton
|
||||
public class ShowSchedules {
|
||||
@Inject
|
||||
private SchoolManagementService schools;
|
||||
|
||||
public ShowSchedules() {}
|
||||
|
||||
public ShowSchedules(final SchoolManagementService schools) {
|
||||
this.schools = schools;
|
||||
}
|
||||
|
||||
/** Retains assignments by date/type; removing dayparts may retain an invalid preferredDayParts in the draft. */
|
||||
public Show configure(final long schoolId, final Long showId, final Show settings) {
|
||||
synchronized (schools) {
|
||||
ShowConfiguration.validateParts(settings.getDayParts());
|
||||
final Show existing = showId == null ? null : schools.show(schoolId, showId);
|
||||
final int preferredDayParts = settings.getPreferredDayParts() == null
|
||||
? (existing == null
|
||||
? (settings.getDayParts().size() == 1 ? 1 : 2)
|
||||
: SchedulingInputs.preferred(existing))
|
||||
: settings.getPreferredDayParts();
|
||||
validatePreferredDayParts(
|
||||
preferredDayParts, existing, settings.getDayParts().size());
|
||||
final Show show = existing == null
|
||||
? schools.createShow(schoolId, settings.getName(), settings.getDayParts(), null)
|
||||
: existing;
|
||||
final boolean changed = preferredDayParts != SchedulingInputs.preferred(show)
|
||||
|| !keys(show.getDayParts()).equals(keys(settings.getDayParts()));
|
||||
show.setDayParts(ShowConfiguration.retainAssignments(show, settings.getDayParts()));
|
||||
show.setName(settings.getName());
|
||||
show.setPreferredDayParts(preferredDayParts);
|
||||
if (changed) {
|
||||
schools.touch(show);
|
||||
}
|
||||
return show;
|
||||
}
|
||||
}
|
||||
|
||||
private static List<String> keys(final List<DayPart> parts) {
|
||||
return parts.stream()
|
||||
.map((final DayPart p) -> p.getDate() + ":" + p.getType())
|
||||
.toList();
|
||||
}
|
||||
|
||||
private static void validatePreferredDayParts(final int preferredDayParts, final Show existing, final int parts) {
|
||||
if (preferredDayParts < 1
|
||||
|| preferredDayParts > 6
|
||||
|| (parts > 0
|
||||
&& preferredDayParts > parts
|
||||
&& (existing == null || preferredDayParts != SchedulingInputs.preferred(existing)))) {
|
||||
throw new ManagementException(
|
||||
ManagementExceptionKind.INVALID_INPUT,
|
||||
"preferredDayParts must be between 1 and the number of dayparts. Retained preferredDayParts must"
|
||||
+ " be corrected before scheduling.");
|
||||
}
|
||||
}
|
||||
|
||||
/** Saves even rule-breaking drafts, but rejects malformed lists and foreign group references atomically. */
|
||||
public Show saveDraft(final long schoolId, final long showId, final List<List<Long>> assignments) {
|
||||
synchronized (schools) {
|
||||
final Show show = schools.show(schoolId, showId);
|
||||
if (assignments == null || assignments.size() != show.getDayParts().size()) {
|
||||
throw new ManagementException(ManagementExceptionKind.INVALID_INPUT, "Provide every daypart.");
|
||||
}
|
||||
final List<DayPart> replacement = new ArrayList<>();
|
||||
for (int p = 0; p < assignments.size(); p++) {
|
||||
final DayPart old = show.getDayParts().get(p);
|
||||
final List<Long> ids = assignments.get(p);
|
||||
if (ids == null || ids.stream().anyMatch(java.util.Objects::isNull)) {
|
||||
throw new ManagementException(ManagementExceptionKind.INVALID_INPUT, "Invalid group IDs.");
|
||||
}
|
||||
final List<DanceGroup> groups = ids.stream()
|
||||
.map((final Long id) -> schools.group(schoolId, id))
|
||||
.toList();
|
||||
replacement.add(DayPart.builder()
|
||||
.date(old.getDate())
|
||||
.type(old.getType())
|
||||
.danceGroups(groups)
|
||||
.build());
|
||||
}
|
||||
show.setDayParts(List.copyOf(replacement));
|
||||
schools.touch(show);
|
||||
return show;
|
||||
}
|
||||
}
|
||||
|
||||
/** Marks a schedule final only after checking all hard rules against current school data. */
|
||||
public Show finalizeSchedule(final long schoolId, final long showId) {
|
||||
synchronized (schools) {
|
||||
final Show show = schools.show(schoolId, showId);
|
||||
final List<String> errors = violations(schoolId, showId);
|
||||
if (!errors.isEmpty()) {
|
||||
throw new ManagementException(ManagementExceptionKind.INVALID_INPUT, String.join(" ", errors));
|
||||
}
|
||||
show.setFinalized(true);
|
||||
return show;
|
||||
}
|
||||
}
|
||||
|
||||
public List<String> violations(final long schoolId, final long showId) {
|
||||
synchronized (schools) {
|
||||
final Show show = schools.show(schoolId, showId);
|
||||
final SchedulingInput input = SchedulingInputs.of(schools.school(schoolId), show, 0);
|
||||
return ScheduleRules.violations(
|
||||
input,
|
||||
show.getDayParts().stream()
|
||||
.map((final DayPart p) -> p.getDanceGroups().stream()
|
||||
.map(DanceGroup::getId)
|
||||
.toList())
|
||||
.toList());
|
||||
}
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user