Auto-assigns profiles to regular workshifts.

    Parameters
    ----------
    semester : workshift.models.Semester, optional
    pool : workshift.models.WorkshiftPool, optional
    profiles : list of workshift.models.WorkshiftProfile, optional
    shifts : list of workshift.models.RegularWorkshift, optional

def auto_assign_shifts(semester=None, pool=None, profiles=None, shifts=None):
    """
    Auto-assigns profiles to regular workshifts.

    Parameters
    ----------
    semester : workshift.models.Semester, optional
    pool : workshift.models.WorkshiftPool, optional
    profiles : list of workshift.models.WorkshiftProfile, optional
    shifts : list of workshift.models.RegularWorkshift, optional
    """
    if semester is None:
        try:
            semester = Semester.objects.get(current=True)
        except (Semester.DoesNotExist, Semester.MultipleObjectsReturned):
            return
    if pool is None:
        pool = WorkshiftPool.objects.get(
            semester=semester,
            is_primary=True,
        )
    if profiles is None:
        profiles = WorkshiftProfile.objects.filter(
            semester=semester,
        ).order_by('preference_save_time')
    if shifts is None:
        shifts = RegularWorkshift.objects.filter(
            pool=pool,
            workshift_type__assignment=WorkshiftType.AUTO_ASSIGN,
        )

    shifts = set([
        shift
        for shift in shifts
        if shift.current_assignees.count() < shift.count
    ])
    profiles = list(profiles)

    # List of hours assigned to each profile
    hours_mapping = defaultdict(float)

    # Pre-process, rank shifts by their times / preferences
    rankings = defaultdict(set)

    # Initialize with already-assigned shifts
    for profile in profiles:
        pool_hours = profile.pool_hours.get(pool=pool)
        hours_mapping[profile] = float(pool_hours.assigned_hours)

        for shift in shifts:
            # Skip shifts that put a member over their hour requirement
            added_hours = float(shift.hours) + hours_mapping[profile]
            if added_hours > float(pool_hours.hours):
                continue

            # Check how well this shift fits the member's schedule
            status = is_available(profile, shift)
            if not status or status == TimeBlock.BUSY:
                continue

            try:
                rating = profile.ratings.get(
                    workshift_type=shift.workshift_type,
                ).rating
            except WorkshiftRating.DoesNotExist:
                rating = WorkshiftRating.INDIFFERENT

            if rating == WorkshiftRating.DISLIKE:
                rank = 5
            elif rating == WorkshiftRating.INDIFFERENT:
                rank = 3
            else:
                rank = 1

            if status != TimeBlock.PREFERRED:
                rank += 1

            rankings[profile, rank].add(shift)

    # Assign shifts in a round-robin manner, run until we can't assign anyone
    # any more shifts
    while any(rankings.values()):
        for profile in profiles[:]:
            pool_hours = profile.pool_hours.get(pool=pool)

            # Assign a shift, picking from the most preferable groups first
            for rank in range(1, 7):
                # Update the rankings with the list of available shifts
                rankings[profile, rank].intersection_update(shifts)

                if rankings[profile, rank]:
                    # Select the shift, starting with those that take the most
                    # hours and fit the best into the workshifter's allotted
                    # hours
                    shift = max(rankings[profile, rank], key=lambda x: x.hours)

                    # Assign the person to their shift
                    shift.current_assignees.add(profile)

                    hours_mapping[profile] += float(shift.hours)

                    # Remove shift from shifts if it has been completely filled
                    if shift.current_assignees.count() >= shift.count:
                        shifts.remove(shift)

                    break

            # Remove profiles when their hours have all been assigned
            if float(pool_hours.hours) <= hours_mapping[profile]:
                profiles.remove(profile)

                for rank in range(1, 7):
                    if (profile, rank) in rankings:
                        del rankings[profile, rank]

                continue

            # Otherwise, Remove shifts that put people above their weekly
            # allocation
            for rank in range(1, 7):
                rankings[profile, rank] = set(
                    shift
                    for shift in rankings[profile, rank]
                    if float(shift.hours) + hours_mapping[profile] <=
                    float(pool_hours.hours)
                )

    # Return profiles that were incompletely assigned shifts
    return profiles