package org.karmaexchange.dao;
import static com.google.common.base.CharMatcher.WHITESPACE;
import static java.util.Arrays.asList;
import static org.karmaexchange.util.OfyService.ofy;
import static org.karmaexchange.util.UserService.getCurrentUserKey;
import static org.karmaexchange.util.UserService.isCurrentUserAdmin;
import java.util.Comparator;
import java.util.List;
import javax.annotation.Nullable;
import javax.xml.bind.annotation.XmlRootElement;
import javax.xml.bind.annotation.XmlTransient;
import org.apache.commons.validator.routines.EmailValidator;
import org.karmaexchange.provider.SocialNetworkProvider;
import org.karmaexchange.provider.SocialNetworkProvider.SocialNetworkProviderType;
import org.karmaexchange.resources.msg.BaseDaoView;
import org.karmaexchange.resources.msg.ValidationErrorInfo;
import org.karmaexchange.resources.msg.ValidationErrorInfo.ValidationError;
import org.karmaexchange.resources.msg.ValidationErrorInfo.ValidationErrorType;
import org.karmaexchange.task.UpdateNamedKeysAdminTaskServlet;
import org.karmaexchange.util.SearchUtil.ReservedToken;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.EqualsAndHashCode;
import lombok.NoArgsConstructor;
import lombok.ToString;
import com.google.common.base.Objects;
import com.google.common.base.Predicate;
import com.google.common.collect.ImmutableList;
import com.google.common.collect.Iterables;
import com.google.common.collect.Lists;
import com.googlecode.objectify.Key;
import com.googlecode.objectify.VoidWork;
import com.googlecode.objectify.annotation.Entity;
import com.googlecode.objectify.annotation.Ignore;
import com.googlecode.objectify.annotation.Index;
@XmlRootElement
@Entity
@Data
@EqualsAndHashCode(callSuper=true)
@ToString(callSuper=true)
public class Organization extends NameBaseDao<Organization> implements BaseDaoView<Organization> {
private String orgName;
@Index
private String searchableOrgName;
private PageRef page;
// This field non-null only if the organization itself does not have a facebook page.
//
// We need the listing org page info so we display an icon / cover photo for
// automatically created organizations.
@Nullable
private PageRef listingOrgPage;
private String mission;
@Index
private OrganizationNamedKeyWrapper parentOrg;
// TODO(avaliani): Should we replicate this info or always fetch it from facebook from the UI?
// private String about;
// private String website;
// private ContactInfo contactInfo;
// Email address is not a field in facebook pages.
private String email;
private List<AutoMembershipRule> autoMembershipRules = Lists.newArrayList();
private Address address;
private List<CauseType> causes = Lists.newArrayList();
@Index
private long karmaPoints;
private IndexedAggregateRating eventRating;
@Ignore
private String searchTokenSuffix;
private String donationUrl;
@Nullable
private SourceOrganizationInfo sourceOrgInfo;
public enum Role {
ADMIN(3),
ORGANIZER(2),
MEMBER(1);
private int capabilityLevel;
private Role(int capabilityLevel) {
this.capabilityLevel = capabilityLevel;
}
public boolean hasEqualOrMoreCapabilities(Role otherRole) {
return capabilityLevel >= otherRole.capabilityLevel;
}
}
/*
* TODO(avaliani): is the type information useful for people to know? Non-profits and for
* profits can both generate volunteer events.
*
* | public enum Type {
* | NON_PROFIT,
* | COMMERCIAL
* | }
* | private Type type;
*/
public static String orgIdToName(String name) {
// We're making the name case insensitive
return name.toLowerCase();
}
public static String getOrgId(Key<Organization> orgKey) {
return orgKey.getName();
}
public static Key<Organization> createKey(String orgId) {
return Key.create(Organization.class, orgIdToName(orgId));
}
public static String getPrimaryOrgSearchToken(Key<Organization> orgKey) {
return ReservedToken.PRIMARY_ORG.create(orgKey.getName());
}
public static String getAssociatedOrgsSearchToken(Key<Organization> orgKey) {
return ReservedToken.ORG.create(orgKey.getName());
}
public String getSearchTokenSuffix() {
// TODO(avaliani): Potential bug. If two orgs have different page names unparsed but the
// same page name parsed we have a collision. We should eventually fix parseTokenSuffix to
// handle this.
return ReservedToken.parseTokenSuffix(name);
}
public void initFromPage() {
owner = null;
if ((page == null) || !page.isValid()) {
throw ValidationErrorInfo.createException(ImmutableList.of(
new ResourceValidationError(this,
ValidationErrorType.RESOURCE_FIELD_VALUE_REQUIRED, "page")));
}
// Right now we only support facebook.
SocialNetworkProvider provider =
SocialNetworkProviderType.FACEBOOK.getProvider();
provider.initOrganization(this, page.getName());
}
@Override
protected void preProcessInsert() {
super.preProcessInsert();
if (orgName != null) {
orgName = WHITESPACE.trimFrom(orgName);
searchableOrgName = orgName.toLowerCase();
}
updateParentOrgName();
// For now, avoid doing this unless we are sure the email address is not a generic email
// address like gmail, yahoo, hotmail, etc. We don't want to accidentally give permissions
// to arbitrary users. The UI can do this and warn the user.
//
// if (domains.isEmpty() && (email != null)) {
// String[] splitEmail = email.split("@", 2);
// if (splitEmail.length > 1) {
// domains.add(splitEmail[1]);
// }
// }
eventRating = IndexedAggregateRating.create();
karmaPoints = 0;
validateOrganization();
}
@Override
protected void processUpdate(Organization oldOrg) {
super.processUpdate(oldOrg);
if (orgName != null) {
orgName = WHITESPACE.trimFrom(orgName);
searchableOrgName = orgName.toLowerCase();
}
if (!Objects.equal(parentOrg, oldOrg.parentOrg)) {
updateParentOrgName();
}
// Restore fields that are explicitly updated by the backend.
karmaPoints = oldOrg.karmaPoints;
eventRating = oldOrg.eventRating;
validateOrganizationUpdate(oldOrg);
validateOrganization();
if (!orgName.equals(oldOrg.orgName)) {
UpdateNamedKeysAdminTaskServlet.enqueueTask(Key.create(this));
}
}
private void updateParentOrgName() {
if (parentOrg != null) {
try {
parentOrg.updateName();
} catch (IllegalArgumentException e) {
throw ValidationErrorInfo.createException(asList(
new ResourceValidationError(this,
ValidationErrorType.RESOURCE_FIELD_VALUE_INVALID, "parentOrg")));
}
}
}
private void validateOrganization() {
List<ValidationError> validationErrors = Lists.newArrayList();
if ((orgName == null) || orgName.isEmpty()) {
validationErrors.add(new ResourceValidationError(
this, ValidationErrorType.RESOURCE_FIELD_VALUE_REQUIRED, "orgName"));
}
if (page == null) {
if (listingOrgPage == null) {
validationErrors.add(new ResourceValidationError(
this, ValidationErrorType.RESOURCE_FIELD_VALUE_REQUIRED, "page | listingOrgPage"));
} else if (!listingOrgPage.isValid()) {
validationErrors.add(new ResourceValidationError(
this, ValidationErrorType.RESOURCE_FIELD_VALUE_INVALID, "listingOrgPage"));
}
} else {
if (!page.isValid()) {
validationErrors.add(new ResourceValidationError(
this, ValidationErrorType.RESOURCE_FIELD_VALUE_INVALID, "page"));
} else if (!orgIdToName(page.getName()).equals(name)) {
validationErrors.add(new ResourceValidationError(
this, ValidationErrorType.RESOURCE_FIELD_VALUE_INVALID, "name"));
}
}
if ((email != null) && !EmailValidator.getInstance().isValid(email)) {
validationErrors.add(new ResourceValidationError(
this, ValidationErrorType.RESOURCE_FIELD_VALUE_INVALID, "email"));
}
for (AutoMembershipRule autoMembershipRule : autoMembershipRules) {
validationErrors.addAll(autoMembershipRule.validate(this));
}
if (!validationErrors.isEmpty()) {
throw ValidationErrorInfo.createException(validationErrors);
}
}
private void validateOrganizationUpdate(Organization oldOrg) {
List<ValidationError> validationErrors = Lists.newArrayList();
if (!oldOrg.page.equals(page)) {
validationErrors.add(new ResourceValidationError(
this, ValidationErrorType.RESOURCE_FIELD_VALUE_UNMODIFIABLE, "page"));
}
if (!validationErrors.isEmpty()) {
throw ValidationErrorInfo.createException(validationErrors);
}
}
// TODO(avaliani): remove the load in eval permissions.
@Override
protected Permission evalPermission() {
if (isCurrentUserOrgAdmin()) {
return Permission.ALL;
}
return Permission.READ;
}
/*
* Note that this call fetches the current user's user object to evaluate the users role in
* the org.
*/
@XmlTransient
public boolean isCurrentUserOrgAdmin() {
if (isCurrentUserAdmin()) {
return true;
}
User currentUser = ofy().transactionless().load().key(getCurrentUserKey()).now();
if (currentUser == null) {
return false;
}
// In the future we can support hierarchical ADMIN roles if people request it.
return currentUser.hasOrgMembership(Key.create(this), Role.ADMIN);
}
public static class OrgNameComparator implements Comparator<Organization> {
public static final OrgNameComparator INSTANCE = new OrgNameComparator();
@Override
public int compare(Organization org1, Organization org2) {
return org1.searchableOrgName.compareTo(org2.searchableOrgName);
}
}
/**
* This class represents the roles that can be granted automatically based upon user email
* domain based membership.
*/
@Data
@NoArgsConstructor
@AllArgsConstructor
public static class AutoMembershipRule {
private String domain;
private Role maxGrantableRole;
public List<ValidationError> validate(Organization org) {
List<ValidationError> validationErrors = Lists.newArrayList();
if ((!EmailValidator.getInstance().isValid("user@" + domain)) ||
(maxGrantableRole == null)) {
validationErrors.add(new ResourceValidationError(
org, ValidationErrorType.RESOURCE_FIELD_VALUE_INVALID,
"autoMembershipRules[domain=\"" + domain + "\"]"));
}
return validationErrors;
}
public static Predicate<AutoMembershipRule> emailPredicate(final String email) {
return new Predicate<AutoMembershipRule>() {
@Override
public boolean apply(@Nullable AutoMembershipRule input) {
return email.toLowerCase().endsWith("." + input.domain.toLowerCase()) ||
email.toLowerCase().endsWith("@" + input.domain.toLowerCase());
}
};
}
}
public boolean canAutoGrantMembership(String email, Role reqRole) {
AutoMembershipRule rule = Iterables.tryFind(autoMembershipRules,
AutoMembershipRule.emailPredicate(email)).orNull();
return (rule != null) &&
rule.getMaxGrantableRole().hasEqualOrMoreCapabilities(reqRole);
}
public static List<Key<Organization>> getOrgAndAncestorOrgKeys(Key<Organization> orgKey) {
List<Key<Organization>> allOrgs = Lists.newArrayList();
for (Organization org : getOrgAndAncestorOrgs(orgKey)) {
allOrgs.add(Key.create(org));
}
return allOrgs;
}
public static List<Organization> getOrgAndAncestorOrgs(Key<Organization> orgKey) {
List<Organization> allOrgs = Lists.newArrayList();
while (orgKey != null) {
Organization org = ofy().transactionless().load().key(orgKey).now();
orgKey = null; // For next iteration.
if (org != null) {
allOrgs.add(org);
if (org.parentOrg != null) {
orgKey = KeyWrapper.toKey(org.parentOrg);
}
}
}
return allOrgs;
}
@Override
public void updateDependentNamedKeys() {
Iterable<Key<Organization>> childOrgKeys =
ofy().load().type(Organization.class).filter("parentOrg.key", Key.create(this)).keys();
for (Key<Organization> childOrgKey : childOrgKeys) {
ofy().transact(new UpdateDependentNamedKeyTxn(childOrgKey,
new OrganizationNamedKeyWrapper(this)));
}
}
@Data
@EqualsAndHashCode(callSuper=false)
public static class UpdateDependentNamedKeyTxn extends VoidWork {
private final Key<Organization> childOrgKey;
private final OrganizationNamedKeyWrapper parentOrgNamedKey;
public void vrun() {
Organization childOrg = ofy().load().key(childOrgKey).now();
if ((childOrg != null) && (childOrg.parentOrg != null) &&
childOrg.parentOrg.getKey().equals(parentOrgNamedKey.getKey()) &&
!childOrg.parentOrg.getName().equals(parentOrgNamedKey.getName())) {
childOrg.parentOrg = parentOrgNamedKey;
BaseDao.partialUpdate(childOrg);
}
}
}
@Override
public Organization getDao() {
return this;
}
@Data
@NoArgsConstructor
public static final class SourceOrganizationInfo {
// An external id uniquely identifying the source org.
private String id;
// The listing org may or may not be the same organization.
private KeyWrapper<Organization> listingOrg;
public SourceOrganizationInfo(String id, Key<Organization> listingOrgKey) {
this.id = id;
this.listingOrg = KeyWrapper.create(listingOrgKey);
}
}
}