def _sum_cell(self, show_sum: bool, rows: List[List[str]]): if not show_sum: return [""] sum_ = timedelta() for row in rows: sum_ += sum(row[1:], timedelta()) return ["Sum: %s" % self._timedelta_conversion_function(sum_)]
def to_date(self, str_: str) -> Union[date, None]: """Parses a string to a datetime.date. :param str_: a string on the form on the form YYYY-MM-DD, 'yesterday', 'monday' or 'Monday' :return: a datetime.date() object for the supplied date, or None if the date could not be parsed. """ if self._is_date(str_): return datetime.strptime(str_, "%Y-%m-%d").date() elif str_ == "yesterday": return self.today - timedelta(days=1) try: index = "monday tuesday wednesday thursday friday".split().index( str_.lower()) date_ = self.today + timedelta(days=-self.today.weekday() + index) if self.contains_next: date_ += timedelta(weeks=1) self.next_shall_be_removed = True if self.contains_last: date_ -= timedelta(weeks=1) self.last_shall_be_removed = True return date_ except ValueError: pass return None
def test_minute(): assert TimeParser.as_timedelta("34m") == timedelta(minutes=34) assert TimeParser.as_timedelta("3m") == timedelta(minutes=3) assert TimeParser.as_timedelta("34 m") == timedelta(minutes=34) assert TimeParser.as_timedelta("75m") == timedelta(hours=1, minutes=15) with pytest.raises(TimeParserError): TimeParser.as_timedelta("m")
def working_time(self) -> timedelta: if self.came and self.left: lunch = self.lunch if self.lunch else timedelta() seconds_at_work = self.to_seconds(self.left) - self.to_seconds(self.came) working_time_excluding_lunch = timedelta(seconds=seconds_at_work) return working_time_excluding_lunch - lunch else: return timedelta()
def _mondays(self): first_monday = self._closest_monday_to_first_day_of_target_month() mondays = [ first_monday + timedelta(days=days) for days in range(0, 38, 7) # Max 31+7 days first-last Mon if (first_monday + timedelta(days=days)).month in (self.month_index, self.last_month_index) ] return mondays
def show(self, calendar): total_flex = timedelta() date_ = self.from_ while True: if calendar.flex(date_): total_flex += calendar.flex(date_) if date_ == self.to: break date_ += timedelta(days=1) return f"Total flex from {self.from_} to {self.to}: " f"{total_flex}"
def default_project_time(self, date_): project_time_sum = timedelta() for project_name in self.days[date_].projects: project = [ project for project in self.projects if project.name == project_name ][0] if project.work: project_time_sum += self.days[date_].projects[project_name] default_project_time = self.days[date_].working_time - project_time_sum # Set to 0 hours if less than 0 hours return max(default_project_time, timedelta())
class Command: TIMEDELTA = timedelta(weeks=1) WRITE_TO_DISK = True def __init__(self, calendar: Calendar, date_: date, args: Union[list, str]): self.calendar = calendar self.date = date_ self.args = args # TODO: use the new argument splitter method instead if isinstance(self.args, str): self.args = self.args.split() if "last" in self.args: self.date -= self.TIMEDELTA elif "next" in self.args: self.date += self.TIMEDELTA self.options = self._parse_options() def _parse_options(self) -> Dict[str, str]: options = {} new_args = [] assert isinstance(self.args, list) for arg in self.args: if arg.startswith("--"): name = arg.split("=")[0] value = arg.split("=")[1] if "=" in arg else True options[name] = value if name not in self.valid_options(): raise UnexpectedOptionError(name) else: new_args.append(arg) self.args = new_args return options @classmethod def can_handle(cls, args) -> bool: args = [arg for arg in args if not arg.startswith("--")] args = [arg for arg in args if arg not in ("last", "next")] return cls._can_handle(args) @classmethod def _can_handle(cls, args: List[str]) -> bool: raise NotImplementedError def valid_options(self) -> List[str]: return [] def execute( self, created_at: datetime.datetime = datetime.datetime.now() ) -> Tuple[Calendar, View]: return self.new_calendar(created_at), self.view() def view(self) -> View: return ConsoleWeekView(self.date) def new_calendar(self, created_at: datetime.datetime) -> Calendar: return self.calendar
def test_serialize(self): c = Calendar(target_hours_per_day=timedelta(hours=8.00)) c = c.add(Day("came 9 left 18", today)) data = c.dump() c2 = Calendar.load(data) s = ConsoleDayShower(c2).show_days(today, 1) assert re.search("Flex *01:00", s)
def as_timedelta(cls, str_: str) -> timedelta: if "m" in str_: try: minutes = int(str_.split("m")[0]) hours = minutes // 60 minutes = minutes % 60 return timedelta(hours=hours, minutes=minutes) except ValueError: pass try: hours, minutes = cls._to_hours_and_minutes(str_) return timedelta(hours=hours, minutes=minutes) except TimeParserError: raise TimeParserError( f'Error: Could not parse "{str_}" as time interval. Time intervals must be on the ' f"form H, HH:MM, H:MM, HHMM, HMM, M m, MM m, M min or MM min")
def test_show_last_week_html(self, mock_browser): s, _ = main("show last week html") last_monday = str( timereporter.__main__.today() + timedelta(days=-timereporter.__main__.today().weekday(), weeks=-1)) with open(mock_browser.url) as f: s = f.read() assert last_monday in s
def _flex_row(self, dates) -> List[str]: flex_times = [self.calendar.flex(date_) for date_ in dates] flex_times = list( map( lambda x: timedelta() if x is None else x * self.FLEX_MULTIPLIER, flex_times, )) if not self.SHOW_EARNED_FLEX: flex_times = list( map( lambda x: timedelta() if x is None or x <= timedelta() else x, flex_times, )) flex_times = [ self._timedelta_conversion_function(flex) for flex in flex_times ] return ["Flex"] + flex_times
def test_added(self): c = Calendar() wednesday = today + timedelta(days=-today.weekday() + 2) c = c.add(Day("came 8 left 18 lunch 45m", wednesday)) s = ConsoleDayShower(c).show_days(today, 5) assert "08:00" in s assert "18:00" in s assert "0:45" in s assert "Came" in s assert "Left" in s assert "Lunch" in s
class ShowMonthCommand(ShowWeekendCommand): # Close enough to a month TIMEDELTA = timedelta(days=30) @classmethod def _can_handle(cls, args) -> bool: return args == "show month".split() def view(self): return ConsoleMonthView( self.date, ConsoleMonthView.MONTHS[self.date.month - 1], "--show-weekend" in self.options, )
def flex(self, date_: date) -> Union[timedelta, None]: """Calculates the flex time earned or spent on a certain day. The flex time is equal to the working time plus the no-work project time minus the target hours per day. """ working_time = self.days[date_].working_time no_work_projects_names = [ project.name for project in self.projects if not project.work ] no_work_project_time = sum( [ self.days[date_].projects[project_name] for project_name in no_work_projects_names ], timedelta(), ) if working_time: return working_time - self.target_hours_per_day + no_work_project_time else: return None
def show_days( self, first_date: date, day_count, ): """Shows a number of days from the calendar in table format. :param first_date: the first day to show. :param day_count: the number of days to show, including the first day. :param table_format: the table format, see https://bitbucket.org/astanin/python-tabulate for the alternatives. """ dates = [first_date + timedelta(days=i) for i in range(day_count)] weekdays = "Monday Tuesday Wednesday Thursday Friday Saturday " "Sunday".split( ) weekdays_to_show = [weekdays[date_.weekday() % 7] for date_ in dates] came_times = [self.calendar.days[date_].came for date_ in dates] leave_times = [self.calendar.days[date_].left for date_ in dates] lunch_times = [self.calendar.days[date_].lunch for date_ in dates] rows = self._rows(dates) table = tabulate( [ self._sum_cell(self.SHOW_SUM, rows) + dates, [""] + weekdays_to_show, ["Came"] + came_times, ["Left"] + leave_times, ["Lunch"] + lunch_times, *rows, ], tablefmt=self.TABLE_FORMAT, ) return html.unescape(table)
def _closest_monday(self): closest_monday = self.date + timedelta(days=-self.date.weekday()) return closest_monday
def test_day_fix_wrong_lunch_format(self): d = Day("lunch 01:00") assert d.lunch == timedelta(minutes=60)
def _to_timedelta(cls, t: Union[time, timedelta, None]) -> Union[timedelta, None]: if t is None: return None if isinstance(t, timedelta): return t return timedelta(seconds=t.hour * 3600 + t.minute * 60)
def show(self, calendar): closest_monday = self.date + timedelta(days=-self.date.weekday()) return ConsoleDayShower(calendar).show_days(closest_monday, self.day_count)
def test_basic(self): c = Calendar(target_hours_per_day=timedelta(hours=8.00)) c = c.add(Day("came 9 left 18", today)) s = ConsoleDayShower(c).show_days(today, 1) assert re.search("Flex *01:00", s)
def test_positive(self): assert str(timedelta(seconds=60)) == "00:01"
def test_positive_hours(self): assert str(timedelta(seconds=3660)) == "01:01"
def test_convert_from_timedelta(self): td = timedelta(seconds=3600 + 3600 * 0.25) tdd = timedeltaDecimal.from_timedelta(td) assert str(tdd) == "1,25"
def test_convert_from_timedelta_negative(self): td = timedelta(seconds=-3600 - 3600 * 0.25) tdd = timedeltaDecimal.from_timedelta(td) assert str(tdd) == "-1,25"
def test_negative_hours(self): assert str(timedelta(seconds=-3660)) == "-01:01"
def test_negative(self): assert str(timedelta(seconds=-60)) == "-00:01"
def test_overwrite_lunch(self): c = Calendar() c = c.add(Day("lunch 45m", today)) c = c.add(Day("lunch 35m", today)) assert c.days[today].lunch == timedelta(minutes=35)
def _closest_monday_to_first_day_of_target_month(self): first_day_of_month = self._first_day_of_month( self._date_in_correct_year()) closest_monday = first_day_of_month + timedelta( days=-first_day_of_month.weekday()) return closest_monday
class Calendar: """Contains a dictionary mapping Day objects to dates, and handles visualization of those days """ DEFAULT_TARGET_HOURS_PER_DAY = timedelta(hours=7.75) DEFAULT_PROJECT_NAME = "EPG Program" def __init__( self, raw_days=None, projects=None, redo_list=None, target_hours_per_day=DEFAULT_TARGET_HOURS_PER_DAY, default_project_name=DEFAULT_PROJECT_NAME, aliases=None, ): self._raw_days = [] if raw_days is None else raw_days self.redo_list = [] if redo_list is None else redo_list self.projects = [] if projects is None else projects self.target_hours_per_day = target_hours_per_day self.default_project_name = default_project_name self._aliases = aliases or {} # type: Dict[str, str] self._days = None @property def days(self) -> Dict[date, Day]: """Retrieve all Day objects in the calendar. :return: A dictionary (date, Day) with all the Days in the calendar. """ if not self._days: self._days = defaultdict(Day) for day in self._raw_days: self._days[day.date] += day return self._days def add(self, day: Day): """Add a day to the calendar.""" new_days = self._raw_days + [day] return Calendar( raw_days=new_days, redo_list=[], projects=self.projects[:], target_hours_per_day=self.target_hours_per_day, default_project_name=self.default_project_name, aliases=self.aliases.copy(), ) def add_project(self, project_name: str, work=True): """Adds a project with the specified project name to the calendar""" return Calendar( raw_days=self._raw_days[:], redo_list=[], projects=self.projects + [Project(project_name, work)], target_hours_per_day=self.target_hours_per_day, default_project_name=self.default_project_name, aliases=self.aliases.copy(), ) def undo(self) -> Tuple["Calendar", Optional[date]]: """Undo the last edits with the same created_at to the calendar.""" if not self._raw_days: return self, None created_at = self._raw_days[-1].created_at raw_days = self._raw_days[:] days_to_undo = [] new_redo_list = self.redo_list while raw_days and raw_days[-1].created_at == created_at: days_to_undo.append(raw_days.pop()) new_redo_list.append(days_to_undo) return ( Calendar( raw_days=raw_days, redo_list=new_redo_list, projects=self.projects[:], target_hours_per_day=self.target_hours_per_day, default_project_name=self.default_project_name, aliases=self.aliases.copy(), ), self._raw_days[-1].date, ) def redo(self) -> Tuple["Calendar", Optional[date]]: """Redo the last undo made to the calendar. Returns None instead of a date if there is nothing to undo.""" new_days = self._raw_days if self.redo_list: new_days.extend(self.redo_list[-1]) return ( Calendar( raw_days=new_days, redo_list=self.redo_list[:-1], projects=self.projects[:], target_hours_per_day=self.target_hours_per_day, default_project_name=self.default_project_name, aliases=self.aliases.copy(), ), self.redo_list[-1][-1].date if self.redo_list else None, ) @property def aliases(self): return self._aliases def add_alias(self, short: str, full: str): """Add a new alias""" aliases = self.aliases.copy() aliases[short] = full return Calendar( raw_days=self._raw_days[:], redo_list=[], projects=self.projects, target_hours_per_day=self.target_hours_per_day, default_project_name=self.default_project_name, aliases=aliases, ) def remove_alias(self, short: str): """Add a new alias""" aliases = self.aliases.copy() del aliases[short] return Calendar( raw_days=self._raw_days[:], redo_list=[], projects=self.projects, target_hours_per_day=self.target_hours_per_day, default_project_name=self.default_project_name, aliases=aliases, ) def default_project_time(self, date_): project_time_sum = timedelta() for project_name in self.days[date_].projects: project = [ project for project in self.projects if project.name == project_name ][0] if project.work: project_time_sum += self.days[date_].projects[project_name] default_project_time = self.days[date_].working_time - project_time_sum # Set to 0 hours if less than 0 hours return max(default_project_time, timedelta()) def flex(self, date_: date) -> Union[timedelta, None]: """Calculates the flex time earned or spent on a certain day. The flex time is equal to the working time plus the no-work project time minus the target hours per day. """ working_time = self.days[date_].working_time no_work_projects_names = [ project.name for project in self.projects if not project.work ] no_work_project_time = sum( [ self.days[date_].projects[project_name] for project_name in no_work_projects_names ], timedelta(), ) if working_time: return working_time - self.target_hours_per_day + no_work_project_time else: return None def dump(self): return Camel([camelRegistry]).dump(self) @classmethod def load(cls, data, version=1): return Camel([camelRegistry]).load(data)