SEBSERV-495 implementation and fix special chars in password
This commit is contained in:
parent
b837163c79
commit
c301016d87
19 changed files with 424 additions and 55 deletions
|
@ -192,7 +192,9 @@ public final class API {
|
|||
public static final String CONFIGURATION_SEB_SETTINGS_DOWNLOAD_PATH_SEGMENT = "/downloadSettings";
|
||||
public static final String CONFIGURATION_IMPORT_PATH_SEGMENT = "/import";
|
||||
public static final String IMPORT_PASSWORD_ATTR_NAME = "importFilePassword";
|
||||
public static final String QUIT_PASSWORD_ATTR_NAME = "quitPassword";
|
||||
public static final String IMPORT_FILE_ATTR_NAME = "importFile";
|
||||
public static final String CONFIGURATION_SET_QUIT_PWD_PATH_SEGMENT = "/quitpwd";
|
||||
|
||||
public static final String TEMPLATE_ATTRIBUTE_ENDPOINT = "/template-attribute";
|
||||
public static final String TEMPLATE_ATTRIBUTE_RESET_VALUES = "/reset";
|
||||
|
|
|
@ -11,16 +11,19 @@ package ch.ethz.seb.sebserver.gui.content.configs;
|
|||
import java.io.InputStream;
|
||||
import java.util.Collection;
|
||||
import java.util.List;
|
||||
import java.util.function.Function;
|
||||
import java.util.function.Predicate;
|
||||
import java.util.function.Supplier;
|
||||
import java.util.function.*;
|
||||
|
||||
import ch.ethz.seb.sebserver.gbl.model.sebconfig.ConfigurationValue;
|
||||
import ch.ethz.seb.sebserver.gbl.util.Cryptor;
|
||||
import ch.ethz.seb.sebserver.gui.form.FieldBuilder;
|
||||
import ch.ethz.seb.sebserver.gui.service.remote.webservice.api.seb.examconfig.*;
|
||||
import ch.ethz.seb.sebserver.gui.widget.PasswordConfirmInput;
|
||||
import ch.ethz.seb.sebserver.gui.widget.PasswordInput;
|
||||
import org.apache.commons.lang3.BooleanUtils;
|
||||
import org.apache.commons.lang3.StringUtils;
|
||||
import org.eclipse.swt.SWT;
|
||||
import org.eclipse.swt.layout.GridData;
|
||||
import org.eclipse.swt.widgets.Composite;
|
||||
import org.eclipse.swt.widgets.Control;
|
||||
import org.eclipse.swt.widgets.Label;
|
||||
import org.eclipse.swt.widgets.*;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.context.annotation.Lazy;
|
||||
|
@ -53,10 +56,6 @@ import ch.ethz.seb.sebserver.gui.service.page.impl.ModalInputDialog;
|
|||
import ch.ethz.seb.sebserver.gui.service.page.impl.PageAction;
|
||||
import ch.ethz.seb.sebserver.gui.service.remote.webservice.api.RestCall;
|
||||
import ch.ethz.seb.sebserver.gui.service.remote.webservice.api.RestCallError;
|
||||
import ch.ethz.seb.sebserver.gui.service.remote.webservice.api.seb.examconfig.GetExamConfigNodeNames;
|
||||
import ch.ethz.seb.sebserver.gui.service.remote.webservice.api.seb.examconfig.ImportExamConfigOnExistingConfig;
|
||||
import ch.ethz.seb.sebserver.gui.service.remote.webservice.api.seb.examconfig.ImportNewExamConfig;
|
||||
import ch.ethz.seb.sebserver.gui.service.remote.webservice.api.seb.examconfig.SaveExamConfigHistory;
|
||||
import ch.ethz.seb.sebserver.gui.widget.FileUploadSelection;
|
||||
import ch.ethz.seb.sebserver.gui.widget.WidgetFactory;
|
||||
|
||||
|
@ -77,6 +76,14 @@ public class SEBExamConfigImportPopup {
|
|||
new LocTextKey("sebserver.examconfig.action.import.missing-password"));
|
||||
private static final LocTextKey MESSAGE_SAVE_INTEGRITY_VIOLATION =
|
||||
new LocTextKey("sebserver.examconfig.action.saveToHistory.integrity-violation");
|
||||
private static final LocTextKey TITLE_QUIT_PASSWORD =
|
||||
new LocTextKey("sebserver.examconfig.action.import.quitpwd.title");
|
||||
private static final LocTextKey LABEL_QUIT_PASSWORD =
|
||||
new LocTextKey("sebserver.clientconfig.form.hashedQuitPassword");
|
||||
private static final LocTextKey MESSAGE_QUIT_PASSWORD_REMOVED =
|
||||
new LocTextKey("sebserver.examconfig.action.import.quitpwd.removed");
|
||||
private static final LocTextKey MESSAGE_QUIT_PASSWORD_SET =
|
||||
new LocTextKey("sebserver.examconfig.action.import.quitpwd.set");
|
||||
|
||||
private final static String AUTO_PUBLISH_FLAG = "AUTO_PUBLISH_FLAG";
|
||||
|
||||
|
@ -178,15 +185,18 @@ public class SEBExamConfigImportPopup {
|
|||
final Result<Configuration> importResult = restCall.call();
|
||||
if (!importResult.hasError()) {
|
||||
context.publishInfo(SEBExamConfigForm.FORM_IMPORT_CONFIRM_TEXT_KEY);
|
||||
final Configuration configuration = importResult.get();
|
||||
|
||||
// Auto publish?
|
||||
if (!newConfig && BooleanUtils.toBoolean(form.getFieldValue(AUTO_PUBLISH_FLAG))) {
|
||||
this.pageService.getRestService()
|
||||
.getBuilder(SaveExamConfigHistory.class)
|
||||
.withURIVariable(API.PARAM_MODEL_ID, importResult.get().getModelId())
|
||||
.withURIVariable(API.PARAM_MODEL_ID, configuration.getModelId())
|
||||
.call()
|
||||
.onError(error -> notifyErrorOnSave(error, context));
|
||||
}
|
||||
|
||||
handleQuitPassword(configuration, context);
|
||||
} else {
|
||||
handleImportError(formHandle, importResult);
|
||||
}
|
||||
|
@ -207,6 +217,81 @@ public class SEBExamConfigImportPopup {
|
|||
}
|
||||
}
|
||||
|
||||
private void handleQuitPassword(final Configuration configuration, final PageContext context) {
|
||||
try {
|
||||
final ConfigurationValue configurationValue = this.pageService.getRestService()
|
||||
.getBuilder(GetConfigurationValues.class)
|
||||
.withQueryParam(
|
||||
ConfigurationValue.FILTER_ATTR_CONFIGURATION_ID,
|
||||
configuration.getModelId())
|
||||
.call()
|
||||
.getOrThrow()
|
||||
.stream()
|
||||
.filter(v -> v.attributeId.intValue() == 4)
|
||||
.findFirst()
|
||||
.orElse(null);
|
||||
|
||||
final boolean hashedSet = configurationValue != null &&
|
||||
StringUtils.isNotBlank(configurationValue.getValue());
|
||||
|
||||
final ModalInputDialog<PasswordConfirmInput> dialog = new ModalInputDialog<>(
|
||||
context.getShell(),
|
||||
this.pageService.getWidgetFactory());
|
||||
|
||||
final ModalInputDialogComposer<PasswordConfirmInput> contentComposer = comp -> {
|
||||
|
||||
this.pageService.getWidgetFactory().labelLocalized(
|
||||
comp,
|
||||
hashedSet ? MESSAGE_QUIT_PASSWORD_REMOVED : MESSAGE_QUIT_PASSWORD_SET,
|
||||
true);
|
||||
final PasswordConfirmInput passwordInput = new PasswordConfirmInput(
|
||||
comp,
|
||||
this.pageService.getWidgetFactory(),
|
||||
this.pageService.getCryptor(),
|
||||
LABEL_QUIT_PASSWORD,
|
||||
LABEL_QUIT_PASSWORD);
|
||||
|
||||
return () -> passwordInput;
|
||||
};
|
||||
|
||||
final String configNodeId = String.valueOf(configuration.configurationNodeId);
|
||||
final Predicate<PasswordConfirmInput> callback = input -> {
|
||||
|
||||
if (input.hasError()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
|
||||
final CharSequence value = input.getValue();
|
||||
if (value != null) {
|
||||
this.pageService.getRestService()
|
||||
.getBuilder(ResetImportQuitPassword.class)
|
||||
.withURIVariable(API.PARAM_MODEL_ID, configNodeId)
|
||||
.withFormParam(API.QUIT_PASSWORD_ATTR_NAME, String.valueOf(value))
|
||||
.call()
|
||||
.onError(context::notifyUnexpectedError);
|
||||
} else {
|
||||
deleteQuitPassword(context, configNodeId);
|
||||
}
|
||||
return true;
|
||||
};
|
||||
|
||||
final Runnable cancel = () -> deleteQuitPassword(context, configNodeId);
|
||||
|
||||
dialog.open(TITLE_QUIT_PASSWORD, callback, cancel, contentComposer);
|
||||
} catch (final Exception e) {
|
||||
log.error("Failed to handle quit password for import: ", e);
|
||||
}
|
||||
}
|
||||
|
||||
private void deleteQuitPassword(final PageContext context, final String configNodeId) {
|
||||
this.pageService.getRestService()
|
||||
.getBuilder(ResetImportQuitPassword.class)
|
||||
.withURIVariable(API.PARAM_MODEL_ID, configNodeId)
|
||||
.call()
|
||||
.onError(context::notifyUnexpectedError);
|
||||
}
|
||||
|
||||
private void handleImportError(
|
||||
final FormHandle<ConfigurationNode> formHandle,
|
||||
final Result<Configuration> configuration) {
|
||||
|
@ -285,9 +370,7 @@ public class SEBExamConfigImportPopup {
|
|||
.call()
|
||||
.getOrThrow()
|
||||
.stream()
|
||||
.filter(n -> n.name.equals(fieldValue))
|
||||
.findFirst()
|
||||
.isPresent()) {
|
||||
.anyMatch(n -> n.name.equals(fieldValue))) {
|
||||
|
||||
form.setFieldError(
|
||||
Domain.CONFIGURATION_NODE.ATTR_NAME,
|
||||
|
@ -304,6 +387,8 @@ public class SEBExamConfigImportPopup {
|
|||
return true;
|
||||
}
|
||||
|
||||
|
||||
|
||||
private final class ImportFormContext implements ModalInputDialogComposer<FormHandle<ConfigurationNode>> {
|
||||
|
||||
private final PageService pageService;
|
||||
|
|
|
@ -10,6 +10,7 @@ package ch.ethz.seb.sebserver.gui.service.examconfig.impl;
|
|||
|
||||
import java.util.Collection;
|
||||
|
||||
import org.apache.commons.lang3.StringUtils;
|
||||
import org.eclipse.swt.SWT;
|
||||
import org.eclipse.swt.widgets.Control;
|
||||
import org.eclipse.swt.widgets.Event;
|
||||
|
@ -104,7 +105,7 @@ public abstract class AbstractInputField<T extends Control> implements InputFiel
|
|||
return;
|
||||
}
|
||||
this.errorLabel.setVisible(false);
|
||||
this.errorLabel.setText("");
|
||||
this.errorLabel.setText(StringUtils.EMPTY);
|
||||
|
||||
}
|
||||
|
||||
|
|
|
@ -18,6 +18,7 @@ import java.util.function.Function;
|
|||
import java.util.function.Supplier;
|
||||
|
||||
import ch.ethz.seb.sebserver.gbl.FeatureService;
|
||||
import ch.ethz.seb.sebserver.gbl.util.Cryptor;
|
||||
import org.eclipse.swt.SWT;
|
||||
import org.eclipse.swt.custom.ScrolledComposite;
|
||||
import org.eclipse.swt.graphics.Point;
|
||||
|
@ -73,6 +74,8 @@ public interface PageService {
|
|||
|
||||
Logger log = LoggerFactory.getLogger(PageService.class);
|
||||
|
||||
Cryptor getCryptor();
|
||||
|
||||
FeatureService getFeatureService();
|
||||
|
||||
/** Get the WidgetFactory service
|
||||
|
|
|
@ -13,6 +13,7 @@ import java.util.function.Consumer;
|
|||
import java.util.function.Predicate;
|
||||
import java.util.function.Supplier;
|
||||
|
||||
import ch.ethz.seb.sebserver.gui.widget.PasswordInput;
|
||||
import org.eclipse.rap.rwt.RWT;
|
||||
import org.eclipse.swt.SWT;
|
||||
import org.eclipse.swt.graphics.Rectangle;
|
||||
|
@ -157,6 +158,7 @@ public class ModalInputDialog<T> extends Dialog {
|
|||
finishUp(this.shell);
|
||||
}
|
||||
|
||||
|
||||
public void open(
|
||||
final LocTextKey title,
|
||||
final Predicate<T> okCallback,
|
||||
|
|
|
@ -111,9 +111,14 @@ public class PageServiceImpl implements PageService {
|
|||
this.featureService = featureService;
|
||||
}
|
||||
|
||||
@Override
|
||||
public Cryptor getCryptor() {
|
||||
return this.cryptor;
|
||||
}
|
||||
|
||||
@Override
|
||||
public FeatureService getFeatureService() {
|
||||
return featureService;
|
||||
return this.featureService;
|
||||
}
|
||||
|
||||
@Override
|
||||
|
|
|
@ -430,6 +430,7 @@ public abstract class RestCall<T> {
|
|||
+ this.queryParams
|
||||
+ ", uriVariables=" + this.uriVariables + "]";
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
public static final class TypeKey<T> {
|
||||
|
|
|
@ -0,0 +1,32 @@
|
|||
package ch.ethz.seb.sebserver.gui.service.remote.webservice.api.seb.examconfig;
|
||||
|
||||
|
||||
import ch.ethz.seb.sebserver.gbl.api.API;
|
||||
import ch.ethz.seb.sebserver.gbl.api.EntityType;
|
||||
import ch.ethz.seb.sebserver.gbl.model.sebconfig.ConfigurationNode;
|
||||
import ch.ethz.seb.sebserver.gbl.profile.GuiProfile;
|
||||
import ch.ethz.seb.sebserver.gui.service.remote.webservice.api.RestCall;
|
||||
import com.fasterxml.jackson.core.type.TypeReference;
|
||||
import org.springframework.context.annotation.Lazy;
|
||||
import org.springframework.http.HttpMethod;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
@Lazy
|
||||
@Component
|
||||
@GuiProfile
|
||||
public class ResetImportQuitPassword extends RestCall<ConfigurationNode> {
|
||||
|
||||
public ResetImportQuitPassword() {
|
||||
super(new TypeKey<>(
|
||||
CallType.UNDEFINED,
|
||||
EntityType.CONFIGURATION_NODE,
|
||||
new TypeReference<ConfigurationNode>() {
|
||||
}),
|
||||
HttpMethod.POST,
|
||||
MediaType.APPLICATION_FORM_URLENCODED,
|
||||
API.CONFIGURATION_NODE_ENDPOINT +
|
||||
API.MODEL_ID_VAR_PATH_SEGMENT +
|
||||
API.CONFIGURATION_SET_QUIT_PWD_PATH_SEGMENT);
|
||||
}
|
||||
}
|
|
@ -0,0 +1,106 @@
|
|||
package ch.ethz.seb.sebserver.gui.widget;
|
||||
|
||||
import ch.ethz.seb.sebserver.gbl.util.Cryptor;
|
||||
import ch.ethz.seb.sebserver.gui.service.i18n.LocTextKey;
|
||||
import org.apache.commons.lang3.StringUtils;
|
||||
import org.eclipse.rap.rwt.RWT;
|
||||
import org.eclipse.swt.SWT;
|
||||
import org.eclipse.swt.layout.GridData;
|
||||
import org.eclipse.swt.layout.GridLayout;
|
||||
import org.eclipse.swt.widgets.Composite;
|
||||
import org.eclipse.swt.widgets.Label;
|
||||
import org.eclipse.swt.widgets.Listener;
|
||||
|
||||
public class PasswordConfirmInput extends Composite {
|
||||
|
||||
private static final LocTextKey VAL_CONFIRM_PWD_TEXT_KEY =
|
||||
new LocTextKey("sebserver.examconfig.props.validation.password.confirm");
|
||||
|
||||
final WidgetFactory widgetFactory;
|
||||
final Cryptor cryptor;
|
||||
private final PasswordInput password;
|
||||
private final PasswordInput confirm;
|
||||
private final Label errorLabel;
|
||||
|
||||
public PasswordConfirmInput(
|
||||
final Composite parent,
|
||||
final WidgetFactory widgetFactory,
|
||||
final Cryptor cryptor,
|
||||
final LocTextKey ariaLabel,
|
||||
final LocTextKey testLabel) {
|
||||
|
||||
super(parent, SWT.NONE);
|
||||
|
||||
this.widgetFactory = widgetFactory;
|
||||
this.cryptor = cryptor;
|
||||
final GridLayout gridLayout = new GridLayout(1, false);
|
||||
gridLayout.horizontalSpacing = 0;
|
||||
gridLayout.verticalSpacing = 10;
|
||||
gridLayout.marginHeight = 0;
|
||||
gridLayout.marginWidth = 10;
|
||||
this.setLayout(gridLayout);
|
||||
this.setLayoutData(new GridData(SWT.FILL, SWT.FILL, true, false));
|
||||
|
||||
this.password = new PasswordInput(parent, widgetFactory, ariaLabel, testLabel);
|
||||
this.confirm = new PasswordInput(parent, widgetFactory, ariaLabel, testLabel);
|
||||
this.errorLabel = new Label(parent, SWT.NONE);
|
||||
final GridData gridData = new GridData(SWT.FILL, SWT.TOP, true, true);
|
||||
errorLabel.setLayoutData(gridData);
|
||||
errorLabel.setVisible(false);
|
||||
errorLabel.setData(RWT.CUSTOM_VARIANT, WidgetFactory.CustomVariant.ERROR.key);
|
||||
|
||||
final Listener valueChangeEventListener = event -> checkError();
|
||||
this.password.addListener(SWT.FocusOut, valueChangeEventListener);
|
||||
this.password.addListener(SWT.Traverse, valueChangeEventListener);
|
||||
this.confirm.addListener(SWT.FocusOut, valueChangeEventListener);
|
||||
this.confirm.addListener(SWT.Traverse, valueChangeEventListener);
|
||||
}
|
||||
|
||||
public void setValue(final CharSequence value) {
|
||||
if (StringUtils.isBlank(value)) {
|
||||
this.password.setValue(null);
|
||||
this.confirm.setValue(null);
|
||||
} else {
|
||||
final CharSequence val = cryptor.decrypt(value).getOr(value);
|
||||
this.password.setValue(val);
|
||||
this.confirm.setValue(val);
|
||||
}
|
||||
}
|
||||
|
||||
public CharSequence getValue() {
|
||||
final CharSequence value = password.getValue();
|
||||
if (StringUtils.isNotBlank(value)) {
|
||||
return cryptor.encrypt(value).getOr(value);
|
||||
} else {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
public boolean hasError() {
|
||||
return checkError();
|
||||
}
|
||||
|
||||
public void clearError() {
|
||||
this.errorLabel.setVisible(false);
|
||||
this.errorLabel.setText(StringUtils.EMPTY);
|
||||
}
|
||||
|
||||
private boolean checkError() {
|
||||
clearError();
|
||||
|
||||
final CharSequence pwd = this.password.getValue();
|
||||
final CharSequence confirm = this.confirm.getValue();
|
||||
|
||||
if (pwd == null && confirm == null) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!pwd.equals(confirm)) {
|
||||
errorLabel.setText(widgetFactory.getI18nSupport().getText(VAL_CONFIRM_PWD_TEXT_KEY));
|
||||
errorLabel.setVisible(true);
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
}
|
|
@ -143,7 +143,7 @@ public class PasswordInput extends Composite {
|
|||
public void setValue(final CharSequence value) {
|
||||
if (this.passwordInputField != null) {
|
||||
this.passwordInputField.setText(value != null
|
||||
? Utils.escapeHTML_XML_EcmaScript(value.toString())
|
||||
? value.toString()
|
||||
: StringUtils.EMPTY);
|
||||
if (StringUtils.endsWith(value, Constants.IMPORTED_PASSWORD_MARKER)) {
|
||||
this.visibilityButton.setEnabled(false);
|
||||
|
|
|
@ -36,7 +36,7 @@ public interface ConfigurationDAO extends EntityDAO<Configuration, Configuration
|
|||
}
|
||||
|
||||
/** Saves the current follow-up Configuration of the ConfigurationNode of given id
|
||||
* as a point in history and creates new new follow-up Configuration.
|
||||
* as a point in history and creates new follow-up Configuration.
|
||||
*
|
||||
* @param configurationNodeId the identifier of the ConfigurationNode to create a new history entry from current
|
||||
* follow-up
|
||||
|
@ -50,9 +50,9 @@ public interface ConfigurationDAO extends EntityDAO<Configuration, Configuration
|
|||
Result<Configuration> undo(Long configurationNodeId);
|
||||
|
||||
/** Restores the attribute values to the default values that have been set for the specified configuration
|
||||
* on initialization. This are the base default values if the configuration has no template or the default
|
||||
* on initialization. These are the base default values if the configuration has no template or the default
|
||||
* values from the template if there is one assigned to the configuration.
|
||||
*
|
||||
* <p>
|
||||
* In fact. this just gets the initial configuration values and reset the current values with that one
|
||||
*
|
||||
* @param configurationNodeId the ConfigurationNode identifier
|
||||
|
@ -60,9 +60,9 @@ public interface ConfigurationDAO extends EntityDAO<Configuration, Configuration
|
|||
Result<Configuration> restoreToDefaultValues(final Long configurationNodeId);
|
||||
|
||||
/** Restores the attribute values to the default values that have been set for the specified configuration
|
||||
* on initialization. This are the base default values if the configuration has no template or the default
|
||||
* on initialization. These are the base default values if the configuration has no template or the default
|
||||
* values from the template if there is one assigned to the configuration.
|
||||
*
|
||||
* <p>
|
||||
* In fact. this just gets the initial configuration values and reset the current values with that one
|
||||
*
|
||||
* @param configuration the Configuration that defines the ConfigurationNode identifier
|
||||
|
@ -107,7 +107,7 @@ public interface ConfigurationDAO extends EntityDAO<Configuration, Configuration
|
|||
|
||||
/** Use this to get the follow-up configuration identifer for a specified configuration node.
|
||||
*
|
||||
* @param configurationNode ConfigurationNode to get the current follow-up configuration from
|
||||
* @param configNodeId ConfigurationNode to get the current follow-up configuration from
|
||||
* @return the current follow-up configuration identifier */
|
||||
Result<Long> getFollowupConfigurationId(Long configNodeId);
|
||||
|
||||
|
|
|
@ -78,4 +78,15 @@ public interface ConfigurationValueDAO extends EntityDAO<ConfigurationValue, Con
|
|||
* @return the String value of the SEB setting attribute */
|
||||
Result<String> getConfigAttributeValue(Long configId, Long attrId);
|
||||
|
||||
/** This applies the ignore SEB Service policy as described in Issue SEBWIN-464 on the given configuration
|
||||
*
|
||||
* @param configurationId The configuration identifier*/
|
||||
void applyIgnoreSEBService(Long institutionId, Long configurationId);
|
||||
|
||||
/** Saves the given hashed quit password as value for the given configuration
|
||||
*
|
||||
* @param configurationId The configuration identifier
|
||||
* @param pwd The hashed quit password
|
||||
* @return Result refer to void or to an error when happened*/
|
||||
Result<Void> saveQuitPassword(Long configurationId, String pwd);
|
||||
}
|
||||
|
|
|
@ -8,6 +8,8 @@
|
|||
|
||||
package ch.ethz.seb.sebserver.webservice.servicelayer.dao.impl;
|
||||
|
||||
import static ch.ethz.seb.sebserver.webservice.datalayer.batis.mapper.ConfigurationValueRecordDynamicSqlSupport.*;
|
||||
import static java.lang.reflect.Array.set;
|
||||
import static org.mybatis.dynamic.sql.SqlBuilder.isEqualTo;
|
||||
import static org.mybatis.dynamic.sql.SqlBuilder.isIn;
|
||||
|
||||
|
@ -27,9 +29,12 @@ import java.util.function.Function;
|
|||
import java.util.function.Predicate;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
import ch.ethz.seb.sebserver.webservice.servicelayer.dao.*;
|
||||
import org.apache.commons.lang3.BooleanUtils;
|
||||
import org.apache.commons.lang3.StringUtils;
|
||||
import org.mybatis.dynamic.sql.SqlBuilder;
|
||||
import org.mybatis.dynamic.sql.update.MyBatis3UpdateModelAdapter;
|
||||
import org.mybatis.dynamic.sql.update.UpdateDSL;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.context.annotation.Lazy;
|
||||
|
@ -52,11 +57,6 @@ import ch.ethz.seb.sebserver.webservice.datalayer.batis.mapper.ConfigurationValu
|
|||
import ch.ethz.seb.sebserver.webservice.datalayer.batis.model.ConfigurationAttributeRecord;
|
||||
import ch.ethz.seb.sebserver.webservice.datalayer.batis.model.ConfigurationRecord;
|
||||
import ch.ethz.seb.sebserver.webservice.datalayer.batis.model.ConfigurationValueRecord;
|
||||
import ch.ethz.seb.sebserver.webservice.servicelayer.dao.ConfigurationValueDAO;
|
||||
import ch.ethz.seb.sebserver.webservice.servicelayer.dao.DAOLoggingSupport;
|
||||
import ch.ethz.seb.sebserver.webservice.servicelayer.dao.FilterMap;
|
||||
import ch.ethz.seb.sebserver.webservice.servicelayer.dao.ResourceNotFoundException;
|
||||
import ch.ethz.seb.sebserver.webservice.servicelayer.dao.TransactionHandler;
|
||||
import ch.ethz.seb.sebserver.webservice.servicelayer.sebconfig.ExamConfigInitService;
|
||||
|
||||
@Lazy
|
||||
|
@ -156,6 +156,37 @@ public class ConfigurationValueDAOImpl implements ConfigurationValueDAO {
|
|||
});
|
||||
}
|
||||
|
||||
|
||||
private static final String KEY_SEB_SERVICE_POLICY = "sebServicePolicy";
|
||||
private static final String KEY_ATTR_1 = "enableWindowsUpdate";
|
||||
private static final String KEY_ATTR_2 = "enableChromeNotifications";
|
||||
private static final String KEY_ATTR_3 = "allowScreenSharing";
|
||||
@Override
|
||||
public void applyIgnoreSEBService(final Long institutionId, final Long configurationId) {
|
||||
try {
|
||||
|
||||
final String val = this.getConfigAttributeValue(configurationId, 318L).getOrThrow();
|
||||
final boolean ignoreSEBService = BooleanUtils.toBoolean(val);
|
||||
if (ignoreSEBService) {
|
||||
// set default values sebServicePolicy
|
||||
this.setDefaultValues(institutionId, configurationId, 300L)
|
||||
.onError(error -> log.warn("Failed to set defaultValue on IgnoreSEBService for sebServicePolicy"));
|
||||
// set default values enableWindowsUpdate
|
||||
this.setDefaultValues(institutionId, configurationId, 321L)
|
||||
.onError(error -> log.warn("Failed to set defaultValue on IgnoreSEBService for sebServicePolicy"));
|
||||
// set default values enableChromeNotifications
|
||||
this.setDefaultValues(institutionId, configurationId, 322L)
|
||||
.onError(error -> log.warn("Failed to set defaultValue on IgnoreSEBService for sebServicePolicy"));
|
||||
// set default values allowScreenSharing
|
||||
this.setDefaultValues(institutionId, configurationId, 303L)
|
||||
.onError(error -> log.warn("Failed to set defaultValue on IgnoreSEBService for sebServicePolicy"));
|
||||
}
|
||||
|
||||
} catch (final Exception e) {
|
||||
log.error("Failed to apply Ignore SEB Service to configuration: {}", configurationId, e);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
@Transactional(readOnly = true)
|
||||
public Result<Collection<ConfigurationValue>> allOf(final Set<Long> pks) {
|
||||
|
@ -276,6 +307,37 @@ public class ConfigurationValueDAOImpl implements ConfigurationValueDAO {
|
|||
.onError(TransactionHandler::rollback);
|
||||
}
|
||||
|
||||
@Override
|
||||
@Transactional
|
||||
public Result<Void> saveQuitPassword(final Long configId, final String pwd) {
|
||||
return Result.tryCatch(() -> {
|
||||
|
||||
final Long hashedQuitPasswordId = configurationAttributeRecordMapper.selectIdsByExample()
|
||||
.where(ConfigurationAttributeRecordDynamicSqlSupport.name, isEqualTo("hashedQuitPassword"))
|
||||
.build()
|
||||
.execute()
|
||||
.get(0);
|
||||
|
||||
UpdateDSL<MyBatis3UpdateModelAdapter<Integer>> dsl = UpdateDSL.updateWithMapper(
|
||||
configurationValueRecordMapper::update, configurationValueRecord);
|
||||
|
||||
if (StringUtils.isNotBlank(pwd)) {
|
||||
dsl = dsl.set(value).equalTo(pwd);
|
||||
} else {
|
||||
dsl = dsl.set(value).equalToNull();
|
||||
}
|
||||
|
||||
final Integer execute = dsl.where(configurationId, isEqualTo(configId))
|
||||
.and(configurationAttributeId, isEqualTo(hashedQuitPasswordId))
|
||||
.build()
|
||||
.execute();
|
||||
|
||||
if (execute == null || execute != 1) {
|
||||
throw new NoResourceFoundException(EntityType.CONFIGURATION_VALUE, "Failed to force save");
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@Override
|
||||
@Transactional
|
||||
public Result<Collection<EntityKey>> delete(final Set<EntityKey> all) {
|
||||
|
|
|
@ -158,4 +158,10 @@ public interface ExamConfigService {
|
|||
* @return Result refer to the given ConfigurationNode or to an error if the check has failed */
|
||||
Result<ConfigurationNode> checkSaveConsistency(ConfigurationNode configurationNode);
|
||||
|
||||
/** Sets or resets an imported (hashed) quiz password
|
||||
*
|
||||
* @param node the ConfigurationNode
|
||||
* @param quitPassword the quit password to reset (if null or empty, no quit password shall be set)
|
||||
* @return Result refer to the origin ConfigurationNode or to an error when happened*/
|
||||
Result<ConfigurationNode> setQuitPassword(ConfigurationNode node, String quitPassword);
|
||||
}
|
||||
|
|
|
@ -20,6 +20,7 @@ import java.util.concurrent.Future;
|
|||
import java.util.stream.Collectors;
|
||||
import java.util.stream.Stream;
|
||||
|
||||
import ch.ethz.seb.sebserver.webservice.servicelayer.dao.*;
|
||||
import org.apache.commons.codec.digest.DigestUtils;
|
||||
import org.apache.commons.io.IOUtils;
|
||||
import org.apache.commons.lang3.StringUtils;
|
||||
|
@ -41,10 +42,6 @@ import ch.ethz.seb.sebserver.gbl.model.sebconfig.ConfigurationTableValues;
|
|||
import ch.ethz.seb.sebserver.gbl.model.sebconfig.ConfigurationValue;
|
||||
import ch.ethz.seb.sebserver.gbl.profile.WebServiceProfile;
|
||||
import ch.ethz.seb.sebserver.gbl.util.Result;
|
||||
import ch.ethz.seb.sebserver.webservice.servicelayer.dao.ConfigurationAttributeDAO;
|
||||
import ch.ethz.seb.sebserver.webservice.servicelayer.dao.ConfigurationDAO;
|
||||
import ch.ethz.seb.sebserver.webservice.servicelayer.dao.ConfigurationNodeDAO;
|
||||
import ch.ethz.seb.sebserver.webservice.servicelayer.dao.ExamConfigurationMapDAO;
|
||||
import ch.ethz.seb.sebserver.webservice.servicelayer.sebconfig.ConfigurationFormat;
|
||||
import ch.ethz.seb.sebserver.webservice.servicelayer.sebconfig.ConfigurationValueValidator;
|
||||
import ch.ethz.seb.sebserver.webservice.servicelayer.sebconfig.ExamConfigService;
|
||||
|
@ -64,6 +61,7 @@ public class ExamConfigServiceImpl implements ExamConfigService {
|
|||
private final ExamConfigIO examConfigIO;
|
||||
private final ConfigurationNodeDAO configurationNodeDAO;
|
||||
private final ConfigurationAttributeDAO configurationAttributeDAO;
|
||||
private final ConfigurationValueDAO configurationValueDAO;
|
||||
private final ExamConfigurationMapDAO examConfigurationMapDAO;
|
||||
private final Collection<ConfigurationValueValidator> validators;
|
||||
private final ClientCredentialService clientCredentialService;
|
||||
|
@ -75,6 +73,7 @@ public class ExamConfigServiceImpl implements ExamConfigService {
|
|||
final ExamConfigIO examConfigIO,
|
||||
final ConfigurationNodeDAO configurationNodeDAO,
|
||||
final ConfigurationAttributeDAO configurationAttributeDAO,
|
||||
final ConfigurationValueDAO configurationValueDAO,
|
||||
final ExamConfigurationMapDAO examConfigurationMapDAO,
|
||||
final Collection<ConfigurationValueValidator> validators,
|
||||
final ClientCredentialService clientCredentialService,
|
||||
|
@ -85,6 +84,7 @@ public class ExamConfigServiceImpl implements ExamConfigService {
|
|||
this.examConfigIO = examConfigIO;
|
||||
this.configurationNodeDAO = configurationNodeDAO;
|
||||
this.configurationAttributeDAO = configurationAttributeDAO;
|
||||
this.configurationValueDAO = configurationValueDAO;
|
||||
this.examConfigurationMapDAO = examConfigurationMapDAO;
|
||||
this.validators = validators;
|
||||
this.clientCredentialService = clientCredentialService;
|
||||
|
@ -384,7 +384,7 @@ public class ExamConfigServiceImpl implements ExamConfigService {
|
|||
|
||||
if (streamDecrypted != null) {
|
||||
final Exception exception = streamDecrypted.get();
|
||||
if (exception != null && exception instanceof APIMessageException) {
|
||||
if (exception instanceof APIMessageException) {
|
||||
throw exception;
|
||||
}
|
||||
}
|
||||
|
@ -478,6 +478,34 @@ public class ExamConfigServiceImpl implements ExamConfigService {
|
|||
});
|
||||
}
|
||||
|
||||
@Override
|
||||
public Result<ConfigurationNode> setQuitPassword(final ConfigurationNode node, final String quitPassword) {
|
||||
return Result.tryCatch(() -> {
|
||||
|
||||
final Long followupId = this.configurationDAO
|
||||
.getFollowupConfigurationId(node.id)
|
||||
.getOrThrow();
|
||||
|
||||
this.configurationValueDAO.saveQuitPassword(followupId, quitPassword)
|
||||
.onError(error -> log.warn(
|
||||
"Failed to reset quit password for configuration: {} cause: {}",
|
||||
node,
|
||||
error.getMessage()));
|
||||
|
||||
final Configuration config = this.configurationDAO
|
||||
.getConfigurationLastStableVersion(node.id)
|
||||
.getOrThrow();
|
||||
|
||||
this.configurationValueDAO.saveQuitPassword(config.id, quitPassword)
|
||||
.onError(error -> log.warn(
|
||||
"Failed to reset quit password for configuration: {} cause: {}",
|
||||
node,
|
||||
error.getMessage()));
|
||||
|
||||
return node;
|
||||
});
|
||||
}
|
||||
|
||||
@Override
|
||||
public Result<ConfigurationNode> resetToTemplateSettings(final ConfigurationNode configurationNode) {
|
||||
return Result.tryCatch(() -> {
|
||||
|
|
|
@ -554,6 +554,7 @@ public class ExamSessionServiceImpl implements ExamSessionService {
|
|||
.getOr(false);
|
||||
|
||||
if (!BooleanUtils.toBoolean(isUpToDate)) {
|
||||
// TODO this should only flush the exam cache but not the SEB connection cache
|
||||
return flushCache(exam);
|
||||
} else {
|
||||
return Result.of(exam);
|
||||
|
@ -570,13 +571,11 @@ public class ExamSessionServiceImpl implements ExamSessionService {
|
|||
return Result.tryCatch(() -> {
|
||||
this.examSessionCacheService.evict(exam);
|
||||
this.examSessionCacheService.evictDefaultSEBConfig(exam.id);
|
||||
// evict client connection
|
||||
this.clientConnectionDAO
|
||||
.getConnectionTokens(exam.id)
|
||||
.getOrElse(Collections::emptyList)
|
||||
.forEach(token -> {
|
||||
// evict client connection
|
||||
this.examSessionCacheService.evictClientConnection(token);
|
||||
});
|
||||
.forEach(this.examSessionCacheService::evictClientConnection);
|
||||
|
||||
return exam;
|
||||
});
|
||||
|
|
|
@ -20,6 +20,7 @@ import javax.servlet.http.HttpServletRequest;
|
|||
import javax.servlet.http.HttpServletResponse;
|
||||
import javax.validation.Valid;
|
||||
|
||||
import ch.ethz.seb.sebserver.webservice.servicelayer.dao.*;
|
||||
import org.apache.commons.io.IOUtils;
|
||||
import org.apache.commons.lang3.BooleanUtils;
|
||||
import org.apache.commons.lang3.StringUtils;
|
||||
|
@ -64,13 +65,6 @@ import ch.ethz.seb.sebserver.webservice.servicelayer.authorization.Authorization
|
|||
import ch.ethz.seb.sebserver.webservice.servicelayer.authorization.UserService;
|
||||
import ch.ethz.seb.sebserver.webservice.servicelayer.authorization.impl.SEBServerUser;
|
||||
import ch.ethz.seb.sebserver.webservice.servicelayer.bulkaction.BulkActionService;
|
||||
import ch.ethz.seb.sebserver.webservice.servicelayer.dao.ConfigurationDAO;
|
||||
import ch.ethz.seb.sebserver.webservice.servicelayer.dao.ConfigurationNodeDAO;
|
||||
import ch.ethz.seb.sebserver.webservice.servicelayer.dao.ExamConfigurationMapDAO;
|
||||
import ch.ethz.seb.sebserver.webservice.servicelayer.dao.FilterMap;
|
||||
import ch.ethz.seb.sebserver.webservice.servicelayer.dao.OrientationDAO;
|
||||
import ch.ethz.seb.sebserver.webservice.servicelayer.dao.UserActivityLogDAO;
|
||||
import ch.ethz.seb.sebserver.webservice.servicelayer.dao.ViewDAO;
|
||||
import ch.ethz.seb.sebserver.webservice.servicelayer.sebconfig.ExamConfigService;
|
||||
import ch.ethz.seb.sebserver.webservice.servicelayer.sebconfig.ExamConfigTemplateService;
|
||||
import ch.ethz.seb.sebserver.webservice.servicelayer.session.ExamConfigUpdateService;
|
||||
|
@ -91,6 +85,7 @@ public class ConfigurationNodeController extends EntityController<ConfigurationN
|
|||
private final ExamConfigService sebExamConfigService;
|
||||
private final ExamConfigUpdateService examConfigUpdateService;
|
||||
private final ExamConfigTemplateService sebExamConfigTemplateService;
|
||||
private final ConfigurationValueDAO configurationValueDAO;
|
||||
|
||||
protected ConfigurationNodeController(
|
||||
final AuthorizationService authorization,
|
||||
|
@ -105,7 +100,8 @@ public class ConfigurationNodeController extends EntityController<ConfigurationN
|
|||
final OrientationDAO orientationDAO,
|
||||
final ExamConfigService sebExamConfigService,
|
||||
final ExamConfigUpdateService examConfigUpdateService,
|
||||
final ExamConfigTemplateService sebExamConfigTemplateService) {
|
||||
final ExamConfigTemplateService sebExamConfigTemplateService,
|
||||
final ConfigurationValueDAO configurationValueDAO) {
|
||||
|
||||
super(authorization,
|
||||
bulkActionService,
|
||||
|
@ -122,6 +118,7 @@ public class ConfigurationNodeController extends EntityController<ConfigurationN
|
|||
this.sebExamConfigService = sebExamConfigService;
|
||||
this.examConfigUpdateService = examConfigUpdateService;
|
||||
this.sebExamConfigTemplateService = sebExamConfigTemplateService;
|
||||
this.configurationValueDAO = configurationValueDAO;
|
||||
}
|
||||
|
||||
@Override
|
||||
|
@ -312,7 +309,6 @@ public class ConfigurationNodeController extends EntityController<ConfigurationN
|
|||
this.checkModifyPrivilege(institutionId);
|
||||
|
||||
final SEBServerUser currentUser = this.authorization.getUserService().getCurrentUser();
|
||||
|
||||
final ConfigurationNode configurationNode = new ConfigurationNode(
|
||||
null,
|
||||
institutionId,
|
||||
|
@ -338,8 +334,7 @@ public class ConfigurationNodeController extends EntityController<ConfigurationN
|
|||
EntityType.CONFIGURATION_NODE))));
|
||||
}
|
||||
|
||||
final Configuration config = doImport
|
||||
.getOrThrow();
|
||||
final Configuration config = doImport.getOrThrow();
|
||||
|
||||
// user log
|
||||
this.configurationNodeDAO.byPK(config.configurationNodeId)
|
||||
|
@ -387,6 +382,27 @@ public class ConfigurationNodeController extends EntityController<ConfigurationN
|
|||
.getOrThrow();
|
||||
}
|
||||
|
||||
@RequestMapping(
|
||||
path = API.MODEL_ID_VAR_PATH_SEGMENT + API.CONFIGURATION_SET_QUIT_PWD_PATH_SEGMENT,
|
||||
method = RequestMethod.POST,
|
||||
consumes = MediaType.APPLICATION_FORM_URLENCODED_VALUE,
|
||||
produces = MediaType.APPLICATION_JSON_VALUE)
|
||||
public ConfigurationNode setQuitPassword(
|
||||
@PathVariable final Long modelId,
|
||||
@RequestParam(
|
||||
name = API.PARAM_INSTITUTION_ID,
|
||||
required = true,
|
||||
defaultValue = UserService.USERS_INSTITUTION_AS_DEFAULT) final Long institutionId,
|
||||
@RequestParam(name = API.QUIT_PASSWORD_ATTR_NAME, required = false) final String quitPassword) {
|
||||
|
||||
return this.entityDAO.byPK(modelId)
|
||||
.flatMap(this.authorization::checkModify)
|
||||
.flatMap(configNode ->this.sebExamConfigService.setQuitPassword(configNode, quitPassword))
|
||||
.getOrThrow();
|
||||
}
|
||||
|
||||
|
||||
|
||||
@RequestMapping(
|
||||
path = API.PARENT_MODEL_ID_VAR_PATH_SEGMENT + API.TEMPLATE_ATTRIBUTE_ENDPOINT,
|
||||
method = RequestMethod.GET,
|
||||
|
@ -600,6 +616,8 @@ public class ConfigurationNodeController extends EntityController<ConfigurationN
|
|||
password)
|
||||
.getOrThrow();
|
||||
|
||||
processPostImport(result);
|
||||
|
||||
return Result.of(result);
|
||||
|
||||
} catch (final Exception e) {
|
||||
|
@ -608,4 +626,8 @@ public class ConfigurationNodeController extends EntityController<ConfigurationN
|
|||
}
|
||||
}
|
||||
|
||||
private void processPostImport(final Configuration config) {
|
||||
this.configurationValueDAO.applyIgnoreSEBService(config.institutionId, config.id);
|
||||
}
|
||||
|
||||
}
|
||||
|
|
|
@ -55,10 +55,10 @@ sebserver.webservice.lms.address.alias=lms.mockup.com=lms.address.alias
|
|||
sebserver.webservice.cache.moodle.course.pageSize=250
|
||||
|
||||
# actuator configuration
|
||||
management.server.port=${server.port}
|
||||
management.endpoints.web.base-path=/management
|
||||
management.endpoints.web.exposure.include=logfile,loggers,jolokia
|
||||
management.endpoints.web.path-mapping.jolokia=jmx
|
||||
#management.server.port=${server.port}
|
||||
#management.endpoints.web.base-path=/management
|
||||
#management.endpoints.web.exposure.include=logfile,loggers,jolokia
|
||||
#management.endpoints.web.path-mapping.jolokia=jmx
|
||||
### Open API Documentation
|
||||
springdoc.api-docs.enabled=true
|
||||
springdoc.swagger-ui.enabled=true
|
||||
|
|
|
@ -1012,7 +1012,7 @@ sebserver.clientconfig.form.sebServerFallbackPasswordHash=Fallback Password
|
|||
sebserver.clientconfig.form.sebServerFallbackPasswordHash.tooltip=A password if set a SEB Client user must provide before SEB starts the fallback procedure
|
||||
sebserver.clientconfig.form.sebServerFallbackPasswordHash.confirm=Confirm Password
|
||||
sebserver.clientconfig.form.sebServerFallbackPasswordHash.tooltip.confirm=Please confirm the fallback password
|
||||
sebserver.clientconfig.form.hashedQuitPassword=Quit Password
|
||||
|
||||
sebserver.clientconfig.form.hashedQuitPassword.tooltip=A password if set a SEB user must provide to be able to quit SEB
|
||||
sebserver.clientconfig.form.hashedQuitPassword.confirm=Confirm Password
|
||||
sebserver.clientconfig.form.hashedQuitPassword.tooltip.confirm=Please confirm the quit password
|
||||
|
@ -1123,12 +1123,16 @@ sebserver.examconfig.action.import.config.text=To import a valid SEB settings co
|
|||
sebserver.examconfig.action.import.settings.text=To import a valid SEB settings configuration file (.seb) into an existing exam configuration,<br/>please select the file from the local directory and provide a password if the file is protected.<br/>Please note that this import will override the existing SEB settings of this exam configuration<br/>and also try to publish the changes if selected.
|
||||
sebserver.examconfig.action.import.auto-publish=Publish
|
||||
sebserver.examconfig.action.import.auto-publish.tooltip=Try to automatically publish the imported changes
|
||||
sebserver.examconfig.action.import.quitpwd.title=Quit Password in imported Configuration
|
||||
sebserver.examconfig.action.import.quitpwd.removed=The hashed quit password was removed,<br/>please set a new password below.
|
||||
sebserver.examconfig.action.import.quitpwd.set=No quit password set,<br/>enter a password below (or click "ok" if none is to be entered).
|
||||
|
||||
sebserver.examconfig.action.state-change.confirm=This configuration is already attached to an exam.<br/>Please note that changing an attached configuration will take effect on the exam when the configuration changes are saved<br/><br/>Are you sure to change this configuration to an editable state?
|
||||
sebserver.examconfig.message.error.file=Please select a valid SEB Exam Configuration File
|
||||
sebserver.examconfig.message.confirm.delete=This will completely delete the exam configuration.<br/><br/>Are you sure you want to delete this exam configuration?
|
||||
sebserver.examconfig.message.consistency.error=The exam configuration cannot be deleted since it is used by at least one running or upcoming exam.<br/>Please remove the exam configuration from running and upcoming exams first.
|
||||
sebserver.examconfig.message.delete.confirm=The exam configuration ({0}) was successfully deleted.
|
||||
sebserver.examconfig.message.delete.partialerror=The exam configuration ({0}) was deleted but there where some dependency errors:<br/><br/>{1}
|
||||
sebserver.examconfig.message.delete.partialerror=The exam configuration ({0}) was deleted but there were some dependency errors:<br/><br/>{1}
|
||||
sebserver.examconfig.action.restore.template.settings=Reset To Template Settings
|
||||
sebserver.examconfig.action.restore.template.settings.success=Configuration settings successfully restored to template defaults
|
||||
sebserver.examconfig.action.restore.template.settings.confirm=Are you sure to reset this configuration setting to the templates settings?
|
||||
|
@ -1157,7 +1161,7 @@ sebserver.examconfig.status.READY_TO_USE=Ready To Use
|
|||
sebserver.examconfig.status.IN_USE=In Use
|
||||
sebserver.examconfig.status.ARCHIVED=Archived
|
||||
|
||||
sebserver.examconfig.props.from.unpublished.message=Note: There are unpublished changes to this Settings. Use 'Save/Publish Settings' to make sure the settings are active.
|
||||
sebserver.examconfig.props.from.unpublished.message=Note: There are unpublished changes to these Settings. Use 'Save/Publish Settings' to make sure the settings are active.
|
||||
sebserver.examconfig.props.from.title=SEB Settings ({0})
|
||||
sebserver.examconfig.props.from.title.subtitle=
|
||||
sebserver.examconfig.props.form.views.general=General
|
||||
|
|
Loading…
Reference in a new issue