示例#1
0
    def delete(self, org_id, location_id, role_id, shift_id):
        shift = Shift2.query.get_or_404(shift_id)
        location = Location.query.get_or_404(location_id)
        user_id = shift.user_id  # cached becuase we are deleting the shift

        # check if a schedule exists during this time - if so, bust the cache
        schedule = Schedule2.query \
            .filter(
                Schedule2.role_id == role_id,
                Schedule2.start <= shift.start,
                Schedule2.stop > shift.start,
            ).first()

        try:
            db.session.delete(shift)
            db.session.commit()
        except Exception as exception:
            db.session.rollback()
            current_app.logger.error(str(exception))
            abort(400)

        # clear cache
        if schedule is not None:
            Shifts2Cache.delete(schedule.id)

        if (g.current_user.id != shift.user_id) and shift.published:
            default_tz = get_default_tz()
            local_tz = location.timezone_pytz
            local_datetime = default_tz.localize(
                shift.start).astimezone(local_tz)
            alert_changed_shift(org_id, location_id, role_id, local_datetime,
                                user_id)

        g.current_user.track_event("deleted_shift")
        return {}, 204
示例#2
0
    def _get_schedule_range(self, role):
        """
        given a Role object, determines the start/stop of its next schedule

        return: (tuple) start, stop
        """

        org = Organization.query.join(Location)\
            .join(Role).filter(Role.id == role.id).first()

        default_tz = get_default_tz()
        local_tz = role.location.timezone_pytz

        last_schedule = Schedule2.query \
            .filter_by(role_id=role.id) \
            .order_by(desc(Schedule2.start)) \
            .first()

        # schedule exists
        if last_schedule:
            start = default_tz.localize(last_schedule.stop)

        # need to create a start for the 1st schedule
        else:

            now = local_tz.localize(normalize_to_midnight(datetime.utcnow()))

            now_utc = now.astimezone(default_tz)
            week_start_index = constants.DAYS_OF_WEEK.index(
                org.day_week_starts)
            adjust_days = ((6 - week_start_index + now.weekday()) %
                           constants.WEEK_LENGTH)

            start = normalize_to_midnight(
                (now_utc - timedelta(days=adjust_days, hours=23)
                 ).astimezone(local_tz)).astimezone(default_tz)

        stop = normalize_to_midnight((start + timedelta(days=constants.WEEK_LENGTH, hours=1)).astimezone(local_tz)) \
            .astimezone(default_tz)

        return start, stop
示例#3
0
    def is_within_caps(self, user_id):
        """
        does the addition of this shift exceed the user's hourly caps
        """

        role = Role.query.get(self.role_id)
        location = role.location
        org = location.organization
        role_to_user = RoleToUser.query.filter_by(role_id=self.role_id,
                                                  user_id=user_id,
                                                  archived=False).first()

        # archived users should not qualify
        if role_to_user is None:
            return False

        min_seconds_shift_gap = role.min_half_hours_between_shifts / constants.HALF_HOUR_TO_HOUR * constants.SECONDS_PER_HOUR
        max_seconds_workday = role.max_half_hours_per_workday / constants.HALF_HOUR_TO_HOUR * constants.SECONDS_PER_HOUR
        max_seconds_workweek = role_to_user.max_half_hours_per_workweek / constants.HALF_HOUR_TO_HOUR * constants.SECONDS_PER_HOUR

        default_tz = get_default_tz()
        local_tz = role.location.timezone_pytz

        # get schedule that exists during this shift
        schedule = self.get_schedule()

        # schedule may not exist - in which case must artificially determine search bounds and get shifts
        if schedule is None:
            local_start = default_tz.localize(self.start).astimezone(local_tz)

            # these values correspond to what the schedules date range would be
            start = normalize_to_midnight(
                org.get_week_start_from_datetime(local_start)).astimezone(
                    default_tz)
            stop = normalize_to_midnight(
                (start + timedelta(days=constants.WEEK_LENGTH, hours=1)
                 ).astimezone(local_tz)).astimezone(default_tz).replace(
                     tzinfo=None)

            start = start.replace(tzinfo=None)

        # create same variables for parity
        else:
            start = schedule.start
            stop = schedule.stop

        # now get full range for query - need to know beyond just start/stop for
        # measuring consecutive days off
        query_start = start - timedelta(days=role.max_consecutive_workdays)
        query_stop = stop + timedelta(days=role.max_consecutive_workdays)

        # need localized start/end of the week/schedule
        week_start_local = default_tz.localize(start).astimezone(local_tz)
        week_stop_local = default_tz.localize(stop).astimezone(local_tz)

        # need to get all shifts that occur during the schedule
        # but also need those that extend beyond
        shifts = Shift2.query \
            .filter(
                Shift2.role_id == self.role_id,
                Shift2.user_id == user_id,
                Shift2.start >= query_start,
                Shift2.start < query_stop,
                Shift2.id != self.id,
            ).all()

        # time to do some comparisons

        # add self to the shifts and then sort
        # it's important to exclude the shift from the query, and
        # then add it separately there are cases where it could be
        # double counted, which would be bad
        shifts.append(self)
        shifts.sort(key=lambda x: x.start)

        workday_totals = defaultdict(int)
        consecutive_days = 0

        previous_stop = None
        current_date = None
        next_date = None

        for shift in shifts:
            shift_start_local = default_tz.localize(
                shift.start).astimezone(local_tz)
            shift_stop_local = default_tz.localize(
                shift.stop).astimezone(local_tz)

            # increment consecutive days if needed
            if shift_start_local.day == next_date:
                consecutive_days += 1

            # reset it if not consecutive days AND it's not on the same day as already credited
            elif shift_start_local.day != current_date:
                consecutive_days = 0

            # need to collect more data for the shifts that occur during the workweek
            if (start <= shift.start < stop) or (start <= shift.stop <= stop):

                # shift starts and ends on the same day
                if shift_stop_local.day == shift_start_local.day:
                    workday_totals[shift_start_local.day] += int(
                        (shift.stop - shift.start).total_seconds())

                # shift splits between midnight
                else:
                    split = normalize_to_midnight(shift_stop_local)

                    # account for case where shift overlaps from or into another week
                    if week_start_local <= shift_start_local <= week_stop_local:
                        workday_totals[shift_start_local.day] += int(
                            (split - shift_start_local).total_seconds())

                    if week_start_local <= shift_stop_local <= week_stop_local:
                        workday_totals[shift_stop_local.day] += int(
                            (shift_stop_local - split).total_seconds())

            # check time between shifts
            if previous_stop:
                if int(
                    (shift.start -
                     previous_stop).total_seconds()) < min_seconds_shift_gap:
                    return False

            # consecutive days
            if consecutive_days > role.max_consecutive_workdays:
                return False

            previous_stop = shift.stop
            next_date = (shift_start_local + timedelta(days=1)).day
            current_date = shift_start_local.day

        # total hours per workday is too high
        if sum(workday_totals.values()) > max_seconds_workweek:
            return False

        # shift is too long for a given workday
        for key in workday_totals:
            if workday_totals[key] > max_seconds_workday:
                return False

        return True  # Does not defy caps :-)
