001/* 002 * Copyright 2022 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.plugins.forms.dao; 017 018import java.util.ArrayList; 019import java.util.HashMap; 020import java.util.LinkedHashMap; 021import java.util.List; 022import java.util.Map; 023import java.util.Optional; 024import java.util.Set; 025import java.util.stream.Collectors; 026 027import javax.jcr.RepositoryException; 028 029import org.apache.avalon.framework.component.Component; 030import org.apache.avalon.framework.service.ServiceException; 031import org.apache.avalon.framework.service.ServiceManager; 032import org.apache.avalon.framework.service.Serviceable; 033import org.apache.cocoon.ProcessingException; 034import org.apache.commons.lang3.StringUtils; 035 036import org.ametys.cms.search.ui.model.SearchUIColumn; 037import org.ametys.cms.search.ui.model.SearchUIColumnHelper; 038import org.ametys.core.observation.Event; 039import org.ametys.core.observation.ObservationManager; 040import org.ametys.core.right.RightManager; 041import org.ametys.core.right.RightManager.RightResult; 042import org.ametys.core.ui.Callable; 043import org.ametys.core.user.CurrentUserProvider; 044import org.ametys.core.user.UserIdentity; 045import org.ametys.plugins.forms.FormEvents; 046import org.ametys.plugins.forms.actions.GetFormEntriesAction; 047import org.ametys.plugins.forms.helper.FormElementDefinitionHelper; 048import org.ametys.plugins.forms.helper.LimitedEntriesHelper; 049import org.ametys.plugins.forms.question.FormQuestionType; 050import org.ametys.plugins.forms.question.types.ChoicesListQuestionType; 051import org.ametys.plugins.forms.question.types.FileQuestionType; 052import org.ametys.plugins.forms.question.types.MatrixQuestionType; 053import org.ametys.plugins.forms.question.types.MultipleAwareFormQuestionType; 054import org.ametys.plugins.forms.question.types.RestrictiveQuestionType; 055import org.ametys.plugins.forms.question.types.RichTextQuestionType; 056import org.ametys.plugins.forms.repository.Form; 057import org.ametys.plugins.forms.repository.FormEntry; 058import org.ametys.plugins.forms.repository.FormQuestion; 059import org.ametys.plugins.repository.AmetysObject; 060import org.ametys.plugins.repository.AmetysObjectIterable; 061import org.ametys.plugins.repository.AmetysObjectResolver; 062import org.ametys.plugins.repository.AmetysRepositoryException; 063import org.ametys.plugins.repository.ModifiableTraversableAmetysObject; 064import org.ametys.plugins.repository.UnknownAmetysObjectException; 065import org.ametys.plugins.repository.jcr.JCRAmetysObject; 066import org.ametys.plugins.workflow.support.WorkflowProvider; 067import org.ametys.plugins.workflow.support.WorkflowProvider.AmetysObjectWorkflow; 068import org.ametys.runtime.i18n.I18nizableText; 069import org.ametys.runtime.model.DefinitionContext; 070import org.ametys.runtime.model.ElementDefinition; 071import org.ametys.runtime.model.Model; 072import org.ametys.runtime.model.ModelItem; 073import org.ametys.runtime.model.type.ModelItemTypeConstants; 074import org.ametys.runtime.plugin.component.AbstractLogEnabled; 075import org.ametys.web.parameters.ParametersManager; 076 077import com.opensymphony.workflow.spi.Step; 078 079/** 080 * Form entry DAO. 081 */ 082public class FormEntryDAO extends AbstractLogEnabled implements Serviceable, Component 083{ 084 /** The Avalon role name. */ 085 public static final String ROLE = FormEntryDAO.class.getName(); 086 087 /** Name for entries root jcr node */ 088 public static final String ENTRIES_ROOT = "ametys-internal:form-entries"; 089 090 /** The right id to consult form entries */ 091 public static final String HANDLE_FORMS_ENTRIES_RIGHT_ID = "Form_Entries_Rights_Data"; 092 093 /** The right id to delete form entries */ 094 public static final String DELETE_FORMS_ENTRIES_RIGHT_ID = "Runtime_Rights_Forms_Entry_Delete"; 095 096 /** Ametys object resolver. */ 097 protected AmetysObjectResolver _resolver; 098 /** The parameters manager */ 099 protected ParametersManager _parametersManager; 100 /** Observer manager. */ 101 protected ObservationManager _observationManager; 102 /** The current user provider. */ 103 protected CurrentUserProvider _currentUserProvider; 104 /** The handling limited entries helper */ 105 protected LimitedEntriesHelper _handleLimitedEntriesHelper; 106 /** The rights manager */ 107 protected RightManager _rightManager; 108 /** The current user provider */ 109 protected WorkflowProvider _workflowProvider; 110 111 @Override 112 public void service(ServiceManager serviceManager) throws ServiceException 113 { 114 _resolver = (AmetysObjectResolver) serviceManager.lookup(AmetysObjectResolver.ROLE); 115 _parametersManager = (ParametersManager) serviceManager.lookup(ParametersManager.ROLE); 116 _observationManager = (ObservationManager) serviceManager.lookup(ObservationManager.ROLE); 117 _currentUserProvider = (CurrentUserProvider) serviceManager.lookup(CurrentUserProvider.ROLE); 118 _handleLimitedEntriesHelper = (LimitedEntriesHelper) serviceManager.lookup(LimitedEntriesHelper.ROLE); 119 _rightManager = (RightManager) serviceManager.lookup(RightManager.ROLE); 120 _workflowProvider = (WorkflowProvider) serviceManager.lookup(WorkflowProvider.ROLE); 121 } 122 123 /** 124 * Check if a user have handle data right on a form element as ametys object 125 * @param userIdentity the user 126 * @param formElement the form element 127 * @return true if the user handle data right for a form element 128 */ 129 public boolean hasHandleDataRightOnForm(UserIdentity userIdentity, AmetysObject formElement) 130 { 131 return _rightManager.hasRight(userIdentity, HANDLE_FORMS_ENTRIES_RIGHT_ID, formElement) == RightResult.RIGHT_ALLOW; 132 } 133 134 /** 135 * Check handle data right for a form element as ametys object 136 * @param formElement the form element as ametys object 137 */ 138 public void checkHandleDataRight(AmetysObject formElement) 139 { 140 UserIdentity user = _currentUserProvider.getUser(); 141 if (!hasHandleDataRightOnForm(user, formElement)) 142 { 143 throw new IllegalAccessError("User '" + user + "' tried to handle form data without convenient right [" + HANDLE_FORMS_ENTRIES_RIGHT_ID + "]"); 144 } 145 } 146 147 /** 148 * Gets properties of a form entry 149 * @param id The id of the form entry 150 * @return The properties 151 */ 152 @Callable 153 public Map<String, Object> getFormEntryProperties (String id) 154 { 155 try 156 { 157 FormEntry entry = _resolver.resolveById(id); 158 return getFormEntryProperties(entry); 159 } 160 catch (UnknownAmetysObjectException e) 161 { 162 getLogger().warn("Can't find entry with id: {}. It probably has just been deleted", id, e); 163 Map<String, Object> infos = new HashMap<>(); 164 infos.put("id", id); 165 return infos; 166 } 167 } 168 169 /** 170 * Gets properties of a form entry 171 * @param entry The form entry 172 * @return The properties 173 */ 174 public Map<String, Object> getFormEntryProperties (FormEntry entry) 175 { 176 Map<String, Object> properties = new HashMap<>(); 177 178 properties.put("id", entry.getId()); 179 properties.put("formId", entry.getForm().getId()); 180 properties.put("rights", _getUserRights(entry)); 181 182 return properties; 183 } 184 185 /** 186 * Get user rights for the given form entry 187 * @param entry the form entry 188 * @return the set of rights 189 */ 190 protected Set<String> _getUserRights (FormEntry entry) 191 { 192 UserIdentity user = _currentUserProvider.getUser(); 193 return _rightManager.getUserRights(user, entry); 194 } 195 196 /** 197 * Creates a {@link FormEntry}. 198 * @param form The parent form 199 * @return return the form entry 200 */ 201 public FormEntry createEntry(Form form) 202 { 203 ModifiableTraversableAmetysObject entriesRoot; 204 if (form.hasChild(ENTRIES_ROOT)) 205 { 206 entriesRoot = form.getChild(ENTRIES_ROOT); 207 } 208 else 209 { 210 entriesRoot = form.createChild(ENTRIES_ROOT, "ametys:collection"); 211 } 212 // Find unique name 213 String originalName = "entry"; 214 String uniqueName = originalName + "-1"; 215 int index = 2; 216 while (entriesRoot.hasChild(uniqueName)) 217 { 218 uniqueName = originalName + "-" + (index++); 219 } 220 FormEntry entry = (FormEntry) entriesRoot.createChild(uniqueName, "ametys:form-entry"); 221 222 Map<String, Object> eventParams = new HashMap<>(); 223 eventParams.put("form", form); 224 _observationManager.notify(new Event(FormEvents.FORM_MODIFIED, _currentUserProvider.getUser(), eventParams)); 225 226 return entry; 227 } 228 229 /** 230 * Get the search model configuration to search form entries 231 * @param formId the identifier of form 232 * @return The search model configuration 233 * @throws ProcessingException If an error occurred 234 */ 235 @Callable 236 public Map<String, Object> getSearchModelConfiguration (String formId) throws ProcessingException 237 { 238 Map<String, Object> result = new HashMap<>(); 239 240 Form form = _resolver.resolveById(formId); 241 result.put("criteria", _getCriteria(form)); 242 result.put("columns", _getColumns(form)); 243 244 result.put("searchUrlPlugin", "forms"); 245 result.put("searchUrl", "form/entries.json"); 246 result.put("pageSize", 50); 247 return result; 248 } 249 250 /** 251 * Get criteria to search form entries 252 * @param form the form 253 * @return the criteria as JSON 254 */ 255 protected Map<String, Object> _getCriteria(Form form) 256 { 257 // Currently, return no criteria for search entries tool 258 return Map.of(); 259 } 260 261 /** 262 * Get the columns for search form entries 263 * @param form the form 264 * @return the columns as JSON 265 * @throws ProcessingException if an error occurred 266 */ 267 protected List<Map<String, Object>> _getColumns(Form form) throws ProcessingException 268 { 269 List<SearchUIColumn> columns = new ArrayList<>(); 270 271 Model formEntryModel = getFormEntryModel(form); 272 273 ModelItem idAttribute = formEntryModel.getModelItem(FormEntry.ATTRIBUTE_ID); 274 SearchUIColumn idColumn = SearchUIColumnHelper.createModelItemColumn(idAttribute); 275 idColumn.setWidth(80); 276 columns.add(idColumn); 277 278 ModelItem userAttribute = formEntryModel.getModelItem(FormEntry.ATTRIBUTE_USER); 279 SearchUIColumn userColumn = SearchUIColumnHelper.createModelItemColumn(userAttribute); 280 userColumn.setWidth(150); 281 columns.add(userColumn); 282 283 ModelItem submitDateAttribute = formEntryModel.getModelItem(FormEntry.ATTRIBUTE_SUBMIT_DATE); 284 SearchUIColumn submitDateColumn = SearchUIColumnHelper.createModelItemColumn(submitDateAttribute); 285 submitDateColumn.setWidth(150); 286 columns.add(submitDateColumn); 287 288 for (ModelItem modelItem : formEntryModel.getModelItems()) 289 { 290 String modelItemName = modelItem.getName(); 291 if (!modelItemName.equals(FormEntry.ATTRIBUTE_IP) 292 && !modelItemName.equals(FormEntry.ATTRIBUTE_ACTIVE) 293 && !modelItemName.equals(FormEntry.ATTRIBUTE_SUBMIT_DATE) 294 && !modelItemName.equals(FormEntry.ATTRIBUTE_ID) 295 && !modelItemName.equals(FormEntry.ATTRIBUTE_USER) 296 && !modelItemName.startsWith(ChoicesListQuestionType.OTHER_PREFIX_DATA_NAME)) 297 { 298 SearchUIColumn<? extends ModelItem> column = SearchUIColumnHelper.createModelItemColumn(modelItem); 299 300 FormQuestion question = form.getQuestion(modelItemName); 301 if (question != null) 302 { 303 FormQuestionType type = question.getType(); 304 305 column.setRenderer(Optional.ofNullable(type.getJSRenderer(question)) 306 .filter(StringUtils::isNotBlank)); 307 308 column.setConverter(Optional.ofNullable(type.getJSConverter(question)) 309 .filter(StringUtils::isNotBlank)); 310 311 column.setSortable(_isSortable(question)); 312 } 313 314 columns.add(column); 315 } 316 } 317 318 List<Map<String, Object>> columnsInfo = new ArrayList<>(); 319 DefinitionContext definitionContext = DefinitionContext.newInstance(); 320 for (SearchUIColumn column : columns) 321 { 322 columnsInfo.add(column.toJSON(definitionContext)); 323 } 324 325 if (form.isQueueEnabled()) 326 { 327 columnsInfo.add( 328 Map.of("name", FormEntry.SYSTEM_ATTRIBUTE_PREFIX + GetFormEntriesAction.QUEUE_STATUS, 329 "label", new I18nizableText("plugin.forms", "PLUGINS_FORMS_QUEUE_STATUS_COLUMN_TITLE_LABEL"), 330 "type", ModelItemTypeConstants.BOOLEAN_TYPE_ID, 331 "path", FormEntry.SYSTEM_ATTRIBUTE_PREFIX + GetFormEntriesAction.QUEUE_STATUS 332 ) 333 ); 334 } 335 336 if (form.hasWorkflow()) 337 { 338 columnsInfo.add( 339 Map.of("name", FormEntry.SYSTEM_ATTRIBUTE_PREFIX + GetFormEntriesAction.FORM_ENTRY_STATUS_ID, 340 "label", new I18nizableText("plugin.forms", "PLUGINS_FORMS_WORKFLOW_TAB_STATUS_COLUMN_TITLE_LABEL"), 341 "type", ModelItemTypeConstants.STRING_TYPE_ID, 342 "path", FormEntry.SYSTEM_ATTRIBUTE_PREFIX + GetFormEntriesAction.FORM_ENTRY_STATUS_ID 343 ) 344 ); 345 } 346 347 columnsInfo.add( 348 Map.of("name", FormEntry.SYSTEM_ATTRIBUTE_PREFIX + GetFormEntriesAction.FORM_ENTRY_ACTIVE, 349 "label", new I18nizableText("plugin.forms", "PLUGINS_FORMS_ENTRY_ACTIVE_COLUMN_TITLE_LABEL"), 350 "type", ModelItemTypeConstants.BOOLEAN_TYPE_ID, 351 "path", FormEntry.SYSTEM_ATTRIBUTE_PREFIX + GetFormEntriesAction.FORM_ENTRY_ACTIVE, 352 "hidden", true 353 ) 354 ); 355 356 return columnsInfo; 357 } 358 359 /** 360 * <code>true</code> if the column link to the question is sortable 361 * @param question the question 362 * @return <code>true</code> if the column link to the question is sortable 363 */ 364 protected boolean _isSortable(FormQuestion question) 365 { 366 FormQuestionType type = question.getType(); 367 if (type instanceof MultipleAwareFormQuestionType multipleType && multipleType.isMultiple(question)) 368 { 369 return false; 370 } 371 372 if (type instanceof MatrixQuestionType || type instanceof RichTextQuestionType || type instanceof FileQuestionType) 373 { 374 return false; 375 } 376 377 return true; 378 } 379 380 /** 381 * Deletes a {@link FormEntry}. 382 * @param id The id of the form entry to delete 383 * @return The entry data 384 */ 385 @Callable 386 public Map<String, String> deleteEntry (String id) 387 { 388 Map<String, String> result = new HashMap<>(); 389 390 FormEntry entry = _resolver.resolveById(id); 391 392 if (_rightManager.currentUserHasRight(DELETE_FORMS_ENTRIES_RIGHT_ID, entry) != RightResult.RIGHT_ALLOW) 393 { 394 throw new IllegalAccessError("User '" + _currentUserProvider.getUser() + "' tried to delete entries without convenient right [" + DELETE_FORMS_ENTRIES_RIGHT_ID + "]"); 395 } 396 397 _handleLimitedEntriesHelper.deactivateEntry(id); 398 399 Form form = entry.getForm(); 400 entry.remove(); 401 402 form.saveChanges(); 403 404 Map<String, Object> eventParams = new HashMap<>(); 405 eventParams.put("form", form); 406 _observationManager.notify(new Event(FormEvents.FORM_MODIFIED, _currentUserProvider.getUser(), eventParams)); 407 408 result.put("entryId", id); 409 result.put("formId", form.getId()); 410 result.put("hasEntries", String.valueOf(form.hasEntries())); 411 412 return result; 413 } 414 415 /** 416 * Delete all entries of a form 417 * @param id The id of the form 418 * @return the deleted entries data 419 */ 420 @Callable 421 public Map<String, Object> clearEntries(String id) 422 { 423 Map<String, Object> result = new HashMap<>(); 424 List<String> entryIds = new ArrayList<>(); 425 Form form = _resolver.resolveById(id); 426 if (_rightManager.currentUserHasRight(DELETE_FORMS_ENTRIES_RIGHT_ID, form) != RightResult.RIGHT_ALLOW) 427 { 428 throw new IllegalAccessError("User '" + _currentUserProvider.getUser() + "' tried to delete entries without convenient right [" + DELETE_FORMS_ENTRIES_RIGHT_ID + "]"); 429 } 430 431 for (FormEntry entry: form.getEntries()) 432 { 433 entryIds.add(entry.getId()); 434 entry.remove(); 435 } 436 form.saveChanges(); 437 Map<String, Object> eventParams = new HashMap<>(); 438 eventParams.put("form", form); 439 _observationManager.notify(new Event(FormEvents.FORM_MODIFIED, _currentUserProvider.getUser(), eventParams)); 440 441 result.put("ids", entryIds); 442 result.put("formId", form.getId()); 443 444 return result; 445 } 446 447 /** 448 * Retrieves the current step id of the form entry 449 * @param entry The form entry 450 * @return the current step id 451 * @throws AmetysRepositoryException if an error occurs. 452 */ 453 public Long getCurrentStepId(FormEntry entry) throws AmetysRepositoryException 454 { 455 AmetysObjectWorkflow workflow = _workflowProvider.getAmetysObjectWorkflow(entry); 456 try 457 { 458 Step currentStep = (Step) workflow.getCurrentSteps(entry.getWorkflowId()).iterator().next(); 459 return Long.valueOf(currentStep.getStepId()); 460 } 461 catch (AmetysRepositoryException e) 462 { 463 return RestrictiveQuestionType.INITIAL_WORKFLOW_ID; // can occur when entry has just been created and workflow is not yet initialized 464 } 465 } 466 467 /** 468 * Get the form entry model 469 * @param form the form 470 * @return the form entry model 471 */ 472 public Model getFormEntryModel(Form form) 473 { 474 Map<String, ModelItem> items = new LinkedHashMap<>(); 475 for (FormQuestion question : form.getQuestions()) 476 { 477 FormQuestionType type = question.getType(); 478 if (!type.onlyForDisplay(question)) 479 { 480 Model entryModel = question.getType().getEntryModel(question); 481 for (ModelItem modelItem : entryModel.getModelItems()) 482 { 483 items.put(modelItem.getName(), modelItem); 484 } 485 486 if (type instanceof ChoicesListQuestionType cLType) 487 { 488 ModelItem otherFieldModel = cLType.getOtherFieldModel(question); 489 if (otherFieldModel != null) 490 { 491 items.put(ChoicesListQuestionType.OTHER_PREFIX_DATA_NAME + question.getNameForForm(), otherFieldModel); 492 } 493 } 494 } 495 } 496 497 ElementDefinition idModelItem = FormElementDefinitionHelper.getElementDefinition(FormEntry.ATTRIBUTE_ID, ModelItemTypeConstants.LONG_TYPE_ID, "PLUGIN_FORMS_MODEL_ITEM_ID_LABEL", null, null); 498 items.put(idModelItem.getName(), idModelItem); 499 500 ElementDefinition userModelItem = FormElementDefinitionHelper.getElementDefinition(FormEntry.ATTRIBUTE_USER, org.ametys.cms.data.type.ModelItemTypeConstants.USER_ELEMENT_TYPE_ID, "PLUGIN_FORMS_MODEL_ITEM_USER_LABEL", null, null); 501 items.put(userModelItem.getName(), userModelItem); 502 503 ElementDefinition activeModelItem = FormElementDefinitionHelper.getElementDefinition(FormEntry.ATTRIBUTE_ACTIVE, ModelItemTypeConstants.BOOLEAN_TYPE_ID, "PLUGIN_FORMS_MODEL_ITEM_ACTIVE_LABEL", null, null); 504 items.put(activeModelItem.getName(), activeModelItem); 505 506 ElementDefinition submitDateModelItem = FormElementDefinitionHelper.getElementDefinition(FormEntry.ATTRIBUTE_SUBMIT_DATE, ModelItemTypeConstants.DATETIME_TYPE_ID, "PLUGIN_FORMS_MODEL_ITEM_SUBMISSION_DATE_LABEL", null, null); 507 items.put(submitDateModelItem.getName(), submitDateModelItem); 508 509 ElementDefinition ipAddressModelItem = FormElementDefinitionHelper.getElementDefinition(FormEntry.ATTRIBUTE_IP, ModelItemTypeConstants.STRING_TYPE_ID, "PLUGIN_FORMS_MODEL_ITEM_IP_LABEL", null, null); 510 items.put(ipAddressModelItem.getName(), ipAddressModelItem); 511 512 return Model.of( 513 "form.entry.model.id", 514 "form.entry.model.family.id", 515 items.values().toArray(new ModelItem[items.size()]) 516 ); 517 } 518 519 /** 520 * Get the form entries 521 * @param form the form 522 * @param onlyActiveEntries <code>true</code> to have only active entries 523 * @param sorts the list of sort 524 * @return the form entries 525 */ 526 public List<FormEntry> getFormEntries(Form form, boolean onlyActiveEntries, List<Sort> sorts) 527 { 528 try 529 { 530 String uuid = ((JCRAmetysObject) form).getNode().getIdentifier(); 531 String xpathQuery = "//element(*, ametys:form)[@jcr:uuid = '" + uuid + "']//element(*, ametys:form-entry)"; 532 if (onlyActiveEntries) 533 { 534 xpathQuery += "[@ametys:ametys-active = 'true']"; 535 } 536 537 String sortsAsString = ""; 538 for (Sort sort : sorts) 539 { 540 if (StringUtils.isNotBlank(sortsAsString)) 541 { 542 sortsAsString += ", "; 543 } 544 545 sortsAsString += "@ametys:" + sort.attributeName() + " " + sort.direction(); 546 } 547 548 if (StringUtils.isNotBlank(sortsAsString)) 549 { 550 xpathQuery += " order by " + sortsAsString; 551 } 552 553 AmetysObjectIterable<FormEntry> formEntries = _resolver.query(xpathQuery); 554 return formEntries.stream() 555 .collect(Collectors.toList()); 556 } 557 catch (RepositoryException e) 558 { 559 getLogger().error("An error occurred getting entries of form '" + form.getId() + "'"); 560 } 561 562 return List.of(); 563 } 564 565 /** 566 * Record representing a sort with form attribute name and direction 567 * @param attributeName the attribute name 568 * @param direction the direction 569 */ 570 public record Sort(String attributeName, String direction) { /* */ } 571}