DatatableRestControllerV2.java

package sk.iway.iwcm.system.datatable;

import org.eclipse.persistence.expressions.Expression;
import org.eclipse.persistence.expressions.ExpressionBuilder;
import org.eclipse.persistence.jpa.JpaEntityManager;
import org.eclipse.persistence.queries.ReadAllQuery;
import org.springframework.beans.BeanWrapperImpl;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.data.domain.Example;
import org.springframework.data.domain.ExampleMatcher;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
import org.springframework.data.jpa.domain.Specification;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.JpaSpecificationExecutor;
import org.springframework.http.HttpStatus;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.validation.BeanPropertyBindingResult;
import org.springframework.validation.BindingResult;
import org.springframework.validation.Errors;
import org.springframework.validation.ObjectError;
import org.springframework.web.bind.WebDataBinder;
import org.springframework.web.bind.annotation.*;

import sk.iway.iwcm.*;
import sk.iway.iwcm.common.CloudToolsForCore;
import sk.iway.iwcm.database.ActiveRecordBase;
import sk.iway.iwcm.i18n.Prop;
import sk.iway.iwcm.system.ConstantsV9;
import sk.iway.iwcm.system.adminlog.AuditEntityListener;
import sk.iway.iwcm.system.datatable.NotifyBean.NotifyType;
import sk.iway.iwcm.system.datatable.annotations.DataTableColumnEditor;
import sk.iway.iwcm.system.datatable.annotations.DataTableColumnEditorAttr;
import sk.iway.iwcm.system.datatable.spring.DomainIdRepository;
import sk.iway.iwcm.system.jpa.JpaTools;
import sk.iway.iwcm.system.spring.NullAwareBeanUtils;
import sk.iway.iwcm.system.stripes.MultipartWrapper;
import sk.iway.iwcm.users.UsersDB;

//import javax.persistence.EntityManagerFactory;
import javax.persistence.Id;
//import javax.persistence.PersistenceUnit;
import javax.persistence.Query;
import javax.persistence.criteria.CriteriaBuilder;
import javax.persistence.criteria.Path;
import javax.persistence.criteria.Predicate;
import javax.persistence.criteria.Root;
import javax.servlet.http.HttpServletRequest;
import javax.validation.*;

import java.io.Serializable;
import java.lang.reflect.Field;
import java.lang.reflect.InvocationTargetException;
import java.lang.reflect.Method;
import java.math.BigDecimal;
import java.sql.Timestamp;
import java.time.LocalDate;
import java.time.LocalDateTime;
import java.util.*;

/**
 * Title        webjet8
 * Company      Interway a. s. (www.interway.sk)
 * Copyright    Interway a. s. (c) 2001-2019
 * @author       tmarcinkova $
 * @created      2019/05/10 12:50
 *
 *  Abstraktny univerzalny RestController na pracu s DataTables Editor-om
 *
 */
public abstract class DatatableRestControllerV2<T, ID extends Serializable>
{
	private final JpaRepository<T, Long> repo;

	//pozor: po zmene je potrebne opravit aj prefix v src/main/webapp/admin/v9/src/js/app.js
	private static final String REGEX_PREFIX = "regex:";

	@Autowired
	private HttpServletRequest request;

	@Autowired
	private Validator validator;

	private static final ThreadLocal<ThreadBean> threadData = new ThreadLocal<>(); //NOSONAR

	boolean checkDomainId = false;

	protected DatatableRestControllerV2() {
		this.repo = null;
	}

	protected DatatableRestControllerV2(JpaRepository<T, Long> repo) {
		this.repo = repo;

		//over, ci maju byt pouzite automaticke podmienky so stlpcom domain_id
		if (InitServlet.isTypeCloud() || Constants.getBoolean("enableStaticFilesExternalDir")==true) {
			if (repo instanceof DomainIdRepository) checkDomainId = true;
		}
	}

	/***************************** CITANIE / ZAPIS DAT *****************************/

	/**
	 * Vlozi NOVU entitu do databazy
	 * @param entity
	 * @return
	 */
	public T insertItem(T entity) {
		//musime z editoFields najskor prepisat hodnoty do entity
		T processed = processToEntity(entity, ProcessItemAction.CREATE);
		//ulozime
		T saved = repo.save(processed);
		//nastavime editorFields atributy
		return processFromEntity(saved, ProcessItemAction.CREATE, 1);
	}

	/**
	 * Ulozi existujucu entitu do databazy
	 * @param entity
	 * @param id
	 * @return
	 */
	public T editItem(T entity, long id) {

		//zachovaj thread bean, lebo volanie getOne ho zmaze a moze to byt nastavene z beforeSave metody
		boolean forceReload = isForceReload();
		List<NotifyBean> notify = getThreadData().getNotify();
		boolean isImporting = isImporting();

		//toto nam zabezpeci aby sa nam nestratili udaje, ktore nemame v editore
		T one = getOne(id);

		if (isForceReload()) setForceReload(forceReload);
		if (notify!=null) addNotify(notify);
		setImporting(isImporting);

		copyEntityIntoOriginal(entity, one);

		//musime z editoFields najskor prepisat hodnoty do entity
		T processed = processToEntity(one, ProcessItemAction.EDIT);
		//ulozime
		T saved = repo.save(processed);
		//nastavime editorFields atributy
		return processFromEntity(saved, ProcessItemAction.EDIT, 1);
	}

	/**
	 * metoda pre ziskanie entity s rovnakou hodnotou v stlci propertyName ako hodnota v obj
	 * @param propertyName
	 * @param obj
	 * @return
	 * @throws IllegalAccessException
	 * @throws NoSuchMethodException
	 * @throws InvocationTargetException
	 */
	@SuppressWarnings("unchecked")
	public List<T> findItemBy(String propertyName, T original) throws IllegalAccessException, NoSuchMethodException, InvocationTargetException, InstantiationException {

		//musime spravit najskor kopiu obj, aby sme na nej mohli zavolat processToEntity bez posahania povodnej entity
		T obj = (T)original.getClass().getDeclaredConstructor().newInstance();
		NullAwareBeanUtils.copyProperties(original, obj);
		processToEntity(obj, ProcessItemAction.EDIT);

		JpaEntityManager entityManager = JpaTools.getEclipseLinkEntityManager(original.getClass());
		ReadAllQuery raq = new ReadAllQuery(original.getClass());
		ExpressionBuilder builder = new ExpressionBuilder();

		BeanWrapperImpl bw = new BeanWrapperImpl(obj);
		Object value = bw.getPropertyValue(propertyName);
		Expression exp = builder.get(propertyName).equal(value);

		//pridaj domainId podmienku ak entita obsahuje domainId stlpec (aby sa neaktualizovali entity v inej domene)
		if (InitServlet.isTypeCloud() || Constants.getBoolean("enableStaticFilesExternalDir")==true) {
			if (bw.getPropertyType("domainId")!=null) {
				exp = exp.and(builder.get("domainId").equal(CloudToolsForCore.getDomainId()));
			}
		}

		raq.setSelectionCriteria(exp);

		Query query = entityManager.createQuery(raq);
		List<T> list = query.getResultList();
		int rowCount = 1;
		for (T entity : list) {
			processFromEntity(entity, ProcessItemAction.FIND, rowCount);
			rowCount++;
		}
		return list;
	}

	@SuppressWarnings("rawtypes")
	private String getIdColumnName(T entity) {
		Class c = entity.getClass();
		for (Field field : c.getDeclaredFields()) {
			if (field.isAnnotationPresent(Id.class)) {
				return field.getName();
			}
		}

		return null;
	}

