From d61dfd8142f0520338413badf1134fa5dac977f3 Mon Sep 17 00:00:00 2001 From: jaykamal Date: Mon, 20 Jan 2025 12:19:33 +0530 Subject: [PATCH 01/58] Start 5.3.0-SNAPSHOT --- pom.xml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pom.xml b/pom.xml index 364a9c21a..59be295e0 100644 --- a/pom.xml +++ b/pom.xml @@ -5,7 +5,7 @@ org.ihtsdo.mlds mlds war - 5.2.0 + 5.3.0-SNAPSHOT ihtsdo-mlds Maven Webapp http://maven.apache.org From 21e5c0a793930a27be4f0369f6c1d47c2dc84d1c Mon Sep 17 00:00:00 2001 From: Quyen Ly Date: Wed, 19 Mar 2025 16:04:17 +0700 Subject: [PATCH 02/58] PIP-718 Update parent bom version to 3.10.0-SNAPSHOT to fix CVE issue --- pom.xml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pom.xml b/pom.xml index 59be295e0..adf5c50cd 100644 --- a/pom.xml +++ b/pom.xml @@ -12,7 +12,7 @@ org.snomed snomed-parent-bom - 3.8.0 + 3.10.0-SNAPSHOT From 341a4b74a36d50882cd213a7196868681223fca6 Mon Sep 17 00:00:00 2001 From: steve-a-snomed Date: Wed, 30 Apr 2025 17:27:37 +0100 Subject: [PATCH 03/58] MLDS-1190 Add documentation for syndication feed --- src/main/documentation/Syndication-feed.md | 36 ++++++++++++++++++++++ 1 file changed, 36 insertions(+) create mode 100644 src/main/documentation/Syndication-feed.md diff --git a/src/main/documentation/Syndication-feed.md b/src/main/documentation/Syndication-feed.md new file mode 100644 index 000000000..2376c3ea3 --- /dev/null +++ b/src/main/documentation/Syndication-feed.md @@ -0,0 +1,36 @@ +# Syndication Feed + +The [MLDS Syndication Feed](https://mlds.ihtsdotools.org/api/feed) is a public XML feed which extends the [Atom Syndication Format](https://tools.ietf.org/html/rfc4287) as described in the [IHTSDO +termserver-syndication](https://github.com/IHTSDO/termserver-syndication) github repository documentation. + +The feed has been available since MLDS 4.0.3 was deployed on 20 September 2023. + +Related JIRA MLDS project tickets include: + +* [MLDS-987](https://jira.ihtsdotools.org/browse/MLDS-987) New: Atom syndication feed for MLDS +* [MLDS-1021](https://jira.ihtsdotools.org/browse/MLDS-1021) Syndication feed refinement +* [MLDS-1028](https://jira.ihtsdotools.org/browse/MLDS-1028) Syndication feed refinements +* [MLDS-1031](https://jira.ihtsdotools.org/browse/MLDS-1031) MLDS Syndication Feed: Improve consistency of alternate links length attribute value formatting +* [MLDS-1043](https://jira.ihtsdotools.org/browse/MLDS-1043) Missing dependencies in MLDS Syndication Feed +* [MLDS-1047](https://jira.ihtsdotools.org/browse/MLDS-1047) MLDS \[update\] : add Access-Control headers to Syndication feed +* [MLDS-1051](https://jira.ihtsdotools.org/browse/MLDS-1051) Syndication dependencies are not highlighted when about to be removed +* [MLDS-1131](https://jira.ihtsdotools.org/browse/MLDS-1131) MLDS Feed Authentication Breaking Change +* [MLDS-1190](https://jira.ihtsdotools.org/browse/MLDS-1190) Clarification: how are Archived releases handled in the syndication feeds? +* [MLDS-1197](https://jira.ihtsdotools.org/browse/MLDS-1197) Syndication Feed: data validation refinement + +## Specification + +The [MLDS-987 description](https://jira.ihtsdotools.org/browse/MLDS-987#descriptionmodule) referred directly to the [IHTSDO +termserver-syndication](https://github.com/IHTSDO/termserver-syndication) documentation as the requirements specification. + +## Testing + +Feed compliance verification in Dev and UAT has used the [W3C Feed Validation Service](https://validator.w3.org/feed/check.cgi?url=https%3A%2F%2Fuat-mlds-v4.ihtsdotools.org%2Fapi%2Ffeed). + +## Additional information + +### Impact of [MLDS-1049](https://jira.ihtsdotools.org/browse/MLDS-1049) archiving features on syndication feed + +[MLDS-1049](https://jira.ihtsdotools.org/browse/MLDS-1049) added release archiving features to MLDS. The impact of these on syndication feed contents was queried in [MLDS-1190](https://jira.ihtsdotools.org/browse/MLDS-1190), and the response also summarised here: + +>The MLDS syndication feed only includes releases with published status, so the impact of archiving changes for [MLDS-1049](https://jira.ihtsdotools.org/browse/MLDS-1049) is to remove a previously published release from the syndication feed if it gets archived. This is also the impact of changing it to any other offline status. From 3de0ac6f4145a49103ba5f6107888e4005e9d3a9 Mon Sep 17 00:00:00 2001 From: steve-a-snomed Date: Thu, 1 May 2025 10:31:48 +0100 Subject: [PATCH 04/58] MLDS-1190 Update W3C Feed Validation Service URL in Syndication-feed.md --- src/main/documentation/Syndication-feed.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/main/documentation/Syndication-feed.md b/src/main/documentation/Syndication-feed.md index 2376c3ea3..4edd3c729 100644 --- a/src/main/documentation/Syndication-feed.md +++ b/src/main/documentation/Syndication-feed.md @@ -25,7 +25,7 @@ termserver-syndication](https://github.com/IHTSDO/termserver-syndication) docume ## Testing -Feed compliance verification in Dev and UAT has used the [W3C Feed Validation Service](https://validator.w3.org/feed/check.cgi?url=https%3A%2F%2Fuat-mlds-v4.ihtsdotools.org%2Fapi%2Ffeed). +Feed compliance verification in Dev and UAT has used the [W3C Feed Validation Service](https://validator.w3.org/feed/). ## Additional information From 43fc78d688a2db4c35b8db3a91e68d703517c34b Mon Sep 17 00:00:00 2001 From: jaykamal Date: Wed, 7 May 2025 21:05:08 +0530 Subject: [PATCH 05/58] PIP-759 Upgrade snomed-parent-bom from 3.10.0 to 3.11.0-SNAPSHOT --- pom.xml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pom.xml b/pom.xml index adf5c50cd..ed821638f 100644 --- a/pom.xml +++ b/pom.xml @@ -12,7 +12,7 @@ org.snomed snomed-parent-bom - 3.10.0-SNAPSHOT + 3.11.0-SNAPSHOT From c233763473bbbe5347094fb085add17c146e309c Mon Sep 17 00:00:00 2001 From: steve-a-snomed Date: Fri, 16 May 2025 13:08:38 +0100 Subject: [PATCH 06/58] MLDS-1128 Update User Management documentation for MLDS 6.x --- src/main/documentation/User-management.md | 33 +++++++++++++++++++++-- 1 file changed, 31 insertions(+), 2 deletions(-) diff --git a/src/main/documentation/User-management.md b/src/main/documentation/User-management.md index d6b13bc88..ff6025167 100644 --- a/src/main/documentation/User-management.md +++ b/src/main/documentation/User-management.md @@ -1,3 +1,32 @@ -# Users +# User Management -The system uses to repositories to manage users. The first is a collection of local tables rooted at "t_user", and the second is the IHTSDO web authentication system (development default is https://usermanagement2.ihtsdotools.org/security-web/). Affiliate users are created in the local database, while administrative users are normally sourced from the web authentication system. +MLDS versions up to and including 6.x have a two-tiered authentication architecture, with multiple hierarchical access roles. + +Anonymous visitors are presented with the MLDS Public views. + +Visitors may register directly within MLDS, with account details saved in the t_user table of the MLDS database, and their email address as their login identity. + +Registered user accounts may then also be approved as an Affiliate, giving them access to the MLDS Affiliate features. + +An alternative authentication route uses Confluence/Crowd accounts which are associated with an existing MLDS account by having the same email address, but which use a separate username for login credentials. + +If a registered user logs into MLDS using their registered email address, they will be authenticated through their MLDS database account and presented with the MLDS Affiliate views (which extend the Public views). + +If a registered user logs into MLDS using their Confluence/Crowd credentials, and their account matches an MLDS user email address, they will be authorized as an MLDS Member, NRC Staff or Administrator and presented with relevant views/features. + +Note that logging in with email/password (MLDS database account) credentials will only present the Affiliate / Public views, even if the site visitor has appropriate Confluence/Crowd credentials. To see the Members/Staff/Administrator features/views, visitors must authenticate using their Confluence/Crowd username/password credentials. + +In increasing features/functionality order, the authentication/roles patterns are: +- Unauthenticated + - **Public** (anonymous, not authenticated) +- Authenticated with MLDS credentials + - **Affiliate (Pending)** (authorized access, but pending account approval as an Affiliate by an Administrator) + - **Affiliate** (authorized access and account approved as an Affiliate by an Administrator) +- Authenticated with Confluence/Crowd credentials + - **Member** (authorized if the account is in the MLDS Members access group, and account email matches an MLDS DB user account) + - **Staff** (authorized if the account is in a relevant MLDS NRC Staff access group, and account email matches an MLDS DB user account) + - **Administrator** (authorized if the account is in a relevant MLDS Admin access group, and account email matches an MLDS DB user account) + +A notable aspect of Confluence/Crowd user authorization is that an MLDS user should only be placed in a single named MLDS access group. If placed in multiple groups, MLDS access may be problematic. + +MLDS user accounts can be inactivated by Administrators, after which login attempts using their email addresses will fail to authenticate. From 0fe34a89ea5df687614afa25fe1724ae60588895 Mon Sep 17 00:00:00 2001 From: Aravinth <109154990+AravinthAasaithambi@users.noreply.github.com> Date: Tue, 15 Oct 2024 15:35:42 +0530 Subject: [PATCH 07/58] MLDS-1137 Drop Table Event --- src/main/resources/db-changelog.xml | 25 ------------------------- 1 file changed, 25 deletions(-) diff --git a/src/main/resources/db-changelog.xml b/src/main/resources/db-changelog.xml index 33841c75f..62a002085 100644 --- a/src/main/resources/db-changelog.xml +++ b/src/main/resources/db-changelog.xml @@ -63,31 +63,6 @@ - - Create an event log so we can record TOS acceptance - - - - - - - - - - - - - - - - - - - - - - - 7:178964a1a1c900a14b2cb2fadf1da5d3 From 95073db43b824ea15bc869cfca3cbd301ee864e7 Mon Sep 17 00:00:00 2001 From: Aravinth <109154990+AravinthAasaithambi@users.noreply.github.com> Date: Tue, 15 Oct 2024 18:09:58 +0530 Subject: [PATCH 08/58] MLDS-1138 Drop table User Registration --- src/main/resources/db-changelog.xml | 12 +----------- 1 file changed, 1 insertion(+), 11 deletions(-) diff --git a/src/main/resources/db-changelog.xml b/src/main/resources/db-changelog.xml index 62a002085..8703f452a 100644 --- a/src/main/resources/db-changelog.xml +++ b/src/main/resources/db-changelog.xml @@ -3,17 +3,7 @@ xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://www.liquibase.org/xml/ns/dbchangelog http://www.liquibase.org/xml/ns/dbchangelog/dbchangelog-3.1.xsd"> - - - - - - - - - - - + 7:685a57551698e8e61c2667a2bbc55f89 From 5495009d345b8e1bb27944b96682559992ca56b6 Mon Sep 17 00:00:00 2001 From: Aravinth <109154990+AravinthAasaithambi@users.noreply.github.com> Date: Wed, 16 Oct 2024 13:00:41 +0530 Subject: [PATCH 09/58] MLDS-1139 Column Name consistent for member --- .../ihtsdo/mlds/domain/Member.java | 6 ++--- .../liquibase/changelog/db-changelog-002.xml | 23 +++++++++++++++++++ 2 files changed, 26 insertions(+), 3 deletions(-) diff --git a/src/main/java/ca/intelliware/ihtsdo/mlds/domain/Member.java b/src/main/java/ca/intelliware/ihtsdo/mlds/domain/Member.java index 348a2bf32..8a41acac1 100644 --- a/src/main/java/ca/intelliware/ihtsdo/mlds/domain/Member.java +++ b/src/main/java/ca/intelliware/ihtsdo/mlds/domain/Member.java @@ -25,13 +25,13 @@ public class Member extends BaseEntity { @Column(name="created_at") Instant createdAt = Instant.now(); - @Column(name="contactEmail") + @Column(name="contact_email") public String contactEmail; - @Column(name="memberOrgName") + @Column(name="member_org_name") public String memberOrgName; - @Column(name="memberOrgURL") + @Column(name="member_org_url") public String memberOrgURL; @JsonIgnore diff --git a/src/main/resources/config/liquibase/changelog/db-changelog-002.xml b/src/main/resources/config/liquibase/changelog/db-changelog-002.xml index b9583d8fe..8a858ce13 100644 --- a/src/main/resources/config/liquibase/changelog/db-changelog-002.xml +++ b/src/main/resources/config/liquibase/changelog/db-changelog-002.xml @@ -156,4 +156,27 @@ + + + + + + + Rename member table columns from camel case to snake case + + + + + + + From d829e818d8c86ab01f59d4d9091e9ebfdd2e21e0 Mon Sep 17 00:00:00 2001 From: Aravinth <109154990+AravinthAasaithambi@users.noreply.github.com> Date: Wed, 16 Oct 2024 15:46:00 +0530 Subject: [PATCH 10/58] MLDS-1140 Column Name consistent for Release Package --- .../ihtsdo/mlds/domain/ReleasePackage.java | 2 +- .../config/liquibase/changelog/db-changelog-002.xml | 12 ++++++++++++ 2 files changed, 13 insertions(+), 1 deletion(-) diff --git a/src/main/java/ca/intelliware/ihtsdo/mlds/domain/ReleasePackage.java b/src/main/java/ca/intelliware/ihtsdo/mlds/domain/ReleasePackage.java index 09e89cda8..e18da2cac 100644 --- a/src/main/java/ca/intelliware/ihtsdo/mlds/domain/ReleasePackage.java +++ b/src/main/java/ca/intelliware/ihtsdo/mlds/domain/ReleasePackage.java @@ -36,7 +36,7 @@ public class ReleasePackage extends BaseEntity { @Column(name="copyrights") String copyrights; - @Column(name="releasePackageURI") + @Column(name="release_package_uri") String releasePackageURI; @Column(name="updated_at") diff --git a/src/main/resources/config/liquibase/changelog/db-changelog-002.xml b/src/main/resources/config/liquibase/changelog/db-changelog-002.xml index 8a858ce13..a0fbd8dba 100644 --- a/src/main/resources/config/liquibase/changelog/db-changelog-002.xml +++ b/src/main/resources/config/liquibase/changelog/db-changelog-002.xml @@ -178,5 +178,17 @@ newColumnName="member_org_url" columnDataType="VARCHAR(255)" /> + + + + + Rename column releasePackageURI to release_package_uri if it exists + + + From 7bde7ed73fde0f60a0bf39fca99e676770fdccec Mon Sep 17 00:00:00 2001 From: Aravinth <109154990+AravinthAasaithambi@users.noreply.github.com> Date: Wed, 16 Oct 2024 17:41:23 +0530 Subject: [PATCH 11/58] MLDS-1141 Column Name consistent for Release Versions --- .../ihtsdo/mlds/domain/ReleaseVersion.java | 6 ++--- .../liquibase/changelog/db-changelog-002.xml | 24 +++++++++++++++++++ 2 files changed, 27 insertions(+), 3 deletions(-) diff --git a/src/main/java/ca/intelliware/ihtsdo/mlds/domain/ReleaseVersion.java b/src/main/java/ca/intelliware/ihtsdo/mlds/domain/ReleaseVersion.java index c98acbae5..674fb4070 100644 --- a/src/main/java/ca/intelliware/ihtsdo/mlds/domain/ReleaseVersion.java +++ b/src/main/java/ca/intelliware/ihtsdo/mlds/domain/ReleaseVersion.java @@ -50,13 +50,13 @@ public class ReleaseVersion extends BaseEntity { boolean online; - @Column(name="versionDependentDerivativeURI") + @Column(name="version_dependent_derivative_uri") String versionDependentDerivativeURI; - @Column(name="versionDependentURI") + @Column(name="version_dependent_uri") String versionDependentURI; - @Column(name="versionURI") + @Column(name="version_uri") String versionURI; @Column(name="published_at") diff --git a/src/main/resources/config/liquibase/changelog/db-changelog-002.xml b/src/main/resources/config/liquibase/changelog/db-changelog-002.xml index a0fbd8dba..3211145d0 100644 --- a/src/main/resources/config/liquibase/changelog/db-changelog-002.xml +++ b/src/main/resources/config/liquibase/changelog/db-changelog-002.xml @@ -190,5 +190,29 @@ newColumnName="release_package_uri" columnDataType="VARCHAR(255)" /> + + + + + + + Rename columns in release_version Table + + + + + From 7a8c56678df939c7fd7a46bec3015b5406d8c868 Mon Sep 17 00:00:00 2001 From: Aravinth <109154990+AravinthAasaithambi@users.noreply.github.com> Date: Wed, 16 Oct 2024 18:27:33 +0530 Subject: [PATCH 12/58] MLDS-1142 Column Name consistent for Email Domain Blocklist --- .../ihtsdo/mlds/registration/DomainBlacklist.java | 11 ++++++----- .../registration/DomainBlacklistRespository.java | 2 +- .../mlds/registration/DomainBlacklistService.java | 14 +++++++------- .../mlds/web/rest/DomainBlacklistResource.java | 4 ++-- .../liquibase/changelog/db-changelog-002.xml | 12 ++++++++++++ src/main/webapp/views/admin/blocklist.html | 8 ++++---- 6 files changed, 32 insertions(+), 19 deletions(-) diff --git a/src/main/java/ca/intelliware/ihtsdo/mlds/registration/DomainBlacklist.java b/src/main/java/ca/intelliware/ihtsdo/mlds/registration/DomainBlacklist.java index 0cc7cf571..064e110f4 100644 --- a/src/main/java/ca/intelliware/ihtsdo/mlds/registration/DomainBlacklist.java +++ b/src/main/java/ca/intelliware/ihtsdo/mlds/registration/DomainBlacklist.java @@ -13,14 +13,15 @@ public class DomainBlacklist extends BaseEntity { @Column(name="domain_id") private Long domainId; - String domainname; + @Column(name="domain_name") + String domainName; - public String getDomainname() { - return domainname; + public String getDomainName() { + return domainName; } - public void setDomainname(String domainname) { - this.domainname = domainname; + public void setDomainName(String domainName) { + this.domainName = domainName; } @Override diff --git a/src/main/java/ca/intelliware/ihtsdo/mlds/registration/DomainBlacklistRespository.java b/src/main/java/ca/intelliware/ihtsdo/mlds/registration/DomainBlacklistRespository.java index d6420c1ec..6d889813c 100644 --- a/src/main/java/ca/intelliware/ihtsdo/mlds/registration/DomainBlacklistRespository.java +++ b/src/main/java/ca/intelliware/ihtsdo/mlds/registration/DomainBlacklistRespository.java @@ -9,6 +9,6 @@ public interface DomainBlacklistRespository extends JpaRepository { // extends PagingAndSortingRepository { - List findByDomainname(String domainName); + List findByDomainName(String domainName); } diff --git a/src/main/java/ca/intelliware/ihtsdo/mlds/registration/DomainBlacklistService.java b/src/main/java/ca/intelliware/ihtsdo/mlds/registration/DomainBlacklistService.java index 540c58ffe..82611965e 100644 --- a/src/main/java/ca/intelliware/ihtsdo/mlds/registration/DomainBlacklistService.java +++ b/src/main/java/ca/intelliware/ihtsdo/mlds/registration/DomainBlacklistService.java @@ -11,25 +11,25 @@ public class DomainBlacklistService { @Resource DomainBlacklistRespository domainBlacklistRespository; - + public boolean isDomainBlacklisted(String email) { String requestEmailDomain = extractDomain(email); - - List result = domainBlacklistRespository.findByDomainname(requestEmailDomain); - + + List result = domainBlacklistRespository.findByDomainName(requestEmailDomain); + if (result.size() > 0) { return true; } - + return false; } private String extractDomain(String email) { String[] result; String delimiter = "@"; - + result = email.split(delimiter); - + return result[1]; } } diff --git a/src/main/java/ca/intelliware/ihtsdo/mlds/web/rest/DomainBlacklistResource.java b/src/main/java/ca/intelliware/ihtsdo/mlds/web/rest/DomainBlacklistResource.java index bddcaa67b..c49725bda 100644 --- a/src/main/java/ca/intelliware/ihtsdo/mlds/web/rest/DomainBlacklistResource.java +++ b/src/main/java/ca/intelliware/ihtsdo/mlds/web/rest/DomainBlacklistResource.java @@ -41,7 +41,7 @@ public Object addDomainToBlacklist(@RequestParam String domain) { DomainBlacklist newDomain = new DomainBlacklist(); - newDomain.setDomainname(domain); + newDomain.setDomainName(domain); domainBlacklistRespository.save(newDomain); @@ -52,7 +52,7 @@ public Object addDomainToBlacklist(@RequestParam String domain) { @RequestMapping(value="api/domain-blacklist/remove", method=RequestMethod.POST) @Timed public Object removeDomainFromBlacklist(@RequestParam String domain) { - domainBlacklistRespository.deleteAll(domainBlacklistRespository.findByDomainname(domain)); + domainBlacklistRespository.deleteAll(domainBlacklistRespository.findByDomainName(domain)); return new ResponseEntity<>(HttpStatus.OK); } } diff --git a/src/main/resources/config/liquibase/changelog/db-changelog-002.xml b/src/main/resources/config/liquibase/changelog/db-changelog-002.xml index 3211145d0..166bedf80 100644 --- a/src/main/resources/config/liquibase/changelog/db-changelog-002.xml +++ b/src/main/resources/config/liquibase/changelog/db-changelog-002.xml @@ -214,5 +214,17 @@ newColumnName="version_uri" columnDataType="VARCHAR(255)" /> + + + + + + Rename column domainname to domain_name in email_domain_blacklist table + + diff --git a/src/main/webapp/views/admin/blocklist.html b/src/main/webapp/views/admin/blocklist.html index b2f990198..f2f489400 100644 --- a/src/main/webapp/views/admin/blocklist.html +++ b/src/main/webapp/views/admin/blocklist.html @@ -4,7 +4,7 @@ -
+
@@ -22,11 +22,11 @@

Blocklist Domains

- {{domain.domainname}} - + {{domain.domainName}} +
- \ No newline at end of file + From 5735f398764f20db416e281636296772ee1f9287 Mon Sep 17 00:00:00 2001 From: Aravinth <109154990+AravinthAasaithambi@users.noreply.github.com> Date: Thu, 17 Oct 2024 09:58:01 +0530 Subject: [PATCH 13/58] MLDS-1149 Release file table file size column changed to bigint Miscellaneous other issues Release file table - file size column need to be changed to bigint --- .../intelliware/ihtsdo/mlds/domain/ReleaseFile.java | 6 +++--- .../config/liquibase/changelog/db-changelog-002.xml | 13 +++++++++++++ 2 files changed, 16 insertions(+), 3 deletions(-) diff --git a/src/main/java/ca/intelliware/ihtsdo/mlds/domain/ReleaseFile.java b/src/main/java/ca/intelliware/ihtsdo/mlds/domain/ReleaseFile.java index 13e8f75bb..0e32e8ed0 100644 --- a/src/main/java/ca/intelliware/ihtsdo/mlds/domain/ReleaseFile.java +++ b/src/main/java/ca/intelliware/ihtsdo/mlds/domain/ReleaseFile.java @@ -42,7 +42,7 @@ public class ReleaseFile extends BaseEntity { String md5Hash; @Column(name="file_size") - String fileSize; + Long fileSize; public ReleaseFile() { } @@ -102,11 +102,11 @@ public void setMd5Hash(String md5Hash) { this.md5Hash = md5Hash; } - public String getFileSize() { + public Long getFileSize() { return fileSize; } - public void setFileSize(String fileSize) { + public void setFileSize(Long fileSize) { this.fileSize = fileSize; } diff --git a/src/main/resources/config/liquibase/changelog/db-changelog-002.xml b/src/main/resources/config/liquibase/changelog/db-changelog-002.xml index 166bedf80..9ccf319a9 100644 --- a/src/main/resources/config/liquibase/changelog/db-changelog-002.xml +++ b/src/main/resources/config/liquibase/changelog/db-changelog-002.xml @@ -226,5 +226,18 @@ newColumnName="domain_name" columnDataType="VARCHAR(255)"/> + + + + + + Set empty file_size values to NULL before changing data type + + + file_size = '' + + Change file_size column to BIGINT in release_file table + + From 0c8b292e9614beab7e10d0e5fcbeaa1d050d0646 Mon Sep 17 00:00:00 2001 From: Aravinth <109154990+AravinthAasaithambi@users.noreply.github.com> Date: Thu, 17 Oct 2024 11:18:11 +0530 Subject: [PATCH 14/58] MLDS-1150 Affiliate_details tables varchar(45), varchar(100) should be varchar (255) --- .../liquibase/changelog/db-changelog-002.xml | 20 +++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/src/main/resources/config/liquibase/changelog/db-changelog-002.xml b/src/main/resources/config/liquibase/changelog/db-changelog-002.xml index 9ccf319a9..c6f00675b 100644 --- a/src/main/resources/config/liquibase/changelog/db-changelog-002.xml +++ b/src/main/resources/config/liquibase/changelog/db-changelog-002.xml @@ -239,5 +239,25 @@ Change file_size column to BIGINT in release_file table + + + + + + + + + + + + Change column data types to VARCHAR(255) in affiliate_details table + + + + + + + + From 2a962e320e6e8e0474c10d37725c9c3abc98b2d6 Mon Sep 17 00:00:00 2001 From: Aravinth <109154990+AravinthAasaithambi@users.noreply.github.com> Date: Thu, 17 Oct 2024 11:56:14 +0530 Subject: [PATCH 15/58] MLDS-1151 T_user tables varchar(45), varchar(100) should be varchar (255) --- .../liquibase/changelog/db-changelog-002.xml | 23 +++++++++++++++++++ 1 file changed, 23 insertions(+) diff --git a/src/main/resources/config/liquibase/changelog/db-changelog-002.xml b/src/main/resources/config/liquibase/changelog/db-changelog-002.xml index c6f00675b..136ac993d 100644 --- a/src/main/resources/config/liquibase/changelog/db-changelog-002.xml +++ b/src/main/resources/config/liquibase/changelog/db-changelog-002.xml @@ -260,4 +260,27 @@ + + + + + + + + + + + + + Change column data types to VARCHAR(255) in affiliate_details table + + + + + + + + + + From 4fe8c1c4ced4e09d0be287214c3865d2daf81f13 Mon Sep 17 00:00:00 2001 From: jaykamal <130353187+jaykamal@users.noreply.github.com> Date: Thu, 17 Oct 2024 18:36:19 +0530 Subject: [PATCH 16/58] MLDS-1145 Table name consistent for t_authority --- .../ca/intelliware/ihtsdo/mlds/domain/Authority.java | 4 ++-- .../config/liquibase/changelog/db-changelog-002.xml | 10 +++++++++- 2 files changed, 11 insertions(+), 3 deletions(-) diff --git a/src/main/java/ca/intelliware/ihtsdo/mlds/domain/Authority.java b/src/main/java/ca/intelliware/ihtsdo/mlds/domain/Authority.java index 696f4f703..3a87f9bf9 100644 --- a/src/main/java/ca/intelliware/ihtsdo/mlds/domain/Authority.java +++ b/src/main/java/ca/intelliware/ihtsdo/mlds/domain/Authority.java @@ -15,11 +15,11 @@ * An authority (a security role) used by Spring Security. */ @Entity -@Table(name = "T_AUTHORITY") +@Table(name = "authority") @Cache(usage = CacheConcurrencyStrategy.NONSTRICT_READ_WRITE) public class Authority implements Serializable { private static final long serialVersionUID = 1L; - + @NotNull @Size(min = 0, max = 50) @Id diff --git a/src/main/resources/config/liquibase/changelog/db-changelog-002.xml b/src/main/resources/config/liquibase/changelog/db-changelog-002.xml index 136ac993d..e97506f31 100644 --- a/src/main/resources/config/liquibase/changelog/db-changelog-002.xml +++ b/src/main/resources/config/liquibase/changelog/db-changelog-002.xml @@ -282,5 +282,13 @@ - + + + + + + Rename table t_authority to authority if it exists + + + From 5702f3079f8e12e164ce85da5199134d5b9494e5 Mon Sep 17 00:00:00 2001 From: jaykamal <130353187+jaykamal@users.noreply.github.com> Date: Thu, 17 Oct 2024 19:18:36 +0530 Subject: [PATCH 17/58] MLDS-1144 Table name consistent for t_user_authority --- src/main/java/ca/intelliware/ihtsdo/mlds/domain/User.java | 2 +- .../config/liquibase/changelog/db-changelog-002.xml | 8 ++++++++ 2 files changed, 9 insertions(+), 1 deletion(-) diff --git a/src/main/java/ca/intelliware/ihtsdo/mlds/domain/User.java b/src/main/java/ca/intelliware/ihtsdo/mlds/domain/User.java index 134f72180..10cdbf408 100644 --- a/src/main/java/ca/intelliware/ihtsdo/mlds/domain/User.java +++ b/src/main/java/ca/intelliware/ihtsdo/mlds/domain/User.java @@ -76,7 +76,7 @@ public class User extends AbstractAuditingEntity implements Serializable { @JsonIgnore @ManyToMany @JoinTable( - name = "T_USER_AUTHORITY", + name = "user_authority", joinColumns = {@JoinColumn(name = "user_id", referencedColumnName = "user_id")}, inverseJoinColumns = {@JoinColumn(name = "name", referencedColumnName = "name")}) @Cache(usage = CacheConcurrencyStrategy.NONSTRICT_READ_WRITE) diff --git a/src/main/resources/config/liquibase/changelog/db-changelog-002.xml b/src/main/resources/config/liquibase/changelog/db-changelog-002.xml index e97506f31..3b8c047bc 100644 --- a/src/main/resources/config/liquibase/changelog/db-changelog-002.xml +++ b/src/main/resources/config/liquibase/changelog/db-changelog-002.xml @@ -291,4 +291,12 @@ + + + + + Rename table t_user_authority to user_authority if it exists + + + From 30bc60b8cb76236c7a189079235a0c9c99e4070e Mon Sep 17 00:00:00 2001 From: jaykamal <130353187+jaykamal@users.noreply.github.com> Date: Thu, 17 Oct 2024 20:55:57 +0530 Subject: [PATCH 18/58] MLDS-1146 Table name consistent for t_persistent_audit_event --- .../ihtsdo/mlds/domain/PersistentAuditEvent.java | 2 +- .../config/liquibase/changelog/db-changelog-002.xml | 8 ++++++++ 2 files changed, 9 insertions(+), 1 deletion(-) diff --git a/src/main/java/ca/intelliware/ihtsdo/mlds/domain/PersistentAuditEvent.java b/src/main/java/ca/intelliware/ihtsdo/mlds/domain/PersistentAuditEvent.java index 605855ec3..ad1889b90 100644 --- a/src/main/java/ca/intelliware/ihtsdo/mlds/domain/PersistentAuditEvent.java +++ b/src/main/java/ca/intelliware/ihtsdo/mlds/domain/PersistentAuditEvent.java @@ -16,7 +16,7 @@ */ @Entity -@Table(name = "T_PERSISTENT_AUDIT_EVENT") +@Table(name = "persistent_audit_event") @Cache(usage = CacheConcurrencyStrategy.NONSTRICT_READ_WRITE) public class PersistentAuditEvent extends BaseEntity { diff --git a/src/main/resources/config/liquibase/changelog/db-changelog-002.xml b/src/main/resources/config/liquibase/changelog/db-changelog-002.xml index 3b8c047bc..681aed456 100644 --- a/src/main/resources/config/liquibase/changelog/db-changelog-002.xml +++ b/src/main/resources/config/liquibase/changelog/db-changelog-002.xml @@ -299,4 +299,12 @@ + + + + + Rename table t_persistent_audit_event to persistent_audit_event if it exists + + + From f6139acfd0237387eadc4be72e6cf24e6443e827 Mon Sep 17 00:00:00 2001 From: jaykamal <130353187+jaykamal@users.noreply.github.com> Date: Fri, 18 Oct 2024 12:20:35 +0530 Subject: [PATCH 19/58] MLDS-1147 Table name consistent for t_persistent_audit_event_data --- .../ihtsdo/mlds/domain/PersistentAuditEvent.java | 2 +- .../config/liquibase/changelog/db-changelog-002.xml | 7 +++++++ 2 files changed, 8 insertions(+), 1 deletion(-) diff --git a/src/main/java/ca/intelliware/ihtsdo/mlds/domain/PersistentAuditEvent.java b/src/main/java/ca/intelliware/ihtsdo/mlds/domain/PersistentAuditEvent.java index ad1889b90..cd1503bed 100644 --- a/src/main/java/ca/intelliware/ihtsdo/mlds/domain/PersistentAuditEvent.java +++ b/src/main/java/ca/intelliware/ihtsdo/mlds/domain/PersistentAuditEvent.java @@ -38,7 +38,7 @@ public class PersistentAuditEvent extends BaseEntity { @ElementCollection @MapKeyColumn(name="name") @Column(name="value") - @CollectionTable(name="T_PERSISTENT_AUDIT_EVENT_DATA", joinColumns=@JoinColumn(name="event_id")) + @CollectionTable(name="persistent_audit_event_data", joinColumns=@JoinColumn(name="event_id")) private Map data = new HashMap<>(); @Column(name="affiliate_id") diff --git a/src/main/resources/config/liquibase/changelog/db-changelog-002.xml b/src/main/resources/config/liquibase/changelog/db-changelog-002.xml index 681aed456..5415a6866 100644 --- a/src/main/resources/config/liquibase/changelog/db-changelog-002.xml +++ b/src/main/resources/config/liquibase/changelog/db-changelog-002.xml @@ -307,4 +307,11 @@ + + + + + Rename table t_persistent_audit_event_data to persistent_audit_event_data if it exists + + From 80c5cb8963325f7c4578dacfefe9f76b9667c0dd Mon Sep 17 00:00:00 2001 From: jaykamal <130353187+jaykamal@users.noreply.github.com> Date: Fri, 18 Oct 2024 13:55:52 +0530 Subject: [PATCH 20/58] MLDS-1148 Table name consistent for t_persistent_token --- .../intelliware/ihtsdo/mlds/domain/PersistentToken.java | 2 +- .../config/liquibase/changelog/db-changelog-002.xml | 8 ++++++++ 2 files changed, 9 insertions(+), 1 deletion(-) diff --git a/src/main/java/ca/intelliware/ihtsdo/mlds/domain/PersistentToken.java b/src/main/java/ca/intelliware/ihtsdo/mlds/domain/PersistentToken.java index 7dc9215c2..98755cdbf 100644 --- a/src/main/java/ca/intelliware/ihtsdo/mlds/domain/PersistentToken.java +++ b/src/main/java/ca/intelliware/ihtsdo/mlds/domain/PersistentToken.java @@ -19,7 +19,7 @@ */ @Entity -@Table(name = "T_PERSISTENT_TOKEN") +@Table(name = "persistent_token") @Cache(usage = CacheConcurrencyStrategy.NONSTRICT_READ_WRITE) public class PersistentToken implements Serializable { private static final long serialVersionUID = 1L; diff --git a/src/main/resources/config/liquibase/changelog/db-changelog-002.xml b/src/main/resources/config/liquibase/changelog/db-changelog-002.xml index 5415a6866..d2db73ed4 100644 --- a/src/main/resources/config/liquibase/changelog/db-changelog-002.xml +++ b/src/main/resources/config/liquibase/changelog/db-changelog-002.xml @@ -314,4 +314,12 @@ Rename table t_persistent_audit_event_data to persistent_audit_event_data if it exists + + + + + + Rename table t_persistent_token to persistent_token if it exists + + From ff1c3a0f943d83697f43f28d977612d5723c0f7f Mon Sep 17 00:00:00 2001 From: jaykamal <130353187+jaykamal@users.noreply.github.com> Date: Fri, 18 Oct 2024 16:47:01 +0530 Subject: [PATCH 21/58] MLDS-1143 Table name consistent for t_user --- src/main/java/ca/intelliware/ihtsdo/mlds/domain/User.java | 4 ++-- .../config/liquibase/changelog/db-changelog-002.xml | 8 ++++++++ .../ihtsdo/mlds/service/AffiliateDeleterTest.java | 4 ++-- 3 files changed, 12 insertions(+), 4 deletions(-) diff --git a/src/main/java/ca/intelliware/ihtsdo/mlds/domain/User.java b/src/main/java/ca/intelliware/ihtsdo/mlds/domain/User.java index 10cdbf408..89815ebae 100644 --- a/src/main/java/ca/intelliware/ihtsdo/mlds/domain/User.java +++ b/src/main/java/ca/intelliware/ihtsdo/mlds/domain/User.java @@ -18,10 +18,10 @@ * A user. */ @Entity -@Table(name = "T_USER") +@Table(name = "user") @Cache(usage = CacheConcurrencyStrategy.NONSTRICT_READ_WRITE) @Where(clause = "inactive_at IS NULL") -@SQLDelete(sql="UPDATE T_USER SET inactive_at = now() WHERE user_id = ?") +@SQLDelete(sql="UPDATE user SET inactive_at = now() WHERE user_id = ?") public class User extends AbstractAuditingEntity implements Serializable { private static final long serialVersionUID = 1L; diff --git a/src/main/resources/config/liquibase/changelog/db-changelog-002.xml b/src/main/resources/config/liquibase/changelog/db-changelog-002.xml index d2db73ed4..c2c3a2cf4 100644 --- a/src/main/resources/config/liquibase/changelog/db-changelog-002.xml +++ b/src/main/resources/config/liquibase/changelog/db-changelog-002.xml @@ -322,4 +322,12 @@ Rename table t_persistent_token to persistent_token if it exists + + + + + + Rename table t_user to user if it exists + + diff --git a/src/test/java/ca/intelliware/ihtsdo/mlds/service/AffiliateDeleterTest.java b/src/test/java/ca/intelliware/ihtsdo/mlds/service/AffiliateDeleterTest.java index 78dfbcc8a..4bfe4513a 100644 --- a/src/test/java/ca/intelliware/ihtsdo/mlds/service/AffiliateDeleterTest.java +++ b/src/test/java/ca/intelliware/ihtsdo/mlds/service/AffiliateDeleterTest.java @@ -144,7 +144,7 @@ public void deleteAffiliateWithAllData() throws Exception { assertEquals(1,matchingNativeRecords("SELECT affiliate_id FROM affiliate WHERE affiliate_id="+affiliate.getAffiliateId())); assertEquals(1,matchingNativeRecords("SELECT application_id FROM application WHERE application_id="+primaryApplication.getApplicationId())); assertEquals(1,matchingNativeRecords("SELECT application_id FROM application WHERE application_id="+extensionApplication.getApplicationId())); - assertEquals(1,matchingNativeRecords("SELECT user_id FROM T_USER WHERE user_id="+user.getUserId())); + assertEquals(1,matchingNativeRecords("SELECT user_id FROM user WHERE user_id="+user.getUserId())); assertEquals(1,matchingNativeRecords("SELECT commercial_usage_id FROM commercial_usage WHERE commercial_usage_id="+commercialUsage.getCommercialUsageId())); assertEquals(1,matchingNativeRecords("SELECT affiliate_details_id FROM affiliate_details WHERE affiliate_details_id="+affiliateDetails.getAffiliateDetailsId())); assertEquals(1,matchingNativeRecords("SELECT affiliate_details_id FROM affiliate_details WHERE affiliate_details_id="+affiliateExtensionDetails.getAffiliateDetailsId())); @@ -219,7 +219,7 @@ public void deleteAffiliateWithSharedAffiliateDetails() throws Exception { assertEquals(1,matchingNativeRecords("SELECT affiliate_id FROM affiliate WHERE affiliate_id="+affiliate.getAffiliateId())); assertEquals(1,matchingNativeRecords("SELECT application_id FROM application WHERE application_id="+primaryApplication.getApplicationId())); assertEquals(1,matchingNativeRecords("SELECT application_id FROM application WHERE application_id="+extensionApplication.getApplicationId())); - assertEquals(1,matchingNativeRecords("SELECT user_id FROM T_USER WHERE user_id="+user.getUserId())); + assertEquals(1,matchingNativeRecords("SELECT user_id FROM user WHERE user_id="+user.getUserId())); assertEquals(1,matchingNativeRecords("SELECT commercial_usage_id FROM commercial_usage WHERE commercial_usage_id="+commercialUsage.getCommercialUsageId())); assertEquals(1,matchingNativeRecords("SELECT affiliate_details_id FROM affiliate_details WHERE affiliate_details_id="+affiliateSharedDetails.getAffiliateDetailsId())); assertEquals(1,matchingNativeRecords("SELECT affiliate_details_id FROM affiliate_details WHERE affiliate_details_id="+affiliateExtensionDetails.getAffiliateDetailsId())); From 0f206186fd51b9025612acc7d55cde46a5984f72 Mon Sep 17 00:00:00 2001 From: jaykamal <130353187+jaykamal@users.noreply.github.com> Date: Fri, 18 Oct 2024 18:57:41 +0530 Subject: [PATCH 22/58] MLDS-1147 Renamed Keys - persistent_audit_event --- .../liquibase/changelog/db-changelog-002.xml | 46 +++++++++++++++++++ 1 file changed, 46 insertions(+) diff --git a/src/main/resources/config/liquibase/changelog/db-changelog-002.xml b/src/main/resources/config/liquibase/changelog/db-changelog-002.xml index c2c3a2cf4..1aabbc411 100644 --- a/src/main/resources/config/liquibase/changelog/db-changelog-002.xml +++ b/src/main/resources/config/liquibase/changelog/db-changelog-002.xml @@ -330,4 +330,50 @@ Rename table t_user to user if it exists + + + Rename keys for persistent_audit_event + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + From 3004f5cf9b8047498ce2ef18d37652a5302c9697 Mon Sep 17 00:00:00 2001 From: jaykamal <130353187+jaykamal@users.noreply.github.com> Date: Fri, 18 Oct 2024 19:26:14 +0530 Subject: [PATCH 23/58] MLDS-1143 Renamed Keys - t_user in release_package and release_version tables. --- .../liquibase/changelog/db-changelog-002.xml | 21 +++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/src/main/resources/config/liquibase/changelog/db-changelog-002.xml b/src/main/resources/config/liquibase/changelog/db-changelog-002.xml index 1aabbc411..fdff1aaf4 100644 --- a/src/main/resources/config/liquibase/changelog/db-changelog-002.xml +++ b/src/main/resources/config/liquibase/changelog/db-changelog-002.xml @@ -376,4 +376,25 @@ + + Rename Indexes name for release_package + + + + + + + + + + + Rename Indexes name for release_version + + + + + + + + From b0e3b23231d84f07039cbdd27eb590379eae676a Mon Sep 17 00:00:00 2001 From: jaykamal <130353187+jaykamal@users.noreply.github.com> Date: Mon, 21 Oct 2024 19:50:05 +0530 Subject: [PATCH 24/58] MLDS-1153 Git Repository : Document Update - DDL Update --- src/main/ad-hoc/minimal-dev-db.sql | 20 +- src/main/documentation/Data-management.md | 2 +- src/main/documentation/schema/er.pdf | Bin 83335 -> 54506 bytes src/main/documentation/schema/er.png | Bin 41934 -> 48783 bytes src/main/documentation/schema/er.svg | 42 ++-- src/main/documentation/schema/mlds.ddl | 272 ++++++++++------------ src/main/documentation/schema/readme.txt | 2 +- 7 files changed, 150 insertions(+), 188 deletions(-) diff --git a/src/main/ad-hoc/minimal-dev-db.sql b/src/main/ad-hoc/minimal-dev-db.sql index 9ab7d9dca..d2787d227 100644 --- a/src/main/ad-hoc/minimal-dev-db.sql +++ b/src/main/ad-hoc/minimal-dev-db.sql @@ -1,15 +1,15 @@ use mlds; --- Inserting data into t_authority table -INSERT INTO t_authority (name) VALUES ('ROLE_ADMIN'); -INSERT INTO t_authority (name) VALUES ('ROLE_USER'); -INSERT INTO t_authority (name) VALUES ('ROLE_STAFF'); -INSERT INTO t_authority (name) VALUES ('ROLE_STAFF_SE'); -INSERT INTO t_authority (name) VALUES ('ROLE_STAFF_IHTSDO'); +-- Inserting data into authority table +INSERT INTO authority (name) VALUES ('ROLE_ADMIN'); +INSERT INTO authority (name) VALUES ('ROLE_USER'); +INSERT INTO authority (name) VALUES ('ROLE_STAFF'); +INSERT INTO authority (name) VALUES ('ROLE_STAFF_SE'); +INSERT INTO authority (name) VALUES ('ROLE_STAFF_IHTSDO'); --- Inserting data into t_user table -INSERT INTO t_user (login, user_id, password, first_name, last_name, email, activated, lang_key, activation_key, created_by, created_date, last_modified_by, last_modified_date) +-- Inserting data into user table +INSERT INTO user (login, user_id, password, first_name, last_name, email, activated, lang_key, activation_key, created_by, created_date, last_modified_by, last_modified_date) VALUES ('system', 1, '572d3b834f32347527d749bc1a41042c920682fc430febd380b4b6a0134f314fd381ce11c9a05abe', NULL, 'System', NULL, true, 'en', NULL, 'system', '2014-06-24 12:29:08', NULL, NULL), ('anonymousUser', 2, '4f54479f8290dfd503b72a654faf5d70593eab443993d87a79e14e5f7cda3eb7988423aa99090c9b', 'Anonymous', 'User', NULL, true, 'en', NULL, 'system', '2014-06-24 12:29:08', NULL, NULL), @@ -19,8 +19,8 @@ VALUES ('sweden', 6, 'e6b4dc6acaa889021ab202e417245c0880c66b254ad5758a298d310d15d38f1385bc0d82fc6f5e62', NULL, NULL, NULL, true, NULL, NULL, 'system', '2014-07-25 10:27:30', NULL, NULL); --- Inserting data into t_user_authority table -INSERT INTO t_user_authority (user_id, name) +-- Inserting data into user_authority table +INSERT INTO user_authority (user_id, name) VALUES (1, 'ROLE_ADMIN'), (1, 'ROLE_USER'), diff --git a/src/main/documentation/Data-management.md b/src/main/documentation/Data-management.md index a2573ee92..db3c72481 100644 --- a/src/main/documentation/Data-management.md +++ b/src/main/documentation/Data-management.md @@ -19,7 +19,7 @@ The system has a few important roots: - Table "member" defines IHTSDO members using the ISO 2 character country code as a key. The table also include an "IHTSDO" pseudo-member to own the international releases, and administer non-member country affiliates - Table "release_package" is the root for the various release versions and files. - Table "affiliate" is the root object for all affiliate data -- "t_user"" and the other t_* tables come from a base framework and implement users, permissions, and event logging. The t_user table has an "active" flag for disabling locally defined users. +- user, authority, user_authority, persistent_audit_event, persistent_audit_event_data and persistent_token tables come from a base framework and implement users, permissions, and event logging. The user table has an "active" flag for disabling locally defined users. ## Data Administration - New Member Country Most administrative activities can be accomplished via the user interface. One exception is the (infrequent) onboarding of a new member country. To do this: diff --git a/src/main/documentation/schema/er.pdf b/src/main/documentation/schema/er.pdf index 3f5cff24f20b6ac35b5e8a98b9f94cdea1b877c2..f7db9c1cbd292c5281e56aefcc4d647ed417a824 100644 GIT binary patch literal 54506 zcmd?S1y~l_*EUWgsURi!fV6anba%Iabcb|<0@6q~NF&{y3P?#yH_~0w$e$1o`r~}x zx!&XX-v4`EoeO663^ViGYwo@7wbp)S6G-y%lT%aBLlcy)=bb>)0jL0GnugF%p8_aF zbxifF^a1o2P14W+0DzL;z}QO1;^MclhLsMlj+U9W4m3MEw564Wj)n=eUEF}`NXWC6 z7tZzSy>Fr*OAOVq$zyR<5NyD7UY`L-&_25X^t;S~h1!lC<1LFe`Z^>h*ua1x@X%EU ztXJiA<`a~dT^6@C(^>>bb!^X>oEe>KPE0k;c3q~7n(y0Bsf^a1IUUqj=M&X`sV`Sw zbJ>j^bKX8fL*IM)x+dXx((!5dhr0BlU5fSU#&i3(iwLIbmP=4(Y)7x`x0S_$`Zrz} zq}IGiTF16w-;fzA>u2|}22y(%qd;3tSE#BbT|Hk{J#|!cz?PD-=6+zD{@`;CTV7`s zCB?A0jVK9yjR0uPDq7D0u7N@(0X=Z0W8Uk0p^b=vn3^)BaH(A z`;C3#hu?J3$wk#(5m9Y9k%@S@aV2(P*;U^!s|ZWsd{`UfHly9qm{k!84ba$Ts9Ar! zUqV-Skk_1xRfod%EQNF6S;80p}WKZX7`XDFmLOt=F%+P6&3o;c7LM)0;)pEP|Za;r<=Xp`Fvmt2~{tOzd^kE zaqHBZMnIXlFaKU6?MP%AX#N9-Y0svzv`p}wfI(`gb2kpBXJNi@kri$o$_F0lQdQPK zgAR{y${IN>N8=_Ac_4vpb4XuNwGJe23rZve zi^U5$JIDg|G|{7z7D4-2d3RG{ycvH}Ev3l@pijU|8#koI-#N*(hKkW0XvjCP%GPOW z8b0`J6t8#r1uDkAvA~k~UU0s5hfgxenle1ot5Ae!Jy`GOi30#Z!iS+f;t%#b04z;R zm6crqR(h=h@x3ywQ%5-r6D-c|m=5|JoH{$j z$`Py&Gu97pAsRBBPK{$%&9A*`zQ>yu)dF7!b)(N;n!SnnMuJlbdsOpXS(7tCljgB zl7sq^D7w!PGpdXSVTMJJQV@plh)|*9>vbBWGh>)?(uI8KuOz1vU{+auy@20xCTjUm z0aDH^iNv3FhN&NOvCRn5Ko8}0B6AM+r#AgSJ?>IVvX`yYJnAljD&TWy{Hdv!4 za%(z8C7fE!7en}EPsH~4UhNal^{&R)VkEg6Jti9bM^n#*v6WIa-@gIUxW$o{$?2DFZ z(aA$Q%RSg1kSqn`vRWXSlVcXip>Tk*!Gn#N>WYu&3&9*(W0;|Q}ceYZlK%_lN+EKlP`#l1YB zT3soaR@B5i%Ggkn_362uI09+{6<|>wY!-{pc0KLWpigJ}0+fPR3X3a+XkbTP4<#k> zP{tfuVG2V`OCJxLhGC}-%rS)_NutSDH(S%GPV|8=E-Ju(%DsOW`nWSuDU8=jFo0?a z(K-TGgVGVq1P>qQ#WZgmhnEfn-PnwW+Y5EN1)+{^Etb~!G$?qA6P*o!h0aU%13pCq zTR%FCF%cdo15T6kN4_MTYSH2jy8J16B?qB_xMV{DIa;IpXqzi- zyx#K6j1eYZjX}0kzbqM$Ni9{EM5=Z^jI&yT>XZ_57=h;3(x8n0xMJXF^SpvgsnAe# zZJV5&^x*^#a}NHRDjj8GGF>60=<`unsE{=tqaE=at$~0xd$9jY!q8wC3%Rgx*6|p z6sQk2s#=r0Com>Q>(V@=HIfnO?(qVPDjOvRi5KZNm3zlzvOR{oM}F9e z6;KkE=b7&fR%Z|;*qxC5L1QM1c{is;v}C1v(DY3drO3mDfyW zUw#dSm)N;a)=Mn4hOoR2_9R|jzg)A0pG~L4M2^sM2v?0pH58?|%iz9+YTwh|1mIDx z`j(D+rSVE{LQ@A9yx!S-sI3y*DJ)ORcuAiHW%MyntV`k(Q#K0zvg6lJyqcR!>0|Ko z+^fcZ&P)NhCS?4;dWr;(%)d@j(!xS>cH@OqZB05DYY;$MGh0FksnffW6ALareA8JP zfI{pRUX=B6+;6!+4AIQQ)*$i#l8qUy=(9$Gy-_229E69SF?BieH#2*(639?Aq}fq3 zEDF(gmd3G>EwOmIKveG+=BxHy(%~ox%gaDb=1<(=F{TaB;H$d#yNrpIpJA`AP1;c$ z`87oDzAk_lG;?@`t^Op!Eud%h%^9b*U9BeTa91!k`f=GYPsW~p6&A%VswTA-gX)g70=6@6ed(mR%J-rDCI zjLx6R4x&TGBp@;!+Oo`;uU-}DG(nV~!BKa(wvl$>P^W5Pb(~!6QFr|yU6x|u4rjWC zP~Uzra@by0HKtUX@JzKCl;}r$P2|gVayIdnJG;QU#cC*lw}v~9g*{TT3M}y0&g%qvv{JZdpi6XWhCsNh zr%qib&c+z~TSNeDCdqtm)-OfZWnJl(`+Q%#^pe9RFaPbz?m+xO$``L{D2~yJ_1!76*DB{vuz({T3=)jj8t2v@u1|=3 z+Qvcu%IZ9Na9-8eLbTgfOAB@%k)BGe8T*~}0mc?9F9b}Meg(HPbjf;x&s6!0F`5On z-F9*d*5TXXxh2J3l@h8L!()aRFa7dOS0$a3u~_D0y0Pcfu2GY! z2<^R-TaAMG#)61oi!aPt*Fnd%+|WLX$rZp6!xGwfJI)Z|swGMgRT*)@79vM^61{`G zM;6fsz>V8`6{mam=0z}gq=oZ67wahj3 zIVQmcxYr0RrY8B$XE)1fn5kg+r5}^)8O-C_RN^coz@?;c4L#z8JwAu#7mz7DiSxmw zB84`Nw(~aKMzL~l9hPy{xX08l00QjfYp!k@R&6H#&AQ&k*oxjt3d zobTNMB;IKK&X=j9!sXL`n4QkV%5PQ&T~gFEYe1klu-sYfVhn~}Ll{q~5H&Ngfjp&) zA%PDpE$rO=KW>&`J~%}C=*`EPlTO~gfp9!a_bOH_fzn@PQh&Y2HMFhg!-M{5PQ)h5 z?vHbAN)Sds)j7MqymHHKC#{+0C#1ye9Ocrjgb5TcVeVlKn{^fS?6) z3043-d;n}G)}q86u@ys&)&{9&Xeu0sosppf9^hEp(+cA1J~vlnB#;xXBfXc{SOm{m z1i@Y1`$SN6U}2qmVDpota1YZ+niiOQEI)KQh-+416q6bhEToeGDQ-VM{x}y?&g$3p%)= z!TNC|V=AoUoRQ1fEIh*`+IE2*eO*QH1K(5}8RS@LGHeZW6~mM9Vnptj7TgmV%$~s% zJrVx!(25S00q{xp&_UQZqo1a_$3AOHBdMk0EDjJGd4QX+jc|?>D-JkVl{>6C@?hbB z>oL>#O#brNg7O$bu&s9nZsO#g;CdzJif7lG;YJzwL>yFZmqxQ$EVFJ9iiUJMMz>X8 zCABkKplahO<}!B4eUH5w%4LP zQB}aq8^HJ7dHHGsGVL&xa2vid)!s*7R}FH1AU7uzp(Hr1OV+-iOId*X^;cs>y@1(h z;Q_l4ThoEH_bg8y(uh2d;nn~#u@aRU0oYcMh)bbc_A5)%*w1*rvg3C@6apV|x5O4S z1RM!`PzasHfg5)|XnnL+CtW}$T~&ZaUJuNSfo1av=O9?A4A;xXH%_H~9zCaFTpPRKiM3>OY?6t)vAC@@_E|>)DD^GzGI4% z6C7iO)mr{8n|(-&s{ch&FOa)I2G<)VuZF3-@qwNW_H)7r;_MaV7c%^jac!@+RT54b zC&0>b5Hx!n_Oh$jP@b&wd!+fnmyD5BFT5Cv!nucpiG|@rP};9P6VB{ZNrB$r9qJAy z6QQ^QCFs;3VFq1k;5znH5gO}5kVf*C$ZAKAMP}s0)T9GS6L9ZK zHSoVE$X9U~!Cyc9N-;RlIMd=rxtYGmAL~ca0KRAixzxL}U9sF-EX^G%gZCi1L#E4z z_8Y6dc~_g~^kYR6BQF-q3X)0GD5D5{dWf8IFLY@9@M;KQk~;5-NaCWTy)Me>^zK#wDga$hERAn6TMW`tEo%7p`*VP{Z6c-ryAKk z;NW=9s(M~(Mg}UOw|Ktx6P{F&xV0AD^#Gw<49d1HF(;A zucP)!=8Kb{kFd($*&0l~ArfAvn|8JoZnvJqLW6pl;G?jbznM{D-S zV*y{G-Xp;~@(g`2{$Rf=)XGpz&l^F~3bR_HJu%_z5v+x}yO4mqOv6`6+9j)sS^e4Z zdADanhls2e3tivaM&}@&;4w14_uQ3RUttGTH&QwU7^F|Pj8kdyW+CLOy(4xWCMT&^ zw8KO!#ML$^(-uy~T)GqG!rBgvjwdArCJ&e*)L#AK_gSWz4%o(Yqba9&gRWqQD zrI{xrV4={z+$#`xP;MRnfedsJ%ENs zc}>|Jq(*&4isw(28#TER{o2%)-BhTXNYxGcoyjb(Q^#VF+*_`56}^M7!MIe6OeUr1 z2gad{xLi$&E+x*zT0D(y8@d85!bl4E$eb@dfO5!uXY5!jJQ~Rc%vJQ`JcU~I!e{Pd z+!Ovl@+x6vSKahe=ca4(_ST5Ewh$a2w%9;MjjuxH@h1f!s-%p;qBj!S3U8_Lw5aSg zlNxbQaZ`vZxZj}DiV&d#_wT}XFIY{0@dI@L0Wk}-1kXUl8AWhdGl%r;ti=nnz-Veq zuP5ywnH2$R+n0T)IT&1WVTlzwd)l#UflJGiapHV1q#a|Hm_h>y7crAmOgo8GIO>+M ztLehNy5@vqK!)0v#hNt0CVOBRJ==58oRz$?&c>a^pp;FvUV>k7)}#U~K6$FE3W1&} zCL=3X{7H>*59jlH5r`Vp#Oz~PC`EH1i4rNz1-pDmTZXUK`u zofw|1?z|&rk3_Kl+Bxr62C^a_3^9odBMym>n!DQcie5K(AfT>h!C4OlQ(kj#n)R~>;+nA zRo|E-V(uP;Xjo22K4?53P!;4fPAApO1w4-4N9ULHwlf;UYwdgUW2f$5>4 z5Q{+Qv-(I)aIq59<`G3|E;L2DMIGZ#Lp!=B1D}|SPG=f@P628{?}|MlI#NKv#^T$j z&{bGt=3du=$3l2B9H^b8tBvMP>GmW zjgi;zQGF4nM%l>J@NC;gl?OHk|6VT-nFTQD`xrT>G$g%Xy;WRFJe-gPs?1k=+p7YefFWMRVVweStX^Mzg;`kBffsApMd z3GISvl9PE@;#ZEb_@3koM+i+vK`TN=QOv_4SvExoqPjtGN96TOG4l1`gw1iwh41Rh zF~O3}cdX8hyOLz%*)t6cIPQpok?>3z%vrC^#J&@~r$ZJVV_goQnyhxR#Vd`?;H{%KDxI6fCeA z)`&5*WU{b>!mFc2_RC#xooT$;%Vw?*I9p!OHGZuwfX?~Mt7Hvlgyss4ul@qN(<-C} zQ&4`#yPIskd$*uoo0DEQ&I?KL|=LAht}M zs|-7rK>A2|`IDbb*jk;K@nhzvyAp(3EIEd3?89-Vv=5{hM@KZ$6p1~33+FOtrrko-WodOIDY4s>|OOl_~~C28PQ6E;ng^RJ5*YSbTO?0y0(?^((;B zr30Hv13#m|eX*2RwNpOlIcnua=BR#(T^F#;Gb>tO&&J8CMx z#qxcf8bD2bvrYq`rnytoe@C&AOKKb9(u81 zp>uJ?t9!U=5Yy2%(BL++1E^g5;l*_>Ohd=O08qW!$#Zdc7l&H{t}wZ360k6{HotYp z*Vnjez1)9!4VjB8TWFYCnqOW^%l@WW2w-Jlt#h-*b8!@}j*Wqqj`JM&Ei4ukJO*Py_dA2EqS8f#EAz4 zwAjWj{GWWk!sa{SnW=72ynI@3o{t+4|FG`=I}pEH{V!v9WjHYnOCtd76>)z770oJ< zu=5@HKx%$lJie;+i{tWgf%A$f7kh=B92O!x>+sixs$)pI+x#bMni&U?V_o_1OW9O- z{94I{)8|9AyGB?q*WzhS>fi1J421&twW?A+<&8+4V@FVC(w*368>^_MW)L${qg%$! zeXCO`%3xhvF3bZ@+rZs<*0jnpZV>u~3^kKv=@a)WRe-9vMbkSRVpUYV>a(h;({pPT z^)iBB^w?subiychB{NsEc-w?{c>$0@^p}7Rab^LDulv%QqezR|wvT4%LAWYjTgBx$ z#e_l&RV@Jlfh_6l-C^8In|(Jf4b2~&`AsK(YkUIjkM88RaD?F}j$Cj3c8)XvFE~&6 ze>%Hh#5u>kjlOe$IZ&7D);Y%oXm<5O=rBS zxEK2fP3QcZE1t)iP3aqcrl)NUKtNQ@cQWJ>neQMnG2Jla%4z*dk*iMr>XZKtiu^SU z{{}^_{MIiNNko4qL~AHQziebd#Q#Dy?%Vo$FULl?`egXi>@KMK**?i!qgYM$N&UB~ zYkS`;#*PMnoss$NJ`3uZhE?lo`Z&l{>Hkrm!9-j>;7=1zl9+`dy(s{ z-_DTV>_wP?fV@KPgq;o{X;BCguT>Sa?@8@ z3jlMiwRmr|7Je{ALCm}$o*pL%juiN%)v^9kUUG> z@AZCp(btqk+(i<)8)N$}9#gU+m{-L$($bZ#Z znk8X(Ypr)rdKI_MS0|5313CA*qRXZvjy|pmJ@_2HL0oKP^SRz@UWByMWs-DE9(9?d zT})CTFm!y%R%GAGi;ZotH1RVVZ-Sp7xfiR6Mch0dJ$Ss5cPV&kvru57=CM&F)7bjZ z*}{4L*l`iLPwzmS4mJJzy*7sv$JLtMwGtLY?>=xaC7X6;hph5QhoN*PqUw$ZnJ~9J z$q=^@5D+8hogBHe*$3 zl9{Q02(B9sbE~hR|E;R^FT(g=XUWx*@E?LJ^$(WF2K*|xX6YmD7F^P1cP6)_&F)Ta z1v}i4+`>0_dMmjV695Phx8OB=q!{p1a%jSFyYMI|?W`Zh^^ zuWbEmApQ-KTnACVxRQTmR6@f93{<&$Cr55Ny9?{yb|rV2_xENmf6`n37)Q{4tKPbd zN;d8!$&Eh!S#RC6-X_UEs9OIN#lJz4>mceEk`VskN&an7NyeQlxh1D>MJ4n%I_#Ex z8UD%q<#m_%+dT>3_r~s|KtP1Jcd_IOn;X6Lqn3D8e*ZJ$I4h%4R?0){g`i!%bH6Hc75s@9&aZKe>|c#{TUjN&LO8iDE`+;a`hnmM^N(bbR`!gp@+JYB)8P`ExpBXBg1Z!XU@;-i8WQ9TX#q`b-8)4C&R2W zDf?4cL3CpA!TOf{Woshux;hweS5+H2GO7 z{9`ovy=mafvU}Q{EV=O-mn``qxUR-8{=yQ*e^9pmDT;rCCD&2ZFD&_Yrh%`@?)7)E zqa zACl{TTORnzXm`-$MvC2XCXD}J3iD6x`!{Lwd-K45Iy&VFn(rw7kq5pS^M2WS8^qr# zT)z*X{=W_J`5Pp;4x@hYCI8Ai@Sl%PVZ5cMZ^b2yH#+RLd4F#P^QScbKgAIM_+=jW zca7k>td)S?rMDPw^y!7(y2X*ZI-1|BTK^h|e}f~}LDVlCA^RgL`S*)Pr`r5 zt$%0iz6=2bv~m|qu59*0ZT(0CGu@;$Z+Q}?YtQ>XD|G+8Y2ZJtC|+T6Ly|v2_Zv5T z8^zzNS$|3c{{x}>)r0UKB>8uyfv?6n-dRy(y6NpM9{yViE~dNe`&$#3e^E~VU0?G5 zX2d+xjXeEXZ{4)sCdq$?X#E?Wqds%GB5M*O<(>br~kAM z{Qnh4{&f7-6*S)+$*q{=2HV>p{#McYb4T;1ApQ-GT!&Eq8#wak@+3Y-5_TX*#||Db05QxyN}B)NJJ{zGm3FZ-H1^GyQg7n0m) zu-kp$wd1`w@E`b6$^Y=-Tg*4&^a7nfs)<)`CAp-|Z4`g6Wc?{5`6ozn9YOu#N&c4& z-(tQ|&A*W3E}?MkdH)%bTpRm;?C>q-8=vtrNp9TqEs{`E{a(@fGfVyq#=qf8u0yC_ zSn}@--2Zg^*0s^TD}`G@2{qN77XH8IFR$O8Lk(b}p`!Zf8`2Ys_I9#-W%*f_tNZI? zjv1_u4!Bl$AVT_x04Nc^=KwC)b}BA!x(6T>oJbEIc{e>m9{&g)@Sukb0K)Gh#6QB< zNeuP?%^ib@mi-HjmOHz;b^%nl?FQVvZnK3>_07%`HHEAVw$cQaj?Fj>ZYW&ROgv7f z;>GS9rlXEUI1i@Ldm4=0b&iE*b6s4}xD+^jp&d^ay54>?Jr4{TZm!6Bv4PdmZeP7A zwT?B^)E!G({0W^{Q9uy0i->1I!^^sQ1~*kCXoJ=4+pMF@K$1AW19;yID@s>_P%A|M zyQkI2Y4m|?YuXMc_biX#3F$_ROrfT3qk^Cp6m^Xt8VTxvH1m|lIJ&p({a}4`@i2o? zBiWhz)IQo}*1Ql1jsmHVLy*60v4XgG)jz|V@B?I?z(|=CB%FMssKm*8Un}gu9_d0! z-ZBQ+YMGQBKqIsj(~2`2*j_rQ1wW;+nKU>0*``IARw%&T&aYP!eFog`2}kE7mcgno zn0w&(_&67SadCF_6V3CTPb0LIUC+E|8K2PM(mH#XdAQ0Rf2~_RORJMU2blsnudQ=D zLtcJ!jQy2k(Xs2iXjA80ZAz`4b*XJfZ57JiYzN6v&+)?~8C=gyA2 z$UUgtp)T!Z-{d$P>ctmAS~^%d<=EQ%HY;&Hj+$5By=lgwnoj=5yQ)j0zK zRv#aa*J4=M%h;2H3v1y*1+1DJ4|(zYUfYEy32UT%4yZ|&{CqD$7LtZTo9gL)m&Xhp z@#I7L&Q33mAk`^YEZRaZt0Ata5TAUrp*@eX=D60-^j#O+>{AkhUT+X`x`UC z0Bp2nUu{qS=8px19~5=M^bJHenZNNXQy7+zdUg-BQM?eYUz}%S%MT6kSN2xrE;Yn3 z7~7)CpO!nFWjIDDT5Y-4J4Be|&#=8H#lOqrBcC^%ILW0F=8#NE_?T9>Nz+!DsCsRJ zPmCcf$a$wEnb2#H$y!OtTB%euLQW+WW^=lS51GT)-&e!VpkTx#P*WbZd4meZy)Z0H z`iwho*vX0O%m~k_;QgNX_F?NXYg2HbLTv2O?rbhL9>_zl{U8O_B_ZLZR*|wUxe{ws z3s;j?iE$jMFB)m?Lov%<+ofGLyQ6dXj56aWG_RG6kJ^a+@&qx^X<20S6jJ*ss=9`| z=X6VT)BS}t^n*?7y^^`$trMw_y&J_(**`eu24_!HlVf1l?pnrj9R5OSH6>vV6ZonPgP+VA}K<|NP6Yo z4B1mUNasf_`|FLYVlAvnQeHM8qOUzeW>?xSCaOumkr`ATTjn=U!c!!EQ9{DOS1%^X z7}}NB5VFkd05<-IP8L=L&JnuTm!+DdOTIhrU)hCO)Cphq@t+k0lKCyU(H7n z(JE8nw;kAk2j;egO)a8q1oIj<0uPnFWHC4^D%(?HYJ?yxWcmW+Ba3v znk>1ckTg-aFln97_|sA_W-p~9x=%QaB1AfFQPl*38V_&+jyP0|>rvAozJw-az=SVz(wr$<7AlI5Asz01@gy(`^8p%eKcvZ|w` zqoxC~pk3Zwt`I#ptC{XIP1cmZOD$nEmm}YNnsm+>L6Sx~t5pXlu5KS}7PCE-ysygM zd;gsgK3aj^DCgNYZ;Gd?_9uFNX0;}#r&3cJn=1)tK6Knpk>T`A8LqF}qLdG$MT`td zOZD^?=XG_Ikt7;nIs>q=V49FzVA|#S?OllHi@L^gjh1ne2*AH$_0gLRm0-i#7|bLV z?=BkN*Eh6%Yz#qP`Z(_6vsa39vCQ;M#Kx)?VOg_ly#%?jDCFvO_vEI`ljP(XJ4uoA z{a_T;C_R%@jEu@xlrtQjwsB1&w)RLS=P9geh;E6n0k_(Ic8j35TD-x>#Fqvy+-8X_o&-nSm)60eO$?aAFV>TM?+*RM&zlZFB6pCVASB7@2x&(`ZkC<&&T|#^ba~zgUgknj<~-dMeL= zVtgWsT4#}UPl@3Q)p9du@%kx$bH4m845`I(07Z<$3#mLtoZ?`I1Lccp%sw7!K~ar9 z@@2#z_ffDD)YVIs>DHiNZ4IZg^L(3H9jpRChfkIvtN$PbO+VwBcJylD;aU^2DNQ!; zNToLD1FjL)9sx}(`4nS7!PH(!>BO+6qz;VKnz&dGbxdM-!dYW_*>W$INm6xZ5|P5* z;>sI$L0yA|#%}$VHzt`@YR<^oAFI@zXSmCeCJUd#o3D?vg!zzp_A>7B2r&RYJM~$U zndZiqG3oGY8lwmRk0!DeGD`1dmD1XcfP`kzoj3XSt!okoSEzZPkl3US^xA(~@h0cQ z_#EOXR3Cdjh&$Sj{v_R}|50`XVOlzUp4M5{5dD(8pwO!dcxPNuV=KWWWfkbc7j4>Q zZuFj(P}2Amg-Goc`#`#lN+ce`AoAfO%u+_-5^g2KCi0@>MYcG;Mqya_BK9#yEB4&|_H6R(-0mVnHS+x8oMKarvFEWR162e2tW}UUf?^*D zzryv4cZjb7+q7D=I`r%VpS+saob%bnKyb||;ppTN^G}8^d_ z+XCnmB^wMHV^r?iv?ZAuGEOdLbZ^+o_SCY9%x+XL+DO)Wz&xT1fe`s+sAtCl-{VP= z6>a)M0bW#k9HqWz-pkD*n%%i3#byTagC!(I=FMz=r1Ck#j7BWA3=Rouw9fkrre23? zP8+-Z6}Zgwv7D2#sxsBf(JNw+TS!=|i*{Apn_mp(-hRpa@^*T;iav5n)<$)>59VF1 z6B!S??2dkGnmI%dHv_tkIDt|xqEYBa9>}WvE%!y; zs*j*`kX1Tyq!Ki{cfR^o%XW`vn&LZvNHF$P!MrW<(M%@p3pv_QSatx*fHlJmyA`O#)^?jLwO6tqi$&k4e_|e#kUxooRT^Bwl~==vU|eca?w#co<)JOR;reLVGvId37}8wt;^xqBvQ}aW89|) z{}e6XDNwA$-W|FN%M-4#ATIGwoPKU7SnlQw6bjI483gW34r z6CK0h5)U~+KReH78t)`gvXRr}tV*5gcY?OL`X4W}Xe3-VJzhF>hMkKZV=2+}AN1*srapZmr zi>eb@#QMbJ2%(t`(;y1LoD(Ok0@IhrJevzp?>M4saD>m9-P(Bv_wXUnR_?uo4sRsT zEU+yZoaL6!a@W?yKeUxg@^NX_k^IE1826za0g;~5xC-RK8&$9-j^fP%eDhc8;fb2B zad<*;rGy_l8@MhJ!Qqb3p<1Mo&9hiw@vTOFXlJAGg_qwRu%OROUwt0G2b&*U4g?Kz z{0+>;g)-&^W!e&+1pP;y=v#NzN6?u*@g^EMi0_nE;d&N*?y3*W&+b+$KY8=Cfz z9l#FL^{Pkp+q6rVqg0U6J%m&r9Khi12G#NmKd=dJ{&cA5)OFX3 zkTEJElx>dkko^^15V0M-8DM@mn+;=G8}a?N64NgGGBC@dx4Q(QkIkLZtiZ~jTTw;x zZ8Vj;mH2GnR=1*79J3#z;k1|y>aIc>IL6PL^>5lybPy2qIT?6-CYvCl|Kd*|U1^15 z@PQ!`E0RI5BP}J0Cqh527fN~HTtt=!#(*?agWd`@9aL)=VJE%ooVcXV$;BH7kEj(& z*`F4pO**~LcKLuVG@?iMnRP^5np)}u_2oy$$QCsbm|lo^A?Q0y)_A2}m|S)BfT>VE zG{cUzFQ;JK%TPNENB&OUXCCAHkIG1>g5EalV2yB_^0Yam*oy`YRt+s+-O2=lgUcw$uY1nIw>2Yj-cH=3w$o-B z>mXQSTg{Fowkx6-eTT%^b0ay1oP$$6_`_=@X~fK6qf|r-{!lpPuE4~nzSj0nxv_&G zgNs8%>)fH}$V__i`9V&_L{m>+bC3Aw%jzG(O?os)CZ8R62f2rBF*?F@NzWlklczQA zwz6u4N6zs_20tc4)eVMoP~x!-qSuC0;-zC0b)4wIz-^H++-H`6a&X}r!!&Earm3L;bh}JvpuM_tWNow;+B+MBkb4~ zj<-T@=hw40L@v(DN9EO3>V;U;UGFPL0qfS7U`aQDUyHi;MK zQ^6uat;*ie86r;-2|2Nka)?h?w^}WmSYWVYq&cU06Nj zM=m=o?K*1>b>MKO{9{qLrzOtlvls)XIC&x>>CANv|&f_9BTC2D^!|6*YZHI7> zbWkbxdw6S!xXiwMWe_*h`Z_44>ispY%39bF5#xJ^=E6 z3#WR<5%`>9niY7;VcOrVA0hlL$^$7}#E`n9?Dl%759)GipT(~e1LafYa4I`j?bX(+K1rrVo9$U_0C z!!1zMb1D>eCXGd$ifIolmPy8Kc$^xuR~*y+9K1738?H}*S`rv`wKC23*iboQAU3gH z>>NBZZ-VlAacB;WX~A{!exgRT;XP>D>9i@6A_3ODuQeFckis*Zf)0#8oA(Z#AC<6a zESi8!ioY%gt08%Qj$!ZQiZp4*>{A~Md9qKqlNMm#5J$pC?s#ubh$D^p>OvpLQz5u zM?k268zXEbyLE!7Nos_;1;EMINpZR(qjO8ML1(c;TUVrZftDV=3;3{WceEHf!wifu zxVeA1Oy$aUJSw18v3LBb=uuujz+N z)9)pGXpsR);AMXL2u5OfW*g{KzCjBF8+psy%?tb~{`0R{Kw7;@9Cr`-MK2LfXjkUY z!)!o>cLSG-!trEHHm`OY0~;rOK50z*7S*E?q}`n=Lv-E}EG$zJB}BJd`pB5}leC8J zaHpO;PZW#YFdVIH1s`W-L+iqHKe%W$vya3>b6ROeqW7}E1X71-ZX|+U4(ay}J>A-{ zE84&r#f7IC`85#XMTOmFF%a!uAelxjO*7&*@IpS@du1%;g3ekp}E4Zj6!-%}5pN8#=Ql4rr=5a*uZ2C>Y?3`<0;6r0!^tOyEpGHunl5Uv?TUmZ24_N9NcR$P1xh z7V^Hw1be8~i3F2QD%AWYI!GHS@gCK^pvra~;Ai02A*sZik07id^+0+%K4gM=gD=4= z?>A_boS(nJIZ~kA;y4t+J%=K$oPBut%Bi2HSD>c4soY=G59k@FepK|SsqPp+Nlo>C zu14R^)vJbdo4QlMe%!Q&#;N83PkUlO;6Z1N3V)JP$OeQj?nTFqwDhw4EtW!j3FtOu z*8$&0aU0VF3=XoO+LpGG3O9_J049kc{Ra*;uxjhj1@20w)g>{7Fr<$ytGi$V18hUH zNL$&L%++S0V~JS59wq_-9Q{zfOj8ydv6P1x=Hs4Dd%L`RHbAdvWi=s!s#cAFE3#ms zwd|xgNIvon>8%kbai0qjL3G0t!jL4h!jrxIDWyVGkX(yn{y(Wq_LMmi_R_3 zq40pI6Hp|IK&Kl@Ow)}w984biBrx;Xl5)IQjV^{>`CB{)x{LWQsF^Rmxa|%x}Jab8(uhCK)Ro6L|pB_1|A^2;OX5e$0zk z&FNgs&v8`~xf*=%ee#8y0`g@Q><>_`Pk1w(g6zfg4ww6`TDdhWb*?72_&)+-j2vhW_Hlt_!}G1x!9uEi-KcQ#}Bsf`KWQsinb9!`1b#W^}Op zKC8uLx$eK`KmApykm16Fj28x_zZleU`J3V5>n3!x^jGVQ7w^)hy?TK#!^OJaItnZS;vK4%;ZcBE-=x# zIK%abp^IMRyZtU6s*BTHR=<9#WB%W({XZtlxU?)a&rgM}czat#q_*;XSRUK^s6|cKdcP4M4YYB6=?oy?h|-V znwT6J+B0M=MVkw^T&5iYMoGFYmu#EYX}i1MH6e-D^TV+<%x+;40rO}{DWO2~0}K7q zZhjPc=y8!0!_SJO#AzWGYEiY765W%v)#A}>{ke{|Et|=U8BJYqaDL^hW7QI_)yyO| z=SMB3R_5d5J;bbzeS)xo-U&F5FpPz)#uU}{%({_Eb(?t($*QU;n?*C-J0K6+SAveLp z#)*|4C`*)uno0XvZsp+$|5wTtg_Soegr#j+;c_b3lkyw#UviytU*&wr!OMwIe3a{( zlczjURAu(nU#>WC4Pe8<8XBPeWdwXp(*{zdT}?Z`h;jI!75YUh@K-L-UUNDWF>VB6 z;It&sFi(r#`>o!-J=9j9Y<%kUIsEy{$;&A(21P%M4)Ge$&~?xo(SgSpisde(F2tp) z?aJ4Oo1>benmdsrmi ztrj?p_~w1eWQiAc;@SG~P%R=uPcaHuv-m@hMH5P~5EP8a&32X~wX+!4qHWFrof*S< zvc+dmZ31>ro3Ni0KCwCRMxF-aae)M{Xc*2@-=-pglsT>aV!FQW{7fJ)YVh!A*XLvP zToR^PZ`k`in5c;q0nScZcS_dO=h73R?o*kL)ZHF>2flCOLssBX<6LthY0;XrU_QMB zhx_vKB%_myDEw4Ot%K)4ZeL>S0OVVj%e@V^Ov}k0dgWs_pW~os2y9XulTT7@7u&ox zC>ftSq905iS_Ax1pX0rAd1=}pTIKDj3DplCrs~4`jpQs^3v#2L4VlR>9+(UCe!V;W zH>}sK$^{JT$4{j5aO)8R4&P@iP1tiFnT-^Yu0l4V>RM~diYNEbKG!vCv*hirCcIw| z%9cQNk5$rkxdjK?Hff_jzTxHU$F8__23(6NdQhhZ8$vjD`k|+s&e?f~(&50FbMadt zUJT1;IMX&D*u%wa->|D|H}0J!eeDovFbgh7uu*-$HYI|_cG8YR#E3OfCqX(77~1TkZIm${o3uKKOfPiloi$tr?foT9kkV~#o2tX6&Xk@8}hn}ylQs9>b@E;{?e z&yRI8GZZ3)_DKEql5^XiSK5jhYTNtxHFV`2lp9q5OM{5x+tT8m{oXlb!IF8}yN*9| z`@avh$%>{h83>$rjvkFcdP#&UHHDsuj{$L3%o z%uxuHBVK*Q>dM*#>djUlp%KdK*Zd63rl~f{YbW5?+Z-nV<3+(g+(kfRcQQB#XE(@= z;PPX+N>Wgc3Crd6Yk^K!_mADtF4N36Z#rSbE~Tk2>29NAKC$dDPud4;LLfnE_=xyHa8ZilSe4ToIn~Juue3hoS|58s z54sF&mGsV5V^~!Kd*$<$B|nrJ8`j~AlkNqlHfa=lJ&}G!w zZdDyc<5V+K1MRLCKcj1ASip+e*!-5c@TtnHR+v^g{HnlJfPJ-1SC@2OZ%{83U1-I3 zeUAFh?i6u4gQ+)2Rg$q-{cvqDyQ_XB8>9Y7XwLcV2P_p>#%K581{MW)#z{m`{0!4` z3EJP7ng}@9Ds(u!-m6MCDQTOu+|&?G|I+cg^tZ9F2m<+}bWq?@d7f~u87cc|PFS7QPyI}9YahsnVh`gx*30i2TQo)N*b1s3QW z;~m3u)Kef^)6kv}L~Enw;H7~}YJ8vQZ6#C`x@+!BeVR6*n3vryTi&+VxYUOdZ@6vb zZ4Yq5^sIgCskiiVc;dQ*%%u22vp)t;MBDKFjDk7$7udlJ@4KFdz>;T<_l6SE-N@+? zi_PP{mm&&Dl0Qp5V?aP! zd*|2Lf+2}X37;ZvF-srUMwjboD!Es3h~8;|e`|pF$)a_dLrkgD&r)7TKa!HwJakJw zoEjx|hKKy^n?P;Tvi_7t3`2$ah#<8)b{#zh`87d*$b(Mgio%YFoec;zRxj@j9^KX{ zql(?HOj%xzJa*sC&>+m=cdHx!Zz4OADB|@TU_z{hJ zQmnu5LT3t7aio-iv+2txMeYcwFV>z2IISC09|vMzEaM)UqN9A?iV}tV8G@I@JK$=i zCLfp3F4AsrU(j-1s`|-_E#GEHvrUA0kZE2Ux0TLPMYtzlWU{Mjc%`zG@P}5Fa${x1 zPGKFIsx}Y5p3KM{r^9;*b7YL4<&*&$GLLqTv%!~nr-;=ArJ9^7fy^fuf+EoWKm3Z+Gni~=~R@^MUZv3?`l;=x?ZGb(9 zTUwa=wz5d3Wz=uViY>5Lra|P;Q&V`zf^!4ZMe^0G$d{icZceJ6-QS`zB-X1Eg5(Fh zW$;z>K+$+KuRO-K$AuF9M_QyfsW8h&htDk=Sr(9)4A~U5dX4Cq>GH{sl^By4?%%kk z(R&T}4+CHfi11+{j`fa%7not8yQE+{{Pj?giM{dcV=sAqxGz2(@7pb$mrl+cBxJRJ z*A;7rYczCySS@t6WeT;$atqR18PVlvdhUW=^J}hpu6_H0TJB*^t-=R*~2sR6}uhX*+X7@e%CkZZYb$;aP{kG@*ynUdG5d^J6Ax^WZD{ zrG5eBHEBY020Mmfzw<7PsUg{-*xZJPcllH`Nv=<)4s6hmE%w_WPCv%0Au;MqMelSt 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 z%0DL&{SgBGYU#hGA^DR6CZ}&=L!R`U5qw*S9q_GjL$Eu{WXlQ4TNZY)t;> z`X7TJej40g2kUBYWDL&?_BRfA=6^qk!9NdyRk4hT|HME54gi>G_4FXN{x6J~orM*= zfd7uMaXcla{yWCZ{Nyr!$JjVPzt_hC{zV4t;(ss4&heDM@^2U`kohTP>EAFAfc439 z{)RDgusx-1{5!_U`jp4)Zy4C3Px*%ajsgBq1^f-;d}8JO9Rsohes34R0s{VS0{|-v z@L4$!032F=s}ID<^845TV97G@O8I-c>>$=>edb_!W&;2xfbCgdI61-UW`C~_$jr|E zdpRHg#QuAqfk2jL^9y7FJSF1&TmOJ8oX>0zWMv1-ru|k9tj_qmO~J92?RUE{vvaWh z-WO&LmS=VWWB*W&jqP_IV&()p%x`@GWB*W&mHk;?I61*WZ+{;LGbbllSMKi^*ss{1 z%_Vqnzqbp%xI7UYpC12STVVHlN&)^G26{f10MJv8>)*;fUo+r{@ysq@jN_RP0ok6f z4;BFXGyh`&JSDRGd%G;G&({$P=W`pdf=~PXTvnDR70$oc2Ugg7)-Ef@6JPi5kB6v>$7oiaz0ycKxWow zHUMMKt_$G!@+@8f0pOh7zx5A{fqrin9EU)^j~57Je%d|!ZH~a$v-k(LJm~lF0)ZUQ zVk{VAd)6)s*b{zlg9Ype&oQ=V{bS*L7E6F&v;00EU<}NA|7{$s?9bL45CpzB{$393 zLeJU-fu6+@AR9C2nVs2~pT%+TGJh7w!J+-x+F}6$pRENj_RL>cfSgaev%k#|D*zlM zo@39(!3yrn@3vz7hrX}^LC^dE1opMx<0%+>W>aw30R0|g!Pv8F9(eEbEDnLd%lt3% z;b^Z97HhTtL(o&v%pI)y`Ckb(6>Mz48^fpN1>SN!DW!^A8-w?MPxkwB59Z*gZ}0fW S4h&o+2n0_-AtEaZ|NjA!)Xswd literal 83335 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 diff --git a/src/main/documentation/schema/er.svg b/src/main/documentation/schema/er.svg index 2ebc0f404..980cae1b7 100644 --- a/src/main/documentation/schema/er.svg +++ b/src/main/documentation/schema/er.svg @@ -124,7 +124,7 @@ application_id - NUMERIC + BIGINT @@ -154,7 +154,7 @@ iso_code_2 - CHARACTER VARYING(2) + VARCHAR(2) @@ -214,21 +214,21 @@ id - CHARACTER VARYING(255) + VARCHAR(255) author - CHARACTER VARYING(255) + VARCHAR(255) filename - CHARACTER VARYING(255) + VARCHAR(255) @@ -258,7 +258,7 @@ domain_id - NUMERIC + BIGINT @@ -273,7 +273,7 @@ password_reset_token_id - CHARACTER VARYING(255) + VARCHAR(255) @@ -325,22 +325,22 @@ - t_user + user - login - CHARACTER VARYING(50) + user_id + BIGINT - t_authority + authority @@ -348,14 +348,14 @@ name - CHARACTER VARYING(255) + VARCHAR(255) - t_persistent_audit_event + persistent_audit_event @@ -370,7 +370,7 @@ - t_persistent_audit_event_data + persistent_audit_event_data @@ -385,14 +385,14 @@ name - CHARACTER VARYING(50) + VARCHAR(255) - t_persistent_token + persistent_token @@ -400,29 +400,29 @@ series - CHARACTER VARYING(255) + VARCHAR(255) - t_user_authority + user_authority - login - CHARACTER VARYING(50) + user_id + BIGINT name - CHARACTER VARYING(255) + VARCHAR(255) diff --git a/src/main/documentation/schema/mlds.ddl b/src/main/documentation/schema/mlds.ddl index 2018a0986..b55a896c0 100644 --- a/src/main/documentation/schema/mlds.ddl +++ b/src/main/documentation/schema/mlds.ddl @@ -57,17 +57,17 @@ CREATE TABLE `affiliate_details` ( `type` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_bin DEFAULT NULL, `other_text` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_bin DEFAULT NULL, `subtype` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_bin DEFAULT NULL, - `first_name` varchar(50) CHARACTER SET utf8mb4 COLLATE utf8mb4_bin DEFAULT NULL, - `last_name` varchar(50) CHARACTER SET utf8mb4 COLLATE utf8mb4_bin DEFAULT NULL, - `email` varchar(100) CHARACTER SET utf8mb4 COLLATE utf8mb4_bin DEFAULT NULL, - `alternate_email` varchar(100) CHARACTER SET utf8mb4 COLLATE utf8mb4_bin DEFAULT NULL, - `third_email` varchar(100) CHARACTER SET utf8mb4 COLLATE utf8mb4_bin DEFAULT NULL, + `first_name` varchar(255) COLLATE utf8mb4_bin DEFAULT NULL, + `last_name` varchar(255) COLLATE utf8mb4_bin DEFAULT NULL, + `email` varchar(255) COLLATE utf8mb4_bin DEFAULT NULL, + `alternate_email` varchar(255) COLLATE utf8mb4_bin DEFAULT NULL, + `third_email` varchar(255) COLLATE utf8mb4_bin DEFAULT NULL, `street` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_bin DEFAULT NULL, `city` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_bin DEFAULT NULL, - `post` varchar(50) CHARACTER SET utf8mb4 COLLATE utf8mb4_bin DEFAULT NULL, + `post` varchar(255) COLLATE utf8mb4_bin DEFAULT NULL, `billing_street` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_bin DEFAULT NULL, `billing_city` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_bin DEFAULT NULL, - `billing_post` varchar(50) CHARACTER SET utf8mb4 COLLATE utf8mb4_bin DEFAULT NULL, + `billing_post` varchar(255) COLLATE utf8mb4_bin DEFAULT NULL, `country_iso_code_2` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_bin DEFAULT NULL, `billing_country_iso_code_2` varchar(2) CHARACTER SET utf8mb4 COLLATE utf8mb4_bin DEFAULT NULL, `organization_name` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_bin DEFAULT NULL, @@ -139,6 +139,19 @@ CREATE TABLE `application` ( ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_bin; /*!40101 SET character_set_client = @saved_cs_client */; +-- +-- Table structure for table `authority` +-- + +DROP TABLE IF EXISTS `authority`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `authority` ( + `name` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_bin NOT NULL, + PRIMARY KEY (`name`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_bin; +/*!40101 SET character_set_client = @saved_cs_client */; + -- -- Table structure for table `commercial_usage` -- @@ -293,33 +306,9 @@ DROP TABLE IF EXISTS `email_domain_blacklist`; /*!50503 SET character_set_client = utf8mb4 */; CREATE TABLE `email_domain_blacklist` ( `domain_id` bigint NOT NULL AUTO_INCREMENT, - `domainname` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_bin DEFAULT NULL, + `domain_name` varchar(255) COLLATE utf8mb4_bin DEFAULT NULL, PRIMARY KEY (`domain_id`) -) ENGINE=InnoDB AUTO_INCREMENT=386731 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_bin; -/*!40101 SET character_set_client = @saved_cs_client */; - --- --- Table structure for table `event` --- - -DROP TABLE IF EXISTS `event`; -/*!40101 SET @saved_cs_client = @@character_set_client */; -/*!50503 SET character_set_client = utf8mb4 */; -CREATE TABLE `event` ( - `event_id` bigint NOT NULL, - `type` varchar(31) CHARACTER SET utf8mb4 COLLATE utf8mb4_bin NOT NULL, - `description` varchar(4096) CHARACTER SET utf8mb4 COLLATE utf8mb4_bin NOT NULL, - `timestamp` timestamp NOT NULL, - `event_sub_type` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_bin DEFAULT NULL, - `principal` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_bin DEFAULT NULL, - `browser_type` varchar(32) CHARACTER SET utf8mb4 COLLATE utf8mb4_bin DEFAULT NULL, - `browser_version` varchar(32) CHARACTER SET utf8mb4 COLLATE utf8mb4_bin DEFAULT NULL, - `ip_address` varchar(32) CHARACTER SET utf8mb4 COLLATE utf8mb4_bin DEFAULT NULL, - `locale` varchar(5) CHARACTER SET utf8mb4 COLLATE utf8mb4_bin DEFAULT NULL, - `session_id` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_bin DEFAULT NULL, - `user_agent` varchar(1024) CHARACTER SET utf8mb4 COLLATE utf8mb4_bin DEFAULT NULL, - PRIMARY KEY (`event_id`) -) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_bin; +) ENGINE=InnoDB AUTO_INCREMENT=384824 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_bin; /*!40101 SET character_set_client = @saved_cs_client */; -- @@ -372,9 +361,9 @@ CREATE TABLE `member` ( `logo_file` bigint DEFAULT NULL, `staff_notification_email` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_bin DEFAULT NULL, `promote_packages` bit(1) NOT NULL DEFAULT b'0', - `contactEmail` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_bin DEFAULT NULL, - `memberOrgName` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_bin DEFAULT NULL, - `memberOrgURL` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_bin DEFAULT NULL, + `contact_email` varchar(255) COLLATE utf8mb4_bin DEFAULT NULL, + `member_org_name` varchar(255) COLLATE utf8mb4_bin DEFAULT NULL, + `member_org_url` varchar(255) COLLATE utf8mb4_bin DEFAULT NULL, PRIMARY KEY (`member_id`), UNIQUE KEY `member_name_unique` (`key`), KEY `member_file` (`license_file`), @@ -397,7 +386,75 @@ CREATE TABLE `password_reset_token` ( `user_id` bigint NOT NULL, PRIMARY KEY (`password_reset_token_id`), KEY `fk_password_reset_token_user_id` (`user_id`), - CONSTRAINT `fk_password_reset_token_user_id` FOREIGN KEY (`user_id`) REFERENCES `t_user` (`user_id`) + CONSTRAINT `fk_password_reset_token_user_id` FOREIGN KEY (`user_id`) REFERENCES `user` (`user_id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_bin; +/*!40101 SET character_set_client = @saved_cs_client */; + +-- +-- Table structure for table `persistent_audit_event` +-- + +DROP TABLE IF EXISTS `persistent_audit_event`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `persistent_audit_event` ( + `event_id` bigint NOT NULL, + `principal` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_bin DEFAULT NULL, + `event_date` timestamp NULL DEFAULT NULL, + `event_type` varchar(50) CHARACTER SET utf8mb4 COLLATE utf8mb4_bin DEFAULT NULL, + `application_id` bigint DEFAULT NULL, + `affiliate_id` bigint DEFAULT NULL, + `release_package_id` bigint DEFAULT NULL, + `release_version_id` bigint DEFAULT NULL, + `release_file_id` bigint DEFAULT NULL, + `commercial_usage_id` bigint DEFAULT NULL, + PRIMARY KEY (`event_id`), + KEY `idx_persistent_audit_event` (`principal`,`event_date`), + KEY `FK_persistent_audit_event_application` (`application_id`), + KEY `FK_persistent_audit_event_affiliate` (`affiliate_id`), + KEY `FK_persistent_audit_event_release_package` (`release_package_id`), + KEY `FK_persistent_audit_event_release_version` (`release_version_id`), + KEY `FK_persistent_audit_event_release_file` (`release_file_id`), + KEY `FK_persistent_audit_event_commercial_usage` (`commercial_usage_id`), + CONSTRAINT `FK_persistent_audit_event_commercial_usage` FOREIGN KEY (`commercial_usage_id`) REFERENCES `commercial_usage` (`commercial_usage_id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_bin; +/*!40101 SET character_set_client = @saved_cs_client */; + +-- +-- Table structure for table `persistent_audit_event_data` +-- + +DROP TABLE IF EXISTS `persistent_audit_event_data`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `persistent_audit_event_data` ( + `event_id` bigint NOT NULL, + `name` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_bin NOT NULL, + `value` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_bin DEFAULT NULL, + PRIMARY KEY (`event_id`,`name`), + KEY `idx_persistent_audit_event_data` (`event_id`), + KEY `idx_persistent_audit_event_data_name` (`name`), + CONSTRAINT `FK_event_persistent_audit_event_data` FOREIGN KEY (`event_id`) REFERENCES `persistent_audit_event` (`event_id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_bin; +/*!40101 SET character_set_client = @saved_cs_client */; + +-- +-- Table structure for table `persistent_token` +-- + +DROP TABLE IF EXISTS `persistent_token`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `persistent_token` ( + `series` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_bin NOT NULL, + `token_value` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_bin DEFAULT NULL, + `token_date` date DEFAULT NULL, + `ip_address` varchar(39) CHARACTER SET utf8mb4 COLLATE utf8mb4_bin DEFAULT NULL, + `user_agent` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_bin DEFAULT NULL, + `user_id` bigint DEFAULT NULL, + PRIMARY KEY (`series`), + KEY `fk_persistent_token_user_id` (`user_id`), + CONSTRAINT `fk_persistent_token_user_id` FOREIGN KEY (`user_id`) REFERENCES `user` (`user_id`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_bin; /*!40101 SET character_set_client = @saved_cs_client */; @@ -416,7 +473,7 @@ CREATE TABLE `release_file` ( `download_url` text CHARACTER SET utf8mb4 COLLATE utf8mb4_bin, `primary_file` bit(1) NOT NULL DEFAULT b'0', `md5_hash` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_bin DEFAULT NULL, - `file_size` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_bin DEFAULT NULL, + `file_size` bigint DEFAULT NULL, PRIMARY KEY (`release_file_id`), KEY `FK_release_file_release_version` (`release_version_id`), CONSTRAINT `FK_release_file_release_version` FOREIGN KEY (`release_version_id`) REFERENCES `release_version` (`release_version_id`) @@ -441,12 +498,12 @@ CREATE TABLE `release_package` ( `licence_file` bigint DEFAULT NULL, `priority` int DEFAULT '-1', `copyrights` text CHARACTER SET utf8mb4 COLLATE utf8mb4_bin, - `releasePackageURI` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_bin DEFAULT NULL, + `release_package_uri` varchar(255) COLLATE utf8mb4_bin DEFAULT NULL, `updated_at` timestamp NULL DEFAULT NULL, PRIMARY KEY (`release_package_id`), - KEY `FK_package_t_user` (`created_by`), KEY `release_package_member` (`member_id`), KEY `licence_file` (`licence_file`), + KEY `FK_package_user` (`created_by`), CONSTRAINT `licence_file` FOREIGN KEY (`licence_file`) REFERENCES `file` (`file_id`), CONSTRAINT `release_package_member` FOREIGN KEY (`member_id`) REFERENCES `member` (`member_id`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_bin; @@ -471,120 +528,39 @@ CREATE TABLE `release_version` ( `inactive_at` timestamp NULL DEFAULT NULL, `release_type` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_bin DEFAULT NULL, `summary` text CHARACTER SET utf8mb4 COLLATE utf8mb4_bin, - `versionDependentDerivativeURI` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_bin DEFAULT NULL, - `versionDependentURI` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_bin DEFAULT NULL, - `versionURI` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_bin DEFAULT NULL, + `version_dependent_derivative_uri` varchar(255) COLLATE utf8mb4_bin DEFAULT NULL, + `version_dependent_uri` varchar(255) COLLATE utf8mb4_bin DEFAULT NULL, + `version_uri` varchar(255) COLLATE utf8mb4_bin DEFAULT NULL, `id` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_bin DEFAULT NULL, `updated_at` timestamp NULL DEFAULT NULL, `package_type` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_bin DEFAULT NULL, `archive` bit(1) NOT NULL DEFAULT b'0', PRIMARY KEY (`release_version_id`), KEY `FK_release_version_release_package` (`release_package_id`), - KEY `FK_release_version_t_user` (`created_by`), + KEY `FK_release_version_user` (`created_by`), CONSTRAINT `FK_release_version_release_package` FOREIGN KEY (`release_package_id`) REFERENCES `release_package` (`release_package_id`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_bin; /*!40101 SET character_set_client = @saved_cs_client */; -- --- Table structure for table `t_authority` +-- Table structure for table `user` -- -DROP TABLE IF EXISTS `t_authority`; +DROP TABLE IF EXISTS `user`; /*!40101 SET @saved_cs_client = @@character_set_client */; /*!50503 SET character_set_client = utf8mb4 */; -CREATE TABLE `t_authority` ( - `name` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_bin NOT NULL, - PRIMARY KEY (`name`) -) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_bin; -/*!40101 SET character_set_client = @saved_cs_client */; - --- --- Table structure for table `t_persistent_audit_event` --- - -DROP TABLE IF EXISTS `t_persistent_audit_event`; -/*!40101 SET @saved_cs_client = @@character_set_client */; -/*!50503 SET character_set_client = utf8mb4 */; -CREATE TABLE `t_persistent_audit_event` ( - `event_id` bigint NOT NULL, - `principal` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_bin DEFAULT NULL, - `event_date` timestamp NULL DEFAULT NULL, - `event_type` varchar(50) CHARACTER SET utf8mb4 COLLATE utf8mb4_bin DEFAULT NULL, - `application_id` bigint DEFAULT NULL, - `affiliate_id` bigint DEFAULT NULL, - `release_package_id` bigint DEFAULT NULL, - `release_version_id` bigint DEFAULT NULL, - `release_file_id` bigint DEFAULT NULL, - `commercial_usage_id` bigint DEFAULT NULL, - PRIMARY KEY (`event_id`), - KEY `idx_persistent_audit_event` (`principal`,`event_date`), - KEY `FK_t_persistent_audit_event_application` (`application_id`), - KEY `FK_t_persistent_audit_event_affiliate` (`affiliate_id`), - KEY `FK_t_persistent_audit_event_release_package` (`release_package_id`), - KEY `FK_t_persistent_audit_event_release_version` (`release_version_id`), - KEY `FK_t_persistent_audit_event_release_file` (`release_file_id`), - KEY `FK_t_persistent_audit_event_commercial_usage` (`commercial_usage_id`), - CONSTRAINT `FK_t_persistent_audit_event_commercial_usage` FOREIGN KEY (`commercial_usage_id`) REFERENCES `commercial_usage` (`commercial_usage_id`) -) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_bin; -/*!40101 SET character_set_client = @saved_cs_client */; - --- --- Table structure for table `t_persistent_audit_event_data` --- - -DROP TABLE IF EXISTS `t_persistent_audit_event_data`; -/*!40101 SET @saved_cs_client = @@character_set_client */; -/*!50503 SET character_set_client = utf8mb4 */; -CREATE TABLE `t_persistent_audit_event_data` ( - `event_id` bigint NOT NULL, - `name` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_bin NOT NULL, - `value` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_bin DEFAULT NULL, - PRIMARY KEY (`event_id`,`name`), - KEY `idx_persistent_audit_event_data` (`event_id`), - KEY `idx_persistent_audit_event_data_name` (`name`), - CONSTRAINT `FK_event_persistent_audit_event_data` FOREIGN KEY (`event_id`) REFERENCES `t_persistent_audit_event` (`event_id`) -) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_bin; -/*!40101 SET character_set_client = @saved_cs_client */; - --- --- Table structure for table `t_persistent_token` --- - -DROP TABLE IF EXISTS `t_persistent_token`; -/*!40101 SET @saved_cs_client = @@character_set_client */; -/*!50503 SET character_set_client = utf8mb4 */; -CREATE TABLE `t_persistent_token` ( - `series` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_bin NOT NULL, - `token_value` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_bin DEFAULT NULL, - `token_date` date DEFAULT NULL, - `ip_address` varchar(39) CHARACTER SET utf8mb4 COLLATE utf8mb4_bin DEFAULT NULL, - `user_agent` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_bin DEFAULT NULL, - `user_id` bigint DEFAULT NULL, - PRIMARY KEY (`series`), - KEY `fk_persistent_token_user_id` (`user_id`), - CONSTRAINT `fk_persistent_token_user_id` FOREIGN KEY (`user_id`) REFERENCES `t_user` (`user_id`) -) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_bin; -/*!40101 SET character_set_client = @saved_cs_client */; - --- --- Table structure for table `t_user` --- - -DROP TABLE IF EXISTS `t_user`; -/*!40101 SET @saved_cs_client = @@character_set_client */; -/*!50503 SET character_set_client = utf8mb4 */; -CREATE TABLE `t_user` ( - `login` varchar(100) CHARACTER SET utf8mb4 COLLATE utf8mb4_bin NOT NULL, - `password` varchar(100) CHARACTER SET utf8mb4 COLLATE utf8mb4_bin DEFAULT NULL, - `first_name` varchar(50) CHARACTER SET utf8mb4 COLLATE utf8mb4_bin DEFAULT NULL, - `last_name` varchar(50) CHARACTER SET utf8mb4 COLLATE utf8mb4_bin DEFAULT NULL, - `email` varchar(100) CHARACTER SET utf8mb4 COLLATE utf8mb4_bin DEFAULT NULL, +CREATE TABLE `user` ( + `login` varchar(255) COLLATE utf8mb4_bin DEFAULT NULL, + `password` varchar(255) COLLATE utf8mb4_bin DEFAULT NULL, + `first_name` varchar(255) COLLATE utf8mb4_bin DEFAULT NULL, + `last_name` varchar(255) COLLATE utf8mb4_bin DEFAULT NULL, + `email` varchar(255) COLLATE utf8mb4_bin DEFAULT NULL, `activated` bit(1) NOT NULL DEFAULT b'1', `lang_key` varchar(5) CHARACTER SET utf8mb4 COLLATE utf8mb4_bin DEFAULT NULL, - `activation_key` varchar(20) CHARACTER SET utf8mb4 COLLATE utf8mb4_bin DEFAULT NULL, - `created_by` varchar(100) CHARACTER SET utf8mb4 COLLATE utf8mb4_bin NOT NULL DEFAULT 'system', + `activation_key` varchar(255) COLLATE utf8mb4_bin DEFAULT NULL, + `created_by` varchar(255) COLLATE utf8mb4_bin DEFAULT NULL, `created_date` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP, - `last_modified_by` varchar(50) CHARACTER SET utf8mb4 COLLATE utf8mb4_bin DEFAULT NULL, + `last_modified_by` varchar(255) COLLATE utf8mb4_bin DEFAULT NULL, `last_modified_date` timestamp NULL DEFAULT NULL, `user_id` bigint NOT NULL, `inactive_at` timestamp NULL DEFAULT NULL, @@ -596,33 +572,19 @@ CREATE TABLE `t_user` ( /*!40101 SET character_set_client = @saved_cs_client */; -- --- Table structure for table `t_user_authority` +-- Table structure for table `user_authority` -- -DROP TABLE IF EXISTS `t_user_authority`; +DROP TABLE IF EXISTS `user_authority`; /*!40101 SET @saved_cs_client = @@character_set_client */; /*!50503 SET character_set_client = utf8mb4 */; -CREATE TABLE `t_user_authority` ( +CREATE TABLE `user_authority` ( `name` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_bin NOT NULL, `user_id` bigint NOT NULL, PRIMARY KEY (`user_id`,`name`), KEY `fk_authority_name` (`name`), - CONSTRAINT `fk_authority_name` FOREIGN KEY (`name`) REFERENCES `t_authority` (`name`), - CONSTRAINT `fk_user_authority_user_id` FOREIGN KEY (`user_id`) REFERENCES `t_user` (`user_id`) -) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_bin; -/*!40101 SET character_set_client = @saved_cs_client */; - --- --- Table structure for table `user_registration` --- - -DROP TABLE IF EXISTS `user_registration`; -/*!40101 SET @saved_cs_client = @@character_set_client */; -/*!50503 SET character_set_client = utf8mb4 */; -CREATE TABLE `user_registration` ( - `user_registration_id` bigint NOT NULL AUTO_INCREMENT, - `email` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_bin DEFAULT NULL, - PRIMARY KEY (`user_registration_id`) + CONSTRAINT `fk_authority_name` FOREIGN KEY (`name`) REFERENCES `authority` (`name`), + CONSTRAINT `fk_user_authority_user_id` FOREIGN KEY (`user_id`) REFERENCES `user` (`user_id`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_bin; /*!40101 SET character_set_client = @saved_cs_client */; diff --git a/src/main/documentation/schema/readme.txt b/src/main/documentation/schema/readme.txt index ace03f551..f79cd9c2f 100644 --- a/src/main/documentation/schema/readme.txt +++ b/src/main/documentation/schema/readme.txt @@ -6,7 +6,7 @@ Some notes: * Tables affiliate and member are important roots. -* t_user and the other t_* tables come from a base framework and implement users, permissions, and event logging. t_authority and t_user_authority will be obsolete once we finish integrating with the staff authentication service. +* user, authority, user_authority, persistent_audit_event, persistent_audit_event_data and persistent_token tables come from a base framework and implement users, permissions, and event logging. authority and user_authority will be obsolete once we finish integrating with the staff authentication service. * Tables "databasechangelog" and "databasechangeloglock" are bookkeeping for Liquibase which manages our db changes. From bcce6053dd735f2281bd38facd4826e22f84a417 Mon Sep 17 00:00:00 2001 From: jaykamal <130353187+jaykamal@users.noreply.github.com> Date: Wed, 23 Oct 2024 13:52:15 +0530 Subject: [PATCH 25/58] MLDS-1064 Column name changes in Repository Queries. --- .../ihtsdo/mlds/repository/ReleaseVersionRepository.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/main/java/ca/intelliware/ihtsdo/mlds/repository/ReleaseVersionRepository.java b/src/main/java/ca/intelliware/ihtsdo/mlds/repository/ReleaseVersionRepository.java index c613cff03..b56ded70d 100644 --- a/src/main/java/ca/intelliware/ihtsdo/mlds/repository/ReleaseVersionRepository.java +++ b/src/main/java/ca/intelliware/ihtsdo/mlds/repository/ReleaseVersionRepository.java @@ -11,7 +11,7 @@ public interface ReleaseVersionRepository extends JpaRepository { - @Query(value="SELECT CONCAT(release_package.name, '-', release_version.name) AS title, release_file.download_url, member.memberOrgName, member.memberOrgURL, member.contactEmail, release_version.id, release_package.copyrights, release_version.updated_at, release_version.published_at, release_version.summary, release_package.releasePackageURI, release_version.versionURI, release_version.versionDependentURI, release_version.versionDependentDerivativeURI, release_package.release_package_id, release_version.release_version_id, release_file.release_file_id, release_file.primary_file, release_file.md5_hash, release_file.file_size, release_version.package_type FROM release_package JOIN release_version ON release_version.release_package_id = release_package.release_package_id JOIN release_file ON release_file.release_version_id = release_version.release_version_id JOIN member ON member.member_id = release_package.member_id WHERE release_version.release_type = 'online' AND release_package.releasePackageURI <> '' AND release_version.versionURI <> '' ",nativeQuery = true) + @Query(value="SELECT CONCAT(release_package.name, '-', release_version.name) AS title, release_file.download_url, member.member_org_name, member.member_org_url, member.contact_email, release_version.id, release_package.copyrights, release_version.updated_at, release_version.published_at, release_version.summary, release_package.release_package_uri, release_version.version_uri, release_version.version_dependent_uri, release_version.version_dependent_derivative_uri, release_package.release_package_id, release_version.release_version_id, release_file.release_file_id, release_file.primary_file, release_file.md5_hash, release_file.file_size, release_version.package_type FROM release_package JOIN release_version ON release_version.release_package_id = release_package.release_package_id JOIN release_file ON release_file.release_version_id = release_version.release_version_id JOIN member ON member.member_id = release_package.member_id WHERE release_version.release_type = 'online' AND release_package.release_package_uri <> '' AND release_version.version_uri <> '' ",nativeQuery = true) Collection listAtomFeed(); @Query(value = "SELECT CASE WHEN :releaseVersionURI IS NULL OR TRIM(:releaseVersionURI) = '' THEN 0 ELSE CASE WHEN EXISTS (SELECT 1 FROM release_version WHERE (TRIM(versionDependentURI) = TRIM(:releaseVersionURI) OR TRIM(versionDependentDerivativeURI) = TRIM(:releaseVersionURI))) THEN 1 ELSE 0 END END AS is_present", nativeQuery = true) From 6d0b7f4a5b7b46b0466c9dabca596632f96cd44f Mon Sep 17 00:00:00 2001 From: Aravinth Aasaithambi <109154990+AravinthAasaithambi@users.noreply.github.com> Date: Wed, 22 Jan 2025 18:22:50 +0530 Subject: [PATCH 26/58] MLDS-1188 API should return unarchived / archived versions in the "Release Management" and "Archived Release" tabs respectively MLDS-1188 API should return unarchived / archived versions in the "Release Management" and "Archived Release" tabs respectively --- .../web/rest/ReleasePackagesResource.java | 49 ++++++++++++++++--- .../ihtsdo/mlds/web/rest/Routes.java | 1 + 2 files changed, 43 insertions(+), 7 deletions(-) diff --git a/src/main/java/ca/intelliware/ihtsdo/mlds/web/rest/ReleasePackagesResource.java b/src/main/java/ca/intelliware/ihtsdo/mlds/web/rest/ReleasePackagesResource.java index 7aeadde7d..63fb911cb 100644 --- a/src/main/java/ca/intelliware/ihtsdo/mlds/web/rest/ReleasePackagesResource.java +++ b/src/main/java/ca/intelliware/ihtsdo/mlds/web/rest/ReleasePackagesResource.java @@ -76,19 +76,54 @@ public class ReleasePackagesResource { produces = MediaType.APPLICATION_JSON_VALUE) @PermitAll @Timed - public ResponseEntity> getReleasePackages() { - - Collection releasePackages = releasePackageRepository.findAll(); + public ResponseEntity> getReleasePackages() { + List releasePackages = releasePackageRepository.findAll(); releasePackages = filterReleasePackagesByOnline(releasePackages); + List response = releasePackages.stream() + .map(releasePackage -> { + Set notArchivedVersions = releasePackage.getReleaseVersions().stream() + .filter(releaseVersion -> !releaseVersion.isArchive()) + .collect(Collectors.toSet()); + if (!notArchivedVersions.isEmpty()) { + releasePackage.setReleaseVersions(notArchivedVersions); // Keep only archived versions + return releasePackage; // Include this package + } + return null; // Skip this package + }) + .filter(Objects::nonNull) // Exclude null packages + .toList(); + return new ResponseEntity<>(response, HttpStatus.OK); + } + @GetMapping(value = Routes.ARCHIVE_RELEASE_PACKAGES, + produces = MediaType.APPLICATION_JSON_VALUE) + @RolesAllowed(AuthoritiesConstants.ADMIN) + @Timed + public ResponseEntity> getArchiveReleasePackages() { + + List releasePackages = releasePackageRepository.findAll(); - return new ResponseEntity<>(releasePackages, HttpStatus.OK); + releasePackages = filterReleasePackagesByOnline(releasePackages); + List response = releasePackages.stream() + .map(releasePackage -> { + Set archivedVersions = releasePackage.getReleaseVersions().stream() + .filter(ReleaseVersion::isArchive) + .collect(Collectors.toSet()); + if (!archivedVersions.isEmpty()) { + releasePackage.setReleaseVersions(archivedVersions); // Keep only archived versions + return releasePackage; // Include this package + } + return null; // Skip this package + }) + .filter(Objects::nonNull) // Exclude null packages + .toList(); + return new ResponseEntity<>(response, HttpStatus.OK); } - private Collection filterReleasePackagesByOnline( - Collection releasePackages) { + private List filterReleasePackagesByOnline( + List releasePackages) { - Collection result = releasePackages; + List result = releasePackages; if (!authorizationChecker.shouldSeeOfflinePackages()) { result = new ArrayList<>(); diff --git a/src/main/java/ca/intelliware/ihtsdo/mlds/web/rest/Routes.java b/src/main/java/ca/intelliware/ihtsdo/mlds/web/rest/Routes.java index 2a304303e..761bddbc3 100644 --- a/src/main/java/ca/intelliware/ihtsdo/mlds/web/rest/Routes.java +++ b/src/main/java/ca/intelliware/ihtsdo/mlds/web/rest/Routes.java @@ -113,6 +113,7 @@ public class Routes { * - POST to create */ public static final String RELEASE_PACKAGES = "/api/releasePackages"; + public static final String ARCHIVE_RELEASE_PACKAGES = "/api/archiveReleasePackages"; /** * control endpoint for single release package: From cf3a62e0e974cc8198d9418eb38360c9d069126e Mon Sep 17 00:00:00 2001 From: jaykamal Date: Thu, 23 Jan 2025 11:33:41 +0530 Subject: [PATCH 27/58] MLDS-1192 Release File Download Count - exclude all SI users --- .../ihtsdo/mlds/repository/PersistenceAuditEventRepository.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/main/java/ca/intelliware/ihtsdo/mlds/repository/PersistenceAuditEventRepository.java b/src/main/java/ca/intelliware/ihtsdo/mlds/repository/PersistenceAuditEventRepository.java index db9a09589..87d3a331c 100644 --- a/src/main/java/ca/intelliware/ihtsdo/mlds/repository/PersistenceAuditEventRepository.java +++ b/src/main/java/ca/intelliware/ihtsdo/mlds/repository/PersistenceAuditEventRepository.java @@ -30,6 +30,6 @@ public interface PersistenceAuditEventRepository extends JpaRepository findTypeAndEventDate(@Param("start") Instant start, @Param("end") Instant end); - @Query("SELECT ae FROM PersistentAuditEvent ae WHERE ae.auditEventType = 'RELEASE_FILE_DOWNLOADED' AND ae.auditEventDate BETWEEN :start AND :end AND ae.affiliateId IS NOT NULL") + @Query("SELECT ae FROM PersistentAuditEvent ae WHERE ae.auditEventType = 'RELEASE_FILE_DOWNLOADED' AND ae.auditEventDate BETWEEN :start AND :end AND ae.affiliateId IS NOT NULL AND ae.principal NOT LIKE '%@ihtsdo.org' AND ae.principal NOT LIKE '%@snomed.org'") List findTypeAndEventDateWithAffiliateIdNotNull(@Param("start") Instant start, @Param("end") Instant end); } From 111883aa4bf60f5d24629e16793462411df6c0fc Mon Sep 17 00:00:00 2001 From: jaykamal Date: Thu, 23 Jan 2025 12:06:34 +0530 Subject: [PATCH 28/58] MLDS-1189 Release type should not be changed for Archived releases --- .../ihtsdo/mlds/web/rest/ReleaseVersionsResource.java | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/src/main/java/ca/intelliware/ihtsdo/mlds/web/rest/ReleaseVersionsResource.java b/src/main/java/ca/intelliware/ihtsdo/mlds/web/rest/ReleaseVersionsResource.java index 19f982c22..905714349 100644 --- a/src/main/java/ca/intelliware/ihtsdo/mlds/web/rest/ReleaseVersionsResource.java +++ b/src/main/java/ca/intelliware/ihtsdo/mlds/web/rest/ReleaseVersionsResource.java @@ -125,11 +125,12 @@ public class ReleaseVersionsResource { releaseVersion.setName(body.getName()); releaseVersion.setDescription(body.getDescription()); - releaseVersion.setReleaseType(body.getReleaseType()); + if(!releaseVersion.isArchive()) { + releaseVersion.setReleaseType(body.getReleaseType()); + } releaseVersion.setPublishedAt(body.getPublishedAt()); releaseVersion.setSummary(body.getSummary()); - releaseVersion.setReleaseType(body.getReleaseType()); releaseVersion.setVersionDependentURI(body.getVersionDependentURI()); releaseVersion.setVersionDependentDerivativeURI(body.getVersionDependentDerivativeURI()); releaseVersion.setVersionURI(body.getVersionURI()); From fe45c56d161f71a8bfb2a7af025d544656ec4800 Mon Sep 17 00:00:00 2001 From: jaykamal Date: Wed, 19 Feb 2025 18:34:57 +0530 Subject: [PATCH 29/58] MLDS-1163 Unit test method to update member feed url --- .../ihtsdo/mlds/web/rest/MemberResource.java | 5 ++ .../mlds/web/rest/MemberResourceTest.java | 69 ++++++++++++++----- 2 files changed, 57 insertions(+), 17 deletions(-) diff --git a/src/main/java/ca/intelliware/ihtsdo/mlds/web/rest/MemberResource.java b/src/main/java/ca/intelliware/ihtsdo/mlds/web/rest/MemberResource.java index 80e04abcb..27ef71133 100644 --- a/src/main/java/ca/intelliware/ihtsdo/mlds/web/rest/MemberResource.java +++ b/src/main/java/ca/intelliware/ihtsdo/mlds/web/rest/MemberResource.java @@ -216,6 +216,11 @@ public ResponseEntity updateMember(@PathVariable String memberKey, @RequestBo @Timed public ResponseEntity updateMemberFeedURL(@PathVariable String memberKey, @RequestBody MemberDTO body) throws IOException { Member member = memberRepository.findOneByKey(memberKey); + + if (member == null) { + return ResponseEntity.status(HttpStatus.NOT_FOUND).body("Member not found for key: " + memberKey); + } + member.setContactEmail(body.getContactEmail()); member.setMemberOrgURL(body.getMemberOrgURL()); member.setMemberOrgName(body.getMemberOrgName()); diff --git a/src/test/java/ca/intelliware/ihtsdo/mlds/web/rest/MemberResourceTest.java b/src/test/java/ca/intelliware/ihtsdo/mlds/web/rest/MemberResourceTest.java index 2efe508fa..6a315d7cd 100644 --- a/src/test/java/ca/intelliware/ihtsdo/mlds/web/rest/MemberResourceTest.java +++ b/src/test/java/ca/intelliware/ihtsdo/mlds/web/rest/MemberResourceTest.java @@ -30,33 +30,33 @@ public class MemberResourceTest { @Mock private MemberRepository memberRepository; @Mock private FileRepository fileRepository; @Mock private SessionService sessionService; - + private MockMvc restUserMockMvc; - + @Before public void setup() { MockitoAnnotations.initMocks(this); MemberResource memberResource = new MemberResource(); - + memberResource.memberRepository = memberRepository; memberResource.fileRepository = fileRepository; memberResource.sessionService = sessionService; - + this.restUserMockMvc = MockMvcBuilders .standaloneSetup(memberResource) .setMessageConverters(new MockMvcJacksonTestSupport().getConfiguredMessageConverters()) .build(); - + Mockito.when(memberRepository.findAll()).thenReturn(Arrays.asList(new Member("SE", 1), new Member("IHTSDO", 2))); } - + @Test public void getMembersShouldIncludeAllMembers() throws Exception { restUserMockMvc.perform(get(Routes.MEMBERS) .accept(MediaType.APPLICATION_JSON_UTF8)) .andExpect(status().isOk()) .andExpect(content().string(Matchers.allOf(Matchers.containsString("SE"), Matchers.containsString("IHTSDO")))); - + } @Test @@ -66,22 +66,22 @@ public void getMembersShouldReturnPopulatedDTOs() throws Exception { .andExpect(status().isOk()) .andExpect(content().string(Matchers.containsString("\"memberId\":1,\"key\":\"SE\""))); } - + @Test public void postMembersNotificationsShouldUpdateStaffNotifications() throws Exception { Member member = new Member("SE", 1L); Mockito.when(memberRepository.findOneByKey("SE")).thenReturn(member); - + restUserMockMvc.perform(put(Routes.MEMBER_NOTIFICATIONS, "SE") .contentType(MediaType.APPLICATION_JSON_UTF8) .content("{ \"staffNotificationEmail\": \"staff@test.com\" }") .accept(MediaType.APPLICATION_JSON_UTF8)) .andExpect(status().isOk()); - + Mockito.verify(memberRepository).save(member); assertThat(member.getStaffNotificationEmail(), equalTo("staff@test.com")); } - + @Test public void updateMemberShouldFailForUnknownMember() throws Exception { restUserMockMvc.perform(put(Routes.MEMBER, "ZZ") @@ -89,7 +89,7 @@ public void updateMemberShouldFailForUnknownMember() throws Exception { .content("{ \"staffNotificationEmail\": \"new@test.com\" }") .accept(MediaType.APPLICATION_JSON_UTF8)) .andExpect(status().isNotFound()); - + Mockito.verify(memberRepository, never()).save(Mockito.any(Member.class)); } @@ -99,18 +99,53 @@ public void updateMemberShouldUpdateFields() throws Exception { member.setStaffNotificationEmail("old@test.com"); member.setPromotePackages(false); Mockito.when(memberRepository.findOneByKey("SE")).thenReturn(member); - + restUserMockMvc.perform(put(Routes.MEMBER, "SE") .contentType(MediaType.APPLICATION_JSON_UTF8) .content("{ \"promotePackages\": true, \"staffNotificationEmail\": \"new@test.com\" }") .accept(MediaType.APPLICATION_JSON_UTF8)) .andExpect(status().isOk()); - + Mockito.verify(memberRepository).save(member); - + assertThat(member.getStaffNotificationEmail(), equalTo("new@test.com")); assertThat(member.getPromotePackages(), equalTo(true)); } - - + + @Test + public void updateMemberFeedURLShouldUpdateFields() throws Exception { + Member member = new Member("SE", 1L); + member.setContactEmail("oldemail@example.com"); + member.setMemberOrgURL("http://old-url.com"); + member.setMemberOrgName("Old Org"); + Mockito.when(memberRepository.findOneByKey("SE")).thenReturn(member); + + restUserMockMvc.perform(put(Routes.MEMBER_FEED_URL, "SE") + .contentType(MediaType.APPLICATION_JSON_UTF8) + .content( "{ \"contactEmail\": \"newemail@example.com\", " + + "\"memberOrgURL\": \"http://new-url.com\", " + + "\"memberOrgName\": \"New Org\" }") + .accept(MediaType.APPLICATION_JSON_UTF8)) + .andExpect(status().isOk()); + + Mockito.verify(memberRepository).save(member); + assertThat(member.getContactEmail(), equalTo("newemail@example.com")); + assertThat(member.getMemberOrgURL(), equalTo("http://new-url.com")); + assertThat(member.getMemberOrgName(), equalTo("New Org")); + } + + @Test + public void updateMemberFeedURLShouldReturnNotFoundForUnknownMember() throws Exception { + Mockito.when(memberRepository.findOneByKey("ZZ")).thenReturn(null); + + restUserMockMvc.perform(put(Routes.MEMBER_FEED_URL, "ZZ") + .contentType(MediaType.APPLICATION_JSON_UTF8) + .content("{ \"contactEmail\": \"newemail@example.com\", " + + "\"memberOrgURL\": \"http://new-url.com\", " + + "\"memberOrgName\": \"New Org\" }") + .accept(MediaType.APPLICATION_JSON_UTF8)) + .andExpect(status().isNotFound()); + Mockito.verify(memberRepository, never()).save(Mockito.any(Member.class)); + } + } From 4bf89304938161b6dad378b6a3d189cacf15a8aa Mon Sep 17 00:00:00 2001 From: jaykamal Date: Wed, 19 Feb 2025 18:36:45 +0530 Subject: [PATCH 30/58] MLDS-1162 Unit test method to check ihtsdo file --- .../mlds/web/rest/ReleaseFilesResourceTest.java | 17 ++++++++++++----- 1 file changed, 12 insertions(+), 5 deletions(-) diff --git a/src/test/java/ca/intelliware/ihtsdo/mlds/web/rest/ReleaseFilesResourceTest.java b/src/test/java/ca/intelliware/ihtsdo/mlds/web/rest/ReleaseFilesResourceTest.java index 2ac71636a..72346b770 100644 --- a/src/test/java/ca/intelliware/ihtsdo/mlds/web/rest/ReleaseFilesResourceTest.java +++ b/src/test/java/ca/intelliware/ihtsdo/mlds/web/rest/ReleaseFilesResourceTest.java @@ -12,14 +12,11 @@ import org.mockito.Mock; import org.mockito.Mockito; import org.mockito.MockitoAnnotations; -import org.springframework.beans.factory.annotation.Autowired; import org.springframework.http.MediaType; import org.springframework.test.web.servlet.MockMvc; import org.springframework.test.web.servlet.request.MockMvcRequestBuilders; import org.springframework.test.web.servlet.setup.MockMvcBuilders; -import org.springframework.web.util.NestedServletException; -import ca.intelliware.ihtsdo.mlds.domain.Affiliate; import ca.intelliware.ihtsdo.mlds.domain.ReleaseFile; import ca.intelliware.ihtsdo.mlds.domain.ReleaseVersion; import ca.intelliware.ihtsdo.mlds.repository.ReleaseFileRepository; @@ -28,8 +25,7 @@ import ca.intelliware.ihtsdo.mlds.security.ihtsdo.CurrentSecurityContext; import ca.intelliware.ihtsdo.mlds.service.UserMembershipAccessor; -import java.util.ArrayList; -import java.util.List; +import java.io.FileNotFoundException; import java.util.Optional; public class ReleaseFilesResourceTest { @@ -126,4 +122,15 @@ public void downloadReleaseFileShouldFailIfCheckDenied() throws Exception { } } + @Test + public void testCheckIhtsdoFile() throws FileNotFoundException { + long releaseFileId = 1L; + ReleaseFile mockReleaseFile = new ReleaseFile(); + mockReleaseFile.setDownloadUrl("https://ire.published.release.ihtsdo.s3.amazonaws.com/international/SnomedCT_Release_INT_20140131.zip"); + Mockito.when(releaseFileRepository.findById(releaseFileId)) + .thenReturn(Optional.of(mockReleaseFile)); + Boolean result = releaseFilesResource.checkIhtsdoFile(1L, 1L, releaseFileId, null, null); + Assert.assertTrue("The download URL should contain 'ihtsdo'", result); + } + } From fd8da32b6257f9c20a042a3553a41772a3186902 Mon Sep 17 00:00:00 2001 From: jaykamal Date: Wed, 19 Feb 2025 18:38:34 +0530 Subject: [PATCH 31/58] MLDS-1167 Unit test method to find release file audit data --- .../mlds/web/rest/AuditResourceTest.java | 123 +++++++++++++++++- 1 file changed, 117 insertions(+), 6 deletions(-) diff --git a/src/test/java/ca/intelliware/ihtsdo/mlds/web/rest/AuditResourceTest.java b/src/test/java/ca/intelliware/ihtsdo/mlds/web/rest/AuditResourceTest.java index 1c80779f4..e34c8c28e 100644 --- a/src/test/java/ca/intelliware/ihtsdo/mlds/web/rest/AuditResourceTest.java +++ b/src/test/java/ca/intelliware/ihtsdo/mlds/web/rest/AuditResourceTest.java @@ -1,5 +1,7 @@ package ca.intelliware.ihtsdo.mlds.web.rest; +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertTrue; import static org.mockito.Mockito.when; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; import static org.springframework.test.web.servlet.result.MockMvcResultHandlers.print; @@ -8,12 +10,18 @@ import java.text.SimpleDateFormat; import java.time.Instant; -import java.util.Arrays; +import java.time.LocalDate; +import java.time.ZoneId; +import java.util.*; +import ca.intelliware.ihtsdo.mlds.domain.PersistentAuditEvent; +import ca.intelliware.ihtsdo.mlds.repository.PersistenceAuditEventRepository; +import ca.intelliware.ihtsdo.mlds.web.rest.dto.AuditEventRequestDTO; +import ca.intelliware.ihtsdo.mlds.web.rest.dto.ReleaseFileCountDTO; import org.hamcrest.Matchers; -import org.junit.Assert; import org.junit.Before; import org.junit.Test; +import org.mockito.InjectMocks; import org.mockito.Mock; import org.mockito.MockitoAnnotations; import org.springframework.boot.actuate.audit.AuditEvent; @@ -30,11 +38,22 @@ public class AuditResourceTest { @Mock private AuditEventService auditEventService; + @Mock + private PersistenceAuditEventRepository persistenceAuditEventRepository; private MockMvc restUserMockMvc; SecurityContextSetup securityContextSetup = new SecurityContextSetup(); + @InjectMocks + private AuditResource auditEventController; + + private LocalDate startDate; + private LocalDate endDate; + private Instant startInstant; + private Instant endInstant; + private Set expectedUserSet; + @Before public void setup() { MockitoAnnotations.initMocks(this); @@ -44,6 +63,14 @@ public void setup() { auditResource.auditEventService = auditEventService; securityContextSetup.asAdmin(); + startDate = LocalDate.of(2023, 10, 1); + endDate = LocalDate.of(2023, 10, 31); + startInstant = startDate.atStartOfDay().atZone(ZoneId.systemDefault()).toInstant(); + endInstant = endDate.atTime(23, 59, 59).atZone(ZoneId.systemDefault()).toInstant(); + + expectedUserSet = new HashSet<>(); + expectedUserSet.add("user1"); + expectedUserSet.add("user2"); this.restUserMockMvc = MockMvcBuilders .standaloneSetup(auditResource) @@ -112,7 +139,7 @@ public void findByFilterShouldFailForUnparseableDateRange() throws Exception { .andExpect(status().is4xxClientError()) ; } catch (NestedServletException e) { - Assert.assertEquals("Invalid format: \"NOT A DATE\"", e.getCause().getMessage()); + assertEquals("Invalid format: \"NOT A DATE\"", e.getCause().getMessage()); } } @@ -141,7 +168,91 @@ public void findByFilterShouldAllowFilteringByApplication() throws Exception { ; } - private AuditEvent createAuditEvent(String type) { - return new AuditEvent("testUser", type, "value1"); - } + @Test + public void findReleaseFileDownloadAuditData_ShouldReturnCorrectCounts() { + + AuditEventRequestDTO request = createAuditEventRequestDTO(); + request.setExcludeAdminAndStaff(false); + + Instant[] dateRange = new Instant[]{startInstant, endInstant}; + when(auditEventService.getStartEndInstant(request)).thenReturn(dateRange); + + List mockEvents = Arrays.asList( + createPersistentAuditEvent("user1", "FileA", "Version1", "PackageX"), + createPersistentAuditEvent("user2", "FileA", "Version1", "PackageX"), + createPersistentAuditEvent("user3", "FileB", "Version2", "PackageY") + ); + + when(auditEventService.getAuditEvents(false, startInstant, endInstant)).thenReturn(mockEvents); + when(auditEventService.filterDownloadEvents(mockEvents)).thenReturn(mockEvents); + + List response = auditEventController.findReleaseFileDownloadAuditData(request); + assertEquals(2, response.size()); + + assertAuditResponse(response, "FileA", "Version1", 2); + assertAuditResponse(response, "FileB", "Version2", 1); + } + + + @Test + public void findReleaseFileDownloadDataForCsv_ShouldReturnFilteredEvents() { + + AuditEventRequestDTO request = createAuditEventRequestDTO(); + request.setExcludeAdminAndStaff(true); + + Instant[] dateRange = new Instant[]{startInstant, endInstant}; + when(auditEventService.getStartEndInstant(request)).thenReturn(dateRange); + + List mockEvents = Arrays.asList( + createPersistentAuditEvent("user1", "FileA", "Version1", "PackageX"), + createPersistentAuditEvent("user2", "FileB", "Version2", "PackageY") + ); + + when(auditEventService.getAuditEvents(true, startInstant, endInstant)).thenReturn(mockEvents); + when(auditEventService.filterDownloadEvents(mockEvents)).thenReturn(mockEvents); + + List response = auditEventController.findReleaseFileDownloadDataForCsv(request); + + assertEquals(2, response.size()); + assertEquals("user1", response.get(0).getPrincipal()); + assertEquals("user2", response.get(1).getPrincipal()); + } + + + + private AuditEvent createAuditEvent(String type) { + return new AuditEvent("testUser", type, "value1"); + } + + + private AuditEventRequestDTO createAuditEventRequestDTO() { + AuditEventRequestDTO request = new AuditEventRequestDTO(); + request.setStartDate(startDate); + request.setEndDate(endDate); + return request; + } + + private PersistentAuditEvent createPersistentAuditEvent(String principal, String fileLabel, String versionName, String packageName) { + PersistentAuditEvent event = new PersistentAuditEvent(); + event.setPrincipal(principal); + event.setAuditEventType("RELEASE_FILE_DOWNLOADED"); + event.setAuditEventDate(startInstant); + + Map data = new HashMap<>(); + data.put("releaseFile.label", fileLabel); + data.put("releaseVersion.name", versionName); + data.put("releasePackage.name", packageName); + event.setData(data); + + return event; + } + + private void assertAuditResponse(List response, String expectedFileName, String expectedVersionName, int expectedCount) { + assertTrue(response.stream().anyMatch(dto -> + dto.getReleaseFileName().equals(expectedFileName) && + dto.getReleaseVersionName().equals(expectedVersionName) && + dto.getCount() == expectedCount)); + } + + } From a87c36c0c52d488da3e3d44246e12db75577539a Mon Sep 17 00:00:00 2001 From: AravinthAasai <109154990+AravinthAasaithambi@users.noreply.github.com> Date: Mon, 24 Feb 2025 16:32:05 +0530 Subject: [PATCH 32/58] MLDS-1168 Unit test case method to get atom feed --- .../mlds/web/rest/AtomFeedResourceTest.java | 86 +++++++++++++++++++ 1 file changed, 86 insertions(+) create mode 100644 src/test/java/ca/intelliware/ihtsdo/mlds/web/rest/AtomFeedResourceTest.java diff --git a/src/test/java/ca/intelliware/ihtsdo/mlds/web/rest/AtomFeedResourceTest.java b/src/test/java/ca/intelliware/ihtsdo/mlds/web/rest/AtomFeedResourceTest.java new file mode 100644 index 000000000..11b7a9534 --- /dev/null +++ b/src/test/java/ca/intelliware/ihtsdo/mlds/web/rest/AtomFeedResourceTest.java @@ -0,0 +1,86 @@ +package ca.intelliware.ihtsdo.mlds.web.rest; + +import ca.intelliware.ihtsdo.mlds.repository.ReleaseVersionRepository; +import ca.intelliware.ihtsdo.mlds.service.AtomFeedService; +import org.junit.Test; +import org.junit.jupiter.api.BeforeEach; +import org.junit.runner.RunWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.MockitoJUnitRunner; +import org.springframework.beans.factory.annotation.Value; + +import java.text.SimpleDateFormat; +import java.util.*; + +import static org.junit.Assert.assertTrue; +import static org.mockito.Mockito.when; + +@RunWith(MockitoJUnitRunner.class) +public class AtomFeedResourceTest { + @Mock + private ReleaseVersionRepository releaseVersionRepository; + + @InjectMocks + private AtomFeedService atomFeedService; + + @Value("${atom.feed.baseUrl}") + private String feedBaseUrl; + + @Value("${atom.feed.title}") + private String feedTitle; + + @Value("${atom.feed.link}") + private String feedLink; + + @Value("${atom.feed.UUID}") + private String feedUUID; + + @Value("${atom.feed.generator}") + private String feedGenerator; + + @Value("${atom.feed.profile}") + private String feedProfile; + + public SimpleDateFormat dateFormat; + + @BeforeEach + public void setup() { + dateFormat = new SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss'Z'"); + } + + @Test + public void testGenerateAtomFeed() { + List mockAtomFeedData = createMockAtomFeedData(); + when(releaseVersionRepository.listAtomFeed()).thenReturn(mockAtomFeedData); + + String atomFeedXml = atomFeedService.generateAtomFeed(); + + assertAtomFeedContent(atomFeedXml); + } + + private List createMockAtomFeedData() { + List mockAtomFeedData = new ArrayList<>(); + mockAtomFeedData.add(new Object[]{ + "Title", "DownloadURL", "MemberOrgName", "MemberOrgURL", + "ContactEmail", "Id", "Copyrights", "2023-08-01 12:00:00.0", + "2023-07-31 12:00:00.0", "Summary", "ReleasePackageURI", + "VersionURI", "VersionDependentURI", "VersionDependentDerivativeURI", + "PackageId", "VersionId", "FileId", true, "Md5Hash", "FileSize", + "PackageType" + }); + return mockAtomFeedData; + } + + private void assertAtomFeedContent(String atomFeedXml) { + assertTrue(atomFeedXml.contains("")); + assertTrue(atomFeedXml.contains("" + feedTitle + "")); + assertTrue(atomFeedXml.contains("urn:uuid:" + feedUUID + "")); + assertTrue(atomFeedXml.contains("" + feedGenerator + "")); + assertTrue(atomFeedXml.contains("Title")); + assertTrue(atomFeedXml.contains("Summary")); + assertTrue(atomFeedXml.contains("")); + } + +} From 4cf3d70ff4eea73591e90c52a0646ca721d25680 Mon Sep 17 00:00:00 2001 From: AravinthAasai <109154990+AravinthAasaithambi@users.noreply.github.com> Date: Mon, 24 Feb 2025 16:34:14 +0530 Subject: [PATCH 33/58] MLDS-1165 Unit test method to preparing review usage reports --- ...ialUsageResource_GetUsageReports_Test.java | 43 +++++++++++++++---- 1 file changed, 34 insertions(+), 9 deletions(-) diff --git a/src/test/java/ca/intelliware/ihtsdo/mlds/web/rest/CommercialUsageResource_GetUsageReports_Test.java b/src/test/java/ca/intelliware/ihtsdo/mlds/web/rest/CommercialUsageResource_GetUsageReports_Test.java index 05d678ce4..6c90af38d 100644 --- a/src/test/java/ca/intelliware/ihtsdo/mlds/web/rest/CommercialUsageResource_GetUsageReports_Test.java +++ b/src/test/java/ca/intelliware/ihtsdo/mlds/web/rest/CommercialUsageResource_GetUsageReports_Test.java @@ -1,15 +1,17 @@ package ca.intelliware.ihtsdo.mlds.web.rest; +import static org.junit.Assert.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.Mockito.when; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.content; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; import org.junit.Before; -import org.junit.Ignore; import org.junit.Test; import org.junit.runner.RunWith; +import org.mockito.InjectMocks; import org.mockito.Mock; -import org.mockito.Mockito; import org.mockito.junit.MockitoJUnitRunner; import org.springframework.http.MediaType; import org.springframework.test.web.servlet.MockMvc; @@ -26,6 +28,9 @@ import ca.intelliware.ihtsdo.mlds.service.CommercialUsageAuthorizationChecker; import ca.intelliware.ihtsdo.mlds.service.CommercialUsageResetter; +import java.util.Arrays; +import java.util.Collection; +import java.util.List; import java.util.Optional; @RunWith(MockitoJUnitRunner.class) @@ -34,7 +39,7 @@ public class CommercialUsageResource_GetUsageReports_Test { AffiliateRepository affiliateRepository; @Mock - CommercialUsageAuthorizationChecker authorizationChecker; + CommercialUsageAuthorizationChecker authorizationChecker; @Mock CommercialUsageRepository commercialUsageRepository; @@ -45,7 +50,8 @@ public class CommercialUsageResource_GetUsageReports_Test { @Mock CommercialUsageResetter commercialUsageResetter; - CommercialUsageResource commercialUsageResource; + @InjectMocks + CommercialUsageResource commercialUsageResource; private MockMvc restCommercialUsageResource; @@ -65,9 +71,9 @@ public void setup() { .build(); } - @Test - public void getUsageReportsShouldReturn404ForUnknownAffiliate() throws Exception { - Mockito.when(affiliateRepository.findById(999L)).thenReturn(Optional.empty()); + @Test + public void getUsageReportsShouldReturn404ForUnknownAffiliate() throws Exception { + when(affiliateRepository.findById(999L)).thenReturn(Optional.empty()); restCommercialUsageResource.perform(MockMvcRequestBuilders.get(Routes.USAGE_REPORTS, 999L) .accept(MediaType.APPLICATION_JSON_UTF8)) @@ -84,7 +90,7 @@ public void getUsageReportsShouldReturnUsageReportsForAffiliate() throws Excepti CommercialUsageEntry commercialUsageEntry = new CommercialUsageEntry(4L, commercialUsage); commercialUsageEntry.setName("Test Name"); affiliate.addCommercialUsage(commercialUsage); - Mockito.when(affiliateRepository.findById(1L)).thenReturn(Optional.of(affiliate)); + when(affiliateRepository.findById(1L)).thenReturn(Optional.of(affiliate)); restCommercialUsageResource.perform(MockMvcRequestBuilders.get(Routes.USAGE_REPORTS, 1L) .accept(MediaType.APPLICATION_JSON_UTF8)) @@ -95,5 +101,24 @@ public void getUsageReportsShouldReturnUsageReportsForAffiliate() throws Excepti .andExpect(jsonPath("$[0].entries[0].name").value("Test Name")) ; - } + } + @Test + public void testReviewUsageReportCsv() { + Object[] sampleRow1 = { + 1, "MemberKey123", "US", "UK", "2024-01-01", "2024-12-31", "Active", + "2024-06-01", "AgreementTypeA", "John Doe", "Type-Subtype", "OrganizationName1", + "OrganizationType1", 5, 10, "Research", "Completed", "None", 2, 1, 3, 4, 20 + }; + Object[] sampleRow2 = { + 2, "MemberKey456", "CA", "FR", "2024-02-01", "2024-11-30", "Inactive", + "2024-06-10", "AgreementTypeB", "Jane Smith", "Type-Subtype", "OrganizationName2", + "OrganizationType2", 15, 25, "Education", "In Progress", "Training", 4, 3, 2, 1, 50 + }; + List sampleData = Arrays.asList(sampleRow1, sampleRow2); + when(commercialUsageRepository.findUsageReport()).thenReturn(sampleData); + Collection response = commercialUsageResource.reviewUsageReportCsv(); + assertEquals(sampleData.size(), response.size()); + assertTrue(response.containsAll(sampleData), "The response content should match the sample data"); + } + } From 5c60998cb653ad744fcd0f9bad8a1474bca5cc42 Mon Sep 17 00:00:00 2001 From: AravinthAasai <109154990+AravinthAasaithambi@users.noreply.github.com> Date: Mon, 24 Feb 2025 16:35:00 +0530 Subject: [PATCH 34/58] MLDS-1164 Unit test method to check health Status of the application --- .../web/rest/HealthCheckResourceTest.java | 102 ++++++++++++++++++ 1 file changed, 102 insertions(+) create mode 100644 src/test/java/ca/intelliware/ihtsdo/mlds/web/rest/HealthCheckResourceTest.java diff --git a/src/test/java/ca/intelliware/ihtsdo/mlds/web/rest/HealthCheckResourceTest.java b/src/test/java/ca/intelliware/ihtsdo/mlds/web/rest/HealthCheckResourceTest.java new file mode 100644 index 000000000..5316322e2 --- /dev/null +++ b/src/test/java/ca/intelliware/ihtsdo/mlds/web/rest/HealthCheckResourceTest.java @@ -0,0 +1,102 @@ +package ca.intelliware.ihtsdo.mlds.web.rest; + +import ca.intelliware.ihtsdo.mlds.config.metrics.DatabaseHealthCheckIndicator; +import ca.intelliware.ihtsdo.mlds.config.metrics.JavaMailHealthCheckIndicator; +import org.junit.Test; +import org.junit.jupiter.api.BeforeEach; +import org.junit.runner.RunWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.MockitoAnnotations; +import org.mockito.junit.MockitoJUnitRunner; +import org.springframework.boot.actuate.health.Health; +import org.springframework.boot.actuate.health.Status; + +import static junit.framework.TestCase.assertEquals; +import static junit.framework.TestCase.assertTrue; +import static org.mockito.Mockito.when; + +@RunWith(MockitoJUnitRunner.class) +public class HealthCheckResourceTest { + @InjectMocks + private HealthCheckResource healthCheckResource; + + @Mock + private JavaMailHealthCheckIndicator javaMailHealthCheckIndicator; + + @Mock + private DatabaseHealthCheckIndicator databaseHealthCheckIndicator; + + @BeforeEach + public void setUp() { + MockitoAnnotations.openMocks(this); + } + @Test + public void testCheckHealth_BothServicesHealthy() { + // Arrange + when(javaMailHealthCheckIndicator.health()).thenReturn(Health.up().build()); + when(databaseHealthCheckIndicator.health()).thenReturn(Health.up().build()); + + // Act + Health health = healthCheckResource.checkHealth(); + + // Assert + assertEquals(Status.UP, health.getStatus()); + assertTrue(health.getDetails().containsKey("mail")); + assertTrue(health.getDetails().containsKey("database")); + + // Check that the details are also healthy + Health mailHealthDetail = (Health) health.getDetails().get("mail"); + Health databaseHealthDetail = (Health) health.getDetails().get("database"); + + assertEquals(Status.UP, mailHealthDetail.getStatus()); + assertEquals(Status.UP, databaseHealthDetail.getStatus()); + } + + + @Test + public void testCheckHealth_MailServiceUnhealthy() { + // Arrange + when(javaMailHealthCheckIndicator.health()).thenReturn(Health.down().build()); + when(databaseHealthCheckIndicator.health()).thenReturn(Health.up().build()); + + // Act + Health health = healthCheckResource.checkHealth(); + + // Assert + assertEquals(Status.DOWN, health.getStatus()); + assertTrue(health.getDetails().containsKey("mail")); + assertTrue(health.getDetails().containsKey("database")); + } + + @Test + public void testCheckHealth_DatabaseServiceUnhealthy() { + // Arrange + when(javaMailHealthCheckIndicator.health()).thenReturn(Health.up().build()); + when(databaseHealthCheckIndicator.health()).thenReturn(Health.down().build()); + + // Act + Health health = healthCheckResource.checkHealth(); + + // Assert + assertEquals(Status.DOWN, health.getStatus()); + assertTrue(health.getDetails().containsKey("mail")); + assertTrue(health.getDetails().containsKey("database")); + } + + @Test + public void testCheckHealth_BothServicesUnhealthy() { + // Arrange + when(javaMailHealthCheckIndicator.health()).thenReturn(Health.down().build()); + when(databaseHealthCheckIndicator.health()).thenReturn(Health.down().build()); + + // Act + Health health = healthCheckResource.checkHealth(); + + // Assert + assertEquals(Status.DOWN, health.getStatus()); + assertTrue(health.getDetails().containsKey("mail")); + assertTrue(health.getDetails().containsKey("database")); + } + +} From 86aeaa85150cc5bc7b807646890a6a888399d373 Mon Sep 17 00:00:00 2001 From: AravinthAasai <109154990+AravinthAasaithambi@users.noreply.github.com> Date: Mon, 24 Feb 2025 16:35:54 +0530 Subject: [PATCH 35/58] MLDS-1161 Unit test method to check version dependency --- .../web/rest/ReleaseVersionsResourceTest.java | 39 ++++++++++++++++++- 1 file changed, 37 insertions(+), 2 deletions(-) diff --git a/src/test/java/ca/intelliware/ihtsdo/mlds/web/rest/ReleaseVersionsResourceTest.java b/src/test/java/ca/intelliware/ihtsdo/mlds/web/rest/ReleaseVersionsResourceTest.java index 460c96d4c..be01884ee 100644 --- a/src/test/java/ca/intelliware/ihtsdo/mlds/web/rest/ReleaseVersionsResourceTest.java +++ b/src/test/java/ca/intelliware/ihtsdo/mlds/web/rest/ReleaseVersionsResourceTest.java @@ -1,7 +1,6 @@ package ca.intelliware.ihtsdo.mlds.web.rest; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; -import static org.junit.Assert.assertEquals; -import static org.junit.Assert.assertTrue; +import static org.junit.Assert.*; import static org.mockito.ArgumentMatchers.any; import static org.mockito.Mockito.*; import org.springframework.http.HttpStatus; @@ -257,4 +256,40 @@ public void testUpdateArchiveReleaseVersionNotFound() { verify(releaseVersionRepository).findById(1L); verify(authorizationChecker, never()).checkCanEditReleasePackage(any()); } + + @Test + public void testCheckVersionDependency_ReturnsTrue() { + long releaseVersionId = 1L; + ReleaseVersion releaseVersion = new ReleaseVersion(); + releaseVersion.setVersionURI("someVersionURI"); + + when(releaseVersionRepository.findById(releaseVersionId)).thenReturn(Optional.of(releaseVersion)); + when(releaseVersionRepository.checkDependent("someVersionURI")).thenReturn(1L); + + Boolean result = releaseVersionsResource.checkVersionDependency(releaseVersionId); + assertTrue(result); + } + + @Test + public void testCheckVersionDependency_ReturnsFalseWhenNoDependency() { + long releaseVersionId = 1L; + ReleaseVersion releaseVersion = new ReleaseVersion(); + releaseVersion.setVersionURI("someVersionURI"); + + when(releaseVersionRepository.findById(releaseVersionId)).thenReturn(Optional.of(releaseVersion)); + when(releaseVersionRepository.checkDependent("someVersionURI")).thenReturn(0L); + + Boolean result = releaseVersionsResource.checkVersionDependency(releaseVersionId); + assertFalse(result); + } + + @Test + public void testCheckVersionDependency_ReturnsFalseWhenNotFound() { + long releaseVersionId = 1L; + + when(releaseVersionRepository.findById(releaseVersionId)).thenReturn(Optional.empty()); + + Boolean result = releaseVersionsResource.checkVersionDependency(releaseVersionId); + assertFalse(result); + } } From 8cfb4799bfb495d71331257497cf3db0f482d68f Mon Sep 17 00:00:00 2001 From: AravinthAasai <109154990+AravinthAasaithambi@users.noreply.github.com> Date: Thu, 6 Mar 2025 15:25:21 +0530 Subject: [PATCH 36/58] MLDS-1173 Enable admin to change email addresses through the app --- .../ihtsdo/mlds/domain/Affiliate.java | 25 ++- .../AffiliateDetailsRepository.java | 14 +- .../mlds/repository/AffiliateRepository.java | 15 ++ .../repository/ApplicationRepository.java | 5 + .../mlds/repository/MemberRepository.java | 7 + .../ihtsdo/mlds/service/UserService.java | 177 ++++++++++++++++-- .../mlds/web/rest/AffiliateResource.java | 6 +- .../mlds/web/rest/ApplicationResource.java | 8 +- .../web/rest/CommercialUsageResource.java | 5 +- .../ihtsdo/mlds/web/rest/UserResource.java | 55 ++++++ .../rest/dto/AffiliateDetailsResponseDTO.java | 47 +++++ .../liquibase/changelog/db-changelog-002.xml | 11 ++ 12 files changed, 351 insertions(+), 24 deletions(-) create mode 100644 src/main/java/ca/intelliware/ihtsdo/mlds/web/rest/dto/AffiliateDetailsResponseDTO.java diff --git a/src/main/java/ca/intelliware/ihtsdo/mlds/domain/Affiliate.java b/src/main/java/ca/intelliware/ihtsdo/mlds/domain/Affiliate.java index 5ab95d598..e356402fe 100644 --- a/src/main/java/ca/intelliware/ihtsdo/mlds/domain/Affiliate.java +++ b/src/main/java/ca/intelliware/ihtsdo/mlds/domain/Affiliate.java @@ -37,7 +37,7 @@ public class Affiliate extends BaseEntity { @GenericField(searchable = Searchable.YES) Long affiliateId; - Instant created = Instant.now(); + Instant created = Instant.now(); @JsonIgnore @Column(name="inactive_at") @@ -91,7 +91,11 @@ public String getHomeMemberKey() { @Column(name="standing_state") StandingState standingState = StandingState.APPLYING; - public void addCommercialUsage(CommercialUsage newEntryValue) { + @Column(name = "deactivated", nullable = false, columnDefinition = "BOOLEAN DEFAULT FALSE") + private boolean deactivated = false; + + + public void addCommercialUsage(CommercialUsage newEntryValue) { Validate.notNull(newEntryValue.getCommercialUsageId()); if (newEntryValue.affiliate != null) { @@ -123,6 +127,14 @@ public Affiliate() { } + + public Instant getCreated() { + return created; + } + + public void setCreated(Instant created) { + this.created = created; + } //For Tests public Affiliate(Long affiliateId) { this.affiliateId = affiliateId; @@ -211,4 +223,13 @@ public Instant getInactiveAt() { public void setInactiveAt(Instant inactiveAt) { this.inactiveAt = inactiveAt; } + + + public boolean isDeactivated() { + return deactivated; + } + + public void setDeactivated(boolean deactivated) { + this.deactivated = deactivated; + } } diff --git a/src/main/java/ca/intelliware/ihtsdo/mlds/repository/AffiliateDetailsRepository.java b/src/main/java/ca/intelliware/ihtsdo/mlds/repository/AffiliateDetailsRepository.java index 335aa2848..832ff2d22 100644 --- a/src/main/java/ca/intelliware/ihtsdo/mlds/repository/AffiliateDetailsRepository.java +++ b/src/main/java/ca/intelliware/ihtsdo/mlds/repository/AffiliateDetailsRepository.java @@ -1,7 +1,19 @@ package ca.intelliware.ihtsdo.mlds.repository; import ca.intelliware.ihtsdo.mlds.domain.AffiliateDetails; +import jakarta.transaction.Transactional; import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Modifying; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; +import org.springframework.stereotype.Repository; +import java.util.List; + +@Repository public interface AffiliateDetailsRepository extends JpaRepository { -} \ No newline at end of file + + @Query(value = "select * from affiliate_details where email= :email",nativeQuery = true) + List getAllAffiliateDetailsByEmail(String email); + +} diff --git a/src/main/java/ca/intelliware/ihtsdo/mlds/repository/AffiliateRepository.java b/src/main/java/ca/intelliware/ihtsdo/mlds/repository/AffiliateRepository.java index e270c6ddb..ea895be0d 100644 --- a/src/main/java/ca/intelliware/ihtsdo/mlds/repository/AffiliateRepository.java +++ b/src/main/java/ca/intelliware/ihtsdo/mlds/repository/AffiliateRepository.java @@ -1,11 +1,14 @@ package ca.intelliware.ihtsdo.mlds.repository; import ca.intelliware.ihtsdo.mlds.domain.Affiliate; +import ca.intelliware.ihtsdo.mlds.domain.AffiliateDetails; import ca.intelliware.ihtsdo.mlds.domain.Member; import ca.intelliware.ihtsdo.mlds.domain.StandingState; +import jakarta.transaction.Transactional; import org.springframework.data.domain.Page; import org.springframework.data.domain.Pageable; import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Modifying; import org.springframework.data.jpa.repository.Query; import org.springframework.data.repository.query.Param; @@ -87,4 +90,16 @@ public interface AffiliateRepository extends JpaRepository { + "AND a.application.approvalState = ca.intelliware.ihtsdo.mlds.domain.ApprovalState.APPROVED " ) Iterable findByUsersAndStandingStateInAndApprovedHomeMembership(@Param("standingStates") List standingStates, @Param("member") Member member); + + @Query(value = "select * from affiliate where affiliate_details_id = :affiliateDetailsId",nativeQuery = true) + Affiliate getAllAffiliateByAffiliateDetailsId(Long affiliateDetailsId); + + @Query(value = "SELECT * FROM mlds.affiliate where home_member_id=1 and standing_state='PENDING_INVOICE'",nativeQuery = true) + List getIHTSDOPendingInvoices(); + + @Modifying + @Query("UPDATE Affiliate a SET a.deactivated = true WHERE a.affiliateId IN :affiliateIds") + int bulkDeactivateAffiliates(@Param("affiliateIds") List affiliateIds); + + } diff --git a/src/main/java/ca/intelliware/ihtsdo/mlds/repository/ApplicationRepository.java b/src/main/java/ca/intelliware/ihtsdo/mlds/repository/ApplicationRepository.java index 65b3e1668..d38c25c05 100644 --- a/src/main/java/ca/intelliware/ihtsdo/mlds/repository/ApplicationRepository.java +++ b/src/main/java/ca/intelliware/ihtsdo/mlds/repository/ApplicationRepository.java @@ -1,11 +1,13 @@ package ca.intelliware.ihtsdo.mlds.repository; +import ca.intelliware.ihtsdo.mlds.domain.Affiliate; import ca.intelliware.ihtsdo.mlds.domain.Application; import ca.intelliware.ihtsdo.mlds.domain.ApprovalState; import ca.intelliware.ihtsdo.mlds.domain.Member; import org.springframework.data.domain.Page; import org.springframework.data.domain.Pageable; import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; import org.springframework.stereotype.Repository; import java.util.Collection; @@ -24,4 +26,7 @@ public interface ApplicationRepository extends JpaRepository Page findByMember(Member member, Pageable pageable); + @Query(value = "select * from application where member_id = :memberId",nativeQuery = true) + List findMemberById(Long memberId); + } diff --git a/src/main/java/ca/intelliware/ihtsdo/mlds/repository/MemberRepository.java b/src/main/java/ca/intelliware/ihtsdo/mlds/repository/MemberRepository.java index 10a9a10a1..4d2518290 100644 --- a/src/main/java/ca/intelliware/ihtsdo/mlds/repository/MemberRepository.java +++ b/src/main/java/ca/intelliware/ihtsdo/mlds/repository/MemberRepository.java @@ -1,10 +1,17 @@ package ca.intelliware.ihtsdo.mlds.repository; +import ca.intelliware.ihtsdo.mlds.domain.Application; import ca.intelliware.ihtsdo.mlds.domain.Member; import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; import org.springframework.stereotype.Repository; +import java.util.List; + @Repository public interface MemberRepository extends JpaRepository { public Member findOneByKey(String key); + + @Query(value = "select * from member where member_id = :memberId",nativeQuery = true) + Member findMemberById(Long memberId); } diff --git a/src/main/java/ca/intelliware/ihtsdo/mlds/service/UserService.java b/src/main/java/ca/intelliware/ihtsdo/mlds/service/UserService.java index f92361cbf..7336df55f 100644 --- a/src/main/java/ca/intelliware/ihtsdo/mlds/service/UserService.java +++ b/src/main/java/ca/intelliware/ihtsdo/mlds/service/UserService.java @@ -1,14 +1,12 @@ package ca.intelliware.ihtsdo.mlds.service; -import ca.intelliware.ihtsdo.mlds.domain.Authority; -import ca.intelliware.ihtsdo.mlds.domain.PersistentToken; -import ca.intelliware.ihtsdo.mlds.domain.User; -import ca.intelliware.ihtsdo.mlds.repository.AuthorityRepository; -import ca.intelliware.ihtsdo.mlds.repository.PersistentTokenRepository; -import ca.intelliware.ihtsdo.mlds.repository.UserRepository; +import ca.intelliware.ihtsdo.mlds.domain.*; +import ca.intelliware.ihtsdo.mlds.repository.*; import ca.intelliware.ihtsdo.mlds.security.SecurityUtils; import ca.intelliware.ihtsdo.mlds.service.util.RandomUtil; +import ca.intelliware.ihtsdo.mlds.web.rest.dto.AffiliateDetailsResponseDTO; import jakarta.annotation.Resource; +import org.joda.time.DateTime; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.beans.factory.annotation.Autowired; @@ -18,11 +16,13 @@ import org.springframework.transaction.annotation.Transactional; +import java.time.Instant; import java.time.LocalDate; -import java.util.HashSet; -import java.util.List; -import java.util.Optional; -import java.util.Set; +import java.time.temporal.ChronoUnit; +import java.util.*; +import java.util.stream.Collectors; + +import static ca.intelliware.ihtsdo.mlds.domain.ApprovalState.CHANGE_REQUESTED; /** * Service class for managing users. @@ -44,9 +44,18 @@ public class UserService { @Autowired private AuthorityRepository authorityRepository; - + + @Autowired + private MemberRepository memberRepository; + @Resource AutologinService autologinService; + @Autowired + private AffiliateDetailsRepository affiliateDetailsRepository; + @Autowired + private AffiliateRepository affiliateRepository; + @Autowired + private ApplicationRepository applicationRepository; public User activateRegistration(String key) { log.debug("Activating user for activation key {}", key); @@ -58,10 +67,10 @@ public User activateRegistration(String key) { // MLDS-234 Rory requested that activation keys not expire // user.setActivationKey(null); autologinService.loginUser(user); - + log.debug("Activated user: {}", user); } - + return user; } @@ -104,17 +113,17 @@ public void changePassword(String password) { changePassword(currentUser, password); } - protected void changePassword(User user, String password) { - String encryptedPassword = passwordEncoder.encode(password); + protected void changePassword(User user, String password) { + String encryptedPassword = passwordEncoder.encode(password); user.setPassword(encryptedPassword); log.debug("Changed password for User: {}", user); - } + } @Transactional(readOnly = true) public User getUserWithAuthorities() { User currentUser = userRepository.findByLoginIgnoreCase(SecurityUtils.getCurrentLogin()); if (currentUser != null) { - currentUser.getAuthorities().size(); // eagerly load the association + currentUser.getAuthorities().size(); // eagerly load the association } return currentUser; } @@ -153,4 +162,138 @@ public void removeNotActivatedUsers() { userRepository.delete(user); } } + + public AffiliateDetailsResponseDTO getAffiliateDetails(String email, Long affiliateDetailsId) { + // Fetch user details + User user = userRepository.findByLoginIgnoreCase(email); + + // Fetch affiliates by creator email + List affiliates = affiliateRepository.findByCreatorIgnoreCase(email); + + // Return an empty DTO if no affiliates are found + if (affiliates.isEmpty()) { + return new AffiliateDetailsResponseDTO(user, null, Collections.emptyList()); + } + + // Get first affiliate's details safely + Affiliate firstAffiliate = affiliates.get(0); + + return new AffiliateDetailsResponseDTO(user, firstAffiliate.getAffiliateDetails(), affiliates); + } + + + @Transactional + public void updatePrimaryEmail(String login, String updatedEmail) { + User user = userRepository.findByLoginIgnoreCase(login); + if (user == null) { + throw new NoSuchElementException("User not found for login: " + login); + } + + // Check if the updated email already exists in the system + User existingUser = userRepository.findByLoginIgnoreCase(updatedEmail); + if (existingUser != null) { + handleExistingUserConflict(existingUser, updatedEmail); + } + + // Update the current user's email + updateUserEmail(user, updatedEmail); + + // Update associated records + updateRelatedEntities(login, updatedEmail); + } + + /** + * Handles the scenario where the updated email already exists in the system. + */ + private void handleExistingUserConflict(User existingUser, String updatedEmail) { + String modifiedEmail = addOldToEmail(updatedEmail); + + updateAffiliates(updatedEmail, modifiedEmail, true); // Deactivate affiliates + updateAffiliateDetails(updatedEmail, modifiedEmail); + updateApplications(updatedEmail, modifiedEmail); + + // Deactivate and rename existing user + existingUser.setLogin(modifiedEmail); + existingUser.setEmail(modifiedEmail); + existingUser.setActivated(false); + userRepository.save(existingUser); + } + + /** + * Updates the email of the given user. + */ + private void updateUserEmail(User user, String updatedEmail) { + user.setLogin(updatedEmail); + user.setEmail(updatedEmail); + userRepository.save(user); + } + + /** + * Updates all related entities (Affiliates, Applications, and AffiliateDetails). + */ + private void updateRelatedEntities(String oldEmail, String newEmail) { + updateApplications(oldEmail, newEmail); + updateAffiliates(oldEmail, newEmail, false); + updateAffiliateDetails(oldEmail, newEmail); + } + + /** + * Updates the email field in the Application table. + */ + private void updateApplications(String oldEmail, String newEmail) { + List applications = applicationRepository.findByUsernameIgnoreCase(oldEmail); + if (!applications.isEmpty()) { + applications.forEach(app -> app.setUsername(newEmail)); + applicationRepository.saveAll(applications); + } + } + + /** + * Updates the creator field in the Affiliate table. + */ + private void updateAffiliates(String oldEmail, String newEmail, boolean deactivate) { + List affiliates = affiliateRepository.findByCreatorIgnoreCase(oldEmail); + if (!affiliates.isEmpty()) { + affiliates.forEach(affiliate -> { + affiliate.setCreator(newEmail); + if (deactivate) { + affiliate.setDeactivated(true); + } + }); + affiliateRepository.saveAll(affiliates); + } + } + + /** + * Updates the email field in the AffiliateDetails table. + */ + private void updateAffiliateDetails(String oldEmail, String newEmail) { + List affiliateDetailsList = affiliateDetailsRepository.getAllAffiliateDetailsByEmail(oldEmail); + if (!affiliateDetailsList.isEmpty()) { + affiliateDetailsList.forEach(details -> details.setEmail(newEmail)); + affiliateDetailsRepository.saveAll(affiliateDetailsList); + } + } + + /** + * Utility method to modify an email (adds "old" before "@"). + */ + private String addOldToEmail(String email) { + String[] parts = email.split("@"); + if (parts.length == 2) { + return parts[0] + "old@" + parts[1]; + } + throw new IllegalArgumentException("Invalid email format: " + email); + } + + + + } + + + + + + + diff --git a/src/main/java/ca/intelliware/ihtsdo/mlds/web/rest/AffiliateResource.java b/src/main/java/ca/intelliware/ihtsdo/mlds/web/rest/AffiliateResource.java index a886513aa..62df82fdc 100644 --- a/src/main/java/ca/intelliware/ihtsdo/mlds/web/rest/AffiliateResource.java +++ b/src/main/java/ca/intelliware/ihtsdo/mlds/web/rest/AffiliateResource.java @@ -34,6 +34,7 @@ import java.util.*; import java.util.regex.Matcher; import java.util.regex.Pattern; +import java.util.stream.Collectors; @RestController public class AffiliateResource { @@ -150,8 +151,11 @@ public class AffiliateResource { } } } + AffiliateSearchResult result = new AffiliateSearchResult(); - result.setAffiliates(affiliates.getContent()); + + result.setAffiliates(affiliates.getContent().stream().filter(a -> !a.isDeactivated()) // Filtering out deactivated affiliates + .collect(Collectors.toList())); result.setTotalResults(affiliates.getTotalElements()); result.setTotalPages(affiliates.getTotalPages()); diff --git a/src/main/java/ca/intelliware/ihtsdo/mlds/web/rest/ApplicationResource.java b/src/main/java/ca/intelliware/ihtsdo/mlds/web/rest/ApplicationResource.java index 52f0be7ff..23ed34754 100644 --- a/src/main/java/ca/intelliware/ihtsdo/mlds/web/rest/ApplicationResource.java +++ b/src/main/java/ca/intelliware/ihtsdo/mlds/web/rest/ApplicationResource.java @@ -38,7 +38,7 @@ import java.util.*; import java.util.regex.Matcher; import java.util.regex.Pattern; - +import java.util.stream.Collectors; @RestController @@ -213,7 +213,11 @@ private Affiliate findAffiliateByUsername(String username) { applications = applicationRepository.findByApprovalStateIn(approvalStates, pageRequest); } } - return new ResponseEntity(new ApplicationCollection(applications), HttpStatus.OK); + List activeApplications = applications.stream() + .filter(a -> !a.getAffiliate().isDeactivated()) // Filtering out deactivated affiliates + .collect(Collectors.toList()); + + return new ResponseEntity(new ApplicationCollection(activeApplications), HttpStatus.OK); } private static final Map> ORDER_BY_FIELD_MAPPINGS = new HashMap>(); diff --git a/src/main/java/ca/intelliware/ihtsdo/mlds/web/rest/CommercialUsageResource.java b/src/main/java/ca/intelliware/ihtsdo/mlds/web/rest/CommercialUsageResource.java index 996d0ee20..820993f43 100644 --- a/src/main/java/ca/intelliware/ihtsdo/mlds/web/rest/CommercialUsageResource.java +++ b/src/main/java/ca/intelliware/ihtsdo/mlds/web/rest/CommercialUsageResource.java @@ -23,6 +23,7 @@ import org.springframework.web.bind.annotation.*; import java.util.*; +import java.util.stream.Collectors; @RestController @@ -97,7 +98,9 @@ public ResponseEntity getAllUsageReports( usageReports = commercialUsageRepository.searchUsageReports(searchText,UsageReportState.NOT_SUBMITTED, pageRequest); } - return new ResponseEntity<>(new CommercialUsageCollection(usageReports), HttpStatus.OK); + return new ResponseEntity<>(new CommercialUsageCollection(usageReports.stream() + .filter(a -> !a.getAffiliate().isDeactivated()) // Filtering out deactivated affiliates + .collect(Collectors.toList()) ), HttpStatus.OK); } private Sort createUsageReportsSort(String orderby) { diff --git a/src/main/java/ca/intelliware/ihtsdo/mlds/web/rest/UserResource.java b/src/main/java/ca/intelliware/ihtsdo/mlds/web/rest/UserResource.java index 0069555a5..ccf42fd7a 100644 --- a/src/main/java/ca/intelliware/ihtsdo/mlds/web/rest/UserResource.java +++ b/src/main/java/ca/intelliware/ihtsdo/mlds/web/rest/UserResource.java @@ -1,17 +1,31 @@ package ca.intelliware.ihtsdo.mlds.web.rest; +import ca.intelliware.ihtsdo.mlds.domain.Affiliate; +import ca.intelliware.ihtsdo.mlds.domain.AffiliateDetails; +import ca.intelliware.ihtsdo.mlds.domain.Application; import ca.intelliware.ihtsdo.mlds.domain.User; +import ca.intelliware.ihtsdo.mlds.repository.AffiliateDetailsRepository; +import ca.intelliware.ihtsdo.mlds.repository.AffiliateRepository; +import ca.intelliware.ihtsdo.mlds.repository.ApplicationRepository; import ca.intelliware.ihtsdo.mlds.repository.UserRepository; import ca.intelliware.ihtsdo.mlds.security.AuthoritiesConstants; +import ca.intelliware.ihtsdo.mlds.service.UserService; +import ca.intelliware.ihtsdo.mlds.web.rest.dto.AffiliateDetailsResponseDTO; import com.codahale.metrics.annotation.Timed; import jakarta.annotation.security.RolesAllowed; import jakarta.servlet.http.HttpServletResponse; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.*; +import java.util.List; +import java.util.NoSuchElementException; +import java.util.Optional; + /** * REST controller for managing users. */ @@ -24,6 +38,9 @@ public class UserResource { @Autowired private UserRepository userRepository; + @Autowired + private UserService userService; + @RequestMapping(value = "/users", method = RequestMethod.GET, produces = "application/json") @@ -50,4 +67,42 @@ public User getUser(@PathVariable String login, HttpServletResponse response) { } return user; } + + @RequestMapping(value = "/getUserDetails", method = RequestMethod.POST) + @Timed + @RolesAllowed(AuthoritiesConstants.ADMIN) + public ResponseEntity getUserDetails(@RequestParam String login, @RequestParam Long affiliateDetailsId) { + log.debug("REST request to get User : {}", login); + + AffiliateDetailsResponseDTO responseDTO = userService.getAffiliateDetails(login, affiliateDetailsId); + + // If no affiliate details are found, return an empty DTO with HTTP 200 + if (responseDTO.getAffiliateDetails() == null && responseDTO.getAffiliate().isEmpty()) { + return ResponseEntity.ok(responseDTO); + } + + return ResponseEntity.ok(responseDTO); + } + + + @RequestMapping(value = "/updatePrimaryEmail", method = RequestMethod.POST) + @RolesAllowed(AuthoritiesConstants.ADMIN) + @Timed + public ResponseEntity updatePrimaryEmail(@RequestParam String login, @RequestParam String updatedEmail) { + try { + + userService.updatePrimaryEmail(login, updatedEmail); + return ResponseEntity.ok("Primary email updated successfully"); + } catch (NoSuchElementException e) { + return ResponseEntity.status(HttpStatus.NOT_FOUND).body("User or related data not found"); + } catch (Exception e) { + return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).body("An error occurred while updating the email"); + } + } + + + + } + + diff --git a/src/main/java/ca/intelliware/ihtsdo/mlds/web/rest/dto/AffiliateDetailsResponseDTO.java b/src/main/java/ca/intelliware/ihtsdo/mlds/web/rest/dto/AffiliateDetailsResponseDTO.java new file mode 100644 index 000000000..b6c70f220 --- /dev/null +++ b/src/main/java/ca/intelliware/ihtsdo/mlds/web/rest/dto/AffiliateDetailsResponseDTO.java @@ -0,0 +1,47 @@ +package ca.intelliware.ihtsdo.mlds.web.rest.dto; + +import ca.intelliware.ihtsdo.mlds.domain.Affiliate; +import ca.intelliware.ihtsdo.mlds.domain.AffiliateDetails; +import ca.intelliware.ihtsdo.mlds.domain.User; + +import java.util.List; + +public class AffiliateDetailsResponseDTO { + private User user; + private AffiliateDetails affiliateDetails; + private List affiliate; + + // Constructors + public AffiliateDetailsResponseDTO() {} + + public AffiliateDetailsResponseDTO(User user, AffiliateDetails affiliateDetails, List affiliate) { + this.user = user; + this.affiliateDetails = affiliateDetails; + this.affiliate = affiliate; + } + + // Getters and Setters + public User getUser() { + return user; + } + + public void setUser(User user) { + this.user = user; + } + + public AffiliateDetails getAffiliateDetails() { + return affiliateDetails; + } + + public void setAffiliateDetails(AffiliateDetails affiliateDetails) { + this.affiliateDetails = affiliateDetails; + } + + public List getAffiliate() { + return affiliate; + } + + public void setAffiliate(List affiliate) { + this.affiliate = affiliate; + } +} diff --git a/src/main/resources/config/liquibase/changelog/db-changelog-002.xml b/src/main/resources/config/liquibase/changelog/db-changelog-002.xml index fdff1aaf4..923275892 100644 --- a/src/main/resources/config/liquibase/changelog/db-changelog-002.xml +++ b/src/main/resources/config/liquibase/changelog/db-changelog-002.xml @@ -155,6 +155,17 @@ + + Add deactivated column in affiliate table + + + + + + + + + From 0c44f3025418339f6597c1583f7b2a37a36736bf Mon Sep 17 00:00:00 2001 From: AravinthAasai <109154990+AravinthAasaithambi@users.noreply.github.com> Date: Thu, 6 Mar 2025 15:42:05 +0530 Subject: [PATCH 37/58] MLDS-1172 Add the ability to auto decline/deactivate an account if no activity --- .../ihtsdo/mlds/domain/Member.java | 33 ++++ .../ihtsdo/mlds/service/UserService.java | 144 ++++++++++++++++++ .../ihtsdo/mlds/web/rest/MemberResource.java | 38 +++++ .../ihtsdo/mlds/web/rest/Routes.java | 2 + .../ihtsdo/mlds/web/rest/UserResource.java | 12 ++ .../rest/dto/AutoDeactivationMemberDTO.java | 33 ++++ .../liquibase/changelog/db-changelog-002.xml | 16 +- 7 files changed, 277 insertions(+), 1 deletion(-) create mode 100644 src/main/java/ca/intelliware/ihtsdo/mlds/web/rest/dto/AutoDeactivationMemberDTO.java diff --git a/src/main/java/ca/intelliware/ihtsdo/mlds/domain/Member.java b/src/main/java/ca/intelliware/ihtsdo/mlds/domain/Member.java index 8a41acac1..29c7b66ef 100644 --- a/src/main/java/ca/intelliware/ihtsdo/mlds/domain/Member.java +++ b/src/main/java/ca/intelliware/ihtsdo/mlds/domain/Member.java @@ -59,6 +59,39 @@ public class Member extends BaseEntity { @Column(name="promote_packages") private Boolean promotePackages; + @Column(name = "pending_application") + private int pendingApplication; + + @Column(name = "invoices_pending") + private int invoicesPending; + + public int getUsageReports() { + return usageReports; + } + + public void setUsageReports(int usageReports) { + this.usageReports = usageReports; + } + + public int getInvoicesPending() { + return invoicesPending; + } + + public void setInvoicesPending(int invoicesPending) { + this.invoicesPending = invoicesPending; + } + + public int getPendingApplication() { + return pendingApplication; + } + + public void setPendingApplication(int pendingApplication) { + this.pendingApplication = pendingApplication; + } + + @Column(name = "usage_reports") + private int usageReports; + public Member() {} public Member(String key, long memberId) { diff --git a/src/main/java/ca/intelliware/ihtsdo/mlds/service/UserService.java b/src/main/java/ca/intelliware/ihtsdo/mlds/service/UserService.java index 7336df55f..5ae1ac77f 100644 --- a/src/main/java/ca/intelliware/ihtsdo/mlds/service/UserService.java +++ b/src/main/java/ca/intelliware/ihtsdo/mlds/service/UserService.java @@ -287,7 +287,151 @@ private String addOldToEmail(String email) { } + @Scheduled(cron = "0 0 17 * * ?", zone = "Asia/Kolkata") + public void removePendingApplication() { + Logger logger = LoggerFactory.getLogger(getClass()); + Instant currentDate = Instant.now(); + + // Fetch all applications at once (Avoids multiple DB calls) + List applications = applicationRepository.findAll(); + logger.info("Total applications retrieved: {}", applications.size()); + + List filteredAffiliateIds = new ArrayList<>(); + + for (Application application : applications) { + Long memberId = application.getMember().getMemberId(); + Member member = getMemberById(memberId); + + if (member == null) { + logger.warn("Member not found for application ID: {}", application.getApplicationId()); + continue; // Skip if member not found + } + + int pendingApplication = member.getPendingApplication(); + if (pendingApplication == 0) { + logger.info("Skipping application ID {}: PendingApplication is 0", application.getApplicationId()); + continue; // Skip processing if no pending applications + } + + Instant cutoffDate = getCutoffDate(pendingApplication); + Instant completedAt = application.getCompletedAt(); + Instant submittedAt = application.getSubmittedAt(); + ApprovalState approvalState = application.getApprovalState(); + + // Check if the application meets the criteria + if ((approvalState == ApprovalState.CHANGE_REQUESTED || + approvalState == ApprovalState.NOT_SUBMITTED || + approvalState == ApprovalState.REJECTED) && + (completedAt == null ? completedAt.isBefore(cutoffDate) : submittedAt.isBefore(cutoffDate))) { + + filteredAffiliateIds.add(application.getAffiliate().getAffiliateId()); + } + } + + logger.info("Total applications meeting the criteria: {}", filteredAffiliateIds.size()); + + // Deactivate affiliates using reusable method + deactivateAffiliates(filteredAffiliateIds); + } + + + /** + * Scheduled process to deactivate affiliates whose invoices are in a pending state + * beyond the defined period for their respective member country. + * + * - Fetches all affiliates with pending invoices. + * - Retrieves the defined invoice pending period for the member. + * - Identifies affiliates whose invoice pending period has exceeded the cutoff date. + * - Performs a bulk deactivation for the identified affiliates. + * + * This ensures that affiliates who have not cleared their invoices within the allowed timeframe + * are deactivated automatically, maintaining compliance with membership policies. + */ + @Scheduled(cron = "0 0 16 * * ?", zone = "Asia/Kolkata") + public void removeInvoicesPending() { + Logger logger = LoggerFactory.getLogger(getClass()); + + Member member = getMemberById(1L); + if (member.getInvoicesPending() == 0) { + logger.info("Skipping processing: PendingApplication is 0 for Member ID 1"); + return; + } + + Instant cutoffDate = getCutoffDate(member.getInvoicesPending()); + List affiliateIds = getFilteredAffiliateIds(cutoffDate); + + deactivateAffiliates(affiliateIds); + } + + /*Get filtered affiliate IDs for Pending Incoices State For IHTSDO members*/ + private List getFilteredAffiliateIds(Instant cutoffDate) { + List affiliates = affiliateRepository.getIHTSDOPendingInvoices(); + return affiliates.stream() + .filter(affiliate -> affiliate.getStandingState() == StandingState.PENDING_INVOICE + && affiliate.getCreated().isBefore(cutoffDate)) + .map(Affiliate::getAffiliateId) + .collect(Collectors.toList()); + } + + /*Fetch member details using the Member Id*/ + private Member getMemberById(Long memberId) { + return memberRepository.findMemberById(memberId); + } + + /*Reusable Method to Compute cutoff date based on Standing State , Approval State, Usage Reports*/ + private Instant getCutoffDate(int invoicesPending) { + return Instant.now().minus(invoicesPending, ChronoUnit.DAYS); + } + + /*Reusable method for Bulk Deactivate the Affiliates if applicable*/ + private void deactivateAffiliates(List affiliateIds) { + Logger logger = LoggerFactory.getLogger(getClass()); + + if (!affiliateIds.isEmpty()) { + int updatedCount = affiliateRepository.bulkDeactivateAffiliates(affiliateIds); + logger.info("Total affiliates deactivated: {}", updatedCount); + } else { + logger.info("No affiliates met the criteria for deactivation."); + } + } + + @Scheduled(cron = "0 0 15 * * ?", zone = "Asia/Kolkata") + public void removeUsageReports() { + Logger logger = LoggerFactory.getLogger(getClass()); + + List affiliates = affiliateRepository.findAll(); + List affiliateIdsForDeactivation = new ArrayList<>(); + + for (Affiliate affiliate : affiliates) { + Member member = getMemberById(affiliate.getHomeMember().getMemberId()); + // Step 1: Skip processing if Usage Reports for Member is 0 + if (member.getUsageReports() == 0) { + logger.info("Skipping processing: Usage Reports is 0 for Member ID {}", affiliate.getHomeMember().getMemberId()); + continue; + } + // Step 2: Get commercial usage details + Set commercialUsages = affiliate.getCommercialUsages(); + if (commercialUsages == null || commercialUsages.isEmpty()) { + logger.info("Skipping Affiliate ID {}: No CommercialUsage records found.", affiliate.getAffiliateId()); + continue; + } + // Step 3: Compute cutoff date + Instant cutoffDate = getCutoffDate(member.getUsageReports()); + // Step 4: Check conditions on CommercialUsage + for (CommercialUsage usage : commercialUsages) { + if (usage.getState().equals(ApprovalState.NOT_SUBMITTED) && usage.getSubmitted() == null) { + Instant createdDate = usage.getCreated(); + if (createdDate.isBefore(cutoffDate)) { + affiliateIdsForDeactivation.add(affiliate.getAffiliateId()); + break; // No need to check further for this affiliate + } + } + } + } + // Step 5: Bulk update affiliates for deactivation + deactivateAffiliates(affiliateIdsForDeactivation); + } } diff --git a/src/main/java/ca/intelliware/ihtsdo/mlds/web/rest/MemberResource.java b/src/main/java/ca/intelliware/ihtsdo/mlds/web/rest/MemberResource.java index 27ef71133..e285da3f1 100644 --- a/src/main/java/ca/intelliware/ihtsdo/mlds/web/rest/MemberResource.java +++ b/src/main/java/ca/intelliware/ihtsdo/mlds/web/rest/MemberResource.java @@ -7,6 +7,7 @@ import ca.intelliware.ihtsdo.mlds.repository.MemberRepository; import ca.intelliware.ihtsdo.mlds.security.AuthoritiesConstants; import ca.intelliware.ihtsdo.mlds.web.SessionService; +import ca.intelliware.ihtsdo.mlds.web.rest.dto.AutoDeactivationMemberDTO; import ca.intelliware.ihtsdo.mlds.web.rest.dto.MemberDTO; import com.codahale.metrics.annotation.Timed; import jakarta.annotation.Resource; @@ -32,6 +33,7 @@ import java.time.Instant; import java.util.ArrayList; import java.util.List; +import java.util.Map; @RestController @@ -227,5 +229,41 @@ public ResponseEntity updateMemberFeedURL(@PathVariable String memberKey, @Re memberRepository.save(member); return new ResponseEntity(new MemberDTO(member), HttpStatus.OK); } + @RequestMapping(value = Routes.MEMBER_AUTO_DEACTIVATION, method = RequestMethod.GET, produces = "application/json") + @RolesAllowed({AuthoritiesConstants.STAFF, AuthoritiesConstants.ADMIN}) + @Transactional + @Timed + public ResponseEntity getAutoDeactivationMemberDeatils(@PathVariable String memberKey){ + Member member=memberRepository.findOneByKey(memberKey); + AutoDeactivationMemberDTO deactivationMemberDTO=new AutoDeactivationMemberDTO(); + deactivationMemberDTO.setUsageReports(member.getUsageReports()); + deactivationMemberDTO.setPendingApplications(member.getPendingApplication()); + deactivationMemberDTO.setInvoicesPending(member.getInvoicesPending()); + return new ResponseEntity((deactivationMemberDTO), HttpStatus.OK); + } + + @RequestMapping(value = Routes.POST_MEMBER_AUTO_DEACTIVATION, method = RequestMethod.PUT, produces = "application/json") + @RolesAllowed({AuthoritiesConstants.STAFF, AuthoritiesConstants.ADMIN}) + @Transactional + @Timed + public ResponseEntity postAutoDeactivationMemberDetails( + @PathVariable String memberKey, + @RequestBody AutoDeactivationMemberDTO autoDeactivationDTO) { + + Member member = memberRepository.findOneByKey(memberKey); + if (member == null) { + return ResponseEntity.status(HttpStatus.NOT_FOUND).body("Member not found"); + } + + member.setInvoicesPending(autoDeactivationDTO.getInvoicesPending()); + member.setUsageReports(autoDeactivationDTO.getUsageReports()); + member.setPendingApplication(autoDeactivationDTO.getPendingApplications()); + + memberRepository.save(member); + return ResponseEntity.ok(Map.of("message", "Data Saved Successfully").toString()); + + } + + } diff --git a/src/main/java/ca/intelliware/ihtsdo/mlds/web/rest/Routes.java b/src/main/java/ca/intelliware/ihtsdo/mlds/web/rest/Routes.java index 761bddbc3..57072fab2 100644 --- a/src/main/java/ca/intelliware/ihtsdo/mlds/web/rest/Routes.java +++ b/src/main/java/ca/intelliware/ihtsdo/mlds/web/rest/Routes.java @@ -218,6 +218,8 @@ public class Routes { public static final String MEMBER_FEED_URL = "/api/members/{memberKey}/memberFeedUrl"; public static final String FEED_URL = "/api/feed"; + public static final String MEMBER_AUTO_DEACTIVATION = "/api/members/{memberKey}"; + public static final String POST_MEMBER_AUTO_DEACTIVATION = "/api/members/{memberKey}/autoDeactivation"; } diff --git a/src/main/java/ca/intelliware/ihtsdo/mlds/web/rest/UserResource.java b/src/main/java/ca/intelliware/ihtsdo/mlds/web/rest/UserResource.java index ccf42fd7a..6ed26c8d8 100644 --- a/src/main/java/ca/intelliware/ihtsdo/mlds/web/rest/UserResource.java +++ b/src/main/java/ca/intelliware/ihtsdo/mlds/web/rest/UserResource.java @@ -99,7 +99,19 @@ public ResponseEntity updatePrimaryEmail(@RequestParam String login, @Re return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).body("An error occurred while updating the email"); } } + @RequestMapping(value = "/testRun", method = RequestMethod.GET) + @Timed + public ResponseEntity testRun() { + try { + userService.removeInvoicesPending(); + return ResponseEntity.ok("Pendind Applications Removed successfully"); + } catch (NoSuchElementException e) { + return ResponseEntity.status(HttpStatus.NOT_FOUND).body("User or related data not found"); + } catch (Exception e) { + return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).body("An error occurred while removing the Pendind Applications"); + } + } diff --git a/src/main/java/ca/intelliware/ihtsdo/mlds/web/rest/dto/AutoDeactivationMemberDTO.java b/src/main/java/ca/intelliware/ihtsdo/mlds/web/rest/dto/AutoDeactivationMemberDTO.java new file mode 100644 index 000000000..f2747b6a9 --- /dev/null +++ b/src/main/java/ca/intelliware/ihtsdo/mlds/web/rest/dto/AutoDeactivationMemberDTO.java @@ -0,0 +1,33 @@ +package ca.intelliware.ihtsdo.mlds.web.rest.dto; + +public class AutoDeactivationMemberDTO { + + private int pendingApplications; + private int usageReports; + + public int getInvoicesPending() { + return invoicesPending; + } + + public void setInvoicesPending(int invoicesPending) { + this.invoicesPending = invoicesPending; + } + + public int getPendingApplications() { + return pendingApplications; + } + + public void setPendingApplications(int pendinginvoices) { + this.pendingApplications = pendinginvoices; + } + + public int getUsageReports() { + return usageReports; + } + + public void setUsageReports(int usageReports) { + this.usageReports = usageReports; + } + + private int invoicesPending; +} diff --git a/src/main/resources/config/liquibase/changelog/db-changelog-002.xml b/src/main/resources/config/liquibase/changelog/db-changelog-002.xml index 923275892..74978fb65 100644 --- a/src/main/resources/config/liquibase/changelog/db-changelog-002.xml +++ b/src/main/resources/config/liquibase/changelog/db-changelog-002.xml @@ -163,7 +163,21 @@ - + + Re-add pending_application, invoices_pending, and usage_reports columns to member table with default 0 + + + + + + + + + + + + + From 311dcdce26f61bd655e7d92caf6a0bc852a11dd9 Mon Sep 17 00:00:00 2001 From: AravinthAasai <109154990+AravinthAasaithambi@users.noreply.github.com> Date: Tue, 11 Mar 2025 11:07:50 +0530 Subject: [PATCH 38/58] MLDS-1172 Auto decline/deactivate - Added Last processed methods --- .../ihtsdo/mlds/domain/Affiliate.java | 20 +++ .../ihtsdo/mlds/domain/Application.java | 10 ++ .../ihtsdo/mlds/domain/CommercialUsage.java | 10 ++ .../mlds/domain/ReasonForDeactivation.java | 6 + .../mlds/repository/AffiliateRepository.java | 20 ++- .../repository/ApplicationRepository.java | 7 + .../repository/CommercialUsageRepository.java | 8 + .../ihtsdo/mlds/service/UserService.java | 155 ++++++++++++------ .../ihtsdo/mlds/web/rest/UserResource.java | 2 +- .../liquibase/changelog/db-changelog-002.xml | 29 +++- 10 files changed, 207 insertions(+), 60 deletions(-) create mode 100644 src/main/java/ca/intelliware/ihtsdo/mlds/domain/ReasonForDeactivation.java diff --git a/src/main/java/ca/intelliware/ihtsdo/mlds/domain/Affiliate.java b/src/main/java/ca/intelliware/ihtsdo/mlds/domain/Affiliate.java index e356402fe..f30b9d51c 100644 --- a/src/main/java/ca/intelliware/ihtsdo/mlds/domain/Affiliate.java +++ b/src/main/java/ca/intelliware/ihtsdo/mlds/domain/Affiliate.java @@ -94,6 +94,13 @@ public String getHomeMemberKey() { @Column(name = "deactivated", nullable = false, columnDefinition = "BOOLEAN DEFAULT FALSE") private boolean deactivated = false; + @JsonIgnore + @Column(name="last_processed") + private Instant lastProcessed; + + @Enumerated(EnumType.STRING) + @Column(name="reason_for_deactivation") + private ReasonForDeactivation reasonForDeactivation; public void addCommercialUsage(CommercialUsage newEntryValue) { Validate.notNull(newEntryValue.getCommercialUsageId()); @@ -128,6 +135,13 @@ public Affiliate() { } + public ReasonForDeactivation getReasonForDeactivation() { + return reasonForDeactivation; + } + + public void setReasonForDeactivation(ReasonForDeactivation reasonForDeactivation) { + this.reasonForDeactivation = reasonForDeactivation; + } public Instant getCreated() { return created; } @@ -162,7 +176,13 @@ public void setApplication(PrimaryApplication application) { addApplication(application); } } + public Instant getLastProcessed() { + return lastProcessed; + } + public void setLastProcessed(Instant lastProcessed) { + this.lastProcessed = lastProcessed; + } public AffiliateDetails getAffiliateDetails() { return affiliateDetails; } diff --git a/src/main/java/ca/intelliware/ihtsdo/mlds/domain/Application.java b/src/main/java/ca/intelliware/ihtsdo/mlds/domain/Application.java index 3f65903da..274e85dfd 100644 --- a/src/main/java/ca/intelliware/ihtsdo/mlds/domain/Application.java +++ b/src/main/java/ca/intelliware/ihtsdo/mlds/domain/Application.java @@ -79,11 +79,21 @@ public static enum ApplicationType { @Column(name="application_type", insertable=false, updatable=false) String applicationTypeValue; + @JsonIgnore + @Column(name="last_processed") + private Instant lastProcessed; + public Application() { } + public Instant getLastProcessed() { + return lastProcessed; + } + public void setLastProcessed(Instant lastProcessed) { + this.lastProcessed = lastProcessed; + } // For tests public Application(Long applicationId) { this.applicationId = applicationId; diff --git a/src/main/java/ca/intelliware/ihtsdo/mlds/domain/CommercialUsage.java b/src/main/java/ca/intelliware/ihtsdo/mlds/domain/CommercialUsage.java index 507b515fb..99e3cfb4e 100644 --- a/src/main/java/ca/intelliware/ihtsdo/mlds/domain/CommercialUsage.java +++ b/src/main/java/ca/intelliware/ihtsdo/mlds/domain/CommercialUsage.java @@ -73,6 +73,10 @@ public class CommercialUsage extends BaseEntity { @Where(clause = "inactive_at IS NULL") Set countries = new HashSet<>() /*Sets.newHashSet()*/; + @JsonIgnore + @Column(name="last_processed") + private Instant lastProcessed; + public CommercialUsage() { } @@ -82,7 +86,13 @@ public CommercialUsage(Long commercialUsageId, Affiliate affiliate) { this.commercialUsageId = commercialUsageId; this.affiliate = affiliate; } + public Instant getLastProcessed() { + return lastProcessed; + } + public void setLastProcessed(Instant lastProcessed) { + this.lastProcessed = lastProcessed; + } public Long getCommercialUsageId() { return commercialUsageId; } diff --git a/src/main/java/ca/intelliware/ihtsdo/mlds/domain/ReasonForDeactivation.java b/src/main/java/ca/intelliware/ihtsdo/mlds/domain/ReasonForDeactivation.java new file mode 100644 index 000000000..0d4e90c88 --- /dev/null +++ b/src/main/java/ca/intelliware/ihtsdo/mlds/domain/ReasonForDeactivation.java @@ -0,0 +1,6 @@ +package ca.intelliware.ihtsdo.mlds.domain; + +public enum ReasonForDeactivation { + PRIMARYCONTACTEMAIL, + AUTODEACTIVATION; +} diff --git a/src/main/java/ca/intelliware/ihtsdo/mlds/repository/AffiliateRepository.java b/src/main/java/ca/intelliware/ihtsdo/mlds/repository/AffiliateRepository.java index ea895be0d..5cc2edaa7 100644 --- a/src/main/java/ca/intelliware/ihtsdo/mlds/repository/AffiliateRepository.java +++ b/src/main/java/ca/intelliware/ihtsdo/mlds/repository/AffiliateRepository.java @@ -1,9 +1,6 @@ package ca.intelliware.ihtsdo.mlds.repository; -import ca.intelliware.ihtsdo.mlds.domain.Affiliate; -import ca.intelliware.ihtsdo.mlds.domain.AffiliateDetails; -import ca.intelliware.ihtsdo.mlds.domain.Member; -import ca.intelliware.ihtsdo.mlds.domain.StandingState; +import ca.intelliware.ihtsdo.mlds.domain.*; import jakarta.transaction.Transactional; import org.springframework.data.domain.Page; import org.springframework.data.domain.Pageable; @@ -12,6 +9,7 @@ import org.springframework.data.jpa.repository.Query; import org.springframework.data.repository.query.Param; +import java.time.Instant; import java.util.Collection; import java.util.List; @@ -94,12 +92,20 @@ public interface AffiliateRepository extends JpaRepository { @Query(value = "select * from affiliate where affiliate_details_id = :affiliateDetailsId",nativeQuery = true) Affiliate getAllAffiliateByAffiliateDetailsId(Long affiliateDetailsId); - @Query(value = "SELECT * FROM mlds.affiliate where home_member_id=1 and standing_state='PENDING_INVOICE'",nativeQuery = true) + @Query(value = "SELECT * FROM mlds.affiliate where home_member_id=1 and standing_state='PENDING_INVOICE' and last_processed is null",nativeQuery = true) List getIHTSDOPendingInvoices(); + + @Query("SELECT a.id FROM Affiliate a WHERE a.id IN :affiliateIds AND a.deactivated = false") + List findActiveAffiliateIds(@Param("affiliateIds") List affiliateIds); + + @Modifying + @Query("UPDATE Affiliate a SET a.deactivated = true, a.reasonForDeactivation = :reason WHERE a.id = :affiliateId") + int updateAffiliateDeactivationReason(@Param("affiliateId") Long affiliateId, @Param("reason") ReasonForDeactivation reason); + @Modifying - @Query("UPDATE Affiliate a SET a.deactivated = true WHERE a.affiliateId IN :affiliateIds") - int bulkDeactivateAffiliates(@Param("affiliateIds") List affiliateIds); + @Query("UPDATE Affiliate a SET a.lastProcessed = :timestamp WHERE a.id IN :affiliateIds") + void updateLastProcessed(@Param("affiliateIds") List affiliateIds, @Param("timestamp") Instant timestamp); } diff --git a/src/main/java/ca/intelliware/ihtsdo/mlds/repository/ApplicationRepository.java b/src/main/java/ca/intelliware/ihtsdo/mlds/repository/ApplicationRepository.java index d38c25c05..1e3c6c264 100644 --- a/src/main/java/ca/intelliware/ihtsdo/mlds/repository/ApplicationRepository.java +++ b/src/main/java/ca/intelliware/ihtsdo/mlds/repository/ApplicationRepository.java @@ -7,9 +7,12 @@ import org.springframework.data.domain.Page; import org.springframework.data.domain.Pageable; import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Modifying; import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; import org.springframework.stereotype.Repository; +import java.time.Instant; import java.util.Collection; import java.util.List; @@ -29,4 +32,8 @@ public interface ApplicationRepository extends JpaRepository @Query(value = "select * from application where member_id = :memberId",nativeQuery = true) List findMemberById(Long memberId); + @Modifying + @Query("UPDATE Application a SET a.lastProcessed = :timestamp WHERE a.id IN :affiliateIds") + void updateLastProcessed(@Param("affiliateIds") List affiliateIds, @Param("timestamp") Instant timestamp); + } diff --git a/src/main/java/ca/intelliware/ihtsdo/mlds/repository/CommercialUsageRepository.java b/src/main/java/ca/intelliware/ihtsdo/mlds/repository/CommercialUsageRepository.java index 111b6d0bb..b7ea1d1c6 100644 --- a/src/main/java/ca/intelliware/ihtsdo/mlds/repository/CommercialUsageRepository.java +++ b/src/main/java/ca/intelliware/ihtsdo/mlds/repository/CommercialUsageRepository.java @@ -6,9 +6,11 @@ import org.springframework.data.domain.Page; import org.springframework.data.domain.Pageable; import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Modifying; import org.springframework.data.jpa.repository.Query; import org.springframework.data.repository.query.Param; +import java.time.Instant; import java.time.LocalDate; import java.util.*; @@ -41,4 +43,10 @@ public interface CommercialUsageRepository extends JpaRepository searchUsageReports(@Param("searchText") String searchText, @Param("state") UsageReportState state, Pageable pageable); + @Query(value="SELECT * FROM mlds.commercial_usage where state='NOT_SUBMITTED' and inactive_at is null and last_processed is null",nativeQuery = true) + List findByState(); + + @Modifying + @Query("UPDATE CommercialUsage a SET a.lastProcessed = :timestamp WHERE a.id IN :affiliateIds") + void updateLastProcessed(@Param("affiliateIds") List affiliateIds, @Param("timestamp") Instant timestamp); } diff --git a/src/main/java/ca/intelliware/ihtsdo/mlds/service/UserService.java b/src/main/java/ca/intelliware/ihtsdo/mlds/service/UserService.java index 5ae1ac77f..18c560efb 100644 --- a/src/main/java/ca/intelliware/ihtsdo/mlds/service/UserService.java +++ b/src/main/java/ca/intelliware/ihtsdo/mlds/service/UserService.java @@ -18,6 +18,7 @@ import java.time.Instant; import java.time.LocalDate; +import java.time.ZoneId; import java.time.temporal.ChronoUnit; import java.util.*; import java.util.stream.Collectors; @@ -56,6 +57,8 @@ public class UserService { private AffiliateRepository affiliateRepository; @Autowired private ApplicationRepository applicationRepository; + @Autowired + private CommercialUsageRepository commercialUsageRepository; public User activateRegistration(String key) { log.debug("Activating user for activation key {}", key); @@ -166,18 +169,14 @@ public void removeNotActivatedUsers() { public AffiliateDetailsResponseDTO getAffiliateDetails(String email, Long affiliateDetailsId) { // Fetch user details User user = userRepository.findByLoginIgnoreCase(email); - // Fetch affiliates by creator email List affiliates = affiliateRepository.findByCreatorIgnoreCase(email); - // Return an empty DTO if no affiliates are found if (affiliates.isEmpty()) { return new AffiliateDetailsResponseDTO(user, null, Collections.emptyList()); } - // Get first affiliate's details safely Affiliate firstAffiliate = affiliates.get(0); - return new AffiliateDetailsResponseDTO(user, firstAffiliate.getAffiliateDetails(), affiliates); } @@ -188,16 +187,13 @@ public void updatePrimaryEmail(String login, String updatedEmail) { if (user == null) { throw new NoSuchElementException("User not found for login: " + login); } - // Check if the updated email already exists in the system User existingUser = userRepository.findByLoginIgnoreCase(updatedEmail); if (existingUser != null) { handleExistingUserConflict(existingUser, updatedEmail); } - // Update the current user's email updateUserEmail(user, updatedEmail); - // Update associated records updateRelatedEntities(login, updatedEmail); } @@ -207,11 +203,9 @@ public void updatePrimaryEmail(String login, String updatedEmail) { */ private void handleExistingUserConflict(User existingUser, String updatedEmail) { String modifiedEmail = addOldToEmail(updatedEmail); - updateAffiliates(updatedEmail, modifiedEmail, true); // Deactivate affiliates updateAffiliateDetails(updatedEmail, modifiedEmail); updateApplications(updatedEmail, modifiedEmail); - // Deactivate and rename existing user existingUser.setLogin(modifiedEmail); existingUser.setEmail(modifiedEmail); @@ -257,6 +251,8 @@ private void updateAffiliates(String oldEmail, String newEmail, boolean deactiva affiliates.forEach(affiliate -> { affiliate.setCreator(newEmail); if (deactivate) { + affiliate.setReasonForDeactivation(ReasonForDeactivation.PRIMARYCONTACTEMAIL); + affiliate.setLastProcessed(Instant.now()); affiliate.setDeactivated(true); } }); @@ -290,10 +286,10 @@ private String addOldToEmail(String email) { @Scheduled(cron = "0 0 17 * * ?", zone = "Asia/Kolkata") public void removePendingApplication() { Logger logger = LoggerFactory.getLogger(getClass()); - Instant currentDate = Instant.now(); // Fetch all applications at once (Avoids multiple DB calls) - List applications = applicationRepository.findAll(); + List newApplication = applicationRepository.findAll(); + List applications=newApplication.stream().filter(a->a.getLastProcessed().equals(null)).collect(Collectors.toList()); logger.info("Total applications retrieved: {}", applications.size()); List filteredAffiliateIds = new ArrayList<>(); @@ -313,9 +309,9 @@ public void removePendingApplication() { continue; // Skip processing if no pending applications } - Instant cutoffDate = getCutoffDate(pendingApplication); - Instant completedAt = application.getCompletedAt(); - Instant submittedAt = application.getSubmittedAt(); + LocalDate cutoffDate = getCutoffDate(pendingApplication); + LocalDate completedAt = application.getCompletedAt().atZone(ZoneId.systemDefault()).toLocalDate(); + LocalDate submittedAt = application.getSubmittedAt().atZone(ZoneId.systemDefault()).toLocalDate(); ApprovalState approvalState = application.getApprovalState(); // Check if the application meets the criteria @@ -329,7 +325,7 @@ public void removePendingApplication() { } logger.info("Total applications meeting the criteria: {}", filteredAffiliateIds.size()); - + applicationRepository.updateLastProcessed(filteredAffiliateIds,Instant.now()); // Deactivate affiliates using reusable method deactivateAffiliates(filteredAffiliateIds); } @@ -357,21 +353,28 @@ public void removeInvoicesPending() { return; } - Instant cutoffDate = getCutoffDate(member.getInvoicesPending()); + LocalDate cutoffDate = getCutoffDate(member.getInvoicesPending()); List affiliateIds = getFilteredAffiliateIds(cutoffDate); - + updateLastProcessedForAffiliates(affiliateIds); deactivateAffiliates(affiliateIds); } + private void updateLastProcessedForAffiliates(List affiliateIds) { + if (!affiliateIds.isEmpty()) { + affiliateRepository.updateLastProcessed(affiliateIds, Instant.now()); + } + } + /*Get filtered affiliate IDs for Pending Incoices State For IHTSDO members*/ - private List getFilteredAffiliateIds(Instant cutoffDate) { + private List getFilteredAffiliateIds(LocalDate cutoffDate) { List affiliates = affiliateRepository.getIHTSDOPendingInvoices(); return affiliates.stream() .filter(affiliate -> affiliate.getStandingState() == StandingState.PENDING_INVOICE - && affiliate.getCreated().isBefore(cutoffDate)) + && affiliate.getCreated().atZone(ZoneId.systemDefault()).toLocalDate().isBefore(cutoffDate)) .map(Affiliate::getAffiliateId) .collect(Collectors.toList()); + } /*Fetch member details using the Member Id*/ @@ -380,59 +383,113 @@ private Member getMemberById(Long memberId) { } /*Reusable Method to Compute cutoff date based on Standing State , Approval State, Usage Reports*/ - private Instant getCutoffDate(int invoicesPending) { - return Instant.now().minus(invoicesPending, ChronoUnit.DAYS); + private LocalDate getCutoffDate(int invoicesPending) { + return LocalDate.now().minus(invoicesPending, ChronoUnit.DAYS); } - /*Reusable method for Bulk Deactivate the Affiliates if applicable*/ - private void deactivateAffiliates(List affiliateIds) { + /* Reusable method for Bulk Deactivate the Affiliates if applicable */ + private void deactivateAffiliates(List affiliateid) { Logger logger = LoggerFactory.getLogger(getClass()); - if (!affiliateIds.isEmpty()) { - int updatedCount = affiliateRepository.bulkDeactivateAffiliates(affiliateIds); - logger.info("Total affiliates deactivated: {}", updatedCount); + if (!affiliateid.isEmpty()) { + // Fetch only active affiliates from the provided IDs + List activeAffiliateIds = affiliateRepository.findActiveAffiliateIds(new ArrayList<>(affiliateid)); + + if (!activeAffiliateIds.isEmpty()) { + int updatedCount = 0; + for (Long affiliateId : activeAffiliateIds) { +// ReasonForDeactivation reason = ReasonForDeactivation.AUTODEACTIVATION; + updatedCount = affiliateRepository.updateAffiliateDeactivationReason(affiliateId, ReasonForDeactivation.AUTODEACTIVATION); + + } + logger.info("Total affiliates deactivated: {}", updatedCount); + } else { + logger.info("No active affiliates found for deactivation."); + } } else { - logger.info("No affiliates met the criteria for deactivation."); + logger.info("No affiliates provided for deactivation."); } } + + @Scheduled(cron = "0 0 15 * * ?", zone = "Asia/Kolkata") public void removeUsageReports() { Logger logger = LoggerFactory.getLogger(getClass()); - List affiliates = affiliateRepository.findAll(); + // Step 1: Fetch CommercialUsage records where state = 'NOT_SUBMITTED' + List commercialUsages = commercialUsageRepository.findByState(); + if (commercialUsages.isEmpty()) { + logger.info("No CommercialUsage records found with state 'NOT_SUBMITTED'."); + return; + } + List affiliateIdsForDeactivation = new ArrayList<>(); - for (Affiliate affiliate : affiliates) { - Member member = getMemberById(affiliate.getHomeMember().getMemberId()); - // Step 1: Skip processing if Usage Reports for Member is 0 + // Step 2: Iterate over commercial usage records + for (CommercialUsage usage : commercialUsages) { + if (usage.getAffiliate()== null) { + logger.warn("CommercialUsage ID {} has no associated Affiliate.", usage.getCommercialUsageId()); + continue; + } + + Long affiliateId = usage.getAffiliate().getAffiliateId(); + if (affiliateId == null) { + logger.warn("Affiliate ID is null for CommercialUsage ID {}", usage.getCommercialUsageId()); + continue; + } + + // Step 3: Fetch Affiliate details + Affiliate affiliate = affiliateRepository.findById(affiliateId).orElse(null); + if (affiliate == null || affiliate.getHomeMember() == null) { + logger.warn("Affiliate or HomeMember is null for Affiliate ID {}", affiliateId); + continue; + } + + Long homeMemberId = affiliate.getHomeMember().getMemberId(); + + // Step 4: Fetch Member details using homeMemberId + Member member = getMemberById(homeMemberId); + if (member == null) { + logger.warn("Member not found for ID: {}", homeMemberId); + continue; + } + + // Step 5: Compute cutoff date if (member.getUsageReports() == 0) { - logger.info("Skipping processing: Usage Reports is 0 for Member ID {}", affiliate.getHomeMember().getMemberId()); + logger.info("Skipping processing: Usage Reports is 0 for Member ID {}", homeMemberId); continue; } - // Step 2: Get commercial usage details - Set commercialUsages = affiliate.getCommercialUsages(); - if (commercialUsages == null || commercialUsages.isEmpty()) { - logger.info("Skipping Affiliate ID {}: No CommercialUsage records found.", affiliate.getAffiliateId()); + LocalDate cutoffDate = getCutoffDate(member.getUsageReports()); + + // Step 6: Compare CommercialUsage created date with cutoff date + if (usage.getCreated() == null) { + logger.warn("Skipping CommercialUsage ID {}: Created date is null.", usage.getCommercialUsageId()); continue; } - // Step 3: Compute cutoff date - Instant cutoffDate = getCutoffDate(member.getUsageReports()); - // Step 4: Check conditions on CommercialUsage - for (CommercialUsage usage : commercialUsages) { - if (usage.getState().equals(ApprovalState.NOT_SUBMITTED) && usage.getSubmitted() == null) { - Instant createdDate = usage.getCreated(); - if (createdDate.isBefore(cutoffDate)) { - affiliateIdsForDeactivation.add(affiliate.getAffiliateId()); - break; // No need to check further for this affiliate - } - } + + LocalDate createdDate = usage.getCreated().atZone(ZoneId.systemDefault()).toLocalDate(); + if (createdDate.isBefore(cutoffDate)) { + affiliateIdsForDeactivation.add(affiliateId); + usage.setLastProcessed(Instant.now()); +// affiliate.setLastProcessed(Instant.now()); + + // ✅ Save updates + commercialUsageRepository.save(usage); } } - // Step 5: Bulk update affiliates for deactivation - deactivateAffiliates(affiliateIdsForDeactivation); + + // Step 7: Bulk deactivate affiliates + if (!affiliateIdsForDeactivation.isEmpty()) { + deactivateAffiliates(affiliateIdsForDeactivation); + logger.info("Deactivated {} affiliates", affiliateIdsForDeactivation.size()); + } else { + logger.info("No affiliates found for deactivation."); + } } + + } diff --git a/src/main/java/ca/intelliware/ihtsdo/mlds/web/rest/UserResource.java b/src/main/java/ca/intelliware/ihtsdo/mlds/web/rest/UserResource.java index 6ed26c8d8..32ddc6ba9 100644 --- a/src/main/java/ca/intelliware/ihtsdo/mlds/web/rest/UserResource.java +++ b/src/main/java/ca/intelliware/ihtsdo/mlds/web/rest/UserResource.java @@ -104,7 +104,7 @@ public ResponseEntity updatePrimaryEmail(@RequestParam String login, @Re public ResponseEntity testRun() { try { - userService.removeInvoicesPending(); + userService.removeUsageReports(); return ResponseEntity.ok("Pendind Applications Removed successfully"); } catch (NoSuchElementException e) { return ResponseEntity.status(HttpStatus.NOT_FOUND).body("User or related data not found"); diff --git a/src/main/resources/config/liquibase/changelog/db-changelog-002.xml b/src/main/resources/config/liquibase/changelog/db-changelog-002.xml index 74978fb65..903733306 100644 --- a/src/main/resources/config/liquibase/changelog/db-changelog-002.xml +++ b/src/main/resources/config/liquibase/changelog/db-changelog-002.xml @@ -168,13 +168,36 @@ - + - + - + + + + + Add last_processed timestamp column to affiliate, commercial_usage, and application tables with default NULL + + + + + + + + + + + + + + + + + + + From f31158ad86467a04035010128dac7b73cbfb36ea Mon Sep 17 00:00:00 2001 From: jaykamal Date: Mon, 17 Mar 2025 14:18:03 +0530 Subject: [PATCH 39/58] MLDS-1172 Fixes to Auto Deactivation - Remove pending application last processed value --- .../repository/ApplicationRepository.java | 7 ++- .../ihtsdo/mlds/service/UserService.java | 43 +++++++++++-------- 2 files changed, 30 insertions(+), 20 deletions(-) diff --git a/src/main/java/ca/intelliware/ihtsdo/mlds/repository/ApplicationRepository.java b/src/main/java/ca/intelliware/ihtsdo/mlds/repository/ApplicationRepository.java index 1e3c6c264..1230386a5 100644 --- a/src/main/java/ca/intelliware/ihtsdo/mlds/repository/ApplicationRepository.java +++ b/src/main/java/ca/intelliware/ihtsdo/mlds/repository/ApplicationRepository.java @@ -33,7 +33,12 @@ public interface ApplicationRepository extends JpaRepository List findMemberById(Long memberId); @Modifying - @Query("UPDATE Application a SET a.lastProcessed = :timestamp WHERE a.id IN :affiliateIds") + @Query("UPDATE Application a SET a.lastProcessed = :timestamp WHERE a.affiliate.affiliateId IN :affiliateIds") void updateLastProcessed(@Param("affiliateIds") List affiliateIds, @Param("timestamp") Instant timestamp); + @Query(value = "SELECT * FROM mlds.application WHERE (approval_state = 'REJECTED' OR approval_state = 'CHANGE_REQUESTED' OR approval_state = 'NOT_SUBMITTED') AND inactive_at IS NULL AND last_processed IS NULL",nativeQuery = true) + List getAllApplication(); + + + } diff --git a/src/main/java/ca/intelliware/ihtsdo/mlds/service/UserService.java b/src/main/java/ca/intelliware/ihtsdo/mlds/service/UserService.java index 18c560efb..467802cff 100644 --- a/src/main/java/ca/intelliware/ihtsdo/mlds/service/UserService.java +++ b/src/main/java/ca/intelliware/ihtsdo/mlds/service/UserService.java @@ -287,9 +287,8 @@ private String addOldToEmail(String email) { public void removePendingApplication() { Logger logger = LoggerFactory.getLogger(getClass()); - // Fetch all applications at once (Avoids multiple DB calls) - List newApplication = applicationRepository.findAll(); - List applications=newApplication.stream().filter(a->a.getLastProcessed().equals(null)).collect(Collectors.toList()); + // Fetch all applications meeting the approval state conditions & lastProcessed is null + List applications = applicationRepository.getAllApplication(); logger.info("Total applications retrieved: {}", applications.size()); List filteredAffiliateIds = new ArrayList<>(); @@ -300,34 +299,40 @@ public void removePendingApplication() { if (member == null) { logger.warn("Member not found for application ID: {}", application.getApplicationId()); - continue; // Skip if member not found + continue; } int pendingApplication = member.getPendingApplication(); if (pendingApplication == 0) { logger.info("Skipping application ID {}: PendingApplication is 0", application.getApplicationId()); - continue; // Skip processing if no pending applications + continue; } LocalDate cutoffDate = getCutoffDate(pendingApplication); - LocalDate completedAt = application.getCompletedAt().atZone(ZoneId.systemDefault()).toLocalDate(); - LocalDate submittedAt = application.getSubmittedAt().atZone(ZoneId.systemDefault()).toLocalDate(); - ApprovalState approvalState = application.getApprovalState(); - - // Check if the application meets the criteria - if ((approvalState == ApprovalState.CHANGE_REQUESTED || - approvalState == ApprovalState.NOT_SUBMITTED || - approvalState == ApprovalState.REJECTED) && - (completedAt == null ? completedAt.isBefore(cutoffDate) : submittedAt.isBefore(cutoffDate))) { - - filteredAffiliateIds.add(application.getAffiliate().getAffiliateId()); + LocalDate submittedAt = application.getSubmittedAt() != null + ? application.getSubmittedAt().atZone(ZoneId.systemDefault()).toLocalDate() + : null; + LocalDate completedAt = application.getCompletedAt() != null + ? application.getCompletedAt().atZone(ZoneId.systemDefault()).toLocalDate() + : null; + + if (application.getAffiliate() != null) { + if ((completedAt != null && completedAt.isBefore(cutoffDate)) || + (submittedAt != null && submittedAt.isBefore(cutoffDate))) { + + filteredAffiliateIds.add(application.getAffiliate().getAffiliateId()); + } + } else { + logger.warn("Affiliate is null for application ID: {}", application.getApplicationId()); } } logger.info("Total applications meeting the criteria: {}", filteredAffiliateIds.size()); - applicationRepository.updateLastProcessed(filteredAffiliateIds,Instant.now()); - // Deactivate affiliates using reusable method - deactivateAffiliates(filteredAffiliateIds); + + if (!filteredAffiliateIds.isEmpty()) { + applicationRepository.updateLastProcessed(filteredAffiliateIds, Instant.now()); + deactivateAffiliates(filteredAffiliateIds); + } } From f9037833be35a6e7ffca1d1848adc864c89e6ba4 Mon Sep 17 00:00:00 2001 From: jaykamal Date: Tue, 15 Apr 2025 19:32:22 +0530 Subject: [PATCH 40/58] MLDS-1195 Include the list of dependent packages in the "Confirm Deletion" warning modal --- .../repository/ReleaseVersionRepository.java | 7 ++-- .../web/rest/ReleaseVersionsResource.java | 18 ++++++++++ .../ihtsdo/mlds/web/rest/Routes.java | 2 ++ .../rest/dto/ReleaseVersionCheckViewDTO.java | 6 ++++ .../web/rest/ReleaseVersionsResourceTest.java | 35 +++++++++++++++++++ 5 files changed, 66 insertions(+), 2 deletions(-) create mode 100644 src/main/java/ca/intelliware/ihtsdo/mlds/web/rest/dto/ReleaseVersionCheckViewDTO.java diff --git a/src/main/java/ca/intelliware/ihtsdo/mlds/repository/ReleaseVersionRepository.java b/src/main/java/ca/intelliware/ihtsdo/mlds/repository/ReleaseVersionRepository.java index b56ded70d..a5ed03a22 100644 --- a/src/main/java/ca/intelliware/ihtsdo/mlds/repository/ReleaseVersionRepository.java +++ b/src/main/java/ca/intelliware/ihtsdo/mlds/repository/ReleaseVersionRepository.java @@ -2,11 +2,13 @@ import ca.intelliware.ihtsdo.mlds.domain.ReleaseVersion; +import ca.intelliware.ihtsdo.mlds.web.rest.dto.ReleaseVersionCheckViewDTO; import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.data.jpa.repository.Query; import org.springframework.data.repository.query.Param; import java.util.Collection; +import java.util.List; public interface ReleaseVersionRepository extends JpaRepository { @@ -14,9 +16,10 @@ public interface ReleaseVersionRepository extends JpaRepository listAtomFeed(); - @Query(value = "SELECT CASE WHEN :releaseVersionURI IS NULL OR TRIM(:releaseVersionURI) = '' THEN 0 ELSE CASE WHEN EXISTS (SELECT 1 FROM release_version WHERE (TRIM(versionDependentURI) = TRIM(:releaseVersionURI) OR TRIM(versionDependentDerivativeURI) = TRIM(:releaseVersionURI))) THEN 1 ELSE 0 END END AS is_present", nativeQuery = true) + @Query(value = "SELECT CASE WHEN :releaseVersionURI IS NULL OR TRIM(:releaseVersionURI) = '' THEN 0 ELSE CASE WHEN EXISTS (SELECT 1 FROM release_version rv JOIN release_package rp ON rv.release_package_id = rp.release_package_id WHERE (TRIM(rv.versionDependentURI) = TRIM(:releaseVersionURI) OR TRIM(rv.versionDependentDerivativeURI) = TRIM(:releaseVersionURI)) AND rp.inactive_at IS NULL) THEN 1 ELSE 0 END END AS is_present;", nativeQuery = true) Long checkDependent(@Param("releaseVersionURI") String releaseVersionURI); - + @Query(value = "SELECT rv.name AS releaseVersionName, rv.release_package_id AS releasePackageId, rp.inactive_at AS inactiveAt FROM release_version rv JOIN release_package rp ON rv.release_package_id = rp.release_package_id WHERE rv.versionDependentURI = :releaseVersionURI AND rp.inactive_at IS NULL", nativeQuery = true) + List getDependentVersionNames(@Param("releaseVersionURI") String releaseVersionURI); } diff --git a/src/main/java/ca/intelliware/ihtsdo/mlds/web/rest/ReleaseVersionsResource.java b/src/main/java/ca/intelliware/ihtsdo/mlds/web/rest/ReleaseVersionsResource.java index 905714349..758587601 100644 --- a/src/main/java/ca/intelliware/ihtsdo/mlds/web/rest/ReleaseVersionsResource.java +++ b/src/main/java/ca/intelliware/ihtsdo/mlds/web/rest/ReleaseVersionsResource.java @@ -6,6 +6,7 @@ import ca.intelliware.ihtsdo.mlds.repository.ReleaseVersionRepository; import ca.intelliware.ihtsdo.mlds.security.AuthoritiesConstants; import ca.intelliware.ihtsdo.mlds.security.ihtsdo.CurrentSecurityContext; +import ca.intelliware.ihtsdo.mlds.web.rest.dto.ReleaseVersionCheckViewDTO; import com.codahale.metrics.annotation.Timed; import com.google.common.base.Objects; import jakarta.annotation.Resource; @@ -19,8 +20,11 @@ import java.time.Instant; +import java.util.ArrayList; +import java.util.List; import java.util.Optional; import java.util.UUID; +import java.util.stream.Collectors; @RestController public class ReleaseVersionsResource { @@ -234,4 +238,18 @@ public ResponseEntity updateArchive(@PathVariable long releaseVe return new ResponseEntity<>(releaseVersion, HttpStatus.OK); } + + @GetMapping(value = Routes.RELEASE_VERSION_DEPENDENCY_NAMES, + produces = MediaType.APPLICATION_JSON_VALUE) + @Transactional + @RolesAllowed({AuthoritiesConstants.STAFF, AuthoritiesConstants.ADMIN}) + @Timed + public List getDependentVersionsNames(@PathVariable long releaseVersionId) { + ReleaseVersion releaseVersion = releaseVersionRepository.findById(releaseVersionId).orElse(null); + if (releaseVersion == null) { + return new ArrayList<>(); + } + return releaseVersionRepository.getDependentVersionNames(releaseVersion.getVersionURI()); + } + } diff --git a/src/main/java/ca/intelliware/ihtsdo/mlds/web/rest/Routes.java b/src/main/java/ca/intelliware/ihtsdo/mlds/web/rest/Routes.java index 57072fab2..acf344966 100644 --- a/src/main/java/ca/intelliware/ihtsdo/mlds/web/rest/Routes.java +++ b/src/main/java/ca/intelliware/ihtsdo/mlds/web/rest/Routes.java @@ -221,5 +221,7 @@ public class Routes { public static final String MEMBER_AUTO_DEACTIVATION = "/api/members/{memberKey}"; public static final String POST_MEMBER_AUTO_DEACTIVATION = "/api/members/{memberKey}/autoDeactivation"; + static final String RELEASE_VERSION_DEPENDENCY_NAMES = "/api/getVersionDependencyNames/{releaseVersionId}"; + } diff --git a/src/main/java/ca/intelliware/ihtsdo/mlds/web/rest/dto/ReleaseVersionCheckViewDTO.java b/src/main/java/ca/intelliware/ihtsdo/mlds/web/rest/dto/ReleaseVersionCheckViewDTO.java new file mode 100644 index 000000000..665cd3d19 --- /dev/null +++ b/src/main/java/ca/intelliware/ihtsdo/mlds/web/rest/dto/ReleaseVersionCheckViewDTO.java @@ -0,0 +1,6 @@ +package ca.intelliware.ihtsdo.mlds.web.rest.dto; + +public interface ReleaseVersionCheckViewDTO { + String getReleaseVersionName(); + Long getReleasePackageId(); +} diff --git a/src/test/java/ca/intelliware/ihtsdo/mlds/web/rest/ReleaseVersionsResourceTest.java b/src/test/java/ca/intelliware/ihtsdo/mlds/web/rest/ReleaseVersionsResourceTest.java index be01884ee..9d98e4ffa 100644 --- a/src/test/java/ca/intelliware/ihtsdo/mlds/web/rest/ReleaseVersionsResourceTest.java +++ b/src/test/java/ca/intelliware/ihtsdo/mlds/web/rest/ReleaseVersionsResourceTest.java @@ -1,8 +1,11 @@ package ca.intelliware.ihtsdo.mlds.web.rest; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; import static org.junit.Assert.*; import static org.mockito.ArgumentMatchers.any; import static org.mockito.Mockito.*; + +import ca.intelliware.ihtsdo.mlds.web.rest.dto.ReleaseVersionCheckViewDTO; import org.springframework.http.HttpStatus; import org.springframework.http.ResponseEntity; import org.hamcrest.Matchers; @@ -26,6 +29,8 @@ import ca.intelliware.ihtsdo.mlds.repository.ReleaseVersionRepository; import ca.intelliware.ihtsdo.mlds.security.ihtsdo.CurrentSecurityContext; +import java.util.Arrays; +import java.util.List; import java.util.Optional; public class ReleaseVersionsResourceTest { @@ -270,6 +275,36 @@ public void testCheckVersionDependency_ReturnsTrue() { assertTrue(result); } + @Test + public void shouldReturnDependentVersionsNames() { + long releaseVersionId = 1L; + String versionURI = "someVersionURI"; + + ReleaseVersion releaseVersion = new ReleaseVersion(); + releaseVersion.setVersionURI(versionURI); + + ReleaseVersionCheckViewDTO version1 = Mockito.mock(ReleaseVersionCheckViewDTO.class); + when(version1.getReleaseVersionName()).thenReturn("Version 1"); + when(version1.getReleasePackageId()).thenReturn(100L); + + ReleaseVersionCheckViewDTO version2 = Mockito.mock(ReleaseVersionCheckViewDTO.class); + when(version2.getReleaseVersionName()).thenReturn("Version 2"); + when(version2.getReleasePackageId()).thenReturn(101L); + + when(releaseVersionRepository.findById(releaseVersionId)).thenReturn(Optional.of(releaseVersion)); + when(releaseVersionRepository.getDependentVersionNames(versionURI)).thenReturn(Arrays.asList(version1, version2)); + + List result = releaseVersionsResource.getDependentVersionsNames(releaseVersionId); + + assertNotNull(result); + assertEquals(2, result.size()); + assertEquals("Version 1", result.get(0).getReleaseVersionName()); + assertEquals("Version 2", result.get(1).getReleaseVersionName()); + } + + + + @Test public void testCheckVersionDependency_ReturnsFalseWhenNoDependency() { long releaseVersionId = 1L; From 05d1da6507ed1fa4b7e132f94f003284e6765ec6 Mon Sep 17 00:00:00 2001 From: jaykamal Date: Tue, 15 Apr 2025 20:05:12 +0530 Subject: [PATCH 41/58] MLDS-1205 Unit test method to deactivate user --- .../ihtsdo/mlds/service/UserService.java | 4 +- .../ihtsdo/mlds/web/rest/UserResource.java | 4 +- .../ihtsdo/mlds/service/UserServiceTest.java | 170 ++++++++++++++++++ 3 files changed, 174 insertions(+), 4 deletions(-) create mode 100644 src/test/java/ca/intelliware/ihtsdo/mlds/service/UserServiceTest.java diff --git a/src/main/java/ca/intelliware/ihtsdo/mlds/service/UserService.java b/src/main/java/ca/intelliware/ihtsdo/mlds/service/UserService.java index 467802cff..0c7f6f6d4 100644 --- a/src/main/java/ca/intelliware/ihtsdo/mlds/service/UserService.java +++ b/src/main/java/ca/intelliware/ihtsdo/mlds/service/UserService.java @@ -56,7 +56,7 @@ public class UserService { @Autowired private AffiliateRepository affiliateRepository; @Autowired - private ApplicationRepository applicationRepository; + ApplicationRepository applicationRepository; @Autowired private CommercialUsageRepository commercialUsageRepository; @@ -388,7 +388,7 @@ private Member getMemberById(Long memberId) { } /*Reusable Method to Compute cutoff date based on Standing State , Approval State, Usage Reports*/ - private LocalDate getCutoffDate(int invoicesPending) { + public LocalDate getCutoffDate(int invoicesPending) { return LocalDate.now().minus(invoicesPending, ChronoUnit.DAYS); } diff --git a/src/main/java/ca/intelliware/ihtsdo/mlds/web/rest/UserResource.java b/src/main/java/ca/intelliware/ihtsdo/mlds/web/rest/UserResource.java index 32ddc6ba9..27b6ab621 100644 --- a/src/main/java/ca/intelliware/ihtsdo/mlds/web/rest/UserResource.java +++ b/src/main/java/ca/intelliware/ihtsdo/mlds/web/rest/UserResource.java @@ -36,10 +36,10 @@ public class UserResource { private final Logger log = LoggerFactory.getLogger(UserResource.class); @Autowired - private UserRepository userRepository; + UserRepository userRepository; @Autowired - private UserService userService; + UserService userService; @RequestMapping(value = "/users", method = RequestMethod.GET, diff --git a/src/test/java/ca/intelliware/ihtsdo/mlds/service/UserServiceTest.java b/src/test/java/ca/intelliware/ihtsdo/mlds/service/UserServiceTest.java new file mode 100644 index 000000000..5bc068ce8 --- /dev/null +++ b/src/test/java/ca/intelliware/ihtsdo/mlds/service/UserServiceTest.java @@ -0,0 +1,170 @@ +package ca.intelliware.ihtsdo.mlds.service; + +import ca.intelliware.ihtsdo.mlds.domain.*; +import ca.intelliware.ihtsdo.mlds.repository.AffiliateRepository; +import ca.intelliware.ihtsdo.mlds.repository.ApplicationRepository; +import ca.intelliware.ihtsdo.mlds.repository.CommercialUsageRepository; +import ca.intelliware.ihtsdo.mlds.repository.MemberRepository; +import org.junit.Before; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.Mockito; +import org.mockito.junit.MockitoJUnitRunner; + +import java.lang.reflect.Field; +import java.time.Instant; +import java.time.temporal.ChronoUnit; +import java.util.Arrays; +import java.util.List; + +import static org.mockito.Mockito.*; + +@RunWith(MockitoJUnitRunner.class) +public class UserServiceTest { + @Mock + private ApplicationRepository applicationRepository; + + @Mock + private MemberRepository memberRepository; + + @Mock + private AffiliateRepository affiliateRepository; + + @Mock + private CommercialUsageRepository commercialUsageRepository; + + @InjectMocks + private UserService userService; + + Member swedenMember; + + @Before + public void setup() { + swedenMember = new Member("SE", 1); + swedenMember.setPendingApplication(10); + swedenMember.setInvoicesPending(0); + swedenMember.setUsageReports(5); + } + + + @Test + public void testRemovePendingApplication() { + Affiliate affiliate1 = withAffiliate(StandingState.APPLYING, AffiliateType.ACADEMIC, 10L); + Affiliate affiliate2 = withAffiliate(StandingState.APPLYING, AffiliateType.INDIVIDUAL, 11L); + + PrimaryApplication app1 = withExistingSwedishPrimaryApplication(1L, affiliate1); + PrimaryApplication app2 = withExistingSwedishPrimaryApplication(2L, affiliate2); + + when(applicationRepository.getAllApplication()).thenReturn(Arrays.asList(app1, app2)); + when(memberRepository.findMemberById(1L)).thenReturn(swedenMember); + + userService.removePendingApplication(); + + List resultAffiliate = Arrays.asList(10L,11L); + verify(applicationRepository, atLeastOnce()).getAllApplication(); + verify(applicationRepository, atLeastOnce()).updateLastProcessed(eq(resultAffiliate), any(Instant.class)); + } + + + @Test + public void testRemoveInvoicesPending_NoPendingInvoices() { + + when(memberRepository.findMemberById(1L)).thenReturn(swedenMember); + userService.removeInvoicesPending(); + + verify(affiliateRepository, never()).getIHTSDOPendingInvoices(); + verify(affiliateRepository, never()).updateLastProcessed(anyList(), any(Instant.class)); + verify(affiliateRepository, never()).updateAffiliateDeactivationReason(anyLong(), any()); + } + + @Test + public void testRemoveInvoicesPending_WithPendingInvoices() { + + swedenMember.setInvoicesPending(5); + when(memberRepository.findMemberById(1L)).thenReturn(swedenMember); + + + Affiliate affiliate1 = new Affiliate(10L); + affiliate1.setStandingState(StandingState.PENDING_INVOICE); + affiliate1.setCreated(Instant.now().minus(6, ChronoUnit.DAYS)); + + Affiliate affiliate2 = new Affiliate(11L); + affiliate2.setStandingState(StandingState.PENDING_INVOICE); + affiliate2.setCreated(Instant.now().minus(7, ChronoUnit.DAYS)); + + when(affiliateRepository.getIHTSDOPendingInvoices()).thenReturn(Arrays.asList(affiliate1, affiliate2)); + + + when(affiliateRepository.findActiveAffiliateIds(Arrays.asList(10L, 11L))).thenReturn(Arrays.asList(10L, 11L)); + + + Instant now = Instant.now(); + + userService.removeInvoicesPending(); + + verify(affiliateRepository).getIHTSDOPendingInvoices(); + verify(affiliateRepository).updateLastProcessed(eq(Arrays.asList(10L, 11L)), argThat(argument -> { + return argument.isAfter(now.minusSeconds(1)) && argument.isBefore(now.plusSeconds(1)); + })); + verify(affiliateRepository).updateAffiliateDeactivationReason(10L, ReasonForDeactivation.AUTODEACTIVATION); + verify(affiliateRepository).updateAffiliateDeactivationReason(11L, ReasonForDeactivation.AUTODEACTIVATION); + } + + + @Test + public void testRemoveUsageReports_WithValidData() throws Exception { + + Affiliate affiliate = new Affiliate(1L); + affiliate.setHomeMember(swedenMember); + + CommercialUsage usage = new CommercialUsage(); + + usage.setState(UsageReportState.NOT_SUBMITTED); + setField(usage, "commercialUsageId", 1L); + setField(usage, "affiliate", affiliate); + setField(usage, "created", Instant.now().minus(10, ChronoUnit.DAYS)); + + when(commercialUsageRepository.findByState()).thenReturn(List.of(usage)); + when(affiliateRepository.findById(1L)).thenReturn(java.util.Optional.of(affiliate)); + when(affiliateRepository.findActiveAffiliateIds(any())).thenReturn(List.of(1L)); + when(affiliateRepository.updateAffiliateDeactivationReason(eq(1L), any())).thenReturn(1); + + when(memberRepository.findMemberById(1L)).thenReturn(swedenMember); + + userService.removeUsageReports(); + + verify(commercialUsageRepository, times(1)).findByState(); + verify(affiliateRepository, times(1)).findById(1L); + verify(affiliateRepository, times(1)).updateAffiliateDeactivationReason(1L, ReasonForDeactivation.AUTODEACTIVATION); + } + + private void setField(Object obj, String fieldName, Object value) throws Exception { + Field field = obj.getClass().getDeclaredField(fieldName); + field.setAccessible(true); + field.set(obj, value); + } + + private Affiliate withAffiliate(StandingState existingStandingState, AffiliateType affiliateType, Long affiliateId) { + Affiliate affiliate = new Affiliate(affiliateId); + affiliate.setStandingState(existingStandingState); + affiliate.setAffiliateDetails(new AffiliateDetails()); + affiliate.setType(affiliateType); + Mockito.when(affiliateRepository.findByCreatorIgnoreCase(Mockito.anyString())).thenReturn(List.of(affiliate)); + return affiliate; + } + + private PrimaryApplication withExistingSwedishPrimaryApplication(long primaryApplicationId, Affiliate affiliate) { + PrimaryApplication primaryApplication = new PrimaryApplication(primaryApplicationId); + primaryApplication.setAffiliateDetails(new AffiliateDetails()); + primaryApplication.setMember(swedenMember); + primaryApplication.setAffiliate(affiliate); + primaryApplication.setApprovalState(ApprovalState.NOT_SUBMITTED); + primaryApplication.setSubmittedAt(Instant.now().minus(30, ChronoUnit.DAYS)); + CommercialUsage commercialUsage = new CommercialUsage(10L, affiliate); + commercialUsage.setType(affiliate.getType()); + primaryApplication.setCommercialUsage(commercialUsage); + return primaryApplication; + } +} From b84650310eeeb1c4bb6107878198e093cc7265e9 Mon Sep 17 00:00:00 2001 From: jaykamal Date: Wed, 16 Apr 2025 18:28:28 +0530 Subject: [PATCH 42/58] MLDS-1207 Unit test method to post interval for deactivate an account for a scheduled period --- .../mlds/web/rest/MemberResourceTest.java | 43 +++++++++++++++++++ 1 file changed, 43 insertions(+) diff --git a/src/test/java/ca/intelliware/ihtsdo/mlds/web/rest/MemberResourceTest.java b/src/test/java/ca/intelliware/ihtsdo/mlds/web/rest/MemberResourceTest.java index 6a315d7cd..abe183fda 100644 --- a/src/test/java/ca/intelliware/ihtsdo/mlds/web/rest/MemberResourceTest.java +++ b/src/test/java/ca/intelliware/ihtsdo/mlds/web/rest/MemberResourceTest.java @@ -10,6 +10,7 @@ import java.util.Arrays; +import ca.intelliware.ihtsdo.mlds.web.rest.dto.AutoDeactivationMemberDTO; import org.hamcrest.Matchers; import org.junit.Before; import org.junit.Test; @@ -148,4 +149,46 @@ public void updateMemberFeedURLShouldReturnNotFoundForUnknownMember() throws Exc Mockito.verify(memberRepository, never()).save(Mockito.any(Member.class)); } + + @Test + public void postAutoDeactivationMemberDetailsShouldUpdateMember() throws Exception { + String memberKey = "SE"; + Member member = new Member(memberKey, 1L); + + Mockito.when(memberRepository.findOneByKey(memberKey)).thenReturn(member); + + AutoDeactivationMemberDTO autoDeactivationDTO = new AutoDeactivationMemberDTO(); + autoDeactivationDTO.setInvoicesPending(5); + autoDeactivationDTO.setUsageReports(3); + autoDeactivationDTO.setPendingApplications(2); + + restUserMockMvc.perform(put(Routes.POST_MEMBER_AUTO_DEACTIVATION, memberKey) + .contentType(MediaType.APPLICATION_JSON) + .content("{ \"invoicesPending\": 5, \"usageReports\": 3, \"pendingApplications\": 2 }") + .accept(MediaType.APPLICATION_JSON)) + .andExpect(status().isOk()) + .andExpect(content().string(Matchers.containsString("Data Saved Successfully"))); + + Mockito.verify(memberRepository).save(member); + assertThat(member.getInvoicesPending(), equalTo(5)); + assertThat(member.getUsageReports(), equalTo(3)); + assertThat(member.getPendingApplication(), equalTo(2)); + } + + + @Test + public void postAutoDeactivationMemberDetailsShouldReturnNotFoundForUnknownMember() throws Exception { + String memberKey = "ZZ"; + Mockito.when(memberRepository.findOneByKey(memberKey)).thenReturn(null); + + restUserMockMvc.perform(put(Routes.POST_MEMBER_AUTO_DEACTIVATION, memberKey) + .contentType(MediaType.APPLICATION_JSON) + .content("{ \"invoicesPending\": 5, \"usageReports\": 3, \"pendingApplications\": 2 }") + .accept(MediaType.APPLICATION_JSON)) + .andExpect(status().isNotFound()) + .andExpect(content().string(Matchers.containsString("Member not found"))); + + Mockito.verify(memberRepository, never()).save(Mockito.any(Member.class)); + } + } From 54c6fdc06da6fc93bd1c9076ca97f7464f2058a4 Mon Sep 17 00:00:00 2001 From: jaykamal Date: Wed, 16 Apr 2025 18:29:26 +0530 Subject: [PATCH 43/58] MLDS-1206 Unit test method to get the time Interval for member --- .../ihtsdo/mlds/web/rest/MemberResource.java | 3 ++ .../mlds/web/rest/MemberResourceTest.java | 32 +++++++++++++++++++ 2 files changed, 35 insertions(+) diff --git a/src/main/java/ca/intelliware/ihtsdo/mlds/web/rest/MemberResource.java b/src/main/java/ca/intelliware/ihtsdo/mlds/web/rest/MemberResource.java index e285da3f1..9f010d4de 100644 --- a/src/main/java/ca/intelliware/ihtsdo/mlds/web/rest/MemberResource.java +++ b/src/main/java/ca/intelliware/ihtsdo/mlds/web/rest/MemberResource.java @@ -235,6 +235,9 @@ public ResponseEntity updateMemberFeedURL(@PathVariable String memberKey, @Re @Timed public ResponseEntity getAutoDeactivationMemberDeatils(@PathVariable String memberKey){ Member member=memberRepository.findOneByKey(memberKey); + if (member == null) { + return ResponseEntity.status(HttpStatus.NOT_FOUND).body(null); + } AutoDeactivationMemberDTO deactivationMemberDTO=new AutoDeactivationMemberDTO(); deactivationMemberDTO.setUsageReports(member.getUsageReports()); deactivationMemberDTO.setPendingApplications(member.getPendingApplication()); diff --git a/src/test/java/ca/intelliware/ihtsdo/mlds/web/rest/MemberResourceTest.java b/src/test/java/ca/intelliware/ihtsdo/mlds/web/rest/MemberResourceTest.java index abe183fda..895e941b6 100644 --- a/src/test/java/ca/intelliware/ihtsdo/mlds/web/rest/MemberResourceTest.java +++ b/src/test/java/ca/intelliware/ihtsdo/mlds/web/rest/MemberResourceTest.java @@ -190,5 +190,37 @@ public void postAutoDeactivationMemberDetailsShouldReturnNotFoundForUnknownMembe Mockito.verify(memberRepository, never()).save(Mockito.any(Member.class)); } + + + @Test + public void getAutoDeactivationMemberDetailsShouldReturnMemberDetails() throws Exception { + String memberKey = "SE"; + Member member = new Member(memberKey, 1L); + member.setInvoicesPending(5); + member.setUsageReports(3); + member.setPendingApplication(2); + + Mockito.when(memberRepository.findOneByKey(memberKey)).thenReturn(member); + + restUserMockMvc.perform(get(Routes.MEMBER_AUTO_DEACTIVATION, memberKey) + .accept(MediaType.APPLICATION_JSON)) + .andExpect(status().isOk()) + .andExpect(content().contentType(MediaType.APPLICATION_JSON)) + .andExpect(content().string("{\"pendingApplications\":2,\"usageReports\":3,\"invoicesPending\":5}")); + } + + @Test + public void getAutoDeactivationMemberDetailsShouldReturnNotFoundForUnknownMember() throws Exception { + + String memberKey = "ZZ"; + Mockito.when(memberRepository.findOneByKey(memberKey)).thenReturn(null); + + restUserMockMvc.perform(get(Routes.MEMBER_AUTO_DEACTIVATION, memberKey) + .accept(MediaType.APPLICATION_JSON)) + .andExpect(status().isNotFound()) + .andExpect(content().string(Matchers.containsString(""))); + + Mockito.verify(memberRepository).findOneByKey(memberKey); + } } From 04fea966bd92c0edb6cae2159597cdd7752a0668 Mon Sep 17 00:00:00 2001 From: jaykamal Date: Mon, 21 Apr 2025 11:19:40 +0530 Subject: [PATCH 44/58] MLDS-1172 Affiliate management pagination fixes --- .../mlds/repository/AffiliateRepository.java | 15 ++++++--- .../repository/AffiliateSearchRepository.java | 1 + .../mlds/web/rest/AffiliateResource.java | 13 ++++---- ...filiateResource_AffiliatesFilter_Test.java | 33 ++++++++++--------- 4 files changed, 35 insertions(+), 27 deletions(-) diff --git a/src/main/java/ca/intelliware/ihtsdo/mlds/repository/AffiliateRepository.java b/src/main/java/ca/intelliware/ihtsdo/mlds/repository/AffiliateRepository.java index 5cc2edaa7..193f7b8fc 100644 --- a/src/main/java/ca/intelliware/ihtsdo/mlds/repository/AffiliateRepository.java +++ b/src/main/java/ca/intelliware/ihtsdo/mlds/repository/AffiliateRepository.java @@ -36,13 +36,20 @@ public interface AffiliateRepository extends JpaRepository { Page findByHomeMemberAndTextQuery(@Param("homeMember") Member homeMember, @Param("q") String q, Pageable pageable); - @Query(value = "SELECT a FROM Affiliate a LEFT JOIN a.applications b" - + " WHERE a.homeMember = :homeMember " - + " OR b.member = :homeMember ") - Page findByHomeMember(@Param("homeMember") Member homeMember, Pageable pageable); + @Query(value = "SELECT a FROM Affiliate a LEFT JOIN a.applications b " + + "WHERE (a.homeMember = :homeMember OR b.member = :homeMember) " + + "AND a.deactivated = false") + Page findByHomeMember(@Param("homeMember") Member homeMember, Pageable pageable); Iterable findByStandingStateInAndCreatorNotNull(Collection standingState); + Page findAllByDeactivatedFalse(Pageable pageable); + Page findByStandingStateNotAndDeactivatedFalse(StandingState standingState, Pageable pageable); + Page findByStandingStateAndDeactivatedFalse(StandingState standingState, Pageable pageable); + + Page findByHomeMemberAndStandingStateAndDeactivatedFalse(Member homeMember, StandingState standingState, Pageable pageable); + Page findByHomeMemberAndStandingStateNotAndDeactivatedFalse(Member homeMember, StandingState standingState, Pageable pageable); + Page findByHomeMemberAndStandingState(Member homeMember, StandingState standingState, Pageable pageable); Page findByHomeMemberAndStandingStateNot(Member homeMember, StandingState standingState, Pageable pageable); diff --git a/src/main/java/ca/intelliware/ihtsdo/mlds/repository/AffiliateSearchRepository.java b/src/main/java/ca/intelliware/ihtsdo/mlds/repository/AffiliateSearchRepository.java index 31cf85f94..a8cbce20d 100644 --- a/src/main/java/ca/intelliware/ihtsdo/mlds/repository/AffiliateSearchRepository.java +++ b/src/main/java/ca/intelliware/ihtsdo/mlds/repository/AffiliateSearchRepository.java @@ -47,6 +47,7 @@ public Page findFullTextAndMember(String q, Member homeMember, Standi // Apply filters based on homeMember and standingState List filteredList = resultList.stream() + .filter(affiliate -> !affiliate.isDeactivated()) .filter(affiliate -> isAffiliateMatching(affiliate, homeMember, standingState, standingStateNot)) .toList(); // Convert stream to List diff --git a/src/main/java/ca/intelliware/ihtsdo/mlds/web/rest/AffiliateResource.java b/src/main/java/ca/intelliware/ihtsdo/mlds/web/rest/AffiliateResource.java index 62df82fdc..dde98fb4e 100644 --- a/src/main/java/ca/intelliware/ihtsdo/mlds/web/rest/AffiliateResource.java +++ b/src/main/java/ca/intelliware/ihtsdo/mlds/web/rest/AffiliateResource.java @@ -131,12 +131,12 @@ public class AffiliateResource { } else { if (member == null) { if (standingState == null) { - affiliates = affiliateRepository.findAll(pageRequest); + affiliates = affiliateRepository.findAllByDeactivatedFalse(pageRequest); } else { if (standingStateNot) { - affiliates = affiliateRepository.findByStandingStateNot(standingState, pageRequest); + affiliates = affiliateRepository.findByStandingStateNotAndDeactivatedFalse(standingState, pageRequest); } else { - affiliates = affiliateRepository.findByStandingState(standingState, pageRequest); + affiliates = affiliateRepository.findByStandingStateAndDeactivatedFalse(standingState, pageRequest); } } } else { @@ -144,9 +144,9 @@ public class AffiliateResource { affiliates = affiliateRepository.findByHomeMember(member, pageRequest); } else { if (standingStateNot) { - affiliates = affiliateRepository.findByHomeMemberAndStandingStateNot(member, standingState, pageRequest); + affiliates = affiliateRepository.findByHomeMemberAndStandingStateNotAndDeactivatedFalse(member, standingState, pageRequest); } else { - affiliates = affiliateRepository.findByHomeMemberAndStandingState(member, standingState, pageRequest); + affiliates = affiliateRepository.findByHomeMemberAndStandingStateAndDeactivatedFalse(member, standingState, pageRequest); } } } @@ -154,8 +154,7 @@ public class AffiliateResource { AffiliateSearchResult result = new AffiliateSearchResult(); - result.setAffiliates(affiliates.getContent().stream().filter(a -> !a.isDeactivated()) // Filtering out deactivated affiliates - .collect(Collectors.toList())); + result.setAffiliates(affiliates.getContent()); result.setTotalResults(affiliates.getTotalElements()); result.setTotalPages(affiliates.getTotalPages()); diff --git a/src/test/java/ca/intelliware/ihtsdo/mlds/web/rest/AffiliateResource_AffiliatesFilter_Test.java b/src/test/java/ca/intelliware/ihtsdo/mlds/web/rest/AffiliateResource_AffiliatesFilter_Test.java index a99c2212f..8fe48dd78 100644 --- a/src/test/java/ca/intelliware/ihtsdo/mlds/web/rest/AffiliateResource_AffiliatesFilter_Test.java +++ b/src/test/java/ca/intelliware/ihtsdo/mlds/web/rest/AffiliateResource_AffiliatesFilter_Test.java @@ -91,7 +91,7 @@ public void setup() { @Test public void getAffiliates() throws Exception { PageImpl matches = withAffiliateResultForTestCreator(); - when(affiliateRepository.findAll(Mockito.any(Pageable.class))).thenReturn(matches); + when(affiliateRepository.findAllByDeactivatedFalse(Mockito.any(Pageable.class))).thenReturn(matches); restUserMockMvc.perform(get(Routes.AFFILIATES) .accept(MediaType.APPLICATION_JSON_UTF8)) @@ -104,6 +104,7 @@ public void getAffiliates() throws Exception { private PageImpl withAffiliateResultForTestCreator() { Affiliate affiliate = createBlankAffiliate(); affiliate.setCreator("testCreator"); + affiliate.setDeactivated(false); PageImpl matches = new PageImpl<>(Arrays.asList(affiliate)); return matches; @@ -116,7 +117,7 @@ private ResultMatcher contentMatchesTestCreator() { @Test public void getAffiliatesCanReturnNoResults() throws Exception { PageImpl matches = new PageImpl<>(Lists.emptyList()); - when(affiliateRepository.findAll(Mockito.any(Pageable.class))).thenReturn(matches); + when(affiliateRepository.findAllByDeactivatedFalse(Mockito.any(Pageable.class))).thenReturn(matches); restUserMockMvc.perform(get(Routes.AFFILIATES) .accept(MediaType.APPLICATION_JSON_UTF8)) @@ -128,7 +129,7 @@ public void getAffiliatesCanReturnNoResults() throws Exception { @Test public void getAffiliatesShouldSortByAffiliateIdByDefault() throws Exception { PageImpl matches = withAffiliateResultForTestCreator(); - when(affiliateRepository.findAll(Mockito.any(Pageable.class))).thenReturn(matches); + when(affiliateRepository.findAllByDeactivatedFalse(Mockito.any(Pageable.class))).thenReturn(matches); restUserMockMvc.perform(get(Routes.AFFILIATES) .accept(MediaType.APPLICATION_JSON_UTF8)) @@ -137,14 +138,14 @@ public void getAffiliatesShouldSortByAffiliateIdByDefault() throws Exception { .andExpect(contentMatchesTestCreator()); ArgumentCaptor argument = ArgumentCaptor.forClass(Pageable.class); - verify(affiliateRepository).findAll(argument.capture()); + verify(affiliateRepository).findAllByDeactivatedFalse(argument.capture()); assertThat(argument.getValue().getSort().toString(), is("affiliateId: ASC")); } @Test public void getAffiliatesShouldBeSortable() throws Exception { PageImpl matches = withAffiliateResultForTestCreator(); - when(affiliateRepository.findAll(Mockito.any(Pageable.class))).thenReturn(matches); + when(affiliateRepository.findAllByDeactivatedFalse(Mockito.any(Pageable.class))).thenReturn(matches); restUserMockMvc.perform(get(Routes.AFFILIATES) .param("$orderby", "member") @@ -154,14 +155,14 @@ public void getAffiliatesShouldBeSortable() throws Exception { .andExpect(contentMatchesTestCreator()); ArgumentCaptor argument = ArgumentCaptor.forClass(Pageable.class); - verify(affiliateRepository).findAll(argument.capture()); + verify(affiliateRepository).findAllByDeactivatedFalse(argument.capture()); assertThat(argument.getValue().getSort().toString(), is("homeMember.key: ASC,affiliateId: ASC")); } @Test public void getAffiliatesShouldBeSortableDescending() throws Exception { PageImpl matches = withAffiliateResultForTestCreator(); - when(affiliateRepository.findAll(Mockito.any(Pageable.class))).thenReturn(matches); + when(affiliateRepository.findAllByDeactivatedFalse(Mockito.any(Pageable.class))).thenReturn(matches); restUserMockMvc.perform(get(Routes.AFFILIATES) .param("$orderby", "member desc") @@ -171,14 +172,14 @@ public void getAffiliatesShouldBeSortableDescending() throws Exception { .andExpect(contentMatchesTestCreator()); ArgumentCaptor argument = ArgumentCaptor.forClass(Pageable.class); - verify(affiliateRepository).findAll(argument.capture()); + verify(affiliateRepository).findAllByDeactivatedFalse(argument.capture()); assertThat(argument.getValue().getSort().toString(), is("homeMember.key: DESC,affiliateId: ASC")); } @Test public void getAffiliatesShouldStartOnFirstPageByDefault() throws Exception { PageImpl matches = withAffiliateResultForTestCreator(); - when(affiliateRepository.findAll(Mockito.any(Pageable.class))).thenReturn(matches); + when(affiliateRepository.findAllByDeactivatedFalse(Mockito.any(Pageable.class))).thenReturn(matches); restUserMockMvc.perform(get(Routes.AFFILIATES) .accept(MediaType.APPLICATION_JSON_UTF8)) @@ -187,7 +188,7 @@ public void getAffiliatesShouldStartOnFirstPageByDefault() throws Exception { .andExpect(contentMatchesTestCreator()); ArgumentCaptor argument = ArgumentCaptor.forClass(Pageable.class); - verify(affiliateRepository).findAll(argument.capture()); + verify(affiliateRepository).findAllByDeactivatedFalse(argument.capture()); assertThat(argument.getValue().getPageNumber(), is(0)); assertThat(argument.getValue().getPageSize(), is(50)); assertThat(argument.getValue().getSort().toString(), is("affiliateId: ASC")); @@ -196,7 +197,7 @@ public void getAffiliatesShouldStartOnFirstPageByDefault() throws Exception { @Test public void getAffiliatesShouldAllowPaging() throws Exception { PageImpl matches = withAffiliateResultForTestCreator(); - when(affiliateRepository.findAll(Mockito.any(Pageable.class))).thenReturn(matches); + when(affiliateRepository.findAllByDeactivatedFalse(Mockito.any(Pageable.class))).thenReturn(matches); restUserMockMvc.perform(get(Routes.AFFILIATES) .param("$page", "2") @@ -207,7 +208,7 @@ public void getAffiliatesShouldAllowPaging() throws Exception { .andExpect(contentMatchesTestCreator()); ArgumentCaptor argument = ArgumentCaptor.forClass(Pageable.class); - verify(affiliateRepository).findAll(argument.capture()); + verify(affiliateRepository).findAllByDeactivatedFalse(argument.capture()); assertThat(argument.getValue().getPageNumber(), is(2)); assertThat(argument.getValue().getPageSize(), is(20)); } @@ -229,7 +230,7 @@ public void getAffiliatesShouldFilterByQuery() throws Exception { @Test public void getAffiliatesShouldFilterByStandingState() throws Exception { PageImpl matches = withAffiliateResultForTestCreator(); - when(affiliateRepository.findByStandingState(Mockito.eq(StandingState.APPLYING), Mockito.any(Pageable.class))).thenReturn(matches); + when(affiliateRepository.findByStandingStateAndDeactivatedFalse(Mockito.eq(StandingState.APPLYING), Mockito.any(Pageable.class))).thenReturn(matches); restUserMockMvc.perform(get(Routes.AFFILIATES) .param("$filter", "standingState eq 'APPLYING'") @@ -242,7 +243,7 @@ public void getAffiliatesShouldFilterByStandingState() throws Exception { @Test public void getAffiliatesShouldFilterByStandingStateNot() throws Exception { PageImpl matches = withAffiliateResultForTestCreator(); - when(affiliateRepository.findByStandingStateNot(Mockito.eq(StandingState.APPLYING), Mockito.any(Pageable.class))).thenReturn(matches); + when(affiliateRepository.findByStandingStateNotAndDeactivatedFalse(Mockito.eq(StandingState.APPLYING), Mockito.any(Pageable.class))).thenReturn(matches); restUserMockMvc.perform(get(Routes.AFFILIATES) .param("$filter", "not standingState eq 'APPLYING'") @@ -274,7 +275,7 @@ public void getAffiliatesShouldFilterByHomeMemberAndStandingState() throws Excep when(memberRepository.findOneByKey("SE")).thenReturn(member); PageImpl matches = withAffiliateResultForTestCreator(); - when(affiliateRepository.findByHomeMemberAndStandingState(Mockito.eq(member), Mockito.eq(StandingState.APPLYING), Mockito.any(Pageable.class))).thenReturn(matches); + when(affiliateRepository.findByHomeMemberAndStandingStateAndDeactivatedFalse(Mockito.eq(member), Mockito.eq(StandingState.APPLYING), Mockito.any(Pageable.class))).thenReturn(matches); restUserMockMvc.perform(get(Routes.AFFILIATES) .param("$filter", "homeMember eq 'SE'") @@ -291,7 +292,7 @@ public void getAffiliatesShouldFilterByHomeMemberAndStandingStateNot() throws Ex when(memberRepository.findOneByKey("SE")).thenReturn(member); PageImpl matches = withAffiliateResultForTestCreator(); - when(affiliateRepository.findByHomeMemberAndStandingStateNot(Mockito.eq(member), Mockito.eq(StandingState.APPLYING), Mockito.any(Pageable.class))).thenReturn(matches); + when(affiliateRepository.findByHomeMemberAndStandingStateNotAndDeactivatedFalse(Mockito.eq(member), Mockito.eq(StandingState.APPLYING), Mockito.any(Pageable.class))).thenReturn(matches); restUserMockMvc.perform(get(Routes.AFFILIATES) .param("$filter", "homeMember eq 'SE'") From b493f5d46f5fa0162dda451351a6999296f04177 Mon Sep 17 00:00:00 2001 From: jaykamal Date: Mon, 21 Apr 2025 12:24:14 +0530 Subject: [PATCH 45/58] MLDS-1204 Unit test method to get user details - primary email --- .../mlds/web/rest/UserResourceTest.java | 73 +++++++++++++++++-- 1 file changed, 67 insertions(+), 6 deletions(-) diff --git a/src/test/java/ca/intelliware/ihtsdo/mlds/web/rest/UserResourceTest.java b/src/test/java/ca/intelliware/ihtsdo/mlds/web/rest/UserResourceTest.java index 15bd76c9b..040b80654 100644 --- a/src/test/java/ca/intelliware/ihtsdo/mlds/web/rest/UserResourceTest.java +++ b/src/test/java/ca/intelliware/ihtsdo/mlds/web/rest/UserResourceTest.java @@ -1,27 +1,35 @@ package ca.intelliware.ihtsdo.mlds.web.rest; +import static org.mockito.Mockito.*; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; -import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; - +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; import ca.intelliware.ihtsdo.mlds.config.MySqlTestContainerTest; +import ca.intelliware.ihtsdo.mlds.domain.User; +import ca.intelliware.ihtsdo.mlds.service.UserService; +import ca.intelliware.ihtsdo.mlds.web.rest.dto.AffiliateDetailsResponseDTO; import jakarta.transaction.Transactional; import org.junit.Before; import org.junit.Test; import org.junit.runner.RunWith; +import org.mockito.Mock; +import org.mockito.Mockito; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.context.SpringBootTest; import org.springframework.http.MediaType; import org.springframework.test.context.ActiveProfiles; import org.springframework.test.context.TestPropertySource; import org.springframework.test.context.junit4.SpringJUnit4ClassRunner; -import org.springframework.test.util.ReflectionTestUtils; import org.springframework.test.web.servlet.MockMvc; import org.springframework.test.web.servlet.setup.MockMvcBuilders; import ca.intelliware.ihtsdo.mlds.repository.UserRepository; +import java.util.Collections; + /** * Test class for the UserResource REST controller. @@ -38,19 +46,72 @@ public class UserResourceTest extends MySqlTestContainerTest { @Autowired private UserRepository userRepository; + @Mock + public UserService userService; + private MockMvc restUserMockMvc; + UserResource userResource; + @Before public void setup() { - UserResource userResource = new UserResource(); - ReflectionTestUtils.setField(userResource, "userRepository", userRepository); + userResource = new UserResource(); + userResource.userRepository = userRepository; + userResource.userService = userService; this.restUserMockMvc = MockMvcBuilders.standaloneSetup(userResource).build(); } @Test public void testGetUnknownUser() throws Exception { restUserMockMvc.perform(get("/api/users/unknown") - .accept(MediaType.APPLICATION_JSON_UTF8)) + .accept(MediaType.APPLICATION_JSON)) .andExpect(status().isNotFound()); } + + @Test + public void getUserDetailsShouldReturnOk_WhenNoAffiliatesFound() throws Exception { + String login = "test@test.com"; + Long affiliateDetailsId = 1L; + + User user = new User(); + user.setLogin(login); + user.setUserId(1L); + + AffiliateDetailsResponseDTO responseDTO = new AffiliateDetailsResponseDTO(user, null, Collections.emptyList()); + + when(userService.getAffiliateDetails(login, affiliateDetailsId)).thenReturn(responseDTO); + + restUserMockMvc.perform(post("/api/getUserDetails") + .param("login", login) + .param("affiliateDetailsId", affiliateDetailsId.toString()) + .accept(MediaType.APPLICATION_JSON)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.user.login").value(login)) + .andExpect(jsonPath("$.affiliateDetails").isEmpty()) + .andExpect(jsonPath("$.affiliate").isEmpty()); + + Mockito.verify(userService, Mockito.times(1)).getAffiliateDetails(login, affiliateDetailsId); + } + + + @Test + public void getUserDetailsShouldReturnOk_WhenUserNotFound() throws Exception { + String login = "nonexistent@test.com"; + Long affiliateDetailsId = 1L; + + AffiliateDetailsResponseDTO responseDTO = new AffiliateDetailsResponseDTO(null, null, Collections.emptyList()); + + when(userService.getAffiliateDetails(login, affiliateDetailsId)).thenReturn(responseDTO); + + restUserMockMvc.perform(post("/api/getUserDetails") + .param("login", login) + .param("affiliateDetailsId", affiliateDetailsId.toString()) + .accept(MediaType.APPLICATION_JSON)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.user").isEmpty()) + .andExpect(jsonPath("$.affiliateDetails").isEmpty()) + .andExpect(jsonPath("$.affiliate").isEmpty()); + + Mockito.verify(userService, Mockito.times(1)).getAffiliateDetails(login, affiliateDetailsId); + } } From 6700096d4115709b64c26dc15c9973b2fa55b22c Mon Sep 17 00:00:00 2001 From: jaykamal Date: Mon, 21 Apr 2025 12:26:08 +0530 Subject: [PATCH 46/58] MLDS-1203 Unit test method to update primary email address --- .../mlds/web/rest/UserResourceTest.java | 59 +++++++++++++++++++ 1 file changed, 59 insertions(+) diff --git a/src/test/java/ca/intelliware/ihtsdo/mlds/web/rest/UserResourceTest.java b/src/test/java/ca/intelliware/ihtsdo/mlds/web/rest/UserResourceTest.java index 040b80654..c706606b2 100644 --- a/src/test/java/ca/intelliware/ihtsdo/mlds/web/rest/UserResourceTest.java +++ b/src/test/java/ca/intelliware/ihtsdo/mlds/web/rest/UserResourceTest.java @@ -29,6 +29,7 @@ import ca.intelliware.ihtsdo.mlds.repository.UserRepository; import java.util.Collections; +import java.util.NoSuchElementException; /** @@ -114,4 +115,62 @@ public void getUserDetailsShouldReturnOk_WhenUserNotFound() throws Exception { Mockito.verify(userService, Mockito.times(1)).getAffiliateDetails(login, affiliateDetailsId); } + + + @Test + public void updatePrimaryEmailShouldReturnOk_WhenEmailIsUpdatedSuccessfully() throws Exception { + String login = "test@test.com"; + String updatedEmail = "newemail@test.com"; + + // Mock successful execution (no exception thrown) + doNothing().when(userService).updatePrimaryEmail(login, updatedEmail); + + restUserMockMvc.perform(post("/api/updatePrimaryEmail") + .param("login", login) + .param("updatedEmail", updatedEmail) + .accept(MediaType.APPLICATION_JSON)) + .andExpect(status().isOk()) + .andExpect(content().string("Primary email updated successfully")); + + Mockito.verify(userService, Mockito.times(1)).updatePrimaryEmail(login, updatedEmail); + } + + @Test + public void updatePrimaryEmailShouldReturnNotFound_WhenUserDoesNotExist() throws Exception { + String login = "nonexistent@test.com"; + String updatedEmail = "testupdate@test.com"; + + // Mock userService to throw NoSuchElementException + doThrow(new NoSuchElementException("User or related data not found")) + .when(userService).updatePrimaryEmail(login, updatedEmail); + + restUserMockMvc.perform(post("/api/updatePrimaryEmail") + .param("login", login) + .param("updatedEmail", updatedEmail) + .accept(MediaType.APPLICATION_JSON)) + .andExpect(status().isNotFound()) + .andExpect(content().string("User or related data not found")); + + Mockito.verify(userService, Mockito.times(1)).updatePrimaryEmail(login, updatedEmail); + } + + @Test + public void updatePrimaryEmailShouldReturnInternalServerError_WhenUnexpectedErrorOccurs() throws Exception { + String login = "test@test.com"; + String updatedEmail = "testupdate@test.com"; + + // Mock userService to throw a generic exception + doThrow(new RuntimeException("Unexpected error")) + .when(userService).updatePrimaryEmail(login, updatedEmail); + + restUserMockMvc.perform(post("/api/updatePrimaryEmail") + .param("login", login) + .param("updatedEmail", updatedEmail) + .accept(MediaType.APPLICATION_JSON)) + .andExpect(status().isInternalServerError()) + .andExpect(content().string("An error occurred while updating the email")); + + Mockito.verify(userService, Mockito.times(1)).updatePrimaryEmail(login, updatedEmail); + } + } From 5f6c0e233d6ffe92e6a68ff71c50718b724f4cdb Mon Sep 17 00:00:00 2001 From: jaykamal Date: Tue, 22 Apr 2025 19:21:30 +0530 Subject: [PATCH 47/58] MLDS-1226 Unable to search email addresses in Affiliate Management --- .../intelliware/ihtsdo/mlds/domain/AffiliateDetails.java | 7 ++++--- .../ihtsdo/mlds/repository/AffiliateSearchRepository.java | 5 ++++- 2 files changed, 8 insertions(+), 4 deletions(-) diff --git a/src/main/java/ca/intelliware/ihtsdo/mlds/domain/AffiliateDetails.java b/src/main/java/ca/intelliware/ihtsdo/mlds/domain/AffiliateDetails.java index 9ac95f9b9..74a478e77 100644 --- a/src/main/java/ca/intelliware/ihtsdo/mlds/domain/AffiliateDetails.java +++ b/src/main/java/ca/intelliware/ihtsdo/mlds/domain/AffiliateDetails.java @@ -9,6 +9,7 @@ import org.hibernate.search.mapper.pojo.mapping.definition.annotation.FullTextField; import org.hibernate.search.mapper.pojo.mapping.definition.annotation.Indexed; import org.hibernate.search.mapper.pojo.mapping.definition.annotation.IndexedEmbedded; +import org.hibernate.search.mapper.pojo.mapping.definition.annotation.KeywordField; import java.time.Instant; @@ -54,15 +55,15 @@ public class AffiliateDetails extends BaseEntity implements Cloneable { @FullTextField String lastName; - @FullTextField + @KeywordField String email; @Column(name="alternate_email") - @FullTextField + @KeywordField String alternateEmail; @Column(name="third_email") - @FullTextField + @KeywordField String thirdEmail; @Column(name="landline_number") diff --git a/src/main/java/ca/intelliware/ihtsdo/mlds/repository/AffiliateSearchRepository.java b/src/main/java/ca/intelliware/ihtsdo/mlds/repository/AffiliateSearchRepository.java index a8cbce20d..fde1396cc 100644 --- a/src/main/java/ca/intelliware/ihtsdo/mlds/repository/AffiliateSearchRepository.java +++ b/src/main/java/ca/intelliware/ihtsdo/mlds/repository/AffiliateSearchRepository.java @@ -36,8 +36,11 @@ public Page findFullTextAndMember(String q, Member homeMember, Standi } else { result = searchSession.search(Affiliate.class) .where(f -> f.bool() + .should(f.wildcard().field("affiliateDetails.email").matching(q + "*")) + .should(f.wildcard().field("affiliateDetails.alternateEmail").matching(q + "*")) + .should(f.wildcard().field("affiliateDetails.thirdEmail").matching(q + "*")) .should(f.simpleQueryString() - .fields("affiliateDetails.firstName", "affiliateDetails.lastName", "affiliateDetails.email", "affiliateDetails.alternateEmail", "affiliateDetails.thirdEmail", "affiliateDetails.organizationName", "affiliateDetails.organizationType") + .fields("affiliateDetails.firstName", "affiliateDetails.lastName", "affiliateDetails.organizationName", "affiliateDetails.organizationType") .matching(q + "*")) ) .fetch(pageable.getPageSize()); From f430663e26b373e83f97dd22a7696436efdce431 Mon Sep 17 00:00:00 2001 From: AravinthAasai <109154990+AravinthAasaithambi@users.noreply.github.com> Date: Mon, 12 May 2025 16:20:33 +0530 Subject: [PATCH 48/58] MLDS-1157 Configurable AWS regions for MLDS downloads --- .../ihtsdo/mlds/web/rest/UriDownloader.java | 77 ++++++++++++++++--- .../resources/config/application.properties | 4 +- 2 files changed, 71 insertions(+), 10 deletions(-) diff --git a/src/main/java/ca/intelliware/ihtsdo/mlds/web/rest/UriDownloader.java b/src/main/java/ca/intelliware/ihtsdo/mlds/web/rest/UriDownloader.java index cae634632..45f257675 100644 --- a/src/main/java/ca/intelliware/ihtsdo/mlds/web/rest/UriDownloader.java +++ b/src/main/java/ca/intelliware/ihtsdo/mlds/web/rest/UriDownloader.java @@ -28,6 +28,11 @@ import java.net.URISyntaxException; import java.nio.file.Path; import java.nio.file.Paths; +import java.util.Arrays; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.stream.Collectors; @Service public class UriDownloader { @@ -42,7 +47,8 @@ public class UriDownloader { @Value("${aws.region}") private String awsRegion; - + @Value("${aws.regions}") + private String awsRegions; private final Logger log = LoggerFactory.getLogger(UriDownloader.class); private S3Location s3BucketName; @@ -88,7 +94,7 @@ S3Location determineS3Location(String url) { } public int downloadS3(S3Location s3Location, String downloadUrl, HttpServletRequest clientRequest, HttpServletResponse clientResponse) - throws IOException { + throws IOException { log.debug("Attempting to download {} from S3", s3Location.toString()); log.debug(s3Location.bucket); this.s3BucketName = s3Location; @@ -107,12 +113,65 @@ public int downloadS3(S3Location s3Location, String downloadUrl, HttpServletRequ return clientResponse.getStatus(); } + /** + * Returns an initialized S3Client based on configured AWS region properties. + * + * Configuration details: + * - Default region: `aws.region` + * - Multi-bucket specific region mapping: `aws.regions` + * + * Format in application.properties: + * + * # Default region fallback + * aws.region=us-east-1 + * + * # AWS Regions Defined Here + * # Default will be aws.region, and if new regions are to be added, + * # kindly follow the same format as below: + * aws.regions=ihtsdo-mlds.de:eu-central-1,snomed-mlds.in:ap-south-1 + * + * Each entry should be in the format: bucket-name:region + * Multiple entries are comma-separated. + * + * @return S3Client - either a live AWS client or Offline client + */ public S3Client getS3Client() throws IOException { - S3Client s3Client = null; - log.debug("Configuring " + (s3Offline ? "offline" : "online") + " s3 client."); - String regionToUse = awsRegion; - if ("ihtsdo-mlds.de".equals(s3BucketName.bucket)) { - regionToUse = "eu-central-1"; + S3Client s3Client; + // Default region from application.properties + String regionsToUse; + String regionToUse = awsRegions; + + Map> regionMap = new HashMap<>(); + + + // Check if awsRegions is not null or empty before processing + if (awsRegions != null && !awsRegions.isEmpty()) { + regionMap = Arrays.stream(awsRegions.split(",")) + .map(entry -> entry.split(":")) // Split bucket name and regions + .filter(parts -> { + boolean valid = parts.length == 2; + if (!valid) log.warn("Skipping invalid entry: {}", Arrays.toString(parts)); + return valid; + }) + .collect(Collectors.toMap( + parts -> parts[0], // Bucket name + parts -> Arrays.asList(parts[1].split(",")) // List of regions + )); + + + regionMap.forEach((bucket, regions) -> log.debug("Bucket: {} -> Regions: {}", bucket, regions)); + } else { + log.warn("awsRegions property is empty or null!"); + } + + // If the bucket exists in the map, get the first region (or use any logic to select one) + if (regionMap.containsKey(s3BucketName.bucket)) { + List regions = regionMap.get(s3BucketName.bucket); + regionsToUse = regions.get(0); // Choose the first region (modify logic if needed) + log.debug("Available regions for {}: {}", s3BucketName.bucket, regions); + }else + { + regionsToUse=awsRegion; } @@ -121,9 +180,9 @@ public S3Client getS3Client() throws IOException { } else { s3Client = new S3ClientImpl(software.amazon.awssdk.services.s3.S3Client.builder() .credentialsProvider(InstanceProfileCredentialsProvider.create()) - .region(Region.of(regionToUse)) + .region(Region.of(regionsToUse)) .build()); - log.debug("s3Client:", s3Client); + log.debug("s3Client initialized with region: {}", regionToUse); } return s3Client; } diff --git a/src/main/resources/config/application.properties b/src/main/resources/config/application.properties index 6f8fe859a..27cc07554 100644 --- a/src/main/resources/config/application.properties +++ b/src/main/resources/config/application.properties @@ -4,6 +4,8 @@ ims.cookie=local-ims-ihtsdo # Swagger Configuration springdoc.api-docs.path=/api-docs +aws.region=us-east-1 +aws.regions=ihtsdo-mlds.de:eu-central-1,snomed-mlds.in:ap-south-1 #spring.datasource.dataSourceClassName=com.mysql.cj.jdbc.MysqlDataSource spring.datasource.data-source-class-name=com.mysql.cj.jdbc.MysqlDataSource @@ -58,4 +60,4 @@ webauth.applicationName=mlds spring.thymeleaf.mode=XHTML spring.thymeleaf.cache=false -aws.region=us-east-1 + From 38c8a2c3fcac1cda38e0090488763a908cc45e31 Mon Sep 17 00:00:00 2001 From: AravinthAasai <109154990+AravinthAasaithambi@users.noreply.github.com> Date: Mon, 12 May 2025 16:35:40 +0530 Subject: [PATCH 49/58] MLDS-1172 Add the ability to auto decline/deactivate an account if no activity --- .../ihtsdo/mlds/repository/AffiliateRepository.java | 6 ++++-- .../intelliware/ihtsdo/mlds/service/UserService.java | 9 ++++----- src/main/resources/config/application.properties | 5 +++++ .../ihtsdo/mlds/service/UserServiceTest.java | 10 +++++----- 4 files changed, 18 insertions(+), 12 deletions(-) diff --git a/src/main/java/ca/intelliware/ihtsdo/mlds/repository/AffiliateRepository.java b/src/main/java/ca/intelliware/ihtsdo/mlds/repository/AffiliateRepository.java index 193f7b8fc..552da7739 100644 --- a/src/main/java/ca/intelliware/ihtsdo/mlds/repository/AffiliateRepository.java +++ b/src/main/java/ca/intelliware/ihtsdo/mlds/repository/AffiliateRepository.java @@ -107,8 +107,10 @@ public interface AffiliateRepository extends JpaRepository { List findActiveAffiliateIds(@Param("affiliateIds") List affiliateIds); @Modifying - @Query("UPDATE Affiliate a SET a.deactivated = true, a.reasonForDeactivation = :reason WHERE a.id = :affiliateId") - int updateAffiliateDeactivationReason(@Param("affiliateId") Long affiliateId, @Param("reason") ReasonForDeactivation reason); + @Query("UPDATE Affiliate a SET a.standingState = :standingState, a.reasonForDeactivation = :reason WHERE a.id = :affiliateId") + int updateAffiliateStandingStateAndDeactivationReason(@Param("affiliateId") Long affiliateId, + @Param("standingState") StandingState standingState, + @Param("reason") ReasonForDeactivation reason); @Modifying @Query("UPDATE Affiliate a SET a.lastProcessed = :timestamp WHERE a.id IN :affiliateIds") diff --git a/src/main/java/ca/intelliware/ihtsdo/mlds/service/UserService.java b/src/main/java/ca/intelliware/ihtsdo/mlds/service/UserService.java index 0c7f6f6d4..8daed4a69 100644 --- a/src/main/java/ca/intelliware/ihtsdo/mlds/service/UserService.java +++ b/src/main/java/ca/intelliware/ihtsdo/mlds/service/UserService.java @@ -283,7 +283,7 @@ private String addOldToEmail(String email) { } - @Scheduled(cron = "0 0 17 * * ?", zone = "Asia/Kolkata") + @Scheduled(cron = "${scheduler.remove-pending-application.cron}") public void removePendingApplication() { Logger logger = LoggerFactory.getLogger(getClass()); @@ -348,7 +348,7 @@ public void removePendingApplication() { * This ensures that affiliates who have not cleared their invoices within the allowed timeframe * are deactivated automatically, maintaining compliance with membership policies. */ - @Scheduled(cron = "0 0 16 * * ?", zone = "Asia/Kolkata") + @Scheduled(cron = "${scheduler.remove-invoices-pending.cron}") public void removeInvoicesPending() { Logger logger = LoggerFactory.getLogger(getClass()); @@ -403,8 +403,7 @@ private void deactivateAffiliates(List affiliateid) { if (!activeAffiliateIds.isEmpty()) { int updatedCount = 0; for (Long affiliateId : activeAffiliateIds) { -// ReasonForDeactivation reason = ReasonForDeactivation.AUTODEACTIVATION; - updatedCount = affiliateRepository.updateAffiliateDeactivationReason(affiliateId, ReasonForDeactivation.AUTODEACTIVATION); + updatedCount = affiliateRepository.updateAffiliateStandingStateAndDeactivationReason(affiliateId,StandingState.DEREGISTERED, ReasonForDeactivation.AUTODEACTIVATION); } logger.info("Total affiliates deactivated: {}", updatedCount); @@ -418,7 +417,7 @@ private void deactivateAffiliates(List affiliateid) { - @Scheduled(cron = "0 0 15 * * ?", zone = "Asia/Kolkata") + @Scheduled(cron = "${scheduler.remove-usage-reports.cron}") public void removeUsageReports() { Logger logger = LoggerFactory.getLogger(getClass()); diff --git a/src/main/resources/config/application.properties b/src/main/resources/config/application.properties index 27cc07554..65936bc62 100644 --- a/src/main/resources/config/application.properties +++ b/src/main/resources/config/application.properties @@ -4,6 +4,11 @@ ims.cookie=local-ims-ihtsdo # Swagger Configuration springdoc.api-docs.path=/api-docs +# Cron Expression Scheduler Configuration +scheduler.remove-invoices-pending.cron=0 0 15 * * ? +scheduler.remove-pending-application.cron=0 0 16 * * ? +scheduler.remove-usage-reports.cron=0 0 17 * * ? + aws.region=us-east-1 aws.regions=ihtsdo-mlds.de:eu-central-1,snomed-mlds.in:ap-south-1 diff --git a/src/test/java/ca/intelliware/ihtsdo/mlds/service/UserServiceTest.java b/src/test/java/ca/intelliware/ihtsdo/mlds/service/UserServiceTest.java index 5bc068ce8..1cded1d63 100644 --- a/src/test/java/ca/intelliware/ihtsdo/mlds/service/UserServiceTest.java +++ b/src/test/java/ca/intelliware/ihtsdo/mlds/service/UserServiceTest.java @@ -76,7 +76,7 @@ public void testRemoveInvoicesPending_NoPendingInvoices() { verify(affiliateRepository, never()).getIHTSDOPendingInvoices(); verify(affiliateRepository, never()).updateLastProcessed(anyList(), any(Instant.class)); - verify(affiliateRepository, never()).updateAffiliateDeactivationReason(anyLong(), any()); + verify(affiliateRepository, never()).updateAffiliateStandingStateAndDeactivationReason(anyLong(), any(),any()); } @Test @@ -108,8 +108,8 @@ public void testRemoveInvoicesPending_WithPendingInvoices() { verify(affiliateRepository).updateLastProcessed(eq(Arrays.asList(10L, 11L)), argThat(argument -> { return argument.isAfter(now.minusSeconds(1)) && argument.isBefore(now.plusSeconds(1)); })); - verify(affiliateRepository).updateAffiliateDeactivationReason(10L, ReasonForDeactivation.AUTODEACTIVATION); - verify(affiliateRepository).updateAffiliateDeactivationReason(11L, ReasonForDeactivation.AUTODEACTIVATION); + verify(affiliateRepository).updateAffiliateStandingStateAndDeactivationReason(10L,StandingState.DEREGISTERED, ReasonForDeactivation.AUTODEACTIVATION); + verify(affiliateRepository).updateAffiliateStandingStateAndDeactivationReason(11L,StandingState.DEREGISTERED, ReasonForDeactivation.AUTODEACTIVATION); } @@ -129,7 +129,7 @@ public void testRemoveUsageReports_WithValidData() throws Exception { when(commercialUsageRepository.findByState()).thenReturn(List.of(usage)); when(affiliateRepository.findById(1L)).thenReturn(java.util.Optional.of(affiliate)); when(affiliateRepository.findActiveAffiliateIds(any())).thenReturn(List.of(1L)); - when(affiliateRepository.updateAffiliateDeactivationReason(eq(1L), any())).thenReturn(1); + when(affiliateRepository.updateAffiliateStandingStateAndDeactivationReason(eq(1L),any(), any())).thenReturn(1); when(memberRepository.findMemberById(1L)).thenReturn(swedenMember); @@ -137,7 +137,7 @@ public void testRemoveUsageReports_WithValidData() throws Exception { verify(commercialUsageRepository, times(1)).findByState(); verify(affiliateRepository, times(1)).findById(1L); - verify(affiliateRepository, times(1)).updateAffiliateDeactivationReason(1L, ReasonForDeactivation.AUTODEACTIVATION); + verify(affiliateRepository, times(1)).updateAffiliateStandingStateAndDeactivationReason(1L,StandingState.DEREGISTERED, ReasonForDeactivation.AUTODEACTIVATION); } private void setField(Object obj, String fieldName, Object value) throws Exception { From b650d479b3ccc065c20072851c6fc8a5c99560b4 Mon Sep 17 00:00:00 2001 From: jaykamal Date: Wed, 14 May 2025 19:23:47 +0530 Subject: [PATCH 50/58] MLDS-1224 Update Usage Report format available from the UI --- .../ihtsdo/mlds/repository/CommercialUsageRepository.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/main/java/ca/intelliware/ihtsdo/mlds/repository/CommercialUsageRepository.java b/src/main/java/ca/intelliware/ihtsdo/mlds/repository/CommercialUsageRepository.java index b7ea1d1c6..6da46ac10 100644 --- a/src/main/java/ca/intelliware/ihtsdo/mlds/repository/CommercialUsageRepository.java +++ b/src/main/java/ca/intelliware/ihtsdo/mlds/repository/CommercialUsageRepository.java @@ -30,7 +30,7 @@ public interface CommercialUsageRepository extends JpaRepository findByNotStateAndEffectiveToIsNull(@Param("state") UsageReportState state, Pageable pageable); /*MLDS 985---To Download Commercial usage CSV files this below code is used*/ - @Query(value="SELECT commercial_usage.affiliate_id, m2.`key` as \"Member Key\", commercial_usage_entry.country_iso_code_2 as \"Deplyment Country\", commercial_usage_count.country_iso_code_2 as \"Affiliate Country\", commercial_usage.start_date, commercial_usage.end_date, affiliate.standing_state, affiliate.created, affiliate_details.agreement_type, concat (affiliate_details.first_name, ' ', affiliate_details.last_name) AS applicant, concat (affiliate_details.type, '-', affiliate_details.subtype) AS type, affiliate_details.organization_name, affiliate_details.organization_type, current_usage, planned_usage, purpose, implementation_status, other_activities, commercial_usage_count.snomed_practices, commercial_usage_count.hospitals_staffing_practices, commercial_usage_count.databases_per_deployment, commercial_usage_count.deployed_data_analysis_systems, COUNT(commercial_usage_entry.commercial_usage_id) AS hospitals FROM affiliate, affiliate_details, `member` m2, commercial_usage_count, commercial_usage LEFT JOIN commercial_usage_entry ON commercial_usage.commercial_usage_id = commercial_usage_entry.commercial_usage_id WHERE commercial_usage.affiliate_id = affiliate.affiliate_id and affiliate.home_member_id = m2.member_id AND affiliate.affiliate_details_id = affiliate_details.affiliate_details_id AND commercial_usage.commercial_usage_id = commercial_usage_count.commercial_usage_id AND commercial_usage.state = 'SUBMITTED' AND affiliate_details.email NOT LIKE '%ihtsdo.org%' GROUP BY commercial_usage.affiliate_id, m2.`key`,commercial_usage_entry.country_iso_code_2, commercial_usage_count.country_iso_code_2, commercial_usage.start_date, commercial_usage.end_date, affiliate.standing_state, affiliate.created, affiliate_details.first_name, affiliate_details.last_name, affiliate_details.organization_name, affiliate_details.organization_type, current_usage, planned_usage, purpose, implementation_status, other_activities, commercial_usage_count.snomed_practices, commercial_usage_count.hospitals_staffing_practices, commercial_usage_count.databases_per_deployment, commercial_usage_count.deployed_data_analysis_systems, affiliate_details.agreement_type, affiliate.notes_internal, affiliate_details.type, affiliate_details.subtype ORDER BY organization_name, commercial_usage.affiliate_id", nativeQuery = true) + @Query(value="SELECT commercial_usage.affiliate_id, m2.`key` as \"Member Key\", IFNULL(commercial_usage_entry.country_iso_code_2, '\\\\N') AS \"Deployment Country\", IFNULL(commercial_usage_count.country_iso_code_2, '\\\\N') AS \"Affiliate Country\", IFNULL(commercial_usage.start_date, '\\\\N') AS start_date, IFNULL(commercial_usage.end_date, '\\\\N') AS end_date, IFNULL(affiliate.standing_state, '\\\\N') AS standing_state, IFNULL(affiliate.created, '\\\\N') AS created, IFNULL(affiliate_details.agreement_type, '\\\\N') AS agreement_type, IFNULL(CONCAT(affiliate_details.first_name, ' ', affiliate_details.last_name), '\\\\N') AS applicant, IFNULL(CONCAT(affiliate_details.type, '-', affiliate_details.subtype), '\\\\N') AS type, IFNULL(affiliate_details.organization_name, '\\\\N') AS organization_name, IFNULL(affiliate_details.organization_type, '\\\\N') AS organization_type, IFNULL(current_usage, '\\\\N') AS current_usage, IFNULL(planned_usage, '\\\\N') AS planned_usage, IFNULL(purpose, '\\\\N') AS purpose, IFNULL(implementation_status, '\\\\N') AS implementation_status, IFNULL(other_activities, '\\\\N') AS other_activities, IFNULL(commercial_usage_count.snomed_practices, '\\\\N') AS snomed_practices, IFNULL(commercial_usage_count.hospitals_staffing_practices, '\\\\N') AS hospitals_staffing_practices, IFNULL(commercial_usage_count.databases_per_deployment, '\\\\N') AS databases_per_deployment, IFNULL(commercial_usage_count.deployed_data_analysis_systems, '\\\\N') AS deployed_data_analysis_systems, COUNT(commercial_usage_entry.commercial_usage_id) AS hospitals FROM affiliate, affiliate_details, `member` m2, commercial_usage_count, commercial_usage LEFT JOIN commercial_usage_entry ON commercial_usage.commercial_usage_id = commercial_usage_entry.commercial_usage_id WHERE commercial_usage.affiliate_id = affiliate.affiliate_id and affiliate.home_member_id = m2.member_id AND affiliate.affiliate_details_id = affiliate_details.affiliate_details_id AND commercial_usage.commercial_usage_id = commercial_usage_count.commercial_usage_id AND commercial_usage.state = 'SUBMITTED' AND affiliate_details.email NOT LIKE '%ihtsdo.org%' GROUP BY commercial_usage.affiliate_id, m2.`key`,commercial_usage_entry.country_iso_code_2, commercial_usage_count.country_iso_code_2, commercial_usage.start_date, commercial_usage.end_date, affiliate.standing_state, affiliate.created, affiliate_details.first_name, affiliate_details.last_name, affiliate_details.organization_name, affiliate_details.organization_type, current_usage, planned_usage, purpose, implementation_status, other_activities, commercial_usage_count.snomed_practices, commercial_usage_count.hospitals_staffing_practices, commercial_usage_count.databases_per_deployment, commercial_usage_count.deployed_data_analysis_systems, affiliate_details.agreement_type, affiliate.notes_internal, affiliate_details.type, affiliate_details.subtype ORDER BY organization_name, commercial_usage.affiliate_id;", nativeQuery = true) Collection findUsageReport(); /*MLDS 985---To Download Commercial usage CSV files this below code is used*/ From c10304a46b705567b31c834ca5b23822df2e47f2 Mon Sep 17 00:00:00 2001 From: AravinthAasai <109154990+AravinthAasaithambi@users.noreply.github.com> Date: Mon, 19 May 2025 18:25:20 +0530 Subject: [PATCH 51/58] MLDS-1195 Include the list of dependent packages in the "Confirm Deletion" warning modal --- .../ihtsdo/mlds/repository/ReleaseVersionRepository.java | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/main/java/ca/intelliware/ihtsdo/mlds/repository/ReleaseVersionRepository.java b/src/main/java/ca/intelliware/ihtsdo/mlds/repository/ReleaseVersionRepository.java index a5ed03a22..5a3d96345 100644 --- a/src/main/java/ca/intelliware/ihtsdo/mlds/repository/ReleaseVersionRepository.java +++ b/src/main/java/ca/intelliware/ihtsdo/mlds/repository/ReleaseVersionRepository.java @@ -16,10 +16,10 @@ public interface ReleaseVersionRepository extends JpaRepository listAtomFeed(); - @Query(value = "SELECT CASE WHEN :releaseVersionURI IS NULL OR TRIM(:releaseVersionURI) = '' THEN 0 ELSE CASE WHEN EXISTS (SELECT 1 FROM release_version rv JOIN release_package rp ON rv.release_package_id = rp.release_package_id WHERE (TRIM(rv.versionDependentURI) = TRIM(:releaseVersionURI) OR TRIM(rv.versionDependentDerivativeURI) = TRIM(:releaseVersionURI)) AND rp.inactive_at IS NULL) THEN 1 ELSE 0 END END AS is_present;", nativeQuery = true) + @Query(value = "SELECT CASE WHEN :releaseVersionURI IS NULL OR TRIM(:releaseVersionURI) = '' THEN 0 ELSE CASE WHEN EXISTS (SELECT 1 FROM release_version rv JOIN release_package rp ON rv.release_package_id = rp.release_package_id WHERE (TRIM(rv.version_dependent_uri) = TRIM(:releaseVersionURI) OR TRIM(rv.version_dependent_derivative_uri) = TRIM(:releaseVersionURI)) AND rp.inactive_at IS NULL) THEN 1 ELSE 0 END END AS is_present;", nativeQuery = true) Long checkDependent(@Param("releaseVersionURI") String releaseVersionURI); - @Query(value = "SELECT rv.name AS releaseVersionName, rv.release_package_id AS releasePackageId, rp.inactive_at AS inactiveAt FROM release_version rv JOIN release_package rp ON rv.release_package_id = rp.release_package_id WHERE rv.versionDependentURI = :releaseVersionURI AND rp.inactive_at IS NULL", nativeQuery = true) + @Query(value = "SELECT rv.name AS releaseVersionName, rv.release_package_id AS releasePackageId, rp.inactive_at AS inactiveAt FROM release_version rv JOIN release_package rp ON rv.release_package_id = rp.release_package_id WHERE rv.version_dependent_uri = :releaseVersionURI AND rp.inactive_at IS NULL", nativeQuery = true) List getDependentVersionNames(@Param("releaseVersionURI") String releaseVersionURI); } From a65e7653a44066c02df0405e6bb487138675fe97 Mon Sep 17 00:00:00 2001 From: AravinthAasai <109154990+AravinthAasaithambi@users.noreply.github.com> Date: Mon, 19 May 2025 19:08:46 +0530 Subject: [PATCH 52/58] MLDS-1138 Drop table User Registration --- .../config/liquibase/changelog/db-changelog-002.xml | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/src/main/resources/config/liquibase/changelog/db-changelog-002.xml b/src/main/resources/config/liquibase/changelog/db-changelog-002.xml index 903733306..acf63f68c 100644 --- a/src/main/resources/config/liquibase/changelog/db-changelog-002.xml +++ b/src/main/resources/config/liquibase/changelog/db-changelog-002.xml @@ -445,4 +445,11 @@ - + + + + + Drop table user_registration if it exists + + + From 714d529c7fc9d7df247dfe3567737e8361987813 Mon Sep 17 00:00:00 2001 From: AravinthAasai <109154990+AravinthAasaithambi@users.noreply.github.com> Date: Mon, 19 May 2025 19:11:27 +0530 Subject: [PATCH 53/58] MLDS-1137 Drop Table Event --- .../config/liquibase/changelog/db-changelog-002.xml | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/src/main/resources/config/liquibase/changelog/db-changelog-002.xml b/src/main/resources/config/liquibase/changelog/db-changelog-002.xml index acf63f68c..e77b70b3f 100644 --- a/src/main/resources/config/liquibase/changelog/db-changelog-002.xml +++ b/src/main/resources/config/liquibase/changelog/db-changelog-002.xml @@ -452,4 +452,11 @@ Drop table user_registration if it exists + + + + + Drop table event if it exists + + From 3d92e6499b721c64d14d1964297ad817aa8a9817 Mon Sep 17 00:00:00 2001 From: AravinthAasai <109154990+AravinthAasaithambi@users.noreply.github.com> Date: Wed, 28 May 2025 11:25:45 +0530 Subject: [PATCH 54/58] MLDS-1223 Add unsubscribe option on "Notification of New Releases Package" emails --- .../intelliware/ihtsdo/mlds/domain/User.java | 9 ++- .../mlds/service/mail/ClientLinkBuilder.java | 5 ++ .../mlds/service/mail/EmailVariables.java | 3 +- .../ReleasePackageUpdatedEmailSender.java | 77 +++++++++++++++---- .../ihtsdo/mlds/web/rest/UserNotifier.java | 4 +- .../ihtsdo/mlds/web/rest/UserResource.java | 46 ++++++++++- .../liquibase/changelog/db-changelog-002.xml | 8 +- .../mails/releasePackageUpdatedEmail.html | 49 ++++++------ 8 files changed, 157 insertions(+), 44 deletions(-) diff --git a/src/main/java/ca/intelliware/ihtsdo/mlds/domain/User.java b/src/main/java/ca/intelliware/ihtsdo/mlds/domain/User.java index 89815ebae..8b89acf3c 100644 --- a/src/main/java/ca/intelliware/ihtsdo/mlds/domain/User.java +++ b/src/main/java/ca/intelliware/ihtsdo/mlds/domain/User.java @@ -72,7 +72,8 @@ public class User extends AbstractAuditingEntity implements Serializable { @Column(name="inactive_at") private Instant inactiveAt; - + @Column(name = "unsubscribe_key") + private String unsubscribeKey; @JsonIgnore @ManyToMany @JoinTable( @@ -98,7 +99,13 @@ public void setLogin(String login) { public String getPassword() { return password; } + public String getUnsubscribeKey() { + return unsubscribeKey; + } + public void setUnsubscribeKey(String unsubscribeKey) { + this.unsubscribeKey = unsubscribeKey; + } public void setPassword(String password) { this.password = password; } diff --git a/src/main/java/ca/intelliware/ihtsdo/mlds/service/mail/ClientLinkBuilder.java b/src/main/java/ca/intelliware/ihtsdo/mlds/service/mail/ClientLinkBuilder.java index 9206ba062..3266bcd77 100644 --- a/src/main/java/ca/intelliware/ihtsdo/mlds/service/mail/ClientLinkBuilder.java +++ b/src/main/java/ca/intelliware/ihtsdo/mlds/service/mail/ClientLinkBuilder.java @@ -27,5 +27,10 @@ public String buildViewApplication(long applicationId) { public String buildViewReleasePackageLink(long releasePackageId) { return templateEvaluator.getUrlBase() + "#/viewReleases/viewRelease/"+releasePackageId; } + public String buildUnsubscribeLink(long affiliateId, String unsubscribeKey) { + // Build and return the unsubscribe URL + return templateEvaluator.getUrlBase() + "#/unsubscribenotification/" + affiliateId + "/" + unsubscribeKey; + } + } diff --git a/src/main/java/ca/intelliware/ihtsdo/mlds/service/mail/EmailVariables.java b/src/main/java/ca/intelliware/ihtsdo/mlds/service/mail/EmailVariables.java index e3cc3687f..ec309ff0b 100644 --- a/src/main/java/ca/intelliware/ihtsdo/mlds/service/mail/EmailVariables.java +++ b/src/main/java/ca/intelliware/ihtsdo/mlds/service/mail/EmailVariables.java @@ -16,4 +16,5 @@ public class EmailVariables { static final String APPLICATION_MEMBER = "applicationMember"; static final String BLANK_TITLE = "blankTitle"; static final String BLANK_BODY = "blankBody"; -} \ No newline at end of file + static final String UNSUBSCRIBE_URL = "unsubscribeUrl"; +} diff --git a/src/main/java/ca/intelliware/ihtsdo/mlds/service/mail/ReleasePackageUpdatedEmailSender.java b/src/main/java/ca/intelliware/ihtsdo/mlds/service/mail/ReleasePackageUpdatedEmailSender.java index 6ce9ff425..17f863f29 100644 --- a/src/main/java/ca/intelliware/ihtsdo/mlds/service/mail/ReleasePackageUpdatedEmailSender.java +++ b/src/main/java/ca/intelliware/ihtsdo/mlds/service/mail/ReleasePackageUpdatedEmailSender.java @@ -1,34 +1,79 @@ package ca.intelliware.ihtsdo.mlds.service.mail; +import ca.intelliware.ihtsdo.mlds.domain.Affiliate; import ca.intelliware.ihtsdo.mlds.domain.ReleasePackage; import ca.intelliware.ihtsdo.mlds.domain.ReleaseVersion; import ca.intelliware.ihtsdo.mlds.domain.User; +import ca.intelliware.ihtsdo.mlds.repository.AffiliateRepository; +import ca.intelliware.ihtsdo.mlds.repository.UserRepository; import com.google.common.collect.Maps; import jakarta.annotation.Resource; +import org.springframework.beans.factory.annotation.Autowired; import org.springframework.stereotype.Service; -import java.util.Locale; -import java.util.Map; +import java.util.*; @Service public class ReleasePackageUpdatedEmailSender { @Resource MailService mailService; @Resource TemplateEvaluator templateEvaluator; @Resource ClientLinkBuilder clientLinkBuilder; + @Autowired + AffiliateRepository affiliateRepository; + @Autowired + UserRepository userRepository; + + public void sendRelasePackageUpdatedEmail(User user, ReleasePackage releasePackage, ReleaseVersion releaseVersion) { + final Locale locale = Locale.forLanguageTag(user.getLangKey()); + Map variables = Maps.newHashMap(); + + // Add existing variables + variables.put(EmailVariables.RELEASE_PACKAGE, releasePackage); + variables.put(EmailVariables.RELEASE_VERSION, releaseVersion); + variables.put(EmailVariables.USER, user); + variables.put(EmailVariables.MEMBERKEY, releasePackage.getMember().getKey()); + variables.put(EmailVariables.VIEW_RELEASE_PACKAGE_URL, clientLinkBuilder.buildViewReleasePackageLink(releasePackage.getReleasePackageId())); + variables.put(EmailVariables.VIEW_PACKAGES_URL, clientLinkBuilder.buildViewReleasesLink()); + + // Generate unsubscribe URL with affiliateId and unsubscribeKey + String unsubscribeUrl = generateUnsubscribeData(user); + variables.put(EmailVariables.UNSUBSCRIBE_URL, unsubscribeUrl); // Add unsubscribe URL to the variables + + // Generate the content using the template + String content = templateEvaluator.evaluateTemplate("releasePackageUpdatedEmail", locale, variables); + + // Get the subject for the email + String subject = templateEvaluator.getTitleFor("releasePackageUpdated", locale); + + // Send the email + mailService.sendEmail(user.getEmail(), subject, content, false, true); + } + + + // New method to generate unsubscribe key and fetch affiliateId + public String generateUnsubscribeData(User user) { + // Fetch affiliate using creator from Affiliate table (returns Affiliate, not Optional) + List affiliateList = affiliateRepository.findByCreatorIgnoreCase(user.getLogin()); + + // Check if affiliate exists and is not empty + if (affiliateList != null && !affiliateList.isEmpty()) { + Affiliate affiliate = affiliateList.get(0); // Get the first affiliate + + long affiliateId = affiliate.getAffiliateId(); // Get affiliateId + + // Generate unsubscribe key if it does not exist already + if (user.getUnsubscribeKey() == null || user.getUnsubscribeKey().isEmpty()) { + user.setUnsubscribeKey(UUID.randomUUID().toString()); // Generate new unsubscribe key + userRepository.save(user); // Save the user with the new unsubscribe key + } + + // Return the unsubscribe URL with affiliateId and unsubscribeKey + return clientLinkBuilder.buildUnsubscribeLink(affiliateId, user.getUnsubscribeKey()); + } else { + // Handle case where affiliate is not found + throw new RuntimeException("Affiliate not found for the user."); + } + } - public void sendRelasePackageUpdatedEmail(User user, ReleasePackage releasePackage, ReleaseVersion releaseVersion) { - final Locale locale = Locale.forLanguageTag(user.getLangKey()); - Map variables = Maps.newHashMap(); - variables.put(EmailVariables.RELEASE_PACKAGE, releasePackage); - variables.put(EmailVariables.RELEASE_VERSION, releaseVersion); - variables.put(EmailVariables.USER, user); - variables.put(EmailVariables.MEMBERKEY, releasePackage.getMember().getKey()); - variables.put(EmailVariables.VIEW_RELEASE_PACKAGE_URL, clientLinkBuilder.buildViewReleasePackageLink(releasePackage.getReleasePackageId())); - variables.put(EmailVariables.VIEW_PACKAGES_URL, clientLinkBuilder.buildViewReleasesLink()); - String content = templateEvaluator.evaluateTemplate("releasePackageUpdatedEmail", locale, variables); - String subject = templateEvaluator.getTitleFor("releasePackageUpdated", locale); - - mailService.sendEmail(user.getEmail(), subject, content, false, true); - } } diff --git a/src/main/java/ca/intelliware/ihtsdo/mlds/web/rest/UserNotifier.java b/src/main/java/ca/intelliware/ihtsdo/mlds/web/rest/UserNotifier.java index 9940ab0fb..ecb703580 100644 --- a/src/main/java/ca/intelliware/ihtsdo/mlds/web/rest/UserNotifier.java +++ b/src/main/java/ca/intelliware/ihtsdo/mlds/web/rest/UserNotifier.java @@ -25,10 +25,10 @@ public void notifyReleasePackageUpdated(ReleaseVersion releaseVersion) { ReleasePackage releasePackage = releaseVersion.getReleasePackage(); Member member = releasePackage.getMember(); for (User user : userMembershipCalculator.approvedReleaseUsersWithAnyMembership(member)) { - if (user.getAcceptNotifications() && - !(Member.KEY_IHTSDO.equals(member.getKey()) && user.getCountryNotificationsOnly())) { + if (user.getAcceptNotifications() && !(Member.KEY_IHTSDO.equals(member.getKey()) && user.getCountryNotificationsOnly())) { releasePackageUpdatedEmailSender.sendRelasePackageUpdatedEmail(user, releasePackage, releaseVersion); } } + } } diff --git a/src/main/java/ca/intelliware/ihtsdo/mlds/web/rest/UserResource.java b/src/main/java/ca/intelliware/ihtsdo/mlds/web/rest/UserResource.java index 27b6ab621..f9d4f87da 100644 --- a/src/main/java/ca/intelliware/ihtsdo/mlds/web/rest/UserResource.java +++ b/src/main/java/ca/intelliware/ihtsdo/mlds/web/rest/UserResource.java @@ -25,6 +25,7 @@ import java.util.List; import java.util.NoSuchElementException; import java.util.Optional; +import java.util.UUID; /** * REST controller for managing users. @@ -37,7 +38,7 @@ public class UserResource { @Autowired UserRepository userRepository; - + @Autowired AffiliateRepository affiliateRepository; @Autowired UserService userService; @@ -113,6 +114,49 @@ public ResponseEntity testRun() { } } + @PostMapping("/unsubscribenotification/{affiliateId}/{key}") + public ResponseEntity unsubscribeOnce( + @PathVariable Long affiliateId, + @PathVariable String key) { + + // Step 1: Get the Affiliate using the affiliateId + Optional affiliateOpt = affiliateRepository.findById(affiliateId); + + if (affiliateOpt.isPresent()) { + Affiliate affiliate = affiliateOpt.get(); + + // Step 2: Get the creator from the Affiliate + String creatorLogin = affiliate.getCreator(); + + // Step 3: Fetch the User by matching the creator's login (no need for Optional here) + User user = userRepository.findByLoginIgnoreCase(creatorLogin); + + if (user != null) { + // Step 4: Check if the key matches + if (!key.equals(user.getUnsubscribeKey())) { + return ResponseEntity.status(HttpStatus.UNAUTHORIZED).body("Invalid or expired unsubscribe link."); + } + + // Step 5: If already unsubscribed, show a message + if (!user.getAcceptNotifications()) { + return ResponseEntity.status(HttpStatus.GONE).body("This unsubscribe link has already been used."); + } + + // Step 6: Unsubscribe the user by setting acceptNotifications to false + user.setAcceptNotifications(false); + + // Step 7: Invalidate the unsubscribe key by generating a new one + user.setUnsubscribeKey(UUID.randomUUID().toString()); + userRepository.save(user); + + return ResponseEntity.ok("You have successfully unsubscribed."); + } + + return ResponseEntity.status(HttpStatus.NOT_FOUND).body("User not found."); + } + + return ResponseEntity.status(HttpStatus.NOT_FOUND).body("Affiliate not found."); + } } diff --git a/src/main/resources/config/liquibase/changelog/db-changelog-002.xml b/src/main/resources/config/liquibase/changelog/db-changelog-002.xml index e77b70b3f..fa429d60f 100644 --- a/src/main/resources/config/liquibase/changelog/db-changelog-002.xml +++ b/src/main/resources/config/liquibase/changelog/db-changelog-002.xml @@ -459,4 +459,10 @@ Drop table event if it exists - + + + + + + + diff --git a/src/main/resources/mails/releasePackageUpdatedEmail.html b/src/main/resources/mails/releasePackageUpdatedEmail.html index 581826120..19c2cb8b7 100644 --- a/src/main/resources/mails/releasePackageUpdatedEmail.html +++ b/src/main/resources/mails/releasePackageUpdatedEmail.html @@ -1,28 +1,33 @@ - - SNOMED International - MLDS Release Package Updated - - + + SNOMED International - MLDS Release Package Updated + + - -

- Dear user, -

-

- The following release package has been updated: ${releasePackage.name}. -
-
You can view and download the release package by visiting the IHTSD-MLDS system. -

-
-

- Regards, -
- SNOMED International - MLDS -

- + +

+ Dear user, +

+

+ The following release package has been updated: ${releasePackage.name}. +
+
You can view and download the release package by visiting the IHTSD-MLDS system. +

+

+ View Release Package +

+

+ Regards, +
+ SNOMED International - MLDS +

+ + +

+ Unsubscribe from these notifications +

+ From c54bfb57cb0bcf1a33c65acd380a35400bb077a5 Mon Sep 17 00:00:00 2001 From: AravinthAasai <109154990+AravinthAasaithambi@users.noreply.github.com> Date: Wed, 28 May 2025 11:32:43 +0530 Subject: [PATCH 55/58] MLDS-1233 Unit test method to unsubscribe a notification of new release packages emails --- .../mlds/web/rest/UserResourceTest.java | 93 ++++++++++++++++++- 1 file changed, 92 insertions(+), 1 deletion(-) diff --git a/src/test/java/ca/intelliware/ihtsdo/mlds/web/rest/UserResourceTest.java b/src/test/java/ca/intelliware/ihtsdo/mlds/web/rest/UserResourceTest.java index c706606b2..7fc033716 100644 --- a/src/test/java/ca/intelliware/ihtsdo/mlds/web/rest/UserResourceTest.java +++ b/src/test/java/ca/intelliware/ihtsdo/mlds/web/rest/UserResourceTest.java @@ -8,7 +8,11 @@ import ca.intelliware.ihtsdo.mlds.config.MySqlTestContainerTest; +import ca.intelliware.ihtsdo.mlds.domain.Affiliate; +import ca.intelliware.ihtsdo.mlds.domain.Member; import ca.intelliware.ihtsdo.mlds.domain.User; +import ca.intelliware.ihtsdo.mlds.repository.AffiliateRepository; +import ca.intelliware.ihtsdo.mlds.repository.MemberRepository; import ca.intelliware.ihtsdo.mlds.service.UserService; import ca.intelliware.ihtsdo.mlds.web.rest.dto.AffiliateDetailsResponseDTO; import jakarta.transaction.Transactional; @@ -20,6 +24,7 @@ import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.context.SpringBootTest; import org.springframework.http.MediaType; +import org.springframework.test.annotation.DirtiesContext; import org.springframework.test.context.ActiveProfiles; import org.springframework.test.context.TestPropertySource; import org.springframework.test.context.junit4.SpringJUnit4ClassRunner; @@ -30,6 +35,7 @@ import java.util.Collections; import java.util.NoSuchElementException; +import java.util.UUID; /** @@ -46,7 +52,9 @@ public class UserResourceTest extends MySqlTestContainerTest { @Autowired private UserRepository userRepository; - + @Autowired + private AffiliateRepository affiliateRepository; + @Autowired public MemberRepository memberRepository; @Mock public UserService userService; @@ -59,6 +67,7 @@ public void setup() { userResource = new UserResource(); userResource.userRepository = userRepository; userResource.userService = userService; + userResource.affiliateRepository = affiliateRepository; this.restUserMockMvc = MockMvcBuilders.standaloneSetup(userResource).build(); } @@ -173,4 +182,86 @@ public void updatePrimaryEmailShouldReturnInternalServerError_WhenUnexpectedErro Mockito.verify(userService, Mockito.times(1)).updatePrimaryEmail(login, updatedEmail); } + private Member createUniqueMember() { + String uniqueKey = "SE-" + UUID.randomUUID(); + Member member = new Member(uniqueKey, 1); + member.setName("Sweden " + UUID.randomUUID()); + member.setPromotePackages(false); + return memberRepository.save(member); + } + + private Affiliate createAffiliateWithCreator(String creatorLogin, Member member) { + Affiliate affiliate = new Affiliate(); + affiliate.setCreator(creatorLogin); + affiliate.setHomeMember(member); + return affiliateRepository.save(affiliate); + } + + private User createUser(String login, boolean acceptNotifications, String unsubscribeKey) { + User user = new User(); + user.setLogin(login); + user.setAcceptNotifications(acceptNotifications); + user.setUnsubscribeKey(unsubscribeKey); + return userRepository.save(user); + } + @Test + public void unsubscribeOnceShouldReturnOk_WhenValidRequest() throws Exception { + String key = "valid-key"; + Member member = createUniqueMember(); + Affiliate affiliate = createAffiliateWithCreator("creatorLogin", member); + createUser("creatorLogin", true, key); + + restUserMockMvc.perform(post("/api/unsubscribenotification/" + affiliate.getAffiliateId() + "/" + key) + .accept(MediaType.APPLICATION_JSON)) + .andExpect(status().isOk()) + .andExpect(content().string("You have successfully unsubscribed.")); + } + + @Test + public void unsubscribeOnceShouldReturnUnauthorized_WhenKeyMismatch() throws Exception { + Member member = createUniqueMember(); + Affiliate affiliate = createAffiliateWithCreator("creatorLogin", member); + createUser("creatorLogin", true, "correct-key"); + + restUserMockMvc.perform(post("/api/unsubscribenotification/" + affiliate.getAffiliateId() + "/wrong-key") + .accept(MediaType.APPLICATION_JSON)) + .andExpect(status().isUnauthorized()) + .andExpect(content().string("Invalid or expired unsubscribe link.")); + } + + @Test + public void unsubscribeOnceShouldReturnGone_WhenAlreadyUnsubscribed() throws Exception { + String key = "some-key"; + Member member = createUniqueMember(); + Affiliate affiliate = createAffiliateWithCreator("creatorLogin", member); + createUser("creatorLogin", false, key); + + restUserMockMvc.perform(post("/api/unsubscribenotification/" + affiliate.getAffiliateId() + "/" + key) + .accept(MediaType.APPLICATION_JSON)) + .andExpect(status().isGone()) + .andExpect(content().string("This unsubscribe link has already been used.")); + } + + @Test + public void unsubscribeOnceShouldReturnNotFound_WhenUserNotFound() throws Exception { + String key = "some-key"; + Member member = createUniqueMember(); + Affiliate affiliate = createAffiliateWithCreator("nonexistentUser", member); + + restUserMockMvc.perform(post("/api/unsubscribenotification/" + affiliate.getAffiliateId() + "/" + key) + .accept(MediaType.APPLICATION_JSON)) + .andExpect(status().isNotFound()) + .andExpect(content().string("User not found.")); + } + + @Test + public void unsubscribeOnceShouldReturnNotFound_WhenAffiliateNotFound() throws Exception { + restUserMockMvc.perform(post("/api/unsubscribenotification/9999/any-key") + .accept(MediaType.APPLICATION_JSON)) + .andExpect(status().isNotFound()) + .andExpect(content().string("Affiliate not found.")); + } + + + } From 73fc034897d058a6baa20d3f0d0cd66cead5fcc4 Mon Sep 17 00:00:00 2001 From: jaykamal Date: Wed, 28 May 2025 12:35:19 +0530 Subject: [PATCH 56/58] MLDS-1128 MLDS user permissions refinements --- .../config/init/ReleaseMasterConfigLoad.java | 40 ++ .../domain/PermissionVisibilityResponse.java | 41 ++ .../ihtsdo/mlds/domain/ReleasePackage.java | 12 + .../mlds/domain/ReleasePackageAccess.java | 58 +++ .../mlds/domain/ReleasePackageAccessId.java | 46 ++ .../mlds/domain/ReleasePackageConfig.java | 78 ++++ .../mlds/domain/ReleasePermissionType.java | 10 + .../ReleasePackageAccessRepository.java | 41 ++ .../ReleasePackageConfigRepository.java | 17 + .../repository/ReleasePackageRepository.java | 18 + .../service/ReleasePackageAccessService.java | 406 ++++++++++++++++++ .../mlds/service/ReleasePackageService.java | 353 +++++++++++++++ .../web/rest/ReleasePackagesResource.java | 312 ++++++++++++-- .../ihtsdo/mlds/web/rest/Routes.java | 5 + .../ihtsdo/mlds/web/rest/UserResource.java | 21 +- .../rest/dto/ReleasePermissionRequestDTO.java | 39 ++ .../liquibase/changelog/db-changelog-002.xml | 73 ++++ 17 files changed, 1537 insertions(+), 33 deletions(-) create mode 100644 src/main/java/ca/intelliware/ihtsdo/mlds/config/init/ReleaseMasterConfigLoad.java create mode 100644 src/main/java/ca/intelliware/ihtsdo/mlds/domain/PermissionVisibilityResponse.java create mode 100644 src/main/java/ca/intelliware/ihtsdo/mlds/domain/ReleasePackageAccess.java create mode 100644 src/main/java/ca/intelliware/ihtsdo/mlds/domain/ReleasePackageAccessId.java create mode 100644 src/main/java/ca/intelliware/ihtsdo/mlds/domain/ReleasePackageConfig.java create mode 100644 src/main/java/ca/intelliware/ihtsdo/mlds/domain/ReleasePermissionType.java create mode 100644 src/main/java/ca/intelliware/ihtsdo/mlds/repository/ReleasePackageAccessRepository.java create mode 100644 src/main/java/ca/intelliware/ihtsdo/mlds/repository/ReleasePackageConfigRepository.java create mode 100644 src/main/java/ca/intelliware/ihtsdo/mlds/service/ReleasePackageAccessService.java create mode 100644 src/main/java/ca/intelliware/ihtsdo/mlds/service/ReleasePackageService.java create mode 100644 src/main/java/ca/intelliware/ihtsdo/mlds/web/rest/dto/ReleasePermissionRequestDTO.java diff --git a/src/main/java/ca/intelliware/ihtsdo/mlds/config/init/ReleaseMasterConfigLoad.java b/src/main/java/ca/intelliware/ihtsdo/mlds/config/init/ReleaseMasterConfigLoad.java new file mode 100644 index 000000000..3f7b1bebd --- /dev/null +++ b/src/main/java/ca/intelliware/ihtsdo/mlds/config/init/ReleaseMasterConfigLoad.java @@ -0,0 +1,40 @@ +package ca.intelliware.ihtsdo.mlds.config.init; + +import ca.intelliware.ihtsdo.mlds.domain.ReleasePackageConfig; +import ca.intelliware.ihtsdo.mlds.repository.ReleasePackageConfigRepository; +import jakarta.annotation.PostConstruct; +import org.springframework.stereotype.Component; + +import java.util.List; + +@Component +public class ReleaseMasterConfigLoad { + + private final ReleasePackageConfigRepository repository; + + public ReleaseMasterConfigLoad(ReleasePackageConfigRepository repository) { + this.repository = repository; + } + + @PostConstruct + public void loadDefaultsIfEmpty() { + if (repository.count() == 0) { + repository.saveAll(List.of( + create("ONLINE"), + create("ALPHA/BETA"), + create("OFFLINE"), + create("ALL") + )); + } + } + + private ReleasePackageConfig create(String releaseType) { + ReleasePackageConfig config = new ReleasePackageConfig(); + config.setReleaseType(releaseType); + config.setReleasePackageAccess("ALL"); + config.setReleasePermissionType("NOT_SELECTED"); + config.setUserList("[]"); + config.setActive(false); + return config; + } +} diff --git a/src/main/java/ca/intelliware/ihtsdo/mlds/domain/PermissionVisibilityResponse.java b/src/main/java/ca/intelliware/ihtsdo/mlds/domain/PermissionVisibilityResponse.java new file mode 100644 index 000000000..f9f2a2fd6 --- /dev/null +++ b/src/main/java/ca/intelliware/ihtsdo/mlds/domain/PermissionVisibilityResponse.java @@ -0,0 +1,41 @@ +package ca.intelliware.ihtsdo.mlds.domain; + +public class PermissionVisibilityResponse { + + private boolean isMasterPermission; + private String permissionType; + private String releaseType; + + // Constructor + public PermissionVisibilityResponse(boolean isMasterPermission, String permissionType, String releaseType) { + this.isMasterPermission = isMasterPermission; + this.permissionType = permissionType; + this.releaseType = releaseType; + } + + // Getters and Setters + public boolean isMasterPermission() { + return isMasterPermission; + } + + public void setMasterPermission(boolean isMasterPermission) { + this.isMasterPermission = isMasterPermission; + } + + public String getPermissionType() { + return permissionType; + } + + public void setPermissionType(String permissionType) { + this.permissionType = permissionType; + } + + public String getReleaseType() { + return releaseType; + } + + public void setReleaseType(String releaseType) { + this.releaseType = releaseType; + } +} + diff --git a/src/main/java/ca/intelliware/ihtsdo/mlds/domain/ReleasePackage.java b/src/main/java/ca/intelliware/ihtsdo/mlds/domain/ReleasePackage.java index e18da2cac..2d4688f60 100644 --- a/src/main/java/ca/intelliware/ihtsdo/mlds/domain/ReleasePackage.java +++ b/src/main/java/ca/intelliware/ihtsdo/mlds/domain/ReleasePackage.java @@ -65,6 +65,10 @@ public class ReleasePackage extends BaseEntity { @Fetch(FetchMode.SELECT) Set releaseVersions = Sets.newHashSet(); + @Enumerated(EnumType.STRING) + @Column(name="permission_type") + private ReleasePermissionType permissionType; + public ReleasePackage() { } @@ -177,4 +181,12 @@ public Instant getUpdatedAt() { public void setUpdatedAt(Instant updatedAt) { this.updatedAt = updatedAt; } + + public ReleasePermissionType getPermissionType() { + return permissionType; + } + + public void setPermissionType(ReleasePermissionType permissionType) { + this.permissionType = permissionType; + } } diff --git a/src/main/java/ca/intelliware/ihtsdo/mlds/domain/ReleasePackageAccess.java b/src/main/java/ca/intelliware/ihtsdo/mlds/domain/ReleasePackageAccess.java new file mode 100644 index 000000000..518800bfd --- /dev/null +++ b/src/main/java/ca/intelliware/ihtsdo/mlds/domain/ReleasePackageAccess.java @@ -0,0 +1,58 @@ +package ca.intelliware.ihtsdo.mlds.domain; + +import jakarta.persistence.*; + +@Entity +@Table(name = "release_package_access") +@IdClass(ReleasePackageAccessId.class) +public class ReleasePackageAccess { + + @Id + @Column(name = "release_package_id") + private Long releasePackageId; + + @Id + @Column(name = "user_id") + private Long userId; + +// @ManyToOne +// @JoinColumn(name = "release_package_id", referencedColumnName = "release_package_id", insertable = false, updatable = false) +// private ReleasePackage releasePackage; +// +// @ManyToOne +// @JoinColumn(name = "user_id", referencedColumnName = "user_id", insertable = false, updatable = false) +// private User user; + + + public Long getReleasePackageId() { + return releasePackageId; + } + + public void setReleasePackageId(Long releasePackageId) { + this.releasePackageId = releasePackageId; + } + + public Long getUserId() { + return userId; + } + + public void setUserId(Long userId) { + this.userId = userId; + } + +// public ReleasePackage getReleasePackage() { +// return releasePackage; +// } +// +// public void setReleasePackage(ReleasePackage releasePackage) { +// this.releasePackage = releasePackage; +// } +// +// public User getUser() { +// return user; +// } +// +// public void setUser(User user) { +// this.user = user; +// } +} diff --git a/src/main/java/ca/intelliware/ihtsdo/mlds/domain/ReleasePackageAccessId.java b/src/main/java/ca/intelliware/ihtsdo/mlds/domain/ReleasePackageAccessId.java new file mode 100644 index 000000000..d6d2c44c3 --- /dev/null +++ b/src/main/java/ca/intelliware/ihtsdo/mlds/domain/ReleasePackageAccessId.java @@ -0,0 +1,46 @@ +package ca.intelliware.ihtsdo.mlds.domain; + +import java.io.Serializable; +import java.util.Objects; + +public class ReleasePackageAccessId implements Serializable { + + private Long releasePackageId; + private Long userId; + + public ReleasePackageAccessId() {} + + public ReleasePackageAccessId(Long releasePackageId, Long userId) { + this.releasePackageId = releasePackageId; + this.userId = userId; + } + + public Long getReleasePackageId() { + return releasePackageId; + } + + public void setReleasePackageId(Long releasePackageId) { + this.releasePackageId = releasePackageId; + } + + public Long getUserId() { + return userId; + } + + public void setUserId(Long userId) { + this.userId = userId; + } + + @Override + public boolean equals(Object o) { + if (this == o) return true; + if (o == null || getClass() != o.getClass()) return false; + ReleasePackageAccessId that = (ReleasePackageAccessId) o; + return Objects.equals(releasePackageId, that.releasePackageId) && Objects.equals(userId, that.userId); + } + + @Override + public int hashCode() { + return Objects.hash(releasePackageId, userId); + } +} diff --git a/src/main/java/ca/intelliware/ihtsdo/mlds/domain/ReleasePackageConfig.java b/src/main/java/ca/intelliware/ihtsdo/mlds/domain/ReleasePackageConfig.java new file mode 100644 index 000000000..b3952c359 --- /dev/null +++ b/src/main/java/ca/intelliware/ihtsdo/mlds/domain/ReleasePackageConfig.java @@ -0,0 +1,78 @@ +package ca.intelliware.ihtsdo.mlds.domain; + +import jakarta.persistence.*; + +@Entity +@Table(name="release_config_master") +public class ReleasePackageConfig { + + @Id + @GeneratedValue(strategy = GenerationType.SEQUENCE, generator = "hibernate_sequence_generator") + @SequenceGenerator(name = "hibernate_sequence_generator", sequenceName = "mlds.hibernate_sequence", allocationSize = 1) + @Column(name="config_id") + Long configId; + + @Column(name="release_type") + public String releaseType; + + @Column(name="release_package") + public String releasePackageAccess; + + @Column(name="release_permission_type") + public String releasePermissionType; + + @Column(name = "user_list", columnDefinition = "JSON") + private String userList; + + @Column(name = "is_active") + private Boolean isActive; + + + public Long getConfigId() { + return configId; + } + + public void setConfigId(Long configId) { + this.configId = configId; + } + + public String getReleaseType() { + return releaseType; + } + + public void setReleaseType(String releaseType) { + this.releaseType = releaseType; + } + + public String getReleasePackageAccess() { + return releasePackageAccess; + } + + public void setReleasePackageAccess(String releasePackageAccess) { + this.releasePackageAccess = releasePackageAccess; + } + + public String getReleasePermissionType() { + return releasePermissionType; + } + + public void setReleasePermissionType(String releasePermissionType) { + this.releasePermissionType = releasePermissionType; + } + + public String getUserList() { + return userList; + } + + public void setUserList(String userList) { + this.userList = userList; + } + + public Boolean getActive() { + return isActive; + } + + public void setActive(Boolean active) { + isActive = active; + } +} diff --git a/src/main/java/ca/intelliware/ihtsdo/mlds/domain/ReleasePermissionType.java b/src/main/java/ca/intelliware/ihtsdo/mlds/domain/ReleasePermissionType.java new file mode 100644 index 000000000..586cb747c --- /dev/null +++ b/src/main/java/ca/intelliware/ihtsdo/mlds/domain/ReleasePermissionType.java @@ -0,0 +1,10 @@ +package ca.intelliware.ihtsdo.mlds.domain; + +public enum ReleasePermissionType { + ADMIN_ONLY, + ADMIN_AND_STAFF, + ADMIN_STAFF_AFFILIATES, + ADMIN_STAFF_SELECTED_USERS, + EVERYONE, + NOT_SELECTED; +} diff --git a/src/main/java/ca/intelliware/ihtsdo/mlds/repository/ReleasePackageAccessRepository.java b/src/main/java/ca/intelliware/ihtsdo/mlds/repository/ReleasePackageAccessRepository.java new file mode 100644 index 000000000..ab22a0f80 --- /dev/null +++ b/src/main/java/ca/intelliware/ihtsdo/mlds/repository/ReleasePackageAccessRepository.java @@ -0,0 +1,41 @@ +package ca.intelliware.ihtsdo.mlds.repository; + +import ca.intelliware.ihtsdo.mlds.domain.ReleasePackageAccess; +import ca.intelliware.ihtsdo.mlds.domain.ReleasePackageAccessId; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Modifying; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; +import org.springframework.transaction.annotation.Transactional; + +import java.util.List; + +public interface ReleasePackageAccessRepository extends JpaRepository { + List findByReleasePackageId(Long releasePackageId); + + boolean existsByReleasePackageIdAndUserId(long releasePackageId, long userId); + + @Transactional + @Modifying + @Query("DELETE FROM ReleasePackageAccess rpa WHERE rpa.releasePackageId = :packageId") + void deleteReleasePackageAccessByPackageId(@Param("packageId") Long packageId); + + @Transactional + @Modifying + @Query("DELETE FROM ReleasePackageAccess rpa WHERE rpa.releasePackageId IN :packageIds") + void deleteReleasePackageAccessByPackageIds(@Param("packageIds") List packageIds); + + @Transactional + @Modifying + @Query("DELETE FROM ReleasePackageAccess rpa WHERE rpa.releasePackageId = :packageId AND rpa.userId = :userId") + void deleteReleasePackageAccessByPackageIdAndUserId(@Param("packageId") Long packageId, @Param("userId") Long userId); + + @Query(value = "SELECT u.login FROM user u JOIN release_package_access rpa ON u.user_id = rpa.user_id WHERE rpa.release_package_id = :releasePackageId",nativeQuery = true) + List findLoginsByReleasePackageId(@Param("releasePackageId") Long releasePackageId); + + @Query(value = "SELECT u.login FROM user u WHERE u.user_id IN :userIds", nativeQuery = true) + List findLoginsByUserIds(@Param("userIds") List userIds); + + + +} diff --git a/src/main/java/ca/intelliware/ihtsdo/mlds/repository/ReleasePackageConfigRepository.java b/src/main/java/ca/intelliware/ihtsdo/mlds/repository/ReleasePackageConfigRepository.java new file mode 100644 index 000000000..e7a0c6b2a --- /dev/null +++ b/src/main/java/ca/intelliware/ihtsdo/mlds/repository/ReleasePackageConfigRepository.java @@ -0,0 +1,17 @@ +package ca.intelliware.ihtsdo.mlds.repository; + +import ca.intelliware.ihtsdo.mlds.domain.ReleasePackageConfig; +import org.springframework.data.jpa.repository.JpaRepository; + +import java.util.List; + +public interface ReleasePackageConfigRepository extends JpaRepository { + + ReleasePackageConfig findByReleaseType(String releaseType); + + ReleasePackageConfig findByReleaseTypeIgnoreCase(String releaseType); + + List findByReleasePermissionTypeNot(String releasePermissionType); + + List findByReleasePermissionType(String releasePermissionType); +} diff --git a/src/main/java/ca/intelliware/ihtsdo/mlds/repository/ReleasePackageRepository.java b/src/main/java/ca/intelliware/ihtsdo/mlds/repository/ReleasePackageRepository.java index c68f41a57..9a521d705 100644 --- a/src/main/java/ca/intelliware/ihtsdo/mlds/repository/ReleasePackageRepository.java +++ b/src/main/java/ca/intelliware/ihtsdo/mlds/repository/ReleasePackageRepository.java @@ -2,11 +2,29 @@ import ca.intelliware.ihtsdo.mlds.domain.Member; import ca.intelliware.ihtsdo.mlds.domain.ReleasePackage; +import ca.intelliware.ihtsdo.mlds.domain.ReleasePermissionType; import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Modifying; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; +import org.springframework.transaction.annotation.Transactional; import java.util.List; public interface ReleasePackageRepository extends JpaRepository { List findByMemberOrderByPriorityDesc(Member member); + + + List findAllByReleasePackageIdIn(List ids); + + @Transactional + @Modifying + @Query("UPDATE ReleasePackage rp SET rp.permissionType = :permissionType WHERE rp.releasePackageId IN :packageIds") + void updatePermissionTypeForPackages(@Param("permissionType") ReleasePermissionType permissionType, @Param("packageIds") List packageIds); + + @Transactional + @Modifying + @Query("UPDATE ReleasePackage rp SET rp.permissionType = :permissionType") + void updatePermissionTypeForAllPackages(@Param("permissionType") ReleasePermissionType permissionType); } diff --git a/src/main/java/ca/intelliware/ihtsdo/mlds/service/ReleasePackageAccessService.java b/src/main/java/ca/intelliware/ihtsdo/mlds/service/ReleasePackageAccessService.java new file mode 100644 index 000000000..f2f48baa1 --- /dev/null +++ b/src/main/java/ca/intelliware/ihtsdo/mlds/service/ReleasePackageAccessService.java @@ -0,0 +1,406 @@ +package ca.intelliware.ihtsdo.mlds.service; + +import ca.intelliware.ihtsdo.mlds.domain.ReleasePackage; +import ca.intelliware.ihtsdo.mlds.domain.ReleasePackageConfig; +import ca.intelliware.ihtsdo.mlds.domain.ReleasePermissionType; +import ca.intelliware.ihtsdo.mlds.domain.User; +import ca.intelliware.ihtsdo.mlds.repository.ReleasePackageAccessRepository; +import ca.intelliware.ihtsdo.mlds.repository.ReleasePackageConfigRepository; +import ca.intelliware.ihtsdo.mlds.repository.UserRepository; +import ca.intelliware.ihtsdo.mlds.security.ihtsdo.CurrentSecurityContext; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.*; +import java.util.stream.Collectors; + +@Service +@Transactional +public class ReleasePackageAccessService { + + private final Logger log = LoggerFactory.getLogger(ReleasePackageAccessService.class); + + private final ReleasePackageConfigRepository releasePackageConfigRepository; + private final ReleasePackageAccessRepository releasePackageAccessRepository; + private final UserRepository userRepository; + private final CurrentSecurityContext currentSecurityContext; + + public ReleasePackageAccessService(ReleasePackageConfigRepository releasePackageConfigRepository, + ReleasePackageAccessRepository releasePackageAccessRepository, + UserRepository userRepository, + CurrentSecurityContext currentSecurityContext) { + this.releasePackageConfigRepository = releasePackageConfigRepository; + this.releasePackageAccessRepository = releasePackageAccessRepository; + this.userRepository = userRepository; + this.currentSecurityContext = currentSecurityContext; + } + + private static final String PERMISSION_EVERYONE = "EVERYONE"; + private static final String PERMISSION_ADMIN_ONLY = "ADMIN_ONLY"; + private static final String PERMISSION_ADMIN_AND_STAFF = "ADMIN_AND_STAFF"; + private static final String PERMISSION_ADMIN_STAFF_AFFILIATES = "ADMIN_STAFF_AFFILIATES"; + + private static final String PERMISSION_NOT_SELECTED = "NOT_SELECTED"; + private static final String RELEASE_TYPE_ONLINE = "ONLINE"; + private static final String RELEASE_TYPE_ALPHA_BETA = "ALPHA/BETA"; + private static final String RELEASE_TYPE_OFFLINE = "OFFLINE"; + + + public Collection getAccessiblePackagesForStaff( + Collection allPackages, + List onlinePackages, + List alphaBetaPackages, + List offlinePackages, + String staffMemberKey + ) { + Set result = new HashSet<>(); + if(currentSecurityContext.isStaff()) { + result = getStaffOwnedPackages(allPackages, staffMemberKey); + } + + List grantedPermissions = + releasePackageConfigRepository.findByReleasePermissionTypeNot(PERMISSION_NOT_SELECTED); + + List notGrantedPermissions = + releasePackageConfigRepository.findByReleasePermissionType(PERMISSION_NOT_SELECTED); + + // Handle granted permissions + for (ReleasePackageConfig config : grantedPermissions) { + String type = config.getReleaseType(); + String permission = config.getReleasePermissionType(); + + if ("ALL".equalsIgnoreCase(type)) { + return handleAllTypePermission(permission, allPackages, result); + } + + if (!PERMISSION_ADMIN_ONLY.equalsIgnoreCase(permission)) { + addByType(result, type, onlinePackages, alphaBetaPackages, offlinePackages); + } + } + + // Handle not granted permissions + for (ReleasePackageConfig config : notGrantedPermissions) { + String type = config.getReleaseType().toUpperCase(); + List filtered = getFilteredPackagesByType( + type, onlinePackages, alphaBetaPackages, offlinePackages + ); + result.addAll(filtered); + } + + return result; + } + + private Set getStaffOwnedPackages(Collection packages, String staffMemberKey) { + return packages.stream() + .filter(pkg -> pkg.getMember().getKey().equals(staffMemberKey)) + .collect(Collectors.toSet()); + } + + private Collection handleAllTypePermission( + String permissionType, + Collection allPackages, + Set staffOwned + ) { + if (PERMISSION_ADMIN_ONLY.equalsIgnoreCase(permissionType)) { + return new HashSet<>(staffOwned); + } + return allPackages; + } + + private void addByType( + Set result, + String type, + List online, + List alphaBeta, + List offline + ) { + switch (type.toUpperCase()) { + case RELEASE_TYPE_ONLINE -> result.addAll(online); + case RELEASE_TYPE_ALPHA_BETA -> result.addAll(alphaBeta); + case RELEASE_TYPE_OFFLINE -> result.addAll(offline); + default -> log.warn("Unknown release type encountered: {}", type); + } + } + + private List getFilteredPackagesByType( + String type, + List online, + List alphaBeta, + List offline + ) { + List source = switch (type) { + case RELEASE_TYPE_ONLINE -> online; + case RELEASE_TYPE_ALPHA_BETA -> alphaBeta; + case RELEASE_TYPE_OFFLINE -> offline; + default -> Collections.emptyList(); + }; + + return source.stream() + .filter(pkg -> pkg.getPermissionType() != ReleasePermissionType.ADMIN_ONLY && + pkg.getPermissionType() != ReleasePermissionType.NOT_SELECTED) + .toList(); + } + + private List getPackageListByType( + String type, + List online, + List alphaBeta, + List offline + ) { + return switch (type) { + case RELEASE_TYPE_ONLINE -> online; + case RELEASE_TYPE_ALPHA_BETA -> alphaBeta; + case RELEASE_TYPE_OFFLINE -> offline; + default -> Collections.emptyList(); + }; + } + + public Collection getAccessiblePackagesForUnauthenticatedUser( + Collection allPackages, + List onlinePackages, + List alphaBetaPackages, + List offlinePackages + ) { + Set result = new HashSet<>(); + + List grantedPermissions = + releasePackageConfigRepository.findByReleasePermissionTypeNot(PERMISSION_NOT_SELECTED); + List notGrantedPermissions = + releasePackageConfigRepository.findByReleasePermissionType(PERMISSION_NOT_SELECTED); + + handleGrantedPermissionsForUnauthUser(grantedPermissions, result, allPackages, onlinePackages, alphaBetaPackages, offlinePackages); + handleNotGrantedPermissionsForUnauthUser(notGrantedPermissions, result, onlinePackages, alphaBetaPackages, offlinePackages); + + return result; + } + + private void handleGrantedPermissionsForUnauthUser( + List grantedPermissions, + Set result, + Collection allPackages, + List onlinePackages, + List alphaBetaPackages, + List offlinePackages + ) { + for (ReleasePackageConfig config : grantedPermissions) { + String type = config.getReleaseType(); + String permission = config.getReleasePermissionType(); + + if ("ALL".equalsIgnoreCase(type)) { + if (isRestrictedPermission(permission)) { + return; + } + if (PERMISSION_EVERYONE.equalsIgnoreCase(permission)) { + result.addAll(allPackages); + return; + } + } else if (PERMISSION_EVERYONE.equalsIgnoreCase(permission)) { + addPackagesByType(result, type, onlinePackages, alphaBetaPackages, offlinePackages); + } + } + } + + private void handleNotGrantedPermissionsForUnauthUser( + List notGrantedPermissions, + Set result, + List onlinePackages, + List alphaBetaPackages, + List offlinePackages + ) { + for (ReleasePackageConfig config : notGrantedPermissions) { + String type = config.getReleaseType().toUpperCase(); + List packages = getPackageListByType(type, onlinePackages, alphaBetaPackages, offlinePackages); + result.addAll(packages.stream() + .filter(pkg -> pkg.getPermissionType() == ReleasePermissionType.EVERYONE) + .toList()); + } + } + + + public Collection getAccessiblePackagesForUser( + Collection allPackages, + List onlinePackages, + List alphaBetaPackages, + List offlinePackages, + String username + ) { + Set finalResult = new HashSet<>(); + User user = userRepository.findByLoginIgnoreCase(username); + long userId = user.getUserId(); + + List grantedPermissions = + releasePackageConfigRepository.findByReleasePermissionTypeNot(PERMISSION_NOT_SELECTED); + List notGrantedPermissions = + releasePackageConfigRepository.findByReleasePermissionType(PERMISSION_NOT_SELECTED); + + processGrantedPermissionsForUser(grantedPermissions, userId, allPackages, finalResult, onlinePackages, alphaBetaPackages, offlinePackages); + processNotGrantedPermissionsForUser(notGrantedPermissions, userId, finalResult, onlinePackages, alphaBetaPackages, offlinePackages); + + return finalResult; + } + + private void processGrantedPermissionsForUser( + List configs, + long userId, + Collection allPackages, + Set result, + List onlinePackages, + List alphaBetaPackages, + List offlinePackages + ) { + for (ReleasePackageConfig config : configs) { + String type = config.getReleaseType().toUpperCase(); + String permission = config.getReleasePermissionType(); + + if ("ALL".equals(type)) { + if (isAdminOnlyOrStaff(permission)) return; + if (isEveryoneOrAffiliates(permission) || config.getUserList().contains(String.valueOf(userId))) { + result.addAll(allPackages); + return; + } + } else if (!isAdminOnlyOrStaff(permission) && + (isEveryoneOrAffiliates(permission) || config.getUserList().contains(String.valueOf(userId)))) { + addPackagesByType(result, type, onlinePackages, alphaBetaPackages, offlinePackages); + } + } + } + + private void processNotGrantedPermissionsForUser( + List configs, + long userId, + Set result, + List onlinePackages, + List alphaBetaPackages, + List offlinePackages + ) { + for (ReleasePackageConfig config : configs) { + String type = config.getReleaseType().toUpperCase(); + List packages = getPackageListByType(type, onlinePackages, alphaBetaPackages, offlinePackages); + + result.addAll(packages.stream() + .filter(pkg -> pkg.getPermissionType() == ReleasePermissionType.EVERYONE || + pkg.getPermissionType() == ReleasePermissionType.ADMIN_STAFF_AFFILIATES) + .toList()); + + packages.stream() + .filter(pkg -> pkg.getPermissionType() == ReleasePermissionType.ADMIN_STAFF_SELECTED_USERS) + .filter(pkg -> releasePackageAccessRepository.existsByReleasePackageIdAndUserId(pkg.getReleasePackageId(), userId)) + .forEach(result::add); + } + } + + private void addPackagesByType( + Set target, + String type, + List onlinePackages, + List alphaBetaPackages, + List offlinePackages + ) { + target.addAll(getPackageListByType(type, onlinePackages, alphaBetaPackages, offlinePackages)); + } + + private boolean isRestrictedPermission(String permission) { + return List.of(PERMISSION_ADMIN_ONLY, PERMISSION_ADMIN_AND_STAFF, + PERMISSION_ADMIN_STAFF_AFFILIATES, "ADMIN_STAFF_SELECTED_USERS") + .contains(permission.toUpperCase()); + } + + private boolean isAdminOnlyOrStaff(String permission) { + return PERMISSION_ADMIN_ONLY.equalsIgnoreCase(permission) || + PERMISSION_ADMIN_AND_STAFF.equalsIgnoreCase(permission); + } + + private boolean isEveryoneOrAffiliates(String permission) { + return PERMISSION_EVERYONE.equalsIgnoreCase(permission) || + PERMISSION_ADMIN_STAFF_AFFILIATES.equalsIgnoreCase(permission); + } + + + // for each release version access + + public boolean isStaffOwner(ReleasePackage rp) { + return currentSecurityContext.isStaff() && + currentSecurityContext.getStaffMemberKey().equals(rp.getMember().getKey()); + } + + public boolean isAdminOnly(ReleasePackage rp, ReleasePackageConfig config, ReleasePackageConfig master) { + + if (rp.getPermissionType() == ReleasePermissionType.ADMIN_ONLY) { + return true; + } + + if (rp.getPermissionType() == ReleasePermissionType.NOT_SELECTED) { + String masterPermission = master.getReleasePermissionType(); + String configPermission = config.getReleasePermissionType(); + + if (ReleasePermissionType.ADMIN_ONLY.toString().equals(masterPermission) + || ReleasePermissionType.ADMIN_ONLY.toString().equals(configPermission)) { + return true; + } + + return ReleasePermissionType.NOT_SELECTED.toString().equals(masterPermission) + && ReleasePermissionType.NOT_SELECTED.toString().equals(configPermission); + } + + return false; + } + + + public ResponseEntity handlePublicAccess(ReleasePackage rp, ReleasePackageConfig config, ReleasePackageConfig master) { + if (rp.getPermissionType() == ReleasePermissionType.EVERYONE) return ResponseEntity.ok(rp); + + if (rp.getPermissionType() == ReleasePermissionType.NOT_SELECTED && + (ReleasePermissionType.EVERYONE.toString().equals(master.getReleasePermissionType()) || + ReleasePermissionType.EVERYONE.toString().equals(config.getReleasePermissionType()))) { + return ResponseEntity.ok(rp); + } + + return new ResponseEntity<>(HttpStatus.UNAUTHORIZED); + } + + public ResponseEntity handleUserAccess(ReleasePackage rp, ReleasePackageConfig config, ReleasePackageConfig master, long rpId) { + long userId = userRepository.findByLoginIgnoreCase(currentSecurityContext.getCurrentUserName()).getUserId(); + ReleasePermissionType type = rp.getPermissionType(); + + if (type == ReleasePermissionType.ADMIN_ONLY || type == ReleasePermissionType.ADMIN_AND_STAFF) + return new ResponseEntity<>(HttpStatus.UNAUTHORIZED); + + if (type == ReleasePermissionType.EVERYONE || type == ReleasePermissionType.ADMIN_STAFF_AFFILIATES) + return ResponseEntity.ok(rp); + + if (type == ReleasePermissionType.ADMIN_STAFF_SELECTED_USERS && + releasePackageAccessRepository.existsByReleasePackageIdAndUserId(rpId, userId)) { + return ResponseEntity.ok(rp); + } + + if (type == ReleasePermissionType.NOT_SELECTED) { + if (isEveryoneOrAffiliate(master) || isEveryoneOrAffiliate(config)) { + return ResponseEntity.ok(rp); + } + + if (isSelectedUser(master, userId) || isSelectedUser(config, userId)) { + return ResponseEntity.ok(rp); + } + } + + return new ResponseEntity<>(HttpStatus.UNAUTHORIZED); + } + + private boolean isEveryoneOrAffiliate(ReleasePackageConfig cfg) { + String perm = cfg.getReleasePermissionType(); + return ReleasePermissionType.EVERYONE.toString().equals(perm) + || ReleasePermissionType.ADMIN_STAFF_AFFILIATES.toString().equals(perm); + } + + private boolean isSelectedUser(ReleasePackageConfig cfg, long userId) { + return ReleasePermissionType.ADMIN_STAFF_SELECTED_USERS.toString().equals(cfg.getReleasePermissionType()) + && cfg.getUserList().contains(String.valueOf(userId)); + } + + +} + diff --git a/src/main/java/ca/intelliware/ihtsdo/mlds/service/ReleasePackageService.java b/src/main/java/ca/intelliware/ihtsdo/mlds/service/ReleasePackageService.java new file mode 100644 index 000000000..9be23500e --- /dev/null +++ b/src/main/java/ca/intelliware/ihtsdo/mlds/service/ReleasePackageService.java @@ -0,0 +1,353 @@ +package ca.intelliware.ihtsdo.mlds.service; + +import ca.intelliware.ihtsdo.mlds.domain.*; +import ca.intelliware.ihtsdo.mlds.repository.ReleasePackageAccessRepository; +import ca.intelliware.ihtsdo.mlds.repository.ReleasePackageConfigRepository; +import ca.intelliware.ihtsdo.mlds.repository.ReleasePackageRepository; +import ca.intelliware.ihtsdo.mlds.repository.UserRepository; +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.core.type.TypeReference; +import com.fasterxml.jackson.databind.ObjectMapper; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.*; + +@Service +@Transactional +public class ReleasePackageService { + + private final ReleasePackageRepository releasePackageRepository; + private final ReleasePackageAccessRepository releasePackageAccessRepository; + private final ReleasePackageConfigRepository releasePackageConfigRepository; + private final UserRepository userRepository; + private final ObjectMapper objectMapper; + + private static final String RELEASE_TYPE_ONLINE = "online"; + private static final String RELEASE_TYPE_ALPHA_BETA = "alpha/beta"; + private static final String RELEASE_TYPE_OFFLINE = "offline"; + private static final String USER_ACCESS_REVOKED_SUCCESS = "User access revoked successfully."; + + + public ReleasePackageService(ReleasePackageRepository releasePackageRepository, + ReleasePackageAccessRepository releasePackageAccessRepository, + ReleasePackageConfigRepository releasePackageConfigRepository, + UserRepository userRepository, + ObjectMapper objectMapper) { + this.releasePackageRepository = releasePackageRepository; + this.releasePackageAccessRepository = releasePackageAccessRepository; + this.releasePackageConfigRepository = releasePackageConfigRepository; + this.userRepository = userRepository; + this.objectMapper = objectMapper; + } + + + public void updateReleasePackagesPermission(Map request) { + List releasePackages = (List) request.get("releases"); + String releasePackageType = (String) request.get("releasePackageType"); + List users = (List) request.get("users"); + + List ids = releasePackages.stream().map(Integer::longValue).toList(); + List packages = releasePackageRepository.findAllByReleasePackageIdIn(ids); + + Set affectedTypes = new HashSet<>(); + List accessList = new ArrayList<>(); + + for (ReleasePackage pkg : packages) { + updatePermissionType(pkg, releasePackageType); + + if (requiresUserAccess(pkg, users)) { + accessList.addAll(createAccessList(pkg, users)); + } + + if (!pkg.getReleaseVersions().isEmpty()) { + Set types = extractAffectedTypes(pkg); + affectedTypes.addAll(types); + } + } + + if (!accessList.isEmpty()) { + releasePackageAccessRepository.saveAll(accessList); + } + + releasePackageRepository.saveAll(packages); + + revokePermissions(affectedTypes); + } + + private void updatePermissionType(ReleasePackage pkg, String type) { + pkg.setPermissionType(ReleasePermissionType.valueOf(type)); + } + + private boolean requiresUserAccess(ReleasePackage pkg, List users) { + return pkg.getPermissionType() == ReleasePermissionType.ADMIN_STAFF_SELECTED_USERS && users != null; + } + + private List createAccessList(ReleasePackage pkg, List users) { + List list = new ArrayList<>(); + for (String user : users) { + ReleasePackageAccess access = new ReleasePackageAccess(); + access.setReleasePackageId(pkg.getReleasePackageId()); + access.setUserId(Long.parseLong(user)); + list.add(access); + } + return list; + } + + private Set extractAffectedTypes(ReleasePackage pkg) { + Map typeCount = new HashMap<>(); + for (ReleaseVersion version : pkg.getReleaseVersions()) { + if (!version.isArchive()) { + String type = version.getReleaseType(); + typeCount.put(type, typeCount.getOrDefault(type, 0) + 1); + } + } + + List priority = Arrays.asList(RELEASE_TYPE_ONLINE, RELEASE_TYPE_ALPHA_BETA, RELEASE_TYPE_OFFLINE); + for (String type : priority) { + if (typeCount.containsKey(type)) { + return Set.of(type.toUpperCase()); + } + } + + return Collections.emptySet(); + } + + private void revokePermissions(Set releaseTypes) { + for (String type : releaseTypes) { + deactivateConfig(type); + } + deactivateConfig("ALL"); + } + + + // updateReleaseMasterConfig + + public void updateReleaseMasterConfig(Map request) { + String releaseType = (String) request.get("releaseType"); + String releasePackage = (String) request.get("releasePackage"); + String releasePermissionType = (String) request.get("releasePermissionType"); + List users = (List) request.get("users"); + + String userListJson = serializeUserList(users); + + ReleasePackageConfig config = releasePackageConfigRepository.findByReleaseType(releaseType); + if (config == null) { + throw new IllegalStateException("Release type config not found: " + releaseType); + } + + config.setReleasePackageAccess(releasePackage); + config.setReleasePermissionType(releasePermissionType); + config.setActive(true); + config.setUserList(userListJson); + releasePackageConfigRepository.save(config); + + classifyAndRevokePackages(releaseType); + } + + private String serializeUserList(List users) { + try { + return objectMapper.writeValueAsString(users); + } catch (JsonProcessingException e) { + throw new IllegalStateException("Failed to serialize user list", e); + } + } + + private void classifyAndRevokePackages(String releaseType) { + Collection releasePackages = releasePackageRepository.findAll(); + + List onlinePackages = new ArrayList<>(); + List alphaBetaPackages = new ArrayList<>(); + List offlinePackages = new ArrayList<>(); + + for (ReleasePackage pkg : releasePackages) { + classifyPackage(pkg, onlinePackages, alphaBetaPackages, offlinePackages); + } + + switch (releaseType.toLowerCase()) { + case RELEASE_TYPE_ONLINE -> revokeAndUpdate(onlinePackages); + case RELEASE_TYPE_ALPHA_BETA -> revokeAndUpdate(alphaBetaPackages); + case RELEASE_TYPE_OFFLINE -> revokeAndUpdate(offlinePackages); + case "all" -> revokeAllTypes(); + default -> throw new IllegalArgumentException("Unsupported release type: " + releaseType); + } + } + + public void classifyPackage( + ReleasePackage pkg, + List onlinePackages, + List alphaBetaPackages, + List offlinePackages + ) { + boolean hasOnline = false; + boolean hasAlphaBeta = false; + + for (ReleaseVersion version : pkg.getReleaseVersions()) { + if (!version.isArchive()) { + String type = version.getReleaseType().toLowerCase(); + + if (type.equals(RELEASE_TYPE_ONLINE)) { + hasOnline = true; + break; + } + + if (type.equals(RELEASE_TYPE_ALPHA_BETA)) { + hasAlphaBeta = true; + } + } + } + + + if (hasOnline) { + onlinePackages.add(pkg); + } else if (hasAlphaBeta) { + alphaBetaPackages.add(pkg); + } else { + offlinePackages.add(pkg); + } + } + + + private void revokeAndUpdate(List packages) { + List ids = packages.stream() + .map(ReleasePackage::getReleasePackageId) + .toList(); + + releasePackageRepository.updatePermissionTypeForPackages(ReleasePermissionType.NOT_SELECTED, ids); + releasePackageAccessRepository.deleteReleasePackageAccessByPackageIds(ids); + deactivateConfig("ALL"); + } + + private void revokeAllTypes() { + releasePackageRepository.updatePermissionTypeForAllPackages(ReleasePermissionType.NOT_SELECTED); + releasePackageAccessRepository.deleteAll(); + + List configs = releasePackageConfigRepository.findAll(); + for (ReleasePackageConfig config : configs) { + if (!"ALL".equalsIgnoreCase(config.getReleaseType())) { + config.setReleasePermissionType(ReleasePermissionType.NOT_SELECTED.name()); + config.setActive(false); + config.setUserList("[]"); + releasePackageConfigRepository.save(config); + } + } + } + + private void deactivateConfig(String type) { + ReleasePackageConfig config = releasePackageConfigRepository.findByReleaseType(type); + if (config != null) { + config.setReleasePermissionType(ReleasePermissionType.NOT_SELECTED.toString()); + config.setActive(false); + config.setUserList("[]"); + releasePackageConfigRepository.save(config); + } + } + + + // user access revoking by clicking from ui logics + + + public String revokeIndividualUserAccess(String releaseId, String requestUser) throws JsonProcessingException { + List masterConfigIds = Arrays.asList("ONLINE", "ALPHA/BETA", "OFFLINE", "ALL"); + + User user = userRepository.findByLoginIgnoreCase(requestUser); + long userId = user.getUserId(); + + if (masterConfigIds.contains(releaseId)) { + ReleasePackageConfig masterPermission = releasePackageConfigRepository.findByReleaseType(releaseId); + List userList = objectMapper.readValue(masterPermission.getUserList(), new TypeReference>() {}); + + if (userList.remove(userId)) { + masterPermission.setUserList(objectMapper.writeValueAsString(userList)); + releasePackageConfigRepository.save(masterPermission); + return USER_ACCESS_REVOKED_SUCCESS; + } + return "User ID not found in the list."; + } + + releasePackageAccessRepository.deleteReleasePackageAccessByPackageIdAndUserId(Long.valueOf(releaseId), userId); + return USER_ACCESS_REVOKED_SUCCESS; + } + + public String revokeAllReleaseAccess(String releaseId) { + List masterConfigIds = Arrays.asList("ONLINE", "ALPHA/BETA", "OFFLINE", "ALL"); + + if (masterConfigIds.contains(releaseId)) { + ReleasePackageConfig config = releasePackageConfigRepository.findByReleaseType(releaseId); + config.setUserList("[]"); + config.setReleasePermissionType(String.valueOf(ReleasePermissionType.NOT_SELECTED)); + config.setActive(false); + releasePackageConfigRepository.save(config); + } else { + Optional config = releasePackageRepository.findById(Long.valueOf(releaseId)); + if (config.isPresent()) { + ReleasePackage releasePackage = config.get(); + + if (releasePackage.getPermissionType().equals(ReleasePermissionType.ADMIN_STAFF_SELECTED_USERS)) { + releasePackageAccessRepository.deleteReleasePackageAccessByPackageId(Long.valueOf(releaseId)); + } + + releasePackage.setPermissionType(ReleasePermissionType.NOT_SELECTED); + releasePackageRepository.save(releasePackage); + } else { + return "Release package not found."; + } + } + + return USER_ACCESS_REVOKED_SUCCESS; + } + + // check visiblity + + public PermissionVisibilityResponse getVisibilityDetails(ReleasePackage releasePackage) { + String packageType = categorizePackage(releasePackage.getReleaseVersions()); + ReleasePackageConfig releaseTypePermission = releasePackageConfigRepository.findByReleaseTypeIgnoreCase(packageType); + + boolean isMasterPermission = true; + String permissionType = ""; + + if (releaseTypePermission != null && Boolean.FALSE.equals(releaseTypePermission.getActive())) { + isMasterPermission = false; + permissionType = releasePackage.getPermissionType().toString(); + } else { + permissionType = releaseTypePermission != null ? releaseTypePermission.getReleasePermissionType() : ""; + } + + return new PermissionVisibilityResponse(isMasterPermission, permissionType, packageType); + } + + public ReleasePackageConfig getMasterPermission(String releaseType) { + return releasePackageConfigRepository.findByReleaseType(releaseType); + } + + public String categorizePackage(Set releaseVersions) { + boolean hasOnline = false; + boolean hasAlphaOrBeta = false; + + for (ReleaseVersion version : releaseVersions) { + if (!version.isArchive()) { + String releaseType = version.getReleaseType().toLowerCase(); + + if (RELEASE_TYPE_ONLINE.equals(releaseType)) { + hasOnline = true; + break; // No need to continue if we've already found an online version + } + + if (RELEASE_TYPE_ALPHA_BETA.equals(releaseType)) { + hasAlphaOrBeta = true; + } + } + } + + if (hasOnline) { + return RELEASE_TYPE_ONLINE; + } + else if (hasAlphaOrBeta) { + return RELEASE_TYPE_ALPHA_BETA; + } + else { + return RELEASE_TYPE_OFFLINE; + } + } + +} diff --git a/src/main/java/ca/intelliware/ihtsdo/mlds/web/rest/ReleasePackagesResource.java b/src/main/java/ca/intelliware/ihtsdo/mlds/web/rest/ReleasePackagesResource.java index 63fb911cb..c70a40c6c 100644 --- a/src/main/java/ca/intelliware/ihtsdo/mlds/web/rest/ReleasePackagesResource.java +++ b/src/main/java/ca/intelliware/ihtsdo/mlds/web/rest/ReleasePackagesResource.java @@ -1,18 +1,20 @@ package ca.intelliware.ihtsdo.mlds.web.rest; -import ca.intelliware.ihtsdo.mlds.domain.File; -import ca.intelliware.ihtsdo.mlds.domain.ReleasePackage; -import ca.intelliware.ihtsdo.mlds.domain.ReleaseVersion; -import ca.intelliware.ihtsdo.mlds.repository.BlobHelper; -import ca.intelliware.ihtsdo.mlds.repository.FileRepository; -import ca.intelliware.ihtsdo.mlds.repository.ReleasePackageRepository; +import ca.intelliware.ihtsdo.mlds.domain.*; +import ca.intelliware.ihtsdo.mlds.repository.*; import ca.intelliware.ihtsdo.mlds.security.AuthoritiesConstants; import ca.intelliware.ihtsdo.mlds.security.ihtsdo.CurrentSecurityContext; +import ca.intelliware.ihtsdo.mlds.service.ReleasePackageAccessService; import ca.intelliware.ihtsdo.mlds.service.ReleasePackagePrioritizer; +import ca.intelliware.ihtsdo.mlds.service.ReleasePackageService; import ca.intelliware.ihtsdo.mlds.service.UserMembershipAccessor; import ca.intelliware.ihtsdo.mlds.web.SessionService; +import ca.intelliware.ihtsdo.mlds.web.rest.dto.ReleasePermissionRequestDTO; import com.codahale.metrics.annotation.Timed; +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.core.type.TypeReference; +import com.fasterxml.jackson.databind.ObjectMapper; import jakarta.annotation.Resource; import jakarta.annotation.security.PermitAll; import jakarta.annotation.security.RolesAllowed; @@ -68,6 +70,23 @@ public class ReleasePackagesResource { @Autowired ReleasePackagePrioritizer releasePackagePrioritizer; + + + @Autowired + ReleasePackageAccessRepository releasePackageAccessRepository; + + @Autowired + ReleasePackageConfigRepository releasePackageConfigRepository; + + @Autowired + UserRepository userRepository; + + @Autowired + ReleasePackageService releasePackageService; + + @Autowired + ReleasePackageAccessService releasePackageAccessService; + // // //////////////////////////////////////////////////////////////////////////////////////////////////////// // // Release Packages @@ -76,25 +95,67 @@ public class ReleasePackagesResource { produces = MediaType.APPLICATION_JSON_VALUE) @PermitAll @Timed - public ResponseEntity> getReleasePackages() { - List releasePackages = releasePackageRepository.findAll(); + public ResponseEntity> getReleasePackages() { - releasePackages = filterReleasePackagesByOnline(releasePackages); - List response = releasePackages.stream() - .map(releasePackage -> { - Set notArchivedVersions = releasePackage.getReleaseVersions().stream() - .filter(releaseVersion -> !releaseVersion.isArchive()) - .collect(Collectors.toSet()); - if (!notArchivedVersions.isEmpty()) { - releasePackage.setReleaseVersions(notArchivedVersions); // Keep only archived versions - return releasePackage; // Include this package - } - return null; // Skip this package - }) - .filter(Objects::nonNull) // Exclude null packages - .toList(); - return new ResponseEntity<>(response, HttpStatus.OK); + Collection releasePackages = releasePackageRepository.findAll(); + + //admin role checks - no restriction to admin role returns all the packages + if(currentSecurityContext.isAdmin()){ + return new ResponseEntity<>(releasePackages, HttpStatus.OK); + } + + // split online alphabeta offline package logic + List onlinePackages = new ArrayList<>(); + List alphaBetaPackages = new ArrayList<>(); + List offlinePackages = new ArrayList<>(); + List finalMasterResult = new ArrayList<>(); + + for (ReleasePackage eachReleasePackage : releasePackages) { + releasePackageService.classifyPackage(eachReleasePackage, onlinePackages, alphaBetaPackages, offlinePackages); + } + + if (currentSecurityContext.isStaff() || currentSecurityContext.isMember()) { + Collection staffAccessiblePackages = releasePackageAccessService.getAccessiblePackagesForStaff( + releasePackages, + onlinePackages, + alphaBetaPackages, + offlinePackages, + currentSecurityContext.getStaffMemberKey() + ); + return ResponseEntity.ok(staffAccessiblePackages); + } + + if (currentSecurityContext.isUser()) { + Collection userAccessiblePackages = + releasePackageAccessService.getAccessiblePackagesForUser( + releasePackages, + onlinePackages, + alphaBetaPackages, + offlinePackages, + currentSecurityContext.getCurrentUserName() + ); + + return new ResponseEntity<>(userAccessiblePackages, HttpStatus.OK); + } + + if (!currentSecurityContext.isUser() + && !currentSecurityContext.isAdmin() + && !currentSecurityContext.isStaff()) { + + Collection result = releasePackageAccessService + .getAccessiblePackagesForUnauthenticatedUser( + releasePackages, + onlinePackages, + alphaBetaPackages, + offlinePackages + ); + + return new ResponseEntity<>(result, HttpStatus.OK); + } + + return new ResponseEntity<>(finalMasterResult, HttpStatus.OK); } + @GetMapping(value = Routes.ARCHIVE_RELEASE_PACKAGES, produces = MediaType.APPLICATION_JSON_VALUE) @RolesAllowed(AuthoritiesConstants.ADMIN) @@ -171,20 +232,34 @@ private boolean isPackagePublished(ReleasePackage releasePackage) { return result; } - @GetMapping(value = Routes.RELEASE_PACKAGE, - produces = MediaType.APPLICATION_JSON_VALUE) + + @GetMapping(value = Routes.RELEASE_PACKAGE, produces = MediaType.APPLICATION_JSON_VALUE) @RolesAllowed({AuthoritiesConstants.ANONYMOUS, AuthoritiesConstants.USER, AuthoritiesConstants.MEMBER, AuthoritiesConstants.STAFF, AuthoritiesConstants.ADMIN}) @Timed public ResponseEntity getReleasePackage(@PathVariable long releasePackageId) { Optional optionalReleasePackage = releasePackageRepository.findById(releasePackageId); + if (optionalReleasePackage.isEmpty()) return new ResponseEntity<>(HttpStatus.NOT_FOUND); - if (optionalReleasePackage.isEmpty()) { - return new ResponseEntity<>(HttpStatus.NOT_FOUND); + ReleasePackage releasePackage = optionalReleasePackage.get(); + String releaseType = releasePackageService.categorizePackage(releasePackage.getReleaseVersions()); + ReleasePackageConfig config = releasePackageConfigRepository.findByReleaseType(releaseType); + ReleasePackageConfig masterConfig = releasePackageConfigRepository.findByReleaseType("ALL"); + + if (currentSecurityContext.isAdmin()) { + return ResponseEntity.ok(releasePackage); } - ReleasePackage releasePackage = filterReleasePackageByAuthority(optionalReleasePackage.get()); + if (currentSecurityContext.isStaff() || currentSecurityContext.isMember()) { + if (releasePackageAccessService.isStaffOwner(releasePackage)) return ResponseEntity.ok(releasePackage); + if (releasePackageAccessService.isAdminOnly(releasePackage, config, masterConfig)) return new ResponseEntity<>(HttpStatus.UNAUTHORIZED); + return ResponseEntity.ok(releasePackage); + } - return new ResponseEntity<>(releasePackage, HttpStatus.OK); + if (currentSecurityContext.isUser()) { + return releasePackageAccessService.handleUserAccess(releasePackage, config, masterConfig, releasePackageId); + } + + return releasePackageAccessService.handlePublicAccess(releasePackage, config, masterConfig); } private ReleasePackage filterReleasePackageByAuthority(ReleasePackage releasePackage) { @@ -353,4 +428,183 @@ private File updateFile(MultipartFile multipartFile, File existingFile) throws I fileRepository.save(newFile); return newFile; } + + @PutMapping(value = Routes.RELEASE_PACKAGE_PERMISSION, produces = MediaType.APPLICATION_JSON_VALUE) + @RolesAllowed(AuthoritiesConstants.ADMIN) + @Timed + public ResponseEntity updateReleasePackageType(@PathVariable long releasePackageId, @RequestBody Map request) { + + Optional optionalReleasePackage = releasePackageRepository.findById(releasePackageId); + if (optionalReleasePackage.isEmpty()) { + return new ResponseEntity<>(HttpStatus.NOT_FOUND); + } + ReleasePackage releasePackage = optionalReleasePackage.get(); + authorizationChecker.checkCanEditReleasePackage(releasePackage); + String releasePackageType = (String) request.get("releasePackageType"); + if (releasePackageType != null) { + releasePackage.setPermissionType(ReleasePermissionType.valueOf(releasePackageType)); + } + + if(releasePackage.getPermissionType() == ReleasePermissionType.ADMIN_STAFF_SELECTED_USERS) { + List users = (List) request.get("users"); + if (users != null && !users.isEmpty()) { + users.forEach(user ->{ + ReleasePackageAccess access = new ReleasePackageAccess(); + access.setReleasePackageId(releasePackage.getReleasePackageId()); + access.setUserId(Long.parseLong(user)); + releasePackageAccessRepository.save(access); + }); + } + } + + releasePackageRepository.save(releasePackage); + return new ResponseEntity<>(releasePackage, HttpStatus.OK); + } + + @PutMapping(value = Routes.RELEASE_PACKAGES_PERMISSION, produces = MediaType.APPLICATION_JSON_VALUE) + @RolesAllowed(AuthoritiesConstants.ADMIN) + @Timed + public ResponseEntity updateReleasePackagesType(@RequestBody Map request) { + releasePackageService.updateReleasePackagesPermission(request); + return new ResponseEntity<>(HttpStatus.OK); + } + + @PostMapping(value = Routes.RELEASE_PACKAGES_MASTER_PERMISSION, produces = MediaType.APPLICATION_JSON_VALUE) + @RolesAllowed(AuthoritiesConstants.ADMIN) + @Timed + public ResponseEntity updateReleaseMasterConfig(@RequestBody Map request) { + try { + releasePackageService.updateReleaseMasterConfig(request); + return new ResponseEntity<>(HttpStatus.OK); + } catch (RuntimeException e) { + return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).body(e.getMessage()); + } + } + + + @GetMapping(value = "/api/releasePermission", produces = MediaType.APPLICATION_JSON_VALUE) + @RolesAllowed(AuthoritiesConstants.ADMIN) + public ResponseEntity> getReleasePermissions() { + List permissionDTOList = releasePackageRepository.findAll().stream() + .filter(releasePackage -> releasePackage.getPermissionType() != ReleasePermissionType.NOT_SELECTED) + .map(releasePackage -> new ReleasePermissionRequestDTO( + releasePackage.getReleasePackageId(), + releasePackage.getName(), + releasePackage.getPermissionType()) + ) + .toList(); + + return ResponseEntity.ok(permissionDTOList); + } + + + @GetMapping(value = "/api/masterReleasePermission", produces = MediaType.APPLICATION_JSON_VALUE) + @RolesAllowed(AuthoritiesConstants.ADMIN) + public ResponseEntity> getMasterReleasePermissions() { + List masterPermissionList = releasePackageConfigRepository.findAll().stream() + .filter(releasePackage -> !"NOT_SELECTED".equals(releasePackage.getReleasePermissionType())) + .toList(); + + return ResponseEntity.ok(masterPermissionList); + } + + + @GetMapping("/api/{releasePackageId}/usersAccess") + @RolesAllowed(AuthoritiesConstants.ADMIN) + public ResponseEntity> getUsersByReleasePackageId(@PathVariable Long releasePackageId) { + List logins = releasePackageAccessRepository.findLoginsByReleasePackageId(releasePackageId); + return ResponseEntity.ok(logins); + } + + @GetMapping("/api/releaseTypes") + @RolesAllowed(AuthoritiesConstants.ADMIN) + public ResponseEntity> getAllReleaseTypes() { + List releaseTypes = releasePackageConfigRepository.findAll() + .stream() + .map(ReleasePackageConfig::getReleaseType) + .distinct() + .toList(); + + return ResponseEntity.ok(releaseTypes); + } + + + @GetMapping("/api/masterUsersAccess") + @RolesAllowed(AuthoritiesConstants.ADMIN) + public ResponseEntity> getMasterAccessUsersByReleasePackageId(@RequestParam String releaseType) { + ReleasePackageConfig result = releasePackageConfigRepository.findByReleaseType(releaseType); + + if (result == null || result.getUserList() == null || result.getUserList().isEmpty()) { + return ResponseEntity.ok(Collections.emptyList()); + } + + List userIds; + + try { + userIds = new ObjectMapper().readValue(result.getUserList(), new TypeReference>() {}); + } catch (JsonProcessingException e) { + return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR) + .body(Collections.singletonList("Failed to parse user list")); + } + + if (userIds.isEmpty()) { + return ResponseEntity.ok(Collections.emptyList()); + } + + List logins = releasePackageAccessRepository.findLoginsByUserIds(userIds); + return ResponseEntity.ok(logins); + } + + @PutMapping(value = "/api/userAccessRevoke", produces = MediaType.APPLICATION_JSON_VALUE) + @RolesAllowed(AuthoritiesConstants.ADMIN) + public ResponseEntity updateIndividualUserAccess(@RequestBody Map request) { + String releaseId = request.get("releaseId") != null ? request.get("releaseId").toString() : null; + String requestUser = (String) request.get("user"); + + try { + String resultMessage = releasePackageService.revokeIndividualUserAccess(releaseId, requestUser); + if (resultMessage.equals("User access revoked successfully.")) { + return ResponseEntity.ok(resultMessage); + } + return ResponseEntity.status(HttpStatus.NOT_FOUND).body(resultMessage); + } catch (JsonProcessingException e) { + return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).body("Error processing user list."); + } + } + + @PutMapping(value = "/api/releaseAccessRevoke", produces = MediaType.APPLICATION_JSON_VALUE) + @RolesAllowed(AuthoritiesConstants.ADMIN) + public ResponseEntity releaseAccessRevoke(@RequestBody Map request) { + String releaseId = request.get("releaseId") != null ? request.get("releaseId").toString() : null; + + String resultMessage = releasePackageService.revokeAllReleaseAccess(releaseId); + if (resultMessage.equals("User access revoked successfully.")) { + return ResponseEntity.ok(resultMessage); + } + return ResponseEntity.status(HttpStatus.NOT_FOUND).body(resultMessage); + } + + + @GetMapping("/api/viewVisiblity/{releasePackageId}") + @RolesAllowed(AuthoritiesConstants.ADMIN) + public ResponseEntity getVisibilityDetails(@PathVariable Long releasePackageId) { + String releaseType = "ALL"; + ReleasePackageConfig masterPermission = releasePackageService.getMasterPermission(releaseType); + + if (masterPermission != null && Boolean.FALSE.equals(masterPermission.getActive())) { + Optional releasePackage = releasePackageRepository.findById(releasePackageId); + + if (releasePackage.isPresent()) { + PermissionVisibilityResponse response = releasePackageService.getVisibilityDetails(releasePackage.get()); + return ResponseEntity.ok(response); + } else { + return ResponseEntity.notFound().build(); + } + } else { + String permissionType = masterPermission != null ? masterPermission.getReleasePermissionType() : ""; + PermissionVisibilityResponse response = new PermissionVisibilityResponse(true, permissionType, releaseType); + return ResponseEntity.ok(response); + } + } + } diff --git a/src/main/java/ca/intelliware/ihtsdo/mlds/web/rest/Routes.java b/src/main/java/ca/intelliware/ihtsdo/mlds/web/rest/Routes.java index acf344966..60d77b4a5 100644 --- a/src/main/java/ca/intelliware/ihtsdo/mlds/web/rest/Routes.java +++ b/src/main/java/ca/intelliware/ihtsdo/mlds/web/rest/Routes.java @@ -224,4 +224,9 @@ public class Routes { static final String RELEASE_VERSION_DEPENDENCY_NAMES = "/api/getVersionDependencyNames/{releaseVersionId}"; + public static final String RELEASE_PACKAGES_PERMISSION = "/api/releasePackages/updatePermissionType"; + public static final String RELEASE_PACKAGES_MASTER_PERMISSION = "/api/releasePackages/ConfigPermissionType"; + public static final String RELEASE_PACKAGE_PERMISSION = "/api/releasePackages/updatePermissionType/{releasePackageId}"; + + } diff --git a/src/main/java/ca/intelliware/ihtsdo/mlds/web/rest/UserResource.java b/src/main/java/ca/intelliware/ihtsdo/mlds/web/rest/UserResource.java index f9d4f87da..1a4f44f38 100644 --- a/src/main/java/ca/intelliware/ihtsdo/mlds/web/rest/UserResource.java +++ b/src/main/java/ca/intelliware/ihtsdo/mlds/web/rest/UserResource.java @@ -2,12 +2,8 @@ import ca.intelliware.ihtsdo.mlds.domain.Affiliate; -import ca.intelliware.ihtsdo.mlds.domain.AffiliateDetails; -import ca.intelliware.ihtsdo.mlds.domain.Application; import ca.intelliware.ihtsdo.mlds.domain.User; -import ca.intelliware.ihtsdo.mlds.repository.AffiliateDetailsRepository; import ca.intelliware.ihtsdo.mlds.repository.AffiliateRepository; -import ca.intelliware.ihtsdo.mlds.repository.ApplicationRepository; import ca.intelliware.ihtsdo.mlds.repository.UserRepository; import ca.intelliware.ihtsdo.mlds.security.AuthoritiesConstants; import ca.intelliware.ihtsdo.mlds.service.UserService; @@ -26,6 +22,7 @@ import java.util.NoSuchElementException; import java.util.Optional; import java.util.UUID; +import java.util.*; /** * REST controller for managing users. @@ -157,8 +154,24 @@ public ResponseEntity unsubscribeOnce( return ResponseEntity.status(HttpStatus.NOT_FOUND).body("Affiliate not found."); } + @GetMapping("/usersList") + @RolesAllowed(AuthoritiesConstants.ADMIN) + @Timed + public ResponseEntity>> getUsersWithAuthorities() { + List users = userRepository.findAll(); + List> userLogins = users.stream() + .map(user -> { + Map map = new HashMap<>(); + map.put("userId", user.getUserId()); + map.put("login", user.getLogin()); + return map; + }) + .toList(); + + return ResponseEntity.ok(userLogins); + } } diff --git a/src/main/java/ca/intelliware/ihtsdo/mlds/web/rest/dto/ReleasePermissionRequestDTO.java b/src/main/java/ca/intelliware/ihtsdo/mlds/web/rest/dto/ReleasePermissionRequestDTO.java new file mode 100644 index 000000000..7f4aae61b --- /dev/null +++ b/src/main/java/ca/intelliware/ihtsdo/mlds/web/rest/dto/ReleasePermissionRequestDTO.java @@ -0,0 +1,39 @@ +package ca.intelliware.ihtsdo.mlds.web.rest.dto; + +import ca.intelliware.ihtsdo.mlds.domain.ReleasePermissionType; + +public class ReleasePermissionRequestDTO { + private Long releasePackageId; + private String name; + private ReleasePermissionType permissionType; + + public ReleasePermissionRequestDTO(Long releasePackageId, String name, ReleasePermissionType permissionType) { + this.releasePackageId = releasePackageId; + this.name = name; + this.permissionType = permissionType; + } + + public Long getReleasePackageId() { + return releasePackageId; + } + + public void setReleasePackageId(Long releasePackageId) { + this.releasePackageId = releasePackageId; + } + + public String getName() { + return name; + } + + public void setName(String name) { + this.name = name; + } + + public ReleasePermissionType getPermissionType() { + return permissionType; + } + + public void setPermissionType(ReleasePermissionType permissionType) { + this.permissionType = permissionType; + } +} diff --git a/src/main/resources/config/liquibase/changelog/db-changelog-002.xml b/src/main/resources/config/liquibase/changelog/db-changelog-002.xml index fa429d60f..a15d5b351 100644 --- a/src/main/resources/config/liquibase/changelog/db-changelog-002.xml +++ b/src/main/resources/config/liquibase/changelog/db-changelog-002.xml @@ -164,6 +164,7 @@ + 9:04523831040d5448be91706a5e34dbc0 Re-add pending_application, invoices_pending, and usage_reports columns to member table with default 0 @@ -465,4 +466,76 @@ + + + Add ReleaseType Column + + + + + + + + + + + + + + + + + + + + + + + + + + + + + Add Release Config Table + + + + + + + + + + + + + Add User list Column + + + + + + + Add isActive Column + + + + + + + From c25ac28aafa9688024ee56fe5bc0d83eac8cb890 Mon Sep 17 00:00:00 2001 From: jaykamal Date: Wed, 28 May 2025 12:37:27 +0530 Subject: [PATCH 57/58] MLDS-1209 Unit test method to update release package type --- .../web/rest/ReleasePackagesResourceTest.java | 122 ++++++++++++------ 1 file changed, 86 insertions(+), 36 deletions(-) diff --git a/src/test/java/ca/intelliware/ihtsdo/mlds/web/rest/ReleasePackagesResourceTest.java b/src/test/java/ca/intelliware/ihtsdo/mlds/web/rest/ReleasePackagesResourceTest.java index 7e160ee46..0cb4ac8ec 100644 --- a/src/test/java/ca/intelliware/ihtsdo/mlds/web/rest/ReleasePackagesResourceTest.java +++ b/src/test/java/ca/intelliware/ihtsdo/mlds/web/rest/ReleasePackagesResourceTest.java @@ -1,8 +1,13 @@ package ca.intelliware.ihtsdo.mlds.web.rest; import static org.junit.Assert.*; +import static org.mockito.Mockito.*; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; +import ca.intelliware.ihtsdo.mlds.domain.*; +import ca.intelliware.ihtsdo.mlds.repository.*; +import ca.intelliware.ihtsdo.mlds.service.ReleasePackageService; +import com.fasterxml.jackson.databind.ObjectMapper; import org.junit.Assert; import org.junit.Before; import org.junit.Test; @@ -12,25 +17,17 @@ import org.mockito.Mock; import org.mockito.Mockito; import org.mockito.junit.MockitoJUnitRunner; -import org.mockito.junit.jupiter.MockitoSettings; -import org.mockito.quality.Strictness; import org.springframework.http.MediaType; import org.springframework.test.web.servlet.MockMvc; import org.springframework.test.web.servlet.request.MockMvcRequestBuilders; import org.springframework.test.web.servlet.setup.MockMvcBuilders; -import ca.intelliware.ihtsdo.mlds.domain.Member; -import ca.intelliware.ihtsdo.mlds.domain.ReleasePackage; -import ca.intelliware.ihtsdo.mlds.domain.ReleaseVersion; -import ca.intelliware.ihtsdo.mlds.repository.MemberRepository; -import ca.intelliware.ihtsdo.mlds.repository.ReleaseFileRepository; -import ca.intelliware.ihtsdo.mlds.repository.ReleasePackageRepository; -import ca.intelliware.ihtsdo.mlds.repository.ReleaseVersionRepository; import ca.intelliware.ihtsdo.mlds.security.ihtsdo.CurrentSecurityContext; import ca.intelliware.ihtsdo.mlds.security.ihtsdo.SecurityContextSetup; import ca.intelliware.ihtsdo.mlds.service.ReleasePackagePrioritizer; import ca.intelliware.ihtsdo.mlds.service.UserMembershipAccessor; +import java.util.List; import java.util.Optional; @RunWith(MockitoJUnitRunner.class) @@ -64,11 +61,24 @@ public class ReleasePackagesResourceTest { @Mock ReleasePackagePrioritizer releasePackagePrioritizer; - @Captor + @Mock + ReleasePackageAccessRepository releasePackageAccessRepository; + + @Mock + private ReleasePackageConfigRepository releasePackageConfigRepository; + + @Mock + private ObjectMapper objectMapper; + + + @Captor ArgumentCaptor releasePacakgeCaptor; ReleasePackagesResource releasePackagesResource; + @Mock + ReleasePackageService releasePackageService; + SecurityContextSetup securityContextSetup = new SecurityContextSetup(); @@ -82,8 +92,11 @@ public void setup() { releasePackagesResource.releasePackageAuditEvents = releasePackageAuditEvents; releasePackagesResource.userMembershipAccessor = userMembershipAccessor; releasePackagesResource.releasePackagePrioritizer = releasePackagePrioritizer; + releasePackagesResource.releasePackageAccessRepository = releasePackageAccessRepository; + releasePackagesResource.releasePackageService = releasePackageService; - Mockito.when(userMembershipAccessor.getMemberAssociatedWithUser()).thenReturn(new Member("IHTSDO", 1)); + + when(userMembershipAccessor.getMemberAssociatedWithUser()).thenReturn(new Member("IHTSDO", 1)); MockMvcJacksonTestSupport mockMvcJacksonTestSupport = new MockMvcJacksonTestSupport(); mockMvcJacksonTestSupport.memberRepository = memberRepository; @@ -101,13 +114,13 @@ public void testReleasePackageCreateSavesRecord() throws Exception { .accept(MediaType.APPLICATION_JSON_UTF8)) .andExpect(status().isOk()); - Mockito.verify(releasePackageRepository).save(Mockito.any(ReleasePackage.class)); + verify(releasePackageRepository).save(Mockito.any(ReleasePackage.class)); } @Test public void testReleasePackageCreateIgnoresBodyMemberAndAttachesPackageToUserMember() throws Exception { Member userMember = new Member("SE", 1); - Mockito.when(userMembershipAccessor.getMemberAssociatedWithUser()).thenReturn(userMember); + when(userMembershipAccessor.getMemberAssociatedWithUser()).thenReturn(userMember); restReleasePackagesResource.perform(MockMvcRequestBuilders.post(Routes.RELEASE_PACKAGES) .contentType(MediaType.APPLICATION_JSON_UTF8) @@ -115,7 +128,7 @@ public void testReleasePackageCreateIgnoresBodyMemberAndAttachesPackageToUserMem .accept(MediaType.APPLICATION_JSON_UTF8)) .andExpect(status().isOk()); - Mockito.verify(releasePackageRepository).save(releasePacakgeCaptor.capture()); + verify(releasePackageRepository).save(releasePacakgeCaptor.capture()); assertEquals(releasePacakgeCaptor.getValue().getMember(), userMember); } @@ -123,7 +136,7 @@ public void testReleasePackageCreateIgnoresBodyMemberAndAttachesPackageToUserMem public void testReleasePackageCreateUsesBodyMemberForAdmin() throws Exception { Member userMember = new Member("SE", 1); Member bodyMember = new Member("DK", 2); - Mockito.when(memberRepository.findOneByKey("DK")).thenReturn(bodyMember); + when(memberRepository.findOneByKey("DK")).thenReturn(bodyMember); // Mockito.when(userMembershipAccessor.getMemberAssociatedWithUser()).thenReturn(userMember); securityContextSetup.asAdmin(); @@ -133,14 +146,14 @@ public void testReleasePackageCreateUsesBodyMemberForAdmin() throws Exception { .accept(MediaType.APPLICATION_JSON_UTF8)) .andExpect(status().isOk()); - Mockito.verify(releasePackageRepository).save(releasePacakgeCaptor.capture()); + verify(releasePackageRepository).save(releasePacakgeCaptor.capture()); assertEquals(bodyMember, releasePacakgeCaptor.getValue().getMember()); } @Test public void testReleasePackageCreateDefaultsMemberForAdminIfNotInBody() throws Exception { Member userMember = new Member("XX", 1); - Mockito.when(userMembershipAccessor.getMemberAssociatedWithUser()).thenReturn(userMember); + when(userMembershipAccessor.getMemberAssociatedWithUser()).thenReturn(userMember); securityContextSetup.asAdmin(); restReleasePackagesResource.perform(MockMvcRequestBuilders.post(Routes.RELEASE_PACKAGES) @@ -149,7 +162,7 @@ public void testReleasePackageCreateDefaultsMemberForAdminIfNotInBody() throws E .accept(MediaType.APPLICATION_JSON_UTF8)) .andExpect(status().isOk()); - Mockito.verify(releasePackageRepository).save(releasePacakgeCaptor.capture()); + verify(releasePackageRepository).save(releasePacakgeCaptor.capture()); assertEquals(releasePacakgeCaptor.getValue().getMember(), userMember); } @@ -161,7 +174,7 @@ public void testReleasePackageLogsAuditEvent() throws Exception { .accept(MediaType.APPLICATION_JSON_UTF8)) .andExpect(status().isOk()); - Mockito.verify(releasePackageAuditEvents).logCreationOf(Mockito.any(ReleasePackage.class)); + verify(releasePackageAuditEvents).logCreationOf(Mockito.any(ReleasePackage.class)); } @Test @@ -172,15 +185,15 @@ public void testReleasePackageCreateShouldInitializePackagesPriorityToEndOfList( .accept(MediaType.APPLICATION_JSON_UTF8)) .andExpect(status().isOk()); - Mockito.verify(releasePackageRepository).save(Mockito.any(ReleasePackage.class)); + verify(releasePackageRepository).save(Mockito.any(ReleasePackage.class)); - Mockito.verify(releasePackagePrioritizer).prioritize(Mockito.any(ReleasePackage.class), Mockito.eq(ReleasePackagePrioritizer.END_PRIORITY)); + verify(releasePackagePrioritizer).prioritize(Mockito.any(ReleasePackage.class), Mockito.eq(ReleasePackagePrioritizer.END_PRIORITY)); } @Test public void testReleasePackageUpdateFailsForUnknownId() throws Exception { - Mockito.when(releasePackageRepository.findById(999L)).thenReturn(Optional.empty()); + when(releasePackageRepository.findById(999L)).thenReturn(Optional.empty()); restReleasePackagesResource.perform(MockMvcRequestBuilders.put(Routes.RELEASE_PACKAGE, 999L) .contentType(MediaType.APPLICATION_JSON_UTF8) @@ -188,14 +201,14 @@ public void testReleasePackageUpdateFailsForUnknownId() throws Exception { .accept(MediaType.APPLICATION_JSON_UTF8)) .andExpect(status().isNotFound()); - Mockito.verify(releasePackageRepository, Mockito.never()).save(Mockito.any(ReleasePackage.class)); + verify(releasePackageRepository, Mockito.never()).save(Mockito.any(ReleasePackage.class)); } @Test public void testReleasePackageUpdateShouldSave() throws Exception { ReleasePackage releasePackage = new ReleasePackage(); - Mockito.when(releasePackageRepository.findById(1L)).thenReturn(Optional.of(releasePackage)); + when(releasePackageRepository.findById(1L)).thenReturn(Optional.of(releasePackage)); restReleasePackagesResource.perform(MockMvcRequestBuilders.put(Routes.RELEASE_PACKAGE, 1L) .contentType(MediaType.APPLICATION_JSON_UTF8) @@ -203,7 +216,7 @@ public void testReleasePackageUpdateShouldSave() throws Exception { .accept(MediaType.APPLICATION_JSON_UTF8)) .andExpect(status().isOk()); - Mockito.verify(releasePackageRepository).save(Mockito.any(ReleasePackage.class)); + verify(releasePackageRepository).save(Mockito.any(ReleasePackage.class)); } @Test @@ -213,7 +226,7 @@ public void testReleasePackageUpdateShouldOnlyCopySubsetOfFields() throws Except releasePackage.setDescription("originalDescription"); releasePackage.setCreatedBy("originalCreatedBy"); - Mockito.when(releasePackageRepository.findById(1L)).thenReturn(Optional.of(releasePackage)); + when(releasePackageRepository.findById(1L)).thenReturn(Optional.of(releasePackage)); restReleasePackagesResource.perform(MockMvcRequestBuilders.put(Routes.RELEASE_PACKAGE, 1L) .contentType(MediaType.APPLICATION_JSON_UTF8) @@ -222,7 +235,7 @@ public void testReleasePackageUpdateShouldOnlyCopySubsetOfFields() throws Except .andExpect(status().isOk()); ArgumentCaptor savedReleasePackage = ArgumentCaptor.forClass(ReleasePackage.class); - Mockito.verify(releasePackageRepository).save(savedReleasePackage.capture()); + verify(releasePackageRepository).save(savedReleasePackage.capture()); Assert.assertEquals("newName", savedReleasePackage.getValue().getName()); Assert.assertEquals("newDescription", savedReleasePackage.getValue().getDescription()); @@ -238,7 +251,7 @@ public void testReleasePackageUpdateShouldReorderMemberPackagesWhenPriorityChang releasePackage.setCreatedBy("originalCreatedBy"); releasePackage.setPriority(5); - Mockito.when(releasePackageRepository.findById(1L)).thenReturn(Optional.of(releasePackage)); + when(releasePackageRepository.findById(1L)).thenReturn(Optional.of(releasePackage)); restReleasePackagesResource.perform(MockMvcRequestBuilders.put(Routes.RELEASE_PACKAGE, 1L) .contentType(MediaType.APPLICATION_JSON_UTF8) @@ -247,9 +260,9 @@ public void testReleasePackageUpdateShouldReorderMemberPackagesWhenPriorityChang .andExpect(status().isOk()); ArgumentCaptor savedReleasePackage = ArgumentCaptor.forClass(ReleasePackage.class); - Mockito.verify(releasePackageRepository).save(savedReleasePackage.capture()); + verify(releasePackageRepository).save(savedReleasePackage.capture()); - Mockito.verify(releasePackagePrioritizer).prioritize(releasePackage, 9); + verify(releasePackagePrioritizer).prioritize(releasePackage, 9); } @Test @@ -259,14 +272,14 @@ public void testReleasePackageDeleteShouldFailForActiveVersion() throws Exceptio activeVersion.setOnline(true); releasePackage.addReleaseVersion(activeVersion); - Mockito.when(releasePackageRepository.findById(1L)).thenReturn(Optional.of(releasePackage)); + when(releasePackageRepository.findById(1L)).thenReturn(Optional.of(releasePackage)); restReleasePackagesResource.perform(MockMvcRequestBuilders.delete(Routes.RELEASE_PACKAGE, 1L) .contentType(MediaType.APPLICATION_JSON_UTF8) .accept(MediaType.APPLICATION_JSON_UTF8)) .andExpect(status().isConflict()); - Mockito.verify(releasePackageRepository, Mockito.never()).delete(Mockito.any(ReleasePackage.class)); + verify(releasePackageRepository, Mockito.never()).delete(Mockito.any(ReleasePackage.class)); } @Test @@ -276,28 +289,65 @@ public void testReleasePackageDeleteShouldSucceedForInactiveVersion() throws Exc inactiveVersion.setOnline(false); releasePackage.addReleaseVersion(inactiveVersion); - Mockito.when(releasePackageRepository.findById(1L)).thenReturn(Optional.of(releasePackage)); + when(releasePackageRepository.findById(1L)).thenReturn(Optional.of(releasePackage)); restReleasePackagesResource.perform(MockMvcRequestBuilders.delete(Routes.RELEASE_PACKAGE, 1L) .contentType(MediaType.APPLICATION_JSON_UTF8) .accept(MediaType.APPLICATION_JSON_UTF8)) .andExpect(status().isOk()); - Mockito.verify(releasePackageRepository).delete(Mockito.any(ReleasePackage.class)); + verify(releasePackageRepository).delete(Mockito.any(ReleasePackage.class)); } @Test public void testReleasePackageDeleteLogsAuditEvent() throws Exception { ReleasePackage releasePackage = new ReleasePackage(); - Mockito.when(releasePackageRepository.findById(1L)).thenReturn(Optional.of(releasePackage)); + when(releasePackageRepository.findById(1L)).thenReturn(Optional.of(releasePackage)); restReleasePackagesResource.perform(MockMvcRequestBuilders.delete(Routes.RELEASE_PACKAGE, 1L) .contentType(MediaType.APPLICATION_JSON_UTF8) .accept(MediaType.APPLICATION_JSON_UTF8)) .andExpect(status().isOk()); - Mockito.verify(releasePackageAuditEvents).logDeletionOf(Mockito.any(ReleasePackage.class)); + verify(releasePackageAuditEvents).logDeletionOf(Mockito.any(ReleasePackage.class)); } + @Test + public void testUpdateReleasePackageType_AdminStaffSelectedUsers_ShouldSaveAccess() throws Exception { + + ReleasePackage releasePackage = Mockito.spy(new ReleasePackage()); + when(releasePackage.getReleasePackageId()).thenReturn(1L); + when(releasePackageRepository.findById(1L)).thenReturn(Optional.of(releasePackage)); + + Mockito.doNothing().when(authorizationChecker).checkCanEditReleasePackage(Mockito.any()); + + String requestBody = """ + { + "releasePackageType": "ADMIN_STAFF_SELECTED_USERS", + "users": ["100", "101"] + } + """; + + restReleasePackagesResource.perform(MockMvcRequestBuilders.put(Routes.RELEASE_PACKAGE_PERMISSION, 1L) + .contentType(MediaType.APPLICATION_JSON) + .content(requestBody) + .accept(MediaType.APPLICATION_JSON)) + .andExpect(status().isOk()); + + assertEquals(ReleasePermissionType.ADMIN_STAFF_SELECTED_USERS, releasePackage.getPermissionType()); + + ArgumentCaptor captor = ArgumentCaptor.forClass(ReleasePackageAccess.class); + verify(releasePackageAccessRepository, times(2)).save(captor.capture()); + + List savedUserIds = captor.getAllValues().stream() + .map(ReleasePackageAccess::getUserId) + .toList(); + + assertTrue(savedUserIds.contains(100L)); + assertTrue(savedUserIds.contains(101L)); + + verify(releasePackageRepository).save(releasePackage); + } + } From bbc455d16a073d71e15e5797e145407762c60ddd Mon Sep 17 00:00:00 2001 From: jaykamal Date: Wed, 28 May 2025 12:38:03 +0530 Subject: [PATCH 58/58] MLDS-1210 Unit test method to retrieve release packages by user role --- .../service/ReleasePackageServiceTest.java | 239 ++++++++++++++++++ 1 file changed, 239 insertions(+) create mode 100644 src/test/java/ca/intelliware/ihtsdo/mlds/service/ReleasePackageServiceTest.java diff --git a/src/test/java/ca/intelliware/ihtsdo/mlds/service/ReleasePackageServiceTest.java b/src/test/java/ca/intelliware/ihtsdo/mlds/service/ReleasePackageServiceTest.java new file mode 100644 index 000000000..b633c9439 --- /dev/null +++ b/src/test/java/ca/intelliware/ihtsdo/mlds/service/ReleasePackageServiceTest.java @@ -0,0 +1,239 @@ +package ca.intelliware.ihtsdo.mlds.service; + +import ca.intelliware.ihtsdo.mlds.domain.*; +import ca.intelliware.ihtsdo.mlds.repository.ReleasePackageAccessRepository; +import ca.intelliware.ihtsdo.mlds.repository.ReleasePackageConfigRepository; +import ca.intelliware.ihtsdo.mlds.repository.ReleasePackageRepository; +import ca.intelliware.ihtsdo.mlds.repository.UserRepository; +import ca.intelliware.ihtsdo.mlds.security.ihtsdo.CurrentSecurityContext; +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.ObjectMapper; +import org.junit.Before; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.MockitoJUnitRunner; +import org.springframework.http.HttpStatus; + +import java.util.*; + +import static org.junit.Assert.*; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +@RunWith(MockitoJUnitRunner.class) +public class ReleasePackageServiceTest { + + @Mock + private ReleasePackageConfigRepository releasePackageConfigRepository; + + @Mock + private ReleasePackageRepository releasePackageRepository; + + @Mock + private ObjectMapper objectMapper; + + @Mock + private ReleasePackageAccessRepository releasePackageAccessRepository; + + @Mock + private UserRepository userRepository; + + @Mock + private CurrentSecurityContext currentSecurityContext; + + @InjectMocks + private ReleasePackageService releasePackageService; + + @InjectMocks + private ReleasePackageAccessService releasePackageAccessService; + + private User testUser; + private ReleasePackage testPackage; + private ReleasePackageConfig config; + private ReleasePackageConfig masterConfig; + + + @Before + public void setup() { + testUser = new User(); + testUser.setUserId(1L); + testUser.setLogin("user"); + + testPackage = new ReleasePackage(); + testPackage.setPermissionType(ReleasePermissionType.ADMIN_STAFF_SELECTED_USERS); + + config = new ReleasePackageConfig(); + config.setReleasePermissionType("EVERYONE"); + + masterConfig = new ReleasePackageConfig(); + masterConfig.setReleasePermissionType("ADMIN_ONLY"); + } + + @Test + public void shouldUpdateReleaseMasterConfigSuccessfully() throws JsonProcessingException { + + Map request = new HashMap<>(); + request.put("releaseType", "ONLINE"); + request.put("releasePermissionType", "ADMIN_STAFF_SELECTED_USERS"); + List users = List.of("101", "102"); + request.put("users", users); + + String userListJson = "[\"101\",\"102\"]"; + + ReleasePackageConfig existingConfig = new ReleasePackageConfig(); + existingConfig.setReleaseType("ONLINE"); + + when(releasePackageConfigRepository.findByReleaseType("ONLINE")).thenReturn(existingConfig); + when(objectMapper.writeValueAsString(users)).thenReturn(userListJson); + + releasePackageService.updateReleaseMasterConfig(request); + + assertEquals("ADMIN_STAFF_SELECTED_USERS", existingConfig.getReleasePermissionType()); + assertEquals(userListJson, existingConfig.getUserList()); + assertTrue(existingConfig.getActive()); + + verify(releasePackageConfigRepository).save(existingConfig); + verify(releasePackageRepository).findAll(); + } + + @Test + public void shouldUpdatePermissionTypeForEachReleasePackage() { + Map requestBody = new HashMap<>(); + requestBody.put("releasePackageType", "ADMIN_ONLY"); + List releases = List.of(1); + requestBody.put("releases", releases); + + ReleasePackage rp1 = new ReleasePackage(); + rp1.setPermissionType(ReleasePermissionType.ADMIN_ONLY); + + when(releasePackageRepository.findAllByReleasePackageIdIn(List.of(1L))).thenReturn(List.of(rp1)); + + releasePackageService.updateReleasePackagesPermission(requestBody); + + assertEquals(ReleasePermissionType.ADMIN_ONLY, rp1.getPermissionType()); + } + + @Test + public void shouldReturnMasterPermissionVisibilityDetails_WhenMasterConfigIsActive() { + ReleaseVersion releaseVersion = new ReleaseVersion(); + releaseVersion.setReleaseType("online"); + releaseVersion.setArchive(false); + + ReleasePackage releasePackage = new ReleasePackage(); + releasePackage.setPermissionType(ReleasePermissionType.ADMIN_STAFF_SELECTED_USERS); + releasePackage.setReleaseVersions(Set.of(releaseVersion)); + + ReleasePackageConfig config = new ReleasePackageConfig(); + config.setReleaseType("online"); + config.setReleasePermissionType("ADMIN_STAFF"); + config.setActive(true); + + when(releasePackageConfigRepository.findByReleaseTypeIgnoreCase("online")).thenReturn(config); + + PermissionVisibilityResponse response = releasePackageService.getVisibilityDetails(releasePackage); + + assertTrue(response.isMasterPermission()); + assertEquals("ADMIN_STAFF", response.getPermissionType()); + assertEquals("online", response.getReleaseType()); + } + + @Test + public void shouldReturnPackageLevelPermission_WhenMasterConfigIsInactive() { + ReleaseVersion releaseVersion = new ReleaseVersion(); + releaseVersion.setReleaseType("offline"); + releaseVersion.setArchive(false); + + ReleasePackage releasePackage = new ReleasePackage(); + releasePackage.setPermissionType(ReleasePermissionType.NOT_SELECTED); + releasePackage.setReleaseVersions(Set.of(releaseVersion)); + + ReleasePackageConfig config = new ReleasePackageConfig(); + config.setReleaseType("offline"); + config.setReleasePermissionType("ADMIN_STAFF"); + config.setActive(false); + + when(releasePackageConfigRepository.findByReleaseTypeIgnoreCase("offline")).thenReturn(config); + + PermissionVisibilityResponse response = releasePackageService.getVisibilityDetails(releasePackage); + + assertFalse(response.isMasterPermission()); + assertEquals("NOT_SELECTED", response.getPermissionType()); + assertEquals("offline", response.getReleaseType()); + } + + @Test + public void shouldReturnPackagesForStaffWithProperPermissions() { + // Given + when(currentSecurityContext.isStaff()).thenReturn(true); + + ReleasePackage p1 = new ReleasePackage(); + p1.setMember(new Member("SE", 1)); + p1.setPermissionType(ReleasePermissionType.EVERYONE); + + ReleasePackage p2 = new ReleasePackage(); + p2.setMember(new Member("IN", 2)); + p2.setPermissionType(ReleasePermissionType.EVERYONE); + + List all = List.of(p1, p2); + List online = List.of(p1); + + ReleasePackageConfig grantedConfig = new ReleasePackageConfig(); + grantedConfig.setReleaseType("ONLINE"); + grantedConfig.setReleasePackageAccess("SE"); + grantedConfig.setReleasePermissionType("EVERYONE"); + grantedConfig.setUserList("[\"user1\"]"); + grantedConfig.setActive(true); + + ReleasePackageConfig notGrantedConfig = new ReleasePackageConfig(); + notGrantedConfig.setReleaseType("ONLINE"); + notGrantedConfig.setReleasePackageAccess("IN"); + notGrantedConfig.setReleasePermissionType("NOT_SELECTED"); + notGrantedConfig.setUserList("[]"); + notGrantedConfig.setActive(true); + + List granted = List.of(grantedConfig); + List notGranted = List.of(notGrantedConfig); + + when(releasePackageConfigRepository.findByReleasePermissionTypeNot("NOT_SELECTED")).thenReturn(granted); + when(releasePackageConfigRepository.findByReleasePermissionType("NOT_SELECTED")).thenReturn(notGranted); + + var result = releasePackageAccessService.getAccessiblePackagesForStaff(all, online, List.of(), List.of(), "SE"); + + assertEquals(1, result.size()); + assertTrue(result.contains(p1)); + } + + @Test + public void shouldReturnTrueWhenPermissionTypeIsAdminOnly() { + testPackage.setPermissionType(ReleasePermissionType.ADMIN_ONLY); + assertTrue(releasePackageAccessService.isAdminOnly(testPackage, config, masterConfig)); + } + + + @Test + public void shouldAllowAccessToSelectedUser() { + testPackage.setPermissionType(ReleasePermissionType.ADMIN_STAFF_SELECTED_USERS); + + when(currentSecurityContext.getCurrentUserName()).thenReturn("user"); + when(userRepository.findByLoginIgnoreCase("user")).thenReturn(testUser); + when(releasePackageAccessRepository.existsByReleasePackageIdAndUserId(100L, 1L)).thenReturn(true); + + var result = releasePackageAccessService.handleUserAccess(testPackage, config, masterConfig, 100L); + assertEquals(HttpStatus.OK, result.getStatusCode()); + } + + @Test + public void shouldDenyAccessToNonSelectedUser() { + testPackage.setPermissionType(ReleasePermissionType.ADMIN_STAFF_SELECTED_USERS); + + when(currentSecurityContext.getCurrentUserName()).thenReturn("user"); + when(userRepository.findByLoginIgnoreCase("user")).thenReturn(testUser); + when(releasePackageAccessRepository.existsByReleasePackageIdAndUserId(100L, 1L)).thenReturn(false); + + var result = releasePackageAccessService.handleUserAccess(testPackage, config, masterConfig, 100L); + assertEquals(HttpStatus.UNAUTHORIZED, result.getStatusCode()); + } + +}

- View Release Package -