示例#4
0
    def get(self, org_id, location_id, role_id, user_id):
        """
        returns timeclock data for a specific user
        """

        parser = reqparse.RequestParser()
        parser.add_argument("active", type=inputs.boolean)
        parser.add_argument("start", type=str)
        parser.add_argument("end", type=str)
        parameters = parser.parse_args()

        # Filter out null values
        parameters = dict(
            (k, v) for k, v in parameters.iteritems() if v is not None)

        org = Organization.query.get_or_404(org_id)
        location = Location.query.get_or_404(location_id)

        default_tz = get_default_tz()
        local_tz = location.timezone_pytz

        timeclocks = Timeclock.query \
            .filter_by(role_id=role_id) \
            .filter_by(user_id=user_id)

        # if searching for active timeclocks, do not include start and end query ranges
        if "active" in parameters:
            timeclocks = timeclocks.filter_by(stop=None)

        # start and end query
        else:

            # check for end 1st
            if "end" in parameters:

                if "start" not in parameters:
                    return {
                        "message":
                        "A start parameter must be given with an end."
                    }, 400

                # ensure good iso formatting
                try:
                    end = iso8601.parse_date(parameters.get("end"))
                except iso8601.ParseError:
                    return {
                        "message":
                        "End time parameter time needs to be in ISO 8601 format"
                    }, 400
                else:
                    end = (end + end.utcoffset()).replace(tzinfo=default_tz)

                timeclocks = timeclocks.filter(Timeclock.start < end)

            # if a start is defined, it must be iso 8601
            if "start" in parameters:

                # make sure start is in right format, and also convert to full iso form
                try:
                    start = iso8601.parse_date(parameters.get("start"))
                except iso8601.ParseError:
                    return {
                        "message":
                        "Start time parameter needs to be in ISO 8601 format"
                    }, 400
                else:
                    start = (start +
                             start.utcoffset()).replace(tzinfo=default_tz)

            # otherwise determine when current week began
            else:
                now = local_tz.localize(datetime.datetime.utcnow())
                start = normalize_to_midnight(
                    org.get_week_start_from_datetime(now)).astimezone(
                        default_tz)

            # add start to query
            timeclocks = timeclocks.filter(Timeclock.start >= start)

        return {
            constants.API_ENVELOPE:
            map(lambda timeclock: marshal(timeclock, timeclock_fields),
                timeclocks.all())
        }
示例#5
0
    def get(self, org_id, location_id):
        """
        returns all shift data that correlates to a location
        """

        parser = reqparse.RequestParser()
        parser.add_argument("active", type=inputs.boolean)
        parser.add_argument("start", type=str)
        parser.add_argument("end", type=str)
        parameters = parser.parse_args()

        # Filter out null values
        parameters = dict(
            (k, v) for k, v in parameters.iteritems() if v is not None)

        org = Organization.query.get_or_404(org_id)
        location = Location.query.get_or_404(location_id)
        default_tz = get_default_tz()
        local_tz = location.timezone_pytz

        shifts = Shift2.query.join(Role).join(Location).filter(
            Location.id == location_id)

        # if searching for active shifts, do not include start and end query ranges
        if "active" in parameters:
            if "start" in parameters or "end" in parameters:
                return {
                    "message": "Cannot have start or end with active parameter"
                }, 400

            now = datetime.datetime.utcnow()

            shifts = shifts.filter(Shift2.user_id != None, Shift2.start <= now,
                                   Shift2.stop > now)

        # start and end query
        else:

            # check for end 1st
            if "end" in parameters:

                if "start" not in parameters:
                    return {
                        "message":
                        "A start parameter must be given with an end."
                    }, 400

                # ensure good iso formatting
                try:
                    end = iso8601.parse_date(parameters.get("end"))
                except iso8601.ParseError:
                    return {
                        "message":
                        "End time parameter time needs to be in ISO 8601 format"
                    }, 400
                else:
                    end = (end + end.utcoffset()).replace(tzinfo=default_tz)

                shifts = shifts.filter(Shift2.start < end)

            # if a start is defined, it must be iso 8601
            if "start" in parameters:
                # make sure start is in right format, and also convert to full iso form
                try:
                    start = iso8601.parse_date(parameters.get("start"))
                except iso8601.ParseError:
                    return {
                        "message":
                        "Start time parameter needs to be in ISO 8601 format"
                    }, 400
                else:
                    start = (start +
                             start.utcoffset()).replace(tzinfo=default_tz)

            # otherwise determine when current week began
            else:
                now = local_tz.localize(datetime.datetime.utcnow())
                start = normalize_to_midnight(
                    org.get_week_start_from_datetime(now)).astimezone(
                        default_tz)

            # add start to query
            shifts = shifts.filter(Shift2.start >= start)

        return {
            API_ENVELOPE:
            map(lambda shift: marshal(shift, shift_fields), shifts.all())
        }
示例#6
0
    def get(self, org_id, location_id):
        """
        returns all time off request data that correlates a location
        """

        parser = reqparse.RequestParser()
        parser.add_argument("start", type=str)
        parser.add_argument("end", type=str)
        parser.add_argument("state", type=str)
        parameters = parser.parse_args()

        # Filter out null values
        parameters = dict((k, v) for k, v in parameters.iteritems()
                          if v is not None)

        org = Organization.query.get_or_404(org_id)
        location = Location.query.get_or_404(location_id)
        default_tz = get_default_tz()
        local_tz = location.timezone_pytz

        # prepare query
        time_off_requests = TimeOffRequest.query.join(RoleToUser).join(
            Role).join(Location).filter(Location.id == location_id)

        # start and end query
        # check for end 1st
        if "end" in parameters:

            if "start" not in parameters:
                return {
                    "message": "A start parameter must be given with an end."
                }, 400

            # ensure good iso formatting
            try:
                end = iso8601.parse_date(parameters.get("end"))
            except iso8601.ParseError:
                return {
                    "message":
                    "End time parameter time needs to be in ISO 8601 format"
                }, 400
            else:
                end = (end + end.utcoffset()).replace(tzinfo=default_tz)

            time_off_requests = time_off_requests.filter(
                TimeOffRequest.start < end)

        # if a start is defined, it must be iso 8601
        if "start" in parameters:

            # make sure start is in right format, and also convert to full iso form
            try:
                start = iso8601.parse_date(parameters.get("start"))
            except iso8601.ParseError:
                return {
                    "message":
                    "Start time parameter needs to be in ISO 8601 format"
                }, 400
            else:
                start = (start + start.utcoffset()).replace(tzinfo=default_tz)

        # otherwise determine when current week began
        else:
            now = local_tz.localize(datetime.datetime.utcnow())
            start = normalize_to_midnight(
                org.get_week_start_from_datetime(now)).astimezone(default_tz)

        # add start to query
        time_off_requests = time_off_requests.filter(
            TimeOffRequest.start >= start)

        if "state" in parameters:
            state = parameters.get("state")

            if (state == NULL_TIME_OFF_REQUEST):
                state = None

            time_off_requests = time_off_requests.filter(
                TimeOffRequest.state == state)

        return {
            API_ENVELOPE:
            map(lambda time_off_request: marshal(time_off_request, time_off_request_fields),
                time_off_requests.all())
        }