	/**
	 * metoda na upravu beanu v DB na zaklade nazvu stlpca v DB @updateByColumn.
	 * @param entity
	 * @param updateByColumn
	 * @return
	 * @throws IllegalAccessException
	 * @throws NoSuchMethodException
	 * @throws InvocationTargetException
	 */
	private List<T> editItemByColumn(T entity, String updateByColumn) throws IllegalAccessException, NoSuchMethodException, InvocationTargetException, InstantiationException {
		beforeSave(entity);

		// ziskame list entit, ktore obsahuju v stlpci updateByColumn rovnaku hodnotu ako entita
		String idColumnName = getIdColumnName(entity);
		if ("id".equalsIgnoreCase(updateByColumn) && idColumnName!=null) updateByColumn = idColumnName;

		List<T> itemsBy = findItemBy(updateByColumn, entity);
		if (itemsBy.isEmpty()) {
			//zmaz ID column
			try {
				if (idColumnName == null) idColumnName = "id";
				BeanWrapperImpl bw = new BeanWrapperImpl(entity);
				Long id = null;
				try { bw.setPropertyValue(idColumnName, id); }
				catch (Exception ex) { bw.setPropertyValue(idColumnName, 0); }
			} catch (Exception ex) {
				//failsafe
			}
			T processed = insertItem(entity);
			afterSave(entity, processed);
			return Arrays.asList(processed);
		}

		List<T> savedList = new ArrayList<>();
		// nastavenie dat a ulozenie
		for (T itemBy : itemsBy) {
			long id = 0;
			try {
				BeanWrapperImpl bw = new BeanWrapperImpl(itemBy);
				Object value = bw.getPropertyValue(idColumnName != null ? idColumnName : "id");
				if (value instanceof Number) {
					id = ((Number)value).longValue();
				}
			} catch (Exception ex) {
				//failsafe
			}

			BeanWrapperImpl bw = new BeanWrapperImpl(entity);
			//setni ID hodnotu na povodnej entite, aby sa nasledne korektne vykonala processToEntity so spravnym ID
			try {
				bw.setPropertyValue("id", Long.valueOf(id));
			} catch (Exception e) {
				//failsafe
			}
			if (idColumnName!=null && "id".equals(idColumnName)==false) {
				try {
					bw.setPropertyValue(idColumnName, Long.valueOf(id));
				} catch (Exception e) {
					//failsafe
				}
			}

			beforeSave(entity);
			checkItemPermsThrows(entity, id);

			T saved = editItem(entity, id);

			afterSave(entity, saved);

			savedList.add(saved);
		}

		return savedList;
	}

	/**
	 * Zmaze danu entitu z databazy
	 * @param entity
	 * @param id
	 * @return
	 */
	public boolean deleteItem(T entity, long id) {
		if (beforeDelete(repo.getById(id))) {
			try {
				if (checkDomainId) {
					//zmazanie pri pouziti domain_id riesime ziskanim zaznamu cez getOneItem ktore overi aj domain_id stlpec a naslednym zmazanim entity
					T fromRepo = getOneItem(id);
					if (fromRepo != null) {
						DomainIdRepository<T, ID> domainRepo = getDomainRepo();
						if (domainRepo!=null) domainRepo.delete(fromRepo);
					}
				} else {
					T fromRepo = getOneItem(id);
					if (fromRepo != null) {
						repo.delete(fromRepo);
					}
				}
				return true;
			} catch (Exception e) {
				Logger.error(DatatableRestControllerV2.class, e);
			}
		}

		return false;
	}

	/**
	 * Ziska z databazy entitu so zadanym id
	 * @param id
	 * @return
	 */
	public T getOneItem(long id) {
		T result = null;
		if (repo.existsById(id)) {
			Optional<T> byId = Optional.empty();

			if (checkDomainId) {
				DomainIdRepository<T, ID> domainRepo = getDomainRepo();
				if (domainRepo!=null) byId = domainRepo.findFirstByIdAndDomainId(id, CloudToolsForCore.getDomainId());
			} else {
				byId = repo.findById(id);
			}

			if (byId.isPresent()) {
				result = byId.get();
			}
		}
		return processFromEntity(result, ProcessItemAction.GETONE, 1);
	}

	/**
	 * Ziska z databazy vsetky zaznamy
	 * @param pageable
	 * @return
	 */
	public Page<T> getAllItems(Pageable pageable) {
		Page<T> page = null;

		if (checkDomainId) {
			//volame aj s podmienkami domain_id
			DomainIdRepository<T, ID> domainRepo = getDomainRepo();
			if (domainRepo!=null) {
				//ak nemame size parameter tak sa jedna o serverSide: false, takze pageable nemame pouzit
				if (getRequest().getParameter("size")==null) page = new DatatablePageImpl<>(domainRepo.findAllByDomainId(CloudToolsForCore.getDomainId()));
				else page = domainRepo.findAllByDomainId(CloudToolsForCore.getDomainId(), pageable);
			}
		} else {
			//ak nemame size parameter tak sa jedna o serverSide: false, takze pageable nemame pouzit
			if (getRequest().getParameter("size")==null) page = new DatatablePageImpl<>(repo.findAll());
			else page = repo.findAll(pageable);
		}

		processFromEntity(page, ProcessItemAction.GETALL);

		return page;
	}

	/**
	 * Vrati vsetky zaznamy, pricom vykona volanie metody addSpecSearch,
	 * cize je mozne pouzit URL parametre na filtrovanie vsetkych zaznamov.
	 * @param empty - prazdny objekt (je potrebny kvoli vytvoreniu instance)
	 * @param pageable
	 * @return
	 */
	public Page<T> getAllItemsIncludeSpecSearch(T empty, Pageable pageable) {

		Map<String, String> params = getParamsMap(getRequest());

		return searchItem(params, pageable, empty);
	}

	/**
	 * Convert URL/request parameters to Map<String paramName, String paramValue>
	 * @param request
	 * @return
	 */
	public static Map<String, String> getParamsMap(HttpServletRequest request) {
		Map<String, String[]> paramsMulti = request.getParameterMap();
		Map<String, String> params = new HashMap<>();
		for (Map.Entry<String, String[]> entry : paramsMulti.entrySet()) {
			String[] value = entry.getValue();
			if (value != null && value.length>0) {
 				params.put(entry.getKey(), value[0]);
			}
		}
		return params;
	}

	/**
	 * Vykona zadanu akciu (napr. rotacia obrazku v galerii)
	 *
	 * @param entity
	 * @param action
	 * @return false ak nastane chyba
	 */
	public boolean processAction(T entity, String action) {
		return true;
	}

	/**
	 * Vykona upravy vo vsetkych entitach v page objekte pred vratenim cez REST rozhranie
	 * napr. vyvola potrebne editorFields nastavenia (from entity to editorFields)
	 * @param page
	 * @param action - typ zmeny - create,edit,getall...
	 */
	public void processFromEntity(Page<T> page, ProcessItemAction action) {
		if (page == null || page.getContent()==null) { //NOSONAR
			return;
		}

		//pri exporte potrebujeme vsetky data z editorFields, takze sa tvarime ako rezim GETONE
		if (isExporting()) action = ProcessItemAction.GETONE;

		int rowCount = 1;
		for (T entity : page.getContent()) {
			processFromEntity(entity, action, rowCount);
			rowCount++;
		}
	}

	/**
	 * Vykona upravy vo vsetkych entitach v page objekte pred vratenim cez REST rozhranie
	 * napr. vyvola potrebne editorFields nastavenia (from entity to editorFields)
	 * @param entities - list entit
	 * @param action - typ zmeny - create,edit,getall...
	 */
	public void processFromEntity(List<T> entities, ProcessItemAction action) {
		if(entities == null) return;

		//pri exporte potrebujeme vsetky data z editorFields, takze sa tvarime ako rezim GETONE
		if (isExporting()) action = ProcessItemAction.GETONE;

		int rowCount = 1;
		for (T entity : entities) {
			processFromEntity(entity, action, rowCount);
			rowCount++;
		}
	}

	/**
	 * Vykona upravy v entite pred vratenim cez REST rozhranie
	 * napr. vyvola potrebne editorFields nastavenia (from entity to editorFields)
	 * @param entity
	 * @param action - typ zmeny - create,edit,getall...
	 * @param rowCount - cislo riadka v tabulke
	 */
	public T processFromEntity(T entity, ProcessItemAction action, int rowCount) {
		return processFromEntity(entity, action);
	}

	/**
	 * Vykona upravy v entite pred vratenim cez REST rozhranie
	 * napr. vyvola potrebne editorFields nastavenia (from entity to editorFields)
	 * @param entity
	 * @param action - typ zmeny - create,edit,getall...
	 */
	public T processFromEntity(T entity, ProcessItemAction action) {
		return entity;
	}

	/**
	 * Vykona upravy v entite pri odpovedi (ulozeni) z REST rozhranie
	 * napr. vyvola potrebne editorFields nastavenia (from editorFields to entity)
	 * @param entity
	 * @param action - typ zmeny - create,edit,getall,
	 */
	public T processToEntity(T entity, ProcessItemAction action) {
		return entity;
	}


