package de.asideas.crowdsource.domain.model;
import de.asideas.crowdsource.domain.exception.InvalidRequestException;
import de.asideas.crowdsource.domain.exception.NotAuthorizedException;
import de.asideas.crowdsource.domain.exception.ResourceNotFoundException;
import de.asideas.crowdsource.domain.shared.ProjectStatus;
import de.asideas.crowdsource.presentation.Pledge;
import de.asideas.crowdsource.presentation.project.Attachment;
import de.asideas.crowdsource.presentation.project.Project;
import de.asideas.crowdsource.security.Roles;
import org.apache.commons.lang3.builder.EqualsBuilder;
import org.apache.commons.lang3.builder.HashCodeBuilder;
import org.apache.commons.lang3.builder.ToStringBuilder;
import org.joda.time.DateTime;
import org.springframework.data.annotation.CreatedDate;
import org.springframework.data.annotation.Id;
import org.springframework.data.annotation.LastModifiedDate;
import org.springframework.data.mongodb.core.index.Indexed;
import org.springframework.data.mongodb.core.mapping.DBRef;
import org.springframework.data.mongodb.core.mapping.Document;
import org.springframework.util.Assert;
import java.util.ArrayList;
import java.util.List;
import java.util.Objects;
import java.util.Optional;
import static java.util.stream.Collectors.groupingBy;
import static java.util.stream.Collectors.reducing;
// needed for serialization
@Document(collection = "projects")
public class ProjectEntity {
@Id
private String id;
@DBRef
private UserEntity creator;
@DBRef
private FinancingRoundEntity financingRound;
private List<AttachmentValue> attachments;
private String title;
private String shortDescription;
private String description;
private ProjectStatus status;
private int pledgeGoal;
@Indexed // since we order by this field
@CreatedDate
private DateTime createdDate;
@LastModifiedDate
private DateTime lastModifiedDate;
public ProjectEntity(UserEntity creator, Project project, FinancingRoundEntity financingRound) {
this.creator = creator;
this.financingRound = financingRound;
this.title = project.getTitle();
this.shortDescription = project.getShortDescription();
this.description = project.getDescription();
this.pledgeGoal = project.getPledgeGoal();
this.status = ProjectStatus.PROPOSED;
this.attachments = new ArrayList<>();
}
@Deprecated
/**
* @deprecated DO NOT use this one, due to this causes problems in ATs, violating this model's constraints!
*/
public ProjectEntity() {
this.attachments = new ArrayList<>();
this.status = ProjectStatus.PROPOSED;
}
/**
* Allows pledging <code>this</code> project, using budget from the <code>pledgingUser</code> given.
* Moreover negative pledges are supported by reducing investment for the project in which case the amount
* is credited the <code>pledginUser</code>'s budget (only as much posssible as originally pledged by her).
*
* @param pledge The amount to (positively or negatively) pledge <code>this</code>
* @param pledgingUser the user that wants to invest and whose balance is affected
* @param pledgesAlreadyDone all investements that have been done so far for <code>this</code>
* @return the value describing the [reduced] investment done
*/
public PledgeEntity pledge(Pledge pledge, UserEntity pledgingUser, List<PledgeEntity> pledgesAlreadyDone) {
if (this.financingRound == null || !this.financingRound.active()) {
throw InvalidRequestException.noFinancingRoundCurrentlyActive();
}
if (this.status == ProjectStatus.FULLY_PLEDGED) {
throw InvalidRequestException.projectAlreadyFullyPledged();
}
if (this.status != ProjectStatus.PUBLISHED) {
throw InvalidRequestException.projectNotPublished();
}
if (pledge.getAmount() == 0) {
throw InvalidRequestException.zeroPledgeNotValid();
}
if (pledge.getAmount() > pledgingUser.getBudget()) {
throw InvalidRequestException.userBudgetExceeded();
}
if (pledgedAmountOfUser(pledgesAlreadyDone, pledgingUser) + pledge.getAmount() < 0) {
throw InvalidRequestException.reversePledgeExceeded();
}
int newPledgedAmount = pledgedAmount(pledgesAlreadyDone) + pledge.getAmount();
if (newPledgedAmount > this.pledgeGoal) {
throw InvalidRequestException.pledgeGoalExceeded();
}
if (newPledgedAmount == this.pledgeGoal) {
setStatus(ProjectStatus.FULLY_PLEDGED);
}
pledgingUser.accountPledge(pledge);
return new PledgeEntity(this, pledgingUser, pledge, financingRound);
}
/**
* Allows admin users to pledge <code>this</code> project after the last financing round using its remaining budget.
* Thus, the user's budget is not debited or credited rather than <code>this</code>' financingRound's remaining budget.
* Negative pledges are supported as well but only as much as was pledged by admin users on the terminated financing round.
*
* @param pledge the amount to [negatively] pledge
* @param pledgingUser the admin user executing the pledge.
* @param pledgesAlreadyDone all investements that have been done so far for <code>this</code>
* @param postRoundPledgableBudgetAvailable how much budget is available from financing round to be used for investments
* @return the value describing the [reduced] investment done using budget from <code>this</code>' financing round
*/
public PledgeEntity pledgeUsingPostRoundBudget(Pledge pledge, UserEntity pledgingUser, List<PledgeEntity> pledgesAlreadyDone, int postRoundPledgableBudgetAvailable) {
Assert.isTrue(pledgingUser.getRoles().contains(Roles.ROLE_ADMIN), "pledgeUsingPostRoundBudget(..) called with non admin user: " + pledgingUser);
Assert.notNull(this.financingRound, "FinancingRound must not be null; Project was: " + this);
Assert.isTrue(this.financingRound.terminated(), "pledgeUsingPostRoundBudget(..) requires its financingRound to be terminated; Project was: " + this);
if (!this.financingRound.getTerminationPostProcessingDone()) {
throw InvalidRequestException.financingRoundNotPostProcessedYet();
}
if (this.status == ProjectStatus.FULLY_PLEDGED) {
throw InvalidRequestException.projectAlreadyFullyPledged();
}
if (this.status != ProjectStatus.PUBLISHED) {
throw InvalidRequestException.projectNotPublished();
}
if (pledge.getAmount() == 0) {
throw InvalidRequestException.zeroPledgeNotValid();
}
if (pledge.getAmount() > postRoundPledgableBudgetAvailable) {
throw InvalidRequestException.postRoundBudgetExceeded();
}
if (pledgedAmountPostRound(pledgesAlreadyDone) + pledge.getAmount() < 0) {
throw InvalidRequestException.reversePledgeExceeded();
}
int newPledgedAmount = pledgedAmount(pledgesAlreadyDone) + pledge.getAmount();
if (newPledgedAmount > this.pledgeGoal) {
throw InvalidRequestException.pledgeGoalExceeded();
}
if (newPledgedAmount == this.pledgeGoal) {
setStatus(ProjectStatus.FULLY_PLEDGED);
}
return new PledgeEntity(this, pledgingUser, pledge, financingRound);
}
/**
* Modifies status of <code>this</code>.
*
* @param newStatus
* @return whether an actual change of the status has taken place
* @throws InvalidRequestException in case constraints are violated
*/
public boolean modifyStatus(ProjectStatus newStatus) throws InvalidRequestException {
if (this.status == newStatus) {
return false;
}
if (ProjectStatus.FULLY_PLEDGED == this.status) {
throw InvalidRequestException.projectAlreadyFullyPledged();
}
if (ProjectStatus.DEFERRED == newStatus) {
if (this.financingRound != null && this.financingRound.active()) {
throw InvalidRequestException.projectAlreadyInFinancingRound();
}
if (ProjectStatus.REJECTED == this.status) {
throw InvalidRequestException.setToDeferredNotPossibleOnRejected();
}
}
if (ProjectStatus.PUBLISHED_DEFERRED == newStatus) {
if (this.financingRound != null && this.financingRound.active() && this.status == ProjectStatus.PUBLISHED) {
throw InvalidRequestException.projectAlreadyInFinancingRound();
}
}
setStatus(newStatus);
return true;
}
public boolean modifyMasterdata(Project updatedProject, UserEntity requestingUser) {
modificationsAllowedByUserAndState(requestingUser);
if (!masterdataChanged(updatedProject)) {
return false;
}
setTitle(updatedProject.getTitle());
setDescription(updatedProject.getDescription());
setShortDescription(updatedProject.getShortDescription());
setPledgeGoal(updatedProject.getPledgeGoal());
return true;
}
boolean masterdataModificationAllowed() {
switch (this.status) {
case FULLY_PLEDGED:
return false;
case PROPOSED:
case DEFERRED:
return true;
default:
break;
}
if (this.financingRound == null) {
return true;
}
if (this.financingRound.active()) {
return false;
}
return true;
}
public void addAttachmentAllowed(UserEntity attachmentCreator) throws NotAuthorizedException, InvalidRequestException {
modificationsAllowedByUserAndState(attachmentCreator);
}
public void deleteAttachmentAllowed(UserEntity attachmentCreator) throws NotAuthorizedException, InvalidRequestException {
modificationsAllowedByUserAndState(attachmentCreator);
}
public void addAttachment(AttachmentValue attachment) {
this.attachments.add(attachment);
}
public void deleteAttachment(AttachmentValue attachment2Delete) {
attachments.remove(attachment2Delete);
}
/**
* Retrieve the attachment file reference entry that allows association of actual binary data.
* Currently fetching is only supported by file reference (<code>Attachment.id</code>).
*
* @param attachment the query object
* @return the attachment value if it exists
* @throws ResourceNotFoundException in case the attachment couldn't be found
*/
public AttachmentValue findAttachmentByReference(Attachment attachment) throws ResourceNotFoundException {
Assert.notNull(attachment.getId());
final Optional<AttachmentValue> res = this.attachments.stream().filter(a -> a.getFileReference().equals(attachment.getId())).findFirst();
if (!res.isPresent()) {
throw new ResourceNotFoundException();
}
return res.get();
}
boolean masterdataChanged(Project updatedProject) {
boolean changed = false;
changed |= !Objects.equals(updatedProject.getTitle(), this.title);
changed |= !Objects.equals(updatedProject.getDescription(), this.description);
changed |= !Objects.equals(updatedProject.getShortDescription(), this.shortDescription);
changed |= updatedProject.getPledgeGoal() != this.pledgeGoal;
return changed;
}
/**
* Upon termination of its financing round the status is adapted accordingly as well as allocation of financing round.
*
* @param financingRound
* @return whether something actually changed.
*/
public boolean onFinancingRoundTerminated(FinancingRoundEntity financingRound) {
Assert.notNull(financingRound);
if (this.financingRound == null || !this.financingRound.getId().equals(financingRound.getId())) {
return false;
}
if (this.status == ProjectStatus.DEFERRED || this.status == ProjectStatus.PUBLISHED_DEFERRED) {
modifyStatus(ProjectStatus.PUBLISHED);
setFinancingRound(null);
return true;
}
return false;
}
public boolean pledgeGoalAchieved() {
return this.status == ProjectStatus.FULLY_PLEDGED;
}
public int pledgedAmount(List<PledgeEntity> pledges) {
return pledges.stream().mapToInt(PledgeEntity::getAmount).sum();
}
public long countBackers(List<PledgeEntity> pledges) {
Optional<Integer> backers = pledges.stream()
.collect(groupingBy(PledgeEntity::getUser, reducing(new PledgeEntity(), PledgeEntity::add)))
.entrySet().stream()
.map(pledgeSumByUser -> (pledgeSumByUser.getValue().getAmount() == 0 ? 0 : 1)).reduce((a, b) -> a + b);
return backers.orElse(0);
}
public int pledgedAmountOfUser(List<PledgeEntity> pledges, UserEntity requestingUser) {
if (requestingUser == null || pledges == null || pledges.isEmpty()) {
return 0;
}
return pledges.stream().filter(p -> requestingUser.getId().equals(p.getUser().getId()))
.mapToInt(PledgeEntity::getAmount).sum();
}
public int pledgedAmountPostRound(List<PledgeEntity> pledges) {
if (pledges == null || pledges.isEmpty() || this.financingRound == null || !this.financingRound.terminated()) {
return 0;
}
return pledges.stream()
.filter(p -> p.getCreatedDate() != null && p.getCreatedDate().isAfter(financingRound.getEndDate()))
.mapToInt(PledgeEntity::getAmount)
.sum();
}
private void modificationsAllowedByUserAndState(UserEntity requestingUser) throws NotAuthorizedException, InvalidRequestException {
if (!requestingUser.getRoles().contains(Roles.ROLE_ADMIN) && !requestingUser.equals(this.creator)) {
throw new NotAuthorizedException("You are neither admin nor creator of that project");
}
if (!masterdataModificationAllowed()) {
throw InvalidRequestException.masterdataChangeNotAllowed();
}
}
public String getId() {
return this.id;
}
public UserEntity getCreator() {
return this.creator;
}
public FinancingRoundEntity getFinancingRound() {
return this.financingRound;
}
public String getTitle() {
return this.title;
}
public String getShortDescription() {
return this.shortDescription;
}
public String getDescription() {
return this.description;
}
public ProjectStatus getStatus() {
return this.status;
}
public int getPledgeGoal() {
return this.pledgeGoal;
}
public DateTime getCreatedDate() {
return this.createdDate;
}
public DateTime getLastModifiedDate() {
return this.lastModifiedDate;
}
public List<AttachmentValue> getAttachments() {
return attachments;
}
public void setId(String id) {
this.id = id;
}
public void setCreator(UserEntity creator) {
this.creator = creator;
}
public void setFinancingRound(FinancingRoundEntity financingRound) {
this.financingRound = financingRound;
}
public void setTitle(String title) {
this.title = title;
}
public void setShortDescription(String shortDescription) {
this.shortDescription = shortDescription;
}
public void setDescription(String description) {
this.description = description;
}
public void setStatus(ProjectStatus status) {
this.status = status;
}
public void setPledgeGoal(int pledgeGoal) {
this.pledgeGoal = pledgeGoal;
}
public void setCreatedDate(DateTime createdDate) {
this.createdDate = createdDate;
}
public void setLastModifiedDate(DateTime lastModifiedDate) {
this.lastModifiedDate = lastModifiedDate;
}
public void setAttachments(List<AttachmentValue> attachments) {
this.attachments = attachments;
}
@Override
public boolean equals(Object o) {
return EqualsBuilder.reflectionEquals(this, o);
}
@Override
public int hashCode() {
return HashCodeBuilder.reflectionHashCode(this);
}
@Override
public String toString() {
return ToStringBuilder.reflectionToString(this);
}
}