示例#7
0
    def _create_schedules(self):
        """ Create schedules for active orgs """

        default_tz = get_default_tz()

        # Approach - Start with Roles. Join to Org so you know
        # how much lead time for a schedule (demand_opends_days_before_start).
        # Then, OUTER (left) join to Schedules. Look for schedules that
        # are IN the window of that lead time. Then, becuase it's an OUTER join,
        # filter by role IDs that do NOT have a schedule in that window.
        # You are left with roles that need a schedule to be
        # created in that window.

        roles_needing_schedules = Role.query\
            .join(Location)\
            .join(Organization)\
            .outerjoin(Schedule2,
                and_(
                    Role.id == Schedule2.role_id,
                    # Convert to seconds to do this math. Note that `time-to-sec` is mysql-specific
                    func.timestampdiff(
                        sqltext("SECOND"),
                        func.now(),
                        Schedule2.start,
                    # If not offset by 7 - start a week early
                    ) > current_app.config.get("SCHEDULES_CREATED_DAYS_BEFORE_START") * constants.SECONDS_PER_DAY,
                ),
            )\
            .filter(
                Organization.active == True,
                Role.archived == False,
                Schedule2.id == None,
            ).all()

        schedules_created = 0  # for return

        # Make schedules until horizon for all roles that need them
        start = None
        schedule_horizon = default_tz.localize(datetime.utcnow() + timedelta(
            days=current_app.config.get("SCHEDULES_CREATED_DAYS_BEFORE_START"))
                                               )

        # This is a half year of schedules.
        # We discovered that during the apiv1 migration, some orgs only had a couple weeks
        # worth of schedules. When _get_schedule_range() ran, it would get the dates for the next
        # schedule. This requires a high ttl because it is making schedules in the past up to
        # the 100 days in the future that we expect.
        schedule_ttl = 27
        for role in roles_needing_schedules:

            start, stop = self._get_schedule_range(role)
            current_ttl = schedule_ttl
            while (start < schedule_horizon):

                current_ttl -= 1
                if current_ttl < 0:
                    raise Exception(
                        "Schedule creation process infinite looping - start %s role %s"
                        % (start, role))

                Schedule2.create(role.id, start, stop)
                schedules_created += 1

                start, stop = self._get_schedule_range(role)

        return schedules_created
    def post(self, org_id, location_id, role_id, user_id):
        """
        create a new time off request record
        """

        parser = reqparse.RequestParser()
        parser.add_argument("date", type=str, required=True)
        parser.add_argument("state", type=str)
        parser.add_argument("minutes_paid", type=int, default=0)

        parameters = parser.parse_args()

        # Filter out null values
        parameters = dict((k, v) for k, v in parameters.iteritems()
                          if v is not None)

        org = Organization.query.get(org_id)
        location = Location.query.get(location_id)
        rtu = RoleToUser.query.filter_by(
            role_id=role_id, user_id=user_id, archived=False).first_or_404()
        user = User.query.get(user_id)

        user_name = user.email if user.name is None else user.name

        admin_permissions = g.current_user.is_org_admin_or_location_manager(
            org_id, location_id)
        state = parameters.get("state")

        # verify a valid state
        if state not in [
                None, "approved_paid", "approved_unpaid", "sick", "denied"
        ]:
            return {"message": "Invalid time off request state"}, 400

        # non-admins cannot define a state
        if state is not None and not (admin_permissions or
                                      g.current_user.is_sudo()):
            return {
                "message":
                "Only admins can set a state of 'approved_paid', 'approved_unpaid', 'sick', or 'denied'."
            }, 400

        # extract start and stop dates
        default_tz = get_default_tz()
        local_tz = location.timezone_pytz

        try:
            start = iso8601.parse_date(parameters.get("date"))
        except iso8601.ParseError:
            return {"message": "date needs to be in ISO 8601 format"}, 400
        else:
            # apply any offset (there shouldn't be) and then treat as local time
            start = (start + start.utcoffset()).replace(tzinfo=None)

        start_local = local_tz.localize(start)
        start_utc = start_local.astimezone(default_tz)

        # we are using iso8601 to parse the date, but will be restricting it to only the date
        if not check_datetime_is_midnight(start_local):
            return {
                "message": "date must be at exactly midnight in local time"
            }, 400

        # calculate stop
        stop_local = normalize_to_midnight((start_utc + datetime.timedelta(
            days=1, hours=1)).astimezone(local_tz))
        stop_utc = stop_local.astimezone(default_tz)

        duration_seconds = int((stop_utc - start_utc).total_seconds())

        start_utc = start_utc.replace(tzinfo=None)
        stop_utc = stop_utc.replace(tzinfo=None)

        # to consider daylight savings time, check that the duration between start and stop
        # is between 23 and 25 hours in length
        if not (23 * SECONDS_PER_HOUR <= duration_seconds <= 25 *
                SECONDS_PER_HOUR):
            abort(500)

        # finally check on minutes paid
        # these rules also prevent a non-admin from setting minutes_paid
        minutes_paid = parameters.get("minutes_paid")
        if not (0 <= minutes_paid * 60 <= duration_seconds):
            return {
                "message":
                "Cannot set minutes_paid to be greater than the calendar day"
            }, 400

        if minutes_paid > 0 and state == "approved_unpaid":
            return {
                "message":
                "unpaid time off requests cannot have a minutes_paid greater than 0"
            }, 400

        if minutes_paid == 0 and state == "approved_paid":
            return {
                "message":
                "paid time off requests must have a postitive minutes_paid"
            }, 400

        if state is None and minutes_paid != 0:
            return {
                "message":
                "cannot have minutes_paid greater than 0 for time off requests with an undefined state"
            }, 400

        time_off_request = TimeOffRequest(
            role_to_user_id=rtu.id,
            start=start_utc,
            stop=stop_utc,
            state=state,
            minutes_paid=minutes_paid)

        # managers can create pre-approved time off requests
        if time_off_request.state is not None:
            if admin_permissions:
                time_off_request.approver_user_id = g.current_user.id

        # time off requests cannot overlap
        if time_off_request.has_overlaps():
            return {
                "message":
                "This time off request overlaps with another time off request"
            }, 400

        db.session.add(time_off_request)

        try:
            db.session.commit()
        except:
            abort(500)

        if time_off_request.state in [
                "sick", "approved_paid", "approved_unpaid"
        ]:
            time_off_request.unassign_overlapping_shifts()

        # send an email to managers if a worker is the one making the request
        if not (admin_permissions or g.current_user.is_sudo()):
            display_date = start_local.strftime("%A, %B %-d")

            # subject
            subject = "[Action Required] Time off request for %s on %s" % (
                user_name, display_date)

            # calculate start of current week
            week_start_date = org.get_week_start_from_datetime(
                start_local).strftime("%Y-%m-%d")

            # email body
            message = "%s has requested the day off on %s. Please log in to the Manager to approve or deny it:" % (
                user_name, display_date)

            # construct the url
            manager_url = "%s#locations/%s/scheduling/%s" % (url_for(
                'manager.manager_app', org_id=org_id,
                _external=True), location_id, week_start_date)

            # send it
            location.send_manager_email(subject, message, manager_url)

        g.current_user.track_event("created_time_off_request")
        return marshal(time_off_request, time_off_request_fields), 201
示例#9
0
    def get(self, org_id, location_id, role_id):
        # NOTE - we always include user's name with shifts. This helps the front-end.

        parser = reqparse.RequestParser()
        parser.add_argument("start", type=str, required=True)
        parser.add_argument("end", type=str, required=True)
        parser.add_argument("user_id", type=int)
        parser.add_argument("csv_export", type=inputs.boolean, default=False)
        parser.add_argument(
            "include_summary", type=inputs.boolean, default=False)
        parser.add_argument(
            "filter_by_published", type=inputs.boolean, default=False)
        parameters = parser.parse_args(
        )  # Strict breaks calls from parent methods? Sigh.

        # Filter out null values
        parameters = dict((k, v) for k, v in parameters.iteritems()
                          if v is not None)

        default_tz = get_default_tz()

        shifts = Shift2.query.filter_by(role_id=role_id)

        # start and end must be supplied - check if in ok format
        try:
            start = iso8601.parse_date(parameters.get("start"))
        except iso8601.ParseError:
            return {
                "message":
                "Start time parameter needs to be in ISO 8601 format"
            }, 400
        else:
            start = (start + start.utcoffset()).replace(tzinfo=default_tz)

        try:
            end = iso8601.parse_date(parameters.get("end"))
        except iso8601.ParseError:
            return {
                "message":
                "End time parameter time needs to be in ISO 8601 format"
            }, 400
        else:
            end = (end + end.utcoffset()).replace(tzinfo=default_tz)

        shifts = shifts \
            .filter(
                Shift2.start < end,
                Shift2.start >= start,
            )

        if "user_id" in parameters:
            user_id_value = parameters["user_id"]
            if user_id_value == 0:
                user_id_value = None

            shifts = shifts.filter_by(user_id=user_id_value)

        # filter by only published shifts
        if parameters.get("filter_by_published"):
            shifts = shifts.filter_by(published=True)

        # now execute the query
        shifts = shifts \
            .order_by(
                Shift2.start.asc(),
            ) \
            .all()

        # determine if csv export
        if parameters.get("csv_export"):

            csv_rows = [self.CSV_HEADER]

            role_name = Role.query.get_or_404(role_id).name
            download_name = "shifts-%s-%s-%s.csv" % (role_name, start, end)

            for shift in shifts:
                if shift.user_id is None:
                    user_name = "Unassigned Shift"
                    shift_status = "open"
                else:
                    user = User.query.get_or_404(shift.user_id)

                    user_name = user.name if user.name else user.email
                    shift_status = "closed"

                start_date = shift.start.strftime("%-m/%-d/%y")
                start_time = shift.start.strftime("%-I%p")
                stop_date = shift.stop.strftime("%-m/%-d/%y")
                stop_time = shift.stop.strftime("%-I%p")
                open_value = 1 if shift_status == "open" else ""

                csv_rows.append('"%s","%s","%s","%s","%s","%s","","%s","%s"' %
                                (user_name, role_name, start_date, stop_date,
                                 start_time, stop_time, shift_status,
                                 open_value))

            response = make_response("\n".join(csv_rows))
            response.headers[
                "Content-Disposition"] = "attachment; filename=%s" % download_name

            return response

        output = {
            API_ENVELOPE:
            map(lambda shift: marshal(shift, shift_fields), shifts)
        }

        if parameters.get("include_summary"):
            users_summary = {}

            for shift in shifts:
                user_id = shift.user_id if shift.user_id else 0

                if user_id in users_summary.keys():
                    users_summary[user_id]["shifts"] += 1
                    users_summary[user_id]["minutes"] += int(
                        (shift.stop - shift.start).total_seconds() / 60)
                else:
                    if user_id == 0:
                        name = "Unassigned shifts"
                    else:
                        user = User.query.get_or_404(shift.user_id)
                        name = user.name if user.name else user.email

                    users_summary[user_id] = {
                        "user_id":
                        user_id,
                        "user_name":
                        name,
                        "shifts":
                        1,
                        "minutes":
                        int((shift.stop - shift.start).total_seconds() / 60)
                    }

            output["summary"] = users_summary.values()

        return output