	/**
	 * Do objektu searchProperties naplni hladane vyrazy, vrati pripadne upraveny ExampleMatcher
	 * @param params
	 * @param searchProperties - vratena mapa request parametrov pre vyhladavanie
	 * @param searchWrapped
	 * @param matcher - ak sa jedna o exampleMatcher, moze byt null
	 * @param isExampleSearch
	 * @return
	 */
	public ExampleMatcher getSearchProperties(Map<String, String> params, Map<String, String> searchProperties, BeanWrapperImpl searchWrapped, ExampleMatcher matcher, boolean isExampleSearch) {

		//final Map<String, String> searchProperties = new HashMap<>();

		for (Map.Entry<String, String> paramsEntry : params.entrySet()) {
			String key = getCleanKey(paramsEntry.getKey());

			if (!searchWrapped.isReadableProperty(key)) {
				Logger.debug(DatatableRestControllerV2.class, "Property is not readable, key; "+key);
				continue;
			}

			String value = paramsEntry.getValue();
			if (Tools.isEmpty(value)) continue;

			if (value.startsWith("range:") || value.startsWith("daterange:")) {
				searchProperties.put(key, value);
			}
			else {
				String cleanValue = getCleanValue(value);

				if (Tools.isEmpty(cleanValue)) {
					Logger.debug(DatatableRestControllerV2.class, "Value empty, key: "+key+", value: "+value);
					continue;
				}

				if (isExampleSearch) {
					ExampleMatcher.GenericPropertyMatcher genericPropertyMatcherFromValue = getGenericPropertyMatcherFromValue(value);
					matcher = matcher.withMatcher(key, genericPropertyMatcherFromValue);

					searchWrapped.setPropertyValue(key, getCleanValue(value));
				} else {
					searchProperties.put(key, value);
				}
			}
		}

		return matcher;
	}

	/**
	 * Vyhlada objekty podla zadaneho search objektu a pripadnych parametrov z requestu
	 * @param params
	 * @param pageable
	 * @param search
	 * @return
	 */
	@SuppressWarnings("unchecked")
	public Page<T> searchItem(@RequestParam Map<String, String> params, Pageable pageable, T search) {

		//urcenie sposobu hladania - by example alebo pomocou presnych parametrov
		boolean isExampleSearch = true;
		if (repo instanceof JpaSpecificationExecutor) {
			isExampleSearch = false;
		}

		ExampleMatcher matcher = ExampleMatcher.matchingAll();

		BeanWrapperImpl searchWrapped = new BeanWrapperImpl(search);

		final Map<String, String> searchProperties = new HashMap<>();
		matcher = getSearchProperties(params, searchProperties, searchWrapped, matcher, isExampleSearch);

		Page<T> page;
		if (isExampleSearch) {
			matcher = matcher.withIgnoreCase().withIgnoreNullValues();
			Example<T> exampleQuery = Example.of(search, matcher);

			if (pageable != null) page = repo.findAll(exampleQuery, pageable);
			else page = new DatatablePageImpl<>(repo.findAll(exampleQuery));
		} else {
			Specification<T> spec  = getSearchConditions(searchProperties, params, search);
			if (pageable != null) page = ((JpaSpecificationExecutor<T>)repo).findAll(spec, pageable);
			else page = new DatatablePageImpl<>(((JpaSpecificationExecutor<T>)repo).findAll(spec));
		}

		ProcessItemAction action = ProcessItemAction.FIND;
		//pri exporte potrebujeme vsetky data z editorFields, takze sa tvarime ako rezim GETONE
		if (isExporting()) action = ProcessItemAction.GETONE;

		int rowCount = 1;
		for (T entity : page.getContent()) {
			processFromEntity(entity, action, rowCount);
			rowCount++;
		}

		return page;
	}

	/**
	 * Doplni pri volani getAllItems options polozky pre vyberove polia
	 * @param page
	 */
	public void getOptions(DatatablePageImpl<T> page) {
		//page.addOptions(field, options, labelProperty, valueProperty, includeOriginalObject);
	}

	/*************************** BEZPECNOST A VALIDACIA ****************************/

	/**
	 * Validate access to this rest controller, this is not per row/entity check
	 * @param request
	 * @return
	 */
	public boolean checkAccessAllowed(HttpServletRequest request) {
		return true;
	}

	/**
	 * Check item perms, it's called with every save/delete/getOne action
	 * @param entity - current entity
	 * @param id - entity ID
	 * @param errors
	 * @return false if permissions is not allowed
	 */
	public boolean checkItemPerms(T entity, Long id) {
		return true;
	}

	/**
	 * Check and throws exception if item is not allowed to edit
	 * @param entity
	 * @param id
	 */
	private void checkItemPermsThrows(T entity, Long id) {
		boolean valid = checkItemPerms(entity, id);
		if (valid==false) throwConstraintViolation(getProp().getText("components.file_archiv.file_rename.nemate_pravo_na_tuto_editaciu"));
	}

	/**
	 * Pripravena metoda, odporucame implementovat v child triede.
	 * Metoda je volana pre kazdy odoslaby objekt.
	 * Chyby pridava do error objeku pomocou {@link Errors}.rejectValue
	 *
	 * @param request
	 * @param user
	 * @param errors
	 * @param id
	 * @param entity
	 */
	public void validateEditor(HttpServletRequest request, DatatableRequest<Long, T> target, Identity user, Errors errors, Long id, T entity) {}

	/**
	 * Metoda volana pred zmazanim enity z DB, moze vykonat dodatocne akcie
	 * napr. zmazanie suborov z disku, ulozenie do archivu,
	 * alebo specialne kontroly prav
	 * @param entity
	 * @return
	 */
	public boolean beforeDelete(T entity) {
		return true;
	}

	/**
	 * Metoda volana pred insert/save danej entity,
	 * da sa pouzit na nastavenie udajov, napr. datum ulozenia, domainId a podobne
	 * @param entity
	 */
	public void beforeSave(T entity) {

	}

	/**
	 * Metoda volana pred duplikovanim danej entity,
	 * da sa pouzit na resetovanie udajov, napr. priradena default stranka adresara a podobne
	 * @param entity
	 */
	public void beforeDuplicate(T entity) {

	}

	/**
	 * Metoda volana po duplikovanim danej entity,
	 * da sa pouzit na dokopirovanie udajov, napr. media web stranky
	 * @param entity - novo ulozena (zduplikovana) entita
	 * @param originalId - ID povodneho zaznamu ktory sa duplikoval
	 */
	public void afterDuplicate(T entity, Long originalId) {

	}

	/**
	 * Metoda volana po ulozeni entity.
	 * POZOR: pre novo vytvaranu entitu bude jej ID ulozene len v saved entite, povodna entity bude mat ID=0
	 * @param entity - povodna odoslana entita
	 * @param saved - uz ulozena verzia entity
	 */
	public void afterSave(T entity, T saved) {

	}

	/**
	 * Metoda volana po zmazanim enity z DB, moze vykonat dodatocne akcie
	 * napr. zmazanie suborov z disku, ulozenie do archivu,
	 * alebo obnovu cache objektov
	 * @param entity
	 * @return
	 */
	public void afterDelete(T entity, long id) {

	}

	/**
	 * Metoda sa vola pri importe po kazdom chunku
	 * @param chunk - aktualny chunk
	 * @param totalChunks - celkovy pocet chunkov
	 */
	public void afterImportChunk(int chunk, int totalChunks) {

	}


	/************************** PRIVATNE / SUPPORT metody***************************/

	private ExampleMatcher.GenericPropertyMatcher getGenericPropertyMatcherFromValue(String value) {
		if (value.startsWith("^") && value.endsWith("$")) {
			return ExampleMatcher.GenericPropertyMatchers.exact();
		} else if (value.startsWith("^")) {
			return ExampleMatcher.GenericPropertyMatchers.startsWith();
		} else if (value.endsWith("$")) {
			return ExampleMatcher.GenericPropertyMatchers.endsWith();
		} else if (value.startsWith(REGEX_PREFIX)) {
			return ExampleMatcher.GenericPropertyMatchers.regex();
		}
		return ExampleMatcher.GenericPropertyMatchers.contains();
	}

	public static String getCleanKey(String key) {
		return firstToLower(Tools.replace(key, "search", ""));
	}

	public static String getCleanValue(String value) {
		String result = value;
		if (result.length() >= 2) {
			if (result.startsWith("^"))
				result = result.substring(1);
			if (result.endsWith("$"))
				result = result.substring(0, result.length() - 1);
		}
		if (result.startsWith(REGEX_PREFIX) && result.length() > REGEX_PREFIX.length())
			result = result.substring(REGEX_PREFIX.length());

		return result;
	}

	private static String firstToLower(String value) {
		if (value == null || value.length() == 0) {
			return "";
		}

		char[] chArr = value.toCharArray();
		chArr[0] = Character.toLowerCase(chArr[0]);

		return new String(chArr);
	}

