ilios/frontend

View on GitHub
packages/ilios-common/addon/models/learner-group.js

Summary

Maintainability
B
4 hrs
Test Coverage
import Model, { hasMany, belongsTo, attr } from '@ember-data/model';
import escapeRegExp from 'ilios-common/utils/escape-reg-exp';
import { map } from 'rsvp';
import { TrackedAsyncData } from 'ember-async-data';
import { cached } from '@glimmer/tracking';
import { mapBy, uniqueValues } from 'ilios-common/utils/array-helpers';

export default class LearnerGroup extends Model {
  @attr('string')
  title;

  @attr('string')
  location;

  @attr('string')
  url;

  @attr('boolean')
  needsAccommodation;

  @belongsTo('cohort', { async: true, inverse: 'learnerGroups' })
  cohort;

  @belongsTo('learner-group', { async: true, inverse: 'children' })
  parent;

  @cached
  get _parentData() {
    return new TrackedAsyncData(this.parent);
  }

  @hasMany('learner-group', { async: true, inverse: 'parent' })
  children;

  @cached
  get _childrenData() {
    return new TrackedAsyncData(this.children);
  }

  @hasMany('ilm-session', { async: true, inverse: 'learnerGroups' })
  ilmSessions;

  @cached
  get _ilmSessionsData() {
    return new TrackedAsyncData(this.ilmSessions);
  }

  @hasMany('offering', { async: true, inverse: 'learnerGroups' })
  offerings;

  @cached
  get _offeringsData() {
    return new TrackedAsyncData(this.offerings);
  }

  @hasMany('instructor-group', { async: true, inverse: 'learnerGroups' })
  instructorGroups;

  @cached
  get _instructorGroupsData() {
    return new TrackedAsyncData(this.instructorGroups);
  }

  @hasMany('user', { async: true, inverse: 'learnerGroups' })
  users;

  @cached
  get _usersData() {
    return new TrackedAsyncData(this.users);
  }

  @hasMany('user', {
    async: true,
    inverse: 'instructedLearnerGroups',
  })
  instructors;

  @cached
  get _instructorsData() {
    return new TrackedAsyncData(this.instructors);
  }

  @belongsTo('learner-group', {
    inverse: 'descendants',
    async: true,
  })
  ancestor;

  @hasMany('learner-group', {
    inverse: 'ancestor',
    async: true,
  })
  descendants;

  @cached
  get _offeringSessionsData() {
    if (!this._offeringsData.isResolved) {
      return null;
    }

    return new TrackedAsyncData(Promise.all(this._offeringsData.value.map((o) => o.session)));
  }

  @cached
  get _ilmSessionSessionsData() {
    if (!this._ilmSessionsData.isResolved) {
      return null;
    }

    return new TrackedAsyncData(Promise.all(this._ilmSessionsData.value.map((o) => o.session)));
  }

  /**
   * A list of all sessions associated with this learner group, via offerings or via ILMs.
   */
  get sessions() {
    if (
      !this._offeringSessionsData ||
      !this._offeringSessionsData.isResolved ||
      !this._ilmSessionSessionsData ||
      !this._ilmSessionSessionsData.isResolved
    ) {
      return [];
    }

    return uniqueValues(
      [...this._offeringSessionsData.value, ...this._ilmSessionSessionsData.value].filter(Boolean),
    );
  }

  @cached
  get _sessionCourses() {
    return new TrackedAsyncData(Promise.all(this.sessions.map((s) => s.course)));
  }

  /**
   * A list of all courses associated with this learner group, via offerings/sessions or via ILMs.
   */
  get courses() {
    if (!this._sessionCourses.isResolved) {
      return [];
    }
    return uniqueValues(this._sessionCourses.value);
  }

  /**
   * Get the offset for numbering generated subgroups.
   *
   * This is best explained by an example:
   * A learner group 'Foo' has three similarly named subgroups 'Foo 1', 'Foo 2', and 'Foo 4'. The highest identified
   * subgroup number is 4, so the offset for generating new subgroups is 5.
   * If no subgroups exist, or none of the subgroup names match the <code>(Parent) (Number)</code> pattern, then the
   * offset will default to 1.
   */
  async getSubgroupNumberingOffset() {
    const regex = new RegExp('^' + escapeRegExp(this.title) + ' ([0-9]+)$');
    const groups = await this.children;
    let offset = groups.reduce((previousValue, item) => {
      let rhett = previousValue;
      const matches = regex.exec(item.get('title'));
      if (matches) {
        rhett = Math.max(rhett, parseInt(matches[1], 10));
      }
      return rhett;
    }, 0);
    return ++offset;
  }

