org.sakaiproject.gradebookng.tool.model.GbGradebookData.java Source code

Java tutorial

Introduction

Here is the source code for org.sakaiproject.gradebookng.tool.model.GbGradebookData.java

Source

/**
 * Copyright (c) 2003-2017 The Apereo Foundation
 *
 * Licensed under the Educational Community 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://opensource.org/licenses/ecl2
 *
 * 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 org.sakaiproject.gradebookng.tool.model;

import java.io.UnsupportedEncodingException;
import java.util.ArrayList;
import java.util.Base64;
import java.util.Comparator;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.function.Predicate;
import java.util.stream.Collectors;

import org.apache.commons.collections.map.HashedMap;
import org.apache.commons.lang3.StringUtils;
import org.apache.commons.lang3.builder.CompareToBuilder;
import org.apache.wicket.Component;
import org.apache.wicket.model.StringResourceModel;

import org.sakaiproject.component.cover.ServerConfigurationService;
import org.sakaiproject.gradebookng.business.GbCategoryType;
import org.sakaiproject.gradebookng.business.GbRole;
import org.sakaiproject.gradebookng.business.model.GbCourseGrade;
import org.sakaiproject.gradebookng.business.model.GbGradeInfo;
import org.sakaiproject.gradebookng.business.model.GbStudentGradeInfo;
import org.sakaiproject.gradebookng.business.model.GbStudentNameSortOrder;
import org.sakaiproject.gradebookng.business.util.FormatHelper;
import org.sakaiproject.gradebookng.tool.pages.GradebookPage;
import org.sakaiproject.service.gradebook.shared.Assignment;
import org.sakaiproject.service.gradebook.shared.CategoryDefinition;
import org.sakaiproject.service.gradebook.shared.CourseGrade;
import org.sakaiproject.service.gradebook.shared.GradebookInformation;
import org.sakaiproject.service.gradebook.shared.GradingType;

import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.ObjectMapper;

import lombok.Data;
import lombok.Value;

public class GbGradebookData {

    private final int NULL_SENTINEL = 127;

    private static final String SAK_PROP_SHOW_SET_ZERO_SCORE = "gradebookng.showSetZeroScore";
    private static final boolean SAK_PROP_SHOW_SET_ZERO_SCORE_DEFAULT = true;

    private static final String SAK_PROP_SHOW_COURSE_GRADE_STUDENT = "gradebookng.showDisplayCourseGradeToStudent";
    private static final Boolean SAK_PROP_SHOW_COURSE_GRADE_STUDENT_DEFAULT = true;

    private final List<StudentDefinition> students;
    private final List<ColumnDefinition> columns;
    private final List<GbStudentGradeInfo> studentGradeInfoList;
    private final List<CategoryDefinition> categories;
    private final GradebookInformation settings;
    private final GradebookUiSettings uiSettings;
    private final GbRole role;
    private final boolean isUserAbleToEditAssessments;
    private final Map<String, String> toolNameIconCSSMap;
    private final String defaultIconCSS;
    private final Map<String, Double> courseGradeMap;
    private final Map<String, Boolean> hasAssociatedRubricMap;
    private final boolean isStudentNumberVisible;
    private final boolean isSectionsVisible;
    private final Map<Long, CategoryDefinition> categoryMap = new HashMap<>();

    private final Component parent;

    @Data
    private class StudentDefinition {
        private String eid;
        private String userId;
        private String firstName;
        private String lastName;

        private String hasComments;
        private String hasConcurrentEdit;
        private String readonly;
        private String hasExcuse;

        private String studentNumber;
        private String hasDroppedScores;
        private List<String> sections;
    }

    private interface ColumnDefinition {
        public String getType();

        public Score getValueFor(GbStudentGradeInfo studentGradeInfo, boolean isUserAbleToEditAssessments);
    }

    @Value
    private class AssignmentDefinition implements ColumnDefinition {
        private Long assignmentId;
        private String title;
        private String abbrevTitle;
        private String points;
        private String dueDate;

        private boolean isReleased;
        private boolean isIncludedInCourseGrade;
        private boolean isExtraCredit;
        private boolean isExternallyMaintained;
        private boolean hasAssociatedRubric;
        private String externalId;
        private String externalAppName;
        private String externalAppIconCSS;

        private String categoryId;
        private String categoryName;
        private String categoryColor;
        private String categoryWeight;
        private boolean isCategoryExtraCredit;

        private boolean hidden;

        @Override
        public String getType() {
            return "assignment";
        }

        @Override
        public Score getValueFor(final GbStudentGradeInfo studentGradeInfo,
                final boolean isUserAbleToEditAssessments) {
            final Map<Long, GbGradeInfo> studentGrades = studentGradeInfo.getGrades();

            final GbGradeInfo gradeInfo = studentGrades.get(this.assignmentId);

            if (gradeInfo == null) {
                return new ReadOnlyScore(null);
            } else {
                final String grade = gradeInfo.getGrade();
                final boolean excused = gradeInfo.isExcused();

                if (isUserAbleToEditAssessments || gradeInfo.isGradeable()) {
                    final EditableScore score = new EditableScore(grade);
                    score.setExcused(excused);
                    return score;
                } else {
                    final ReadOnlyScore score = new ReadOnlyScore(grade);
                    score.setExcused(excused);
                    return score;
                }
            }
        }
    }

    @Value
    private class CategoryAverageDefinition implements ColumnDefinition {
        private Long categoryId;
        private String categoryName;
        private String title;
        private String weight;
        private Double totalPoints;
        private boolean isExtraCredit;
        private String color;
        private boolean hidden;
        private List<String> dropInfo;

        @Override
        public String getType() {
            return "category";
        }

        @Override
        public Score getValueFor(final GbStudentGradeInfo studentGradeInfo,
                final boolean isUserAbleToEditAssessments) {
            final Map<Long, Double> categoryAverages = studentGradeInfo.getCategoryAverages();

            final Double average = categoryAverages.get(this.categoryId);

            if (average == null) {
                return new ReadOnlyScore(null);
            } else {
                return new ReadOnlyScore(FormatHelper.formatDoubleToDecimal(average));
            }
        }
    }

    @Value
    private class DataSet {
        private List<StudentDefinition> students;
        private List<ColumnDefinition> columns;
        private List<String[]> courseGrades;
        private String serializedGrades;
        private Map<String, Object> settings;

        private int rowCount;
        private int columnCount;

        public DataSet(final List<StudentDefinition> students, final List<ColumnDefinition> columns,
                final List<String[]> courseGrades, final String serializedGrades,
                final Map<String, Object> settings) {
            this.students = students;
            this.columns = columns;
            this.courseGrades = courseGrades;
            this.serializedGrades = serializedGrades;
            this.settings = settings;

            this.rowCount = students.size();
            this.columnCount = columns.size();
        }
    }

    public GbGradebookData(final GbGradeTableData gbGradeTableData, final Component parentComponent) {
        this.parent = parentComponent;
        this.categories = gbGradeTableData.getCategories();
        buildCategoryMap();

        this.settings = gbGradeTableData.getGradebookInformation();
        this.uiSettings = gbGradeTableData.getUiSettings();
        this.role = gbGradeTableData.getRole();
        this.isUserAbleToEditAssessments = gbGradeTableData.isUserAbleToEditAssessments();

        this.courseGradeMap = gbGradeTableData.getCourseGradeMap();

        this.isStudentNumberVisible = gbGradeTableData.isStudentNumberVisible();
        this.isSectionsVisible = gbGradeTableData.isSectionsVisible();

        this.studentGradeInfoList = gbGradeTableData.getGrades();

        this.toolNameIconCSSMap = gbGradeTableData.getToolNameToIconCSS();
        this.defaultIconCSS = gbGradeTableData.getDefaultIconCSS();
        this.hasAssociatedRubricMap = gbGradeTableData.getHasAssociatedRubricMap();

        this.columns = loadColumns(gbGradeTableData.getAssignments());
        this.students = loadStudents(this.studentGradeInfoList);
    }

    /**
     * Helper to build a map of category ID to category, so we can find the correct category to be displayed in the table later
     */
    private void buildCategoryMap() {
        this.categories.forEach(c -> {
            this.categoryMap.put(c.getId(), c);
        });
    }

    public String toScript() {
        final ObjectMapper mapper = new ObjectMapper();

        final List<Score> grades = gradeList();

        // if we can't edit one of the items,
        // we need to serialize this into the data
        if (!isUserAbleToEditAssessments() && grades.stream().anyMatch(g -> !g.canEdit())) {
            int i = 0;
            for (final StudentDefinition student : GbGradebookData.this.students) {
                String readonly = "";
                for (final ColumnDefinition column : GbGradebookData.this.columns) {
                    final Score score = grades.get(i);
                    readonly += score.canEdit() ? "0" : "1";
                    i = i + 1;
                }
                student.setReadonly(readonly);
            }
        }

        final DataSet dataset = new DataSet(GbGradebookData.this.students, GbGradebookData.this.columns,
                courseGrades(), serializeGrades(grades), serializeSettings());

        try {
            return mapper.writeValueAsString(dataset);
        } catch (final JsonProcessingException e) {
            throw new RuntimeException(e);
        }
    }

    private String serializeGrades(final List<Score> gradeList) {
        if (gradeList.stream().anyMatch(score -> !score.isPackable())) {
            return "json:" + serializeLargeGrades(gradeList);
        } else {
            return "packed:" + serializeSmallGrades(gradeList);
        }
    }

    private String serializeLargeGrades(final List<Score> gradeList) {
        final List<Double> scores = gradeList.stream().map(score -> score.isNull() ? null : score.getScore())
                .collect(Collectors.toList());

        try {
            final ObjectMapper mapper = new ObjectMapper();
            return mapper.writeValueAsString(scores);
        } catch (final JsonProcessingException e) {
            throw new RuntimeException(e);
        }
    }

    ///
    // Pack our list of scores into as little space as possible.
    //
    // Most scores will be between 0 - 100, so we store small numbers in a
    // single byte. Larger scores will be stored in two bytes, and scores with
    // fractional parts in three bytes.
    //
    // We go to all of this effort because grade tables can be very large. A
    // site with 2,000 students and 100 gradeable items will have 200,000
    // scores. Even if all of those scores were between 0 and 100, encoding
    // them as a JSON array would use around 3 bytes per score (two digits plus
    // a comma separator), for a total of 600KB. We can generally at least
    // halve that number by byte packing the numbers ourselves and unpacking
    // them using JavaScript on the client.
    //
    // Why not just use AJAX and send them a chunk at a time? The GradebookNG
    // design is built around having a single large table of all grades, and
    // firing AJAX requests on scroll events ends up being prohibitively slow.
    // Having all the data available up front helps keep the scroll performance
    // fast.
    //
    private String serializeSmallGrades(final List<Score> gradeList) {
        final StringBuilder sb = new StringBuilder();

        for (final Score score : gradeList) {
            if (score == null || score.isNull()) {
                // No grade set. Use a sentinel value.
                sb.appendCodePoint(this.NULL_SENTINEL);
                continue;
            }

            final double grade = score.getScore();

            if (grade < 0) {
                throw new IllegalStateException("serializeSmallGrades doesn't support negative scores");
            }

            final boolean hasFraction = ((int) grade != grade);

            if (grade < 127 && !hasFraction) {
                // single byte, no fraction
                //
                // input number like 0nnnnnnn serialized as 0nnnnnnn
                sb.appendCodePoint((int) grade & 0xFF);
            } else if (grade < 16384 && !hasFraction) {
                // two byte, no fraction
                //
                // input number like 00nnnnnn nnnnnnnn serialized as 10nnnnnn nnnnnnnn
                //
                // where leading '10' means 'two bytes, no fraction part'
                sb.appendCodePoint(((int) grade >> 8) | 0b10000000);
                sb.appendCodePoint(((int) grade & 0xFF));
            } else if (grade < 16384) {
                // three byte encoding, fraction
                //
                // input number like 00nnnnnn nnnnnnnn.25 serialized as 11nnnnnn nnnnnnnn 00011001
                //
                // where leading '11' means 'two bytes plus a fraction part',
                // and the fraction part is stored as an integer between 0-99,
                // where 50 represents 0.5, 25 represents .25, etc.

                sb.appendCodePoint(((int) grade >> 8) | 0b11000000);
                sb.appendCodePoint((int) grade & 0xFF);
                sb.appendCodePoint((int) Math.round((grade * 100) - ((int) grade * 100)));
            } else {
                throw new RuntimeException("Grade too large: " + grade);
            }
        }

        try {
            return Base64.getEncoder().encodeToString(sb.toString().getBytes("ISO-8859-1"));
        } catch (final UnsupportedEncodingException e) {
            throw new RuntimeException(e);
        }
    }

    private Map<String, Object> serializeSettings() {
        final Map<String, Object> result = new HashedMap();

        result.put("isCourseLetterGradeDisplayed", this.settings.isCourseLetterGradeDisplayed());
        result.put("isCourseAverageDisplayed", this.settings.isCourseAverageDisplayed());
        result.put("isCoursePointsDisplayed", this.settings.isCoursePointsDisplayed());
        result.put("isPointsGradeEntry",
                GradingType.valueOf(this.settings.getGradeType()).equals(GradingType.POINTS));
        result.put("isPercentageGradeEntry",
                GradingType.valueOf(this.settings.getGradeType()).equals(GradingType.PERCENTAGE));
        result.put("isCategoriesEnabled",
                GbCategoryType.valueOf(this.settings.getCategoryType()) != GbCategoryType.NO_CATEGORY);
        result.put("isCategoryTypeWeighted",
                GbCategoryType.valueOf(this.settings.getCategoryType()) == GbCategoryType.WEIGHTED_CATEGORY);
        result.put("isStudentOrderedByLastName",
                this.uiSettings.getNameSortOrder() == GbStudentNameSortOrder.LAST_NAME);
        result.put("isStudentOrderedByFirstName",
                this.uiSettings.getNameSortOrder() == GbStudentNameSortOrder.FIRST_NAME);
        result.put("isGroupedByCategory", this.uiSettings.isGroupedByCategory());
        result.put("isCourseGradeReleased", this.settings.isCourseGradeDisplayed());
        result.put("showPoints", this.uiSettings.getShowPoints());
        result.put("isUserAbleToEditAssessments", isUserAbleToEditAssessments());
        result.put("isStudentNumberVisible", this.isStudentNumberVisible);
        result.put("isSectionsVisible",
                this.isSectionsVisible && ServerConfigurationService.getBoolean("gradebookng.showSections", true));
        result.put("isSetUngradedToZeroEnabled", ServerConfigurationService.getBoolean(SAK_PROP_SHOW_SET_ZERO_SCORE,
                SAK_PROP_SHOW_SET_ZERO_SCORE_DEFAULT));
        result.put("isShowDisplayCourseGradeToStudentEnabled", ServerConfigurationService
                .getBoolean(SAK_PROP_SHOW_COURSE_GRADE_STUDENT, SAK_PROP_SHOW_COURSE_GRADE_STUDENT_DEFAULT));

        return result;
    };

    private List<String[]> courseGrades() {
        final List<String[]> result = new ArrayList<>();

        final Map<String, Double> gradeMap = this.settings.getSelectedGradingScaleBottomPercents();
        final List<String> ascendingGrades = new ArrayList<>(gradeMap.keySet());
        ascendingGrades.sort(new Comparator<String>() {
            @Override
            public int compare(final String a, final String b) {
                return new CompareToBuilder().append(gradeMap.get(a), gradeMap.get(b)).toComparison();
            }
        });

        for (final GbStudentGradeInfo studentGradeInfo : GbGradebookData.this.studentGradeInfoList) {
            // String[0] = A+ (95%) [133/140] -- display string
            // String[1] = 95 -- raw percentage for sorting
            // String[2] = 1 -- '1' if an override, '0' if calculated
            final String[] gradeData = new String[3];

            final GbCourseGrade gbCourseGrade = studentGradeInfo.getCourseGrade();
            final CourseGrade courseGrade = gbCourseGrade.getCourseGrade();

            gradeData[0] = gbCourseGrade.getDisplayString();

            if (StringUtils.isNotBlank(courseGrade.getEnteredGrade())) {
                gradeData[2] = "1";
            } else {
                gradeData[2] = "0";
            }

            if (StringUtils.isNotBlank(courseGrade.getEnteredGrade())) {
                Double mappedGrade = this.courseGradeMap.get(courseGrade.getEnteredGrade());
                if (mappedGrade == null) {
                    mappedGrade = new Double(0);
                }
                gradeData[1] = FormatHelper.formatGradeForDisplay(mappedGrade);
            } else {
                if (courseGrade.getPointsEarned() == null) {
                    gradeData[1] = "0";
                } else {
                    gradeData[1] = FormatHelper.formatGradeForDisplay(courseGrade.getCalculatedGrade());
                }
            }

            result.add(gradeData);
        }

        return result;
    }

    private List<Score> gradeList() {
        final List<Score> result = new ArrayList<>();

        for (final GbStudentGradeInfo studentGradeInfo : GbGradebookData.this.studentGradeInfoList) {
            for (final ColumnDefinition column : GbGradebookData.this.columns) {
                final Score grade = column.getValueFor(studentGradeInfo, isUserAbleToEditAssessments());
                result.add(grade);
            }
        }

        return result;

    }

    private String getString(final String key) {
        return this.parent.getString(key);
    }

    private List<StudentDefinition> loadStudents(final List<GbStudentGradeInfo> studentInfo) {
        final List<StudentDefinition> result = new ArrayList<>();

        for (final GbStudentGradeInfo student : studentInfo) {
            final StudentDefinition studentDefinition = new StudentDefinition();
            studentDefinition.setEid(student.getStudentEid());
            studentDefinition.setUserId(student.getStudentUuid());
            studentDefinition.setFirstName(student.getStudentFirstName());
            studentDefinition.setLastName(student.getStudentLastName());
            studentDefinition
                    .setHasComments(formatColumnFlags(student, g -> StringUtils.isNotBlank(g.getGradeComment())));
            studentDefinition.setHasDroppedScores(formatColumnFlags(student, g -> g.isDroppedFromCategoryScore()));
            studentDefinition.setHasExcuse(formatColumnFlags(student, g -> g.isExcused()));

            if (this.isStudentNumberVisible) {
                studentDefinition.setStudentNumber(student.getStudentNumber());
            }

            // The JavaScript will ultimately set this when it detects
            // concurrent edits. Initialize to zeroo.
            final StringBuilder zeroes = new StringBuilder();
            for (final ColumnDefinition column : GbGradebookData.this.columns) {
                zeroes.append("0");
            }
            studentDefinition.setHasConcurrentEdit(zeroes.toString());

            studentDefinition.setSections(student.getSections());

            result.add(studentDefinition);
        }

        return result;
    }

    private List<ColumnDefinition> loadColumns(final List<Assignment> assignments) {
        final GradebookUiSettings userSettings = ((GradebookPage) this.parent.getPage()).getUiSettings();

        final List<ColumnDefinition> result = new ArrayList<>();

        if (assignments.isEmpty()) {
            return result;
        }

        for (int i = 0; i < assignments.size(); i++) {
            final Assignment a1 = assignments.get(i);
            final Assignment a2 = ((i + 1) < assignments.size()) ? assignments.get(i + 1) : null;

            String categoryWeight = null;
            if (a1.getWeight() != null) {
                categoryWeight = FormatHelper.formatDoubleAsPercentage(a1.getWeight() * 100);
            }

            boolean counted = a1.isCounted();
            // An assignment is not counted if uncategorised and the categories are enabled
            if ((GbCategoryType.valueOf(this.settings.getCategoryType()) != GbCategoryType.NO_CATEGORY)
                    && a1.getCategoryId() == null) {
                counted = false;
            }
            result.add(new AssignmentDefinition(a1.getId(), FormatHelper.stripLineBreaks(a1.getName()),
                    FormatHelper.abbreviateMiddle(a1.getName()), FormatHelper.formatDoubleToDecimal(a1.getPoints()),
                    FormatHelper.formatDate(a1.getDueDate(), getString("label.studentsummary.noduedate")),

                    a1.isReleased(), counted, a1.isExtraCredit(), a1.isExternallyMaintained(),
                    a1.isExternallyMaintained() ? this.hasAssociatedRubricMap.get(a1.getExternalId())
                            : this.hasAssociatedRubricMap.get(String.valueOf(a1.getId())),
                    a1.getExternalId(), a1.getExternalAppName(),
                    getIconCSSForExternalAppName(a1.getExternalAppName()),

                    nullable(a1.getCategoryId()), a1.getCategoryName(),
                    userSettings.getCategoryColor(a1.getCategoryName()), nullable(categoryWeight),
                    a1.isCategoryExtraCredit(),

                    !this.uiSettings.isAssignmentVisible(a1.getId())));

            // If we're at the end of the assignment list, or we've just changed
            // categories, put out a total.
            if (userSettings.isGroupedByCategory() && a1.getCategoryId() != null
                    && (a2 == null || !a1.getCategoryId().equals(a2.getCategoryId()))) {
                result.add(new CategoryAverageDefinition(a1.getCategoryId(), a1.getCategoryName(),
                        (new StringResourceModel("label.gradeitem.categoryaverage", null,
                                new Object[] { a1.getCategoryName() })).getString(),
                        nullable(categoryWeight), getCategoryPoints(a1.getCategoryId()), a1.isCategoryExtraCredit(),
                        userSettings.getCategoryColor(a1.getCategoryName()),
                        !this.uiSettings.isCategoryScoreVisible(a1.getCategoryName()),
                        FormatHelper.formatCategoryDropInfo(this.categories.stream()
                                .filter(c -> c.getId().equals(a1.getCategoryId())).findAny().orElse(null))));
            }
        }

        // if group by categories is disabled, then show all catagory scores
        // at the end of the table
        if (!userSettings.isGroupedByCategory()) {
            for (final CategoryDefinition category : this.categories) {
                if (!category.getAssignmentList().isEmpty()) {
                    String categoryWeight = null;
                    if (category.getWeight() != null) {
                        categoryWeight = FormatHelper.formatDoubleAsPercentage(category.getWeight() * 100);
                    }
                    result.add(new CategoryAverageDefinition(category.getId(), category.getName(),
                            (new StringResourceModel("label.gradeitem.categoryaverage", null,
                                    new Object[] { category.getName() })).getString(),
                            nullable(categoryWeight), category.getTotalPoints(), category.getExtraCredit(),
                            userSettings.getCategoryColor(category.getName()),
                            !this.uiSettings.isCategoryScoreVisible(category.getName()),
                            FormatHelper.formatCategoryDropInfo(category)));
                }
            }
        }

        return result;
    }

    private Double getCategoryPoints(final Long categoryId) {
        final CategoryDefinition category = this.categoryMap.get(categoryId);
        if (category != null) {
            return category.getTotalPoints();
        }
        return Double.valueOf(0);
    }

    private String formatColumnFlags(final GbStudentGradeInfo student, final Predicate<GbGradeInfo> predicate) {
        final StringBuilder sb = new StringBuilder();

        for (final ColumnDefinition column : this.columns) {
            if (column instanceof AssignmentDefinition) {
                final AssignmentDefinition assignmentColumn = (AssignmentDefinition) column;
                final GbGradeInfo gradeInfo = student.getGrades().get(assignmentColumn.getAssignmentId());
                if (gradeInfo != null && predicate.test(gradeInfo)) {
                    sb.append('1');
                } else {
                    sb.append('0');
                }
            } else {
                sb.append('0');
            }
        }

        return sb.toString();
    }

    private String nullable(final Object value) {
        if (value == null) {
            return null;
        } else {
            return value.toString();
        }
    }

    private boolean isInstructor() {
        return GbRole.INSTRUCTOR.equals(this.role);
    }

    private boolean isUserAbleToEditAssessments() {
        return this.isUserAbleToEditAssessments;
    }

    private abstract class Score {
        private final String score;
        private boolean isExcused;

        public Score(final String score) {
            this.score = score;
        }

        abstract boolean canEdit();

        // We assume you'll check isNull() prior to calling this
        public double getScore() {
            return Double.valueOf(this.score);
        };

        public boolean isNull() {
            return this.score == null;
        }

        public boolean isPackable() {
            return isNull() || (Double.valueOf(this.score) >= 0 && Double.valueOf(this.score) < 16384);
        }

        public boolean isExcused() {
            return this.isExcused;
        }

        public void setExcused(final boolean excused) {
            this.isExcused = excused;
        }
    }

    private class EditableScore extends Score {
        public EditableScore(final String score) {
            super(score);
        }

        @Override
        public boolean canEdit() {
            return true;
        }
    }

    private class ReadOnlyScore extends Score {
        public ReadOnlyScore(final String score) {
            super(score);
        }

        @Override
        public boolean canEdit() {
            return false;
        }
    }

    private String getIconCSSForExternalAppName(final String externalAppName) {
        if (this.toolNameIconCSSMap.containsKey(externalAppName)) {
            return this.toolNameIconCSSMap.get(externalAppName);
        }

        return this.defaultIconCSS;
    }
}