	/**
	 * metoda na validovanie dat z editora, vola metodu validateEditor nejprv ak existuje tak z child objektu, ak nie tak z tohto
	 * validateEditor sa vola pre kazdy objekt v requeste.
	 * @param request
	 * @param binder
	 * @throws IllegalAccessException
	 */
	@InitBinder
	protected void initBinder(HttpServletRequest request, WebDataBinder binder)
	{
		String requestURI = request.getRequestURI();
		if (requestURI.endsWith("/editor")) {
			@SuppressWarnings("unchecked")
			DatatableRequest<Long, T> target = (DatatableRequest<Long, T>) binder.getTarget();
			if (target != null) {
				//clear thread data
				getThreadData().setInvalidImportedRows(null);
				getThreadData().setInvalidImportedRowsErrors(null);
				getThreadData().clearNotifyList();

				Map<Long, T> data = target.getData();
				BindingResult bindingResult = binder.getBindingResult();
				Identity currentUser = UsersDB.getCurrentUser(request);
				if (data != null) {
					Set<Long> invalidImportedRows = new HashSet<>();
					setSkipWrongData( target.isSkipWrongData() );
					for (Map.Entry<Long, T> galleryEntityEntry : data.entrySet()) {
						Long key = galleryEntityEntry.getKey();
						T value = galleryEntityEntry.getValue();

						//zial, nefunguje inak nastavovanie errorov ako na konkretny field, takze to fejkujeme takymto objektom
						//moze to robit haluze pri editacii viacerych objektov naraz, u nas ale pouzivame len spolocne atributy, takze by to mohlo fungovat aj tam
						target.setErrorField(value);

						if (target.getDztotalchunkcount()>0) {
							setImporting(true);
							if (target.getDzchunkindex()>0) setLastImportedRow(target.getDzchunkindex()*Constants.getInt("chunksQuantity"));
							else setLastImportedRow(null);
						} else {
							setImporting(false);
						}

						setImportedColumns(target.getImportedColumns());

						//Use separe binding result
						BeanPropertyBindingResult entityBindingResult = new BeanPropertyBindingResult(binder.getTarget(), binder.getObjectName());
						validateEditor(request, target, currentUser, entityBindingResult, key, value);

						//If we DON'T WANT skip wrong data, push error back into main binding result
						if(isSkipWrongData() == false) {
							bindingResult.addAllErrors(entityBindingResult);
						} else {
							//We skipped wrong data, but use errors for user notification
							if(entityBindingResult.getErrorCount() > 0) {
								invalidImportedRows.add(key);

								addImportedColumnError( entityBindingResult.getFieldErrors().get(0), key.intValue() );
							}
						}
					}

					//Set which rows are invalid
					if(isSkipWrongData() == true) {
						setInvalidImportedRows(invalidImportedRows);
					}
				}

				if (bindingResult.hasFieldErrors()) {
					//vyhod este globalnu error hlasku, aby sa zobrazila aj pri tlacitkach a user si preklikal taby na konkretne chyby
					bindingResult.addError(new ObjectError("global", Prop.getInstance(request).getText("datatable.error.fieldErrorMessage")));
				}
			}
		}
	}


    private static java.lang.reflect.Field getDeclaredFiledRecursive(Class<?> initialClass, String fieldName) throws NoSuchFieldException {
        java.lang.reflect.Field field = null;
        int failsafe=0;
        Class<?> targetClass = initialClass;
        while (targetClass != null && failsafe++<15) {
            try {
                field = targetClass.getDeclaredField(fieldName);
                if(field != null) return field;
            } catch (NoSuchFieldException e) {}
            // Field not found in current class, continue to superclass
            targetClass = targetClass.getSuperclass();
        }

       throw new NoSuchFieldException("Field " + fieldName + " not found in class " + initialClass + " or in super classes");
    }

	private static boolean isFieldType(Class<?> initialClass, String fieldNam, DataTableColumnType type) {
		boolean isProvidedType = false;
		try {
			java.lang.reflect.Field field = getDeclaredFiledRecursive(initialClass, fieldNam);
			if (field.isAnnotationPresent(sk.iway.iwcm.system.datatable.annotations.DataTableColumn.class)) {
				DataTableColumnType[] inputType = field.getAnnotation(sk.iway.iwcm.system.datatable.annotations.DataTableColumn.class).inputType();

				//Check if field inputType is equal with provided inputType
				if(inputType != null && inputType.length > 0)
					isProvidedType = inputType[0].equals(type);
			}
		} catch(Exception e) {
			//Do nothing
		}

		return isProvidedType;
	}

	/**
	 * Vytvori zoznam predikatov pre vyhladavanie
	 * @param properties - ocisteny zoznam params o atributy, ktore sa nechachadzaju v T
	 * @param example - kompletny zoznam request parametrov, vratane pagingu
	 * @return
	 */
	protected Specification<T> getSearchConditions(Map<String, String> properties, Map<String, String> params, T entity) {
		return (Specification<T>) (root, query, builder) -> {
			final List<Predicate> predicates = new ArrayList<>();

			for (Map.Entry<String, String> paramsEntry : properties.entrySet()) {
				String field = paramsEntry.getKey();
				String value = paramsEntry.getValue();

				//toto sa hlada v addSpecSearch
				if ("perexGroups".equals(field)) continue;

				if (value.startsWith("daterange:")) {
					Timestamp from = null;
					Timestamp to = null;
					String[] values = Tools.getTokens(value.substring(value.indexOf(":")+1), "-");
					if (values.length==2) {
						from = new Timestamp(Tools.getLongValue(values[0], 0));
						to = new Timestamp(Tools.getLongValue(values[1], 0));
					} else if (values.length==1) {
						//ked nemame from pride to ako: daterange:-1589666400000
						if (value.contains("range:-")) to = new Timestamp(Tools.getLongValue(values[0], 0));
						else from = new Timestamp(Tools.getLongValue(values[0], 0));
					}

					//Ak sa jedna o DATETIME, ta žiadnu úpravu nespravíme (používateľ nech si časovú zložku nastaví sám)
					//Ak sa jedná o DATE, tak nastavíme časovú zložku FROM na 00:00:00 a TO na 23:59:59
					boolean isDate = isFieldType(entity.getClass(), field, DataTableColumnType.DATE);

					if(isDate && from != null) {
						Calendar cal = Calendar.getInstance();
						cal.setTime(from);
						cal.set(Calendar.HOUR_OF_DAY, 0);
						cal.set(Calendar.MINUTE, 0);
						cal.set(Calendar.SECOND, 0);
						from = new Timestamp( cal.getTimeInMillis() );
					}

					if(isDate && to != null) {
						Calendar cal = Calendar.getInstance();
						cal.setTime(to);
						//set to begining of next day because we will use lessThan
						cal.add(Calendar.DATE, 1);
						cal.set(Calendar.HOUR_OF_DAY, 0);
						cal.set(Calendar.MINUTE, 0);
						cal.set(Calendar.SECOND, 0);
						to = new Timestamp( cal.getTimeInMillis() );
					}

					sk.iway.iwcm.Logger.debug(DatatableRestControllerV2.class, "Daterange from="+Tools.formatDateTimeSeconds(from)+" to="+Tools.formatDateTimeSeconds(to)+" original="+value);

					if (from != null) predicates.add(builder.greaterThanOrEqualTo(root.get(field), from));
					if (to != null) predicates.add(builder.lessThan(root.get(field), to));
				} else if (value.startsWith("range:")) {
					BigDecimal from = null;
					BigDecimal to = null;
					String[] values = Tools.getTokens(value.substring(value.indexOf(":")+1), "-");
					if (values.length==2) {
						from = Tools.getBigDecimalValue(values[0], "0");
						to = Tools.getBigDecimalValue(values[1], "0");
					} else if (values.length==1) {
						//ked nemame from pride to ako: daterange:-1589666400000
						if (value.contains("range:-")) to = Tools.getBigDecimalValue(values[0], "0");
						else from = Tools.getBigDecimalValue(values[0], "0");
					}

					sk.iway.iwcm.Logger.debug(DatatableRestControllerV2.class, "Range from="+from+" to="+to+" original="+value);

					if (from != null) predicates.add(builder.greaterThanOrEqualTo(root.get(field), from));
					if (to != null) predicates.add(builder.lessThanOrEqualTo(root.get(field), to));
				} else {
					try {
						//skus ziskat field, ak to padne na IllegalArgumentException tak neexistuje, nevadi, ignorujeme
						@SuppressWarnings("rawtypes")
						Path path = root.get(field);

						//toto nefunguje dobre
						//path.getJavaType().isInstance(Boolean.class) - aj ked je Boolean vrati false
						//toto funguje
						//path.getJavaType().isAssignableFrom(Boolean.class)

						String simpleName = path.getJavaType().getSimpleName();

						if ("null".equals(value)) {
							predicates.add(builder.isNull(root.get(field)));
						} else if (simpleName.equalsIgnoreCase("Boolean")) {
							predicates.add(builder.equal(root.get(field), Boolean.valueOf(value)));
						} else if (simpleName.equalsIgnoreCase("Integer") || simpleName.equalsIgnoreCase("int")) {
							predicates.add(builder.equal(root.get(field), Integer.valueOf(value)));
						} else if (simpleName.equalsIgnoreCase("Long")) {
							predicates.add(builder.equal(root.get(field), Long.valueOf(value)));
						} else if (simpleName.equalsIgnoreCase("Double")) {
							predicates.add(builder.equal(root.get(field), Double.valueOf(value)));
						} else {

							if (value.startsWith("^") && value.endsWith("$")) predicates.add(builder.equal(root.get(field), value.substring(1, value.length()-1)));
							else {
								if (value.startsWith("^")) value = value.substring(1)+"%";
								else if (value.endsWith("$")) value = "%"+value.substring(0, value.length()-1);
								else value = "%"+value+"%";

								if (Constants.DB_TYPE==Constants.DB_ORACLE && isJpaLowerField(field)) {
									predicates.add(builder.like(builder.lower(root.get(field)), value.toLowerCase()));
								} else if (Constants.DB_TYPE==Constants.DB_PGSQL) {
									predicates.add(builder.like(builder.lower(builder.function("unaccent", String.class, root.get(field))), DB.internationalToEnglish(value).toLowerCase()));
								} else {
									predicates.add(builder.like(root.get(field), value));
								}
							}
						}
					} catch (IllegalArgumentException e) {
						//failsafe
					}
				}
			}

			//pridaj do vyhladavania automaticky podmienku podla domain_id ak je potrebna
			if (checkDomainId) predicates.add(builder.equal(root.get("domainId"), CloudToolsForCore.getDomainId()));

			addSpecSearch(params, predicates, root, builder);

			return builder.and(predicates.toArray(new Predicate[predicates.size()]));
		};
	}