  @cached
  get _allDescendantsData() {
    if (!this._childrenData.isResolved) {
      return null;
    }

    return new TrackedAsyncData(
      Promise.all(this._childrenData.value.map((c) => c.getAllDescendants())),
    );
  }

  /**
   * A list of all nested sub-groups of this group.
   */
  get allDescendants() {
    if (!this._childrenData.isResolved || !this._allDescendantsData?.isResolved) {
      return [];
    }

    return [...this._childrenData.value, ...this._allDescendantsData.value.flat()];
  }

  async getAllDescendants() {
    const children = (await this.children).slice();
    const childDescendants = await map(children, (child) => {
      return child.getAllDescendants();
    });

    return [...children, ...childDescendants.flat()];
  }

  @cached
  get _allDescendantsUsersData() {
    return new TrackedAsyncData(Promise.all(this.allDescendants.map((g) => g.users)));
  }

  /**
   * A list of all users in this group and any of its sub-groups.
   */
  get allDescendantUsers() {
    if (!this._usersData.isResolved || !this._allDescendantsUsersData.isResolved) {
      return null;
    }

    return uniqueValues([...this._usersData.value, ...this._allDescendantsUsersData.value.flat()]);
  }

  async getAllDescendantUsers() {
    const users = await this.users;
    const descendantUsers = await this._getDescendantUsers();
    return uniqueValues([...users.slice(), ...descendantUsers]);
  }

  async _getDescendantUsers() {
    const allDescendants = await this.getAllDescendants();
    const descendantsUsers = await Promise.all(mapBy(allDescendants, 'users'));
    return descendantsUsers.reduce((all, groups) => {
      return [...all, ...groups.slice()];
    }, []);
  }

  /**
   * A list of users that are assigned to this group, excluding those that are ALSO assigned to any of this group's sub-groups.
   */
  get usersOnlyAtThisLevel() {
    if (!this._usersData.isResolved || !this._allDescendantsUsersData.isResolved) {
      return null;
    }
    const descendantUsers = this._allDescendantsUsersData.value.flat();

    return this._usersData.value.filter((user) => !descendantUsers.includes(user));
  }

  async getUsersOnlyAtThisLevel() {
    const users = (await this.users).slice();
    const descendantsUsers = await this._getDescendantUsers();

    return users.filter((user) => !descendantsUsers.includes(user));
  }

  get allParentTitles() {
    if (
      this.isTopLevelGroup ||
      !this._parentData.isResolved ||
      !this._allAncestorsData.isResolved
    ) {
      return [];
    }

    return [...mapBy(this._allAncestorsData.value, 'title'), this._parentData.value.title];
  }

  get allParentsTitle() {
    return this.allParentTitles.reduce((acc, curr) => {
      return (acc += curr + ' > ');
    }, '');
  }

  get sortTitle() {
    if (this.isTopLevelGroup) {
      return this.title.replace(/\s/g, '');
    }

    if (!this._parentData.isResolved || !this._allAncestorsData?.isResolved) {
      return undefined;
    }

    return [
      ...mapBy([...this._allAncestorsData.value.reverse(), this._parentData.value], 'title'),
      this.title,
    ]
      .join('')
      .replace(/\s/g, '');
  }

  /**
   * A text string comprised of all learner-group titles in this group's tree.
   * This includes that titles of all of its ancestors, all its descendants and this group's title itself.
   */
  get filterTitle() {
    if (
      !this._parentData.isResolved ||
      !this._allAncestorsData?.isResolved ||
      !this._childrenData.isResolved ||
      !this._allDescendantsData?.isResolved
    ) {
      return '';
    }

    const up = this.isTopLevelGroup
      ? []
      : [this._parentData.value, ...this._allAncestorsData.value];
    const down = [...this._childrenData.value, ...this._allDescendantsData.value.flat()];

    return [...mapBy(down, 'title'), ...mapBy(up, 'title'), this.title].join('');
  }

  async _getAllParents() {
    const parent = await this.parent;
    if (!parent) {
      return [];
    }
    const grandparents = await parent.getAllParents();

    return [parent, ...grandparents];
  }

  @cached
  get _allAncestorsData() {
    if (!this._parentData.isResolved) {
      return null;
    }

    return new TrackedAsyncData(this._parentData.value?._getAllParents());
  }