示例#10
0
    def post(self, org_id, location_id, role_id):
        """
        create a new shift
        """

        parser = reqparse.RequestParser()
        parser.add_argument("start", type=str, required=True)
        parser.add_argument("stop", type=str, required=True)
        parser.add_argument("user_id", type=int)
        parser.add_argument("published", type=inputs.boolean)
        parser.add_argument("description", type=str)
        parameters = parser.parse_args()

        # Filter out null values
        parameters = dict((k, v) for k, v in parameters.iteritems()
                          if v is not None)

        default_tz = get_default_tz()
        local_tz = Location.query.get(location_id).timezone_pytz

        # start time
        try:
            start = iso8601.parse_date(parameters.get("start"))
        except iso8601.ParseError:
            return {
                "message": "Start time needs to be in ISO 8601 format"
            }, 400
        else:
            start = (start + start.utcoffset()).replace(tzinfo=default_tz)

        # stop time
        try:
            stop = iso8601.parse_date(parameters.get("stop"))
        except iso8601.ParseError:
            return {"message": "Stop time needs to be in ISO 8601 format"}, 400
        else:
            stop = (stop + stop.utcoffset()).replace(tzinfo=default_tz)

        # stop can't be before start
        if start >= stop:
            return {"message": "Stop time must be after start time"}, 400

        # shifts are limited to 23 hours in length
        if int((stop - start).total_seconds()) > MAX_SHIFT_LENGTH:
            return {
                "message":
                "Shifts cannot be more than %s hours long" %
                (MAX_SHIFT_LENGTH / SECONDS_PER_HOUR)
            }, 400

        shift = Shift2(
            role_id=role_id,
            start=start,
            stop=stop,
            published=parameters.get("published", False))

        if "description" in parameters:
            description = parameters.get("description")

            if len(description) > Shift2.MAX_DESCRIPTION_LENGTH:
                return {
                    "message":
                    "Description cannot me more than %s characters" %
                    Shift2.MAX_DESCRIPTION_LENGTH
                }, 400

            shift.description = description

        user_id = parameters.get("user_id")

        # if user_id defined, and if not for unassigned shift, check if user is in role
        # and make sure it won't overlap with existing shifts
        if user_id is not None:
            if user_id > 0:
                role_to_user = RoleToUser.query.filter_by(
                    user_id=user_id, role_id=role_id, archived=False).first()

                if role_to_user is None:
                    return {
                        "message":
                        "User does not exist or is not apart of role"
                    }, 400

                # check if this shift can be assigned to the user
                shift.user_id = user_id

                if shift.has_overlaps():
                    return {
                        "message": "This shift overlaps with an existing shift"
                    }, 400

        db.session.add(shift)
        try:
            db.session.commit()
        except:
            abort(500)

        g.current_user.track_event("created_shift")

        # check if a schedule exists during this time - if so, bust the cache
        schedule = Schedule2.query \
            .filter(
                Schedule2.role_id == role_id,
                Schedule2.start <= shift.start,
                Schedule2.stop > shift.start,
            ).first()

        if schedule is not None:
            Shifts2Cache.delete(schedule.id)

        # timezone stuff
        local_datetime = default_tz.localize(shift.start).astimezone(local_tz)

        # only send emails if future and published
        if not shift.is_in_past and shift.published:

            # if shift is unassigned - alert people that it's available
            if shift.user_id is None:

                # get all users who are eligible for the shift
                eligible_users, _ = shift.eligible_users()

                alert_available_shifts(org_id, location_id, role_id,
                                       local_datetime, eligible_users)

            # Otherwise send an alert_changed_shift notification
            # (function has logic for whether to send)
            elif (g.current_user.id != shift.user_id):
                alert_changed_shift(org_id, location_id, role_id,
                                    local_datetime, shift.user_id)

        return marshal(shift, shift_fields), 201
