From a166f6be4aab7ab6d8a54686ef21860c6fd6b7be Mon Sep 17 00:00:00 2001
From: uhensler <urs.hensler@frentix.com>
Date: Wed, 20 May 2020 08:40:42 +0200
Subject: [PATCH] OO-4630: Rebook participants of an appointment

---
 .../nodes/appointments/Appointment.java       |   4 +-
 .../nodes/appointments/AppointmentRef.java    |  36 +++
 .../appointments/AppointmentSearchParams.java |   6 +-
 .../appointments/AppointmentsService.java     |   6 +-
 .../nodes/appointments/Participation.java     |   4 +-
 .../nodes/appointments/ParticipationRef.java  |  36 +++
 .../appointments/ParticipationResult.java     |  17 +-
 .../ParticipationSearchParams.java            |  21 +-
 .../manager/AppointmentsMailing.java          |  39 +++
 .../manager/AppointmentsServiceImpl.java      |  58 +++-
 .../manager/ParticipationDAO.java             |   6 +
 .../model/ParticipationResultImpl.java        |  12 +-
 .../ui/AbstractRebookController.java          | 292 ++++++++++++++++++
 .../appointments/ui/AppointmentDataModel.java |   2 +
 .../ui/AppointmentDeleteController.java       | 104 +++++++
 .../nodes/appointments/ui/AppointmentRow.java |  13 +
 .../appointments/ui/RebookController.java     |  75 +++++
 .../appointments/ui/TopicCoachController.java |  64 +++-
 .../appointments/ui/TopicsRunController.java  |   6 +-
 .../ui/_content/appointment_row_coach.html    |   3 +
 .../ui/_i18n/LocalStrings_de.properties       |  18 +-
 .../ui/_i18n/LocalStrings_en.properties       |  18 +-
 .../manager/AppointmentDAOTest.java           |   2 +-
 .../manager/ParticipationDAOTest.java         |  56 +++-
 24 files changed, 834 insertions(+), 64 deletions(-)
 create mode 100644 src/main/java/org/olat/course/nodes/appointments/AppointmentRef.java
 create mode 100644 src/main/java/org/olat/course/nodes/appointments/ParticipationRef.java
 create mode 100644 src/main/java/org/olat/course/nodes/appointments/ui/AbstractRebookController.java
 create mode 100644 src/main/java/org/olat/course/nodes/appointments/ui/AppointmentDeleteController.java
 create mode 100644 src/main/java/org/olat/course/nodes/appointments/ui/RebookController.java

diff --git a/src/main/java/org/olat/course/nodes/appointments/Appointment.java b/src/main/java/org/olat/course/nodes/appointments/Appointment.java
index f08814488ae..f878dfd7ce6 100644
--- a/src/main/java/org/olat/course/nodes/appointments/Appointment.java
+++ b/src/main/java/org/olat/course/nodes/appointments/Appointment.java
@@ -30,15 +30,13 @@ import org.olat.core.id.ModifiedInfo;
  * @author uhensler, urs.hensler@frentix.com, http://www.frentix.com
  *
  */