	/**
	 * Doplnenie pecialneho vyhladavanie, interne vola:
	 * - addSpecSearchUserFullName(searchUserFullName, "userId", predicates, root, builder);
	 * @param params
	 * @param predicates
	 */
	public void addSpecSearch(Map<String, String> params, List<Predicate> predicates, Root<T> root, CriteriaBuilder builder) {

		//v DB entite mame bezne len userId a pridavame tam entitu userFullName, defaultne ked existuje parameter searchUserFullName tak hladaj podla userId
		String searchUserFullName = params.get("searchUserFullName");
		if (Tools.isNotEmpty(searchUserFullName)) {
			SpecSearch<T> specSearch = new SpecSearch<>();
			//ziskaj zoznam IDecok userov, ktory maju dane meno
			specSearch.addSpecSearchUserFullName(searchUserFullName, "userId", predicates, root, builder);

		}

		//vyhladavanie na zaklade stavu
		String statusSearch = params.get("searchEditorFields.statusIcons");
		if (Tools.isNotEmpty(statusSearch)) {
			SpecSearch<T> specSearch = new SpecSearch<>();
			specSearch.addSpecSearchStatusIcons(statusSearch, predicates, root, builder);
		}

		//vyhladavanie podla perexSkupiny
		String searchPerexGroups = params.get("searchPerexGroups");
		if (Tools.isNotEmpty(searchPerexGroups)) {
			SpecSearch<T> specSearch = new SpecSearch<>();
			//ziskaj zoznam IDecok userov, ktory maju dane meno
			specSearch.addSpecSearchPerexGroup(searchPerexGroups, "perexGroups", predicates, root, builder);

		}
	}

	/********************************* REST METODY *********************************/

	/**
	 * Vrati vsetky zaznamy v datatabaze (serverovo strankovane a sortovane)
	 */
	@PreAuthorize(value = "@WebjetSecurityService.checkAccessAllowedOnController(this)")
	@GetMapping("/all")
	public Page<T> getAll(Pageable pageable) {
		clearThreadData();
		if ("true".equals(request.getParameter("export"))) {
			setExporting(true);
			Adminlog.add(Adminlog.TYPE_FORM_EXPORT, request.getRequestURI(),-1, -1);
		}
		else {
			setExporting(false);
		}

		Page<T> page = this.getAllItems(pageable);

		//napln options
		DatatablePageImpl<T> pageImpl;
		if (page instanceof DatatablePageImpl) {
			//uz je to impl, moze mat nejake options uz setnute
			pageImpl = (DatatablePageImpl<T>)page;
		} else {
			pageImpl = new DatatablePageImpl<>(page);
		}
		this.getOptions(pageImpl);

		pageImpl.setNotify(getThreadData().getNotify());

		return pageImpl;
	}

	/**
	 * Vyhlada zaznamy v databaze podla zadanych kriterii (serverovo strankovane a sortovane).
	 * Pouziva EampleMatcher, v Beane NESMU BYT pouzite primitivne typy (vsetko musia byt Objekty)
	 */
	@PreAuthorize(value = "@WebjetSecurityService.checkAccessAllowedOnController(this)")
	@GetMapping("/search/findByColumns")
	public Page<T> findByColumns(@RequestParam Map<String, String> params, Pageable pageable, T search) {
		clearThreadData();
		if ("true".equals(request.getParameter("export"))) {
			setExporting(true);
			Adminlog.add(Adminlog.TYPE_FORM_EXPORT, request.getRequestURI(),-1, -1);
		}
		else {
			setExporting(false);
		}
		return searchItem(params, pageable, search);
	}

	/**
	 * Edit import data. For example, set id to -1 if you want to change update to create.
	 *
	 * @param request
	 * @param data
	 * @param importMode
	 * @return
	 */
	public Map<Long, T> preImportDataEdit(HttpServletRequest request, Map<Long, T> data, String importMode) {
		return data;
	}

