/* * Copyright 2002-2005 the original author or authors. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ package info.jtrac.domain; import static info.jtrac.Constants.*; import info.jtrac.util.XmlUtils; import java.io.Serializable; import java.util.ArrayList; import java.util.Arrays; import java.util.Collection; import java.util.Collections; import java.util.EnumMap; import java.util.EnumSet; import java.util.HashMap; import java.util.HashSet; import java.util.LinkedHashMap; import java.util.LinkedList; import java.util.List; import java.util.Map; import java.util.Set; import java.util.TreeMap; import org.dom4j.Document; import org.dom4j.Element; /** * XML metadata is one of the interesting design decisions of JTrac. * Metadata is defined for each space and so Items that belong to a * space are customized by the space metadata. This class can marshall * and unmarshall itself to XML and this XML is stored in the database * in a single column. Because of this approach, Metadata can be made more * and more complicated in the future without impact to the database schema. * * Things that the Metadata configures for a Space: * * 1) custom Fields for an Item (within a Space) * - Label * - whether mandatory or not [ DEPRECATED ] * - the option values (drop down list options) * - the option "key" values are stored in the database (WITHOUT any relationships) * - the values corresponding to "key"s are resolved in memory from the Metadata * and not through a database join. * * 2) the Roles available within a space * - for each (from) State the (to) State transitions allowed for this role * - and within each (from) State the fields that this Role can view / edit * * 3) the State labels corresponding to each state * - internally States are integers, but for display we need a label * - labels can be customized * - special State values: 0 = New, 1 = Open, 99 = Closed * * 4) the order in which the fields are displayed * on the data entry screens and the query result screens etc. * * There is one downside to this approach and that is there is a limit * to the nunmbers of custom fields available. The existing limits are * - Drop Down: 10 * - Free Text: 5 * - Numeric: 3 * - Date/Time: 3 * * Metadata can be inherited, and this allows for "reuse" TODO */ public class Metadata implements Serializable { private long id; private int version; private Integer type; private String name; private String description; private Metadata parent; private Map<Field.Name, Field> fields; private Map<String, Role> roles; private Map<Integer, String> states; private List<Field.Name> fieldOrder; public Metadata() { init(); } private void init() { fields = new EnumMap<Field.Name, Field>(Field.Name.class); roles = new HashMap<String, Role>(); states = new TreeMap<Integer, String>(); fieldOrder = new LinkedList<Field.Name>(); } /* accessor, will be used by Hibernate */ public void setXmlString(String xmlString) { init(); if (xmlString == null) { return; } Document document = XmlUtils.parse(xmlString); for (Element e : (List<Element>) document.selectNodes(FIELD_XPATH)) { Field field = new Field(e); fields.put(field.getName(), field); } for (Element e : (List<Element>) document.selectNodes(ROLE_XPATH)) { Role role = new Role(e); roles.put(role.getName(), role); } for (Element e : (List<Element>) document.selectNodes(STATE_XPATH)) { String key = e.attributeValue(STATUS); String value = e.attributeValue(LABEL); states.put(Integer.parseInt(key), value); } for (Element e : (List<Element>) document.selectNodes(FIELD_ORDER_XPATH)) { String fieldName = e.attributeValue(NAME); fieldOrder.add(Field.convertToName(fieldName)); } } /* accessor, will be used by Hibernate */ public String getXmlString() { Document d = XmlUtils.getNewDocument(METADATA); Element root = d.getRootElement(); Element fs = root.addElement(FIELDS); for (Field field : fields.values()) { field.addAsChildOf(fs); } Element rs = root.addElement(ROLES); for (Role role : roles.values()) { role.addAsChildOf(rs); } Element ss = root.addElement(STATES); for (Map.Entry<Integer, String> entry : states.entrySet()) { Element e = ss.addElement(STATE); e.addAttribute(STATUS, entry.getKey() + ""); e.addAttribute(LABEL, entry.getValue()); } Element fo = fs.addElement(FIELD_ORDER); for (Field.Name f : fieldOrder) { Element e = fo.addElement(FIELD); e.addAttribute(NAME, f.toString()); } return d.asXML(); } public String getPrettyXml() { return XmlUtils.getAsPrettyXml(getXmlString()); } //==================================================================== public void initRoles() { // set up default simple workflow states.put(State.NEW, "New"); states.put(State.OPEN, "Open"); states.put(State.CLOSED, "Closed"); addRole("DEFAULT"); toggleTransition("DEFAULT", State.NEW, State.OPEN); toggleTransition("DEFAULT", State.OPEN, State.OPEN); toggleTransition("DEFAULT", State.OPEN, State.CLOSED); toggleTransition("DEFAULT", State.CLOSED, State.OPEN); } public Field getField(String fieldName) { return fields.get(Field.convertToName(fieldName)); } public void add(Field field) { fields.put(field.getName(), field); // will overwrite if exists if (!fieldOrder.contains(field.getName())) { // but for List, need to check fieldOrder.add(field.getName()); } for (Role role : roles.values()) { for (State state : role.getStates().values()) { state.add(field.getName()); } } } public void removeField(String fieldName) { Field.Name tempName = Field.convertToName(fieldName); fields.remove(tempName); fieldOrder.remove(tempName); for (Role role : roles.values()) { for (State state : role.getStates().values()) { state.remove(tempName); } } } public void addState(String stateName) { // first get the max of existing state keys int maxStatus = 0; for (int status : states.keySet()) { if (status > maxStatus && status != State.CLOSED) { maxStatus = status; } } int newStatus = maxStatus + 1; states.put(newStatus, stateName); // by default each role will have permissions for this state, for all fields for (Role role : roles.values()) { State state = new State(newStatus); state.add(fields.keySet()); role.add(state); } } public void removeState(int stateId) { states.remove(stateId); for (Role role : roles.values()) { role.removeState(stateId); } } public void addRole(String roleName) { Role role = new Role(roleName); for (Map.Entry<Integer, String> entry : states.entrySet()) { State state = new State(entry.getKey()); state.add(fields.keySet()); role.add(state); } roles.put(role.getName(), role); } public void renameRole(String oldRole, String newRole) { // important! this has to be combined with a database update Role role = roles.get(oldRole); if (role == null) { return; // TODO improve JtracTest and assert not null here } role.setName(newRole); roles.remove(oldRole); roles.put(newRole, role); } public void removeRole(String roleName) { // important! this has to be combined with a database update roles.remove(roleName); } public Set<Field.Name> getUnusedFieldNames() { EnumSet<Field.Name> allFieldNames = EnumSet.allOf(Field.Name.class); for (Field f : getFields().values()) { allFieldNames.remove(f.getName()); } return allFieldNames; } public Map<String, String> getAvailableFieldTypes() { Map<String, String> fieldTypes = new LinkedHashMap<String, String>(); for (Field.Name fieldName : getUnusedFieldNames()) { String fieldType = fieldTypes.get(fieldName.getType() + ""); if (fieldType == null) { fieldTypes.put(fieldName.getType() + "", "1"); } else { int count = Integer.parseInt(fieldType); count++; fieldTypes.put(fieldName.getType() + "", count + ""); } } return fieldTypes; } public Field getNextAvailableField(int fieldType) { for (Field.Name fieldName : getUnusedFieldNames()) { if (fieldName.getType() == fieldType) { return new Field(fieldName + ""); } } throw new RuntimeException("No field available of type " + fieldType); } // customized accessor public Map<Field.Name, Field> getFields() { Map<Field.Name, Field> map = fields; if (parent != null) { map.putAll(parent.getFields()); } return map; } public List<Field> getFieldList() { List<Field> list = new ArrayList<Field>(fields.size()); for (Field.Name fieldName : getFieldOrder()) { list.add(fields.get(fieldName)); } return list; } public String getCustomValue(Field.Name fieldName, Integer key) { return getCustomValue(fieldName, key + ""); } public String getCustomValue(Field.Name fieldName, String key) { Field field = fields.get(fieldName); if (field != null) { return field.getCustomValue(key); } if (parent != null) { return parent.getCustomValue(fieldName, key); } return ""; } public String getStatusValue(Integer key) { if (key == null) { return ""; } String s = states.get(key); if (s == null) { return ""; } return s; } public int getRoleCount() { return roles.size(); } public int getFieldCount() { return getFields().size(); } public int getStateCount() { return states.size(); } /** * logic for resolving the next possible transitions for a given role and state * - lookup Role by roleKey * - for this Role, lookup state by key (integer) * - for the State, iterate over transitions, get the label for each and add to map * The map returned is used to render the drop down list on screen, [ key = value ] */ public Map<Integer, String> getPermittedTransitions(List<String> roleKeys, int status) { Map<Integer, String> map = new LinkedHashMap<Integer, String>(); for(String roleKey : roleKeys) { Role role = roles.get(roleKey); if (role != null) { State state = role.getStates().get(status); if (state != null) { for(int transition : state.getTransitions()) { map.put(transition, this.states.get(transition)); } } } } return map; } // returning map ideal for JSTL public Map<String, Boolean> getRolesAbleToTransition(int fromStatus, int toStatus) { Map<String, Boolean> map = new HashMap<String, Boolean>(roles.size()); for(Role role : roles.values()) { State s = role.getStates().get(fromStatus); if(s.getTransitions().contains(toStatus)) { map.put(role.getName(), true); } } return map; } public Set<String> getRolesAbleToTransitionFrom(int state) { Set<String> set = new HashSet<String>(roles.size()); for(Role role : roles.values()) { State s = role.getStates().get(state); if(s.getTransitions().size() > 0) { set.add(role.getName()); } } return set; } private State getRoleState(String roleKey, int stateKey) { Role role = roles.get(roleKey); return role.getStates().get(stateKey); } public void toggleTransition(String roleKey, int fromState, int toState) { State state = getRoleState(roleKey, fromState); if (state.getTransitions().contains(toState)) { state.getTransitions().remove(toState); } else { state.getTransitions().add(toState); } } public void switchMask(int stateKey, String roleKey, String fieldName) { State state = getRoleState(roleKey, stateKey); Field.Name tempName = Field.convertToName(fieldName); Integer mask = state.getFields().get(tempName); switch(mask) { // case State.MASK_HIDDEN: state.getFields().put(name, State.MASK_READONLY); return; HIDDEN support in future case State.MASK_READONLY: state.getFields().put(tempName, State.MASK_OPTIONAL); return; case State.MASK_OPTIONAL: state.getFields().put(tempName, State.MASK_MANDATORY); return; case State.MASK_MANDATORY: state.getFields().put(tempName, State.MASK_READONLY); return; default: // should never happen } } public List<Field> getEditableFields(String roleKey, int status) { return getEditableFields(Collections.singletonList(roleKey), status); } public List<Field> getEditableFields(Collection<String> roleKeys, int status) { Map<Field.Name, Field> fs = new HashMap<Field.Name, Field>(getFieldCount()); for(String roleKey : roleKeys) { if (roleKey.startsWith("ROLE_")) { continue; } if(status > -1) { State state = getRoleState(roleKey, status); fs.putAll(getEditableFields(state)); } else { // we are trying to find all editable fields Role role = roles.get(roleKey); for(State state : role.getStates().values()) { if(state.getStatus() == State.NEW) { continue; } fs.putAll(getEditableFields(state)); } } } // just to fix the order of the fields List<Field> result = new ArrayList<Field>(getFieldCount()); for(Field.Name fieldName : fieldOrder) { Field f = fs.get(fieldName); // and not all fields may be editable if(f != null) { result.add(f); } } return result; } public List<Field> getEditableFields() { return getEditableFields(roles.keySet(), -1); } private Map<Field.Name, Field> getEditableFields(State state) { Map<Field.Name, Field> fs = new HashMap<Field.Name, Field>(getFieldCount()); for(Map.Entry<Field.Name, Integer> entry : state.getFields().entrySet()) { if (entry.getValue() == State.MASK_OPTIONAL || entry.getValue() == State.MASK_MANDATORY) { Field f = fields.get(entry.getKey()); // set if optional or not, this changes depending on the user / role and status f.setOptional(entry.getValue() == State.MASK_OPTIONAL); fs.put(f.getName(), f); } } return fs; } public Collection<Role> getRoleList() { return roles.values(); } public Collection<String> getRoleKeys() { return roles.keySet(); } // introducing Admin permissions per space, slight hack // so Role stands for "workflow" role from now on public List<String> getAdminRoleKeys() { return Arrays.asList(new String[] { Role.ROLE_ADMIN }); } public List<String> getAllRoleKeys() { List<String> list = new ArrayList<String>(getRoleKeys()); list.addAll(getAdminRoleKeys()); return list; } //================================================================== public int getVersion() { return version; } public void setVersion(int version) { this.version = version; } public String getName() { return name; } public void setName(String name) { this.name = name; } public String getDescription() { return description; } public void setDescription(String description) { this.description = description; } public long getId() { return id; } public void setId(long id) { this.id = id; } public Integer getType() { return type; } public void setType(Integer type) { this.type = type; } public Metadata getParent() { return parent; } public void setParent(Metadata parent) { this.parent = parent; } //======================================= // no setters required public Map<String, Role> getRolesMap() { return roles; } public Map<Integer, String> getStatesMap() { return states; } public List<Field.Name> getFieldOrder() { return fieldOrder; } @Override public String toString() { StringBuffer sb = new StringBuffer(); sb.append("id [").append(id); sb.append("]; parent [").append(parent); sb.append("]; fields [").append(fields); sb.append("]; roles [").append(roles); sb.append("]; states [").append(states); sb.append("]; fieldOrder [").append(fieldOrder); sb.append("]"); return sb.toString(); } }