-public interface Appointment extends ModifiedInfo, CreateInfo {
+public interface Appointment extends AppointmentRef, ModifiedInfo, CreateInfo {
 	
 	public enum Status {
 		planned,
 		confirmed
 	}
 	
-	public Long getKey();
-	
 	public Status getStatus();
 	
 	public Date getStatusModified();
diff --git a/src/main/java/org/olat/course/nodes/appointments/AppointmentRef.java b/src/main/java/org/olat/course/nodes/appointments/AppointmentRef.java
new file mode 100644
index 00000000000..61aa8becbfc
--- /dev/null
+++ b/src/main/java/org/olat/course/nodes/appointments/AppointmentRef.java
@@ -0,0 +1,36 @@
+/**
+ * <a href="http://www.openolat.org">
+ * OpenOLAT - Online Learning and Training</a><br>
+ * <p>
+ * Licensed under the Apache License, Version 2.0 (the "License"); <br>
+ * you may not use this file except in compliance with the License.<br>
+ * You may obtain a copy of the License at the
+ * <a href="http://www.apache.org/licenses/LICENSE-2.0">Apache homepage</a>
+ * <p>
+ * Unless required by applicable law or agreed to in writing,<br>
+ * software distributed under the License is distributed on an "AS IS" BASIS, <br>
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. <br>
+ * See the License for the specific language governing permissions and <br>
+ * limitations under the License.
+ * <p>
+ * Initial code contributed and copyrighted by<br>
+ * frentix GmbH, http://www.frentix.com
+ * <p>
+ */
+package org.olat.course.nodes.appointments;
+
+/**
+ * 
+ * Initial date: 19 May 2020<br>
+ * @author uhensler, urs.hensler@frentix.com, http://www.frentix.com
+ *
+ */
+public interface AppointmentRef {
+	
+	public Long getKey();
+	
+	public static AppointmentRef of(Long key) {
+		return () -> key;
+	}
+
+}
diff --git a/src/main/java/org/olat/course/nodes/appointments/AppointmentSearchParams.java b/src/main/java/org/olat/course/nodes/appointments/AppointmentSearchParams.java
index 1bc6e5e8868..4e317da4579 100644
--- a/src/main/java/org/olat/course/nodes/appointments/AppointmentSearchParams.java
+++ b/src/main/java/org/olat/course/nodes/appointments/AppointmentSearchParams.java
@@ -43,10 +43,10 @@ public class AppointmentSearchParams {
 		return appointmentKey;
 	}
 
-	public void setAppointmentKey(Long appointmentKey) {
-		this.appointmentKey = appointmentKey;
+	public void setAppointment(AppointmentRef appointment) {
+		this.appointmentKey = appointment.getKey();
 	}
-	
+
 	public Topic getTopic() {
 		return topic;
 	}
diff --git a/src/main/java/org/olat/course/nodes/appointments/AppointmentsService.java b/src/main/java/org/olat/course/nodes/appointments/AppointmentsService.java
index 6614bee0034..5c11d3ad827 100644
--- a/src/main/java/org/olat/course/nodes/appointments/AppointmentsService.java
+++ b/src/main/java/org/olat/course/nodes/appointments/AppointmentsService.java
@@ -68,7 +68,11 @@ public interface AppointmentsService {
 
 	public List<Appointment> getAppointments(AppointmentSearchParams params);
 
-	public ParticipationResult createParticipation(Appointment appointment, Identity identity, boolean autoConfirmation);
+	public ParticipationResult createParticipation(Appointment appointment, Identity identity,
+			boolean autoConfirmation);
+	
+	public ParticipationResult rebookParticipations(AppointmentRef toAppointmenRef,
+			Collection<? extends ParticipationRef> participationRefs, boolean autoConfirmation);
 
 	public void deleteParticipation(Participation participation);
 
diff --git a/src/main/java/org/olat/course/nodes/appointments/Participation.java b/src/main/java/org/olat/course/nodes/appointments/Participation.java
index ecfa4b78a8b..71e5315f6ec 100644
--- a/src/main/java/org/olat/course/nodes/appointments/Participation.java
+++ b/src/main/java/org/olat/course/nodes/appointments/Participation.java
@@ -29,9 +29,7 @@ import org.olat.core.id.ModifiedInfo;
  * @author uhensler, urs.hensler@frentix.com, http://www.frentix.com
  *
  */
-public interface Participation extends ModifiedInfo, CreateInfo {
-	
-	public Long getKey();
+public interface Participation extends ParticipationRef, ModifiedInfo, CreateInfo {
 	
 	public Appointment getAppointment();
 	
diff --git a/src/main/java/org/olat/course/nodes/appointments/ParticipationRef.java b/src/main/java/org/olat/course/nodes/appointments/ParticipationRef.java
new file mode 100644
index 00000000000..393d79a030a
--- /dev/null
+++ b/src/main/java/org/olat/course/nodes/appointments/ParticipationRef.java
@@ -0,0 +1,36 @@
+/**
+ * <a href="http://www.openolat.org">
+ * OpenOLAT - Online Learning and Training</a><br>
+ * <p>
+ * Licensed under the Apache License, Version 2.0 (the "License"); <br>
+ * you may not use this file except in compliance with the License.<br>
+ * You may obtain a copy of the License at the
+ * <a href="http://www.apache.org/licenses/LICENSE-2.0">Apache homepage</a>
+ * <p>
+ * Unless required by applicable law or agreed to in writing,<br>
+ * software distributed under the License is distributed on an "AS IS" BASIS, <br>
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. <br>
+ * See the License for the specific language governing permissions and <br>
+ * limitations under the License.
+ * <p>
+ * Initial code contributed and copyrighted by<br>
+ * frentix GmbH, http://www.frentix.com
+ * <p>
+ */
+package org.olat.course.nodes.appointments;
+
+/**
+ * 
+ * Initial date: 19 May 2020<br>
+ * @author uhensler, urs.hensler@frentix.com, http://www.frentix.com
+ *
+ */
+public interface ParticipationRef {
+	
+	public Long getKey();
+	
+	public static ParticipationRef of(Long key) {
+		return () -> key;
+	}
+
+}
diff --git a/src/main/java/org/olat/course/nodes/appointments/ParticipationResult.java b/src/main/java/org/olat/course/nodes/appointments/ParticipationResult.java
index e70fe55beea..0f52c907c62 100644
--- a/src/main/java/org/olat/course/nodes/appointments/ParticipationResult.java
+++ b/src/main/java/org/olat/course/nodes/appointments/ParticipationResult.java
@@ -19,6 +19,9 @@
  */
 package org.olat.course.nodes.appointments;
 
+import java.util.Collection;
+import java.util.Collections;
+
 import org.olat.course.nodes.appointments.model.ParticipationResultImpl;
 
 /**
@@ -32,24 +35,30 @@ public interface ParticipationResult {
 	static final ParticipationResult APPOINTMENT_DELETED = new ParticipationResultImpl(Status.appointmentDeleted);
 	static final ParticipationResult APPOINTMENT_FULL = new ParticipationResultImpl(Status.appointmentFull);
 	static final ParticipationResult APPOINTMENT_CONFIRMED = new ParticipationResultImpl(Status.appointmentConfirmed);
+	static final ParticipationResult NO_PARTICIPATIONS = new ParticipationResultImpl(Status.noParticipations);
 	
 	static ParticipationResult of(Participation participation) {
-		return new ParticipationResultImpl(Status.ok, participation);
+		return of(Collections.singletonList(participation));
+	}
+	
+	static ParticipationResult of(Collection<Participation> participations) {
+		return new ParticipationResultImpl(Status.ok, participations);
 	}
 	
 	enum Status {
 		ok,
 		appointmentDeleted,
 		appointmentFull,
-		appointmentConfirmed
+		appointmentConfirmed,
+		noParticipations
 	}
 	
 	public Status getStatus();
 	
 	/**
 	 *
-	 * @return the participation if status == ok.
+	 * @return the participations if status == ok.
 	 */
-	public Participation getParticipation();
+	public Collection<Participation> getParticipations();
 
 }
diff --git a/src/main/java/org/olat/course/nodes/appointments/ParticipationSearchParams.java b/src/main/java/org/olat/course/nodes/appointments/ParticipationSearchParams.java
index 74c6fc781c3..e7c8b8556bd 100644
--- a/src/main/java/org/olat/course/nodes/appointments/ParticipationSearchParams.java
+++ b/src/main/java/org/olat/course/nodes/appointments/ParticipationSearchParams.java
@@ -39,6 +39,7 @@ public class ParticipationSearchParams {
 	private String subIdent;
 	private Identity identity;
 	private Date createdAfter;
+	private Collection<Long> participationKeys;
 	private Collection<Long> appointmentKeys;
 	private Date startAfter;
 	private Appointment.Status status;
@@ -89,20 +90,30 @@ public class ParticipationSearchParams {
 		this.createdAfter = createdAfter;
 	}
 
-	public Collection<Long> getAppointmentKeys() {
-		return appointmentKeys;
+	public Collection<Long> getParticipationKeys() {
+		return participationKeys;
 	}
 	
-	public void setAppointments(Collection<Appointment> appointments) {
-		this.appointmentKeys = appointments.stream()
-				.map(Appointment::getKey)
+	public void setParticipations(Collection<? extends ParticipationRef> participations) {
+		this.participationKeys = participations.stream()
+				.map(ParticipationRef::getKey)
 				.collect(Collectors.toSet());
 	}
 
+	public Collection<Long> getAppointmentKeys() {
+		return appointmentKeys;
+	}
+	
 	public void setAppointmentKeys(Collection<Long> appointmentKeys) {
 		this.appointmentKeys = appointmentKeys;
 	}
 
+	public void setAppointments(Collection<? extends AppointmentRef> appointments) {
+		this.appointmentKeys = appointments.stream()
+				.map(AppointmentRef::getKey)
+				.collect(Collectors.toSet());
+	}
+
 	public Date getStartAfter() {
 		return startAfter;
 	}
diff --git a/src/main/java/org/olat/course/nodes/appointments/manager/AppointmentsMailing.java b/src/main/java/org/olat/course/nodes/appointments/manager/AppointmentsMailing.java
index c0153c6cee9..e527f8bf9d1 100644
--- a/src/main/java/org/olat/course/nodes/appointments/manager/AppointmentsMailing.java
+++ b/src/main/java/org/olat/course/nodes/appointments/manager/AppointmentsMailing.java
@@ -161,6 +161,45 @@ public class AppointmentsMailing {
 		}
 	}
 	
+	void sendRebook(Appointment toAppointment, List<Participation> fromParticipations) {
+		if (toAppointment == null || fromParticipations == null || fromParticipations.isEmpty()) return;
+		
+		ParticipationSearchParams participationParams = new ParticipationSearchParams();
+		participationParams.setParticipations(fromParticipations);
+		participationParams.setFetchAppointments(true);
+		participationParams.setFetchTopics(true);
+		participationParams.setFetchIdentities(true);
+		participationParams.setFetchUser(true);
+		participationDao.loadParticipations(participationParams).stream()
+				.forEach(participation -> sendRebook(toAppointment, participation));
+	}
+	
+	void sendRebook(Appointment toAppointment, Participation fromParticipation) {
+		Identity identity = fromParticipation.getIdentity();
+		Locale locale = I18nManager.getInstance().getLocaleOrDefault(identity.getUser().getPreferences().getLanguage());
+		Translator translator = Util.createPackageTranslator(AppointmentsRunController.class, locale);
+				
+		String subject = translator.translate("mail.rebooked.subject");
+		String body = translator.translate("mail.rebooked.body", new String[] {
+				userManager.getUserDisplayName(identity.getKey()),
+				createFormatedAppointments(singletonList(fromParticipation.getAppointment()), translator),
+				createFormatedAppointments(singletonList(toAppointment), translator)
+		});
+		
+		MailerResult result = new MailerResult();
+		MailBundle bundle = new MailBundle();
+		bundle.setToId(identity);
+		bundle.setContent(subject, body);
+		bundle.setContext(getMailContext(toAppointment.getTopic()));
+		
+		result = mailManager.sendMessage(bundle);
+		if (!result.isSuccessful()) {
+			log.warn(MessageFormat.format("Sending rebook appointment [from key={0}, to key={1}] to {2} failed: {3}",
+					fromParticipation.getAppointment().getKey(), toAppointment.getKey(),
+					fromParticipation.getIdentity(), result.getErrorMessage()));
+		}
+	}
+	
 	private String createFormatedAppointments(List<Appointment> appointments, Translator translator) {
 		StringBuilder sb = new StringBuilder();
 		for (int i = 0; i < appointments.size(); i++) {
diff --git a/src/main/java/org/olat/course/nodes/appointments/manager/AppointmentsServiceImpl.java b/src/main/java/org/olat/course/nodes/appointments/manager/AppointmentsServiceImpl.java
index 0df5e249292..a00d5e65880 100644
--- a/src/main/java/org/olat/course/nodes/appointments/manager/AppointmentsServiceImpl.java
+++ b/src/main/java/org/olat/course/nodes/appointments/manager/AppointmentsServiceImpl.java
@@ -21,6 +21,7 @@ package org.olat.course.nodes.appointments.manager;
 
 import static java.util.Collections.singletonList;
 
+import java.util.ArrayList;
 import java.util.Collection;
 import java.util.Date;
 import java.util.List;
@@ -36,10 +37,12 @@ import org.olat.course.CourseFactory;
 import org.olat.course.ICourse;
 import org.olat.course.nodes.appointments.Appointment;
 import org.olat.course.nodes.appointments.Appointment.Status;
+import org.olat.course.nodes.appointments.AppointmentRef;
 import org.olat.course.nodes.appointments.AppointmentSearchParams;
 import org.olat.course.nodes.appointments.AppointmentsService;
 import org.olat.course.nodes.appointments.Organizer;
 import org.olat.course.nodes.appointments.Participation;
+import org.olat.course.nodes.appointments.ParticipationRef;
 import org.olat.course.nodes.appointments.ParticipationResult;
 import org.olat.course.nodes.appointments.ParticipationSearchParams;
 import org.olat.course.nodes.appointments.Topic;
@@ -195,7 +198,7 @@ public class AppointmentsServiceImpl implements AppointmentsService {
 	@Override
 	public void confirmAppointment(Appointment appointment) {
 		AppointmentSearchParams appointmentParams = new AppointmentSearchParams();
-		appointmentParams.setAppointmentKey(appointment.getKey());
+		appointmentParams.setAppointment(appointment);
 		appointmentParams.setFetchTopic(true);
 		List<Appointment> appointments = appointmentDao.loadAppointments(appointmentParams);
 		if (!appointments.isEmpty()) {
@@ -228,7 +231,7 @@ public class AppointmentsServiceImpl implements AppointmentsService {
 	@Override
 	public void unconfirmAppointment(Appointment appointment) {
 		AppointmentSearchParams appointmentParams = new AppointmentSearchParams();
-		appointmentParams.setAppointmentKey(appointment.getKey());
+		appointmentParams.setAppointment(appointment);
 		appointmentParams.setFetchTopic(true);
 		List<Appointment> appointments = appointmentDao.loadAppointments(appointmentParams);
 		if (!appointments.isEmpty()) {
@@ -257,7 +260,7 @@ public class AppointmentsServiceImpl implements AppointmentsService {
 	public ParticipationResult createParticipation(Appointment appointment, Identity identity,
 			boolean autoConfirmation) {
 		AppointmentSearchParams appointmentParams = new AppointmentSearchParams();
-		appointmentParams.setAppointmentKey(appointment.getKey());
+		appointmentParams.setAppointment(appointment);
 		appointmentParams.setFetchTopic(true);
 		List<Appointment> appointments = appointmentDao.loadAppointments(appointmentParams);
 		if (appointments.isEmpty()) {
@@ -289,6 +292,55 @@ public class AppointmentsServiceImpl implements AppointmentsService {
 		return ParticipationResult.of(participation);
 	}
 
+	@Override
+	public ParticipationResult rebookParticipations(AppointmentRef toAppointmentRef,
+			Collection<? extends ParticipationRef> participationRefs, boolean autoConfirmation) {
+		AppointmentSearchParams aParams = new AppointmentSearchParams();
+		aParams.setAppointment(toAppointmentRef);
+		aParams.setFetchTopic(true);
+		List<Appointment> appointments = appointmentDao.loadAppointments(aParams);
+		if (appointments.isEmpty()) {
+			return ParticipationResult.APPOINTMENT_DELETED;
+		}
+		Appointment toAppointment = appointments.get(0);
+		
+		if (toAppointment.getMaxParticipations() != null) {
+			ParticipationSearchParams participationParams = new ParticipationSearchParams();
+			participationParams.setAppointments(singletonList(toAppointment));
+			Long count = participationDao.loadParticipationCount(participationParams);
+			if ((count.longValue() + participationRefs.size()) > toAppointment.getMaxParticipations().intValue()) {
+				return ParticipationResult.APPOINTMENT_FULL;
+			}
+		}
+		
+		ParticipationSearchParams pParams = new ParticipationSearchParams();
+		pParams.setParticipations(participationRefs);
+		pParams.setFetchIdentities(true);
+		List<Participation> fromParticipations = participationDao.loadParticipations(pParams);
+		if (fromParticipations.isEmpty()) {
+			return ParticipationResult.NO_PARTICIPATIONS;
+		}
+		
+		List<Participation> participations = new ArrayList<>(fromParticipations.size());
+		for (Participation fromParticipation : fromParticipations) {
+			Identity identity = fromParticipation.getIdentity();
+			Participation participation = participationDao.createParticipation(toAppointment, identity);
+			participations.add(participation);
+			calendarSyncher.syncCalendar(toAppointment, identity);
+		}
+		markNews(toAppointment.getTopic());
+		appointmentsMailing.sendRebook(toAppointment, fromParticipations);
+
+		if (autoConfirmation) {
+			confirmReloadedAppointment(toAppointment, false);
+		}
+		
+		// Delete after send email to have the from participations informations in the email
+		fromParticipations.forEach(this::deleteParticipation);
+		
+		return ParticipationResult.of(participations);
+	}
+
 	@Override
 	public void deleteParticipation(Participation participation) {
 		calendarSyncher.unsyncCalendar(participation.getAppointment(), participation.getIdentity());
diff --git a/src/main/java/org/olat/course/nodes/appointments/manager/ParticipationDAO.java b/src/main/java/org/olat/course/nodes/appointments/manager/ParticipationDAO.java
index 34379cd0c8b..9d4b83c8e30 100644
--- a/src/main/java/org/olat/course/nodes/appointments/manager/ParticipationDAO.java
+++ b/src/main/java/org/olat/course/nodes/appointments/manager/ParticipationDAO.java
@@ -191,6 +191,9 @@ class ParticipationDAO {
 		if (params.getCreatedAfter() != null) {
 			sb.and().append("participation.creationDate >= :createdAfter");
 		}
+		if (params.getParticipationKeys() != null && !params.getParticipationKeys().isEmpty()) {
+			sb.and().append("participation.key in (:participationKeys)");
+		}
 		if (params.getAppointmentKeys() != null && !params.getAppointmentKeys().isEmpty()) {
 			sb.and().append("participation.appointment.key in (:appointmentKeys)");
 		}
@@ -224,6 +227,9 @@ class ParticipationDAO {
 		if (params.getCreatedAfter() != null) {
 			query.setParameter("createdAfter", params.getCreatedAfter());
 		}
+		if (params.getParticipationKeys() != null && !params.getParticipationKeys().isEmpty()) {
+			query.setParameter("participationKeys", params.getParticipationKeys());
+		}
 		if (params.getAppointmentKeys() != null && !params.getAppointmentKeys().isEmpty()) {
 			query.setParameter("appointmentKeys", params.getAppointmentKeys());
 		}
diff --git a/src/main/java/org/olat/course/nodes/appointments/model/ParticipationResultImpl.java b/src/main/java/org/olat/course/nodes/appointments/model/ParticipationResultImpl.java
index ad79504906f..1e351b5f40c 100644
--- a/src/main/java/org/olat/course/nodes/appointments/model/ParticipationResultImpl.java
+++ b/src/main/java/org/olat/course/nodes/appointments/model/ParticipationResultImpl.java
@@ -19,6 +19,8 @@
  */
 package org.olat.course.nodes.appointments.model;
 
+import java.util.Collection;
+
 import org.olat.course.nodes.appointments.Participation;
 import org.olat.course.nodes.appointments.ParticipationResult;
 
@@ -31,15 +33,15 @@ import org.olat.course.nodes.appointments.ParticipationResult;
 public class ParticipationResultImpl implements ParticipationResult {
 
 	private final Status status;
-	private final Participation participation;
+	private final Collection<Participation> participations;
 	
 	public ParticipationResultImpl(Status status) {
 		this(status, null);
 	}
 	
-	public ParticipationResultImpl(Status status, Participation participation) {
+	public ParticipationResultImpl(Status status, Collection<Participation> participations) {
 		this.status = status;
-		this.participation = participation;
+		this.participations = participations;
 	}
 
 	@Override
@@ -48,8 +50,8 @@ public class ParticipationResultImpl implements ParticipationResult {
 	}
 
 	@Override
-	public Participation getParticipation() {
-		return participation;
+	public Collection<Participation> getParticipations() {
+		return participations;
 	}
 
 }
diff --git a/src/main/java/org/olat/course/nodes/appointments/ui/AbstractRebookController.java b/src/main/java/org/olat/course/nodes/appointments/ui/AbstractRebookController.java
new file mode 100644
index 00000000000..4253f773606
--- /dev/null
+++ b/src/main/java/org/olat/course/nodes/appointments/ui/AbstractRebookController.java
@@ -0,0 +1,292 @@
+/**
+ * <a href="http://www.openolat.org">
+ * OpenOLAT - Online Learning and Training</a><br>
+ * <p>
+ * Licensed under the Apache License, Version 2.0 (the "License"); <br>
+ * you may not use this file except in compliance with the License.<br>
+ * You may obtain a copy of the License at the
+ * <a href="http://www.apache.org/licenses/LICENSE-2.0">Apache homepage</a>
+ * <p>
+ * Unless required by applicable law or agreed to in writing,<br>
+ * software distributed under the License is distributed on an "AS IS" BASIS, <br>
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. <br>
+ * See the License for the specific language governing permissions and <br>
+ * limitations under the License.
+ * <p>
+ * Initial code contributed and copyrighted by<br>
+ * frentix GmbH, http://www.frentix.com
+ * <p>
+ */
+package org.olat.course.nodes.appointments.ui;
+
+import static java.util.Collections.emptyList;
+import static org.olat.core.gui.components.util.KeyValues.entry;
+
+import java.text.DateFormat;
+import java.util.Arrays;
+import java.util.Collection;
+import java.util.Collections;
+import java.util.List;
+import java.util.Map;
+import java.util.stream.Collectors;
+
+import org.olat.core.gui.UserRequest;
+import org.olat.core.gui.components.form.flexible.FormItem;
+import org.olat.core.gui.components.form.flexible.FormItemContainer;
+import org.olat.core.gui.components.form.flexible.elements.MultipleSelectionElement;
+import org.olat.core.gui.components.form.flexible.elements.SingleSelection;
+import org.olat.core.gui.components.form.flexible.elements.StaticTextElement;
+import org.olat.core.gui.components.form.flexible.impl.FormBasicController;
+import org.olat.core.gui.components.form.flexible.impl.FormEvent;
+import org.olat.core.gui.components.form.flexible.impl.FormLayoutContainer;
+import org.olat.core.gui.components.util.KeyValues;
+import org.olat.core.gui.control.Controller;
+import org.olat.core.gui.control.Event;
+import org.olat.core.gui.control.WindowControl;
+import org.olat.course.nodes.appointments.Appointment;
+import org.olat.course.nodes.appointments.AppointmentRef;
+import org.olat.course.nodes.appointments.AppointmentSearchParams;
+import org.olat.course.nodes.appointments.AppointmentsService;
+import org.olat.course.nodes.appointments.Participation;
+import org.olat.course.nodes.appointments.ParticipationRef;
+import org.olat.course.nodes.appointments.ParticipationSearchParams;
+import org.olat.user.UserManager;
+import org.springframework.beans.factory.annotation.Autowired;
+
+/**
+ * 
+ * Initial date: 18 May 2020<br>
+ * @author uhensler, urs.hensler@frentix.com, http://www.frentix.com
+ *
+ */
+public abstract class AbstractRebookController extends FormBasicController {
+	
+	private static final String[] EMPTY = new String[0];
+
+	private MultipleSelectionElement participationsEl;
+	private SingleSelection appointmentsEl;
+	private StaticTextElement noAppointmentsEl;
+	
+	private final DateFormat dateFormat;
+	private final Appointment currentAppointment;
+	private final List<Participation> participations;
+	private final Configuration config;
+	private String selectedAppointmentKey;
+	
+	@Autowired
+	private AppointmentsService appointmentsService;
+	@Autowired
+	private UserManager userManager;
+
+	public AbstractRebookController(UserRequest ureq, WindowControl wControl, Appointment appointment,
+			Configuration config) {
+		super(ureq, wControl);
+		this.currentAppointment = appointment;
+		this.config = config;
+		
+		dateFormat = DateFormat.getDateTimeInstance(DateFormat.SHORT, DateFormat.SHORT, getLocale());
+		
+		ParticipationSearchParams params = new ParticipationSearchParams();
+		params.setAppointments(Collections.singletonList(appointment));
+		participations = appointmentsService.getParticipations(params);
+		
+		initForm(ureq);
+	}
+	
+	abstract boolean isShowParticipations();
+	
+	abstract boolean isParticipationsReadOnly();
+	
+	abstract boolean isAllParticipationsSelected();
+	
+	abstract boolean isShowAppointments();
+	
+	abstract String getSubmitI18nKey();
+	
+	abstract void initFormTop(FormItemContainer formLayout, Controller listener, UserRequest ureq);
+	
+	abstract void onAfterRebooking();
+	
+	AppointmentsService getAppointmentsService() {
+		return appointmentsService;
+	}
+	
+	Appointment getCurrentAppointment() {
+		return currentAppointment;
+	}
+
+	int getNumParticipations() {
+		return participations.size();
+	}
+	
+	@Override
+	protected void initForm(FormItemContainer formLayout, Controller listener, UserRequest ureq) {
+		initFormTop(formLayout, listener, ureq);
+		
+		participationsEl = uifactory.addCheckboxesVertical("rebook.participation", formLayout,  EMPTY, EMPTY, 2);
+		participationsEl.addActionListener(FormEvent.ONCHANGE);
+				
+		appointmentsEl = uifactory.addRadiosVertical("rebook.appointments", "rebook.appointments", formLayout, EMPTY, EMPTY);
+		
+		noAppointmentsEl = uifactory.addStaticTextElement("rebook.no.appointments",
+				translate("rebook.no.appointments.text"), formLayout);
+		
+		FormLayoutContainer buttonCont = FormLayoutContainer.createButtonLayout("buttons", getTranslator());
+		buttonCont.setRootForm(mainForm);
+		formLayout.add(buttonCont);
+		uifactory.addFormSubmitButton(getSubmitI18nKey(), buttonCont);
+		uifactory.addFormCancelButton("cancel", buttonCont, ureq, getWindowControl());
+		
+		updateUI();
+	}
+	
+	void updateUI(){
+		if (participations.isEmpty()) {
+			participationsEl.setVisible(false);
+			appointmentsEl.setVisible(false);
+			noAppointmentsEl.setVisible(false);
+			return;
+		}
+		
+		if (isShowParticipations()) {
+			KeyValues participationsKV = new KeyValues();
+			
+			ParticipationSearchParams params = new ParticipationSearchParams();
+			params.setAppointments(Collections.singletonList(currentAppointment));
+			appointmentsService.getParticipations(params)
+					.forEach(participation -> participationsKV.add(entry(
+							participation.getKey().toString(),
+							userManager.getUserDisplayName(participation.getIdentity().getKey()))));
+			
+			participationsKV.sort(KeyValues.VALUE_ASC);
+			participationsEl.setKeysAndValues(participationsKV.keys(), participationsKV.values());
+			if (isAllParticipationsSelected()) {
+				participationsEl.selectAll();
+			}
+			
+			participationsEl.setVisible(true);
+		} else {
+			participationsEl.setVisible(false);
+		}
+		participationsEl.setEnabled(!isParticipationsReadOnly());
+		
+		updateAppointmentsUI();
+	}
+
+	private void updateAppointmentsUI() {
+		if (isShowAppointments()) {
+			selectedAppointmentKey = appointmentsEl.isOneSelected()
+					? appointmentsEl.getSelectedKey()
+					: selectedAppointmentKey;
+			int numParticipants = participationsEl.isAtLeastSelected(1)
+					? participationsEl.getSelectedKeys().size()
+					: 1;
+			
+			ParticipationSearchParams pParams = new ParticipationSearchParams();
+			pParams.setTopic(currentAppointment.getTopic());
+			List<Participation> participations = appointmentsService.getParticipations(pParams);
+			Map<Long, List<Participation>> appointmentKeyToParticipations = participations.stream()
+					.collect(Collectors.groupingBy(p -> p.getAppointment().getKey()));
+			
+			KeyValues freeKV = new KeyValues();
+			AppointmentSearchParams aParams = new AppointmentSearchParams();
+			aParams.setTopic(currentAppointment.getTopic());
+			appointmentsService.getAppointments(aParams).stream()
+					.filter(appointment -> hasFreeParticipations(appointment, appointmentKeyToParticipations, numParticipants))
+					.sorted((a1, a2) -> a1.getStart().compareTo(a2.getStart()))
+					.forEach(appointment -> freeKV.add(KeyValues.entry(appointment.getKey().toString(), formatDate(appointment))));
+			
+			if (freeKV.size() > 0) {
+				appointmentsEl.setKeysAndValues(freeKV.keys(), freeKV.values(), null);
+				if (Arrays.asList(appointmentsEl.getKeys()).contains(selectedAppointmentKey)) {
+					appointmentsEl.select(selectedAppointmentKey, true);
+				}
+				
+				appointmentsEl.setVisible(true);
+				noAppointmentsEl.setVisible(false);
+			} else {
+				appointmentsEl.setVisible(false);
+				noAppointmentsEl.setVisible(true);
+			}
+		} else {
+			appointmentsEl.setVisible(false);
+			noAppointmentsEl.setVisible(false);
+		}
+	}
+	
+	private boolean hasFreeParticipations(Appointment appointment,
+			Map<Long, List<Participation>> appointmentKeyToParticipation, int numParticipants) {
+		if (appointment.equals(currentAppointment)) return false;
+		if (appointment.getMaxParticipations() == null) return true;
+		
+		List<Participation> participations = appointmentKeyToParticipation.getOrDefault(appointment.getKey(), emptyList());
+		return appointment.getMaxParticipations().intValue() >= (participations.size() + numParticipants);
+	}
+
+	private String formatDate(Appointment appointment) {
+		return new StringBuilder()
+				.append(dateFormat.format(appointment.getStart()))
+				.append(" - ")
+				.append(dateFormat.format(appointment.getEnd()))
+				.toString();
+	}
+	
+	@Override
+	protected void formInnerEvent(UserRequest ureq, FormItem source, FormEvent event) {
+		if (source == participationsEl) {
+			updateAppointmentsUI();
+		}
+		super.formInnerEvent(ureq, source, event);
+	}
+
+	@Override
+	protected boolean validateFormLogic(UserRequest ureq) {
+		boolean allOk = super.validateFormLogic(ureq);
+		
+		participationsEl.clearError();
+		if (participationsEl.isVisible()) {
+			if (!participationsEl.isAtLeastSelected(1)) {
+				participationsEl.setErrorKey("error.select.participant", null);
+				allOk &= false;
+			}
+		}
+		
+		appointmentsEl.clearError();
+		if (appointmentsEl.isVisible()) {
+			if (!appointmentsEl.isOneSelected()) {
+				appointmentsEl.setErrorKey("error.select.appointment", null);
+				allOk &= false;
+			}
+		}
+		
+		return allOk;
+	}
+
+	@Override
+	protected void formCancelled(UserRequest ureq) {
+		fireEvent(ureq, Event.CANCELLED_EVENT);
+	}
+
+	@Override
+	protected void formOK(UserRequest ureq) {
+		if (appointmentsEl.isVisible()) {
+			Collection<ParticipationRef> participationRefs = participationsEl.getSelectedKeys().stream()
+					.map(Long::valueOf)
+					.map(ParticipationRef::of)
+					.collect(Collectors.toList());
+			Long appointmentKey = Long.valueOf(appointmentsEl.getSelectedKey());
+			appointmentsService.rebookParticipations(AppointmentRef.of(appointmentKey), participationRefs,
+					!config.isConfirmation());
+		}
+		
+		onAfterRebooking();
+		
+		fireEvent(ureq, Event.DONE_EVENT);
+	}
+
+	@Override
+	protected void doDispose() {
+		//
+	}
+
+}
diff --git a/src/main/java/org/olat/course/nodes/appointments/ui/AppointmentDataModel.java b/src/main/java/org/olat/course/nodes/appointments/ui/AppointmentDataModel.java
index c26c392b984..df54523e01c 100644
--- a/src/main/java/org/olat/course/nodes/appointments/ui/AppointmentDataModel.java
+++ b/src/main/java/org/olat/course/nodes/appointments/ui/AppointmentDataModel.java
@@ -69,6 +69,7 @@ implements SortableFlexiTableDataModel<AppointmentRow> {
 			case maxParticipations: return row.getAppointment().getMaxParticipations();
 			case freeParticipations: return row.getFreeParticipations();
 			case participants: return row.getParticipantsWrapper();
+			case rebook: return row.getRebookLink();
 			case confirm: return row.getConfirmLink();
 			case delete: return row.getDeleteLink();
 			case edit: return row.getEditLink();
@@ -91,6 +92,7 @@ implements SortableFlexiTableDataModel<AppointmentRow> {
 		maxParticipations("appointment.max.participations"),
 		freeParticipations("appointment.free.participations"),
 		participants("participants"),
+		rebook("rebook"),
 		confirm("confirm"),
 		edit("edit"),
 		delete("delete");
diff --git a/src/main/java/org/olat/course/nodes/appointments/ui/AppointmentDeleteController.java b/src/main/java/org/olat/course/nodes/appointments/ui/AppointmentDeleteController.java
new file mode 100644
index 00000000000..1d53125fa6f
--- /dev/null
+++ b/src/main/java/org/olat/course/nodes/appointments/ui/AppointmentDeleteController.java
@@ -0,0 +1,104 @@
+/**
+ * <a href="http://www.openolat.org">
+ * OpenOLAT - Online Learning and Training</a><br>
+ * <p>
+ * Licensed under the Apache License, Version 2.0 (the "License"); <br>
+ * you may not use this file except in compliance with the License.<br>
+ * You may obtain a copy of the License at the
+ * <a href="http://www.apache.org/licenses/LICENSE-2.0">Apache homepage</a>
+ * <p>
+ * Unless required by applicable law or agreed to in writing,<br>
+ * software distributed under the License is distributed on an "AS IS" BASIS, <br>
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. <br>
+ * See the License for the specific language governing permissions and <br>
+ * limitations under the License.
+ * <p>
+ * Initial code contributed and copyrighted by<br>
+ * frentix GmbH, http://www.frentix.com
+ * <p>
+ */
+package org.olat.course.nodes.appointments.ui;
+
+import static org.olat.core.gui.translator.TranslatorHelper.translateAll;
+
+import org.olat.core.gui.UserRequest;
+import org.olat.core.gui.components.form.flexible.FormItem;
+import org.olat.core.gui.components.form.flexible.FormItemContainer;
+import org.olat.core.gui.components.form.flexible.elements.SingleSelection;
+import org.olat.core.gui.components.form.flexible.impl.FormEvent;
+import org.olat.core.gui.control.Controller;
+import org.olat.core.gui.control.WindowControl;
+import org.olat.course.nodes.appointments.Appointment;
+
+/**
+ * 
+ * Initial date: 18 May 2020<br>
+ * @author uhensler, urs.hensler@frentix.com, http://www.frentix.com
+ *
+ */
+public class AppointmentDeleteController extends AbstractRebookController {
+	
+	private static final String DELETE = "appointment.delete.no";
+	private static final String CHANGE = "appointment.delete.yes";
+	private static final String[] PARTICIPATIONS = { DELETE, CHANGE };
+	
+	private SingleSelection changeEl;
+
+	public AppointmentDeleteController(UserRequest ureq, WindowControl wControl, Appointment appointment,
+			Configuration config) {
+		super(ureq, wControl, appointment, config);
+	}
+	
+	@Override
+	boolean isShowParticipations() {
+		return getNumParticipations() > 0;
+	}
+
+	@Override
+	boolean isParticipationsReadOnly() {
+		return true;
+	}
+
+	@Override
+	boolean isAllParticipationsSelected() {
+		return true;
+	}
+
+	@Override
+	boolean isShowAppointments() {
+		return changeEl != null && changeEl.isOneSelected() && CHANGE.equals(changeEl.getSelectedKey());
+	}
+
+	@Override
+	String getSubmitI18nKey() {
+		return "delete";
+	}
+	
+	@Override
+	protected void initFormTop(FormItemContainer formLayout, Controller listener, UserRequest ureq) {
+		if (getNumParticipations() > 0) {
+			setFormDescription("appointment.delete.participations", new String[] { String.valueOf(getNumParticipations()) } );
+		
+			changeEl = uifactory.addRadiosHorizontal("appointment.delete.rebook", formLayout, PARTICIPATIONS,
+					translateAll(getTranslator(), PARTICIPATIONS));
+			changeEl.addActionListener(FormEvent.ONCHANGE);
+			changeEl.select(DELETE, true);
+		} else {
+			setFormDescription("confirm.appointment.delete");
+		}
+	}
+
+	@Override
+	protected void formInnerEvent(UserRequest ureq, FormItem source, FormEvent event) {
+		if (source == changeEl) {
+			super.updateUI();
+		}
+		super.formInnerEvent(ureq, source, event);
+	}
+
+	@Override
+	void onAfterRebooking() {
+		getAppointmentsService().deleteAppointment(getCurrentAppointment());
+	}
+	
+}
diff --git a/src/main/java/org/olat/course/nodes/appointments/ui/AppointmentRow.java b/src/main/java/org/olat/course/nodes/appointments/ui/AppointmentRow.java
index 05d1eea901a..6720bd30ed3 100644
--- a/src/main/java/org/olat/course/nodes/appointments/ui/AppointmentRow.java
+++ b/src/main/java/org/olat/course/nodes/appointments/ui/AppointmentRow.java
@@ -46,6 +46,7 @@ public class AppointmentRow {
 	private String statusCSS;
 	private Integer freeParticipations;
 	private Integer maxParticipations;
+	private FormLink rebookLink;
 	private FormLink confirmLink;
 	private FormLink deleteLink;
 	private FormLink editLink;
@@ -162,6 +163,14 @@ public class AppointmentRow {
 		this.maxParticipations = maxParticipations;
 	}
 
+	public FormLink getRebookLink() {
+		return rebookLink;
+	}
+
+	public String getRebookLinkName() {
+		return rebookLink != null? rebookLink.getName(): null;
+	}
+	
 	public FormLink getConfirmLink() {
 		return confirmLink;
 	}
@@ -174,6 +183,10 @@ public class AppointmentRow {
 		this.confirmLink = confirmLink;
 	}
 
+	public void setRebookLink(FormLink rebookLink) {
+		this.rebookLink = rebookLink;
+	}
+
 	public FormLink getDeleteLink() {
 		return deleteLink;
 	}
diff --git a/src/main/java/org/olat/course/nodes/appointments/ui/RebookController.java b/src/main/java/org/olat/course/nodes/appointments/ui/RebookController.java
new file mode 100644
index 00000000000..8d6f0dd21eb
--- /dev/null
+++ b/src/main/java/org/olat/course/nodes/appointments/ui/RebookController.java
@@ -0,0 +1,75 @@
+/**
+ * <a href="http://www.openolat.org">
+ * OpenOLAT - Online Learning and Training</a><br>
+ * <p>
+ * Licensed under the Apache License, Version 2.0 (the "License"); <br>
+ * you may not use this file except in compliance with the License.<br>
+ * You may obtain a copy of the License at the
+ * <a href="http://www.apache.org/licenses/LICENSE-2.0">Apache homepage</a>
+ * <p>
+ * Unless required by applicable law or agreed to in writing,<br>
+ * software distributed under the License is distributed on an "AS IS" BASIS, <br>
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. <br>
+ * See the License for the specific language governing permissions and <br>
+ * limitations under the License.
+ * <p>
+ * Initial code contributed and copyrighted by<br>
+ * frentix GmbH, http://www.frentix.com
+ * <p>
+ */
+package org.olat.course.nodes.appointments.ui;
+
+import org.olat.core.gui.UserRequest;
+import org.olat.core.gui.components.form.flexible.FormItemContainer;
+import org.olat.core.gui.control.Controller;
+import org.olat.core.gui.control.WindowControl;
+import org.olat.course.nodes.appointments.Appointment;
+
+/**
+ * 
+ * Initial date: 19 May 2020<br>
+ * @author uhensler, urs.hensler@frentix.com, http://www.frentix.com
+ *
+ */
+public class RebookController extends AbstractRebookController {
+
+	public RebookController(UserRequest ureq, WindowControl wControl, Appointment appointment, Configuration config) {
+		super(ureq, wControl, appointment, config);
+	}
+
+	@Override
+	boolean isShowParticipations() {
+		return true;
+	}
+
+	@Override
+	boolean isParticipationsReadOnly() {
+		return false;
+	}
+
+	@Override
+	boolean isShowAppointments() {
+		return true;
+	}
+
+	@Override
+	boolean isAllParticipationsSelected() {
+		return false;
+	}
+
+	@Override
+	String getSubmitI18nKey() {
+		return "rebook";
+	}
+
+	@Override
+	void initFormTop(FormItemContainer formLayout, Controller listener, UserRequest ureq) {
+		//
+	}
+
+	@Override
+	void onAfterRebooking() {
+		//
+	}
+
+}
diff --git a/src/main/java/org/olat/course/nodes/appointments/ui/TopicCoachController.java b/src/main/java/org/olat/course/nodes/appointments/ui/TopicCoachController.java
index cf9e590dacf..d1e4c3f928d 100644
--- a/src/main/java/org/olat/course/nodes/appointments/ui/TopicCoachController.java
+++ b/src/main/java/org/olat/course/nodes/appointments/ui/TopicCoachController.java
@@ -73,6 +73,7 @@ import org.springframework.beans.factory.annotation.Autowired;
  */
 public class TopicCoachController extends FormBasicController implements FlexiTableComponentDelegate {
 	
+	private static final String CMD_REBOOK = "rebook";
 	private static final String CMD_CONFIRM = "confirm";
 	private static final String CMD_DELETE = "delete";
 	private static final String CMD_EDIT = "edit";
@@ -88,8 +89,9 @@ public class TopicCoachController extends FormBasicController implements FlexiTa
 	private TopicEditController topicEditCtrl;
 	private OrganizersEditController organizersEditCtrl;
 	private AppointmentEditController appointmentEditCtrl;
+	private RebookController rebookCtrl;
 	private DialogBoxController confirmDeleteTopicCrtl;
-	private DialogBoxController confirmDeletionCrtl;
+	private AppointmentDeleteController appointmentDeleteCtrl;
 
 	private Topic topic;
 	private final AppointmentsSecurityCallback secCallback;
@@ -153,6 +155,9 @@ public class TopicCoachController extends FormBasicController implements FlexiTa
 		participantsModel.setCellRenderer(new ParticipationsRenderer());
 		participantsModel.setDefaultVisible(false);
 		columnsModel.addFlexiColumnModel(participantsModel);
+		DefaultFlexiColumnModel rebookModel = new DefaultFlexiColumnModel(AppointmentCols.rebook);
+		rebookModel.setExportable(false);
+		columnsModel.addFlexiColumnModel(rebookModel);
 		DefaultFlexiColumnModel confirmModel = new DefaultFlexiColumnModel(AppointmentCols.confirm);
 		confirmModel.setExportable(false);
 		columnsModel.addFlexiColumnModel(confirmModel);
@@ -276,6 +281,9 @@ public class TopicCoachController extends FormBasicController implements FlexiTa
 		row.setTranslatedStatus(translate("appointment.status." + appointment.getStatus().name()));
 		row.setStatusCSS("o_ap_status_" + appointment.getStatus().name());
 
+		if (participations.size() > 0) {
+			forgeRebookLink(row);
+		}
 		if (config.isConfirmation()) {
 			boolean confirmable = Appointment.Status.planned == appointment.getStatus()
 					&& participations.size() > 0;
@@ -290,6 +298,12 @@ public class TopicCoachController extends FormBasicController implements FlexiTa
 		return row;
 	}
 
+	private void forgeRebookLink(AppointmentRow row) {
+		FormLink link = uifactory.addFormLink("rebook_" + row.getKey(), CMD_REBOOK, "rebook", null, null, Link.LINK);
+		link.setUserObject(row);
+		row.setRebookLink(link);
+	}
+	
 	private void forgeConfirmLink(AppointmentRow row, boolean confirmable) {
 		String i18nKey = confirmable? "confirm": "unconfirm";
 		FormLink link = uifactory.addFormLink("confirm_" + row.getKey(), CMD_CONFIRM, i18nKey, null, null, Link.LINK);
@@ -331,7 +345,10 @@ public class TopicCoachController extends FormBasicController implements FlexiTa
 			} else if (CMD_CONFIRM.equals(cmd)) {
 				AppointmentRow row = (AppointmentRow)link.getUserObject();
 				doConfirm(row.getAppointment());
-			}
+			} else if (CMD_REBOOK.equals(cmd)) {
+				AppointmentRow row = (AppointmentRow)link.getUserObject();
+				doRebook(ureq, row.getAppointment());
+			} 
 		}
 		super.formInnerEvent(ureq, source, event);
 	}
@@ -357,15 +374,22 @@ public class TopicCoachController extends FormBasicController implements FlexiTa
 			}
 			cmc.deactivate();
 			cleanUp();
+		} else if (appointmentDeleteCtrl == source) {
+			if (event == Event.DONE_EVENT) {
+				updateModel();
+			}
+			cmc.deactivate();
+			cleanUp();
+		}  else if (rebookCtrl == source) {
+			if (event == Event.DONE_EVENT) {
+				updateModel();
+			}
+			cmc.deactivate();
+			cleanUp();
 		} else if (source == confirmDeleteTopicCrtl) {
 			if (DialogBoxUIFactory.isYesEvent(event) || DialogBoxUIFactory.isOkEvent(event)) {
 				doDeleteTopic(ureq);
 			}
-		} else if (source == confirmDeletionCrtl) {
-			if (DialogBoxUIFactory.isYesEvent(event) || DialogBoxUIFactory.isOkEvent(event)) {
-				Appointment appointment = (Appointment)confirmDeletionCrtl.getUserObject();
-				doDelete(appointment);
-			}
 		} else if (cmc == source) {
 			cleanUp();
 		}
@@ -373,9 +397,13 @@ public class TopicCoachController extends FormBasicController implements FlexiTa
 	}
 	
 	private void cleanUp() {
+		removeAsListenerAndDispose(appointmentDeleteCtrl);
 		removeAsListenerAndDispose(appointmentEditCtrl);
+		removeAsListenerAndDispose(rebookCtrl);
 		removeAsListenerAndDispose(cmc);
+		appointmentDeleteCtrl = null;
 		appointmentEditCtrl = null;
+		rebookCtrl = null;
 		cmc = null;
 	}
 
@@ -436,10 +464,13 @@ public class TopicCoachController extends FormBasicController implements FlexiTa
 	}
 	
 	private void doConfirmDeletion(UserRequest ureq, Appointment appointment) {
-		String title = translate("confirm.delete.title");
-		String text = translate("confirm.delete");
-		confirmDeletionCrtl = activateYesNoDialog(ureq, title, text, confirmDeletionCrtl);
-		confirmDeletionCrtl.setUserObject(appointment);
+		appointmentDeleteCtrl = new AppointmentDeleteController(ureq, getWindowControl(), appointment, config);
+		listenTo(appointmentDeleteCtrl);
+
+		cmc = new CloseableModalController(getWindowControl(), "close", appointmentDeleteCtrl.getInitialComponent(),
+				true, translate("confirm.appointment.delete.title"));
+		listenTo(cmc);
+		cmc.activate();
 	}
 
 	private void doConfirm(Appointment appointment) {
@@ -450,10 +481,15 @@ public class TopicCoachController extends FormBasicController implements FlexiTa
 		}
 		updateModel();
 	}
+	
+	private void doRebook(UserRequest ureq, Appointment appointment) {
+		rebookCtrl = new RebookController(ureq, getWindowControl(), appointment, config);
+		listenTo(rebookCtrl);
 
-	private void doDelete(Appointment appointment) {
-		appointmentsService.deleteAppointment(appointment);
-		updateModel();
+		cmc = new CloseableModalController(getWindowControl(), "close", rebookCtrl.getInitialComponent(),
+				true, translate("rebook.title"));
+		listenTo(cmc);
+		cmc.activate();
 	}
 
 	@Override
diff --git a/src/main/java/org/olat/course/nodes/appointments/ui/TopicsRunController.java b/src/main/java/org/olat/course/nodes/appointments/ui/TopicsRunController.java
index 34a2b1f5251..365513ca424 100644
--- a/src/main/java/org/olat/course/nodes/appointments/ui/TopicsRunController.java
+++ b/src/main/java/org/olat/course/nodes/appointments/ui/TopicsRunController.java
@@ -137,10 +137,10 @@ public class TopicsRunController extends BasicController {
 		List<Participation> myParticipations = appointmentsService.getParticipations(myParticipationsParams);
 		Map<Long, List<Participation>> topicKeyToMyParticipation = myParticipations.stream()
 				.collect(Collectors.groupingBy(p -> p.getAppointment().getTopic().getKey()));
-		Map<Long, List<Participation>> appointmentKeyToMyParticipation = myParticipations.stream()
+		Map<Long, List<Participation>> appointmentsToMyParticipation = myParticipations.stream()
 				.collect(Collectors.groupingBy(p -> p.getAppointment().getKey()));
 		
-		Set<Long> myAppointmentKeys = appointmentKeyToMyParticipation.keySet();
+		Set<Long> myAppointmentKeys = appointmentsToMyParticipation.keySet();
 		ParticipationSearchParams allParticipationParams = new ParticipationSearchParams();
 		allParticipationParams.setAppointmentKeys(myAppointmentKeys);
 		Map<Long, List<Participation>> appointmentKeyToAllParticipations = appointmentsService
@@ -154,7 +154,7 @@ public class TopicsRunController extends BasicController {
 			wrapOrganizers(wrapper, organizers);
 			List<Appointment> appointments = topicKeyToAppointments.getOrDefault(topic.getKey(), emptyList());
 			List<Participation> topicParticipations = topicKeyToMyParticipation.getOrDefault(topic.getKey(), emptyList());
-			wrapParticpations(wrapper, topic, topicParticipations, appointments, appointmentKeyToMyParticipation,
+			wrapParticpations(wrapper, topic, topicParticipations, appointments, appointmentsToMyParticipation,
 					appointmentKeyToAllParticipations);
 			wrappers.add(wrapper);
 		}
diff --git a/src/main/java/org/olat/course/nodes/appointments/ui/_content/appointment_row_coach.html b/src/main/java/org/olat/course/nodes/appointments/ui/_content/appointment_row_coach.html
index 6f109a0d5fc..d33dd3baf38 100644
--- a/src/main/java/org/olat/course/nodes/appointments/ui/_content/appointment_row_coach.html
+++ b/src/main/java/org/olat/course/nodes/appointments/ui/_content/appointment_row_coach.html
@@ -65,6 +65,9 @@
 
 		<div class="o_buttons">
 			<div class="o_button_group o_button_group_right">
+				#if($row.rebookLinkName)
+					$r.render($row.rebookLinkName)
+				#end
 				#if($row.confirmLinkName)
 					$r.render($row.confirmLinkName)
 				#end
diff --git a/src/main/java/org/olat/course/nodes/appointments/ui/_i18n/LocalStrings_de.properties b/src/main/java/org/olat/course/nodes/appointments/ui/_i18n/LocalStrings_de.properties
index fb2bd214a26..3cd0abf2958 100644
--- a/src/main/java/org/olat/course/nodes/appointments/ui/_i18n/LocalStrings_de.properties
+++ b/src/main/java/org/olat/course/nodes/appointments/ui/_i18n/LocalStrings_de.properties
@@ -5,6 +5,10 @@ add.topic=Termine hinzuf\u00FCgen
 add.topic.title=$:\add.topic
 all.appointments.allocated=Es sind alle Termine belegt.
 appointment.details=Details
+appointment.delete.rebook=Teilnehmer umbuchen?
+appointment.delete.no=Nein
+appointment.delete.participations=Es haben sich {0} Teilnehmer in diesen Termin eingeschrieben. Sollen diese Teilnehmer umgebucht werden?
+appointment.delete.yes=Ja
 appointment.end=Ende
 appointment.id=ID
 appointment.location=Ort
@@ -32,8 +36,8 @@ config.confirmation=Terminbest\u00E4tigung
 config.edit.appointment=Termine bearbeiten
 config.edit.topic=Thema bearbeiten
 confirm=Best\u00E4tigen
-confirm.delete=Wollen Sie diesen Termin wirklich absagen?
-confirm.delete.title=Termin absagen
+confirm.appointment.delete=Wollen Sie diesen Termin wirklich absagen?
+confirm.appointment.delete.title=Termin absagen
 confirm.participation.self=Wollen Sie sich wirklich f\u00FCr diesen Termin eintragen? Es kann anschliessend kein anderer Termin mehr gew\u00E4hlt werden.
 confirm.participation.self.title=Termin ausw\u00E4hlen
 confirm.topic.delete=Wollen Sie wirklich alle Termine absagen?
@@ -49,6 +53,8 @@ email.title=Neue Nachricht
 email.organizer.recipients=Organisatoren
 email.organizer.subject=Termin "{0}"
 error.positiv.number=Geben Sie eine positive Ganzzahl ein.
+error.select.appointment=Sie m\u00FCssen einen Termin ausw\u00E4hlen.
+error.select.participant=Sie m\u00FCssen einen Teilnehmer ausw\u00E4hlen.
 error.start.after.end=Das Ende darf nicht vor dem Start liegen.
 error.too.much.participations=Es gibt bereits {0} Teilnahmen.
 general=Konfiguration
@@ -60,6 +66,8 @@ mail.deleted.body=Liebe/r {0} <br><br>Folgender Termin wurde abgesagt.<br><br>{1
 mail.deleted.subject=Termin "{0}" abgesagt
 mail.end=Ende: {0}
 mail.location=Ort: {0}
+mail.rebooked.subject=Termin umgebucht
+mail.rebooked.body=Liebe/r {0} <br><br>Folgender Termin wurde umbgebucht.<br><br><b>Bisheriger Termin</b><br>{1}<br><b>Neuer Termin</b><br>{2}
 mail.start=Start: {0}
 mail.unconfirmed.body=Liebe/r {0} <br><br>Folgender Termin wurde wieder ge\u00F6ffnet.<br><br>{1}
 mail.unconfirmed.subject=Termin "{0}" wieder ge\u00F6ffnet
@@ -76,6 +84,12 @@ participants=Teilnehmer
 participations.free=Es sind noch {0} Pl\u00E4tze frei.
 participations.free.one=Es ist noch ein Platz frei.
 participations.selected=Es sind {0} Teilnehmer in {1} Terminen eingetragen.
+rebook=Umbuchen
+rebook.appointments=Termin
+rebook.participation=Teilnehmer
+rebook.no.appointments=$\:rebook.appointments
+rebook.no.appointments.text=<i>Es gibt keine Termine mit gen\u00FCgend freien Pl\u00E4tzen.</i>
+rebook.title=Teilnehmer umbuchen
 role.coach=Betreuer
 save.back=Speichern und zur\u00FCck
 table.empty.appointments=Es sind keine Termine vorhanden.
diff --git a/src/main/java/org/olat/course/nodes/appointments/ui/_i18n/LocalStrings_en.properties b/src/main/java/org/olat/course/nodes/appointments/ui/_i18n/LocalStrings_en.properties
index 4facccd26ce..087b544bed8 100644
--- a/src/main/java/org/olat/course/nodes/appointments/ui/_i18n/LocalStrings_en.properties
+++ b/src/main/java/org/olat/course/nodes/appointments/ui/_i18n/LocalStrings_en.properties
@@ -5,6 +5,10 @@ add.topic=Add appointments
 add.topic.title=$:\add.topic
 all.appointments.allocated=All appointments are allocated.
 appointment.details=Details
+appointment.delete.rebook=Rebook appointment?
+appointment.delete.no=No
+appointment.delete.participations={0} participants are enrolled to this appointment. Should these participants be rebooked?
+appointment.delete.yes=Yes
 appointment.end=End
 appointment.id=ID
 appointment.location=Location
@@ -32,8 +36,8 @@ config.confirmation=Confirmation of the appointment
 config.edit.appointment=Edit appointments
 config.edit.topic=Edit topic
 confirm=Confirm
-confirm.delete=Do you really want to decline that appointment?
-confirm.delete.title=Decline appointment
+confirm.appointment.delete=Do you really want to decline this appointment?
+confirm.appointment.delete.title=Decline appointment
 confirm.participation.self=Do you really want to enroll for this appointment? No other appointment can be chosen afterwards.
 confirm.participation.self.title=Select appointment
 confirm.topic.delete=Do you really want to decline all appointments?
@@ -49,6 +53,8 @@ email.title=New message
 email.organizer.recipients=Organizers
 email.organizer.subject=Appointment "{0}"
 error.positiv.number=It has to be a positive integer.
+error.select.appointment=You have to select an appointment.
+error.select.participant=You have to select a participant.
 error.start.after.end=The end date must not be before the start date.
 error.too.much.participations=There are already {0} participations.
 general=Configuration
@@ -60,6 +66,8 @@ mail.deleted.body=Dear {0} <br><br>The following appointment was declined.<br><b
 mail.deleted.subject=Appointment "{0}" declined
 mail.end=End: {0}
 mail.location=Location: {0}
+mail.rebooked.subject=Appointment rebooked
+mail.rebooked.body=Dear {0} <br><br>The following appointment was rebooked.<br><br><b>Previous appointment</b><br>{1}<br><b>New appointment</b><br>{2}
 mail.start=Start: {0}
 mail.unconfirmed.body=Dear {0} <br><br>The following appointment was reopened.<br><br>{1}
 mail.unconfirmed.subject=Appointment "{0}" reopened
@@ -76,6 +84,12 @@ participants=Participants
 participations.free=There are {0} places left.
 participations.free.one=There is one place left.
 participations.selected={0} participants in {1} appointments are enrolled.
+rebook=Rebook
+rebook.appointments=Appointment
+rebook.participation=Participants
+rebook.no.appointments=$\:rebook.appointments
+rebook.no.appointments.text=<i>There are no appointments with enough free places.</i>
+rebook.title=Rebook participants
 role.coach=Coach
 save.back=Save and back
 table.empty.appointments=There are no appointments available.
diff --git a/src/test/java/org/olat/course/nodes/appointments/manager/AppointmentDAOTest.java b/src/test/java/org/olat/course/nodes/appointments/manager/AppointmentDAOTest.java
index 05007ce09aa..eb034200fa2 100644
--- a/src/test/java/org/olat/course/nodes/appointments/manager/AppointmentDAOTest.java
+++ b/src/test/java/org/olat/course/nodes/appointments/manager/AppointmentDAOTest.java
@@ -247,7 +247,7 @@ public class AppointmentDAOTest extends OlatTestCase {
 		dbInstance.commitAndCloseSession();
 		
 		AppointmentSearchParams params = new AppointmentSearchParams();
-		params.setAppointmentKey(appointment.getKey());
+		params.setAppointment(appointment);
 		List<Appointment> appointments = sut.loadAppointments(params);
 		
 		assertThat(appointments).containsExactlyInAnyOrder(appointment);
diff --git a/src/test/java/org/olat/course/nodes/appointments/manager/ParticipationDAOTest.java b/src/test/java/org/olat/course/nodes/appointments/manager/ParticipationDAOTest.java
index 68337f98899..532ae0a2c33 100644
--- a/src/test/java/org/olat/course/nodes/appointments/manager/ParticipationDAOTest.java
+++ b/src/test/java/org/olat/course/nodes/appointments/manager/ParticipationDAOTest.java
@@ -35,6 +35,7 @@ import org.olat.core.commons.persistence.DB;
 import org.olat.core.id.Identity;
 import org.olat.course.nodes.appointments.Appointment;
 import org.olat.course.nodes.appointments.Appointment.Status;
+import org.olat.course.nodes.appointments.AppointmentRef;
 import org.olat.course.nodes.appointments.Participation;
 import org.olat.course.nodes.appointments.ParticipationSearchParams;
 import org.olat.course.nodes.appointments.Topic;
@@ -213,10 +214,10 @@ public class ParticipationDAOTest extends OlatTestCase {
 		sut.createParticipation(appointmentD1 , identity1);
 		dbInstance.commitAndCloseSession();
 		
-		Collection<Long> appointmentKeys = Arrays.asList(appointmentA1.getKey(), appointmentA2.getKey(),
-				appointmentB1.getKey(), appointmentD1.getKey());
+		Collection<AppointmentRef> appointments = Arrays.asList(appointmentA1, appointmentA2, appointmentB1,
+				appointmentD1);
 		ParticipationSearchParams params = new ParticipationSearchParams();
-		params.setAppointmentKeys(appointmentKeys);
+		params.setAppointments(appointments);
 		params.setStatus(Status.confirmed);
 		Long count = sut.loadParticipationCount(params);
 		
@@ -303,13 +304,39 @@ public class ParticipationDAOTest extends OlatTestCase {
 		
 		//Is just a syntax check. The appointment key is the limiting clause.
 		ParticipationSearchParams params = new ParticipationSearchParams();
-		params.setAppointmentKeys(asList(appointment.getKey()));
+		params.setAppointments(asList(appointment));
 		params.setCreatedAfter(new GregorianCalendar(2000, 1, 1).getTime());
 		List<Participation> participations = sut.loadParticipations(params);
 		
 		assertThat(participations).containsExactlyInAnyOrder(participation);
 	}
 	
+	@Test
+	public void shouldLoadKeys() {
+		RepositoryEntry entry = JunitTestHelper.createAndPersistRepositoryEntry();
+		Identity identity1 = JunitTestHelper.createAndPersistIdentityAsUser(random());
+		Identity identity2 = JunitTestHelper.createAndPersistIdentityAsUser(random());
+		Topic topic1 = topicDao.createTopic(entry, JunitTestHelper.random());
+		Topic topic2 = topicDao.createTopic(entry, JunitTestHelper.random());
+		Appointment appointment11 = appointmentDao.createAppointment(topic1);
+		Appointment appointment12 = appointmentDao.createAppointment(topic1);
+		Appointment appointment21 = appointmentDao.createAppointment(topic2);
+		Participation participation111 = sut.createParticipation(appointment11, identity1);
+		Participation participation121 = sut.createParticipation(appointment12, identity1);
+		Participation participation112 = sut.createParticipation(appointment11, identity2);
+		Participation participation211 = sut.createParticipation(appointment21, identity1);
+		dbInstance.commitAndCloseSession();
+		
+		ParticipationSearchParams params = new ParticipationSearchParams();
+		Collection<Participation> asList = Arrays.asList(participation112, participation211);
+		params.setParticipations(asList);
+		List<Participation> participations = sut.loadParticipations(params);
+		
+		assertThat(participations)
+				.containsExactlyInAnyOrder(participation112, participation211)
+				.doesNotContain(participation111, participation121);
+	}
+	
 	@Test
 	public void shouldLoadByAppointments() {
 		RepositoryEntry entry1 = JunitTestHelper.createAndPersistRepositoryEntry();
@@ -337,9 +364,9 @@ public class ParticipationDAOTest extends OlatTestCase {
 		Participation participationD11 = sut.createParticipation(appointmentD1 , identity1);
 		dbInstance.commitAndCloseSession();
 		
-		Collection<Long> appointmentKeys = Arrays.asList(appointmentA1.getKey(), appointmentA2.getKey(), appointmentC1.getKey());
+		Collection<Appointment> appointments = Arrays.asList(appointmentA1, appointmentA2, appointmentC1);
 		ParticipationSearchParams params = new ParticipationSearchParams();
-		params.setAppointmentKeys(appointmentKeys);
+		params.setAppointments(appointments);
 		List<Participation> participations = sut.loadParticipations(params);
 		
 		assertThat(participations)
@@ -386,10 +413,10 @@ public class ParticipationDAOTest extends OlatTestCase {
 		Participation participationD11 = sut.createParticipation(appointmentD1 , identity1);
 		dbInstance.commitAndCloseSession();
 		
-		Collection<Long> appointmentKeys = Arrays.asList(appointmentA1.getKey(), appointmentA2.getKey(),
-				appointmentB1.getKey(), appointmentD1.getKey());
+		Collection<Appointment> appointments = Arrays.asList(appointmentA1, appointmentA2, appointmentB1,
+				appointmentD1);
 		ParticipationSearchParams params = new ParticipationSearchParams();
-		params.setAppointmentKeys(appointmentKeys);
+		params.setAppointments(appointments);
 		params.setStartAfter(new GregorianCalendar(2020, 1, 1).getTime());
 		List<Participation> participations = sut.loadParticipations(params);
 		
@@ -432,10 +459,10 @@ public class ParticipationDAOTest extends OlatTestCase {
 		Participation participationD11 = sut.createParticipation(appointmentD1 , identity1);
 		dbInstance.commitAndCloseSession();
 		
-		Collection<Long> appointmentKeys = Arrays.asList(appointmentA1.getKey(), appointmentA2.getKey(),
-				appointmentB1.getKey(), appointmentD1.getKey());
+		Collection<Appointment> appointments = Arrays.asList(appointmentA1, appointmentA2, appointmentB1,
+				appointmentD1);
 		ParticipationSearchParams params = new ParticipationSearchParams();
-		params.setAppointmentKeys(appointmentKeys);
+		params.setAppointments(appointments);
 		params.setStatus(Status.confirmed);
 		List<Participation> participations = sut.loadParticipations(params);
 		
@@ -474,10 +501,9 @@ public class ParticipationDAOTest extends OlatTestCase {
 		Participation participationD11 = sut.createParticipation(appointmentD1 , identity);
 		dbInstance.commitAndCloseSession();
 		
-		Collection<Long> appointmentKeys = Arrays.asList(appointmentA1.getKey(), appointmentA2.getKey(),
-				appointmentB1.getKey(), appointmentD1.getKey());
+		Collection<Appointment> appointments = Arrays.asList(appointmentA1, appointmentA2, appointmentB1, appointmentD1);
 		ParticipationSearchParams params = new ParticipationSearchParams();
-		params.setAppointmentKeys(appointmentKeys);
+		params.setAppointments(appointments);
 		params.setStatusModifiedAfter(new GregorianCalendar(2020, 3, 1, 12, 30, 0).getTime());
 		List<Participation> participations = sut.loadParticipations(params);
 		
-- 
GitLab