示例#11
0
def alert_timeclock_change(timeclock, org_id, location_id, role_id,
                           original_start, original_stop, worker, manager):
    """
    - sends an email to manager and the worker from the timeclock
    - if timeclock is None, then it was deleted
    - original_start and timeclock.start must be a datetime
    - timeclock.stop or original_stop can be defined or None
        - their state will determine which email is sent
    """

    org = Organization.query.get(org_id)
    location = Location.query.get(location_id)

    default_tz = get_default_tz()
    local_tz = location.timezone_pytz

    original_start_local = default_tz.localize(original_start).astimezone(
        local_tz)

    original_start_date = original_start_local.strftime("%-m/%-d")

    if manager.is_sudo():
        manager_name = SUDO_EXTERNAL_NAME
        manager_email = SUDO_EXTERNAL_EMAIL
    else:
        manager_name = manager.name or manager.email
        manager_email = manager.email

    worker_name = worker.name or worker.email
    tc_email_format = "%-I:%M:%S %p %-m/%-d/%Y"

    week_start_date = org.get_week_start_from_datetime(
        original_start_local).strftime("%Y-%m-%d")

    manager_url = "%s#locations/%s/attendance/%s" % (url_for(
        'manager.manager_app', org_id=org_id, _external=True), location_id,
                                                     week_start_date)

    worker_url = "%s#week/%s" % (url_for(
        'myschedules.myschedules_app',
        org_id=org_id,
        location_id=location_id,
        role_id=role_id,
        user_id=worker.id,
        _external=True), week_start_date)

    manager_subject = "Confirmation of timeclock change for %s on %s" % (
        worker_name, original_start_date)

    # the timeclock was deleted
    if timeclock is None:

        # if timeclock was open while deleted, log it as if it were just closed
        if original_stop is None:
            original_stop_local = default_tz.localize(
                original_stop).astimezone(local_tz)
        else:
            original_stop_local = default_tz.localize(
                datetime.datetime.utcnow()).astimezone(local_tz)

        manager_message = render_template(
            "email/timeclock/manager/deleted.html",
            user=manager,
            worker_name=worker_name,
            original_start_date=original_start_date,
            before_start=original_start_local.strftime(tc_email_format),
            before_stop=original_stop_local.strftime(tc_email_format),
            url=manager_url)

        worker_subject = "[Alert] Your %s manager deleted a timeclock on %s" % (
            org.name, original_start_date)
        worker_message = render_template(
            "email/timeclock/worker/deleted.html",
            user=worker,
            org_name=org.name,
            manager_name=manager_name,
            manager_email=manager_email,
            original_start_date=original_start_date,
            before_start=original_start_local.strftime(tc_email_format),
            before_stop=original_stop_local.strftime(tc_email_format),
            url=worker_url)

    # timeclock was modified
    else:
        new_start_local = default_tz.localize(
            timeclock.start).astimezone(local_tz)

        if original_stop is None:

            # start time was adjusted
            if timeclock.stop is None:
                manager_message = render_template(
                    "email/timeclock/manager/adjust_start.html",
                    user=manager,
                    worker_name=worker_name,
                    before_start=original_start_local.strftime(
                        tc_email_format),
                    now_start=new_start_local.strftime(tc_email_format),
                    url=manager_url)

                worker_subject = "[Alert] Your %s manager adjusted the start of your timeclock on %s" % (
                    org.name, original_start_date)
                worker_message = render_template(
                    "email/timeclock/worker/adjust_start.html",
                    user=worker,
                    org_name=org.name,
                    manager_name=manager_name,
                    manager_email=manager_email,
                    original_start_date=original_start_date,
                    before_start=original_start_local.strftime(
                        tc_email_format),
                    now_start=new_start_local.strftime(tc_email_format),
                    url=worker_url)

            # manager clocked the worker out
            else:
                stop_local = default_tz.localize(
                    timeclock.stop).astimezone(local_tz)

                manager_message = render_template(
                    "email/timeclock/manager/clocked_out.html",
                    user=manager,
                    worker_name=worker_name,
                    before_start=original_start_local.strftime(
                        tc_email_format),
                    now_start=new_start_local.strftime(tc_email_format),
                    now_stop=stop_local.strftime(tc_email_format),
                    url=manager_url)

                worker_subject = "[Alert] Your %s manager has clocked you out" % org.name
                worker_message = render_template(
                    "email/timeclock/worker/clocked_out.html",
                    user=worker,
                    org_name=org.name,
                    manager_name=manager_name,
                    manager_email=manager_email,
                    before_start=original_start_local.strftime(
                        tc_email_format),
                    now_start=new_start_local.strftime(tc_email_format),
                    now_stop=stop_local.strftime(tc_email_format),
                    url=worker_url)

                if worker.phone_number:
                    worker.send_sms("Your manager has clocked you out.")

        # completed timeclock was modified
        else:
            original_stop_local = default_tz.localize(
                original_stop).astimezone(local_tz)
            new_stop_local = default_tz.localize(
                timeclock.stop).astimezone(local_tz)

            manager_message = render_template(
                "email/timeclock/manager/adjusted.html",
                user=manager,
                worker_name=worker_name,
                original_start_date=original_start_date,
                before_start=original_start_local.strftime(tc_email_format),
                before_stop=original_stop_local.strftime(tc_email_format),
                now_start=new_start_local.strftime(tc_email_format),
                now_stop=new_stop_local.strftime(tc_email_format),
                url=manager_url)

            worker_subject = "[Alert] Your %s manager adjusted your timeclock on %s" % (
                org.name, original_start_date)
            worker_message = render_template(
                "email/timeclock/worker/adjusted.html",
                user=worker,
                org_name=org.name,
                manager_name=manager_name,
                manager_email=manager_email,
                original_start_date=original_start_date,
                before_start=original_start_local.strftime(tc_email_format),
                before_stop=original_stop_local.strftime(tc_email_format),
                now_start=new_start_local.strftime(tc_email_format),
                now_stop=new_stop_local.strftime(tc_email_format),
                url=worker_url)

    worker.send_email(worker_subject, worker_message, force_send=True)
    manager.send_email(manager_subject, manager_message, force_send=True)
    def get(self, org_id, location_id):
        """
        returns nested timeclock and shift data for the dates occurring
        between a specified start and end day
        """

        parser = reqparse.RequestParser()
        parser.add_argument("startDate", type=str, required=True)
        parser.add_argument("endDate", type=str, required=True)
        parser.add_argument("csv_export", type=inputs.boolean, default=False)
        parameters = parser.parse_args()

        data = {}
        summary = {}
        iso_date = "%Y-%m-%d"
        default_tz = get_default_tz()

        organization = Organization.query.get(org_id)
        location = Location.query.get(location_id)
        roles = Role.query.filter_by(location_id=location_id).all()

        # get start and end values for the query + ensure good iso formatting
        try:
            start_local = iso8601.parse_date(parameters.get("startDate"))
        except iso8601.ParseError:
            return {
                "message":
                "Start time parameter needs to be in ISO 8601 format"
            }, 400

        try:
            end_local = iso8601.parse_date(parameters.get("endDate"))
        except iso8601.ParseError:
            return {
                "message":
                "End time parameter time needs to be in ISO 8601 format"
            }, 400

        # pytz can't can't have iso8601 utc timezone object (needs naive)
        start_local = normalize_to_midnight(start_local).replace(tzinfo=None)
        end_local = normalize_to_midnight(end_local).replace(tzinfo=None)

        # adjust naive/local times for a utc query
        location_timezone = location.timezone_pytz
        start_utc = location_timezone.localize(start_local).astimezone(
            default_tz)

        # NOTE - the query needs to include the whole last day,
        # so add 1 day ahead and make it a <
        end_utc = normalize_to_midnight(
            (location_timezone.localize(end_local).astimezone(default_tz) +
             datetime.timedelta(days=1, hours=1)
             ).astimezone(location_timezone)).astimezone(default_tz)

        shift_query = Shift2.query \
            .filter(
                Shift2.start >= start_utc,
                Shift2.start < end_utc,
            )

        timeclock_query = Timeclock.query\
            .filter(
                Timeclock.start >= start_utc,
                Timeclock.start < end_utc,
                Timeclock.stop != None
            )

        time_off_request_query = TimeOffRequest.query\
            .filter(
                TimeOffRequest.start >= start_utc,
                TimeOffRequest.start < end_utc,
                TimeOffRequest.state.in_(["approved_paid", "approved_unpaid", "sick"]),
            )

        # determine if csv export
        if parameters.get("csv_export"):

            current_app.logger.info(
                "Generating a timeclock csv export for organization %s location %s"
                % (organization.id, location.id))
            g.current_user.track_event("timeclock_csv_export")

            csv_rows = [CSV_HEADER % (location.timezone, location.timezone)]
            download_name = "attendance-%s-%s-%s.csv" % (
                location.name, parameters.get("startDate"),
                parameters.get("endDate"))

            combined_list = []

            for role in roles:
                for role_to_user in role.members:
                    user_timeclocks = timeclock_query.filter_by(
                        role_id=role.id).filter_by(
                            user_id=role_to_user.user_id).order_by(
                                Timeclock.start.asc()).all()

                    user_time_off_requests = time_off_request_query.filter_by(
                        role_to_user_id=role_to_user.id).order_by(
                            TimeOffRequest.start.asc()).all()

                    combined_list += user_timeclocks + user_time_off_requests

            combined_list.sort(key=lambda x: x.start)

            for record in combined_list:
                start_utc = record.start.isoformat()
                start_local = default_tz.localize(
                    record.start).astimezone(location_timezone).isoformat()
                stop_utc = record.stop.isoformat()
                stop_local = default_tz.localize(
                    record.stop).astimezone(location_timezone).isoformat()

                # record will be either a time off request or a timeclock
                if isinstance(record, Timeclock):
                    rtu = RoleToUser.query.filter_by(
                        role_id=record.role_id,
                        user_id=record.user_id).first()
                    user = User.query.get(record.user_id)
                    role = Role.query.get(record.role_id)
                    minutes = int(
                        (record.stop - record.start).total_seconds() / 60)
                    record_type = "Recorded Time"
                    record_state = ""

                # time off requests
                else:
                    rtu = RoleToUser.query.get(record.role_to_user_id)
                    user = rtu.user
                    role = rtu.role
                    minutes = record.minutes_paid
                    record_type = "Time Off"
                    record_state = record.state.replace("_", " ").title()

                csv_rows.append(
                    ",".join(['"%s"'] * len(CSV_HEADER.split(","))) %
                    (user.name if user.name is not None else "",
                     rtu.internal_id
                     or "", user.email, organization.name, location.name,
                     role.name, start_utc, stop_utc, start_local, stop_local,
                     record_type, record_state, minutes))

            response = make_response("\n".join(csv_rows))
            response.headers[
                "Content-Disposition"] = "attachment; filename=%s" % download_name

            return response

        # create a dict with keys for each day of the week needed
        delta = end_local - start_local
        for x in xrange(delta.days + 1):
            data[(start_local +
                  datetime.timedelta(days=x)).strftime(iso_date)] = {}

        # all data is nested underneath each role
        for role in roles:

            # Timeclocks and Time Off Requests
            # timeclock and time off request data is nested underneath
            # each user
            for user in role.members:

                role_user_index = str(role.id) + "-" + str(user.user_id)

                # Timeclocks
                user_timeclocks = timeclock_query.filter_by(
                    role_id=role.id).filter_by(user_id=user.user_id).all()

                # sort each timeclock into correct day bucket
                for timeclock in user_timeclocks:

                    # get localized time for placing in the proper bucket
                    localized_dt = default_tz.localize(
                        timeclock.start).astimezone(location_timezone)
                    local_date = localized_dt.strftime(iso_date)
                    elapsed_time = int(
                        (timeclock.stop - timeclock.start).total_seconds())

                    # add timeclock to user object for the right day
                    if role_user_index in data[local_date]:
                        data[local_date][role_user_index]["timeclocks"].append(
                            marshal(timeclock, timeclock_fields))
                        data[local_date][role_user_index][
                            "logged_time"] += elapsed_time

                    # if user has no records on day, create one
                    else:
                        data[local_date][role_user_index] = {
                            "user_id": user.user_id,
                            "role_id": role.id,
                            "timeclocks":
                            [marshal(timeclock, timeclock_fields)],
                            "time_off_requests": None,
                            "shifts": [],
                            "logged_time": elapsed_time,
                        }

                    if role_user_index in summary:
                        summary[role_user_index]["logged_time"] += elapsed_time
                        summary[role_user_index]["timeclock_count"] += 1
                    else:
                        summary[role_user_index] = {
                            "user_id": user.user_id,
                            "role_id": role.id,
                            "logged_time": elapsed_time,
                            "scheduled_time": 0,
                            "shift_count": 0,
                            "timeclock_count": 1,
                            "time_off_request_count": 0,
                        }

                # Time Off Requests
                # user.id is the role_to_user id
                user_time_off_requests = time_off_request_query.filter_by(
                    role_to_user_id=user.id).all()

                for time_off_request in user_time_off_requests:

                    # get localized time for placing in the proper bucket
                    localized_dt = default_tz.localize(
                        time_off_request.start).astimezone(location_timezone)
                    local_date = localized_dt.strftime(iso_date)

                    # convert minutes_paid to seconds
                    recorded_time = time_off_request.minutes_paid * 60

                    # add time_off_request to user object for the right day
                    if role_user_index in data[local_date]:
                        data[local_date][role_user_index][
                            "time_off_requests"] = marshal(
                                time_off_request, time_off_request_fields)
                        data[local_date][role_user_index][
                            "logged_time"] += recorded_time

                    # if user has no records on day, create one
                    else:
                        data[local_date][role_user_index] = {
                            "user_id":
                            user.user_id,
                            "role_id":
                            role.id,
                            "shifts": [],
                            "timeclocks": [],
                            "time_off_requests":
                            marshal(time_off_request, time_off_request_fields),
                            "logged_time":
                            recorded_time,
                        }

                    if role_user_index in summary:
                        summary[role_user_index][
                            "logged_time"] += recorded_time
                        summary[role_user_index]["time_off_request_count"] += 1
                    else:
                        summary[role_user_index] = {
                            "user_id": user.user_id,
                            "role_id": role.id,
                            "logged_time": recorded_time,
                            "scheduled_time": 0,
                            "shift_count": 0,
                            "timeclock_count": 0,
                            "time_off_request_count": 1,
                        }

            # shifts
            shifts = shift_query \
                .filter(
                    Shift2.role_id == role.id,
                    Shift2.user_id > 0,
                ).all()

            # segment out each shift
            for shift in shifts:
                role_user_index = str(shift.role_id) + "-" + str(shift.user_id)

                # get localized time for placing in the proper bucket
                localized_dt = default_tz.localize(
                    shift.start).astimezone(location_timezone)
                local_date = localized_dt.strftime(iso_date)

                # add shift to user object for the right day
                if role_user_index in data[local_date]:
                    data[local_date][role_user_index]["shifts"].append(
                        marshal(shift, shift_fields))

                # if user has no records on day, create one
                else:
                    data[local_date][role_user_index] = {
                        "user_id": shift.user_id,
                        "role_id": role.id,
                        "timeclocks": [],
                        "time_off_requests": None,
                        "shifts": [marshal(shift, shift_fields)],
                    }

                if role_user_index in summary:
                    summary[role_user_index]["shift_count"] += 1
                    summary[role_user_index]["scheduled_time"] += int(
                        (shift.stop - shift.start).total_seconds())
                else:
                    summary[role_user_index] = {
                        "user_id":
                        shift.user_id,
                        "role_id":
                        role.id,
                        "logged_time":
                        0,
                        "scheduled_time":
                        int((shift.stop - shift.start).total_seconds()),
                        "shift_count":
                        1,
                        "timeclock_count":
                        0,
                        "time_off_request_count":
                        0,
                    }

        # remove user index to flatten
        for key in data:
            data[key] = data[key].values()

        return {API_ENVELOPE: data, "summary": summary.values()}
