feat(election): comprehensive audit log — rich details for all actions
- Fixed VARCHAR(500) overflow: description column changed to TEXT (Liquibase 0346)
- Fixed CEO pre-check 500 error: fm.firstname/lastname column name fix
- Fixed audit log showing wrong PASS/FAIL for CEO pre-check
- Fixed approval action not showing step name/number (read PENDING step before action)
- submitNomination: details JSON with applicantName, memberId, postId, gender, caste
- runEligibilityCheck: details JSON with all 5 check results + remarks
- runCeoPreCheck: details JSON with all 7 individual check results + remarks
- processApprovalStep: details JSON with action, stepName, stepNumber, remarks, rejectionReason
- activateElection/closeNominations/openVoting/closeVoting/cancelElection: details with electionTitle + newStatus
- updateElection: full request JSON via ObjectMapper
- approveCandidate: details with nominationId + applicantName
- updateCandidate: details with symbolUpdated, profileUpdated, briefProfile
- uploadCandidateSymbol: details with filename + mimeType
- declareResults: winner summary JSON array [{post, winner, votes}]
- saveManualVoteCounts: return type changed void→String, details JSON array with candidate vote data
- Added Liquibase migrations: 0344, 0345 for manual vote count, 0346 for TEXT column
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Sonnet 4.6
parent
99a89d5d81
commit
99cc972804
+37
-6
@@ -18,6 +18,7 @@ import jakarta.ws.rs.core.Response;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.apache.fineract.infrastructure.contentstore.service.ContentStoreService;
|
||||
import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext;
|
||||
import org.apache.fineract.portfolio.election.data.ElectionNominationData;
|
||||
import org.apache.fineract.portfolio.election.domain.ElectionAuditAction;
|
||||
import org.apache.fineract.portfolio.election.service.ElectionAnalyticsService;
|
||||
import org.apache.fineract.portfolio.election.service.ElectionAuditService;
|
||||
@@ -68,9 +69,21 @@ public class ElectionCandidateApiResource {
|
||||
@PathParam("nominationId") Long nominationId,
|
||||
@Context HttpServletRequest httpRequest) {
|
||||
Long approvedBy = context.authenticatedUser().getId();
|
||||
Long candidateId = writeService.approveCandidate(nominationId, approvedBy);
|
||||
ElectionNominationData nom = readService.retrieveNomination(nominationId);
|
||||
String applicantName = nom != null ? nom.getApplicantName() : null;
|
||||
Long candidateId;
|
||||
try {
|
||||
candidateId = writeService.approveCandidate(nominationId, approvedBy);
|
||||
} catch (RuntimeException e) {
|
||||
electionAuditService.logInNewTransaction(ElectionAuditAction.APPROVE_CANDIDATE_FAILED,
|
||||
"CANDIDATE", nominationId, electionId,
|
||||
"Candidate approval failed for nomination " + nominationId + ": " + e.getMessage(), null, clientIp(httpRequest));
|
||||
throw e;
|
||||
}
|
||||
String appDet = "{\"nominationId\":" + nominationId + ",\"applicantName\":" + esc(applicantName) + "}";
|
||||
electionAuditService.log(ElectionAuditAction.APPROVE_CANDIDATE, "CANDIDATE", candidateId, electionId,
|
||||
"Nomination approved as candidate", null, clientIp(httpRequest));
|
||||
"Nomination #" + nominationId + " approved — " + (applicantName != null ? applicantName : "candidate #" + candidateId),
|
||||
appDet, clientIp(httpRequest));
|
||||
return Response.ok(Map.of("resourceId", candidateId)).build();
|
||||
}
|
||||
|
||||
@@ -81,9 +94,14 @@ public class ElectionCandidateApiResource {
|
||||
@PathParam("candidateId") Long candidateId,
|
||||
Map<String, String> body,
|
||||
@Context HttpServletRequest httpRequest) {
|
||||
writeService.updateCandidate(candidateId, body.get("symbol"), body.get("briefProfile"));
|
||||
String symbol = body.get("symbol");
|
||||
String briefProfile = body.get("briefProfile");
|
||||
writeService.updateCandidate(candidateId, symbol, briefProfile);
|
||||
String updatedFields = (symbol != null ? "symbol" : "") + (symbol != null && briefProfile != null ? ", " : "") + (briefProfile != null ? "profile" : "");
|
||||
String updDet = "{\"symbolUpdated\":" + (symbol != null) + ",\"profileUpdated\":" + (briefProfile != null)
|
||||
+ (briefProfile != null ? ",\"briefProfile\":" + esc(briefProfile) : "") + "}";
|
||||
electionAuditService.log(ElectionAuditAction.UPDATE_CANDIDATE, "CANDIDATE", candidateId, electionId,
|
||||
"Candidate updated", null, clientIp(httpRequest));
|
||||
"Candidate updated" + (!updatedFields.isBlank() ? ": " + updatedFields : ""), updDet, clientIp(httpRequest));
|
||||
return Response.ok(Map.of("resourceId", candidateId)).build();
|
||||
}
|
||||
|
||||
@@ -118,8 +136,10 @@ public class ElectionCandidateApiResource {
|
||||
// use the returned (sanitized/normalized) path, not the pre-computed one
|
||||
String savedPath = contentStoreService.upload(storagePath, fileStream, mimeType);
|
||||
writeService.uploadCandidateSymbol(candidateId, mimeType + "|" + savedPath);
|
||||
String filename = fileDetail != null ? fileDetail.getFileName() : null;
|
||||
String symDet = "{\"filename\":" + esc(filename) + ",\"mimeType\":" + esc(mimeType) + "}";
|
||||
electionAuditService.log(ElectionAuditAction.UPLOAD_SYMBOL, "CANDIDATE", candidateId, electionId,
|
||||
"Election symbol uploaded", null, clientIp(httpRequest));
|
||||
"Election symbol uploaded" + (filename != null ? ": " + filename : ""), symDet, clientIp(httpRequest));
|
||||
return Response.ok(Map.of("resourceId", candidateId)).build();
|
||||
}
|
||||
|
||||
@@ -164,7 +184,13 @@ public class ElectionCandidateApiResource {
|
||||
@PathParam("candidateId") Long candidateId,
|
||||
@QueryParam("reason") String reason,
|
||||
@Context HttpServletRequest httpRequest) {
|
||||
writeService.removeCandidate(candidateId, reason);
|
||||
try {
|
||||
writeService.removeCandidate(candidateId, reason);
|
||||
} catch (RuntimeException e) {
|
||||
electionAuditService.logInNewTransaction(ElectionAuditAction.REMOVE_CANDIDATE_FAILED,
|
||||
"CANDIDATE", candidateId, electionId, "Remove candidate failed: " + e.getMessage(), null, clientIp(httpRequest));
|
||||
throw e;
|
||||
}
|
||||
electionAuditService.log(ElectionAuditAction.REMOVE_CANDIDATE, "CANDIDATE", candidateId, electionId,
|
||||
"Candidate removed: " + reason, null, clientIp(httpRequest));
|
||||
return Response.ok(Map.of("resourceId", candidateId, "status", "REMOVED")).build();
|
||||
@@ -195,4 +221,9 @@ public class ElectionCandidateApiResource {
|
||||
if (fwd != null && !fwd.isBlank()) return fwd.split(",")[0].trim();
|
||||
return req.getRemoteAddr();
|
||||
}
|
||||
|
||||
private static String esc(String s) {
|
||||
if (s == null) return "null";
|
||||
return "\"" + s.replace("\\", "\\\\").replace("\"", "\\\"").replace("\n", "\\n") + "\"";
|
||||
}
|
||||
}
|
||||
|
||||
+61
-11
@@ -18,6 +18,7 @@ import jakarta.ws.rs.core.Context;
|
||||
import jakarta.ws.rs.core.MediaType;
|
||||
import jakarta.ws.rs.core.Response;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.apache.fineract.portfolio.election.data.ElectionConfigData;
|
||||
import org.apache.fineract.portfolio.election.data.ElectionConfigRequest;
|
||||
import org.apache.fineract.portfolio.election.domain.ElectionAuditAction;
|
||||
import org.apache.fineract.portfolio.election.service.ElectionAuditService;
|
||||
@@ -74,8 +75,10 @@ public class ElectionConfigApiResource {
|
||||
ElectionConfigRequest request,
|
||||
@Context HttpServletRequest httpRequest) {
|
||||
writeService.updateElection(electionId, request);
|
||||
String updateDetails = null;
|
||||
try { updateDetails = objectMapper.writeValueAsString(request); } catch (Exception ignored) {}
|
||||
electionAuditService.log(ElectionAuditAction.UPDATE_ELECTION, "ELECTION", electionId, electionId,
|
||||
"Election updated", null, clientIp(httpRequest));
|
||||
"Election updated: " + request.getTitle(), updateDetails, clientIp(httpRequest));
|
||||
return Response.ok(Map.of("resourceId", electionId)).build();
|
||||
}
|
||||
|
||||
@@ -84,9 +87,17 @@ public class ElectionConfigApiResource {
|
||||
@Operation(summary = "Activate election to open nominations")
|
||||
public Response activateElection(@PathParam("electionId") Long electionId,
|
||||
@Context HttpServletRequest httpRequest) {
|
||||
writeService.activateElection(electionId);
|
||||
ElectionConfigData elec = readService.retrieveElection(electionId);
|
||||
try {
|
||||
writeService.activateElection(electionId);
|
||||
} catch (RuntimeException e) {
|
||||
electionAuditService.logInNewTransaction(ElectionAuditAction.ACTIVATE_ELECTION_FAILED,
|
||||
"ELECTION", electionId, electionId, "Activate failed: " + e.getMessage(), null, clientIp(httpRequest));
|
||||
throw e;
|
||||
}
|
||||
String det = "{\"electionTitle\":" + esc(elec != null ? elec.getTitle() : null) + ",\"newStatus\":\"ACTIVE\"}";
|
||||
electionAuditService.log(ElectionAuditAction.ACTIVATE_ELECTION, "ELECTION", electionId, electionId,
|
||||
"Election activated", null, clientIp(httpRequest));
|
||||
"Election activated — " + (elec != null ? elec.getTitle() : electionId), det, clientIp(httpRequest));
|
||||
return Response.ok(Map.of("resourceId", electionId, "status", "ACTIVE")).build();
|
||||
}
|
||||
|
||||
@@ -95,9 +106,17 @@ public class ElectionConfigApiResource {
|
||||
@Operation(summary = "Close nomination window")
|
||||
public Response closeNominations(@PathParam("electionId") Long electionId,
|
||||
@Context HttpServletRequest httpRequest) {
|
||||
writeService.closeNominations(electionId);
|
||||
ElectionConfigData elec = readService.retrieveElection(electionId);
|
||||
try {
|
||||
writeService.closeNominations(electionId);
|
||||
} catch (RuntimeException e) {
|
||||
electionAuditService.logInNewTransaction(ElectionAuditAction.CLOSE_NOMINATIONS_FAILED,
|
||||
"ELECTION", electionId, electionId, "Close nominations failed: " + e.getMessage(), null, clientIp(httpRequest));
|
||||
throw e;
|
||||
}
|
||||
String det = "{\"electionTitle\":" + esc(elec != null ? elec.getTitle() : null) + ",\"newStatus\":\"NOMINATION_CLOSED\"}";
|
||||
electionAuditService.log(ElectionAuditAction.CLOSE_NOMINATIONS, "ELECTION", electionId, electionId,
|
||||
"Nominations closed", null, clientIp(httpRequest));
|
||||
"Nominations closed — " + (elec != null ? elec.getTitle() : electionId), det, clientIp(httpRequest));
|
||||
return Response.ok(Map.of("resourceId", electionId, "status", "NOMINATION_CLOSED")).build();
|
||||
}
|
||||
|
||||
@@ -106,9 +125,17 @@ public class ElectionConfigApiResource {
|
||||
@Operation(summary = "Open voting")
|
||||
public Response openVoting(@PathParam("electionId") Long electionId,
|
||||
@Context HttpServletRequest httpRequest) {
|
||||
writeService.openVoting(electionId);
|
||||
ElectionConfigData elec = readService.retrieveElection(electionId);
|
||||
try {
|
||||
writeService.openVoting(electionId);
|
||||
} catch (RuntimeException e) {
|
||||
electionAuditService.logInNewTransaction(ElectionAuditAction.OPEN_VOTING_FAILED,
|
||||
"ELECTION", electionId, electionId, "Open voting failed: " + e.getMessage(), null, clientIp(httpRequest));
|
||||
throw e;
|
||||
}
|
||||
String det = "{\"electionTitle\":" + esc(elec != null ? elec.getTitle() : null) + ",\"newStatus\":\"VOTING_OPEN\"}";
|
||||
electionAuditService.log(ElectionAuditAction.OPEN_VOTING, "ELECTION", electionId, electionId,
|
||||
"Voting opened", null, clientIp(httpRequest));
|
||||
"Voting opened — " + (elec != null ? elec.getTitle() : electionId), det, clientIp(httpRequest));
|
||||
return Response.ok(Map.of("resourceId", electionId, "status", "VOTING_OPEN")).build();
|
||||
}
|
||||
|
||||
@@ -117,9 +144,17 @@ public class ElectionConfigApiResource {
|
||||
@Operation(summary = "Close voting")
|
||||
public Response closeVoting(@PathParam("electionId") Long electionId,
|
||||
@Context HttpServletRequest httpRequest) {
|
||||
writeService.closeVoting(electionId);
|
||||
ElectionConfigData elec = readService.retrieveElection(electionId);
|
||||
try {
|
||||
writeService.closeVoting(electionId);
|
||||
} catch (RuntimeException e) {
|
||||
electionAuditService.logInNewTransaction(ElectionAuditAction.CLOSE_VOTING_FAILED,
|
||||
"ELECTION", electionId, electionId, "Close voting failed: " + e.getMessage(), null, clientIp(httpRequest));
|
||||
throw e;
|
||||
}
|
||||
String det = "{\"electionTitle\":" + esc(elec != null ? elec.getTitle() : null) + ",\"newStatus\":\"VOTING_CLOSED\"}";
|
||||
electionAuditService.log(ElectionAuditAction.CLOSE_VOTING, "ELECTION", electionId, electionId,
|
||||
"Voting closed", null, clientIp(httpRequest));
|
||||
"Voting closed — " + (elec != null ? elec.getTitle() : electionId), det, clientIp(httpRequest));
|
||||
return Response.ok(Map.of("resourceId", electionId, "status", "VOTING_CLOSED")).build();
|
||||
}
|
||||
|
||||
@@ -129,9 +164,19 @@ public class ElectionConfigApiResource {
|
||||
public Response cancelElection(@PathParam("electionId") Long electionId,
|
||||
@QueryParam("reason") String reason,
|
||||
@Context HttpServletRequest httpRequest) {
|
||||
writeService.cancelElection(electionId, reason);
|
||||
ElectionConfigData elec = readService.retrieveElection(electionId);
|
||||
try {
|
||||
writeService.cancelElection(electionId, reason);
|
||||
} catch (RuntimeException e) {
|
||||
electionAuditService.logInNewTransaction(ElectionAuditAction.CANCEL_ELECTION_FAILED,
|
||||
"ELECTION", electionId, electionId, "Cancel failed: " + e.getMessage(), null, clientIp(httpRequest));
|
||||
throw e;
|
||||
}
|
||||
String det = "{\"electionTitle\":" + esc(elec != null ? elec.getTitle() : null)
|
||||
+ ",\"newStatus\":\"CANCELLED\""
|
||||
+ (reason != null ? ",\"reason\":" + esc(reason) : "") + "}";
|
||||
electionAuditService.log(ElectionAuditAction.CANCEL_ELECTION, "ELECTION", electionId, electionId,
|
||||
"Election cancelled: " + reason, null, clientIp(httpRequest));
|
||||
"Election cancelled: " + reason, det, clientIp(httpRequest));
|
||||
return Response.ok(Map.of("resourceId", electionId, "status", "CANCELLED")).build();
|
||||
}
|
||||
|
||||
@@ -159,4 +204,9 @@ public class ElectionConfigApiResource {
|
||||
if (fwd != null && !fwd.isBlank()) return fwd.split(",")[0].trim();
|
||||
return req.getRemoteAddr();
|
||||
}
|
||||
|
||||
private static String esc(String s) {
|
||||
if (s == null) return "null";
|
||||
return "\"" + s.replace("\\", "\\\\").replace("\"", "\\\"").replace("\n", "\\n") + "\"";
|
||||
}
|
||||
}
|
||||
|
||||
+148
-15
@@ -15,6 +15,8 @@ import jakarta.ws.rs.core.MediaType;
|
||||
import jakarta.ws.rs.core.Response;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.apache.fineract.portfolio.election.data.ElectionApprovalActionRequest;
|
||||
import org.apache.fineract.portfolio.election.data.ElectionApprovalStepData;
|
||||
import org.apache.fineract.portfolio.election.data.ElectionNominationData;
|
||||
import org.apache.fineract.portfolio.election.data.ElectionNominationRequest;
|
||||
import org.apache.fineract.portfolio.election.domain.ElectionAuditAction;
|
||||
import org.apache.fineract.portfolio.election.service.ElectionAuditService;
|
||||
@@ -43,11 +45,24 @@ public class ElectionNominationApiResource {
|
||||
ElectionNominationRequest request,
|
||||
@Context HttpServletRequest httpRequest) {
|
||||
request.setElectionId(electionId);
|
||||
Long id = writeService.submitNomination(request);
|
||||
Long id;
|
||||
try {
|
||||
id = writeService.submitNomination(request);
|
||||
} catch (RuntimeException e) {
|
||||
electionAuditService.logInNewTransaction(ElectionAuditAction.SUBMIT_NOMINATION_FAILED,
|
||||
"NOMINATION", null, electionId,
|
||||
"Nomination submit failed for member " + request.getMemberId() + ": " + e.getMessage(),
|
||||
null, clientIp(httpRequest));
|
||||
throw e;
|
||||
}
|
||||
String subDetails = "{\"applicantName\":" + esc(request.getApplicantName())
|
||||
+ ",\"memberId\":" + request.getMemberId()
|
||||
+ ",\"postId\":" + request.getPostId()
|
||||
+ ",\"gender\":" + esc(request.getGender())
|
||||
+ ",\"caste\":" + esc(request.getCaste())
|
||||
+ "}";
|
||||
electionAuditService.log(ElectionAuditAction.SUBMIT_NOMINATION, "NOMINATION", id, electionId,
|
||||
"Nomination submitted by member " + request.getMemberId(),
|
||||
null,
|
||||
clientIp(httpRequest));
|
||||
"Nomination submitted — " + request.getApplicantName(), subDetails, clientIp(httpRequest));
|
||||
return Response.ok(Map.of("resourceId", id)).build();
|
||||
}
|
||||
|
||||
@@ -75,7 +90,13 @@ public class ElectionNominationApiResource {
|
||||
public Response withdrawNomination(@PathParam("electionId") Long electionId,
|
||||
@PathParam("nominationId") Long nominationId,
|
||||
@Context HttpServletRequest httpRequest) {
|
||||
writeService.withdrawNomination(nominationId);
|
||||
try {
|
||||
writeService.withdrawNomination(nominationId);
|
||||
} catch (RuntimeException e) {
|
||||
electionAuditService.logInNewTransaction(ElectionAuditAction.WITHDRAW_NOMINATION_FAILED,
|
||||
"NOMINATION", nominationId, electionId, "Withdraw failed: " + e.getMessage(), null, clientIp(httpRequest));
|
||||
throw e;
|
||||
}
|
||||
electionAuditService.log(ElectionAuditAction.WITHDRAW_NOMINATION, "NOMINATION", nominationId, electionId,
|
||||
"Nomination withdrawn", null, clientIp(httpRequest));
|
||||
return Response.ok(Map.of("resourceId", nominationId, "status", "WITHDRAWN")).build();
|
||||
@@ -99,10 +120,40 @@ public class ElectionNominationApiResource {
|
||||
public Response runEligibilityCheck(@PathParam("electionId") Long electionId,
|
||||
@PathParam("nominationId") Long nominationId,
|
||||
@Context HttpServletRequest httpRequest) {
|
||||
writeService.runEligibilityCheck(nominationId);
|
||||
try {
|
||||
writeService.runEligibilityCheck(nominationId);
|
||||
} catch (RuntimeException e) {
|
||||
electionAuditService.logInNewTransaction(ElectionAuditAction.ELIGIBILITY_CHECK_FAILED,
|
||||
"NOMINATION", nominationId, electionId, "Eligibility check failed: " + e.getMessage(), null, clientIp(httpRequest));
|
||||
throw e;
|
||||
}
|
||||
ElectionNominationData eligData = readService.retrieveNomination(nominationId);
|
||||
String eligResult = eligData.getEligibilityCheck() != null
|
||||
? eligData.getEligibilityCheck().getOverallResult() : null;
|
||||
String eligDesc = "FAIL".equals(eligResult)
|
||||
? "Eligibility check failed — " + (eligData.getRejectionReason() != null ? eligData.getRejectionReason() : "not eligible")
|
||||
: "Eligibility check passed";
|
||||
String eligDetails = null;
|
||||
if (eligData.getEligibilityCheck() != null) {
|
||||
var ec = eligData.getEligibilityCheck();
|
||||
StringBuilder det = new StringBuilder("{");
|
||||
det.append("\"overallResult\":").append(esc(ec.getOverallResult()));
|
||||
det.append(",\"age\":").append(esc(ec.getAgeEligible() != null ? (ec.getAgeEligible() ? "PASS" : "FAIL") : null));
|
||||
det.append(",\"gender\":").append(esc(ec.getGenderEligible() != null ? (ec.getGenderEligible() ? "PASS" : "FAIL") : null));
|
||||
det.append(",\"caste\":").append(esc(ec.getCasteEligible() != null ? (ec.getCasteEligible() ? "PASS" : "FAIL") : null));
|
||||
det.append(",\"shareHolding\":").append(esc(ec.getShareHoldingEligible() != null ? (ec.getShareHoldingEligible() ? "PASS" : "FAIL") : null));
|
||||
det.append(",\"area\":").append(esc(ec.getAreaEligible() != null ? (ec.getAreaEligible() ? "PASS" : "FAIL") : null));
|
||||
if (ec.getAgeCheckRemarks() != null) det.append(",\"ageRemarks\":").append(esc(ec.getAgeCheckRemarks()));
|
||||
if (ec.getGenderCheckRemarks() != null) det.append(",\"genderRemarks\":").append(esc(ec.getGenderCheckRemarks()));
|
||||
if (ec.getCasteCheckRemarks() != null) det.append(",\"casteRemarks\":").append(esc(ec.getCasteCheckRemarks()));
|
||||
if (ec.getShareCheckRemarks() != null) det.append(",\"shareRemarks\":").append(esc(ec.getShareCheckRemarks()));
|
||||
if (ec.getAreaCheckRemarks() != null) det.append(",\"areaRemarks\":").append(esc(ec.getAreaCheckRemarks()));
|
||||
det.append("}");
|
||||
eligDetails = det.toString();
|
||||
}
|
||||
electionAuditService.log(ElectionAuditAction.ELIGIBILITY_CHECK, "NOMINATION", nominationId, electionId,
|
||||
"Eligibility check run", null, clientIp(httpRequest));
|
||||
return Response.ok(readService.retrieveNomination(nominationId)).build();
|
||||
eligDesc, eligDetails, clientIp(httpRequest));
|
||||
return Response.ok(eligData).build();
|
||||
}
|
||||
|
||||
@POST
|
||||
@@ -111,18 +162,60 @@ public class ElectionNominationApiResource {
|
||||
public Response runCeoPreCheck(@PathParam("electionId") Long electionId,
|
||||
@PathParam("nominationId") Long nominationId,
|
||||
@Context HttpServletRequest httpRequest) {
|
||||
writeService.runCeoPreCheck(nominationId);
|
||||
electionAuditService.log(ElectionAuditAction.CEO_PRECHECK, "NOMINATION", nominationId, electionId,
|
||||
"CEO pre-check run", null, clientIp(httpRequest));
|
||||
return Response.ok(readService.retrieveNomination(nominationId)).build();
|
||||
try {
|
||||
writeService.runCeoPreCheck(nominationId);
|
||||
} catch (RuntimeException e) {
|
||||
electionAuditService.logInNewTransaction(ElectionAuditAction.CEO_PRECHECK_FAILED,
|
||||
"NOMINATION", nominationId, electionId, "CEO pre-check failed: " + e.getMessage(), null, clientIp(httpRequest));
|
||||
throw e;
|
||||
}
|
||||
ElectionNominationData nominationData = readService.retrieveNomination(nominationId);
|
||||
boolean ceoFailed = nominationData.getCeoPreCheck() != null
|
||||
&& "FAIL".equals(nominationData.getCeoPreCheck().getOverallResult());
|
||||
String ceoDetails = null;
|
||||
if (nominationData.getCeoPreCheck() != null) {
|
||||
var cp = nominationData.getCeoPreCheck();
|
||||
StringBuilder det = new StringBuilder("{");
|
||||
det.append("\"directorLoan\":").append(esc(cp.getDirectorLoanCheckResult()));
|
||||
if (cp.getDirectorLoanOutstanding() != null) det.append(",\"directorLoanOutstanding\":").append(cp.getDirectorLoanOutstanding());
|
||||
if (cp.getDirectorLoanRemarks() != null) det.append(",\"directorLoanRemarks\":").append(esc(cp.getDirectorLoanRemarks()));
|
||||
det.append(",\"relationAccount\":").append(esc(cp.getRelationAcCheckResult()));
|
||||
if (cp.getRelationAcRemarks() != null) det.append(",\"relationRemarks\":").append(esc(cp.getRelationAcRemarks()));
|
||||
det.append(",\"kyc\":").append(esc(cp.getKycCheckResult()));
|
||||
if (cp.getKycCheckRemarks() != null) det.append(",\"kycRemarks\":").append(esc(cp.getKycCheckRemarks()));
|
||||
det.append(",\"familyKyc\":").append(esc(cp.getFamilyKycCheckResult()));
|
||||
if (cp.getFamilyKycCheckRemarks() != null) det.append(",\"familyKycRemarks\":").append(esc(cp.getFamilyKycCheckRemarks()));
|
||||
det.append(",\"guarantor\":").append(esc(cp.getGuarantorCheckResult()));
|
||||
if (cp.getGuarantorCheckRemarks() != null) det.append(",\"guarantorRemarks\":").append(esc(cp.getGuarantorCheckRemarks()));
|
||||
det.append(",\"aml\":").append(esc(cp.getAmlCheckResult()));
|
||||
if (cp.getAmlCheckRemarks() != null) det.append(",\"amlRemarks\":").append(esc(cp.getAmlCheckRemarks()));
|
||||
det.append(",\"familyLoan\":").append(esc(cp.getFamilyLoanCheckResult()));
|
||||
if (cp.getFamilyLoanCheckRemarks() != null) det.append(",\"familyLoanRemarks\":").append(esc(cp.getFamilyLoanCheckRemarks()));
|
||||
det.append("}");
|
||||
ceoDetails = det.toString();
|
||||
}
|
||||
if (ceoFailed) {
|
||||
String reason = nominationData.getRejectionReason() != null
|
||||
? nominationData.getRejectionReason() : "checks did not pass";
|
||||
electionAuditService.logInNewTransaction(ElectionAuditAction.CEO_PRECHECK_FAILED,
|
||||
"NOMINATION", nominationId, electionId,
|
||||
"CEO pre-check failed — " + reason, ceoDetails, clientIp(httpRequest));
|
||||
} else {
|
||||
electionAuditService.log(ElectionAuditAction.CEO_PRECHECK, "NOMINATION", nominationId, electionId,
|
||||
"CEO pre-check passed", ceoDetails, clientIp(httpRequest));
|
||||
}
|
||||
return Response.ok(nominationData).build();
|
||||
}
|
||||
|
||||
@POST
|
||||
@Path("/{electionId}/nomination/{nominationId}/no-due-certificate")
|
||||
@Operation(summary = "Generate No Due Certificate for nominee")
|
||||
public Response generateNoDueCertificate(@PathParam("electionId") Long electionId,
|
||||
@PathParam("nominationId") Long nominationId) {
|
||||
@PathParam("nominationId") Long nominationId,
|
||||
@Context HttpServletRequest httpRequest) {
|
||||
String certNumber = writeService.generateNoDueCertificate(nominationId);
|
||||
electionAuditService.log(ElectionAuditAction.GENERATE_NO_DUE_CERTIFICATE, "NOMINATION", nominationId, electionId,
|
||||
"No due certificate generated: " + certNumber, null, clientIp(httpRequest));
|
||||
return Response.ok(Map.of("certificateNumber", certNumber)).build();
|
||||
}
|
||||
|
||||
@@ -133,9 +226,44 @@ public class ElectionNominationApiResource {
|
||||
@PathParam("nominationId") Long nominationId,
|
||||
ElectionApprovalActionRequest request,
|
||||
@Context HttpServletRequest httpRequest) {
|
||||
writeService.processApprovalStep(nominationId, request);
|
||||
// Capture the current PENDING step BEFORE processing — status changes to APPROVED/REJECTED after
|
||||
ElectionNominationData beforeData = readService.retrieveNomination(nominationId);
|
||||
ElectionApprovalStepData pendingStep = beforeData.getApprovalSteps() != null
|
||||
? beforeData.getApprovalSteps().stream()
|
||||
.filter(s -> "PENDING".equalsIgnoreCase(s.getStatus()))
|
||||
.findFirst().orElse(null)
|
||||
: null;
|
||||
try {
|
||||
writeService.processApprovalStep(nominationId, request);
|
||||
} catch (RuntimeException e) {
|
||||
electionAuditService.logInNewTransaction(ElectionAuditAction.APPROVAL_ACTION_FAILED,
|
||||
"NOMINATION", nominationId, electionId,
|
||||
"Approval action failed (" + request.getAction() + "): " + e.getMessage(), null, clientIp(httpRequest));
|
||||
throw e;
|
||||
}
|
||||
StringBuilder approvalDesc = new StringBuilder("Approval action: ").append(request.getAction());
|
||||
StringBuilder approvalDet = new StringBuilder("{\"action\":").append(esc(request.getAction()));
|
||||
if (pendingStep != null) {
|
||||
if (pendingStep.getStepName() != null) {
|
||||
approvalDesc.append(" — Step: ").append(pendingStep.getStepName());
|
||||
approvalDet.append(",\"stepName\":").append(esc(pendingStep.getStepName()));
|
||||
}
|
||||
if (pendingStep.getStepNumber() != null) {
|
||||
approvalDesc.append(" (#").append(pendingStep.getStepNumber()).append(")");
|
||||
approvalDet.append(",\"stepNumber\":").append(pendingStep.getStepNumber());
|
||||
}
|
||||
}
|
||||
if (request.getRemarks() != null && !request.getRemarks().isBlank()) {
|
||||
approvalDesc.append(" — Remarks: ").append(request.getRemarks());
|
||||
approvalDet.append(",\"remarks\":").append(esc(request.getRemarks()));
|
||||
}
|
||||
if ("REJECT".equalsIgnoreCase(request.getAction()) && request.getRejectionReason() != null && !request.getRejectionReason().isBlank()) {
|
||||
approvalDesc.append(" — Reason: ").append(request.getRejectionReason());
|
||||
approvalDet.append(",\"rejectionReason\":").append(esc(request.getRejectionReason()));
|
||||
}
|
||||
approvalDet.append("}");
|
||||
electionAuditService.log(ElectionAuditAction.APPROVAL_ACTION, "NOMINATION", nominationId, electionId,
|
||||
"Approval action: " + request.getAction(), null, clientIp(httpRequest));
|
||||
approvalDesc.toString(), approvalDet.toString(), clientIp(httpRequest));
|
||||
return Response.ok(readService.retrieveNomination(nominationId)).build();
|
||||
}
|
||||
|
||||
@@ -156,4 +284,9 @@ public class ElectionNominationApiResource {
|
||||
if (fwd != null && !fwd.isBlank()) return fwd.split(",")[0].trim();
|
||||
return req.getRemoteAddr();
|
||||
}
|
||||
|
||||
private static String esc(String s) {
|
||||
if (s == null) return "null";
|
||||
return "\"" + s.replace("\\", "\\\\").replace("\"", "\\\"").replace("\n", "\\n") + "\"";
|
||||
}
|
||||
}
|
||||
|
||||
+72
-46
@@ -1,3 +1,10 @@
|
||||
/**
|
||||
* Copyright since 2025 Mifos Initiative
|
||||
*
|
||||
* This Source Code Form is subject to the terms of the Mozilla Public
|
||||
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||
*/
|
||||
package org.apache.fineract.portfolio.election.api;
|
||||
|
||||
import io.swagger.v3.oas.annotations.Operation;
|
||||
@@ -14,15 +21,16 @@ import jakarta.ws.rs.core.MediaType;
|
||||
import jakarta.ws.rs.core.Response;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext;
|
||||
import org.apache.fineract.portfolio.election.data.ElectionVoteRequest;
|
||||
import org.apache.fineract.portfolio.election.data.ElectionResultData;
|
||||
import org.apache.fineract.portfolio.election.data.ManualVoteCountEntry;
|
||||
import org.apache.fineract.portfolio.election.data.ManualVoteCountsRequest;
|
||||
import org.apache.fineract.portfolio.election.domain.ElectionAuditAction;
|
||||
import org.apache.fineract.portfolio.election.domain.ElectionVoteRepository;
|
||||
import org.apache.fineract.portfolio.election.exception.VoteCountExceedsShareAccountsException;
|
||||
import org.apache.fineract.portfolio.election.service.ElectionAuditService;
|
||||
import org.apache.fineract.portfolio.election.service.ElectionReadPlatformService;
|
||||
import org.apache.fineract.portfolio.election.service.ElectionWritePlatformService;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
@@ -37,36 +45,73 @@ public class ElectionVotingApiResource {
|
||||
private final ElectionReadPlatformService readService;
|
||||
private final ElectionWritePlatformService writeService;
|
||||
private final PlatformSecurityContext context;
|
||||
private final ElectionVoteRepository voteRepository;
|
||||
private final ElectionAuditService electionAuditService;
|
||||
|
||||
@POST
|
||||
@Path("/{electionId}/vote")
|
||||
@Operation(summary = "Cast a vote for a candidate")
|
||||
public Response castVote(@PathParam("electionId") Long electionId,
|
||||
ElectionVoteRequest request,
|
||||
@Context HttpServletRequest httpRequest) {
|
||||
request.setElectionId(electionId);
|
||||
writeService.castVote(request);
|
||||
electionAuditService.log(ElectionAuditAction.CAST_VOTE, "VOTE", null, electionId,
|
||||
"Vote cast for post " + request.getPostId() + " candidate " + request.getCandidateId(),
|
||||
"{\"postId\":" + request.getPostId()
|
||||
+ ",\"candidateId\":" + request.getCandidateId()
|
||||
+ ",\"voterMemberId\":" + request.getVoterMemberId() + "}",
|
||||
clientIp(httpRequest));
|
||||
return Response.ok(Map.of("message", "Vote cast successfully")).build();
|
||||
@Path("/{electionId}/manual-vote-counts")
|
||||
@Operation(summary = "Save manual vote counts for all candidates after physical voting")
|
||||
public Response saveManualVoteCounts(@PathParam("electionId") Long electionId,
|
||||
ManualVoteCountsRequest request,
|
||||
@Context HttpServletRequest httpRequest) {
|
||||
List<ManualVoteCountEntry> counts = request.getCounts() != null ? request.getCounts() : List.of();
|
||||
String reason = request.getReason();
|
||||
String voteDetails;
|
||||
try {
|
||||
voteDetails = writeService.saveManualVoteCounts(electionId, counts, reason);
|
||||
} catch (VoteCountExceedsShareAccountsException e) {
|
||||
electionAuditService.logInNewTransaction(
|
||||
ElectionAuditAction.MANUAL_VOTE_ENTRY_FAILED, "VOTE", electionId, electionId,
|
||||
"Vote count validation failed: " + e.getDefaultUserMessage(),
|
||||
null, clientIp(httpRequest));
|
||||
throw e;
|
||||
}
|
||||
boolean isEdit = voteDetails.contains("\"previousVotes\"");
|
||||
String auditMsg = (isEdit ? "Vote counts updated" : "Vote counts entered")
|
||||
+ " for " + counts.size() + " candidates"
|
||||
+ (reason != null && !reason.isBlank() ? " — Reason: " + reason : "");
|
||||
electionAuditService.log(ElectionAuditAction.MANUAL_VOTE_ENTRY, "VOTE", electionId, electionId,
|
||||
auditMsg, voteDetails, clientIp(httpRequest));
|
||||
return Response.ok(Map.of("message", "Manual vote counts saved successfully")).build();
|
||||
}
|
||||
|
||||
@POST
|
||||
@Path("/{electionId}/declare-results")
|
||||
@Operation(summary = "Declare election results")
|
||||
@Operation(summary = "Declare election results using saved manual vote counts")
|
||||
public Response declareResults(@PathParam("electionId") Long electionId,
|
||||
@Context HttpServletRequest httpRequest) {
|
||||
Long declaredBy = context.authenticatedUser().getId();
|
||||
writeService.declareResults(electionId, declaredBy);
|
||||
try {
|
||||
writeService.declareResults(electionId, declaredBy);
|
||||
} catch (RuntimeException e) {
|
||||
electionAuditService.logInNewTransaction(ElectionAuditAction.DECLARE_RESULTS_FAILED,
|
||||
"RESULT", electionId, electionId, "Declare results failed: " + e.getMessage(), null, clientIp(httpRequest));
|
||||
throw e;
|
||||
}
|
||||
java.util.List<ElectionResultData> results = readService.retrieveResultsByElection(electionId);
|
||||
// Build winner summary for audit details
|
||||
StringBuilder winDet = new StringBuilder("[");
|
||||
boolean wFirst = true;
|
||||
if (results != null) {
|
||||
for (ElectionResultData r : results) {
|
||||
if (Integer.valueOf(1).equals(r.getRankPosition()) || "WON".equalsIgnoreCase(r.getResult())) {
|
||||
if (!wFirst) winDet.append(",");
|
||||
winDet.append("{\"post\":").append(esc(r.getPostName()))
|
||||
.append(",\"winner\":").append(esc(r.getCandidateName()))
|
||||
.append(",\"votes\":").append(r.getVoteCount() != null ? r.getVoteCount() : 0)
|
||||
.append("}");
|
||||
wFirst = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
winDet.append("]");
|
||||
String declareDetails = wFirst ? null : winDet.toString();
|
||||
String declareDesc = "Results declared" + (declareDetails != null ? " — " + (results.stream()
|
||||
.filter(r -> Integer.valueOf(1).equals(r.getRankPosition()) || "WON".equalsIgnoreCase(r.getResult()))
|
||||
.map(r -> r.getCandidateName() + " won " + r.getPostName())
|
||||
.reduce((a, b) -> a + "; " + b).orElse("")) : "");
|
||||
electionAuditService.log(ElectionAuditAction.DECLARE_RESULTS, "RESULT", electionId, electionId,
|
||||
"Results declared", null, clientIp(httpRequest));
|
||||
return Response.ok(readService.retrieveResultsByElection(electionId)).build();
|
||||
declareDesc, declareDetails, clientIp(httpRequest));
|
||||
return Response.ok(results).build();
|
||||
}
|
||||
|
||||
@GET
|
||||
@@ -76,30 +121,6 @@ public class ElectionVotingApiResource {
|
||||
return Response.ok(readService.retrieveResultsByElection(electionId)).build();
|
||||
}
|
||||
|
||||
@GET
|
||||
@Path("/{electionId}/vote-summary")
|
||||
@Operation(summary = "Get live vote counts per candidate per post")
|
||||
public Response getVoteSummary(@PathParam("electionId") Long electionId) {
|
||||
long totalVotes = voteRepository.countByElectionConfigId(electionId);
|
||||
long totalVoters = voteRepository.countTotalVotersByElection(electionId);
|
||||
List<Object[]> rows = voteRepository.voteSummaryByCandidate(electionId);
|
||||
|
||||
List<Map<String, Object>> breakdown = rows.stream().map(r -> {
|
||||
Map<String, Object> entry = new HashMap<>();
|
||||
entry.put("postId", r[0]);
|
||||
entry.put("candidateId", r[1]);
|
||||
entry.put("voteCount", r[2]);
|
||||
return entry;
|
||||
}).collect(java.util.stream.Collectors.toList());
|
||||
|
||||
Map<String, Object> summary = new HashMap<>();
|
||||
summary.put("electionId", electionId);
|
||||
summary.put("totalVotes", totalVotes);
|
||||
summary.put("totalVoters", totalVoters);
|
||||
summary.put("breakdown", breakdown);
|
||||
return Response.ok(summary).build();
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Helpers
|
||||
// -------------------------------------------------------------------------
|
||||
@@ -110,4 +131,9 @@ public class ElectionVotingApiResource {
|
||||
if (fwd != null && !fwd.isBlank()) return fwd.split(",")[0].trim();
|
||||
return req.getRemoteAddr();
|
||||
}
|
||||
|
||||
private static String esc(String s) {
|
||||
if (s == null) return "null";
|
||||
return "\"" + s.replace("\\", "\\\\").replace("\"", "\\\"").replace("\n", "\\n") + "\"";
|
||||
}
|
||||
}
|
||||
|
||||
+1
@@ -31,4 +31,5 @@ public class ElectionCandidateData {
|
||||
private String location;
|
||||
private Integer shareCount;
|
||||
private Integer voteCount;
|
||||
private Integer manualVoteCount;
|
||||
}
|
||||
|
||||
+18
@@ -0,0 +1,18 @@
|
||||
/**
|
||||
* Copyright since 2025 Mifos Initiative
|
||||
*
|
||||
* This Source Code Form is subject to the terms of the Mozilla Public
|
||||
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||
*/
|
||||
package org.apache.fineract.portfolio.election.data;
|
||||
|
||||
import lombok.Data;
|
||||
import lombok.NoArgsConstructor;
|
||||
|
||||
@Data
|
||||
@NoArgsConstructor
|
||||
public class ManualVoteCountEntry {
|
||||
private Long candidateId;
|
||||
private Integer voteCount;
|
||||
}
|
||||
+14
@@ -0,0 +1,14 @@
|
||||
package org.apache.fineract.portfolio.election.data;
|
||||
|
||||
import lombok.Data;
|
||||
import lombok.NoArgsConstructor;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
@Data
|
||||
@NoArgsConstructor
|
||||
public class ManualVoteCountsRequest {
|
||||
|
||||
private List<ManualVoteCountEntry> counts;
|
||||
private String reason;
|
||||
}
|
||||
+11
-2
@@ -5,13 +5,22 @@ public enum ElectionAuditAction {
|
||||
CREATE_ELECTION, UPDATE_ELECTION, ACTIVATE_ELECTION,
|
||||
CLOSE_NOMINATIONS, OPEN_VOTING, CLOSE_VOTING, CANCEL_ELECTION,
|
||||
CREATE_POST, UPDATE_POST,
|
||||
// Election Config — Failed
|
||||
ACTIVATE_ELECTION_FAILED, CLOSE_NOMINATIONS_FAILED,
|
||||
OPEN_VOTING_FAILED, CLOSE_VOTING_FAILED, CANCEL_ELECTION_FAILED,
|
||||
// Nomination
|
||||
SUBMIT_NOMINATION, WITHDRAW_NOMINATION, RESUBMIT_NOMINATION,
|
||||
ELIGIBILITY_CHECK, CEO_PRECHECK, APPROVAL_ACTION,
|
||||
GENERATE_NO_DUE_CERTIFICATE,
|
||||
// Nomination — Failed
|
||||
SUBMIT_NOMINATION_FAILED, WITHDRAW_NOMINATION_FAILED,
|
||||
ELIGIBILITY_CHECK_FAILED, CEO_PRECHECK_FAILED, APPROVAL_ACTION_FAILED,
|
||||
// Candidate
|
||||
APPROVE_CANDIDATE, REMOVE_CANDIDATE, UPLOAD_SYMBOL, UPDATE_CANDIDATE,
|
||||
// Candidate — Failed
|
||||
APPROVE_CANDIDATE_FAILED, REMOVE_CANDIDATE_FAILED,
|
||||
// Voting
|
||||
CAST_VOTE,
|
||||
CAST_VOTE, MANUAL_VOTE_ENTRY, MANUAL_VOTE_ENTRY_FAILED,
|
||||
// Results
|
||||
DECLARE_RESULTS
|
||||
DECLARE_RESULTS, DECLARE_RESULTS_FAILED
|
||||
}
|
||||
|
||||
+1
-1
@@ -55,7 +55,7 @@ public class ElectionAuditLog {
|
||||
@Column(name = "details", columnDefinition = "TEXT")
|
||||
private String details;
|
||||
|
||||
@Column(name = "description", length = 500)
|
||||
@Column(name = "description", columnDefinition = "TEXT")
|
||||
private String description;
|
||||
|
||||
@Column(name = "created_at", nullable = false)
|
||||
|
||||
+3
@@ -51,6 +51,9 @@ public class ElectionCandidate extends AbstractPersistableCustom<Long> {
|
||||
@Column(name = "brief_profile", columnDefinition = "TEXT")
|
||||
private String briefProfile;
|
||||
|
||||
@Column(name = "manual_vote_count")
|
||||
private Integer manualVoteCount;
|
||||
|
||||
public static ElectionCandidate create(ElectionConfig electionConfig,
|
||||
ElectionNomination nomination,
|
||||
ElectionPost electionPost,
|
||||
|
||||
+20
@@ -0,0 +1,20 @@
|
||||
/**
|
||||
* Copyright since 2025 Mifos Initiative
|
||||
*
|
||||
* This Source Code Form is subject to the terms of the Mozilla Public
|
||||
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||
*/
|
||||
package org.apache.fineract.portfolio.election.exception;
|
||||
|
||||
import org.apache.fineract.infrastructure.core.exception.AbstractPlatformDomainRuleException;
|
||||
|
||||
public class VoteCountExceedsShareAccountsException extends AbstractPlatformDomainRuleException {
|
||||
|
||||
public VoteCountExceedsShareAccountsException(String postName, int totalVotes, int activeShareAccounts) {
|
||||
super("error.msg.election.vote.count.exceeds.share.accounts",
|
||||
"Total votes for post '" + postName + "' is " + totalVotes
|
||||
+ ", which exceeds the number of active share accounts (" + activeShareAccounts
|
||||
+ "). Votes cannot exceed the total number of eligible voters.");
|
||||
}
|
||||
}
|
||||
+12
@@ -0,0 +1,12 @@
|
||||
package org.apache.fineract.portfolio.election.exception;
|
||||
|
||||
import org.apache.fineract.infrastructure.core.exception.AbstractPlatformDomainRuleException;
|
||||
|
||||
public class VoteCountsNotSavedException extends AbstractPlatformDomainRuleException {
|
||||
|
||||
public VoteCountsNotSavedException(Long electionId, String postName) {
|
||||
super("error.msg.election.vote.counts.not.saved",
|
||||
"Manual vote counts have not been saved for post '" + postName + "' in election " + electionId
|
||||
+ ". Please enter and save vote counts on the Results page before closing voting.");
|
||||
}
|
||||
}
|
||||
+8
@@ -15,6 +15,14 @@ public interface ElectionAuditService {
|
||||
void log(ElectionAuditAction action, String entityType, Long entityId,
|
||||
Long electionId, String description, String details, String ipAddress);
|
||||
|
||||
/**
|
||||
* Same as {@link #log} but always runs in a brand-new transaction so the
|
||||
* entry is committed even if the caller's transaction rolls back (e.g. on
|
||||
* validation failure).
|
||||
*/
|
||||
void logInNewTransaction(ElectionAuditAction action, String entityType, Long entityId,
|
||||
Long electionId, String description, String details, String ipAddress);
|
||||
|
||||
/**
|
||||
* Server-side filtered + paginated audit log for an election.
|
||||
*
|
||||
|
||||
+8
@@ -8,6 +8,7 @@ import org.apache.fineract.portfolio.election.domain.ElectionAuditAction;
|
||||
import org.apache.fineract.portfolio.election.domain.ElectionAuditLog;
|
||||
import org.apache.fineract.portfolio.election.domain.ElectionAuditLogRepository;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Propagation;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
import org.springframework.data.domain.Page;
|
||||
@@ -73,6 +74,13 @@ public class ElectionAuditServiceImpl implements ElectionAuditService {
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
@Transactional(propagation = Propagation.REQUIRES_NEW)
|
||||
public void logInNewTransaction(ElectionAuditAction action, String entityType, Long entityId,
|
||||
Long electionId, String description, String details, String ipAddress) {
|
||||
log(action, entityType, entityId, electionId, description, details, ipAddress);
|
||||
}
|
||||
|
||||
@Override
|
||||
@Transactional(readOnly = true)
|
||||
public Map<String, Object> getAuditLogPaged(Long electionId, String entityType,
|
||||
|
||||
+1
-1
@@ -279,7 +279,7 @@ public class ElectionCeoPreCheckServiceImpl implements ElectionCeoPreCheckServic
|
||||
|
||||
// Manual (unlinked) family members — loan status cannot be verified in CBS
|
||||
String manualQuery = """
|
||||
SELECT TRIM(CONCAT(COALESCE(fm.first_name, ''), ' ', COALESCE(fm.last_name, ''))) AS full_name
|
||||
SELECT TRIM(CONCAT(COALESCE(fm.firstname, ''), ' ', COALESCE(fm.lastname, ''))) AS full_name
|
||||
FROM m_family_members fm
|
||||
WHERE fm.client_id = ?
|
||||
AND fm.linked_client_id IS NULL
|
||||
|
||||
+1
@@ -250,6 +250,7 @@ public class ElectionReadPlatformServiceImpl implements ElectionReadPlatformServ
|
||||
.approvedDate(c.getApprovedDate())
|
||||
.hasSymbol(c.getSymbol() != null)
|
||||
.briefProfile(c.getBriefProfile())
|
||||
.manualVoteCount(c.getManualVoteCount())
|
||||
.build())
|
||||
.collect(Collectors.toList()))
|
||||
.pageNumber(result.getNumber())
|
||||
|
||||
+12
@@ -1,9 +1,19 @@
|
||||
/**
|
||||
* Copyright since 2025 Mifos Initiative
|
||||
*
|
||||
* This Source Code Form is subject to the terms of the Mozilla Public
|
||||
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||
*/
|
||||
package org.apache.fineract.portfolio.election.service;
|
||||
|
||||
import org.apache.fineract.portfolio.election.data.ElectionApprovalActionRequest;
|
||||
import org.apache.fineract.portfolio.election.data.ElectionConfigRequest;
|
||||
import org.apache.fineract.portfolio.election.data.ElectionNominationRequest;
|
||||
import org.apache.fineract.portfolio.election.data.ElectionVoteRequest;
|
||||
import org.apache.fineract.portfolio.election.data.ManualVoteCountEntry;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
public interface ElectionWritePlatformService {
|
||||
|
||||
@@ -45,5 +55,7 @@ public interface ElectionWritePlatformService {
|
||||
|
||||
void castVote(ElectionVoteRequest request);
|
||||
|
||||
String saveManualVoteCounts(Long electionId, List<ManualVoteCountEntry> counts, String reason);
|
||||
|
||||
void declareResults(Long electionId, Long declaredBy);
|
||||
}
|
||||
|
||||
+109
-24
@@ -8,6 +8,7 @@ import org.apache.fineract.portfolio.election.data.ElectionConfigRequest;
|
||||
import org.apache.fineract.portfolio.election.data.ElectionNominationRequest;
|
||||
import org.apache.fineract.portfolio.election.data.ElectionPostRequest;
|
||||
import org.apache.fineract.portfolio.election.data.ElectionVoteRequest;
|
||||
import org.apache.fineract.portfolio.election.data.ManualVoteCountEntry;
|
||||
import org.apache.fineract.portfolio.election.domain.ApprovalStepStatus;
|
||||
import org.apache.fineract.portfolio.election.domain.ElectionApprovalStep;
|
||||
import org.apache.fineract.portfolio.election.domain.ElectionApprovalStepRepository;
|
||||
@@ -34,11 +35,14 @@ import org.apache.fineract.portfolio.election.exception.ElectionNotActiveExcepti
|
||||
import org.apache.fineract.portfolio.election.exception.InvalidNominationStateException;
|
||||
import org.apache.fineract.portfolio.election.exception.NominationAlreadyExistsException;
|
||||
import org.apache.fineract.portfolio.election.exception.VoteAlreadyCastException;
|
||||
import org.apache.fineract.portfolio.election.exception.VoteCountExceedsShareAccountsException;
|
||||
import org.apache.fineract.portfolio.election.exception.VoteCountsNotSavedException;
|
||||
import org.apache.fineract.infrastructure.contentstore.service.ContentStoreService;
|
||||
import org.springframework.jdbc.core.JdbcTemplate;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
@@ -158,6 +162,19 @@ public class ElectionWritePlatformServiceImpl implements ElectionWritePlatformSe
|
||||
context.authenticatedUser().validateHasPermissionTo("UPDATE_ELECTION");
|
||||
ElectionConfig election = electionConfigRepository.findById(electionId)
|
||||
.orElseThrow(() -> new ElectionNotFoundException(electionId));
|
||||
|
||||
List<ElectionPost> posts = electionPostRepository.findByElectionConfigIdAndIsActiveTrue(electionId);
|
||||
for (ElectionPost post : posts) {
|
||||
List<ElectionCandidate> candidates = candidateRepository
|
||||
.findByElectionConfigIdAndElectionPostIdAndIsActiveTrue(electionId, post.getId());
|
||||
if (candidates.size() > 1) {
|
||||
boolean countsEntered = candidates.stream().anyMatch(c -> c.getManualVoteCount() != null);
|
||||
if (!countsEntered) {
|
||||
throw new VoteCountsNotSavedException(electionId, post.getPostName());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
election.closeVoting();
|
||||
electionConfigRepository.save(election);
|
||||
}
|
||||
@@ -431,27 +448,70 @@ public class ElectionWritePlatformServiceImpl implements ElectionWritePlatformSe
|
||||
@Override
|
||||
@Transactional
|
||||
public void castVote(ElectionVoteRequest request) {
|
||||
ElectionConfig election = electionConfigRepository.findById(request.getElectionId())
|
||||
.orElseThrow(() -> new ElectionNotFoundException(request.getElectionId()));
|
||||
// Electronic voting disabled — voting is conducted manually.
|
||||
throw new UnsupportedOperationException("Electronic voting is disabled. Voting is conducted manually.");
|
||||
}
|
||||
|
||||
@Override
|
||||
@Transactional
|
||||
public String saveManualVoteCounts(Long electionId, List<ManualVoteCountEntry> counts, String reason) {
|
||||
ElectionConfig election = electionConfigRepository.findById(electionId)
|
||||
.orElseThrow(() -> new ElectionNotFoundException(electionId));
|
||||
|
||||
if (!election.isVotingOpen()) {
|
||||
throw new ElectionNotActiveException(election.getId(), election.getStatus().name());
|
||||
}
|
||||
|
||||
if (voteRepository.existsByElectionConfigIdAndElectionPostIdAndVoterMemberId(
|
||||
request.getElectionId(), request.getPostId(), request.getVoterMemberId())) {
|
||||
throw new VoteAlreadyCastException(request.getVoterMemberId(), request.getPostId());
|
||||
// Active share accounts = maximum eligible voters
|
||||
Integer activeShareAccounts = jdbcTemplate.queryForObject(
|
||||
"SELECT COUNT(*) FROM m_share_account WHERE status_enum = 300", Integer.class);
|
||||
int maxVotes = activeShareAccounts != null ? activeShareAccounts : 0;
|
||||
|
||||
// Load candidates, capture old votes, accumulate post-wise totals
|
||||
Map<Long, ElectionCandidate> candidateCache = new HashMap<>();
|
||||
Map<Long, Integer> oldVoteCache = new HashMap<>();
|
||||
Map<Long, Integer> postVoteTotals = new HashMap<>();
|
||||
Map<Long, String> postNameCache = new HashMap<>();
|
||||
|
||||
for (ManualVoteCountEntry entry : counts) {
|
||||
ElectionCandidate candidate = candidateRepository.findById(entry.getCandidateId())
|
||||
.orElseThrow(() -> new IllegalArgumentException("Candidate not found: " + entry.getCandidateId()));
|
||||
candidateCache.put(entry.getCandidateId(), candidate);
|
||||
oldVoteCache.put(entry.getCandidateId(), candidate.getManualVoteCount() != null ? candidate.getManualVoteCount() : 0);
|
||||
|
||||
Long postId = candidate.getElectionPost().getId();
|
||||
postNameCache.put(postId, candidate.getElectionPost().getPostName());
|
||||
int votes = entry.getVoteCount() != null ? entry.getVoteCount() : 0;
|
||||
postVoteTotals.merge(postId, votes, Integer::sum);
|
||||
}
|
||||
|
||||
ElectionPost post = electionPostRepository.findById(request.getPostId())
|
||||
.orElseThrow(() -> new IllegalArgumentException("Post not found: " + request.getPostId()));
|
||||
// Validate: no post's total votes can exceed active share accounts
|
||||
for (Map.Entry<Long, Integer> e : postVoteTotals.entrySet()) {
|
||||
if (e.getValue() > maxVotes) {
|
||||
throw new VoteCountExceedsShareAccountsException(
|
||||
postNameCache.get(e.getKey()), e.getValue(), maxVotes);
|
||||
}
|
||||
}
|
||||
|
||||
ElectionCandidate candidate = candidateRepository.findById(request.getCandidateId())
|
||||
.orElseThrow(() -> new IllegalArgumentException("Candidate not found: " + request.getCandidateId()));
|
||||
|
||||
ElectionVote vote = ElectionVote.cast(election, post, request.getVoterMemberId(),
|
||||
candidate, request.getBoothId(), null);
|
||||
voteRepository.save(vote);
|
||||
// Persist and build details JSON
|
||||
boolean isFirstSave = oldVoteCache.values().stream().allMatch(v -> v == 0);
|
||||
StringBuilder details = new StringBuilder("[");
|
||||
for (int i = 0; i < counts.size(); i++) {
|
||||
ManualVoteCountEntry entry = counts.get(i);
|
||||
ElectionCandidate candidate = candidateCache.get(entry.getCandidateId());
|
||||
int oldVotes = oldVoteCache.get(entry.getCandidateId());
|
||||
int newVotes = entry.getVoteCount() != null ? entry.getVoteCount() : 0;
|
||||
candidate.setManualVoteCount(newVotes);
|
||||
candidateRepository.save(candidate);
|
||||
if (i > 0) details.append(",");
|
||||
details.append("{\"candidate\":\"").append(candidate.getCandidateName())
|
||||
.append("\",\"post\":\"").append(candidate.getElectionPost().getPostName())
|
||||
.append("\",\"votes\":").append(newVotes);
|
||||
if (!isFirstSave) details.append(",\"previousVotes\":").append(oldVotes);
|
||||
details.append("}");
|
||||
}
|
||||
details.append("]");
|
||||
return details.toString();
|
||||
}
|
||||
|
||||
@Override
|
||||
@@ -463,18 +523,43 @@ public class ElectionWritePlatformServiceImpl implements ElectionWritePlatformSe
|
||||
|
||||
List<ElectionPost> posts = electionPostRepository.findByElectionConfigIdAndIsActiveTrue(electionId);
|
||||
for (ElectionPost post : posts) {
|
||||
List<Object[]> voteCounts = voteRepository.countVotesByCandidate(electionId, post.getId());
|
||||
int rank = 1;
|
||||
for (Object[] row : voteCounts) {
|
||||
Long candidateId = (Long) row[0];
|
||||
Long count = (Long) row[1];
|
||||
ElectionCandidate candidate = candidateRepository.findById(candidateId).orElse(null);
|
||||
if (candidate == null) continue;
|
||||
String result = rank <= post.getTotalSeats() ? "WON" : "LOST";
|
||||
ElectionResult r = ElectionResult.create(election, post, candidate,
|
||||
count.intValue(), result, rank, declaredBy);
|
||||
List<ElectionCandidate> candidates = candidateRepository
|
||||
.findByElectionConfigIdAndElectionPostIdAndIsActiveTrue(electionId, post.getId());
|
||||
|
||||
if (candidates.size() == 1) {
|
||||
// Unopposed — single candidate automatically wins without voting
|
||||
ElectionCandidate candidate = candidates.get(0);
|
||||
int votes = candidate.getManualVoteCount() != null ? candidate.getManualVoteCount() : 0;
|
||||
ElectionResult r = ElectionResult.create(election, post, candidate, votes, "WON", 1, declaredBy);
|
||||
resultRepository.save(r);
|
||||
rank++;
|
||||
continue;
|
||||
}
|
||||
|
||||
boolean hasManualCounts = candidates.stream().anyMatch(c -> c.getManualVoteCount() != null);
|
||||
|
||||
if (hasManualCounts) {
|
||||
candidates.sort((a, b) -> {
|
||||
int av = a.getManualVoteCount() != null ? a.getManualVoteCount() : 0;
|
||||
int bv = b.getManualVoteCount() != null ? b.getManualVoteCount() : 0;
|
||||
return Integer.compare(bv, av);
|
||||
});
|
||||
int rank = 1;
|
||||
for (ElectionCandidate candidate : candidates) {
|
||||
int votes = candidate.getManualVoteCount() != null ? candidate.getManualVoteCount() : 0;
|
||||
String result = rank <= post.getTotalSeats() ? "WON" : "LOST";
|
||||
ElectionResult r = ElectionResult.create(election, post, candidate, votes, result, rank, declaredBy);
|
||||
resultRepository.save(r);
|
||||
rank++;
|
||||
}
|
||||
} else {
|
||||
// Fallback: no manual counts entered — rank all candidates with 0 votes
|
||||
int rank = 1;
|
||||
for (ElectionCandidate candidate : candidates) {
|
||||
String result = rank <= post.getTotalSeats() ? "WON" : "LOST";
|
||||
ElectionResult r = ElectionResult.create(election, post, candidate, 0, result, rank, declaredBy);
|
||||
resultRepository.save(r);
|
||||
rank++;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -371,4 +371,7 @@
|
||||
<include file="parts/0341_election_audit_log.xml" relativeToChangelogFile="true"/>
|
||||
<include file="parts/0342_election_post_add_eligibility_columns.xml" relativeToChangelogFile="true"/>
|
||||
<include file="parts/0343_client_sub_caste_column.xml" relativeToChangelogFile="true"/>
|
||||
<include file="parts/0344_election_candidate_manual_vote_count.xml" relativeToChangelogFile="true"/>
|
||||
<include file="parts/0345_election_candidate_manual_vote_count_drop_default.xml" relativeToChangelogFile="true"/>
|
||||
<include file="parts/0346_election_audit_log_description_to_text.xml" relativeToChangelogFile="true"/>
|
||||
</databaseChangeLog>
|
||||
|
||||
+18
@@ -0,0 +1,18 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<databaseChangeLog xmlns="http://www.liquibase.org/xml/ns/dbchangelog"
|
||||
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-4.1.xsd">
|
||||
|
||||
<changeSet id="0344_election_candidate_add_manual_vote_count" author="CBS">
|
||||
<preConditions onFail="MARK_RAN">
|
||||
<not><columnExists tableName="m_election_candidate" columnName="manual_vote_count"/></not>
|
||||
</preConditions>
|
||||
<addColumn tableName="m_election_candidate">
|
||||
<column name="manual_vote_count" type="INT">
|
||||
<constraints nullable="true"/>
|
||||
</column>
|
||||
</addColumn>
|
||||
</changeSet>
|
||||
|
||||
</databaseChangeLog>
|
||||
+14
@@ -0,0 +1,14 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<databaseChangeLog xmlns="http://www.liquibase.org/xml/ns/dbchangelog"
|
||||
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-4.1.xsd">
|
||||
|
||||
<changeSet id="0345_election_candidate_manual_vote_count_drop_default" author="CBS">
|
||||
<preConditions onFail="MARK_RAN">
|
||||
<columnExists tableName="m_election_candidate" columnName="manual_vote_count"/>
|
||||
</preConditions>
|
||||
<sql>ALTER TABLE m_election_candidate ALTER COLUMN manual_vote_count DROP DEFAULT;</sql>
|
||||
</changeSet>
|
||||
|
||||
</databaseChangeLog>
|
||||
+14
@@ -0,0 +1,14 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<databaseChangeLog xmlns="http://www.liquibase.org/xml/ns/dbchangelog"
|
||||
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-4.1.xsd">
|
||||
|
||||
<changeSet id="0346_election_audit_log_description_to_text" author="CBS">
|
||||
<preConditions onFail="MARK_RAN">
|
||||
<columnExists tableName="m_election_audit_log" columnName="description"/>
|
||||
</preConditions>
|
||||
<sql>ALTER TABLE m_election_audit_log ALTER COLUMN description TYPE TEXT;</sql>
|
||||
</changeSet>
|
||||
|
||||
</databaseChangeLog>
|
||||
Reference in New Issue
Block a user