/*
* Copyright 2012
* Ubiquitous Knowledge Processing (UKP) Lab and FG Language Technology
* Technische Universität Darmstadt
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package de.tudarmstadt.ukp.clarin.webanno.api.dao;
import static org.apache.commons.io.IOUtils.closeQuietly;
import static org.apache.commons.io.IOUtils.copyLarge;
import java.beans.PropertyDescriptor;
import java.io.File;
import java.io.FileInputStream;
import java.io.FileNotFoundException;
import java.io.FileOutputStream;
import java.io.IOException;
import java.io.InputStream;
import java.io.OutputStream;
import java.util.ArrayList;
import java.util.Collections;
import java.util.Date;
import java.util.Enumeration;
import java.util.List;
import java.util.Map.Entry;
import java.util.Properties;
import java.util.zip.ZipEntry;
import java.util.zip.ZipFile;
import javax.annotation.Resource;
import javax.persistence.EntityManager;
import javax.persistence.NoResultException;
import javax.persistence.PersistenceContext;
import org.apache.commons.io.FileUtils;
import org.apache.commons.io.FilenameUtils;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.slf4j.MDC;
import org.springframework.beans.BeanWrapper;
import org.springframework.beans.PropertyAccessorFactory;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.beans.factory.config.BeanDefinition;
import org.springframework.context.SmartLifecycle;
import org.springframework.context.annotation.ClassPathScanningCandidateComponentProvider;
import org.springframework.core.type.filter.AnnotationTypeFilter;
import org.springframework.stereotype.Component;
import org.springframework.transaction.annotation.Transactional;
import de.tudarmstadt.ukp.clarin.webanno.api.ProjectLifecycleAware;
import de.tudarmstadt.ukp.clarin.webanno.api.ProjectLifecycleAwareRegistry;
import de.tudarmstadt.ukp.clarin.webanno.api.ProjectService;
import de.tudarmstadt.ukp.clarin.webanno.api.ProjectType;
import de.tudarmstadt.ukp.clarin.webanno.api.SecurityUtil;
import de.tudarmstadt.ukp.clarin.webanno.model.AnnotationDocumentState;
import de.tudarmstadt.ukp.clarin.webanno.model.Mode;
import de.tudarmstadt.ukp.clarin.webanno.model.PermissionLevel;
import de.tudarmstadt.ukp.clarin.webanno.model.Project;
import de.tudarmstadt.ukp.clarin.webanno.model.ProjectPermission;
import de.tudarmstadt.ukp.clarin.webanno.security.UserDao;
import de.tudarmstadt.ukp.clarin.webanno.security.model.Authority;
import de.tudarmstadt.ukp.clarin.webanno.security.model.User;
import de.tudarmstadt.ukp.clarin.webanno.support.ZipUtils;
import de.tudarmstadt.ukp.clarin.webanno.support.logging.Logging;
@Component(ProjectService.SERVICE_NAME)
public class ProjectServiceImpl
implements ProjectService, SmartLifecycle
{
private final Logger log = LoggerFactory.getLogger(getClass());
@PersistenceContext
private EntityManager entityManager;
@Resource(name = "userRepository")
private UserDao userRepository;
@Resource
private ProjectLifecycleAwareRegistry projectLifecycleAwareRegistry;
@Value(value = "${repository.path}")
private File dir;
// The annotation preference properties File name
private static final String annotationPreferencePropertiesFileName = "annotation.properties";
private boolean running = false;
private List<ProjectType> projectTypes;
public ProjectServiceImpl()
{
// Nothing to do
}
@Override
@Transactional
public void createProject(Project aProject)
throws IOException
{
entityManager.persist(aProject);
String path = dir.getAbsolutePath() + PROJECT + aProject.getId();
FileUtils.forceMkdir(new File(path));
try (MDC.MDCCloseable closable = MDC.putCloseable(Logging.KEY_PROJECT_ID,
String.valueOf(aProject.getId()))) {
log.info("Created project [{}]({})", aProject.getName(), aProject.getId());
}
// Notify all relevant service so that they can initialize themselves for the given project
for (ProjectLifecycleAware bean : projectLifecycleAwareRegistry.getBeans()) {
try {
bean.afterProjectCreate(aProject);
}
catch (IOException e) {
throw e;
}
catch (Exception e) {
throw new IllegalStateException(e);
}
}
}
@Override
@Transactional
public void updateProject(Project aProject)
{
entityManager.merge(aProject);
}
@Override
@Transactional
public void createProjectPermission(ProjectPermission aPermission)
{
entityManager.persist(aPermission);
try (MDC.MDCCloseable closable = MDC.putCloseable(Logging.KEY_PROJECT_ID,
String.valueOf(aPermission.getProject().getId()))) {
log.info("Created permission [{}] for user [{}] on project [{}]({})",
aPermission.getLevel(), aPermission.getUser(),
aPermission.getProject().getName(), aPermission.getProject().getId());
}
}
@Override
@Transactional
public boolean existsProject(String aName)
{
try {
entityManager.createQuery("FROM Project WHERE name = :name", Project.class)
.setParameter("name", aName).getSingleResult();
return true;
}
catch (NoResultException ex) {
return false;
}
}
@Override
public boolean existsProjectPermission(User aUser, Project aProject)
{
List<ProjectPermission> projectPermissions = entityManager
.createQuery(
"FROM ProjectPermission WHERE user = :user AND " + "project =:project",
ProjectPermission.class).setParameter("user", aUser.getUsername())
.setParameter("project", aProject).getResultList();
// if at least one permission level exist
if (projectPermissions.size() > 0) {
return true;
}
else {
return false;
}
}
@Override
@Transactional
public boolean existsProjectPermissionLevel(User aUser, Project aProject, PermissionLevel aLevel)
{
try {
entityManager
.createQuery(
"FROM ProjectPermission WHERE user = :user AND "
+ "project =:project AND level =:level",
ProjectPermission.class).setParameter("user", aUser.getUsername())
.setParameter("project", aProject).setParameter("level", aLevel)
.getSingleResult();
return true;
}
catch (NoResultException ex) {
return false;
}
}
@Override
@Transactional
public boolean existsProjectTimeStamp(Project aProject, String aUsername)
{
try {
if (getProjectTimeStamp(aProject, aUsername) == null) {
return false;
}
return true;
}
catch (NoResultException ex) {
return false;
}
}
@Override
public boolean existsProjectTimeStamp(Project aProject)
{
try {
if (getProjectTimeStamp(aProject) == null) {
return false;
}
return true;
}
catch (NoResultException ex) {
return false;
}
}
@Override
public File getProjectLogFile(Project aProject)
{
return new File(dir.getAbsolutePath() + PROJECT + "project-" + aProject.getId() + ".log");
}
@Override
public File getGuidelinesFile(Project aProject)
{
return new File(dir.getAbsolutePath() + PROJECT + aProject.getId() + GUIDELINE);
}
@Override
public File getMetaInfFolder(Project aProject)
{
return new File(dir.getAbsolutePath() + PROJECT + aProject.getId() + META_INF);
}
@Override
@Transactional(noRollbackFor = NoResultException.class)
public List<Authority> listAuthorities(User aUser)
{
return entityManager
.createQuery("FROM Authority where username =:username", Authority.class)
.setParameter("username", aUser).getResultList();
}
@Override
public File getGuideline(Project aProject, String aFilename)
{
return new File(dir.getAbsolutePath() + PROJECT + aProject.getId() + GUIDELINE + aFilename);
}
@Override
@Transactional(noRollbackFor = NoResultException.class)
public List<ProjectPermission> listProjectPermissionLevel(User aUser, Project aProject)
{
return entityManager
.createQuery("FROM ProjectPermission WHERE user =:user AND " + "project =:project",
ProjectPermission.class).setParameter("user", aUser.getUsername())
.setParameter("project", aProject).getResultList();
}
@Override
public List<User> listProjectUsersWithPermissions(Project aProject)
{
List<String> usernames = entityManager
.createQuery(
"SELECT DISTINCT user FROM ProjectPermission WHERE "
+ "project =:project ORDER BY user ASC", String.class)
.setParameter("project", aProject).getResultList();
List<User> users = new ArrayList<User>();
for (String username : usernames) {
if (userRepository.exists(username)) {
users.add(userRepository.get(username));
}
}
return users;
}
@Override
public List<User> listProjectUsersWithPermissions(Project aProject,
PermissionLevel aPermissionLevel)
{
List<String> usernames = entityManager
.createQuery(
"SELECT DISTINCT user FROM ProjectPermission WHERE "
+ "project =:project AND level =:level ORDER BY user ASC",
String.class).setParameter("project", aProject)
.setParameter("level", aPermissionLevel).getResultList();
List<User> users = new ArrayList<User>();
for (String username : usernames) {
if (userRepository.exists(username)) {
users.add(userRepository.get(username));
}
}
return users;
}
@Override
@Transactional
public Project getProject(String aName)
{
return entityManager.createQuery("FROM Project WHERE name = :name", Project.class)
.setParameter("name", aName).getSingleResult();
}
@Override
public Project getProject(long aId)
{
return entityManager.createQuery("FROM Project WHERE id = :id", Project.class)
.setParameter("id", aId).getSingleResult();
}
@Override
public void createGuideline(Project aProject, File aContent, String aFileName, String aUsername)
throws IOException
{
String guidelinePath = dir.getAbsolutePath() + PROJECT + aProject.getId() + GUIDELINE;
FileUtils.forceMkdir(new File(guidelinePath));
copyLarge(new FileInputStream(aContent), new FileOutputStream(new File(guidelinePath
+ aFileName)));
try (MDC.MDCCloseable closable = MDC.putCloseable(Logging.KEY_PROJECT_ID,
String.valueOf(aProject.getId()))) {
log.info("Created guidelines file [{}] in project [{}]({})",
aFileName, aProject.getName(), aProject.getId());
}
}
@Override
@Transactional(noRollbackFor = NoResultException.class)
public List<ProjectPermission> getProjectPermissions(Project aProject)
{
return entityManager
.createQuery("FROM ProjectPermission WHERE project =:project",
ProjectPermission.class).setParameter("project", aProject).getResultList();
}
@Override
@Transactional
public Date getProjectTimeStamp(Project aProject, String aUsername)
{
return entityManager
.createQuery(
"SELECT max(timestamp) FROM AnnotationDocument WHERE project = :project "
+ " AND user = :user", Date.class)
.setParameter("project", aProject).setParameter("user", aUsername)
.getSingleResult();
}
@Override
public Date getProjectTimeStamp(Project aProject)
{
return entityManager
.createQuery("SELECT max(timestamp) FROM SourceDocument WHERE project = :project",
Date.class).setParameter("project", aProject).getSingleResult();
}
@Override
@Transactional(noRollbackFor = NoResultException.class)
public List<Project> listProjectsWithFinishedAnnos()
{
return entityManager
.createQuery("SELECT DISTINCT project FROM AnnotationDocument WHERE state = :state",
Project.class)
.setParameter("state", AnnotationDocumentState.FINISHED.getName()).getResultList();
}
@Override
public List<String> listGuidelines(Project aProject)
{
// list all guideline files
File[] files = new File(dir.getAbsolutePath() + PROJECT + aProject.getId() + GUIDELINE)
.listFiles();
// Name of the guideline files
List<String> annotationGuidelineFiles = new ArrayList<String>();
if (files != null) {
for (File file : files) {
annotationGuidelineFiles.add(file.getName());
}
}
return annotationGuidelineFiles;
}
@Override
@Transactional
public List<Project> listProjects()
{
return entityManager.createQuery("FROM Project ORDER BY name ASC ", Project.class)
.getResultList();
}
@Override
public Properties loadUserSettings(String aUsername, Project aProject)
throws FileNotFoundException, IOException
{
Properties property = new Properties();
property.load(new FileInputStream(new File(dir.getAbsolutePath() + PROJECT
+ aProject.getId() + SETTINGS + aUsername + "/"
+ annotationPreferencePropertiesFileName)));
return property;
}
@Override
@Transactional
public void removeProject(Project aProject)
throws IOException
{
// remove metadata from DB
Project project = aProject;
if (!entityManager.contains(project)) {
project = entityManager.merge(project);
}
// Notify all relevant service so that they can clean up themselves before we remove the
// project - notification happens in reverse order
List<ProjectLifecycleAware> beans = new ArrayList<>(
projectLifecycleAwareRegistry.getBeans());
Collections.reverse(beans);
for (ProjectLifecycleAware bean : beans) {
try {
bean.beforeProjectRemove(aProject);
}
catch (IOException e) {
throw e;
}
catch (Exception e) {
throw new IllegalStateException(e);
}
}
for (ProjectPermission permissions : getProjectPermissions(aProject)) {
entityManager.remove(permissions);
}
entityManager.remove(project);
// remove the project directory from the file system
String path = dir.getAbsolutePath() + PROJECT + aProject.getId();
try {
FileUtils.deleteDirectory(new File(path));
}
catch (FileNotFoundException e) {
try (MDC.MDCCloseable closable = MDC.putCloseable(Logging.KEY_PROJECT_ID,
String.valueOf(aProject.getId()))) {
log.info("Project directory to be deleted was not found: [{}]. Ignoring.", path);
}
}
try (MDC.MDCCloseable closable = MDC.putCloseable(Logging.KEY_PROJECT_ID,
String.valueOf(aProject.getId()))) {
log.info("Removed project [{}]({})", aProject.getName(), aProject.getId());
}
}
@Override
public void removeGuideline(Project aProject, String aFileName, String username)
throws IOException
{
FileUtils.forceDelete(new File(dir.getAbsolutePath() + PROJECT + aProject.getId()
+ GUIDELINE + aFileName));
try (MDC.MDCCloseable closable = MDC.putCloseable(Logging.KEY_PROJECT_ID,
String.valueOf(aProject.getId()))) {
log.info("Removed guidelines file [{}] from project [{}]({})", aFileName,
aProject.getName(), aProject.getId());
}
}
@Override
@Transactional
public void removeProjectPermission(ProjectPermission aPermission)
{
entityManager.remove(aPermission);
try (MDC.MDCCloseable closable = MDC.putCloseable(Logging.KEY_PROJECT_ID,
String.valueOf(aPermission.getProject().getId()))) {
log.info("Removed permission [{}] for user [{}] on project [{}]({})",
aPermission.getLevel(), aPermission.getUser(),
aPermission.getProject().getName(), aPermission.getProject().getId());
}
}
@Override
public void savePropertiesFile(Project aProject, InputStream aIs, String aFileName)
throws IOException
{
String path = dir.getAbsolutePath() + PROJECT + aProject.getId() + "/"
+ FilenameUtils.getFullPath(aFileName);
FileUtils.forceMkdir(new File(path));
File newTcfFile = new File(path, FilenameUtils.getName(aFileName));
OutputStream os = null;
try {
os = new FileOutputStream(newTcfFile);
copyLarge(aIs, os);
}
finally {
closeQuietly(os);
closeQuietly(aIs);
}
}
@Override
public <T> void saveUserSettings(String aUsername, Project aProject, Mode aSubject,
T aConfigurationObject)
throws IOException
{
BeanWrapper wrapper = PropertyAccessorFactory.forBeanPropertyAccess(aConfigurationObject);
Properties property = new Properties();
for (PropertyDescriptor value : wrapper.getPropertyDescriptors()) {
if (wrapper.getPropertyValue(value.getName()) == null) {
continue;
}
property.setProperty(aSubject + "." + value.getName(),
wrapper.getPropertyValue(value.getName()).toString());
}
String propertiesPath = dir.getAbsolutePath() + PROJECT + aProject.getId() + SETTINGS
+ aUsername;
// append existing preferences for the other mode
if (new File(propertiesPath, annotationPreferencePropertiesFileName).exists()) {
// aSubject = aSubject.equals(Mode.ANNOTATION) ? Mode.CURATION :
// Mode.ANNOTATION;
for (Entry<Object, Object> entry : loadUserSettings(aUsername, aProject).entrySet()) {
String key = entry.getKey().toString();
// Maintain other Modes of annotations confs than this one
if (!key.substring(0, key.indexOf(".")).equals(aSubject.toString())) {
property.put(entry.getKey(), entry.getValue());
}
}
}
FileUtils.forceMkdir(new File(propertiesPath));
property.store(new FileOutputStream(new File(propertiesPath,
annotationPreferencePropertiesFileName)), null);
try (MDC.MDCCloseable closable = MDC.putCloseable(Logging.KEY_PROJECT_ID,
String.valueOf(aProject.getId()))) {
log.info("Saved preferences for user [{}] in project [{}]({})", aUsername,
aProject.getName(), aProject.getId());
}
}
@Override
public List<Project> listAccessibleProjects(User user)
{
List<Project> allowedProject = new ArrayList<Project>();
List<Project> allProjects = listProjects();
// if global admin, show all projects
if (SecurityUtil.isSuperAdmin(this, user)) {
return allProjects;
}
// else only projects she is admin of
for (Project project : allProjects) {
if (SecurityUtil.isProjectAdmin(project, this, user)) {
allowedProject.add(project);
}
}
return allowedProject;
}
@Override
@Transactional
public void onProjectImport(ZipFile aZip,
de.tudarmstadt.ukp.clarin.webanno.export.model.Project aExportedProject,
Project aProject)
throws Exception
{
// create project log
createProjectLog(aZip, aProject);
// create project guideline
createProjectGuideline(aZip, aProject);
// create project META-INF
createProjectMetaInf(aZip, aProject);
// Import project permissions
createProjectPermission(aExportedProject, aProject);
}
/**
* copy project log files from the exported project
* @param zip the ZIP file.
* @param aProject the project.
* @throws IOException if an I/O error occurs.
*/
@SuppressWarnings("rawtypes")
private void createProjectLog(ZipFile zip, Project aProject)
throws IOException
{
for (Enumeration zipEnumerate = zip.entries(); zipEnumerate.hasMoreElements();) {
ZipEntry entry = (ZipEntry) zipEnumerate.nextElement();
// Strip leading "/" that we had in ZIP files prior to 2.0.8 (bug #985)
String entryName = ZipUtils.normalizeEntryName(entry);
if (entryName.startsWith(LOG_DIR)) {
FileUtils.copyInputStreamToFile(zip.getInputStream(entry),
getProjectLogFile(aProject));
log.info("Imported log for project [" + aProject.getName() + "] with id ["
+ aProject.getId() + "]");
}
}
}
/**
* copy guidelines from the exported project
* @param zip the ZIP file.
* @param aProject the project.
* @throws IOException if an I/O error occurs.
*/
@SuppressWarnings("rawtypes")
private void createProjectGuideline(ZipFile zip, Project aProject)
throws IOException
{
for (Enumeration zipEnumerate = zip.entries(); zipEnumerate.hasMoreElements();) {
ZipEntry entry = (ZipEntry) zipEnumerate.nextElement();
// Strip leading "/" that we had in ZIP files prior to 2.0.8 (bug #985)
String entryName = ZipUtils.normalizeEntryName(entry);
if (entryName.startsWith(GUIDELINE)) {
String fileName = FilenameUtils.getName(entry.getName());
if(fileName.trim().isEmpty()){
continue;
}
File guidelineDir = getGuidelinesFile(aProject);
FileUtils.forceMkdir(guidelineDir);
FileUtils.copyInputStreamToFile(zip.getInputStream(entry), new File(guidelineDir,
fileName));
log.info("Imported guideline [" + fileName + "] for project [" + aProject.getName()
+ "] with id [" + aProject.getId() + "]");
}
}
}
/**
* copy Project META_INF from the exported project
* @param zip the ZIP file.
* @param aProject the project.
* @throws IOException if an I/O error occurs.
*/
@SuppressWarnings("rawtypes")
private void createProjectMetaInf(ZipFile zip, Project aProject)
throws IOException
{
for (Enumeration zipEnumerate = zip.entries(); zipEnumerate.hasMoreElements();) {
ZipEntry entry = (ZipEntry) zipEnumerate.nextElement();
// Strip leading "/" that we had in ZIP files prior to 2.0.8 (bug #985)
String entryName = ZipUtils.normalizeEntryName(entry);
if (entryName.startsWith(META_INF)) {
File metaInfDir = new File(getMetaInfFolder(aProject),
FilenameUtils.getPath(entry.getName().replace(META_INF, "")));
// where the file reside in the META-INF/... directory
FileUtils.forceMkdir(metaInfDir);
FileUtils.copyInputStreamToFile(zip.getInputStream(entry), new File(metaInfDir,
FilenameUtils.getName(entry.getName())));
log.info("Imported META-INF for project [" + aProject.getName() + "] with id ["
+ aProject.getId() + "]");
}
}
}
/**
* Create {@link ProjectPermission} from the exported
* {@link de.tudarmstadt.ukp.clarin.webanno.export.model.ProjectPermission}
* @param aImportedProjectSetting the imported project.
* @param aImportedProject the project.
* @throws IOException if an I/O error occurs.
*/
private void createProjectPermission(
de.tudarmstadt.ukp.clarin.webanno.export.model.Project aImportedProjectSetting,
Project aImportedProject)
throws IOException
{
for (de.tudarmstadt.ukp.clarin.webanno.export.model.ProjectPermission importedPermission : aImportedProjectSetting
.getProjectPermissions()) {
ProjectPermission permission = new ProjectPermission();
permission.setLevel(importedPermission.getLevel());
permission.setProject(aImportedProject);
permission.setUser(importedPermission.getUser());
createProjectPermission(permission);
}
}
@Override
public boolean isRunning()
{
return running;
}
@Override
public void start()
{
running = true;
scanProjectTypes();
}
@Override
public void stop()
{
running = false;
}
@Override
public int getPhase()
{
return Integer.MAX_VALUE;
}
@Override
public boolean isAutoStartup()
{
return true;
}
@Override
public void stop(Runnable aCallback)
{
stop();
aCallback.run();
}
private void scanProjectTypes()
{
projectTypes = new ArrayList<>();
// Scan for project type annotations
ClassPathScanningCandidateComponentProvider scanner =
new ClassPathScanningCandidateComponentProvider(false);
scanner.addIncludeFilter(new AnnotationTypeFilter(ProjectType.class));
for (BeanDefinition bd : scanner.findCandidateComponents("de.tudarmstadt.ukp")) {
try {
Class<?> clazz = (Class<?>) Class.forName(bd.getBeanClassName());
ProjectType pt = clazz.getAnnotation(ProjectType.class);
if (projectTypes.stream().anyMatch(t -> t.id().equals(pt.id()))) {
log.debug("Ignoring duplicate project type: {} ({})", pt.id(), pt.prio());
}
else {
log.debug("Found project type: {} ({})", pt.id(), pt.prio());
projectTypes.add(pt);
}
}
catch (ClassNotFoundException e) {
log.error("Class [{}] not found", bd.getBeanClassName(), e);
}
}
Collections.sort(projectTypes, (a, b) -> { return a.prio() - b.prio(); });
}
@Override
public List<ProjectType> listProjectTypes()
{
return Collections.unmodifiableList(projectTypes);
}
}