diff --git a/.github/workflows/frontend-ci.yml b/.github/workflows/frontend-ci.yml index bf8ee163..17cd184a 100644 --- a/.github/workflows/frontend-ci.yml +++ b/.github/workflows/frontend-ci.yml @@ -28,6 +28,8 @@ jobs: uses: actions/setup-node@v3 with: node-version: 24.x + - name: Pin npm version + run: npm install -g npm@11.6.1 # install dependencies - name: Install Dependencies run: npm ci diff --git a/app/docker-compose.deps.yml b/app/docker-compose.deps.yml index 64c1df47..1fb11adb 100644 --- a/app/docker-compose.deps.yml +++ b/app/docker-compose.deps.yml @@ -4,16 +4,18 @@ services: image: mysql:8.0 # NOTE: use of "mysql_native_password" is not recommended: https://dev.mysql.com/doc/refman/8.0/en/upgrading-from-previous-series.html#upgrade-caching-sha2-password # (this is just an example, not intended to be a production configuration) - command: --default-authentication-plugin=mysql_native_password + entrypoint: + - /bin/bash + - -c + - | + printf 'CREATE DATABASE IF NOT EXISTS unity_auth;\n' > /tmp/init.sql + exec docker-entrypoint.sh mysqld --default-authentication-plugin=mysql_native_password --init-file=/tmp/init.sql restart: always networks: - unity-network environment: MYSQL_ROOT_PASSWORD: test MYSQL_DATABASE: libre311 - configs: - - source: mysql-init - target: /docker-entrypoint-initdb.d/01-create-databases.sql healthcheck: test: ["CMD", "mysqladmin", "ping", "-h", "localhost", "-u", "root", "-ptest"] @@ -88,11 +90,6 @@ services: # After writing the file, start Nginx nginx -g 'daemon off;' -configs: - mysql-init: - content: | - CREATE DATABASE IF NOT EXISTS unity_auth; - networks: default: name: unity-network diff --git a/app/src/main/java/app/dto/jurisdiction/JurisdictionDTO.java b/app/src/main/java/app/dto/jurisdiction/JurisdictionDTO.java index cbef5047..1d88fd2a 100644 --- a/app/src/main/java/app/dto/jurisdiction/JurisdictionDTO.java +++ b/app/src/main/java/app/dto/jurisdiction/JurisdictionDTO.java @@ -63,6 +63,15 @@ public class JurisdictionDTO { @JsonProperty("project_feature") private app.model.jurisdiction.ProjectFeature projectFeature; + @JsonProperty("photo_voice_service_code") + private Long photoVoiceServiceCode; + + @JsonProperty("show_project_boundaries") + private boolean showProjectBoundaries; + + @JsonProperty("show_exit_project_mode") + private boolean showExitProjectMode; + @JsonProperty("closed_request_days_visible_user") private Integer closedRequestDaysVisibleUser; @@ -92,6 +101,9 @@ public JurisdictionDTO(Jurisdiction jurisdiction) { this.projectFeature = jurisdiction.getProjectFeature(); this.closedRequestDaysVisibleUser = jurisdiction.getClosedRequestDaysVisibleUser(); this.closedRequestDaysVisibleAdmin = jurisdiction.getClosedRequestDaysVisibleAdmin(); + this.photoVoiceServiceCode = jurisdiction.getPhotoVoiceServiceCode(); + this.showProjectBoundaries = jurisdiction.isShowProjectBoundaries(); + this.showExitProjectMode = jurisdiction.isShowExitProjectMode(); } public JurisdictionDTO(Jurisdiction jurisdiction, JurisdictionBoundary boundary) { @@ -223,4 +235,28 @@ public Integer getClosedRequestDaysVisibleAdmin() { public void setClosedRequestDaysVisibleAdmin(Integer closedRequestDaysVisibleAdmin) { this.closedRequestDaysVisibleAdmin = closedRequestDaysVisibleAdmin; } + + public Long getPhotoVoiceServiceCode() { + return photoVoiceServiceCode; + } + + public void setPhotoVoiceServiceCode(Long photoVoiceServiceCode) { + this.photoVoiceServiceCode = photoVoiceServiceCode; + } + + public boolean isShowProjectBoundaries() { + return showProjectBoundaries; + } + + public void setShowProjectBoundaries(boolean showProjectBoundaries) { + this.showProjectBoundaries = showProjectBoundaries; + } + + public boolean isShowExitProjectMode() { + return showExitProjectMode; + } + + public void setShowExitProjectMode(boolean showExitProjectMode) { + this.showExitProjectMode = showExitProjectMode; + } } diff --git a/app/src/main/java/app/dto/jurisdiction/PatchJurisdictionDTO.java b/app/src/main/java/app/dto/jurisdiction/PatchJurisdictionDTO.java index 5dcd5204..dbca1ad0 100644 --- a/app/src/main/java/app/dto/jurisdiction/PatchJurisdictionDTO.java +++ b/app/src/main/java/app/dto/jurisdiction/PatchJurisdictionDTO.java @@ -58,6 +58,15 @@ public class PatchJurisdictionDTO { @JsonProperty("project_feature") private app.model.jurisdiction.ProjectFeature projectFeature; + @JsonProperty("photo_voice_service_code") + private Long photoVoiceServiceCode; + + @JsonProperty("show_project_boundaries") + private Boolean showProjectBoundaries; + + @JsonProperty("show_exit_project_mode") + private Boolean showExitProjectMode; + @JsonProperty("closed_request_days_visible_user") private Integer closedRequestDaysVisibleUser; @@ -154,4 +163,28 @@ public Integer getClosedRequestDaysVisibleAdmin() { public void setClosedRequestDaysVisibleAdmin(Integer closedRequestDaysVisibleAdmin) { this.closedRequestDaysVisibleAdmin = closedRequestDaysVisibleAdmin; } + + public Long getPhotoVoiceServiceCode() { + return photoVoiceServiceCode; + } + + public void setPhotoVoiceServiceCode(Long photoVoiceServiceCode) { + this.photoVoiceServiceCode = photoVoiceServiceCode; + } + + public Boolean getShowProjectBoundaries() { + return showProjectBoundaries; + } + + public void setShowProjectBoundaries(Boolean showProjectBoundaries) { + this.showProjectBoundaries = showProjectBoundaries; + } + + public Boolean getShowExitProjectMode() { + return showExitProjectMode; + } + + public void setShowExitProjectMode(Boolean showExitProjectMode) { + this.showExitProjectMode = showExitProjectMode; + } } diff --git a/app/src/main/java/app/model/jurisdiction/Jurisdiction.java b/app/src/main/java/app/model/jurisdiction/Jurisdiction.java index 0350fe60..d6e34537 100644 --- a/app/src/main/java/app/model/jurisdiction/Jurisdiction.java +++ b/app/src/main/java/app/model/jurisdiction/Jurisdiction.java @@ -71,6 +71,13 @@ public class Jurisdiction { @NotNull private ProjectFeature projectFeature = ProjectFeature.DISABLED; + @Nullable + private Long photoVoiceServiceCode; + + private boolean showProjectBoundaries = true; + + private boolean showExitProjectMode = true; + @OneToMany(cascade = CascadeType.ALL, fetch = FetchType.EAGER, orphanRemoval = true, mappedBy = "jurisdiction") @OnDelete(action = OnDeleteAction.CASCADE) private Set remoteHosts = new HashSet<>(); @@ -216,4 +223,28 @@ public void setProjectFeature(ProjectFeature projectFeature) { this.projectFeature = projectFeature; } + public Long getPhotoVoiceServiceCode() { + return photoVoiceServiceCode; + } + + public void setPhotoVoiceServiceCode(Long photoVoiceServiceCode) { + this.photoVoiceServiceCode = photoVoiceServiceCode; + } + + public boolean isShowProjectBoundaries() { + return showProjectBoundaries; + } + + public void setShowProjectBoundaries(boolean showProjectBoundaries) { + this.showProjectBoundaries = showProjectBoundaries; + } + + public boolean isShowExitProjectMode() { + return showExitProjectMode; + } + + public void setShowExitProjectMode(boolean showExitProjectMode) { + this.showExitProjectMode = showExitProjectMode; + } + } \ No newline at end of file diff --git a/app/src/main/java/app/model/project/Project.java b/app/src/main/java/app/model/project/Project.java index b8d2d032..4b167b43 100644 --- a/app/src/main/java/app/model/project/Project.java +++ b/app/src/main/java/app/model/project/Project.java @@ -35,7 +35,7 @@ public class Project { @NotNull private String name; - @Column(insertable = false, updatable = false) + @Column(updatable = false) private String slug; @Nullable diff --git a/app/src/main/java/app/model/project/ProjectRepository.java b/app/src/main/java/app/model/project/ProjectRepository.java index 78b0b673..db2f361f 100644 --- a/app/src/main/java/app/model/project/ProjectRepository.java +++ b/app/src/main/java/app/model/project/ProjectRepository.java @@ -37,6 +37,9 @@ public interface ProjectRepository extends JpaRepository { Optional findBySlugAndJurisdictionId(String slug, String jurisdictionId); + @Query("SELECT count(p) FROM Project p WHERE p.jurisdiction.id = :jurisdictionId AND p.slug LIKE :slugPrefix") + long countSlugStartingWith(String jurisdictionId, String slugPrefix); + @Query("FROM Project p WHERE p.jurisdiction.id = :jurisdictionId AND p.startDate <= :time AND p.endDate >= :time AND intersects(p.boundary, :location) = true") Optional findProjectForLocationAndTime(String jurisdictionId, Point location, Instant time); } diff --git a/app/src/main/java/app/model/servicerequest/ServiceRequestRepository.java b/app/src/main/java/app/model/servicerequest/ServiceRequestRepository.java index 1bf348ff..68b92f44 100644 --- a/app/src/main/java/app/model/servicerequest/ServiceRequestRepository.java +++ b/app/src/main/java/app/model/servicerequest/ServiceRequestRepository.java @@ -68,6 +68,18 @@ default Page findAllBy(String jurisdictionId, List service return findAll(specification, pageable); } + @Transactional + default Page findAllByNullProject(String jurisdictionId, List serviceCodes, + List status, List priority, + Instant startDate, Instant endDate, Instant closedRequestCutoffDate, Pageable pageable) { + + QuerySpecification specification = getServiceRequestSpecification(jurisdictionId, serviceCodes, + status, priority, startDate, endDate, null, closedRequestCutoffDate) + .and(Specifications.projectIsNull()); + + return findAll(specification, pageable); + } + @Transactional default List findAllBy(String jurisdictionId, List serviceCodes, List status, List priority, @@ -174,6 +186,10 @@ public static QuerySpecification projectIdEqual(Long projectId) return (root, query, criteriaBuilder) -> criteriaBuilder.equal(root.get(ServiceRequest_.project).get("id"), projectId); } + public static QuerySpecification projectIsNull() { + return (root, query, criteriaBuilder) -> criteriaBuilder.isNull(root.get(ServiceRequest_.project)); + } + /** * Filter that applies date restriction only to CLOSED requests. * Non-closed requests are always visible; closed requests must be created after the cutoff date. diff --git a/app/src/main/java/app/service/SystemReservedGroupInitializer.java b/app/src/main/java/app/service/SystemReservedGroupInitializer.java new file mode 100644 index 00000000..0480df26 --- /dev/null +++ b/app/src/main/java/app/service/SystemReservedGroupInitializer.java @@ -0,0 +1,49 @@ +// Copyright 2023 Libre311 Authors +// +// 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 app.service; + +import app.model.jurisdiction.Jurisdiction; +import app.model.jurisdiction.JurisdictionRepository; +import app.model.service.group.ServiceGroup; +import app.model.service.group.ServiceGroupRepository; +import io.micronaut.runtime.event.annotation.EventListener; +import io.micronaut.runtime.server.event.ServerStartupEvent; +import jakarta.inject.Singleton; +import jakarta.transaction.Transactional; + +@Singleton +public class SystemReservedGroupInitializer { + + static final String SYSTEM_RESERVED = "System Reserved"; + + private final JurisdictionRepository jurisdictionRepository; + private final ServiceGroupRepository serviceGroupRepository; + + public SystemReservedGroupInitializer(JurisdictionRepository jurisdictionRepository, + ServiceGroupRepository serviceGroupRepository) { + this.jurisdictionRepository = jurisdictionRepository; + this.serviceGroupRepository = serviceGroupRepository; + } + + @EventListener + @Transactional + public void onStartup(ServerStartupEvent event) { + for (Jurisdiction jurisdiction : jurisdictionRepository.findAll()) { + if (!serviceGroupRepository.existsByNameAndJurisdiction(SYSTEM_RESERVED, jurisdiction)) { + serviceGroupRepository.save(new ServiceGroup(SYSTEM_RESERVED, jurisdiction)); + } + } + } +} diff --git a/app/src/main/java/app/service/jurisdiction/JurisdictionService.java b/app/src/main/java/app/service/jurisdiction/JurisdictionService.java index 9b25f352..cb7a6aac 100644 --- a/app/src/main/java/app/service/jurisdiction/JurisdictionService.java +++ b/app/src/main/java/app/service/jurisdiction/JurisdictionService.java @@ -22,6 +22,7 @@ import io.micronaut.context.annotation.Property; import io.micronaut.http.HttpStatus; import jakarta.inject.Singleton; +import jakarta.transaction.Transactional; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -133,6 +134,7 @@ public JurisdictionDTO createJurisdiction(CreateJurisdictionDTO requestDTO, Long return new JurisdictionDTO(savedJurisdiction, savedBoundary); } + @Transactional public JurisdictionDTO updateJurisdiction(String jurisdictionId, PatchJurisdictionDTO requestDTO) { Optional jurisdictionOptional = jurisdictionRepository.findById(jurisdictionId); @@ -188,6 +190,17 @@ private void applyPatch(PatchJurisdictionDTO jurisdictionDTO, Jurisdiction juris if (jurisdictionDTO.getClosedRequestDaysVisibleAdmin() != null) { jurisdiction.setClosedRequestDaysVisibleAdmin(jurisdictionDTO.getClosedRequestDaysVisibleAdmin()); } + if (jurisdictionDTO.getPhotoVoiceServiceCode() != null) { + jurisdiction.setPhotoVoiceServiceCode( + jurisdictionDTO.getPhotoVoiceServiceCode().equals(0L) ? null : jurisdictionDTO.getPhotoVoiceServiceCode() + ); + } + if (jurisdictionDTO.getShowProjectBoundaries() != null) { + jurisdiction.setShowProjectBoundaries(jurisdictionDTO.getShowProjectBoundaries()); + } + if (jurisdictionDTO.getShowExitProjectMode() != null) { + jurisdiction.setShowExitProjectMode(jurisdictionDTO.getShowExitProjectMode()); + } } public JurisdictionDTO setJurisdictionRemoteHosts(String jurisdictionId, Set remoteHosts) { diff --git a/app/src/main/java/app/service/project/ProjectService.java b/app/src/main/java/app/service/project/ProjectService.java index ac2e10ad..5014eae2 100644 --- a/app/src/main/java/app/service/project/ProjectService.java +++ b/app/src/main/java/app/service/project/ProjectService.java @@ -95,9 +95,18 @@ public ProjectDTO createProject(CreateProjectDTO dto, String jurisdictionId) { project.setEndDate(dto.getEndDate()); project.setJurisdiction(jurisdiction); + project.setSlug(generateUniqueSlug(dto.getName(), jurisdictionId)); return new ProjectDTO(projectRepository.save(project)); } + private String generateUniqueSlug(String name, String jurisdictionId) { + String base = name.toLowerCase() + .replaceAll("[^a-z0-9\\s]", "") + .replaceAll("\\s+", "-"); + long count = projectRepository.countSlugStartingWith(jurisdictionId, base + "%"); + return count == 0 ? base : base + "-" + count; + } + @Transactional public ProjectDTO updateProject(Long id, UpdateProjectDTO dto, String jurisdictionId) { Project project = projectRepository.findByIdAndJurisdictionId(id, jurisdictionId) diff --git a/app/src/main/java/app/service/servicerequest/ServiceRequestService.java b/app/src/main/java/app/service/servicerequest/ServiceRequestService.java index c15c5d90..9cfe1007 100644 --- a/app/src/main/java/app/service/servicerequest/ServiceRequestService.java +++ b/app/src/main/java/app/service/servicerequest/ServiceRequestService.java @@ -187,7 +187,8 @@ public PostResponseServiceRequestDTO createServiceRequest(HttpRequest request ServiceRequest serviceRequest = transformDtoToServiceRequest(serviceRequestDTO, service); Jurisdiction jurisdiction = jurisdictionRepository.findByJurisdictionId(jurisdictionId); - if (jurisdiction.getProjectFeature() != ProjectFeature.DISABLED) { + boolean isPhotoVoice = service.getId().equals(jurisdiction.getPhotoVoiceServiceCode()); + if (jurisdiction.getProjectFeature() != ProjectFeature.DISABLED && !isPhotoVoice) { if (serviceRequestDTO.getProjectId() != null) { Project project = projectRepository.findByIdAndJurisdictionId(serviceRequestDTO.getProjectId(), jurisdictionId) .orElseThrow(() -> new InvalidServiceRequestException("Project not found")); @@ -521,11 +522,13 @@ public Page findAll(GetServiceRequestsDTO requestDTO, String // Get the visibility days from jurisdiction config Jurisdiction jurisdiction = jurisdictionRepository.findByJurisdictionId(jurisdictionId); + int closedRequestDaysVisible = canViewSensitive ? jurisdiction.getClosedRequestDaysVisibleAdmin() : jurisdiction.getClosedRequestDaysVisibleUser(); - Page page = getServiceRequestPage(requestDTO, jurisdictionId, closedRequestDaysVisible); + boolean onlyNullProject = jurisdiction.getProjectFeature() == ProjectFeature.REQUIRED && requestDTO.getProjectId() == null; + Page page = getServiceRequestPage(requestDTO, jurisdictionId, closedRequestDaysVisible, onlyNullProject); Page dtoPage = page.map(mapper); if (canViewSensitive && !dtoPage.getContent().isEmpty()) { @@ -541,7 +544,7 @@ public Page findAll(GetServiceRequestsDTO requestDTO, String return dtoPage; } - private Page getServiceRequestPage(GetServiceRequestsDTO requestDTO, String jurisdictionId, int closedRequestDaysVisible) { + private Page getServiceRequestPage(GetServiceRequestsDTO requestDTO, String jurisdictionId, int closedRequestDaysVisible, boolean onlyNullProject) { String serviceRequestIds = requestDTO.getId(); List serviceCodes = requestDTO.getServiceCodes(); List statuses = requestDTO.getStatuses(); @@ -563,6 +566,10 @@ private Page getServiceRequestPage(GetServiceRequestsDTO request // Calculate the cutoff date for closed requests visibility Instant closedRequestCutoffDate = Instant.now().minus(closedRequestDaysVisible, ChronoUnit.DAYS); + if (onlyNullProject) { + return serviceRequestRepository.findAllByNullProject(jurisdictionId, serviceCodes, statuses, priorities, startDate, endDate, closedRequestCutoffDate, pageable); + } + return serviceRequestRepository.findAllBy(jurisdictionId, serviceCodes, statuses, priorities, startDate, endDate, projectId, closedRequestCutoffDate, pageable); } diff --git a/app/src/main/resources/db/migration/V23__add_photo_voice_service_code.sql b/app/src/main/resources/db/migration/V23__add_photo_voice_service_code.sql new file mode 100644 index 00000000..031c77b6 --- /dev/null +++ b/app/src/main/resources/db/migration/V23__add_photo_voice_service_code.sql @@ -0,0 +1 @@ +ALTER TABLE jurisdictions ADD COLUMN photo_voice_service_code BIGINT NULL; diff --git a/app/src/main/resources/db/migration/V24__promote_project_slug_to_real_column.sql b/app/src/main/resources/db/migration/V24__promote_project_slug_to_real_column.sql new file mode 100644 index 00000000..86c2bbb8 --- /dev/null +++ b/app/src/main/resources/db/migration/V24__promote_project_slug_to_real_column.sql @@ -0,0 +1,34 @@ +-- Add standalone index so the jurisdiction_id FK retains index support +-- when we drop the compound index from V17 +ALTER TABLE projects ADD INDEX idx_projects_jurisdiction_id (jurisdiction_id); + +-- Drop the V17 compound index, then the virtual column +DROP INDEX idx_projects_jurisdiction_slug ON projects; +ALTER TABLE projects DROP COLUMN slug; + +-- Add a real slug column +ALTER TABLE projects ADD COLUMN slug VARCHAR(255) NOT NULL DEFAULT ''; + +-- Backfill existing rows using the counter approach: +-- partition by (jurisdiction_id, base_slug) ordered by id so the first project +-- gets the plain slug and subsequent ones get slug-1, slug-2, etc. +UPDATE projects p +JOIN ( + SELECT id, + base_slug, + ROW_NUMBER() OVER (PARTITION BY jurisdiction_id, base_slug ORDER BY id) - 1 AS cnt + FROM ( + SELECT id, + jurisdiction_id, + LOWER(REGEXP_REPLACE(REGEXP_REPLACE(name, '[^a-zA-Z0-9 ]', ''), ' +', '-')) AS base_slug + FROM projects + ) base +) sub ON p.id = sub.id +SET p.slug = CASE WHEN sub.cnt = 0 THEN sub.base_slug ELSE CONCAT(sub.base_slug, '-', sub.cnt) END; + +ALTER TABLE projects ALTER COLUMN slug DROP DEFAULT; + +-- uk_projects_jurisdiction_slug covers jurisdiction_id as its leftmost prefix, +-- so the temporary standalone index is no longer needed +ALTER TABLE projects ADD UNIQUE KEY uk_projects_jurisdiction_slug (jurisdiction_id, slug); +DROP INDEX idx_projects_jurisdiction_id ON projects; diff --git a/app/src/main/resources/db/migration/V25__add_show_project_boundaries.sql b/app/src/main/resources/db/migration/V25__add_show_project_boundaries.sql new file mode 100644 index 00000000..d506510a --- /dev/null +++ b/app/src/main/resources/db/migration/V25__add_show_project_boundaries.sql @@ -0,0 +1 @@ +ALTER TABLE jurisdictions ADD COLUMN show_project_boundaries BOOLEAN NOT NULL DEFAULT TRUE; diff --git a/app/src/main/resources/db/migration/V26__add_show_exit_project_mode.sql b/app/src/main/resources/db/migration/V26__add_show_exit_project_mode.sql new file mode 100644 index 00000000..e53b36a5 --- /dev/null +++ b/app/src/main/resources/db/migration/V26__add_show_exit_project_mode.sql @@ -0,0 +1 @@ +ALTER TABLE jurisdictions ADD COLUMN show_exit_project_mode BOOLEAN NOT NULL DEFAULT TRUE; diff --git a/app/src/test/java/app/ProjectAdminControllerTest.java b/app/src/test/java/app/ProjectAdminControllerTest.java index 8f930ad2..b57e44d9 100644 --- a/app/src/test/java/app/ProjectAdminControllerTest.java +++ b/app/src/test/java/app/ProjectAdminControllerTest.java @@ -106,6 +106,7 @@ void testIndexAuthenticated() { Project project = new Project(); project.setName("Test Project"); + project.setSlug("test-project"); project.setJurisdiction(jurisdiction); project.setBoundary(geometryFactory.createPolygon(new Double[][]{{0.0, 0.0}, {0.0, 1.0}, {1.0, 1.0}, {1.0, 0.0}, {0.0, 0.0}})); project.setStartDate(Instant.now()); @@ -147,6 +148,7 @@ void testUpdateProject() { Project project = new Project(); project.setName("Old Name"); + project.setSlug("old-name"); project.setJurisdiction(jurisdiction); project.setBoundary(geometryFactory.createPolygon(new Double[][]{{0.0, 0.0}, {0.0, 1.0}, {1.0, 1.0}, {1.0, 0.0}, {0.0, 0.0}})); project.setStartDate(Instant.now()); diff --git a/app/src/test/java/app/model/project/ProjectRepositoryTest.java b/app/src/test/java/app/model/project/ProjectRepositoryTest.java index b90759f0..969b0747 100644 --- a/app/src/test/java/app/model/project/ProjectRepositoryTest.java +++ b/app/src/test/java/app/model/project/ProjectRepositoryTest.java @@ -58,13 +58,14 @@ void testFindProjectForLocationAndTime() { Project project = new Project(); project.setName("Test Project"); + project.setSlug("test-project"); project.setJurisdiction(jurisdiction); project.setBoundary(geometryFactory.createPolygon(bounds)); - + Instant now = Instant.now().truncatedTo(ChronoUnit.SECONDS); project.setStartDate(now.minus(1, ChronoUnit.DAYS)); project.setEndDate(now.plus(1, ChronoUnit.DAYS)); - + projectRepository.save(project); // Point at (5,5) should be inside @@ -88,13 +89,14 @@ void testFindProjectForLocationAndTime_Outside() { Project project = new Project(); project.setName("Test Project"); + project.setSlug("test-project"); project.setJurisdiction(jurisdiction); project.setBoundary(geometryFactory.createPolygon(bounds)); - + Instant now = Instant.now().truncatedTo(ChronoUnit.SECONDS); project.setStartDate(now.minus(1, ChronoUnit.DAYS)); project.setEndDate(now.plus(1, ChronoUnit.DAYS)); - + projectRepository.save(project); // Point at (15,15) should be outside @@ -117,6 +119,7 @@ void testFindProjectForLocationAndTime_MultipleOverlapping() { Project project1 = new Project(); project1.setName("Project 1"); + project1.setSlug("project-1"); project1.setJurisdiction(jurisdiction); project1.setBoundary(geometryFactory.createPolygon(bounds)); Instant now = Instant.now().truncatedTo(ChronoUnit.SECONDS); @@ -126,6 +129,7 @@ void testFindProjectForLocationAndTime_MultipleOverlapping() { Project project2 = new Project(); project2.setName("Project 2"); + project2.setSlug("project-2"); project2.setJurisdiction(jurisdiction); project2.setBoundary(geometryFactory.createPolygon(bounds)); project2.setStartDate(now.minus(1, ChronoUnit.DAYS)); @@ -152,6 +156,7 @@ void testFindProjectForLocationAndTime_OnBoundary() { Project project = new Project(); project.setName("Boundary Project"); + project.setSlug("boundary-project"); project.setJurisdiction(jurisdiction); project.setBoundary(geometryFactory.createPolygon(bounds)); Instant now = Instant.now().truncatedTo(ChronoUnit.SECONDS); @@ -171,6 +176,7 @@ void testFindProjectForLocationAndTime_TimeBounds() { Double[][] bounds = { {0.0, 0.0}, {0.0, 10.0}, {10.0, 10.0}, {10.0, 0.0}, {0.0, 0.0} }; Project project = new Project(); project.setName("Timed Project"); + project.setSlug("timed-project"); project.setJurisdiction(jurisdiction); project.setBoundary(geometryFactory.createPolygon(bounds)); diff --git a/app/src/test/java/app/model/project/ServiceRequestProjectPersistenceTest.java b/app/src/test/java/app/model/project/ServiceRequestProjectPersistenceTest.java index 08b97697..1adb574e 100644 --- a/app/src/test/java/app/model/project/ServiceRequestProjectPersistenceTest.java +++ b/app/src/test/java/app/model/project/ServiceRequestProjectPersistenceTest.java @@ -79,6 +79,7 @@ void setup() { Double[][] bounds = {{0.0, 0.0}, {0.0, 1.0}, {1.0, 1.0}, {1.0, 0.0}, {0.0, 0.0}}; project = new Project(); project.setName("Test Project"); + project.setSlug("test-project"); project.setJurisdiction(jurisdiction); project.setBoundary(geometryFactory.createPolygon(bounds)); project.setStartDate(Instant.now()); diff --git a/app/src/test/java/app/service/project/ProjectServiceTest.java b/app/src/test/java/app/service/project/ProjectServiceTest.java index 91d5f066..1727e084 100644 --- a/app/src/test/java/app/service/project/ProjectServiceTest.java +++ b/app/src/test/java/app/service/project/ProjectServiceTest.java @@ -103,6 +103,7 @@ void testCreateProject_JurisdictionNotFound() { void testUpdateProject() { Project project = new Project(); project.setName("Old Name"); + project.setSlug("old-name"); project.setJurisdiction(jurisdiction); project.setBoundary(new LibreGeometryFactory().createPolygon(new Double[][]{{0.0, 0.0}, {0.0, 1.0}, {1.0, 1.0}, {1.0, 0.0}, {0.0, 0.0}})); project.setStartDate(Instant.now()); @@ -134,6 +135,7 @@ void testUpdateProject_NotFound() { void testGetProjects() { Project p1 = new Project(); p1.setName("P1"); + p1.setSlug("p1"); p1.setJurisdiction(jurisdiction); p1.setBoundary(new LibreGeometryFactory().createPolygon(new Double[][]{{0.0, 0.0}, {0.0, 1.0}, {1.0, 1.0}, {1.0, 0.0}, {0.0, 0.0}})); p1.setStartDate(Instant.now()); diff --git a/app/src/test/java/app/service/project/ProjectStatusTest.java b/app/src/test/java/app/service/project/ProjectStatusTest.java index 1566c18d..c35b924e 100644 --- a/app/src/test/java/app/service/project/ProjectStatusTest.java +++ b/app/src/test/java/app/service/project/ProjectStatusTest.java @@ -86,6 +86,7 @@ private Project createProject(Instant startDate, Instant endDate) { Double[][] projectBounds = { {10.0, 10.0}, {10.0, 20.0}, {20.0, 20.0}, {20.0, 10.0}, {10.0, 10.0} }; Project project = new Project(); project.setName("Test Project"); + project.setSlug("test-project"); project.setJurisdiction(jurisdiction); project.setBoundary(geometryFactory.createPolygon(projectBounds)); project.setStartDate(startDate); diff --git a/app/src/test/java/app/service/servicerequest/ProjectFeatureTest.java b/app/src/test/java/app/service/servicerequest/ProjectFeatureTest.java index ffdc5220..d7bf6847 100644 --- a/app/src/test/java/app/service/servicerequest/ProjectFeatureTest.java +++ b/app/src/test/java/app/service/servicerequest/ProjectFeatureTest.java @@ -90,6 +90,7 @@ private Project createProject() { Double[][] projectBounds = { {10.0, 10.0}, {10.0, 20.0}, {20.0, 20.0}, {20.0, 10.0}, {10.0, 10.0} }; Project project = new Project(); project.setName("Test Project"); + project.setSlug("test-project"); project.setJurisdiction(jurisdiction); project.setBoundary(geometryFactory.createPolygon(projectBounds)); Instant now = Instant.now(); diff --git a/app/src/test/java/app/util/DbCleanup.java b/app/src/test/java/app/util/DbCleanup.java index 0cb0ba32..bfa9752e 100644 --- a/app/src/test/java/app/util/DbCleanup.java +++ b/app/src/test/java/app/util/DbCleanup.java @@ -17,6 +17,7 @@ import app.model.jurisdiction.JurisdictionBoundaryRepository; import app.model.jurisdiction.JurisdictionRepository; import app.model.jurisdictionuser.JurisdictionUserRepository; +import app.model.project.ProjectRepository; import app.model.service.ServiceRepository; import app.model.service.group.ServiceGroupRepository; import app.model.servicedefinition.AttributeValueRepository; @@ -57,16 +58,20 @@ public class DbCleanup { @Inject public JurisdictionUserRepository jurisdictionUserRepository; + @Inject + public ProjectRepository projectRepository; + @Transactional public void cleanupAll(){ userRepository.deleteAll(); jurisdictionUserRepository.deleteAll(); attributeValueRepository.deleteAll(); serviceDefinitionAttributeRepository.deleteAll(); + serviceRequestRepository.deleteAll(); + projectRepository.deleteAll(); serviceRepository.deleteAll(); serviceGroupRepository.deleteAll(); jurisdictionRepository.deleteAll(); - serviceRequestRepository.deleteAll(); } @Transactional diff --git a/build.gradle b/build.gradle new file mode 100644 index 00000000..58844a64 --- /dev/null +++ b/build.gradle @@ -0,0 +1,3 @@ +plugins { + id("com.bmuschko.docker-remote-api") version "9.4.0" apply false +} diff --git a/frontend/package-lock.json b/frontend/package-lock.json index df89ee93..e58b2ba4 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -260,6 +260,7 @@ } ], "license": "MIT", + "peer": true, "engines": { "node": ">=18" }, @@ -283,6 +284,7 @@ } ], "license": "MIT", + "peer": true, "engines": { "node": ">=18" } @@ -1667,6 +1669,7 @@ "integrity": "sha512-nKNhUdt61vtD961kQpUk6vLDhpnV0yku5F1uYNWvrJYFV0+cGfmW7ol0JVMSjHMXlMtmmv2FTc+nPRrTFwb2UA==", "dev": true, "hasInstallScript": true, + "peer": true, "dependencies": { "@types/cookie": "^0.6.0", "cookie": "^0.6.0", @@ -1698,6 +1701,7 @@ "resolved": "https://registry.npmjs.org/@sveltejs/vite-plugin-svelte/-/vite-plugin-svelte-3.0.1.tgz", "integrity": "sha512-CGURX6Ps+TkOovK6xV+Y2rn8JKa8ZPUHPZ/NKgCxAmgBrXReavzFl8aOSCj3kQ1xqT7yGJj53hjcV/gqwDAaWA==", "dev": true, + "peer": true, "dependencies": { "@sveltejs/vite-plugin-svelte-inspector": "^2.0.0-next.0 || ^2.0.0", "debug": "^4.3.4", @@ -4068,6 +4072,7 @@ "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-6.18.1.tgz", "integrity": "sha512-zct/MdJnVaRRNy9e84XnVtRv9Vf91/qqe+hZJtKanjojud4wAVy/7lXxJmMyX6X6J+xc6c//YEWvpeif8cAhWA==", "dev": true, + "peer": true, "dependencies": { "@typescript-eslint/scope-manager": "6.18.1", "@typescript-eslint/types": "6.18.1", @@ -4331,6 +4336,7 @@ "version": "8.11.3", "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.11.3.tgz", "integrity": "sha512-Y9rRfJG5jcKOE0CLisYbojUjIrIEE7AGMzA/Sm4BslANhbS+cDMpgBdcPT91oJ7OuJ9hYJBx59RjbhxVnrF8Xg==", + "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -4589,6 +4595,7 @@ "url": "https://github.com/sponsors/ai" } ], + "peer": true, "dependencies": { "caniuse-lite": "^1.0.30001565", "electron-to-chromium": "^1.4.601", @@ -5334,6 +5341,7 @@ "resolved": "https://registry.npmjs.org/eslint/-/eslint-8.56.0.tgz", "integrity": "sha512-Go19xM6T9puCOWntie1/P997aXxFsOi37JIHRWI514Hc6ZnaHGKY9xFhrU65RT6CcBEzZoGG1e6Nq+DT04ZtZQ==", "dev": true, + "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.2.0", "@eslint-community/regexpp": "^4.6.1", @@ -6389,6 +6397,7 @@ "version": "1.21.0", "resolved": "https://registry.npmjs.org/jiti/-/jiti-1.21.0.tgz", "integrity": "sha512-gFqAIbuKyyso/3G2qhiO2OM6shY6EPP/R0+mkDbyspxKazh8BXDC5FiFsUjlczgdNz/vfra0da2y+aHrusLG/Q==", + "peer": true, "bin": { "jiti": "bin/jiti.js" } @@ -6419,6 +6428,7 @@ "integrity": "sha512-MyL55p3Ut3cXbeBEG7Hcv0mVM8pp8PBNWxRqchZnSfAiES1v1mRnMeFfaHWIPULpwsYfvO+ZmMZz5tGCnjzDUQ==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "cssstyle": "^4.0.1", "data-urls": "^5.0.0", @@ -7243,6 +7253,7 @@ } ], "license": "MIT", + "peer": true, "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", @@ -7301,6 +7312,7 @@ "url": "https://github.com/sponsors/ai" } ], + "peer": true, "dependencies": { "lilconfig": "^3.0.0", "yaml": "^2.3.4" @@ -7412,6 +7424,7 @@ "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.2.1.tgz", "integrity": "sha512-qSUWshj1IobVbKc226Gw2pync27t0Kf0EdufZa9j7uBSJay1CC+B3K5lAAZoqgX3ASiKuWsk6OmzKRetXNObWg==", "dev": true, + "peer": true, "bin": { "prettier": "bin/prettier.cjs" }, @@ -7427,6 +7440,7 @@ "resolved": "https://registry.npmjs.org/prettier-plugin-svelte/-/prettier-plugin-svelte-3.1.2.tgz", "integrity": "sha512-7xfMZtwgAWHMT0iZc8jN4o65zgbAQ3+O32V6W7pXrqNvKnHnkoyQCGCbKeUyXKZLbYE0YhFRnamfxfkEGxm8qA==", "dev": true, + "peer": true, "peerDependencies": { "prettier": "^3.0.0", "svelte": "^3.2.0 || ^4.0.0-next.0 || ^5.0.0-next.0" @@ -7827,28 +7841,6 @@ "rimraf": "bin.js" } }, - "node_modules/sass": { - "version": "1.97.3", - "resolved": "https://registry.npmjs.org/sass/-/sass-1.97.3.tgz", - "integrity": "sha512-fDz1zJpd5GycprAbu4Q2PV/RprsRtKC/0z82z0JLgdytmcq0+ujJbJ/09bPGDxCLkKY3Np5cRAOcWiVkLXJURg==", - "dev": true, - "license": "MIT", - "optional": true, - "dependencies": { - "chokidar": "^4.0.0", - "immutable": "^5.0.2", - "source-map-js": ">=0.6.2 <2.0.0" - }, - "bin": { - "sass": "sass.js" - }, - "engines": { - "node": ">=14.0.0" - }, - "optionalDependencies": { - "@parcel/watcher": "^2.4.1" - } - }, "node_modules/sass-embedded": { "version": "1.97.3", "resolved": "https://registry.npmjs.org/sass-embedded/-/sass-embedded-1.97.3.tgz", @@ -8213,38 +8205,6 @@ "url": "https://github.com/chalk/supports-color?sponsor=1" } }, - "node_modules/sass/node_modules/chokidar": { - "version": "4.0.3", - "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-4.0.3.tgz", - "integrity": "sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==", - "dev": true, - "license": "MIT", - "optional": true, - "dependencies": { - "readdirp": "^4.0.1" - }, - "engines": { - "node": ">= 14.16.0" - }, - "funding": { - "url": "https://paulmillr.com/funding/" - } - }, - "node_modules/sass/node_modules/readdirp": { - "version": "4.1.2", - "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-4.1.2.tgz", - "integrity": "sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==", - "dev": true, - "license": "MIT", - "optional": true, - "engines": { - "node": ">= 14.18.0" - }, - "funding": { - "type": "individual", - "url": "https://paulmillr.com/funding/" - } - }, "node_modules/saxes": { "version": "6.0.0", "resolved": "https://registry.npmjs.org/saxes/-/saxes-6.0.0.tgz", @@ -8634,6 +8594,7 @@ "version": "4.2.19", "resolved": "https://registry.npmjs.org/svelte/-/svelte-4.2.19.tgz", "integrity": "sha512-IY1rnGr6izd10B0A8LqsBfmlT5OILVuZ7XsI0vdGPEvuonFV7NYEUK4dAkm9Zg2q0Um92kYjTpS1CAP3Nh/KWw==", + "peer": true, "dependencies": { "@ampproject/remapping": "^2.2.1", "@jridgewell/sourcemap-codec": "^1.4.15", @@ -8834,6 +8795,7 @@ "version": "3.4.1", "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-3.4.1.tgz", "integrity": "sha512-qAYmXRfk3ENzuPBakNK0SRrUDipP8NQnEY6772uDhflcQz5EhRdD7JNZxyrFHVQNCwULPBn6FNPp9brpO7ctcA==", + "peer": true, "dependencies": { "@alloc/quick-lru": "^5.2.0", "arg": "^5.0.2", @@ -9141,6 +9103,7 @@ "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.3.3.tgz", "integrity": "sha512-pXWcraxM0uxAS+tN0AG/BF2TyqmHO014Z070UsJ+pFvYuRSq8KH8DmWpnbXe0pEPDHXZV3FcAbJkijJ5oNEnWw==", "dev": true, + "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -9233,6 +9196,7 @@ "integrity": "sha512-j3lYzGC3P+B5Yfy/pfKNgVEg4+UtcIJcVRt2cDjIOmhLourAqPqf8P7acgxeiSgUB7E3p2P8/3gNIgDLpwzs4g==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "esbuild": "^0.21.3", "postcss": "^8.4.43", @@ -9344,6 +9308,7 @@ "integrity": "sha512-Ljb1cnSJSivGN0LqXd/zmDbWEM0RNNg2t1QW/XUhYl/qPqyu7CsqeWtqQXHVaJsecLPuDoak2oJcZN2QoRIOag==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@vitest/expect": "1.6.1", "@vitest/runner": "1.6.1", diff --git a/frontend/src/lib/components/CreateServiceRequest/PhotoVoiceDetailsForm.svelte b/frontend/src/lib/components/CreateServiceRequest/PhotoVoiceDetailsForm.svelte new file mode 100644 index 00000000..8da48113 --- /dev/null +++ b/frontend/src/lib/components/CreateServiceRequest/PhotoVoiceDetailsForm.svelte @@ -0,0 +1,118 @@ + + +
+
+ {#if imageData} +
+ preview +
+ {/if} + + {#if loading} +

Loading...

+ {:else if questionAttribute} + {#if service.description} +

{service.description}

+ {/if} + + + + {#if answerError} +

{answerError}

+ {/if} + {:else} +

+ This feature is not fully configured. Contact an administrator. +

+ {/if} +
+ + + Confirm Details + +
+ + diff --git a/frontend/src/lib/components/CreateServiceRequest/ReviewServiceRequest.svelte b/frontend/src/lib/components/CreateServiceRequest/ReviewServiceRequest.svelte index f347e526..7cdff9dc 100644 --- a/frontend/src/lib/components/CreateServiceRequest/ReviewServiceRequest.svelte +++ b/frontend/src/lib/components/CreateServiceRequest/ReviewServiceRequest.svelte @@ -23,6 +23,8 @@ const dispatch = createEventDispatcher<{ submitted: void }>(); export let params: CreateServiceRequestUIParams; + export let title: string = messages['reviewServiceRequest']['title']; + export let submitLabel: string = messages['reviewServiceRequest']['button_submit']; let imageData: string | undefined; let submittingServiceRequest: boolean = false; @@ -100,7 +102,7 @@
-

{messages['reviewServiceRequest']['title']}

+

{title}

@@ -162,9 +164,7 @@ - {messages['reviewServiceRequest']['button_submit']} + {submitLabel}
diff --git a/frontend/src/lib/components/CreateServiceRequest/SelectARequestCategory.svelte b/frontend/src/lib/components/CreateServiceRequest/SelectARequestCategory.svelte index 48795e41..30b97677 100644 --- a/frontend/src/lib/components/CreateServiceRequest/SelectARequestCategory.svelte +++ b/frontend/src/lib/components/CreateServiceRequest/SelectARequestCategory.svelte @@ -18,6 +18,7 @@ import type { CreateServiceRequestUIParams } from './shared'; import messages from '$media/messages.json'; import { setUpAlertRole } from '$lib/utils/functions'; + import { SYSTEM_RESERVED_GROUP_NAME } from '$lib/constants/photoVoice'; export let params: Partial; @@ -36,10 +37,11 @@ onMount(fetchServiceList); function fetchServiceList() { - libre311 - .getServiceList() - .then((res) => { - serviceList = asAsyncSuccess(res); + Promise.all([libre311.getServiceList(), libre311.getGroupList()]) + .then(([services, groups]) => { + const reservedId = groups.find((g) => g.name === SYSTEM_RESERVED_GROUP_NAME)?.id; + const filtered = services.filter((s) => s.group_id !== reservedId); + serviceList = asAsyncSuccess(filtered); }) .catch((err) => (serviceList = asAsyncFailure(err))); } @@ -103,7 +105,7 @@ + {#if photoVoiceService && nameDirty} +
+ +
+ {/if} +
+ +
+ +

+ Shown above the question to provide context to citizens. +

+ + {#if photoVoiceService && descriptionDirty} +
+ +
+ {/if} +
+ +
+ +

The prompt citizens respond to.

+ + {#if photoVoiceService && questionDirty} +
+ +
+ {/if} +
+
+ +
+ {#if photoVoiceService} + + Status: Enabled + + + {:else} + + Status: Disabled + + + {/if} +
+ {/if} +
diff --git a/frontend/src/routes/groups/config/+page.svelte b/frontend/src/routes/groups/config/+page.svelte index 9c3b9eea..5f91c642 100644 --- a/frontend/src/routes/groups/config/+page.svelte +++ b/frontend/src/routes/groups/config/+page.svelte @@ -1,6 +1,7 @@ + + +
+
+

{messages['photoVoice']['create']}

+ {#if serviceLoading} +

Loading...

+ {:else if !photoVoiceService} +

+ Photo Voice is not currently enabled. An administrator must enable it from System + Administration. +

+ {:else if step === CreateServiceRequestSteps.LOCATION} + + {:else if step === CreateServiceRequestSteps.DETAILS} + + {:else if step === CreateServiceRequestSteps.REVIEW} + {#if isPhotoVoiceUIParams(params)} + {}} + /> + {:else} +

+ Something went wrong. . +

+ {/if} + {:else} + + {/if} +
+
+
+ + + + {#if step === CreateServiceRequestSteps.LOCATION && $isOnline} + + {/if} + + +
+ + +
+
+
+
diff --git a/frontend/src/routes/projects/+page.svelte b/frontend/src/routes/projects/+page.svelte index 5ff22e72..89cbf4ca 100644 --- a/frontend/src/routes/projects/+page.svelte +++ b/frontend/src/routes/projects/+page.svelte @@ -33,7 +33,7 @@ { column: 'end_date', label: 'End Date', class: 'w-1/6', placement: 'left' }, { column: 'closed_date', label: 'Closed Date', class: 'w-1/6', placement: 'left' }, { column: 'status', label: 'Status', class: 'w-1/6', placement: 'left' }, - { column: 'request_count', label: 'Requests', class: 'w-1/12', placement: 'left' } + { column: 'request_count', label: 'Submissions', class: 'w-1/12', placement: 'left' } ]; let showAllClosed = false; diff --git a/frontend/src/routes/projects/[project_id]/(view)/+layout.svelte b/frontend/src/routes/projects/[project_id]/(view)/+layout.svelte index 5cdabf98..02cbcace 100644 --- a/frontend/src/routes/projects/[project_id]/(view)/+layout.svelte +++ b/frontend/src/routes/projects/[project_id]/(view)/+layout.svelte @@ -185,6 +185,18 @@ {project.status} + {#if project.slug} + URL: + + {$page.url.origin}/issues/map/project/{project.slug} + + + {/if} {/if} diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties index 23449a2b..5dd3c012 100644 --- a/gradle/wrapper/gradle-wrapper.properties +++ b/gradle/wrapper/gradle-wrapper.properties @@ -1,6 +1,6 @@ distributionBase=GRADLE_USER_HOME distributionPath=wrapper/dists -distributionUrl=https\://services.gradle.org/distributions/gradle-9.2.1-bin.zip +distributionUrl=https\://services.gradle.org/distributions/gradle-9.5.1-bin.zip networkTimeout=10000 validateDistributionUrl=true zipStoreBase=GRADLE_USER_HOME