示例#13
0
    def patch(self, org_id, location_id, role_id, user_id,
              time_off_request_id):
        """
        modifies an existing time_off_request record
        NOTE that start and stop cannot be modified
        """

        parser = reqparse.RequestParser()
        parser.add_argument("state", type=str)
        parser.add_argument("minutes_paid", type=int)

        parameters = parser.parse_args()

        # Filter out null values
        parameters = dict(
            (k, v) for k, v in parameters.iteritems() if v is not None)
        changes = {}

        time_off_request = TimeOffRequest.query.get_or_404(time_off_request_id)
        role_to_user = RoleToUser.query.get(time_off_request.role_to_user_id)
        user = User.query.get(user_id)
        location = Location.query.get(location_id)
        org = Organization.query.get(org_id)

        # verify for state
        state = parameters.get("state", time_off_request.state)

        if state not in [
                None, "", "approved_paid", "approved_unpaid", "sick", "denied"
        ]:
            return {"message": "Invalid time off request state"}, 400

        if "state" in parameters:
            # state can be set to None - which get parsed through as an empty string
            if not parameters["state"]:
                changes["state"] = None
                changes["approver_user_id"] = None
            else:
                changes["state"] = parameters["state"]

                # log the approver if its an administrator
                if g.current_user.is_org_admin_or_location_manager(
                        org_id, location_id):
                    changes["approver_user_id"] = g.current_user.id
                else:
                    changes["approver_user_id"] = None

        # verification for minutes_paid
        minutes_paid = parameters.get("minutes_paid",
                                      time_off_request.minutes_paid)
        duration = int(
            (time_off_request.stop - time_off_request.start).total_seconds())

        if not (0 <= minutes_paid * 60 <= duration):
            return {
                "message":
                "minutes_paid must be within the duration of the day"
            }, 400

        if minutes_paid > 0 and state == "approved_unpaid":
            return {
                "message":
                "Unpaid time off requests cannot have a minutes_paid greater than 0"
            }, 400

        if minutes_paid == 0 and state == "approved_paid":
            return {
                "message":
                "Paid time off requests must have a postitive minutes_paid"
            }, 400

        if state is None and minutes_paid != 0:
            return {
                "message":
                "Cannot have minutes_paid greater than 0 for time off requests with an undefined state"
            }, 400

        if "minutes_paid" in parameters:
            changes["minutes_paid"] = parameters["minutes_paid"]

        for change, value in changes.iteritems():
            try:
                setattr(time_off_request, change, value)
                db.session.commit()
            except Exception as exception:
                db.session.rollback()
                current_app.logger.exception(str(exception))
                abort(400)

        g.current_user.track_event("time_off_request_modified")

        # unassign shifts that overlap
        if changes.get("state") is not None and time_off_request.state in [
                "sick", "approved_paid", "approved_unpaid"
        ]:
            time_off_request.unassign_overlapping_shifts()

        # send an email to the user whenever the approved state changes
        # only send an email for unarchived workers and
        # the time off request is in the future
        if changes.get("state") is not None \
            and not role_to_user.archived \
            and time_off_request.start > datetime.datetime.utcnow():

            default_tz = get_default_tz()
            local_tz = location.timezone_pytz

            start_local = default_tz.localize(
                time_off_request.start).astimezone(local_tz)
            display_date = start_local.strftime("%A, %B %-d")

            # calculate myschedules url
            week_start_date = org.get_week_start_from_datetime(
                start_local).strftime("%Y-%m-%d")

            myschedules_url = "%s#week/%s" % (url_for(
                'myschedules.myschedules_app',
                org_id=org_id,
                location_id=location_id,
                role_id=role_id,
                user_id=user_id,
                _external=True), week_start_date)

            # prepare subject and starting body - each state can be fine tuned
            if time_off_request.state == "denied":
                subject = "Your time off request on %s has been denied" % display_date
                body = "Your time off request on %s has been denied" % display_date

            elif time_off_request.state == "approved_paid":
                subject = "Your time off request on %s has been approved" % display_date
                body = "You have been approved for paid time off on %s" % display_date

            elif time_off_request.state == "approved_unpaid":
                subject = "Your time off request on %s has been approved" % display_date
                body = "You have been approved for unpaid time off on %s" % display_date

            elif time_off_request.state == "sick":
                subject = "Your time off request on %s has been approved" % display_date
                body = "You have been approved to take a sick day on %s" % display_date

            # add in approval info if it is avilable
            if time_off_request.approver_user_id:
                approval_user = User.query.get(
                    time_off_request.approver_user_id)
                approval_name = approval_user.email if approval_user.name is None else approval_user.email
                body += " by %s." % approval_name
            else:
                body += "."

            body += " Visit My Schedules to learn more:<br><a href=\"%s\">%s</a>" % (
                myschedules_url, myschedules_url)

            alert_email(user, subject, body)

        return changes
