def _read_validated_scopes() -> List[str]: """ Prompts the user to enter a list of space-separated TimeScopes - if nothing is entered, fall back to the "current" scope - if whitespace is entered, return an empty list - TimeScopes are checked for validity """ today_scope = TimeScope(datetime.now().strftime("%G-ww%V.%u")) entered_scopes = input(f"Enter scopes to add [{today_scope}]: {Color.RED}") print(Color.END, end='', flush=True) if not entered_scopes: requested_scopes = [today_scope] elif not entered_scopes.strip(): requested_scopes = [] else: requested_scopes = [TimeScope(s) for s in entered_scopes.split()] try: [s.get_type() for s in requested_scopes] except ValueError as e: print(e) return return requested_scopes
def test_invalid(): with pytest.raises(ValueError): s = TimeScope("invalid scope string") s.type with pytest.raises(ValueError): s = TimeScope("2020-w35") s.type
def test_latest_scope(task_v1_session): csv_test_file = """desc,scopes task 1,2020-ww39.1 2020-ww39.2 2020-ww39.3 """ import_from_csv(io.StringIO(csv_test_file), task_v1_session) t: Task = Task.query.all()[0] assert len(list(matching_scopes(t.task_id))) == 3 assert latest_scope(t.task_id, TimeScope("2020-ww39.1")) == "2020-ww39.3" assert not latest_scope(t.task_id, TimeScope("2020-ww39.3"))
def report_notes_all(): page_scope = None try: parsed_scope = TimeScope(escape(request.args.get('scope'))) parsed_scope.get_type() page_scope = parsed_scope except ValueError: pass page_domain = request.args.get('domain') return report.report_notes(page_scope=page_scope, page_domain=page_domain)
def edit_tasks_in_scope(scope_id): if scope_id == 'week': return redirect( url_for(".edit_tasks_in_scope", scope_id=datetime.now().strftime("%G-ww%V"))) page_scope = None try: parsed_scope = TimeScope(scope_id) parsed_scope.get_type() page_scope = parsed_scope except ValueError: pass return report.edit_tasks_in_scope(page_scope=page_scope)
def update(session, requested_scopes, category, resolution, task_id): t: Task = Task.query.filter(Task.task_id == task_id).one() if requested_scopes: # TODO: This is redundant, should let sqlalchemy enforce the no-dupes existing_scopes = tasks_v1.report.matching_scopes(task_id) for requested_scope in requested_scopes: if TimeScope(requested_scope) not in existing_scopes: session.add( TaskTimeScope(task_id=t.task_id, time_scope_id=requested_scope)) if category: t.category = category session.add(t) if resolution: t.resolution = resolution session.add(t) try: session.commit() print() print(f"Updated task {t.task_id}") print(json.dumps(t.to_json_dict(), indent=4)) except OperationalError as e: print() print(e) return
def _to_summary_html(t: Task, ref_scope: Optional[TimeScope], print_task_id=False) -> str: def _link_replacer(mdown: str): return re.sub(r'\[(.+?)\]\((.+?)\)', r"""[\1](<a href="\2">\2</a>)""", mdown) def _to_time_html(t: Task) -> str: if t.time_estimate is not None and t.time_actual is not None: return f"`{t.time_estimate}h => {t.time_actual}h`" elif t.time_estimate is not None: return f"`{t.time_estimate}h`" elif t.time_actual is not None: return f"`=> {t.time_actual}`" else: return f"{t.time_estimate} => {t.time_actual}" response_html = "" if ref_scope: short_scope = TimeScope(t.first_scope).shorten(ref_scope) if short_scope: response_html += f'\n<span class="time-scope">{short_scope}</span>' response_html += f'\n<span class="desc">{_link_replacer(t.desc)}</span>' if t.time_estimate is not None or t.time_actual is not None: response_html += f'\n<span class="task-time">{_to_time_html(t)}</span>' if print_task_id: response_html += f'\n<span class="task-id"><a href="/task/{t.task_id}">#{t.task_id}</a></span>' return response_html
def add_from_cli(session): # Read description for the task desc = input(f"Enter description: {Color.RED}") print(Color.END, end='', flush=True) # Read relevant scopes requested_scopes = sorted(_read_validated_scopes()) if not len(requested_scopes): requested_scopes = [TimeScope(datetime.now().strftime("%G-ww%V.%u"))] print(f" => defaulting to {requested_scopes}") if len(requested_scopes) > 1: print(f" => parsed as {requested_scopes}") # Create a Task, now that we have all required fields t = Task(desc=desc, first_scope=requested_scopes[0], created_at=datetime.now()) # And a category category = input(f"Enter category: {Color.RED}") if category is not None and category != "": t.category = category print(Color.END, end='', flush=True) # And a time_estimate time_estimate = input(f"Enter time_estimate: {Color.RED}") if time_estimate is not None and time_estimate != "": if not re.fullmatch(r"\d+\.\d", time_estimate): print(Color.END, end='') print( "time_estimate must be in format like `12.0` (\"\\d+\\.\\d\"), exiting" ) print() return t.time_estimate = time_estimate print(Color.END, end='', flush=True) # Try committing the Task try: session.add(t) session.commit() except StatementError as e: print() print("Hit exception when parsing:") print(json.dumps(t.to_json_dict(), indent=4)) session.rollback() return # Try creating the TaskTimeScopes for requested_scope in requested_scopes: session.add( TaskTimeScope(task_id=t.task_id, time_scope_id=requested_scope)) session.commit() print() print(f"Created task {t.task_id}") print(json.dumps(t.to_json_dict(), indent=4))
def test_blank_domains_reporting(note_session): csv_test_file = """scope,desc,short_desc,domains 2020-ww48.4,,day-long event, """ import_from_csv(io.StringIO(csv_test_file), note_session) dict = _report_notes_for(scope=TimeScope("2020—Q4"), domain=None) assert dict assert "2020—Q4" in dict assert '2020-ww48' in dict["2020—Q4"]["child_scopes"]
def _force_day_scope(scope_str): maybe_scope = TimeScope(scope_str) if maybe_scope.type == TimeScope.Type.quarter: # Convert "quarter" scopes into their first day scope = maybe_scope.start.strftime("%G-ww%V.%u") return scope elif maybe_scope.type == TimeScope.Type.week: # Convert "week" scopes into their first day scope = maybe_scope.start.strftime("%G-ww%V.%u") return scope return scope_str
def print_sort_time(n: Note) -> str: scope_id_to_print = TimeScope(n.time_scope_id) # For within-day notes, print something more detailed if parent_scope_id.type == TimeScope.Type.day: # Override with sort_time as needed if n.sort_time: scope_id_to_print = TimeScope( n.sort_time.strftime("%G-ww%V.%u")) # If the days are identical, just print the time if parent_scope_id == scope_id_to_print: return f'<span class="time">{str(n.sort_time)[11:16]}</span>' else: return f'''<span class="time">{ scope_id_to_print.shorten(parent_scope_id) } { str(n.sort_time)[11:16] }</span>''' return f'<span class="time">{scope_id_to_print.shorten(parent_scope_id)}</span>'
def test_report_emdash(note_session): csv_test_file = """scope,desc,short_desc,domains 2020-ww48.4,,day-long event,d1 2020-ww48,,week-long,d1 2020—Q4,,quarter-long,d1 """ import_from_csv(io.StringIO(csv_test_file), note_session) dict = _report_notes_for(scope=TimeScope("2020—Q4"), domain=None) assert dict assert "2020—Q4" in dict assert '2020-ww48' in dict["2020—Q4"]["child_scopes"]
def _migrate_simple(session, t1: Task_v1) -> Task_v2: """ Migrate an orphan task Task_v1 only has a few fields that will turn into linkages: - for first_scope, we can add the `created_at` field, and possibly an `as_json()` dump - for the last scope, we set `resolution` + `time_elapsed` - every other linkage has no info, a `roll => wwXX.Y` resolution at most Note that first_scope isn't guaranteed to be the _earliest_ scope, it's intended to be the one where the task was created. """ t2 = Task_v2(desc=t1.desc, category=t1.category, time_estimate=t1.time_estimate) session.add(t2) session.flush() #print(f"DEBUG: {t2} <= _migrate_simple({t1})") created_at_linkage = None prior_linkage = None for scope_id in _get_scopes(t1): linkage = TaskLinkage(task_id=t2.task_id, time_scope_id=scope_id) linkage.created_at = datetime.now() session.add(linkage) # Touch up created_at_linkage if scope_id == _force_day_scope(t1.first_scope): created_at_linkage = linkage created_at_linkage.created_at = t1.created_at if t1.created_at else datetime.now( ) created_at_linkage.detailed_resolution = json.dumps(t1.as_json(), indent=4) # For "ordinary" linkages, append `roll => wwXX.Y` resolution if prior_linkage: min_scope_id = TimeScope(t1.first_scope).minimize(scope_id) prior_linkage.resolution = f"roll => {min_scope_id}" prior_linkage = linkage # For the final linkage, inherit any/all Task_v1 fields final_linkage = prior_linkage if prior_linkage else created_at_linkage final_linkage.resolution = t1.resolution final_linkage.time_elapsed = t1.time_actual return t2
def report_open_tasks(hide_future_tasks: bool): query: Query = Task.query \ .filter(Task.resolution == None) \ .order_by(Task.category, Task.created_at) future_tasks_cutoff = datetime.now() ref_scope = TimeScope(future_tasks_cutoff.date().strftime("%G-ww%V.%u")) if hide_future_tasks: # TODO: This is a very simple filter that barely does what's requested. query = query.filter(Task.first_scope < ref_scope) return render_template('task.html', short_scope=_short_scope, tasks_by_scope={ref_scope: query.all()}, to_details_html=to_details_html, to_summary_html=to_summary_html)
def update_from_cli(session, task_id): # Open relevant task t: Task = Task.query.filter(Task.task_id == task_id).one() if len(t.desc) <= 70: print(t.desc) print() else: print(t.desc[:60] + "…") print() matching_scopes = tasks_v1.report.matching_scopes(task_id) print(f"Existing scopes => {', '.join(matching_scopes)}") # Read relevant scopes requested_scopes = _read_validated_scopes() # Decide whether we're resolving it if t.resolution: print(f"Task already has a resolution => {t.resolution}") requested_resolution = input( f"Enter resolution to set [{t.resolution}]: {Color.RED}") print(Color.END, end='', flush=True) else: requested_resolution = input(f"Enter resolution to set: {Color.RED}") print(Color.END, end='', flush=True) if requested_resolution: t.resolution = requested_resolution session.add(t) # And update the scopes, maybe for requested_scope in requested_scopes: if TimeScope(requested_scope) not in matching_scopes: session.add( TaskTimeScope(task_id=t.task_id, time_scope_id=requested_scope)) try: session.commit() print() print(f"Updated task {t.task_id}") print(json.dumps(t.to_json_dict(), indent=4)) except OperationalError as e: print() print(e) return
def add_note(self, n: Note): """ This is the only time Notes are sorted into special summary/event types. """ # Add the note to its dict s = TimeScope(n.time_scope_id) # Note that summaries go into their _enclosing_ scope. # Except for quarters, which they don't have such a thing. if n.type == "summary": enclosing_scope = TimeScopeUtils.enclosing_scope(s)[0] self.scope_to_summaries_dict[enclosing_scope].append(n) self.add_scope(enclosing_scope) elif n.type == "event" or (n.type and n.type[0:7] == "event: "): self.scope_to_events_dict[s].append(n) self.add_scope(s) else: self.scope_to_notes_dict[s].append(n) self.add_scope(s)
def report_tasks(scope): tasks_by_scope = {} subscopes = TaskTimeScope.query \ .filter(TaskTimeScope.time_scope_id.like(scope + "%")) \ .order_by(TaskTimeScope.time_scope_id) \ .all() sorted_scopes = [ *TimeScopeUtils.enclosing_scope(scope, recurse=True), scope, *[s.time_scope_id for s in subscopes] ] for s in sorted_scopes: tasks = Task.query \ .join(TaskTimeScope, Task.task_id == TaskTimeScope.task_id) \ .filter(TaskTimeScope.time_scope_id == s) \ .order_by(TaskTimeScope.time_scope_id, Task.category) \ .all() tasks_by_scope[TimeScope(s)] = tasks prev_scope = TimeScopeUtils.prev_scope(scope) prev_scope_html = f'<a href="/report-tasks/{prev_scope}">{prev_scope}</a>' next_scope = TimeScopeUtils.next_scope(scope) next_scope_html = f'<a href="/report-tasks/{next_scope}">{next_scope}</a>' def mdown_desc_cleaner(desc: str): desc = re.sub(r'\[(.+?)]\((.+?)\)', r"""[\1](<a href="\2">\2</a>)""", desc) return desc return render_template('task.html', prev_scope=prev_scope_html, next_scope=next_scope_html, tasks_by_scope=tasks_by_scope, link_replacer=mdown_desc_cleaner, short_scope=_short_scope, to_details_html=to_details_html, to_summary_html=to_summary_html)
def test_enclosing_scopes(): ref = TimeScope("2023-ww04.3") assert TimeScopeUtils.enclosing_scope(ref, recurse=False) == [TimeScope("2023-ww04")] assert set(TimeScopeUtils.enclosing_scope(ref, recurse=True)) \ == {TimeScope("2023-ww04"), TimeScope("2023—Q1")}
def _short_scope(t: Task, ref_scope): short_scope_str = TimeScope(t.first_scope).shorten(ref_scope) if short_scope_str: return short_scope_str return None
def note_sorter(n: Note): if not n.sort_time: return TimeScope(n.time_scope_id).start return n.sort_time
def test_day_type(): s = TimeScope("2020-ww35.5") assert s.type == TimeScope.Type.day assert s.start == _construct_dt(2020, 8, 28) assert s.end == _construct_dt(2020, 8, 29)
def test_quarter_type(): s = TimeScope("2018—Q3") assert s.type == TimeScope.Type.quarter assert s.start == _construct_dt(2018, 7, 1) assert s.end == _construct_dt(2018, 10, 1)
def test_create_quarterly(task_v1_session): task = Task(desc="아니아", first_scope="2020—Q4") time = TimeScope(task.first_scope) assert time.type == TimeScope.Type.quarter
def test_shorten_today(): ref = "2020-ww48.4" s = TimeScope("2020-ww48.4").shorten(ref) assert not s
def test_shorten_weeks(): ref = "2020-ww47.3" s = TimeScope("2020-ww48.4").shorten(ref) assert s == "ww48.4"
def test_create(): s = TimeScope("2020-ww35") assert s
def test_minimize(): ref = TimeScope("2020-ww48.4") assert ref.minimize("2019-ww48.5") == "2019-ww48.5" assert ref.minimize("2020-ww48.5") == "ww48.5" assert ref.minimize("2020-ww50.4") == "ww50.4"
def test_shorten_years(): ref = "2010-ww48.4" s = TimeScope("2020-ww48.4").shorten(ref) assert s == "2020-ww48.4"
def test_shorten_years_close(): ref = "2020-ww52.4" s = TimeScope("2021-ww02.1").shorten(ref) assert s == "2021-ww02.1"
def test_prev_next_day(): dref = TimeScope("2002-ww04.4") assert TimeScopeUtils.next_scope(dref) == "2002-ww04.5"