	/**
	 * Ulozenie zaznamu do DB vo formate posielanom Datatables Editor, moze naraz zapisat viac zaznamov
	 */
	@PreAuthorize(value = "@WebjetSecurityService.checkAccessAllowedOnController(this)")
	@PostMapping(value = "/editor", consumes = MediaType.APPLICATION_JSON_VALUE)
	public ResponseEntity<DatatableResponse<T>> handleEditor(HttpServletRequest request, @RequestBody DatatableRequest<Long, T> datatableRequest) {
		boolean isImporting = isImporting();
		Integer lastImportedRow = getLastImportedRow();
		List<NotifyBean> notifyListBeforeClear = getThreadData().getNotify();

		//SKIP wrong data support variables
		boolean skipWrongData = datatableRequest.isSkipWrongData();
		Set<Long> invalidImportedRows = getInvalidImportedRows();
		TreeMap<Integer, String> invalidImportedRowsErrors = getThreadData().getInvalidImportedRowsErrors();

		clearThreadData();
		if (isImporting) {
			setImporting(true);
			setSkipWrongData(skipWrongData);
			//pri importe moze vykonat converter nastavenie nejakych notifikacii, pre istotu takto zachovame
			if (notifyListBeforeClear!=null && notifyListBeforeClear.isEmpty()==false) addNotify(notifyListBeforeClear);
		}

		DatatableResponse<T> response = new DatatableResponse<>();

		if (datatableRequest.isDeleteOldData()) {
			//je potrebne sa zamysliet nad bezpecnostou, zatial schovane aj v UI
			//repo.deleteAll();
		}

		setForceReload(false);
		setImportedColumns(datatableRequest.getImportedColumns());

		String updateByColumn = datatableRequest.getUpdateByColumn();
		getThreadData().setUpdateByColumn(updateByColumn);

		String importMode = datatableRequest.getImportMode();
		getThreadData().setImportMode(importMode);

		if(isImporting == true) datatableRequest.setData( preImportDataEdit(request, datatableRequest.getData(), importMode) );

		int rowCounter = 0;
		if (isImporting && lastImportedRow!=null) rowCounter = lastImportedRow.intValue();
		for (Long id : datatableRequest.getData().keySet()) {
			rowCounter++;

			//This row was marked as invalid, skip it
			if(isImporting() && skipWrongData == true && invalidImportedRows.contains(id)) {
				//Mark row
				setLastImportedRow(rowCounter);
				continue;
			}

			T entity = datatableRequest.getData().get(id);

			if (entity instanceof ActiveRecordBase) {
				Integer rowNum = ((ActiveRecordBase)entity).get__rowNum__();
				setLastImportedRow(rowNum);
			} else {
				if (isImporting()) setLastImportedRow(rowCounter);
				else setLastImportedRow(null);
			}

			//tu nepouzijeme podmienku checkDomainId, aby sa domainId nastavilo vzdy a nezostalo NULL/0 aj ked je aktualne enableStaticFilesExternalDir vypnute (napr. na produkcii)
			if (repo instanceof DomainIdRepository) {
				//over, ci entita ma property domainId a ci sedi voci aktualnemu CloudToolsForCore.getDomainId()
				BeanWrapperImpl bw = new BeanWrapperImpl(entity);
				Integer domainId = (Integer)bw.getPropertyValue("domainId");
				if (domainId == null || domainId.intValue()<1 || datatableRequest.isInsert()) {
					//domainId nie je nastavene, setni na aktualnu hodnotu
					domainId = CloudToolsForCore.getDomainId();
					bw.setPropertyValue("domainId", domainId);
				} else {
					if (CloudToolsForCore.getDomainId() != domainId.intValue()) {
						//domainId nesedi, je to nejaka manipulacia s datami, vyhod chybu
						throwError("datatables.error.domainId");
					}
				}
			}

			boolean isDuplicate = false;
			if (datatableRequest.isInsert()) {
				try {
					if (id>0) {
						//jedna sa o duplikovanie, musime zrusit hodnotu ID property
						String propertyName = "id";
						for (Field field : entity.getClass().getDeclaredFields()) {
							if (field.isAnnotationPresent(sk.iway.iwcm.system.datatable.annotations.DataTableColumn.class)) {
								DataTableColumnType[] inputType = field.getAnnotation(sk.iway.iwcm.system.datatable.annotations.DataTableColumn.class).inputType();
								if (inputType.length>0 && inputType[0]==DataTableColumnType.ID) {
									propertyName = field.getName();
									break;
								}
							}
						}

						Long lnull = null;
						Long inull = null;
						try {
							//musime ist takto, pretoze na lombok triedach BeanUtils nic nespravi
							String methodName = "set"+propertyName.substring(0,1).toUpperCase()+propertyName.substring(1);
							Method setId = entity.getClass().getMethod(methodName, Long.class);
							setId.invoke(entity, lnull);
						} catch (Exception e) {
							try {
								//na starych WJ triedach je potrebne nastavit integer hodnotu, napr. setTempId(0)
								String methodName = "set"+propertyName.substring(0,1).toUpperCase()+propertyName.substring(1);
								Method setId = entity.getClass().getMethod(methodName, Integer.class);
								setId.invoke(entity, 0);
							} catch (Exception e2) {
								try {
									//na starych WJ triedach je potrebne nastavit int hodnotu, napr. setTempId(0)
									String methodName = "set"+propertyName.substring(0,1).toUpperCase()+propertyName.substring(1);
									Method setId = entity.getClass().getMethod(methodName, int.class);
									setId.invoke(entity, 0);
								} catch (Exception e21) {
									BeanWrapperImpl bw = new BeanWrapperImpl(entity);
									//setni ID hodnotu na povodnej entite, aby sa nasledne korektne vykonala processToEntity so spravnym ID
									try {
										bw.setPropertyValue(propertyName, lnull);
									} catch (Exception e3) {
										try {
											bw.setPropertyValue(propertyName, inull);
										} catch (Exception e4) {
											try {
												bw.setPropertyValue(propertyName, 0);
											} catch (Exception e5) {

											}
										}
									}
								}
							}
						}

						isDuplicate = true;
						beforeDuplicate(entity);
					}

				} catch (Exception ex) {
					Logger.error(DatatableRestControllerV2.class, ex);
					throwError("datatables.error.system.js");
				}

				if (isImporting && "onlyNew".equals(importMode) && Tools.isNotEmpty(updateByColumn)) {
					try {
						List<T> itemsBy = findItemBy(updateByColumn, entity);
						if (itemsBy.isEmpty()==false) {
							//SKIP import, entity allready exists
							Logger.debug(DatatableRestControllerV2.class, "import SKIP entity - allready exists, entity="+entity+", "+updateByColumn+"="+updateByColumn);
							response.setForceReload(Boolean.TRUE);
							continue;
						}
					} catch (IllegalAccessException | NoSuchMethodException | InvocationTargetException | InstantiationException e) {
						response.setError(String.format("Field: %s not found", updateByColumn));
						Logger.error(DatatableRestControllerV2.class, e);
						return ResponseEntity.ok(response);
					} catch(RuntimeException ex) {
						//Ignore error if skipWrongData is true
						if(skipWrongData == true) {
							addImportedColumnError(ex);
							continue;
						}
						throw ex;
					}
				}

				try {
					ResponseEntity<T> re = add(entity); //This method throws ConstraintViolationException
					response.add(re.getBody());

					if (isDuplicate) afterDuplicate(entity, id);
				} catch (ConstraintViolationException ex) {
					//Ignore error if skipWrongData is true
					if(skipWrongData == true) {
						List<ConstraintViolation<?>> violations = new ArrayList<>();
						for (ConstraintViolation<?> violation : ex.getConstraintViolations()) {
							violations.add(violation);
						}
						addImportedColumnError(violations);
						continue;
					}
					throw ex;
				} catch(RuntimeException ex) {
					//Ignore error if skipWrongData is true
					if(skipWrongData == true) {
						addImportedColumnError(ex);
						continue;
					}
					throw ex;
				}

			} else if (datatableRequest.isUpdate()) {

				if (isImporting) {
					setImporting(true);
				}

				ResponseEntity<T> re=null;
				// Ak updatujeme na zaklade stlpca v DB
				if (Tools.isNotEmpty(updateByColumn)) {
					try {
						response.setData(editItemByColumn(entity, updateByColumn));
					} catch (IllegalAccessException | NoSuchMethodException | InvocationTargetException | InstantiationException e) {
						response.setError(String.format("Field: %s not found", updateByColumn));
						Logger.error(DatatableRestControllerV2.class, e);
						return ResponseEntity.ok(response);
					} catch(RuntimeException ex) {
						//Ignore error if skipWrongData is true
						if(skipWrongData == true) {
							addImportedColumnError(ex);
							continue;
						}
						throw ex;
					}
				}
				else {
					re = edit(id, entity);
					response.add(re.getBody());
				}
			} else if (datatableRequest.isDelete()) {

				ResponseEntity<Map<String, Object>> re = delete(id, entity);

				//delete(id, datatableRequest.getData().get(id));
				Map<String, Object> body = re.getBody();
				if (body == null || body.get("result") == null || body.get("result").equals(Boolean.TRUE)==false) {
					throwError("editor.delete_error");
				}
			}
		}

		//We skipped worng data, prepare and show errors notification
		if(skipWrongData == true) {

			if(invalidImportedRowsErrors == null) {
				invalidImportedRowsErrors = new TreeMap<>();
			}

			if(getThreadData().getInvalidImportedRowsErrors() != null) {
				invalidImportedRowsErrors.putAll( getThreadData().getInvalidImportedRowsErrors() );
			}

			if(invalidImportedRowsErrors.size() > 0) {
				StringBuilder allInsertErrors = new StringBuilder("");

				for (Map.Entry<Integer, String> set : invalidImportedRowsErrors.entrySet()) {
					allInsertErrors.append(set.getValue()).append("<br> <br>");
				}

				NotifyBean error = new NotifyBean(Prop.getInstance().getText("datatables.error.title.js"), allInsertErrors.toString(), NotifyType.ERROR);
				getThreadData().addNotify(error);
			}
		}

		//!! CLear SKIP wrong data support variables
		getThreadData().setInvalidImportedRows(null);
		getThreadData().setInvalidImportedRowsErrors(null);

		if (isForceReload()) {
			response.setForceReload(Boolean.TRUE);
		}

		if (isImporting) {
			afterImportChunk(datatableRequest.getDzchunkindex(), datatableRequest.getDztotalchunkcount());
		}

		//If thread notify list != null, set list into response
		if(hasNotify()) response.setNotify(getThreadData().getNotify());

		if (datatableRequest.getData().size()>5) {
			//aby nenastala chyba 429 pri importe musime spomalit download
			MultipartWrapper.slowdownUpload();
		}

		return ResponseEntity.ok(response);
	}

	/**
	 * Volanie specialnej akcie (napr. otocenie obrazku v galerii).
	 * V pug subore sa vola ako galleryTable.executeAction("rotate");
	 * @param action
	 * @param ids
	 * @return
	 */
	@PreAuthorize(value = "@WebjetSecurityService.checkAccessAllowedOnController(this)")
	@PostMapping(value = "/action/{action}")
	public ResponseEntity<DatatableResponse<T>> action(@PathVariable String action, @RequestParam(value = "ids[]") Long[] ids) {
		clearThreadData();
		DatatableResponse<T> response = new DatatableResponse<>();

		for (Long id : ids) {
			Logger.debug(DatatableRestControllerV2.class, "action=" + action + ", id=" + id);

			T entity = null;
			//id==-1 je v situacii ked sa nic neselectne, napr. pre refresh akciu
			if (id != -1) entity = getOneItem(id);
			if (entity != null || id==-1) {
				checkItemPermsThrows(entity, id);
				boolean success = processAction(entity, action);
				if (success == false) {
					response.setError(getProp().getText("datatable.error.unknown") + ": id=" + id);
				}
			}
		}

		//If thread notify list != null, set list into response
		if(hasNotify()) response.setNotify(getThreadData().getNotify());
		response.setForceReload(isForceReload());

		return ResponseEntity.ok(response);
	}

