def setUp(self): # (if you are copying and pasting, update class title below) super(BaseShift, self).setUp() # create a span of time and only 2 shifts will exist within it today = normalize_to_midnight(datetime.utcnow()) self.range_start = (today - timedelta(days=4)).isoformat() self.range_stop = (today + timedelta(days=4)).isoformat() # one unassigned unassigned_start = (today + timedelta(days=1, hours=8)).isoformat() unassigned_stop = (today + timedelta(days=1, hours=16)).isoformat() self.unassigned_shift = self.role.create_shift(start=unassigned_start, stop=unassigned_stop) # the other assigned self.coworker = self.role.create_worker( email="*****@*****.**", min_hours_per_workweek=20, max_hours_per_workweek=40) assigned_start = (today - timedelta(days=2, hours=15)).isoformat() assigned_stop = (today - timedelta(days=2, hours=7)).isoformat() self.assigned_shift = self.role.create_shift( start=assigned_start, stop=assigned_stop, user_id=self.coworker.get_id())
def test_time_off_request_crud_worker(self): self.update_permission_worker() # get requests = self.worker.get_time_off_requests( start=self.range_start, end=self.range_stop) assert len(requests) == 1 # post today = normalize_to_midnight(datetime.utcnow()) # create a time off request for testing against date1 = (today + timedelta(days=8)).strftime("%Y-%m-%d") date2 = (today + timedelta(days=5)).strftime("%Y-%m-%d") default_tz = pytz.timezone("UTC") local_tz = pytz.timezone(self.location.data.get("timezone")) # can request on a date, but state needs to be left alone new_time_off_request = self.worker.create_time_off_request(date=date1) request_date = default_tz.localize( iso8601.parse_date(new_time_off_request.data.get("start") ) \ .replace(tzinfo=None)) \ .astimezone(local_tz) \ .strftime("%Y-%m-%d") assert request_date == date1 assert new_time_off_request.data.get("state") is None # cannot request with it defined with pytest.raises(Exception): self.worker.create_time_off_request( date=date2, minutes_paid=480, state="approved_paid") # patch # not allowed with pytest.raises(Exception): self.time_off_request.patch(state="approved_paid", minute_paid=480) with pytest.raises(Exception): new_time_off_request.patch(state="denied", minutes_paid=0) # delete new_time_off_request.delete() # can delete an unresolved request with pytest.raises(Exception): self.worker.get_time_off_request(new_time_off_request.get_id()) # cannot delete once manager has touched it self.update_permission_manager() self.time_off_request.patch(state="approved_paid", minutes_paid=300) self.update_permission_worker() with pytest.raises(Exception): self.time_off_request.delete()
def test_time_off_request_crud_manager(self): self.update_permission_manager() # get requests = self.worker.get_time_off_requests( start=self.range_start, end=self.range_stop) assert len(requests) == 1 # post today = normalize_to_midnight(datetime.utcnow()) # create a time off request for testing against date1 = (today + timedelta(days=8)).strftime("%Y-%m-%d") date2 = (today + timedelta(days=5)).strftime("%Y-%m-%d") default_tz = pytz.timezone("UTC") local_tz = pytz.timezone(self.location.data.get("timezone")) # normal new_time_off_request = self.worker.create_time_off_request( date=date1, minutes_paid=480, state="approved_paid") request_date = default_tz.localize( iso8601.parse_date(new_time_off_request.data.get("start") ) \ .replace(tzinfo=None)) \ .astimezone(local_tz) \ .strftime("%Y-%m-%d") assert request_date == date1 assert new_time_off_request.data.get("minutes_paid") == 480 assert new_time_off_request.data.get("state") == "approved_paid" # other roles fail for managers with pytest.raises(Exception): self.other_worker.create_time_off_request( date=date2, minutes_paid=0, state="approved_unpaid") # patch # respond to existing time off request self.time_off_request.patch(state="approved_unpaid", minute_paid=0) assert self.time_off_request.data.get("minutes_paid") == 0 assert self.time_off_request.data.get("state") == "approved_unpaid" # modify another new_time_off_request.patch(state="denied", minutes_paid=0) assert new_time_off_request.data.get("minutes_paid") == 0 assert new_time_off_request.data.get("state") == "denied" # delete self.time_off_request.delete() new_time_off_request.delete() with pytest.raises(Exception): self.worker.get_time_off_request(self.time_off_request.get_id()) with pytest.raises(Exception): self.worker.get_time_off_request(new_time_off_request.get_id())
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 setUp(self): # (if you are copying and pasting, update class title below) super(BaseTimeOffRequest, self).setUp() today = normalize_to_midnight(datetime.utcnow()) self.range_start = today.isoformat() self.range_stop = (today + timedelta(days=10)).isoformat() # create a time off request for testing against next_week_str = (today + timedelta(days=7)).strftime("%Y-%m-%d") self.time_off_request = self.worker.create_time_off_request( date=next_week_str)
def setUp(self): # (if you are copying and pasting, update class title below) super(BaseShiftQuery, self).setUp() today = normalize_to_midnight(datetime.utcnow()) self.query_start = (today + timedelta(days=2, hours=8)).isoformat() self.query_stop = (today + timedelta(days=2, hours=15)).isoformat() self.long_stop = (today + timedelta(days=3, hours=8)).isoformat() # the other assigned self.coworker = self.role.create_worker( email="*****@*****.**", min_hours_per_workweek=20, max_hours_per_workweek=40)
def setUp(self): # (if you are copying and pasting, update class title below) super(BaseSchedule, self).setUp() today = normalize_to_midnight(datetime.utcnow()) self.range_start = (today + timedelta(days=7)).isoformat() self.range_stop = (today + timedelta(days=14)).isoformat() self.demand = { "monday": [ 0, 0, 0, 0, 0, 0, 0, 0, 3, 6, 10, 13, 12, 12, 12, 12, 13, 11, 8, 6, 4, 2, 0, 0 ], "tuesday": [ 0, 0, 0, 0, 0, 0, 0, 0, 2, 6, 9, 9, 9, 10, 12, 13, 13, 11, 7, 5, 4, 2, 0, 0 ], "wednesday": [ 0, 0, 0, 0, 0, 0, 0, 0, 3, 6, 10, 10, 11, 12, 12, 12, 12, 11, 9, 6, 5, 2, 0, 0 ], "thursday": [ 0, 0, 0, 0, 0, 0, 0, 0, 3, 6, 7, 11, 11, 11, 9, 9, 10, 9, 5, 3, 3, 2, 0, 0 ], "friday": [ 0, 0, 0, 0, 0, 0, 0, 0, 2, 2, 7, 9, 9, 10, 12, 12, 12, 8, 7, 5, 3, 2, 0, 0 ], "saturday": [ 0, 0, 0, 0, 0, 0, 0, 0, 2, 3, 7, 7, 7, 7, 7, 7, 6, 5, 4, 2, 2, 1, 0, 0 ], "sunday": [ 0, 0, 0, 0, 0, 0, 0, 0, 1, 2, 3, 3, 3, 3, 3, 3, 3, 3, 3, 2, 2, 1, 0, 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 :-)
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 provision(form): # Make the user account user = User( email=form.email.data.lower().strip(), password=form.password.data, name=form.name.data.strip(), active=False, confirmed=False, ) try: db.session.add(user) db.session.commit() except: db.session.rollback() raise Exception("Dirty session") user.flush_associated_shift_caches() # Send activation email token = user.generate_confirmation_token(trial=True) user.send_email("[Action Required] Activate Your Free Trial", render_template( "email/confirm-trial.html", user=user, token=token), True) # Create an org organization = Organization( name=form.company_name.data, day_week_starts=form.day_week_starts.data.lower(), enterprise_access=form.enterprise_access.data == "yes", plan=form.plan.data, trial_days=current_app.config.get("FREE_TRIAL_DAYS"), active=True, ) db.session.add(organization) db.session.commit() organization.admins.append(user) db.session.commit() # get timezone timezone_name = form.timezone.data.strip() if timezone_name not in pytz.all_timezones: timezone_name = current_app.config.get("DEFAULT_TIMEZONE") timezone = pytz.timezone(timezone_name) default_tz = pytz.timezone(current_app.config.get("DEFAULT_TIMEZONE")) # Add a location l = Location( name="Demo - Cafe", organization_id=organization.id, timezone=timezone_name) db.session.add(l) db.session.commit() # Add two roles r_barista = Role(name="Baristas", location_id=l.id) r_cashier = Role(name="Cashiers", location_id=l.id) db.session.add(r_barista) db.session.add(r_cashier) db.session.commit() barista_user_ids = [] for email in BARISTAS: barista = User.query.filter_by(email=email.lower()).first() if barista is None: barista = User.create_and_invite( email, name="Demo User", silent=True, # Silence on dev ) barista_user_ids.append(barista.id) db.session.add(RoleToUser(user_id=barista.id, role_id=r_barista.id)) db.session.commit() # Add the current user as a barista too db.session.add(RoleToUser(user_id=user.id, role_id=r_barista.id)) db.session.commit() cashier_user_ids = [] for email in CASHIERS: cashier = User.query.filter_by(email=email.lower()).first() if cashier is None: cashier = User.create_and_invite( email, name="Demo User", silent=True, # Silence on dev ) cashier_user_ids.append(cashier.id) # cashiers are full time, so they inherit max_hours_per_week at 40 instead of 29 db.session.add( RoleToUser( user_id=cashier.id, role_id=r_cashier.id, max_half_hours_per_workweek=80)) db.session.commit() # Load schedule data barista_demand = json.loads( '{"monday":[0,0,0,0,0,0,0,0,2,2,3,4,4,4,3,2,1,1,0,0,0,0,0,0],"tuesday":[0,0,0,0,0,0,0,0,2,3,4,4,4,4,3,2,1,1,0,0,0,0,0,0],"wednesday":[0,0,0,0,0,0,0,0,2,3,4,4,4,4,3,2,2,1,0,0,0,0,0,0],"thursday":[0,0,0,0,0,0,0,0,2,3,4,4,4,4,3,2,2,1,0,0,0,0,0,0],"friday":[0,0,0,0,0,0,0,0,2,2,3,4,4,4,3,2,1,1,0,0,0,0,0,0],"saturday":[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0],"sunday":[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0]}' ) cashier_demand = json.loads( '{"monday":[0,0,0,0,0,0,0,0,1,1,1,2,2,2,2,1,1,1,0,0,0,0,0,0],"tuesday":[0,0,0,0,0,0,0,0,1,1,1,2,2,2,2,1,1,1,0,0,0,0,0,0],"wednesday":[0,0,0,0,0,0,0,0,1,1,2,2,2,2,2,2,1,1,0,0,0,0,0,0],"thursday":[0,0,0,0,0,0,0,0,1,1,2,2,2,2,2,2,1,1,0,0,0,0,0,0],"friday":[0,0,0,0,0,0,0,0,1,1,1,2,2,2,1,1,1,1,0,0,0,0,0,0],"saturday":[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0],"sunday":[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0]}' ) # Have 4 schedules done_schedule_start_local = timezone.localize( normalize_to_midnight(datetime.utcnow() + timedelta(days=1))) done_schedule_start = done_schedule_start_local.astimezone(default_tz) # Increment until we get the correct starting day of week # (be cautious here due to infinite loop possibility) ttl = 7 # Prevent infinite loops while (done_schedule_start_local.strftime("%A").lower() != organization.day_week_starts.lower() and ttl > 0): done_schedule_start_local = normalize_to_midnight( (done_schedule_start + timedelta(days=1, hours=1) ).astimezone(timezone)) done_schedule_start = done_schedule_start_local.astimezone(default_tz) ttl -= 1 if ttl == 0: raise Exception("Unable to match day of week") done_schedule_start = done_schedule_start_local.astimezone(default_tz) done_schedule_stop = normalize_to_midnight( (done_schedule_start + timedelta(days=7, hours=1) ).astimezone(timezone)).astimezone(default_tz) current_schedule_start = normalize_to_midnight( (done_schedule_start - timedelta(days=6, hours=23) ).astimezone(timezone)).astimezone(default_tz) past_schedule_start = normalize_to_midnight( (done_schedule_start - timedelta(days=13, hours=23) ).astimezone(timezone)).astimezone(default_tz) # Make the schedules # the previous week barista_schedule_past = Schedule2.create( role_id=r_barista.id, start=past_schedule_start, stop=current_schedule_start, state="published", demand=barista_demand) cashier_schedule_past = Schedule2.create( role_id=r_cashier.id, start=past_schedule_start, stop=current_schedule_start, state="published", demand=cashier_demand) # the current week barista_schedule_current = Schedule2.create( role_id=r_barista.id, start=current_schedule_start, stop=done_schedule_start, state="published", demand=barista_demand) cashier_schedule_current = Schedule2.create( role_id=r_cashier.id, start=current_schedule_start, stop=done_schedule_start, state="published", demand=cashier_demand) # next week (schedule is published) barista_schedule_done = Schedule2.create( role_id=r_barista.id, start=done_schedule_start, stop=done_schedule_stop, state="published", demand=barista_demand) cashier_schedule_done = Schedule2.create( role_id=r_cashier.id, start=done_schedule_start, stop=done_schedule_stop, state="published", demand=cashier_demand) day_mapping = make_day_mapping_dict(organization.day_week_starts) # along with each shift, create a timeclock record if the date is before this cutoff timeclock_cutoff = normalize_to_midnight(datetime.utcnow()) # add barista shifts for i, set_of_shifts in enumerate(BARISTA_SHIFTS): for schedule in [ barista_schedule_past, barista_schedule_current, barista_schedule_done ]: for shift_tuple in set_of_shifts: current_date = schedule.start + timedelta( days=day_mapping[shift_tuple[0]]) shift_start = current_date + timedelta(hours=shift_tuple[1]) shift_stop = shift_start + timedelta(hours=shift_tuple[2]) s = Shift2( role_id=schedule.role_id, user_id=barista_user_ids[i], start=shift_start, stop=shift_stop, published=True) db.session.add(s) db.session.commit() # create a timeclock if appropriate if current_date < timeclock_cutoff: # this will make 7% of the timeclocks needed be empty if random.randint(1, 14) == 14: continue start_minutes = random.randrange(-3, 8) start_seconds = random.randrange(-30, 31) stop_minutes = random.randrange(-3, 15) stop_seconds = random.randrange(-30, 31) start = shift_start + timedelta( minutes=start_minutes, seconds=start_seconds) stop = shift_stop + timedelta( minutes=stop_minutes, seconds=stop_seconds) t = Timeclock( role_id=schedule.role_id, user_id=barista_user_ids[i], start=start, stop=stop) db.session.add(t) db.session.commit() # add cashier shifts for i, set_of_shifts in enumerate(CASHIER_SHIFTS): for schedule in [ cashier_schedule_past, cashier_schedule_current, cashier_schedule_done ]: for shift_tuple in set_of_shifts: current_date = schedule.start + timedelta( days=day_mapping[shift_tuple[0]]) shift_start = current_date + timedelta(hours=shift_tuple[1]) shift_stop = shift_start + timedelta(hours=shift_tuple[2]) s = Shift2( role_id=schedule.role_id, user_id=cashier_user_ids[i], start=shift_start, stop=shift_stop, published=True) db.session.add(s) db.session.commit() # create a timeclock if appropriate if current_date < timeclock_cutoff: # this will make 7% of the timeclocks needed be empty if random.randint(1, 14) == 14: continue start_minutes = random.randrange(-3, 8) start_seconds = random.randrange(-30, 31) stop_minutes = random.randrange(-3, 15) stop_seconds = random.randrange(-30, 31) start = shift_start + timedelta( minutes=start_minutes, seconds=start_seconds) stop = shift_stop + timedelta( minutes=stop_minutes, seconds=stop_seconds) t = Timeclock( role_id=schedule.role_id, user_id=cashier_user_ids[i], start=start, stop=stop) db.session.add(t) db.session.commit() # add shifts for our dear user for schedule in [ barista_schedule_past, barista_schedule_current, barista_schedule_done ]: for shift_tuple in USER_BARISTA_SHIFTS: current_date = schedule.start + timedelta( days=day_mapping[shift_tuple[0]]) shift_start = current_date + timedelta(hours=shift_tuple[1]) shift_stop = shift_start + timedelta(hours=shift_tuple[2]) s = Shift2( role_id=schedule.role_id, user_id=user.id, start=shift_start, stop=shift_stop, published=True) db.session.add(s) db.session.commit() # create a timeclock if appropriate if current_date < timeclock_cutoff: start_minutes = random.randrange(-3, 8) start_seconds = random.randrange(-30, 31) stop_minutes = random.randrange(-3, 15) stop_seconds = random.randrange(-30, 31) start = shift_start + timedelta( minutes=start_minutes, seconds=start_seconds) stop = shift_stop + timedelta( minutes=stop_minutes, seconds=stop_seconds) t = Timeclock( role_id=schedule.role_id, user_id=user.id, start=start, stop=stop) db.session.add(t) db.session.commit() # add unassigned shifts to barista for shift_tuple in UNASSIGNED_BARISTA_SHIFTS: shift_start = barista_schedule_done.start + timedelta( days=day_mapping[shift_tuple[0]], hours=shift_tuple[1]) shift_stop = shift_start + timedelta(hours=shift_tuple[2]) s = Shift2( role_id=barista_schedule_done.role_id, start=shift_start, stop=shift_stop, published=True) db.session.add(s) db.session.commit() current_app.logger.info( "Created a free trial for user %s (id %s) - org %s (id %s)" % (user.email, user.id, organization.name, organization.id)) return
def test_has_overlaps(self): # create some past timeclocks for testing # all tests are with user 1 unless noted otherwise # start from a week ago utcnow = datetime.utcnow() time_base = normalize_to_midnight(utcnow) - timedelta(days=7) # make some timeclocks - all occur on consecutive days t1_start = time_base + timedelta(hours=8) t1_stop = time_base + timedelta(hours=15) timeclock1 = Timeclock(role_id=1, user_id=1, start=t1_start, stop=t1_stop) t2_start = time_base + timedelta(days=1, hours=4) t2_stop = time_base + timedelta(days=1, hours=11) timeclock2 = Timeclock(role_id=1, user_id=1, start=t2_start, stop=t2_stop) t3_start = time_base + timedelta(days=2, hours=8) t3_stop = time_base + timedelta(days=2, hours=17) timeclock3 = Timeclock(role_id=1, user_id=1, start=t3_start, stop=t3_stop) # and they just clocked in for a new shift timeclock_now = Timeclock(role_id=1, user_id=1, start=utcnow) db.session.add(timeclock1) db.session.add(timeclock2) db.session.add(timeclock3) db.session.add(timeclock_now) db.session.commit() # this timeclock doesn't overlap no_overlap_start = time_base + timedelta(days=4, hours=5) no_overlap_stop = time_base + timedelta(days=4, hours=12) no_overlap_timeclock = Timeclock(role_id=1, user_id=1, start=no_overlap_start, stop=no_overlap_stop) self.assertFalse(no_overlap_timeclock.has_overlaps()) # timeclock starting during one will fail # starts during timeclock1 (day 0, 8 - 15) start_within_start = time_base + timedelta(hours=12) start_within_stop = time_base + timedelta(hours=18) tc_start_within = Timeclock(role_id=1, user_id=1, start=start_within_start, stop=start_within_stop) self.assertTrue(tc_start_within.has_overlaps()) # timeclock ending during one will fail # ends during timeclock2 (day 1, 4 - 11) ends_within_start = time_base + timedelta(days=1, hours=2) ends_within_stop = time_base + timedelta(days=1, hours=10) tc_ends_within = Timeclock(role_id=1, user_id=1, start=ends_within_start, stop=ends_within_stop) self.assertTrue(tc_ends_within.has_overlaps()) # timeclock within another will fail # surrounds timeclock3, (day2, 8 - 17) surrounds_start = time_base + timedelta(days=2, hours=6) surrounds_stop = time_base + timedelta(days=2, hours=19) tc_surrounds = Timeclock(role_id=1, user_id=1, start=surrounds_start, stop=surrounds_stop) self.assertTrue(tc_surrounds.has_overlaps()) # creating a timeclock around an active one will fail active_start = utcnow - timedelta(hours=4) active_stop = utcnow + timedelta(hours=4) tc_during_active = Timeclock(role_id=1, user_id=1, start=active_start, stop=active_stop) self.assertTrue(tc_during_active.has_overlaps())
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 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