diff --git a/config/configuration/bundles/src/bundle/org/sakaiproject/config/bundle/default.sakai.properties b/config/configuration/bundles/src/bundle/org/sakaiproject/config/bundle/default.sakai.properties
index 0584f24c3701..cecfa8d47ff7 100644
--- a/config/configuration/bundles/src/bundle/org/sakaiproject/config/bundle/default.sakai.properties
+++ b/config/configuration/bundles/src/bundle/org/sakaiproject/config/bundle/default.sakai.properties
@@ -5341,6 +5341,18 @@
#Default: false
#scorm.config.showNavBar.default=true
+# ###############################
+# MICROSOFT TEAMS
+# ###############################
+
+#SAK-52355 Enable forced synchronization in Microsoft Teams
+#DEFAULT: none, use "all" for all sites or specify a list of site types to allow. Comma-separated list of sitetype: course,project,...
+#microsoft.forced.synchronization.sitetype=all
+
+#SAK-52355 Enable forced synchronization in Microsoft Teams
+#DEFAULT: microsoft.synchronized=true, use your own name and value property. IMPORTANT: The administrator should add this name and optional value in the Microsoft Admin Tool settings, in the corresponding "Site property" input.
+#microsoft.forced.synchronization.propertynameandoptionalvalue=microsoft.synchronized=true
+
# ###############################
# Content-Review
# ###############################
diff --git a/library/src/skins/default/src/sass/modules/tool/sitemanage/_sitemanage.scss b/library/src/skins/default/src/sass/modules/tool/sitemanage/_sitemanage.scss
index f3d9defe5f93..b42eb8cc5780 100644
--- a/library/src/skins/default/src/sass/modules/tool/sitemanage/_sitemanage.scss
+++ b/library/src/skins/default/src/sass/modules/tool/sitemanage/_sitemanage.scss
@@ -494,7 +494,7 @@
}
#toolHolderWW {
@include display-flex;
- .mathJaxToggleArea, .subPageNavToggleArea {
+ .mathJaxToggleArea, .subPageNavToggleArea, .microsoftSynchronizationArea {
@include display-flex;
@include flex-direction(row-reverse);
@include justify-content(flex-end); // align to the left
diff --git a/microsoft-integration/admin-tool/src/main/java/org/sakaiproject/microsoft/controller/AutoConfigController.java b/microsoft-integration/admin-tool/src/main/java/org/sakaiproject/microsoft/controller/AutoConfigController.java
index 5c9f2732d6a9..bbbf31800d98 100644
--- a/microsoft-integration/admin-tool/src/main/java/org/sakaiproject/microsoft/controller/AutoConfigController.java
+++ b/microsoft-integration/admin-tool/src/main/java/org/sakaiproject/microsoft/controller/AutoConfigController.java
@@ -118,8 +118,8 @@ public String autoConfig(
if(!autoConfigSessionBean.isRunning()) {
- //get all synchronizations
- List ssList = microsoftSynchronizationService.getAllSiteSynchronizations(false);
+ //get all enabled synchronizations
+ List ssList = microsoftSynchronizationService.getAllEnabledSiteSynchronizations(false);
//get (filtered) sites
List sitesList = sakaiProxy.getSakaiSites(requestBody.getFilter());
diff --git a/microsoft-integration/admin-tool/src/main/java/org/sakaiproject/microsoft/controller/MainController.java b/microsoft-integration/admin-tool/src/main/java/org/sakaiproject/microsoft/controller/MainController.java
index 6c0cf23db3fe..dc0993a43322 100644
--- a/microsoft-integration/admin-tool/src/main/java/org/sakaiproject/microsoft/controller/MainController.java
+++ b/microsoft-integration/admin-tool/src/main/java/org/sakaiproject/microsoft/controller/MainController.java
@@ -334,7 +334,24 @@ public AjaxResponse updateSiteSynchronizationForced(@PathVariable String id, @Re
return ret;
}
-
+
+ //called by AJAX - returns JSON
+ @PostMapping(path = {"/setDisabled-siteSynchronization/{id}"}, produces = MediaType.APPLICATION_JSON_VALUE)
+ @ResponseBody
+ public AjaxResponse updateSiteSynchronizationDisabled(@PathVariable String id, @RequestParam Boolean disabled, Model model) {
+ SiteSynchronization ss = microsoftSynchronizationService.getSiteSynchronization(SiteSynchronization.builder().id(id).build());
+ AjaxResponse ret = new AjaxResponse();
+ ret.setStatus(false);
+ ret.setError(rb.getString("error.changing_synchronization_status"));
+ if(ss != null) {
+ ss.setDisabled(disabled);
+ microsoftSynchronizationService.saveOrUpdateSiteSynchronization(ss);
+ ret.setStatus(true);
+ ret.setError("");
+ }
+ return ret;
+ }
+
//called by AJAX - returns JSON
@GetMapping(path = {"/setDate-siteSynchronization/{id}"}, produces = MediaType.APPLICATION_JSON_VALUE)
@ResponseBody
diff --git a/microsoft-integration/admin-tool/src/main/resources/Messages.properties b/microsoft-integration/admin-tool/src/main/resources/Messages.properties
index 4448d9ff6d17..75fac0efb935 100644
--- a/microsoft-integration/admin-tool/src/main/resources/Messages.properties
+++ b/microsoft-integration/admin-tool/src/main/resources/Messages.properties
@@ -77,6 +77,8 @@ index.run.disabled=Run Disabled
index.refresh=Refresh
index.groups=Groups
index.edit_groups=Edit Groups
+index.enable=Enable synchronization
+index.disable=Disable synchronization
index.selected_id=Selected id
index.delete_selected=Delete Selected
index.remove_team_users=Remove Team Users
@@ -221,6 +223,7 @@ error.new_channel_empty=New channel name can not be empty
error.new_channel_with_same_name= New channel name can not have the same name of one of the channels created
error.delete_group_synchronization=Error removing selected Group Synchronization
error.set_forced_synchronization=Error setting forced
+error.changing_synchronization_status=Error changing the synchronization status
error.deleting_site_synchronizations=Error removing one or more selected synchronizations
error.cleaning_team=Error cleaning one or more selected synchronizations
error.set_dates=Error setting dates
diff --git a/microsoft-integration/admin-tool/src/main/resources/Messages_es.properties b/microsoft-integration/admin-tool/src/main/resources/Messages_es.properties
index 631c7b55b9b3..6205618ed8a9 100644
--- a/microsoft-integration/admin-tool/src/main/resources/Messages_es.properties
+++ b/microsoft-integration/admin-tool/src/main/resources/Messages_es.properties
@@ -77,6 +77,8 @@ index.run.disabled=Ejecutar Deshabilitado
index.refresh=Refrescar
index.groups=Grupos
index.edit_groups=Editar Grupos
+index.enable=Habilitar sincronizaci\u00f3n
+index.disable=Deshabilitar sincronizaci\u00f3n
index.selected_id=Id seleccionado
index.delete_selected=Borrar seleccionado
index.remove_team_users=Eliminar Usuarios de Teams
@@ -220,6 +222,7 @@ error.new_channel_empty=El nombre del nuevo canal no puede estar vac\u00edo
error.new_channel_with_same_name=El nombre del nuevo canal no puede tener el mismo nombre que uno de los canales ya creados
error.delete_group_synchronization=Error eliminando la sincronizaci\u00f3n de grupo seleccionada
error.set_forced_synchronization=Error de configuraci\u00f3n forzado
+error.changing_synchronization_status=Error al cambiar el estado de la sincronizaci\u00f3n
error.deleting_site_synchronizations=Error al eliminar una o varias sincronizaciones seleccionadas
error.cleaning_team=Error al limpiar una o m\u00e1s sincronizaciones seleccionadas
error.set_dates=Error al establecer las fechas
diff --git a/microsoft-integration/admin-tool/src/main/webapp/WEB-INF/templates/fragments/synchronizationRow.html b/microsoft-integration/admin-tool/src/main/webapp/WEB-INF/templates/fragments/synchronizationRow.html
index 0f53df5fc8b1..a04fb3957564 100644
--- a/microsoft-integration/admin-tool/src/main/webapp/WEB-INF/templates/fragments/synchronizationRow.html
+++ b/microsoft-integration/admin-tool/src/main/webapp/WEB-INF/templates/fragments/synchronizationRow.html
@@ -92,6 +92,12 @@
+
+
+
+
+
diff --git a/microsoft-integration/admin-tool/src/main/webapp/WEB-INF/templates/index.html b/microsoft-integration/admin-tool/src/main/webapp/WEB-INF/templates/index.html
index b02b49829b3e..bd0a2ff9587b 100644
--- a/microsoft-integration/admin-tool/src/main/webapp/WEB-INF/templates/index.html
+++ b/microsoft-integration/admin-tool/src/main/webapp/WEB-INF/templates/index.html
@@ -113,6 +113,35 @@
}
}
+ async function toggleDisabled(elem) {
+ let baseURL = "[(@{/setDisabled-siteSynchronization/})]";
+ let rowId = elem.closest('.table-row').id.replace('row_', '');
+ let icon = elem.querySelector('i');
+ let isDisabled = icon.classList.contains('fa-toggle-off');
+
+ let url = baseURL + rowId + "?disabled=" + !isDisabled;
+
+ let response = await fetch(url, {
+ method: 'POST',
+ headers: {
+ 'X-Requested-With': 'XMLHttpRequest'
+ }
+ });
+ let data = await response.json();
+
+ if (data.status == false) {
+ showAjaxError(data.error);
+ } else {
+ if (!isDisabled) {
+ icon.classList.replace('fa-toggle-on', 'fa-toggle-off');
+ elem.title = /*[[#{index.enable}]]*/ '';
+ } else {
+ icon.classList.replace('fa-toggle-off', 'fa-toggle-on');
+ elem.title = /*[[#{index.disable}]]*/ '';
+ }
+ }
+ }
+
var dateTimeout = null;
async function changeDate(input) {
if(dateTimeout != null){
diff --git a/microsoft-integration/api/src/java/org/sakaiproject/microsoft/api/MicrosoftCommonService.java b/microsoft-integration/api/src/java/org/sakaiproject/microsoft/api/MicrosoftCommonService.java
index 2650b6477798..b3634101afcc 100644
--- a/microsoft-integration/api/src/java/org/sakaiproject/microsoft/api/MicrosoftCommonService.java
+++ b/microsoft-integration/api/src/java/org/sakaiproject/microsoft/api/MicrosoftCommonService.java
@@ -109,6 +109,8 @@ public static enum PermissionRoles { READ, WRITE }
void createTeamFromGroupAsync(String groupId) throws MicrosoftCredentialsException;
boolean deleteTeam(String teamId) throws MicrosoftCredentialsException;
+ boolean archiveTeam(String teamId) throws MicrosoftCredentialsException;
+ boolean unarchiveTeam(String teamId) throws MicrosoftCredentialsException;
MicrosoftMembersCollection getTeamMembers(String id, MicrosoftUserIdentifier key) throws MicrosoftCredentialsException;
MicrosoftUser checkUserInTeam(String identifier, String teamId, MicrosoftUserIdentifier key) throws MicrosoftCredentialsException;
diff --git a/microsoft-integration/api/src/java/org/sakaiproject/microsoft/api/MicrosoftSynchronizationService.java b/microsoft-integration/api/src/java/org/sakaiproject/microsoft/api/MicrosoftSynchronizationService.java
index 65578e0dad8f..d775ea659649 100644
--- a/microsoft-integration/api/src/java/org/sakaiproject/microsoft/api/MicrosoftSynchronizationService.java
+++ b/microsoft-integration/api/src/java/org/sakaiproject/microsoft/api/MicrosoftSynchronizationService.java
@@ -28,7 +28,7 @@
public interface MicrosoftSynchronizationService {
// ------------ Site Synchronization ---------------------------
- List getAllSiteSynchronizations(boolean fillSite);
+ List getAllEnabledSiteSynchronizations(boolean fillSite);
List getFilteredSiteSynchronizations(boolean fillSite, SakaiSiteFilter filter, ZonedDateTime fromDate, ZonedDateTime toDate);
SiteSynchronization getSiteSynchronization(SiteSynchronization ss);
diff --git a/microsoft-integration/api/src/java/org/sakaiproject/microsoft/api/model/SiteSynchronization.java b/microsoft-integration/api/src/java/org/sakaiproject/microsoft/api/model/SiteSynchronization.java
index c6b06d1f7723..f785a4171f71 100644
--- a/microsoft-integration/api/src/java/org/sakaiproject/microsoft/api/model/SiteSynchronization.java
+++ b/microsoft-integration/api/src/java/org/sakaiproject/microsoft/api/model/SiteSynchronization.java
@@ -74,8 +74,9 @@ public class SiteSynchronization {
@Convert(converter = JpaConverterSynchronizationStatus.class)
private SynchronizationStatus status = SynchronizationStatus.NONE;
- @Column(name = "forced")
- private boolean forced;
+ @Column(name = "forced", nullable = false)
+ @Builder.Default
+ private boolean forced = false;
@Column(name = "date_from")
private ZonedDateTime syncDateFrom;
@@ -86,6 +87,10 @@ public class SiteSynchronization {
@Column(name = "status_updated_at")
private ZonedDateTime statusUpdatedAt;
+ @Column(name = "disabled", nullable = false)
+ @Builder.Default
+ private boolean disabled = false;
+
@OneToMany(mappedBy = "siteSynchronization", fetch = FetchType.LAZY, orphanRemoval = true)
@Cascade(CascadeType.ALL)
@OnDelete(action = OnDeleteAction.CASCADE)
diff --git a/microsoft-integration/api/src/java/org/sakaiproject/microsoft/api/persistence/MicrosoftSiteSynchronizationRepository.java b/microsoft-integration/api/src/java/org/sakaiproject/microsoft/api/persistence/MicrosoftSiteSynchronizationRepository.java
index 4c60b0500dea..10e4ab2992be 100644
--- a/microsoft-integration/api/src/java/org/sakaiproject/microsoft/api/persistence/MicrosoftSiteSynchronizationRepository.java
+++ b/microsoft-integration/api/src/java/org/sakaiproject/microsoft/api/persistence/MicrosoftSiteSynchronizationRepository.java
@@ -24,6 +24,7 @@
import org.sakaiproject.serialization.SerializableRepository;
public interface MicrosoftSiteSynchronizationRepository extends SerializableRepository {
+ List findAllEnabled();
Optional findById(String id);
Optional findBySiteTeam(String siteId, String teamId);
List findBySite(String siteId);
diff --git a/microsoft-integration/impl/src/main/java/org/sakaiproject/microsoft/impl/MicrosoftCommonServiceImpl.java b/microsoft-integration/impl/src/main/java/org/sakaiproject/microsoft/impl/MicrosoftCommonServiceImpl.java
index 72912beefe2d..299a93aba1ab 100644
--- a/microsoft-integration/impl/src/main/java/org/sakaiproject/microsoft/impl/MicrosoftCommonServiceImpl.java
+++ b/microsoft-integration/impl/src/main/java/org/sakaiproject/microsoft/impl/MicrosoftCommonServiceImpl.java
@@ -45,6 +45,7 @@
import com.google.gson.Gson;
import com.google.gson.JsonPrimitive;
import com.google.gson.JsonElement;
+import com.google.gson.JsonObject;
import com.microsoft.graph.requests.GraphServiceClient;
import com.microsoft.graph.requests.UserCollectionPage;
import com.microsoft.graph.requests.GroupCollectionPage;
@@ -147,7 +148,9 @@
import com.microsoft.graph.models.OnlineMeetingRole;
import com.microsoft.graph.models.Permission;
import com.microsoft.graph.models.PermissionGrantParameterSet;
+import com.microsoft.graph.models.Site;
import com.microsoft.graph.models.Team;
+import com.microsoft.graph.models.TeamArchiveParameterSet;
import com.microsoft.graph.models.TeamVisibilityType;
import com.microsoft.graph.models.ThumbnailSet;
import com.microsoft.graph.models.UploadSession;
@@ -1010,6 +1013,87 @@ public boolean deleteTeam(String teamId) throws MicrosoftCredentialsException {
return false;
}
+ public boolean archiveTeam(String teamId) throws MicrosoftCredentialsException {
+ try {
+ // 1. Archive team without shouldSetSpoSiteReadOnlyForMembers (no supported in app-only)
+ TeamArchiveParameterSet requestBody = TeamArchiveParameterSet
+ .newBuilder()
+ .withShouldSetSpoSiteReadOnlyForMembers(false)
+ .build();
+
+ getGraphClient().teams(teamId)
+ .archive(requestBody)
+ .buildRequest()
+ .post();
+
+ // 2. Obtain the associated SharePoint site to ensure the team is fully archived before setting it to read-only
+ Site site = getGraphClient().groups(teamId)
+ .sites("root")
+ .buildRequest()
+ .get();
+
+ if (site == null || site.id == null) {
+ log.error("Could not retrieve SharePoint site for team: {}, site will not be set to read-only", teamId);
+ return false;
+ }
+
+ // 3. Set SharePoint site to read-only (as a backup in case the archive operation did not set it correctly)
+ JsonObject jsonBody = new JsonObject();
+ jsonBody.addProperty("lockState", "readOnly");
+
+ getGraphClient().customRequest("/sites/" + site.id)
+ .buildRequest()
+ .patch(jsonBody);
+
+ log.info("Team archived and SharePoint site set to read-only: teamId={}", teamId);
+
+ return true;
+ } catch(MicrosoftCredentialsException e) {
+ throw e;
+ } catch(Exception ex){
+ log.error("Error archiving Microsoft team: id={}", teamId, ex);
+ }
+ return false;
+ }
+
+ public boolean unarchiveTeam(String teamId) throws MicrosoftCredentialsException {
+ try {
+ //1. Unarchive team
+ getGraphClient().teams(teamId)
+ .unarchive()
+ .buildRequest()
+ .post();
+
+ // 2. Obtain the associated SharePoint site to ensure the team is fully unarchived before returning
+ Site site = getGraphClient().groups(teamId)
+ .sites("root")
+ .buildRequest()
+ .get();
+
+ if (site == null || site.id == null) {
+ log.error("Could not retrieve SharePoint site for team: {}, site will remain read-only", teamId);
+ return false;
+ }
+
+ //3. Set SharePoint site to unlocked (as a backup in case the unarchive operation did not set it correctly)
+ JsonObject jsonBody = new JsonObject();
+ jsonBody.addProperty("lockState", "unlocked");
+
+ getGraphClient().customRequest("/sites/" + site.id)
+ .buildRequest()
+ .patch(jsonBody);
+
+ log.info("Team unarchived and SharePoint site unlocked: teamId={}", teamId);
+
+ return true;
+ } catch (MicrosoftCredentialsException e) {
+ throw e;
+ } catch (Exception ex) {
+ log.error("Error unarchiving Microsoft team: id={}", teamId, ex);
+ }
+ return false;
+ }
+
@Override
public MicrosoftMembersCollection getTeamMembers(String id, MicrosoftUserIdentifier key) throws MicrosoftCredentialsException {
MicrosoftMembersCollection ret = new MicrosoftMembersCollection();
diff --git a/microsoft-integration/impl/src/main/java/org/sakaiproject/microsoft/impl/MicrosoftSynchronizationServiceImpl.java b/microsoft-integration/impl/src/main/java/org/sakaiproject/microsoft/impl/MicrosoftSynchronizationServiceImpl.java
index f48dd8cd2f3f..3fbe93506a71 100644
--- a/microsoft-integration/impl/src/main/java/org/sakaiproject/microsoft/impl/MicrosoftSynchronizationServiceImpl.java
+++ b/microsoft-integration/impl/src/main/java/org/sakaiproject/microsoft/impl/MicrosoftSynchronizationServiceImpl.java
@@ -197,8 +197,8 @@ private boolean checkTeam(String teamId) throws MicrosoftCredentialsException {
}
@Override
- public List getAllSiteSynchronizations(boolean fillSite) {
- List result = StreamSupport.stream(microsoftSiteSynchronizationRepository.findAll().spliterator(), false)
+ public List getAllEnabledSiteSynchronizations(boolean fillSite) {
+ List result = StreamSupport.stream(microsoftSiteSynchronizationRepository.findAllEnabled().spliterator(), false)
.map(ss -> {
if(fillSite) {
ss.setSite(sakaiProxy.getSite(ss.getSiteId()));
diff --git a/microsoft-integration/impl/src/main/java/org/sakaiproject/microsoft/impl/jobs/RunSynchronizationsJob.java b/microsoft-integration/impl/src/main/java/org/sakaiproject/microsoft/impl/jobs/RunSynchronizationsJob.java
index 05773c6fcc93..2aa50636df6e 100644
--- a/microsoft-integration/impl/src/main/java/org/sakaiproject/microsoft/impl/jobs/RunSynchronizationsJob.java
+++ b/microsoft-integration/impl/src/main/java/org/sakaiproject/microsoft/impl/jobs/RunSynchronizationsJob.java
@@ -81,7 +81,7 @@ public void execute(JobExecutionContext context) throws JobExecutionException {
session.setAttribute("origin", MicrosoftLogInvokers.JOB.getCode());
SakaiSiteFilter siteFilter = microsoftConfigurationService.getJobSiteFilter();
- List list = microsoftSynchronizationService.getAllSiteSynchronizations(true);
+ List list = microsoftSynchronizationService.getAllEnabledSiteSynchronizations(true);
for(SiteSynchronization ss : list) {
int retryCount = 0;
while (retryCount < MAX_RETRIES) {
@@ -91,7 +91,7 @@ public void execute(JobExecutionContext context) throws JobExecutionException {
}
if(ss.getSite() == null || !siteFilter.match(ss.getSite())) {
- log.debug("Site with id={} skipped due to filter restrinctions", ss.getSiteId());
+ log.debug("Site with id={} skipped due to filter restrictions", ss.getSiteId());
break;
}
diff --git a/microsoft-integration/impl/src/main/java/org/sakaiproject/microsoft/impl/persistence/MicrosoftSiteSynchronizationRepositoryImpl.java b/microsoft-integration/impl/src/main/java/org/sakaiproject/microsoft/impl/persistence/MicrosoftSiteSynchronizationRepositoryImpl.java
index d354e515bdaf..10cfd01385c3 100644
--- a/microsoft-integration/impl/src/main/java/org/sakaiproject/microsoft/impl/persistence/MicrosoftSiteSynchronizationRepositoryImpl.java
+++ b/microsoft-integration/impl/src/main/java/org/sakaiproject/microsoft/impl/persistence/MicrosoftSiteSynchronizationRepositoryImpl.java
@@ -46,6 +46,14 @@ public List findAll(){
.list();
}
+ @Override
+ public List findAllEnabled() {
+ return (List) startCriteriaQuery()
+ .addOrder(Order.asc("status"))
+ .add(Restrictions.eq("disabled", false))
+ .list();
+ }
+
@Override
public Optional findById(String id) {
SiteSynchronization siteSynchronization = (SiteSynchronization) startCriteriaQuery().add(Restrictions.eq("id", id)).uniqueResult();
diff --git a/site-manage/site-manage-tool/tool/pom.xml b/site-manage/site-manage-tool/tool/pom.xml
index 297dc1be2810..22d67e7f0578 100644
--- a/site-manage/site-manage-tool/tool/pom.xml
+++ b/site-manage/site-manage-tool/tool/pom.xml
@@ -176,6 +176,10 @@
org.sakaiproject.scheduler
scheduler-api
+
+ org.sakaiproject.microsoft
+ microsoft-api
+
diff --git a/site-manage/site-manage-tool/tool/src/bundle/sitesetupgeneric.properties b/site-manage/site-manage-tool/tool/src/bundle/sitesetupgeneric.properties
index c3dff66325ac..ef007e3c85ac 100644
--- a/site-manage/site-manage-tool/tool/src/bundle/sitesetupgeneric.properties
+++ b/site-manage/site-manage-tool/tool/src/bundle/sitesetupgeneric.properties
@@ -1152,3 +1152,9 @@ sinfo.gradebookgroupvnav.site=A single site instance
sinfo.gradebookgroupvnav.group=A different instance for the selected group(s)
sinfo.gradebookgroupvnav.selectGroups=Select one or more groups
sinfo.gradebookgroupvnav.noGroupsPresent=Note - There are currently no groups present in this site. Create groups in Site Info to enable additional options.
+
+# SAK-52355
+sinfo.synchronization.microsoft.name=Synchronization with Microsoft Teams
+sinfo.synchronization.microsoft.allowForSite=Enable forced synchronization with Microsoft Teams for this site.
+sinfo.synchronization.microsoft.confirmEnabled=You have enabled forced synchronization with Microsoft Teams for this site.
+sinfo.synchronization.microsoft.enabled=Enabled for the site
diff --git a/site-manage/site-manage-tool/tool/src/bundle/sitesetupgeneric_es.properties b/site-manage/site-manage-tool/tool/src/bundle/sitesetupgeneric_es.properties
index 5b537bf80fe4..b1208ed619c4 100644
--- a/site-manage/site-manage-tool/tool/src/bundle/sitesetupgeneric_es.properties
+++ b/site-manage/site-manage-tool/tool/src/bundle/sitesetupgeneric_es.properties
@@ -1124,3 +1124,9 @@ sinfo.gradebookgroupvnav.site=Una instancia de sitio
sinfo.gradebookgroupvnav.group=Una instancia diferente para cada grupo seleccionado
sinfo.gradebookgroupvnav.selectGroups=Nota - Debes seleccionar al menos un grupo
sinfo.gradebookgroupvnav.noGroupsPresent=Nota - Actualmente no hay grupos en este sitio. Debe crear grupos antes de poder asignar la tarea a un grupo concreto.
+
+# SAK-52355
+sinfo.synchronization.microsoft.name=Sincronizaci\u00f3n con Microsoft Teams
+sinfo.synchronization.microsoft.allowForSite=Habilitar la sincronizaci\u00f3n forzada con Microsoft Teams para este sitio.
+sinfo.synchronization.microsoft.confirmEnabled=Has habilitado la sincronizaci\u00f3n forzada con Microsoft Teams para este sitio.
+sinfo.synchronization.microsoft.enabled=Activado para este sitio
diff --git a/site-manage/site-manage-tool/tool/src/java/org/sakaiproject/site/tool/MicrosoftSynchronizationEnabler.java b/site-manage/site-manage-tool/tool/src/java/org/sakaiproject/site/tool/MicrosoftSynchronizationEnabler.java
new file mode 100644
index 000000000000..238a6cb38552
--- /dev/null
+++ b/site-manage/site-manage-tool/tool/src/java/org/sakaiproject/site/tool/MicrosoftSynchronizationEnabler.java
@@ -0,0 +1,346 @@
+/**
+ * Copyright (c) 2003-2017 The Apereo Foundation
+ *
+ * Licensed under the Educational Community License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://opensource.org/licenses/ecl2
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.sakaiproject.site.tool;
+
+import java.time.ZonedDateTime;
+import java.util.Arrays;
+import java.util.List;
+
+import lombok.extern.slf4j.Slf4j;
+
+import org.sakaiproject.cheftool.Context;
+import org.sakaiproject.component.cover.ComponentManager;
+import org.sakaiproject.component.cover.ServerConfigurationService;
+import org.sakaiproject.entity.api.ResourcePropertiesEdit;
+import org.sakaiproject.event.api.SessionState;
+import org.sakaiproject.microsoft.api.MicrosoftCommonService;
+import org.sakaiproject.microsoft.api.MicrosoftConfigurationService;
+import org.sakaiproject.microsoft.api.MicrosoftSynchronizationService;
+import org.sakaiproject.microsoft.api.data.MicrosoftCredentials;
+import org.sakaiproject.microsoft.api.exceptions.MicrosoftCredentialsException;
+import org.sakaiproject.microsoft.api.model.SiteSynchronization;
+import org.sakaiproject.site.api.Site;
+import org.sakaiproject.util.ParameterParser;
+
+@Slf4j
+public class MicrosoftSynchronizationEnabler {
+
+ private static final String SITE_PROPERTY = "microsoftSynchronization";
+ private static final String STATE_KEY = "isMicrosoftSynchronizationEnabled";
+ private static final String FORM_INPUT_ID = "isMicrosoftSynchronizationEnabled";
+ private static final String CONTEXT_ENABLED_KEY = "isMicrosoftSynchronizationEnabled";
+ private static final String CONTEXT_ALLOWED_KEY = "isMicrosoftSynchronizationAllowed";
+
+ private static final String SAKAI_PROPERTY_SITETYPE = "microsoft.forced.synchronization.sitetype";
+ private static final String SAKAI_PROPERTY_SITE_PROPERTY_NAME_AND_OPTIONAL_VALUE = "microsoft.forced.synchronization.propertynameandoptionalvalue";
+
+ /**
+ * Add MicrosoftSynchronization settings to the context for the edit tools page.
+ * The checkbox will only be available if the site type is allowed in sakai.properties.
+ *
+ * @param context the context
+ * @param site the site
+ * @return true if context was modified
+ */
+ public static boolean addToContext(Context context, Site site) {
+ if (context == null || site == null) return false;
+
+ final boolean isAllowed = isSiteTypeAllowed(site);
+ context.put(CONTEXT_ALLOWED_KEY, isAllowed);
+
+ if (isAllowed) {
+ context.put(CONTEXT_ENABLED_KEY, isEnabledForSite(site));
+ }
+
+ return true;
+ }
+
+ /**
+ * Applies the MicrosoftSynchronization settings to the state.
+ * Only applies if the site type is allowed in sakai.properties.
+ *
+ * @param state the state
+ * @param params the params
+ * @param site the site
+ * @return true if the state was modified
+ */
+ public static boolean applySettingsToState(SessionState state, ParameterParser params, Site site) {
+ if (state == null || params == null || site == null) return false;
+
+ if (!isSiteTypeAllowed(site)) {
+ return false;
+ }
+
+ if ("on".equalsIgnoreCase(params.getString(FORM_INPUT_ID))) {
+ state.setAttribute(STATE_KEY, true);
+ } else {
+ state.setAttribute(STATE_KEY, false);
+ }
+
+ return true;
+ }
+
+ /**
+ * Add the current MicrosoftSynchronization state to the context for the edit tools confirmation page.
+ *
+ * @param context the context
+ * @param state the state
+ * @return true if the context was modified
+ */
+ public static boolean addStateToEditToolsConfirmationContext(Context context, SessionState state) {
+ if (context == null || state == null) {
+ return false;
+ }
+
+ final Boolean isEnabled = (Boolean) state.getAttribute(STATE_KEY);
+ if (Boolean.TRUE.equals(isEnabled)) {
+ context.put(CONTEXT_ENABLED_KEY, Boolean.TRUE);
+ return true;
+ }
+
+ return false;
+ }
+
+ /**
+ * When user selects to enable MicrosoftSynchronization, update the site property
+ * and create the Microsoft Team + SiteSynchronization if it was just enabled.
+ * Only persists if the site type is allowed in sakai.properties.
+ *
+ * @param site the site
+ * @param state the session state
+ * @return true if the site properties were modified
+ */
+ public static boolean prepareSiteForSave(Site site, SessionState state) {
+ if (site == null || state == null) return false;
+
+ if (!isSiteTypeAllowed(site)) {
+ return false;
+ }
+
+ if (state.getAttribute(STATE_KEY) != null) {
+ final boolean isEnabled = (Boolean) state.getAttribute(STATE_KEY);
+ final ResourcePropertiesEdit props = site.getPropertiesEdit();
+ final String[] nameAndValueForSynchronization = getSitePropertyNameAndOptionalValueForSynchronization();
+ MicrosoftCommonService microsoftCommonService = (MicrosoftCommonService) ComponentManager.get(MicrosoftCommonService.class);
+ MicrosoftSynchronizationService microsoftSynchronizationService = (MicrosoftSynchronizationService) ComponentManager.get(MicrosoftSynchronizationService.class);
+
+ if (isEnabled) {
+ props.addProperty(SITE_PROPERTY, Boolean.TRUE.toString());
+
+ MicrosoftConfigurationService microsoftConfigurationService = (MicrosoftConfigurationService) ComponentManager.get(MicrosoftConfigurationService.class);
+
+ // Check if MicrosoftSynchronization was already enabled for the site before creating/restoring the Team
+ createOrRestoreMicrosoftTeamForSite(site, microsoftCommonService, microsoftConfigurationService, microsoftSynchronizationService);
+
+ // Add site property defined in microsoft.forced.synchronization.propertynameandoptionalvalue
+ if (nameAndValueForSynchronization.length > 0) {
+ props.addProperty(nameAndValueForSynchronization[0], nameAndValueForSynchronization[1]);
+ log.debug("Added site property {}={} for site: {}", nameAndValueForSynchronization[0], nameAndValueForSynchronization[1], site.getId());
+ }
+ } else {
+ props.removeProperty(SITE_PROPERTY);
+
+ // If a SiteSynchronization exists with a Team, archive it and delete it
+ if (microsoftSynchronizationService != null && microsoftCommonService != null) {
+ List ssList = microsoftSynchronizationService.getSiteSynchronizationsBySite(site.getId());
+ if (ssList != null) {
+ for (SiteSynchronization ss : ssList) {
+ if (ss.getTeamId() != null && !ss.getTeamId().isEmpty()) {
+ try {
+ if (microsoftCommonService.archiveTeam(ss.getTeamId())) {
+ log.info("Microsoft Team archived for site: {}, teamId: {}", site.getId(), ss.getTeamId());
+ }
+
+ // Remove site property defined in microsoft.forced.synchronization.propertynameandoptionalvalue
+ if (nameAndValueForSynchronization.length > 0) {
+ props.removeProperty(nameAndValueForSynchronization[0]);
+ log.debug("Removed site property {} for site: {}", nameAndValueForSynchronization[0], site.getId());
+ }
+ } catch (Exception e) {
+ log.warn("Could not archive Microsoft Team for site: {}, teamId: {}, {}", site.getId(), ss.getTeamId(), e.toString());
+ }
+ }
+
+ ss.setDisabled(true);
+ microsoftSynchronizationService.saveOrUpdateSiteSynchronization(ss);
+ log.info("SiteSynchronization disabled for site: {}", site.getId());
+ }
+ }
+ }
+ }
+ }
+
+ return true;
+ }
+
+ /**
+ * Remove MicrosoftSynchronization from the state.
+ *
+ * @param state the state
+ * @return true if the state was modified
+ */
+ public static boolean removeFromState(SessionState state) {
+ if (state != null) {
+ state.removeAttribute(STATE_KEY);
+ return true;
+ }
+
+ return false;
+ }
+
+ /**
+ * Creates or restores a Microsoft Team for the given site and saves the SiteSynchronization.
+ * If a SiteSynchronization already exists for the site, the associated Team is unarchived and restored.
+ * Otherwise, a new Team is created and a new SiteSynchronization is saved.
+ *
+ * @param site the site
+ * @param microsoftCommonService the Microsoft common service
+ * @param microsoftConfigurationService the Microsoft configuration service
+ * @param microsoftSynchronizationService the Microsoft synchronization service
+ */
+ private static void createOrRestoreMicrosoftTeamForSite(
+ Site site,
+ MicrosoftCommonService microsoftCommonService,
+ MicrosoftConfigurationService microsoftConfigurationService,
+ MicrosoftSynchronizationService microsoftSynchronizationService) {
+
+ if (microsoftCommonService == null || microsoftConfigurationService == null || microsoftSynchronizationService == null) {
+ log.warn("One or more Microsoft services are null, cannot create team for site: {}", site.getId());
+ return;
+ }
+
+ try {
+ final MicrosoftCredentials credentials = microsoftConfigurationService.getCredentials();
+
+ if (credentials == null || credentials.getEmail() == null) {
+ log.warn("Could not resolve Microsoft credentials email for site: {}, team will not be created", site.getId());
+ return;
+ }
+
+ final long syncDuration = microsoftConfigurationService.getSyncDuration();
+ final ZonedDateTime syncDateFrom = ZonedDateTime.now();
+ final ZonedDateTime syncDateTo = ZonedDateTime.now().plusMonths(syncDuration);
+
+ List existingSsList = microsoftSynchronizationService.getSiteSynchronizationsBySite(site.getId());
+ if (existingSsList != null && !existingSsList.isEmpty()) {
+ // Team already existed before — unarchive it and restore the SiteSynchronization
+ for (SiteSynchronization existingSs : existingSsList) {
+ if (existingSs.getTeamId() != null && !existingSs.getTeamId().isEmpty()) {
+ try {
+ if (!microsoftCommonService.unarchiveTeam(existingSs.getTeamId())) {
+ log.error("Could not unarchive Microsoft Team for site: {}, teamId: {}, skipping state update", site.getId(), existingSs.getTeamId());
+ continue;
+ }
+ } catch (Exception e) {
+ log.error("Could not unarchive Microsoft Team for site: {}, teamId: {}, {}", site.getId(), existingSs.getTeamId(), e.toString());
+ continue;
+ }
+ }
+ // Only reached if unarchiveTeam succeeded
+ existingSs.setSyncDateFrom(syncDateFrom);
+ existingSs.setSyncDateTo(syncDateTo);
+ existingSs.setDisabled(false);
+ microsoftSynchronizationService.saveOrUpdateSiteSynchronization(existingSs);
+ log.info("SiteSynchronization restored for site: {}, teamId: {}", site.getId(), existingSs.getTeamId());
+ }
+ } else {
+ // No existing SiteSynchronization — create a new Team and SiteSynchronization
+ final String teamId;
+ try {
+ teamId = microsoftCommonService.createTeam(site.getTitle(), credentials.getEmail());
+ } catch (Exception e) {
+ log.error("Microsoft Team creation failed for site: {}, {}", site.getId(), e.toString());
+ return;
+ }
+
+ if (teamId == null || teamId.isEmpty()) {
+ log.warn("Microsoft Team creation returned null or empty teamId for site: {}", site.getId());
+ return;
+ }
+
+ final SiteSynchronization ss = SiteSynchronization.builder()
+ .siteId(site.getId())
+ .teamId(teamId)
+ .forced(true)
+ .syncDateFrom(syncDateFrom)
+ .syncDateTo(syncDateTo)
+ .build();
+
+ microsoftSynchronizationService.saveOrUpdateSiteSynchronization(ss);
+ log.info("Microsoft Team created and SiteSynchronization saved: teamId={}, siteId={}", teamId, site.getId());
+ }
+ } catch (Exception e) {
+ log.error("Unexpected error while creating Microsoft Team for site: {}, {}", site.getId(), e.toString());
+ }
+ }
+
+ /**
+ * Check the site's properties for the MicrosoftSynchronization property.
+ *
+ * @param site the site to check
+ * @return true if MicrosoftSynchronization is enabled for the site
+ */
+ private static boolean isEnabledForSite(Site site) {
+ return Boolean.parseBoolean(site.getProperties().getProperty(SITE_PROPERTY));
+ }
+
+ /**
+ * Check whether the site's type is allowed to use MicrosoftSynchronization,
+ * based on the "microsoft.forced.synchronization.sitetype" property in sakai.properties.
+ * Use "all" to allow all site types, or a comma-separated list (e.g. "course,project").
+ *
+ * @param site the site to check
+ * @return true if the site type is allowed
+ */
+ private static boolean isSiteTypeAllowed(Site site) {
+ final String configValue = ServerConfigurationService.getString(SAKAI_PROPERTY_SITETYPE, "").trim();
+
+ if (configValue.isEmpty()) {
+ return false;
+ }
+
+ if ("all".equalsIgnoreCase(configValue)) {
+ return true;
+ }
+
+ final String siteType = site.getType();
+ if (siteType == null || siteType.isEmpty()) {
+ return false;
+ }
+
+ final List allowedTypes = Arrays.asList(configValue.split("\\s*,\\s*"));
+ return allowedTypes.contains(siteType);
+ }
+
+ /**
+ * Reads the "microsoft.forced.synchronization.propertynameandoptionalvalue" sakai.property.
+ * Supports two formats:
+ * - "propertyName=propertyValue" (e.g. "microsoft.synchronized=true")
+ * - "propertyName" (e.g. "microsoft.synchronized"), in which case value will be empty string
+ *
+ * @return a two-element array [propertyName, propertyValue], or null if not defined or blank
+ */
+ private static String[] getSitePropertyNameAndOptionalValueForSynchronization() {
+ final String configValue = ServerConfigurationService.getString(SAKAI_PROPERTY_SITE_PROPERTY_NAME_AND_OPTIONAL_VALUE, null);
+ if (configValue == null || configValue.isBlank()) {
+ return new String[0];
+ }
+ if (configValue.contains("=")) {
+ return configValue.split("=", 2);
+ }
+ return new String[] { configValue.trim(), "" };
+ }
+}
\ No newline at end of file
diff --git a/site-manage/site-manage-tool/tool/src/java/org/sakaiproject/site/tool/SiteAction.java b/site-manage/site-manage-tool/tool/src/java/org/sakaiproject/site/tool/SiteAction.java
index cd2180d4ac4a..cbc430797afc 100644
--- a/site-manage/site-manage-tool/tool/src/java/org/sakaiproject/site/tool/SiteAction.java
+++ b/site-manage/site-manage-tool/tool/src/java/org/sakaiproject/site/tool/SiteAction.java
@@ -1312,6 +1312,8 @@ private void cleanState(SessionState state) {
SubNavEnabler.removeFromState(state);
+ MicrosoftSynchronizationEnabler.removeFromState(state);
+
state.removeAttribute(STATE_CREATE_FROM_ARCHIVE);
} // cleanState
@@ -1860,6 +1862,7 @@ private String buildContextForTemplate(String preIndex, int index, VelocityPortl
MathJaxEnabler.addMathJaxSettingsToEditToolsContext(context, site, state); // SAK-22384
SubNavEnabler.addToContext(context, site);
+ MicrosoftSynchronizationEnabler.addToContext(context, site);
context.put("SiteTitle", site.getTitle());
context.put("existSite", Boolean.TRUE);
context.put("backIndex", SITE_INFO_TEMPLATE_INDEX); // back to site info list page
@@ -2426,6 +2429,7 @@ else if (state.getAttribute(STATE_CM_REQUESTED_SECTIONS) != null) {
MathJaxEnabler.addMathJaxSettingsToSiteInfoContext(context, site, state);
context.put("isGradebookGroupEnabledForSite", GradebookGroupEnabler.isEnabledForSite(site));
SubNavEnabler.addToContext(context, site);
+ MicrosoftSynchronizationEnabler.addToContext(context, site);
return (String) getContext(data).get("template") + TEMPLATE[12];
@@ -2586,6 +2590,8 @@ else if (state.getAttribute(STATE_CM_REQUESTED_SECTIONS) != null) {
SubNavEnabler.addToContext(context, site);
+ MicrosoftSynchronizationEnabler.addToContext(context, site);
+
return (String) getContext(data).get("template") + TEMPLATE[13];
case 14:
/*
@@ -2656,6 +2662,7 @@ else if (state.getAttribute(STATE_CM_REQUESTED_SECTIONS) != null) {
// SAK-22384 mathjax support
MathJaxEnabler.addMathJaxSettingsToSiteInfoContext(context, site, state);
SubNavEnabler.addToContext(context, site);
+ MicrosoftSynchronizationEnabler.addToContext(context, site);
return (String) getContext(data).get("template") + TEMPLATE[14];
case 15:
@@ -2679,6 +2686,7 @@ else if (state.getAttribute(STATE_CM_REQUESTED_SECTIONS) != null) {
MathJaxEnabler.addMathJaxSettingsToEditToolsConfirmationContext(context, site, state, STATE_TOOL_REGISTRATION_TITLE_LIST); // SAK-22384
GradebookGroupEnabler.addSettingsToEditToolsConfirmationContext(context, site, state);
SubNavEnabler.addStateToEditToolsConfirmationContext(context, state);
+ MicrosoftSynchronizationEnabler.addStateToEditToolsConfirmationContext(context, state);
return (String) getContext(data).get("template") + TEMPLATE[15];
case 18:
@@ -8018,6 +8026,7 @@ public void doCancel(RunData data) {
state.setAttribute(STATE_TEMPLATE_INDEX, SiteConstants.SITE_INFO_TEMPLATE_INDEX);
GradebookGroupEnabler.removeFromState(state);
SubNavEnabler.removeFromState(state);
+ MicrosoftSynchronizationEnabler.removeFromState(state);
} else if ("15".equals(currentIndex)) {
params = data.getParameters();
state.setAttribute(STATE_TEMPLATE_INDEX, params
@@ -8881,6 +8890,8 @@ public void doSave_siteInfo(RunData data) {
SubNavEnabler.prepareSiteForSave(Site, state);
+ MicrosoftSynchronizationEnabler.prepareSiteForSave(Site, state);
+
if (state.getAttribute(STATE_MESSAGE) == null) {
try {
siteService.save(Site);
@@ -12289,6 +12300,7 @@ else if (multiAllowed && toolId.startsWith(toolRegId))
updateSite = MathJaxEnabler.prepareMathJaxToolSettingsForSave(site, state);
updateSite = GradebookGroupEnabler.prepareSiteForSave(site, state) || updateSite;
updateSite = SubNavEnabler.prepareSiteForSave(site, state) || updateSite;
+ updateSite = MicrosoftSynchronizationEnabler.prepareSiteForSave(site, state) || updateSite;
if (updateSite) {
commitSite(site);
}
@@ -13174,6 +13186,7 @@ private void removeEditToolState(SessionState state) {
state.removeAttribute(STATE_TOOL_REGISTRATION_SELECTED_LIST);
GradebookGroupEnabler.removeFromState(state);
SubNavEnabler.removeFromState(state);
+ MicrosoftSynchronizationEnabler.removeFromState(state);
}
private List orderToolIds(SessionState state, String type, List toolIdList, boolean synoptic) {
@@ -13382,6 +13395,7 @@ public void doAdd_features(RunData data) {
// continue
MathJaxEnabler.applySettingsToState(state, params); // SAK-22384
SubNavEnabler.applySettingsToState(state, params);
+ MicrosoftSynchronizationEnabler.applySettingsToState(state, params, site);
doContinue(data);
} else if (option.equalsIgnoreCase("back")) {
@@ -15927,6 +15941,9 @@ else if ("continue".equals(option))
// continue with site information edit
MathJaxEnabler.applySettingsToState(state, params); // SAK-22384
+ SubNavEnabler.applySettingsToState(state, params);
+ Site site = getStateSite(state);
+ MicrosoftSynchronizationEnabler.applySettingsToState(state, params, site);
doContinue(data);
}
diff --git a/site-manage/site-manage-tool/tool/src/webapp/vm/sitesetup/chef_site-addRemoveFeatureConfirm.vm b/site-manage/site-manage-tool/tool/src/webapp/vm/sitesetup/chef_site-addRemoveFeatureConfirm.vm
index 992fecb16233..55aa8cd96dc6 100644
--- a/site-manage/site-manage-tool/tool/src/webapp/vm/sitesetup/chef_site-addRemoveFeatureConfirm.vm
+++ b/site-manage/site-manage-tool/tool/src/webapp/vm/sitesetup/chef_site-addRemoveFeatureConfirm.vm
@@ -246,6 +246,12 @@
#end
+ #if($!isMicrosoftSynchronizationEnabled)
+
+ $tlang.getString("sinfo.synchronization.microsoft.confirmEnabled")
+
+ #end
+