	@PreAuthorize(value = "@WebjetSecurityService.checkAccessAllowedOnController(this)")
	@GetMapping("/{id}")
	public T getOne(@PathVariable("id") long id) {
		clearThreadData();
		T result = getOneItem(id);
		checkItemPermsThrows(result, id);
		addNotifyToEditorFields(result);
		return result;
	}

	@PreAuthorize(value = "@WebjetSecurityService.checkAccessAllowedOnController(this)")
	@PostMapping("/add")
	public ResponseEntity<T> add(@Valid @RequestBody T entity) {
		beforeSave(entity);

		// validacia
		Set<ConstraintViolation<T>> violations = validator.validate(entity);

		//Error will be always thrown, but prepare error message for user is we skipping wrong data
		if (!violations.isEmpty()) {
			//convert violations to List<ConstraintViolation<?>>
			List<ConstraintViolation<?>> violationsList = new ArrayList<>();
			for (ConstraintViolation<T> violation : violations) {
				violationsList.add(violation);
			}
			addImportedColumnError(violationsList);
			throw new ConstraintViolationException("Invalid data", violations);
		} else {
			checkItemPermsThrows(entity, -1L);
			T newT = this.insertItem(entity);
			afterSave(entity, newT);
			return new ResponseEntity<>(newT, null, HttpStatus.CREATED);
		}
	}

	@PreAuthorize(value = "@WebjetSecurityService.checkAccessAllowedOnController(this)")
	@PostMapping("/edit/{id}")
	public ResponseEntity<T> edit(@PathVariable("id") long id, @Valid @RequestBody T entity) {
		beforeSave(entity);

		// validacia
		Set<ConstraintViolation<T>> violations = validator.validate(entity);
		if (!violations.isEmpty()) {
			throw new ConstraintViolationException("Invalid data", violations);
		} else {
			checkItemPermsThrows(entity, id);
			T one = this.editItem(entity, id);
			afterSave(entity, one);
			return ResponseEntity.ok(one);
		}
	}

	@PreAuthorize(value = "@WebjetSecurityService.checkAccessAllowedOnController(this)")
	@DeleteMapping("/{id}")
	public ResponseEntity<Map<String, Object>> delete(@PathVariable("id") long id, @RequestBody T entity) {
		clearThreadData();
		Map<String, Object> result = new HashMap<>();
		checkItemPermsThrows(entity, id);

		boolean deleted = false;
		if (beforeDelete(entity)) {
			deleted = this.deleteItem(entity, id);
		}
		result.put("result", deleted);
		if (deleted) {
			afterDelete(entity, id);
		}

		return new ResponseEntity<>(result, HttpStatus.OK);
	}

	public JpaRepository<T, Long> getRepo() {
		return repo;
	}

	public HttpServletRequest getRequest(){
		return this.request;
	}

	public Identity getUser(){
		return UsersDB.getCurrentUser(getRequest());
	}

	public Prop getProp() {
		//kedze nie sme thread safe vraciame takto, request je autowired, cize ten je OK
		return Prop.getInstance(getRequest());
	}

	/**
	 * Vyvola vseobecnu vynimku ulozenia (ked napr. v editItem nastane nejaka vseobecna chyba)
	 * Chybove hlasenie sa zobrazi v editore pri tlacitku odoslat
	 * @param errors
	 */
	@SuppressWarnings("all")
	public void throwError(String errorKey) {
		String message = getProp().getText(errorKey);
		throw new RuntimeException(message);
	}

	/**
	 * Vyvola vynimku platnosti typu pola (napr. kontrola email adresy)
	 * @param errorKey - prekladovy kluc chybovej spravy
	 */
	@SuppressWarnings("all")
	public void throwConstraintViolation(String errorKey) {
		String message = getProp().getText(errorKey);
		throw new ConstraintViolationException(message, null);
	}

	/**
	 * Vyvola vseobecnu vynimku ulozenia (ked napr. v editItem nastane nejaka vseobecna chyba)
	 * Chybove hlasenie sa zobrazi v editore pri tlacitku odoslat
	 * @param errorKeys
	 */
	@SuppressWarnings("all")
	public void throwError(List<String> errorKeys) {
		//preved z klucov na texy
		Prop prop = Prop.getInstance();
		StringBuilder message = new StringBuilder();
		for (String key : errorKeys) {
			if (message.length()>0) message.append(";\n");
			message.append(prop.getText(key));
		}

		throw new RuntimeException(message.toString());
	}

	private static ThreadBean getThreadData() {
		ThreadBean data = threadData.get();
		if (data == null) {
			Logger.debug(DatatableRestControllerV2.class, "ThreadData.creating, id="+Thread.currentThread().getId());
			data = new ThreadBean();
			threadData.set(data);
		}
		//Logger.debug(DatatableRestControllerV2.class, "ThreadData.get, id="+Thread.currentThread().getId()+" data="+data.toString());
		return data;
	}

	private void clearThreadData() {
		getThreadData().clear();
	}

	/**
	 * Indikuje, ze dane volanie je pre export dat
	 * @return
	 */
	public boolean isExporting() {
		boolean exporting = getThreadData().isExporting();
		//Logger.debug(DatatableRestControllerV2.class, "isExporting, thread="+Thread.currentThread().getId()+" exporting="+exporting);
		return exporting;
	}

	private void setExporting(boolean exporting) {
		getThreadData().setExporting(exporting);
	}

	/**
	 * Indikuje, ze dane volanie je pre import dat
	 * @return
	 */
	public boolean isImporting() {
		return getThreadData().isImporting();
	}

	private void setImporting(boolean importing) {
		getThreadData().setImporting(importing);
	}

	/**
	 * Indikuje, ze sa ma vykonat reload tabulky
	 * @return
	 */
	public boolean isForceReload() {
		return getThreadData().isForceReload();
	}

	public void setForceReload(boolean forceReload) {
		getThreadData().setForceReload(forceReload);
	}

	public boolean hasNotify() {
		return getThreadData().getNotify() != null ? true : false;
	}

	/**
	 * Prida notifikaciu pre zobrazenie po odoslani dat
	 * @param notify
	 */
	public static void addNotify(NotifyBean notify) {
		getThreadData().addNotify(notify);
	}

	/**
	 * Prida zoznam notifikacii pre zobrazenie po odoslani dat
	 * @param notifyList
	 */
	public static void addNotify(List<NotifyBean> notifyList) {
		if (notifyList != null && notifyList.isEmpty()==false) {
			for (NotifyBean notify : notifyList) {
				getThreadData().addNotify(notify);
			}
		}
	}

	/**
	 * Nastavi cislo importovaneho riadku (ak sa nachadza v datach)
	 * @param lastImportedRow
	 * @return
	 */
	private void setLastImportedRow(Integer lastImportedRow) {
		getThreadData().setLastImportedRow(lastImportedRow);
	}

	/**
	 * Vrati cislo posledne importovaneho riadku
	 * @return
	 */
	public static Integer getLastImportedRow() {
		return getThreadData().getLastImportedRow();
	}

	private void setImportedColumns(Set<String> importedColumns) {
		getThreadData().setImportedColumns(importedColumns);
	}

	/**
	 * Returns Set<String> of imported columns from xlsx file.
	 * You can check which columns were in Excel file during import process.
	 * @return
	 */
	public static Set<String> getImportedColumns() {
		return getThreadData().getImportedColumns();
	}

	/**
	 * Vrati repo pretypovane na DomainIdRepository pre jednoduchsie pouzitie
	 * @return
	 */
	@SuppressWarnings("unchecked")
	private DomainIdRepository<T, ID> getDomainRepo() {
		if (repo instanceof DomainIdRepository) {
			return (DomainIdRepository<T, ID>)repo;
		}
		return null;
	}

	/**
	 * Prida notify list do BaseEditorFields objektu (ak existuje)
	 * Toto je potrebne pri REST volaniach, kedy sa posiela nazad len zakladna entita
	 * @param entity
	 */
	private void addNotifyToEditorFields(T entity) {
		try {
			List<NotifyBean> notifyList = getThreadData().getNotify();
			if (notifyList!=null && notifyList.isEmpty()==false) {
				BeanWrapperImpl bw = new BeanWrapperImpl(entity);
				if (bw.getPropertyType("editorFields.notify")!=null) {
					bw.setPropertyValue("editorFields.notify", getThreadData().getNotify());
				}
			}
		} catch (Exception e) {
			Logger.error(DatatableRestControllerV2.class, e);
		}
	}