  get allParents() {
    if (
      this.isTopLevelGroup ||
      !this._parentData.isResolved ||
      !this._allAncestorsData?.isResolved
    ) {
      return [];
    }

    return [this._parentData.value, ...this._allAncestorsData.value];
  }

  /**
   * The top-level group in this group's parentage tree, or this group itself if it has no parent.
   */
  get topLevelGroup() {
    if (this.isTopLevelGroup) {
      return this;
    }
    if (!this._parentData.isResolved) {
      return null;
    }
    return this._parentData.value.topLevelGroup;
  }

  async getTopLevelGroup() {
    if (this.isTopLevelGroup) {
      return this;
    }

    const parent = await this.parent;
    return parent.getTopLevelGroup();
  }

  get isTopLevelGroup() {
    return !this.belongsTo('parent').id();
  }

  @cached
  get _instructorGroupUsersData() {
    if (!this._instructorGroupsData.isResolved) {
      return null;
    }

    return new TrackedAsyncData(
      Promise.all(this._instructorGroupsData.value.map((ig) => ig.users)),
    );
  }

  get allInstructors() {
    if (!this._instructorsData?.isResolved || !this._instructorGroupUsersData?.isResolved) {
      return [];
    }

    return uniqueValues([
      ...this._instructorsData.value,
      ...this._instructorGroupUsersData.value.flat(),
    ]);
  }

  get _descendantUsers() {
    return new TrackedAsyncData(Promise.all(this.allDescendants.map((lg) => lg.users)));
  }

  /**
   * Checks if this group or any of its subgroups has any learners.
   */
  get hasLearnersInGroupOrSubgroups() {
    if (this.hasMany('users').ids().length) {
      return true;
    }

    if (!this._allDescendantsUsersData?.isResolved) {
      return false;
    }

    return this._allDescendantsUsersData.value.flat().length > 0;
  }

  /**
   * Recursively checks if any of this group's subgroups and their subgroups need accommodation.
   */
  get hasSubgroupsInNeedOfAccommodation() {
    // no subgroups? no needs.
    if (!this.hasMany('children').ids().length) {
      return false;
    }

    const subGroupsInNeedOfAccomodation = this.allDescendants?.filter(
      (group) => group.needsAccommodation,
    );

    return subGroupsInNeedOfAccomodation?.length > 0;
  }

  /**
   * Returns the number of users in this group
   */
  get usersCount() {
    return this.hasMany('users').ids().length;
  }

  /**
   * Returns the number of children in this group
   */
  get childrenCount() {
    return this.hasMany('children').ids().length;
  }

  /**
   * Takes a user out of  a group and then traverses child groups recursively
   * to remove the user from them as well and returns the entire tree for saving.
   */
  async removeUserFromGroupAndAllDescendants({ id: userId }) {
    const allDescendants = await this.getAllDescendants();
    return await map([this, ...allDescendants], async (group) => {
      if (group.hasMany('users').ids().includes(userId)) {
        group.users = (await group.users).filter(({ id }) => id !== userId);
      }

      return group;
    });
  }

  async getAllParents() {
    const parent = await this.parent;
    if (!parent) {
      return [];
    }
    const allParents = await parent.getAllParents();
    return [...allParents, parent];
  }

  async getAllParentTitles() {
    const parent = await this.parent;
    if (!parent) {
      return [];
    }
    const parents = await parent.getAllParents();
    const titles = mapBy(parents, 'title');
    return [...titles, parent.title];
  }

  async getTitleWithParentTitles() {
    const parentTitles = await this.getAllParentTitles();
    if (!parentTitles.length) {
      return this.title;
    }
    return parentTitles.join(' > ') + ' > ' + this.title;
  }

  /**
   * Adds a user to a group and then traverses parent groups recursively
   * to add the user to them as well.  Will only modify groups where the
   * user currently does not exist.
   */
  async addUserToGroupAndAllParents(user) {
    const modifiedGroups = [];
    const userId = user.id;
    const allParents = await this.getAllParents();
    const groups = [this, ...allParents];
    for (let i = 0; i < groups.length; i++) {
      const users = await groups[i].users;
      const ids = mapBy(users, 'id');
      if (!ids.includes(userId)) {
        users.push(user);
        modifiedGroups.push(groups[i]);
      }
    }
    return uniqueValues(modifiedGroups);
  }
}