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
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
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 :-)
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()) }
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()) }
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()) }
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
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
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
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()}
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
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
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))
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