	/**
	 * There is problem to return single Boolean from JPA query,
	 * it's returned as Boolean in MariaDB/MSSQL and Number (0 or 1) on Oracle
	 * there we cast it correctly to boolean
	 * @param value
	 * @return
	 */
	public static boolean jpaToBoolean(Object value) {
		if (value == null) return false;
		if (value instanceof Boolean) return (Boolean)value;
		if (value instanceof Number) {
			return ((Number)value).intValue() == 1 ? true : false;
		}
		return false;
	}

	/**
	 * Test if field value should be lower cased
	 * @param field
	 * @return
	 */
	private boolean isJpaLowerField(String field)
    {
		String[] jpaToLowerFields = ConstantsV9.getArrayCached("jpaToLowerFields", 120);
        if (jpaToLowerFields==null || jpaToLowerFields.length==0 || field==null) return false;

        for (String one : jpaToLowerFields)
        {
           if (one.equals(field)) return true;
		   //support for descriptionLong* so we don't need to write all language codes
		   if (one.endsWith("*") && one.substring(0, one.length()-1) .equals(field)) return true;
        }
        return false;
    }

	/**
	 * column name which is used to update the row with import
	 * @return
	 */
	public String getUpdateByColumn() {
		return getThreadData().getUpdateByColumn();
	}

	/**
	 * mode of import (append, update, onlyNew)
	 * @return
	 */
	public String getImportMode() {
		return getThreadData().getImportMode();
	}

	/**
	 * Set invalid imported rows
	 * @param invalidImportedRows
	 */
	private void setInvalidImportedRows(Set<Long> invalidImportedRows) {
		getThreadData().setInvalidImportedRows(invalidImportedRows);
	}

	/**
	 * Get invalid imported rows
	 * @return
	 */
	public Set<Long> getInvalidImportedRows() {
		return getThreadData().getInvalidImportedRows();
	}

	/**
	 * Set skip wrong data.
	 * TRUE - wrong data during import will be skipped and process will continue
	 * @param skipWrongData
	 */
	private void setSkipWrongData(boolean skipWrongData) {
		getThreadData().setSkipWrongData(skipWrongData);
	}

	/**
	 * Get skip wrong data.
	 * TRUE - wrong data during import will be skipped and process will continue
	 * @return
	 */
	public boolean isSkipWrongData() {
		return getThreadData().isSkipWrongData();
	}

	/**
	 * List of violated constraints during import (invalid rows during import) will be prepared and ADDED inside of threadData.InvalidImportedRowsErrors
	 * This set of processed error's are used for Warning notification (FOR user). So user can by notified which rows are invalid and WHY.
	 * @param violations - Set of ConstraintViolations
	 */
	private void addImportedColumnError(List<ConstraintViolation<?>> violations) {
		if(violations == null || violations.size() < 1) return;
		ConstraintViolation<?> firstViolation = violations.iterator().next();
		String propertyName = firstViolation.getPropertyPath().toString();
		int dot = propertyName.indexOf(".");
		if (dot > 0 && propertyName.startsWith("editorFields") == false) propertyName = propertyName.substring(0, dot);

		String errCause = firstViolation.getMessageTemplate();
		if(Tools.isNotEmpty(errCause) && errCause.startsWith("{") && errCause.endsWith("}")) {
			//For example {javax.validation.constraints.NotBlank.message}
			errCause = errCause.substring(1, errCause.length() - 1);
			errCause = Prop.getInstance().getText( errCause );
		} else {
			errCause = firstViolation.getMessage();
		}

		StringBuilder errExplanation = new StringBuilder(propertyName);
		int lastImportedRow = getLastImportedRow() == null ? 0 : getLastImportedRow().intValue();

		errExplanation.append(" - ").append(firstViolation.getInvalidValue() == null ? "EMPTY" : firstViolation.getInvalidValue().toString()).append(" - ").append(errCause);
		String errMsg = Prop.getInstance().getText("datatable.error.importRow", String.valueOf(lastImportedRow) , errExplanation.toString());

		TreeMap<Integer, String> rowsErrors = getThreadData().getInvalidImportedRowsErrors();
		if(rowsErrors == null) rowsErrors = new TreeMap<>();
		rowsErrors.put(lastImportedRow, errMsg);
		getThreadData().setInvalidImportedRowsErrors(rowsErrors);
	}

	private void addImportedColumnError(RuntimeException ex) {
		String errExplanation = ex.getMessage();
		int lastImportedRow = getLastImportedRow() == null ? 0 : getLastImportedRow().intValue();

		String errMsg = Prop.getInstance().getText("datatable.error.importRow", String.valueOf(lastImportedRow) , errExplanation);

		TreeMap<Integer, String> rowsErrors = getThreadData().getInvalidImportedRowsErrors();
		if(rowsErrors == null) rowsErrors = new TreeMap<>();
		rowsErrors.put(lastImportedRow, errMsg);
		getThreadData().setInvalidImportedRowsErrors(rowsErrors);
	}


	/**
	 * This error will be prepared and ADDED inside of threadData.InvalidImportedRowsErrors.
	 * This set of processed error's are used for Warning notification (FOR user). So user can by notified which rows are invalid and WHY.
	 * @param err - FieldError
	 * @param rowNumber - imported row number
	 */
	private void addImportedColumnError(org.springframework.validation.FieldError err, Integer rowNumber) {
		String propertyName = err.getField();
		if(propertyName.startsWith("errorField.")) propertyName = propertyName.replace("errorField.", "");
		int dot = propertyName.indexOf(".");
		if (dot > 0 && propertyName.startsWith("editorFields") == false) propertyName = propertyName.substring(0, dot);

		StringBuilder errExplanation = new StringBuilder();
		errExplanation.append(propertyName);
		errExplanation.append(" - ").append( err.getRejectedValue() == null ? "EMPTY" : err.getRejectedValue().toString() );
		errExplanation.append(" - ").append( err.getDefaultMessage() );

		int lastImportedRow = getLastImportedRow() == null ? 0 : getLastImportedRow().intValue();
		String errMsg = Prop.getInstance().getText("datatable.error.importRow", String.valueOf(lastImportedRow + rowNumber + 1) , errExplanation.toString());

		TreeMap<Integer, String> rowsErrors = getThreadData().getInvalidImportedRowsErrors();
		if(rowsErrors == null) rowsErrors = new TreeMap<>();
		rowsErrors.put(lastImportedRow + rowNumber + 1, errMsg);
		getThreadData().setInvalidImportedRowsErrors(rowsErrors);
	}

	/**
	 * Copy fields from provided entity into original entity
	 * @param entity
	 * @param one
	 */
	private void copyEntityIntoOriginal(T entity, T one) {
		List<String> alwaysCopyProperties = new ArrayList<>();
		List<String> ignoreProperties = new ArrayList<>();

		Field[] declaredFields = AuditEntityListener.getDeclaredFieldsTwoLevels(entity.getClass());
		for (Field field : declaredFields) {
			if (field.isAnnotationPresent(sk.iway.iwcm.system.datatable.annotations.DataTableColumn.class)) {
				sk.iway.iwcm.system.datatable.annotations.DataTableColumn annotation = field.getAnnotation(sk.iway.iwcm.system.datatable.annotations.DataTableColumn.class);
				boolean[] hiddenEditor = annotation.hiddenEditor();
				if (hiddenEditor.length > 0) {
					//ak je hiddenEditor preskoc
					if (hiddenEditor[0]==true) {
						ignoreProperties.add(field.getName());
						continue;
					}
				}

				//also skip if editor.attr.disabled=disabled
				DataTableColumnEditor editor[] = annotation.editor();
				if (editor.length > 0) {
					boolean isDisabled = false;
					DataTableColumnEditorAttr attrs[] = editor[0].attr();
					if (attrs.length > 0) {
						for (DataTableColumnEditorAttr attr : attrs) {
							if ("disabled".equals(attr.key())) {
								isDisabled = true;
								break;
							}
						}
					}
					if (isDisabled) {
						ignoreProperties.add(field.getName());
						continue;
					}
				}

				boolean alwaysCopy = false;
				if (annotation.alwaysCopyProperties().length>0) {
					alwaysCopy = annotation.alwaysCopyProperties()[0];
					//implicit false value
					if (alwaysCopy==false) continue;
				}
				if (alwaysCopy || field.getType().isAssignableFrom(Date.class) || field.getType().isAssignableFrom(java.sql.Date.class) || field.getType().isAssignableFrom(LocalDate.class) || field.getType().isAssignableFrom(LocalDateTime.class)) {
					//ak je to datum tak ho dajme do ignore, aby isiel zadat v GUI prazdny datum
					alwaysCopyProperties.add(field.getName());
				}
			}
		}

		NullAwareBeanUtils.copyProperties(entity, one, alwaysCopyProperties, ignoreProperties.toArray(new String[0]));
	}

	public void setValidator(Validator validator) {
		this.validator = validator;
	}

	public void setRequest(HttpServletRequest request) {
		this.request = request;
	}
}