001/* 002 * Copyright 2025 Anyware Services 003 * 004 * Licensed under the Apache License, Version 2.0 (the "License"); 005 * you may not use this file except in compliance with the License. 006 * You may obtain a copy of the License at 007 * 008 * http://www.apache.org/licenses/LICENSE-2.0 009 * 010 * Unless required by applicable law or agreed to in writing, software 011 * distributed under the License is distributed on an "AS IS" BASIS, 012 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 013 * See the License for the specific language governing permissions and 014 * limitations under the License. 015 */ 016package org.ametys.odf.workflow; 017 018import java.util.ArrayList; 019import java.util.List; 020import java.util.Map; 021import java.util.Set; 022import java.util.stream.Collectors; 023 024import org.apache.avalon.framework.service.ServiceException; 025import org.apache.avalon.framework.service.ServiceManager; 026import org.apache.commons.lang3.StringUtils; 027 028import org.ametys.cms.data.type.impl.BooleanRepositoryElementType; 029import org.ametys.cms.repository.Content; 030import org.ametys.cms.repository.ModifiableContent; 031import org.ametys.cms.workflow.EditContentFunction; 032import org.ametys.core.right.RightManager; 033import org.ametys.core.right.RightManager.RightResult; 034import org.ametys.odf.EducationalPathHelper; 035import org.ametys.odf.data.EducationalPath; 036import org.ametys.odf.rights.ODFRightHelper.ContextualizedContent; 037import org.ametys.plugins.repository.data.holder.values.SynchronizableRepeater; 038import org.ametys.plugins.repository.data.holder.values.SynchronizableValue; 039import org.ametys.plugins.repository.model.RepeaterDefinition; 040import org.ametys.plugins.repository.model.ViewHelper; 041import org.ametys.runtime.model.ModelHelper; 042import org.ametys.runtime.model.ModelItem; 043import org.ametys.runtime.model.ModelItemGroup; 044import org.ametys.runtime.model.View; 045import org.ametys.runtime.model.ViewItemContainer; 046import org.ametys.runtime.model.disableconditions.AbstractRelativeDisableCondition; 047import org.ametys.runtime.model.disableconditions.DisableCondition; 048import org.ametys.runtime.model.disableconditions.DisableConditions; 049import org.ametys.runtime.model.type.DataContext; 050 051import com.opensymphony.module.propertyset.PropertySet; 052import com.opensymphony.workflow.WorkflowException; 053 054/** 055 * Edit content function restricted to contextualized data, ie. data into repeater with educational path 056 * This function allows to edit data by educational path for which the user is responsible. 057 */ 058public class EditContextualizedDataFunction extends EditContentFunction 059{ 060 /** The workflow action id to edit repeater with educational path only */ 061 public static final int EDIT_WORKFLOW_ACTION_ID = 20; 062 063 /** Name of educational path attribute in a repeater with education path */ 064 public static final String REPEATER_EDUCATIONAL_PATH_ATTRIBUTE_NAME = "path"; 065 /** Name of common attribute in a repeater with education path (means that entry is common to all educational paths)*/ 066 public static final String REPEATER_COMMON_ATTRIBUTE_NAME = "common"; 067 068 private EducationalPathHelper _educationalPathHelper; 069 private RightManager _rightManager; 070 private String _rightId; 071 072 @Override 073 public void service(ServiceManager smanager) throws ServiceException 074 { 075 super.service(smanager); 076 _educationalPathHelper = (EducationalPathHelper) smanager.lookup(EducationalPathHelper.ROLE); 077 _rightManager = (RightManager) smanager.lookup(RightManager.ROLE); 078 } 079 080 @Override 081 public void execute(Map transientVars, Map args, PropertySet ps) throws WorkflowException 082 { 083 _rightId = (String) args.get("right"); 084 super.execute(transientVars, args, ps); 085 } 086 087 @Override 088 protected boolean canWriteModelItem(ModelItem modelItem, Content content, Map transientVars) 089 { 090 // Only repeater with educational path and its child model items are writable 091 // Always enable boolean "common" item into a repeater with educational path (whatever model item's restrictions) 092 return _inRepeaterWithEducationalPath(modelItem) && (_isCommonBooleanItem(modelItem) || super.canWriteModelItem(modelItem, content, transientVars)); 093 } 094 095 private boolean _isCommonBooleanItem(ModelItem modelItem) 096 { 097 return modelItem.getType() instanceof BooleanRepositoryElementType 098 && modelItem.getName().equals(REPEATER_COMMON_ATTRIBUTE_NAME) 099 && modelItem.getParent() != null 100 && modelItem.getParent() instanceof RepeaterDefinition; 101 } 102 103 private boolean _inRepeaterWithEducationalPath(ModelItem modelItem) 104 { 105 if (modelItem instanceof RepeaterDefinition repeaterDefinition && _educationalPathHelper.isRepeaterWithEducationalPath(repeaterDefinition)) 106 { 107 return true; 108 } 109 110 ModelItemGroup parentModelItem = modelItem.getParent(); 111 return parentModelItem != null ? _inRepeaterWithEducationalPath(parentModelItem) : false; 112 } 113 114 @Override 115 protected Map<String, Object> parseValues(View view, ModifiableContent content, Map<String, Object> rawValues, boolean localOnly, Map transientVars) 116 { 117 Map<String, Object> values = super.parseValues(view, content, rawValues, localOnly, transientVars); 118 119 _filterAndUpdateRepeaterWithPathValues(content, view, values); 120 121 return values; 122 } 123 124 @Override 125 protected Map<String, Object> convertValues(ModifiableContent content, View view, Map<String, Object> values, Map transientVars) 126 { 127 Map<String, Object> convertValues = super.convertValues(content, view, values, transientVars); 128 129 _filterAndUpdateRepeaterWithPathValues(content, view, convertValues); 130 131 return convertValues; 132 } 133 134 @SuppressWarnings("unchecked") 135 private void _filterAndUpdateRepeaterWithPathValues(Content content, ViewItemContainer viewItemContainer, Map<String, Object> values) 136 { 137 ViewHelper.visitView(viewItemContainer, 138 (element, definition) -> { 139 // nothing 140 }, 141 (group, definition) -> { 142 // composite 143 String name = definition.getName(); 144 Map<String, Object> composite = (Map<String, Object>) values.get(name); 145 if (composite != null) 146 { 147 _filterAndUpdateRepeaterWithPathValues(content, group, composite); 148 } 149 }, 150 (group, definition) -> { 151 // repeater 152 String name = definition.getName(); 153 154 SynchronizableRepeater repeaterValue = (SynchronizableRepeater) values.get(name); 155 List<Map<String, Object>> entries = ((SynchronizableRepeater) values.get(name)).getEntries(); 156 if (entries != null) 157 { 158 if (_educationalPathHelper.isRepeaterWithEducationalPath(definition)) 159 { 160 List<Map<String, Object>> storedEntries = _getRepeaterEntries(content, definition); 161 values.put(name, _updateRepeaterWithPathValues(content, repeaterValue, entries, storedEntries, definition)); 162 } 163 else 164 { 165 for (Map<String, Object> entry : entries) 166 { 167 _filterAndUpdateRepeaterWithPathValues(content, group, entry); 168 } 169 } 170 } 171 }, 172 group -> _filterAndUpdateRepeaterWithPathValues(content, group, values)); 173 } 174 175 // Update repeater values recursively. Only entries with allowed path will be really updated 176 private List<Map<String, Object>> _updateRepeaterWithPathValues(Content content, SynchronizableRepeater repeaterValue, List<Map<String, Object>> entryValues, List<Map<String, Object>> storedEntries, RepeaterDefinition repeaterDefinition) 177 { 178 List<Map<String, Object>> updatedValues = new ArrayList<>(); 179 180 if (storedEntries != null && !storedEntries.isEmpty()) 181 { 182 // Get common entries and entries with unauthorized path from content 183 int entryPos = 1; 184 for (Map<String, Object> storedEntry : storedEntries) 185 { 186 boolean commonEntry = _isCommonEntry(repeaterDefinition, storedEntry); 187 EducationalPath educationalPath = (EducationalPath) storedEntry.get(REPEATER_EDUCATIONAL_PATH_ATTRIBUTE_NAME); 188 189 if (commonEntry || educationalPath == null) 190 { 191 // Get the names of sub repeaters if exists 192 List<String> subRepeaterNames = repeaterDefinition 193 .getModelItems() 194 .stream() 195 .filter(i -> i instanceof RepeaterDefinition) 196 .map(ModelItem::getName) 197 .toList(); 198 199 // Get values from stored entry (user not authorized to update them), except from sub-repeaters 200 Map<String, Object> updatedEntryValues = storedEntry 201 .entrySet() 202 .stream() 203 .filter(e -> !subRepeaterNames.contains(e.getKey())) 204 .collect(Collectors.toMap(e -> e.getKey(), e -> e.getValue())); 205 206 // Get repeater entry values from client side 207 int newPos = repeaterValue.getPositionsMapping().get(entryPos); 208 Map<String, Object> entryValue = entryValues.get(newPos - 1); 209 210 // Update sub-repeater values recursively 211 for (String subRepeaterName : subRepeaterNames) 212 { 213 RepeaterDefinition subRepeaterDefinition = (RepeaterDefinition) repeaterDefinition.getModelItem(subRepeaterName); 214 SynchronizableRepeater subRepeaterValue = (SynchronizableRepeater) entryValue.get(subRepeaterName); 215 216 @SuppressWarnings("unchecked") 217 List<Map<String, Object>> storedSubEntries = (List<Map<String, Object>>) storedEntry.get(subRepeaterName); 218 219 updatedEntryValues.put(subRepeaterName, _updateRepeaterWithPathValues(content, subRepeaterValue, subRepeaterValue.getEntries(), storedSubEntries, subRepeaterDefinition)); 220 } 221 222 updatedValues.add(updatedEntryValues); 223 } 224 else if (_rightManager.currentUserHasRight(_rightId, new ContextualizedContent(content, educationalPath)) != RightResult.RIGHT_ALLOW) 225 { 226 // User is not allowed to update this entry => get value from storage 227 updatedValues.add(storedEntry); 228 } 229 230 entryPos++; 231 } 232 } 233 234 // Get from client only entries with authorized path and force common attribute to false 235 for (Map<String, Object> entryValue : entryValues) 236 { 237 if (entryValue.containsKey(REPEATER_EDUCATIONAL_PATH_ATTRIBUTE_NAME)) 238 { 239 boolean commonEntry = _isCommonEntry(repeaterDefinition, entryValue); 240 Object value = entryValue.get(REPEATER_EDUCATIONAL_PATH_ATTRIBUTE_NAME); 241 value = value instanceof SynchronizableValue synchronizableValue ? synchronizableValue.getLocalValue() : value; 242 243 if (!commonEntry && value instanceof EducationalPath educationalPath) 244 { 245 if (_rightManager.currentUserHasRight(_rightId, new ContextualizedContent(content, educationalPath)) == RightResult.RIGHT_ALLOW) 246 { 247 if (repeaterDefinition.hasModelItem(REPEATER_COMMON_ATTRIBUTE_NAME)) 248 { 249 entryValue.put(REPEATER_COMMON_ATTRIBUTE_NAME, false); // Force common to false (required to pass disable condition) 250 } 251 updatedValues.add(entryValue); 252 } 253 } 254 } 255 } 256 257 return updatedValues; 258 } 259 260 private boolean _isCommonEntry(RepeaterDefinition repeaterDefinition, Map<String, Object> entryValues) 261 { 262 boolean commonEntry = repeaterDefinition.hasModelItem(REPEATER_COMMON_ATTRIBUTE_NAME); // default true if repeater supports common entries 263 if (entryValues.containsKey(REPEATER_COMMON_ATTRIBUTE_NAME)) 264 { 265 Object commonValue = entryValues.get(REPEATER_COMMON_ATTRIBUTE_NAME); 266 commonValue = commonValue instanceof SynchronizableValue synchronizableValue ? synchronizableValue.getLocalValue() : commonValue; 267 if (commonValue != null) 268 { 269 commonEntry = (boolean) commonValue; 270 } 271 } 272 return commonEntry; 273 } 274 275 private List<Map<String, Object>> _getRepeaterEntries(Content content, RepeaterDefinition repeaterDefinition) 276 { 277 List<String> itemPaths = new ArrayList<>(); 278 itemPaths.add(repeaterDefinition.getPath()); 279 280 // Add items needed to evaluate disabled conditions 281 itemPaths.addAll(_getDisabledConditionPaths(repeaterDefinition)); 282 283 View view = View.of(content.getModel(), itemPaths.toArray(String[]::new)); 284 285 Map<String, Object> dataToMap = content.dataToMap(view, DataContext.newInstance().withDisabledValues(true).withEmptyValues(false)); 286 287 return _getRepeaterEntries(dataToMap, repeaterDefinition.getPath()); 288 } 289 290 @SuppressWarnings("unchecked") 291 private List<Map<String, Object>> _getRepeaterEntries(Map<String, Object> data, String path) 292 { 293 String[] pathSegments = StringUtils.split(path, ModelItem.ITEM_PATH_SEPARATOR); 294 if (pathSegments.length == 1) 295 { 296 return (List<Map<String, Object>>) data.get(pathSegments[0]); 297 } 298 else 299 { 300 Map<String, Object> subData = (Map<String, Object>) data.get(pathSegments[0]); 301 String remainPath = StringUtils.join(pathSegments, ModelItem.ITEM_PATH_SEPARATOR, 1, pathSegments.length); 302 return _getRepeaterEntries(subData, remainPath); 303 } 304 } 305 306 private Set<String> _getDisabledConditionPaths(ModelItem modelItem) 307 { 308 DisableConditions<? extends DisableCondition> disableConditions = modelItem.getDisableConditions(); 309 if (disableConditions != null) 310 { 311 Set<String> disableConditionPaths = disableConditions.getConditions() 312 .stream() 313 .filter(AbstractRelativeDisableCondition.class::isInstance) 314 .map(c -> ModelHelper.getDisableConditionAbsolutePath(c, modelItem.getPath())) 315 .collect(Collectors.toSet()); 316 317 if (modelItem instanceof ModelItemGroup group) 318 { 319 for (ModelItem childModelItem : group.getChildren()) 320 { 321 disableConditionPaths.addAll(_getDisabledConditionPaths(childModelItem)); 322 } 323 } 324 325 return disableConditionPaths; 326 } 327 return Set.of(); 328 } 329}