示例#14
0
    def patch(self, org_id):
        parser = reqparse.RequestParser()
        parser.add_argument("name", type=str)
        parser.add_argument("active", type=inputs.boolean)
        parser.add_argument("shifts_assigned_days_before_start", type=int)
        parser.add_argument("enable_shiftplanning_export", type=inputs.boolean)
        parser.add_argument("enable_timeclock_default", type=inputs.boolean)
        parser.add_argument("enable_time_off_requests_default",
                            type=inputs.boolean)
        parser.add_argument("enterprise_access", type=inputs.boolean)
        parser.add_argument("workers_can_claim_shifts_in_excess_of_max",
                            type=inputs.boolean)
        parser.add_argument("early_access", type=inputs.boolean)
        parser.add_argument("trial_days", type=int)
        parser.add_argument("paid_until", type=str)

        changes = parser.parse_args(strict=True)
        # Filter out null values
        changes = dict((k, v) for k, v in changes.iteritems() if v is not None)

        # Throw 403 if non-sudo tries to update one of these.
        # It's a patch, not update, so non-sudo should not attempt this.
        sudo_only = [
            "enable_shiftplanning_export",
            "early_access",
            "enterprise_access",
            "trial_days",
            "paid_until",
        ]

        for key in sudo_only:
            if (not g.current_user.is_sudo()) and (key in changes):
                abort(403)

        org = Organization.query.get_or_404(org_id)
        default_tz = get_default_tz()

        #
        # Some verifications
        #

        # timing
        shifts_assigned_days_before_start = changes.get(
            "shifts_assigned_days_before_start",
            org.shifts_assigned_days_before_start)

        if shifts_assigned_days_before_start < 1:
            return {
                "message":
                "shifts_assigned_days_before_start must be greater than 0"
            }, 400
        if shifts_assigned_days_before_start > 100:
            return {
                "message":
                "shifts_assigned_days_before_start must be less than 100"
            }, 400

        trial_days = changes.get("trial_days", org.trial_days)
        if trial_days < 0:
            return {"messages": "trial_days cannot be less than 0"}

        if "paid_until" in changes:
            try:
                paid_until = iso8601.parse_date(changes.get("paid_until"))
            except iso8601.ParseError:
                return {
                    "message": "Paid until time needs to be in ISO 8601 format"
                }, 400
            else:
                paid_until = (paid_until + paid_until.utcoffset()).replace(
                    tzinfo=default_tz)

            changes["paid_until"] = paid_until.isoformat()

        for change, value in changes.iteritems():
            if value is not None:
                try:
                    setattr(org, change, value)
                    db.session.commit()
                except Exception as exception:
                    db.session.rollback()
                    current_app.logger.exception(str(exception))
                    abort(400)
        return changes
示例#15
0
    def patch(self, org_id, location_id, role_id, shift_id):

        parser = reqparse.RequestParser()
        parser.add_argument("start", type=str)
        parser.add_argument("stop", type=str)
        parser.add_argument("user_id", type=int)
        parser.add_argument("published", type=inputs.boolean)
        parser.add_argument("description", type=str)
        changes = parser.parse_args(strict=True)

        # Filter out null values
        changes = dict((k, v) for k, v in changes.iteritems() if v is not None)

        shift = Shift2.query.get(shift_id)
        shift_copy = deepcopy(shift)
        org = Organization.query.get(org_id)
        location = Location.query.get(location_id)
        role_to_user = None
        default_tz = get_default_tz()
        local_tz = location.timezone_pytz

        user_id = changes.get("user_id", shift.user_id)
        if user_id != shift.user_id:
            # Need this for later
            old_user_id = shift.user_id
        else:
            old_user_id = None

        # Check if user is in that role
        if user_id is not None and user_id != 0:
            role_to_user = RoleToUser.query.filter_by(
                user_id=user_id,
                role_id=role_id, ).first_or_404()

        # People that are not Sudo or Org Admins cannot do anything
        # except claim a shift.
        # (But a worker that's also, say, sudo can do so!)
        if not (g.current_user.is_sudo() or
                g.current_user.is_org_admin_or_location_manager(org_id,
                                                                location_id)):
            # User claiming a shift!

            # Check that it's the only change being made
            if set(("user_id", )) != set(changes):
                return {
                    "message":
                    "You are only allowed to claim unassigned shifts"
                }, 400

            # this user must be active to claim
            if role_to_user:
                if role_to_user.archived:
                    abort(404)

            # This user can only claim shifts for themself
            if user_id != g.current_user.id:
                return {
                    "message":
                    "You are not permitted to assign a shift to somebody else"
                }, 400

            # And the shift must be currently unclaimed
            if shift.user_id != 0 and shift.user_id is not None:
                return {"message": "Shift already claimed"}, 400

            # the shift cannot be in the past
            if shift.is_in_past:
                return {"message": "Shift is in the past"}, 400

            # And the user cannot claim the shift if it overlaps
            shift_copy.user_id = user_id
            if shift_copy.has_overlaps():
                return {
                    "message": "Shift overlaps with an existing shift"
                }, 400

            # Users on boss cannot claim if it violates caps and org doesn't allow exceeding
            if (org.is_plan_boss() and
                    not org.workers_can_claim_shifts_in_excess_of_max and
                    not shift_copy.is_within_caps(user_id)):
                return {"message": "This shift breaks existing limits"}, 400

            current_app.logger.info("User %s is claiming shift %s" %
                                    (user_id, shift.id))

        # admin or sudo only

        # get start and stop values
        if "start" in changes:
            try:
                start = iso8601.parse_date(changes.get("start"))
            except iso8601.ParseError:
                return {
                    "message": "Start time needs to be in ISO 8601 format"
                }, 400
            else:
                start = (start + start.utcoffset()).replace(tzinfo=default_tz)
        else:
            start = shift.start.replace(tzinfo=default_tz)

        # get new or current stop value
        if "stop" in changes:
            try:
                stop = iso8601.parse_date(changes.get("stop"))
            except iso8601.ParseError:
                return {
                    "message": "Stop time needs to be in ISO 8601 format"
                }, 400
            else:
                stop = (stop + stop.utcoffset()).replace(tzinfo=default_tz)
        else:
            stop = shift.stop.replace(tzinfo=default_tz)

        # stop can't be before start
        if start >= stop:
            return {"message": "Stop time must be after start time"}, 400

        # shifts are limited to 23 hours in length
        if int((stop - start).total_seconds()) > constants.MAX_SHIFT_LENGTH:
            return {
                "message":
                "Shifts cannot be more than %s hours long" %
                (constants.MAX_SHIFT_LENGTH / constants.SECONDS_PER_HOUR)
            }, 400

        # Unassigned shifts need to be converted to None in db
        if user_id == 0:
            user_id = None
            changes["user_id"] = None

        # assume always checking for overlap except for 3 cases
        # 1) shift was and still will be unassigned
        # 2) shift is becoming unassigned
        # 3) only published state is being changed
        overlap_check = True

        # shift was, and still is unassigned
        if shift.user_id is None and "user_id" not in changes:
            overlap_check = False

        # shift is becoming unassigned, don't need to check
        if "user_id" in changes and (user_id is None or user_id == 0):
            overlap_check = False

        # only published being modified, don't care
        if set(("published", )) == set(changes):
            overlap_check = False

        # a person cannot have overlapping shifts
        if overlap_check:

            shift_copy.start = start.replace(tzinfo=None)
            shift_copy.stop = stop.replace(tzinfo=None)
            shift_copy.user_id = user_id

            # check for overlap - don't need to check for in past here
            if shift_copy.has_overlaps():
                return {
                    "message": "Shift overlaps with an existing shift"
                }, 400

        # start/stop need to be in isoformat for committing changes
        if "start" in changes:
            changes["start"] = start.isoformat()

        if "stop" in changes:
            changes["stop"] = stop.isoformat()

        if "description" in changes:
            if len(changes["description"]) > Shift2.MAX_DESCRIPTION_LENGTH:
                return {
                    "message":
                    "Description cannot me more than %s characters" %
                    Shift2.MAX_DESCRIPTION_LENGTH
                }, 400

        for change, value in changes.iteritems():
            try:
                setattr(shift, change, value)
                db.session.commit()
            except Exception as exception:
                db.session.rollback()
                current_app.logger.exception(str(exception))
                abort(400)

        g.current_user.track_event("modified_shift")

        # check if a schedule exists during this time - if so, bust the cache
        schedule = Schedule2.query \
            .filter(
                Schedule2.role_id == role_id,
                Schedule2.start <= shift.start,
                Schedule2.stop > shift.start,
            ).first()

        if schedule is not None:
            Shifts2Cache.delete(schedule.id)

        if shift.published and not shift.is_in_past:
            local_datetime = default_tz.localize(
                shift.start).astimezone(local_tz)

            # if shift became unassigned, send an email to notify workers
            if shift.user_id is None:

                # get all users who are eligible for the shift
                eligible_users = shift.get_users_within_caps()

                alert_available_shifts(
                    org_id,
                    location_id,
                    role_id,
                    local_datetime,
                    eligible_users,
                    exclude_id=old_user_id)

            if old_user_id != shift.user_id:

                # old worker
                if g.current_user.id != old_user_id:
                    alert_changed_shift(org_id, location_id, role_id,
                                        local_datetime, old_user_id)

                # new worker
                if g.current_user.id != shift.user_id:
                    alert_changed_shift(
                        org_id,
                        location_id,
                        role_id,
                        local_datetime,
                        shift.user_id, )

        return changes
    def create_shift2_for_schedule2(self, schedule_id):
        """
        creates a shift2 for the week according to the recurring shift
        """

        # get org, location, and schedule models
        org = organization_model.Organization.query \
            .join(Location) \
            .join(Role) \
            .filter(
                Role.id == self.role_id,
                Location.id == Role.location_id,
                organization_model.Organization.id == Location.organization_id
            ) \
            .first()

        # get location for the timezone data
        location = Location.query \
            .join(Role) \
            .filter(
                Role.id == self.role_id,
                Location.id == Role.location_id
            ) \
            .first()

        schedule = schedule2_model.Schedule2.query.get(schedule_id)

        local_tz = location.timezone_pytz
        default_tz = get_default_tz()

        # get start and stop time for the shift
        start_local = default_tz.localize(schedule.start).astimezone(local_tz)

        # adjust start to fall on the correct day of the week
        ordered_week = org.get_ordered_week()
        adjust_days = ordered_week.index(self.start_day)

        start_local = start_local + timedelta(days=adjust_days)

        try:
            start_local = start_local.replace(hour=self.start_hour,
                                              minute=self.start_minute)
        except pytz.AmbiguousTimeError:
            start_local = start_local.replace(hour=self.start_hour,
                                              minute=self.start_minute,
                                              is_dst=False)

        stop_local = start_local + timedelta(minutes=self.duration_minutes)

        # convert start and end back to utc time
        start_utc = start_local.astimezone(default_tz).replace(tzinfo=None)
        stop_utc = stop_local.astimezone(default_tz).replace(tzinfo=None)

        published = (schedule.state == "published")

        for _ in range(self.quantity):

            new_shift = shift2_model.Shift2(start=start_utc,
                                            stop=stop_utc,
                                            role_id=self.role_id,
                                            published=published,
                                            user_id=self.user_id)

            # check if shift overlaps - make it unassigned if an overlap
            if self.user_id is not None:
                if new_shift.has_overlaps():
                    new_shift.user_id = None

            db.session.add(new_shift)

        db.session.commit()

        # flush the shift cache
        Shifts2Cache.delete(schedule_id)
        current_app.logger.info(
            "Created shift for recurring shift %s during schedule %s" %
            (self.id, schedule_id))
示例#17
0
    def get(self, org_id, location_id, role_id):

        parser = reqparse.RequestParser()
        parser.add_argument("start", type=str)
        parser.add_argument("end", type=str)
        parameters = parser.parse_args(strict=True)

        # Filter out null values
        parameters = dict(
            (k, v) for k, v in parameters.iteritems() if v is not None)

        response = {
            API_ENVELOPE: [],
        }

        schedules = Schedules2Cache.get(role_id)

        if schedules is None:
            schedules = Schedule2.query \
                .filter_by(role_id=role_id) \
                .order_by(
                    Schedule2.start.asc(),
                ) \
                .all()

            schedules = map(
                lambda schedule: marshal(schedule, schedule_fields), schedules)
            Schedules2Cache.set(role_id, schedules)

        default_tz = get_default_tz()

        if "start" in parameters:
            try:
                start = iso8601.parse_date(parameters.get("start"))
            except iso8601.ParseError:
                return {
                    "message":
                    "Start time parameter needs to be in ISO 8601 format"
                }, 400
            else:
                start = (start + start.utcoffset()).replace(tzinfo=default_tz)

            # run a filter to only keep schedules that occur after start
            schedules = filter(
                lambda x: \
                    iso8601.parse_date(x.get("start")).replace(tzinfo=default_tz) >= start,
                    schedules
                )

        if "end" in parameters:
            try:
                end = iso8601.parse_date(parameters.get("end"))
            except iso8601.ParseError:
                return {
                    "message":
                    "End time parameter time needs to be in ISO 8601 format"
                }, 400
            else:
                end = (end + end.utcoffset()).replace(tzinfo=default_tz)

            schedules = filter(
                lambda x: \
                    iso8601.parse_date(x.get("start")).replace(tzinfo=default_tz) < end,
                    schedules
                )

        response["data"] = schedules
        return response