diff --git a/README.md b/README.md index 809bc26..c0212c1 100644 --- a/README.md +++ b/README.md @@ -17,7 +17,8 @@ password = 'do.it.for.her' [options] project = 'Springfield Nuclear Power Plant' -assignment = 'Nuclear Safety Inspection' +task-category = 'Internal Process' +task-description = 'Nuclear Safety Inspection' focal = 'Mr. Burns' hours = 6 ``` @@ -78,7 +79,7 @@ tt show [OPTIONS] $ tt show Start: 01/05/2019, End: 02/05/2019 +------------+-----------------------------------------------------------------+ -| Date | Description | +| Date | Comments | +------------+-----------------------------------------------------------------+ | 01/05/2019 | BURNS-4765 I pressed a button in the board | +------------+-----------------------------------------------------------------+ @@ -92,7 +93,7 @@ Start: 01/05/2019, End: 02/05/2019 $ tt show -w Start: 01/05/2019, End: 02/05/2019 +---------+------------+-----------------------------------------------------------------+ -| Weekday | Date | Description | +| Weekday | Date | Comments | +---------+------------+-----------------------------------------------------------------+ | W | 01/05/2019 | BURNS-4765 I pressed a button in the board | +---------+------------+-----------------------------------------------------------------+ @@ -106,7 +107,7 @@ Start: 01/05/2019, End: 02/05/2019 $ tt show -s "4 days ago" -e yesterday Start: 28/04/2019, End: 01/05/2019 +------------+-----------------------------------------------------------------+ -| Date | Description | +| Date | Comments | +------------+-----------------------------------------------------------------+ | 28/05/2019 | BURNS-4210 I slept all day long | +------------+-----------------------------------------------------------------+ @@ -124,7 +125,7 @@ Start: 28/04/2019, End: 01/05/2019 $ tt show -d martes Start: 28/04/2019, End: 28/04/2019 +------------+-----------------------------------------------------------------+ -| Date | Description | +| Date | Comments | +------------+-----------------------------------------------------------------+ | 28/05/2019 | BURNS-4210 I slept all day long | +------------+-----------------------------------------------------------------+ @@ -132,7 +133,7 @@ Start: 28/04/2019, End: 28/04/2019 $ tt show -w -d quarta-feira Start: 29/04/2019, End: 29/04/2019 +------------+-----------------------------------------------------------------+ -| Date | Description | +| Date | Comments | +------------+-----------------------------------------------------------------+ | 29/04/2019 BURNS-4283 I missed March | +------------+-----------------------------------------------------------------+ diff --git a/setup.py b/setup.py index f52250a..dd7c748 100644 --- a/setup.py +++ b/setup.py @@ -19,7 +19,7 @@ setup( name='timetracker-cli', - version='1.3.0', + version='1.3.1', description='A command-line utility to interact with BairesDev Time tracker', long_description=long_description, long_description_content_type="text/markdown", diff --git a/timetracker/constants.py b/timetracker/constants.py index 75d39a6..538af9b 100644 --- a/timetracker/constants.py +++ b/timetracker/constants.py @@ -5,11 +5,7 @@ CONFIG_PATH = os.path.join(HOME, '.timetracker/config.toml') BASE_URL = 'https://timetracker.bairesdev.com' -PROJECT_DROPDOWN = 'ctl00_ContentPlaceHolder_idProyectoDropDownList' -ASSIGNMENT_DROPDOWN = 'ctl00_ContentPlaceHolder_idTipoAsignacionDropDownList' -FOCAL_DROPDOWN = 'ctl00_ContentPlaceHolder_idFocalPointClientDropDownList' - LOGIN_CREDENTIALS = ['username', 'password'] -LOAD_HOURS_OPTIONS = ['project', 'assignment', 'focal'] +LOAD_HOURS_OPTIONS = ['project', 'task-category', 'task-description', 'focal'] WEEKDAYS = ['M', 'T', 'W', 'TH', 'F', 'S', 'SU'] diff --git a/timetracker/pages.py b/timetracker/pages.py new file mode 100644 index 0000000..fa37477 --- /dev/null +++ b/timetracker/pages.py @@ -0,0 +1,242 @@ +import re +import logging +from datetime import date +from typing import Dict +from tzlocal import get_localzone + +from requests import Session +from bs4 import BeautifulSoup +from dateparser import parse + + +BASE_URL = "https://timetracker.bairesdev.com" +logger = logging.getLogger(__name__) + + +class Page: + def __init__(self, session: Session, content: str): + self._session: Session = session + self._content = content + self._soup: BeautifulSoup = BeautifulSoup(content, "html.parser") + + @staticmethod + def session() -> Session: + session = Session() + # session.verify = False + session.headers.update( + { + "Host": "timetracker.bairesdev.com", + "Upgrade-Insecure-Requests": "1", + "User-Agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_13_3) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/64.0.3282.186 Safari/537.36", + "Origin": "http://timetracker.bairesdev.com", + "Referer": "http://timetracker.bairesdev.com/", + } + ) + return session + + def _hidden_value(self, name: str): + element = self._soup.find("input", {"name": name}) + if not element: + return "" + return element.get("value", "") + + def _option_value(self, name: str, label: str) -> str: + select = self._soup.find("select", {"name": name}) + if not select: + raise ValueError(f'"{name}" is not available') + options = select.findAll("option") + available = { + option.text: option.get("value") for option in options if option.text + } + if label not in available: + raise KeyError(f'"{label}" not found in {set(available.keys())}') + return available[label] + + +class NewTimeForm(Page): + DATE_FIELD = "ctl00$ContentPlaceHolder$txtFrom" + PROJECT_FIELD = "ctl00$ContentPlaceHolder$idProyectoDropDownList" + HOURS_FIELD = "ctl00$ContentPlaceHolder$TiempoTextBox" + CATEGORY_FIELD = ( + "ctl00$ContentPlaceHolder$idCategoriaTareaXCargoLaboralDropDownList" + ) + TASK_FIELD = "ctl00$ContentPlaceHolder$idTareaXCargoLaboralDownList" + COMMENT_FIELD = "ctl00$ContentPlaceHolder$CommentsTextBox" + FOCAL_FIELD = "ctl00$ContentPlaceHolder$idFocalPointClientDropDownList" + + @staticmethod + def _grab_secret(content: str, name: str) -> str: + match = re.search(rf"hiddenField\|{name}\|([\w*/*\+*=*]*)", content) + if match is None or not match.groups(): + return "" + return match.groups()[0] + + def set_project(self, project: str): + value = self._option_value(self.PROJECT_FIELD, project) + args = { + "ctl00$ContentPlaceHolder$ScriptManager": f"ctl00$ContentPlaceHolder$UpdatePanel1|{self.PROJECT_FIELD}", + "__VIEWSTATE": self._hidden_value("__VIEWSTATE"), + "__VIEWSTATEGENERATOR": self._hidden_value("__VIEWSTATEGENERATOR"), + "__EVENTVALIDATION": self._hidden_value("__EVENTVALIDATION"), + self.DATE_FIELD: date.today().strftime(r"%d/%m/%Y"), + self.PROJECT_FIELD: value, + self.HOURS_FIELD: "", + self.CATEGORY_FIELD: "", + self.TASK_FIELD: "", + self.COMMENT_FIELD: "", + self.FOCAL_FIELD: "", + "__ASYNCPOST": "true", + } + response = self._session.post(f"{BASE_URL}/TimeTrackerAdd.aspx", data=args) + response.raise_for_status() + secrets = { + name: self._grab_secret(response.content.decode(), name) + for name in [ + "__EVENTTARGET", + "__EVENTARGUMENT", + "__LASTFOCUS", + "__VIEWSTATE", + "__VIEWSTATEGENERATOR", + "__EVENTVALIDATION", + ] + } + secrets[self.PROJECT_FIELD] = value + return WithSecrets(self._session, self._content, secrets) + + +class WithSecrets(NewTimeForm): + def __init__(self, session: Session, content: str, secrets: Dict): + super().__init__(session, content) + self._secrets = secrets + + def _grab_tasks(self, content: str) -> Dict[str, str]: + _, interested = content.split(self.TASK_FIELD, maxsplit=2) + interested, _ = interested.split(self.COMMENT_FIELD, maxsplit=2) + options = re.findall(r'', interested) + return {label: value for value, label in options} + + def _grab_focals(self, content: str) -> Dict[str, str]: + _, interested = content.split(self.FOCAL_FIELD, maxsplit=2) + options = re.findall(r'', interested) + return {label: value for value, label in options} + + def set_task_category(self, category: str): + value = self._option_value(self.CATEGORY_FIELD, category) + args = { + "ctl00$ContentPlaceHolder$ScriptManager": f"ctl00$ContentPlaceHolder$UpdatePanel1|{self.CATEGORY_FIELD}", + self.DATE_FIELD: date.today().strftime(r"%d/%m/%Y"), + self.HOURS_FIELD: "", + self.CATEGORY_FIELD: value, + self.TASK_FIELD: "", + self.COMMENT_FIELD: "", + self.FOCAL_FIELD: "", + **self._secrets, + "__ASYNCPOST": "true", + } + response = self._session.post(f"{BASE_URL}/TimeTrackerAdd.aspx", data=args) + response.raise_for_status() + secrets = { + name: self._grab_secret(response.content.decode(), name) + for name in [ + "__EVENTTARGET", + "__EVENTARGUMENT", + "__LASTFOCUS", + "__VIEWSTATE", + "__VIEWSTATEGENERATOR", + "__EVENTVALIDATION", + ] + } + return ReadyToLoad( + self._session, + self._content, + {**self._secrets, **secrets, self.CATEGORY_FIELD: value}, + self._grab_tasks(response.content.decode()), + self._grab_focals(response.content.decode()), + ) + + +class ReadyToLoad(WithSecrets): + def __init__( + self, session: Session, content: str, secrets: Dict, tasks: Dict, focals: Dict + ): + super().__init__(session, content, secrets) + self._secrets = secrets + self._tasks = tasks + self._focals = focals + + def load( + self, + *, + date: str, + task: str, + hours: str, + comment: str, + focal: str, + ): + logger.info("loading...") + parsed_date = parse( + date, + date_formats=[r"%d/%m/%Y"], + settings={"TIMEZONE": get_localzone().zone}, + ) + logger.info("date: %s", parsed_date) + if not parsed_date: + raise ValueError(f'"{date}" is an invalid date literal') + if task not in self._tasks: + raise ValueError(f'"{task}" not found in: {list(self._tasks.keys())}') + if focal not in self._focals: + raise ValueError(f'"{focal}" not found in: {list(self._focals.keys())}') + parsed_hours = float(hours) + args = { + self.DATE_FIELD: parsed_date.strftime(r"%d/%m/%Y"), + self.HOURS_FIELD: f"{parsed_hours:0.2f}", + self.TASK_FIELD: self._tasks[task], + self.COMMENT_FIELD: comment, + self.FOCAL_FIELD: self._focals[focal], + "ctl00$ContentPlaceHolder$btnAceptar": "Accept", + **self._secrets, + } + response = self._session.post(f"{BASE_URL}/TimeTrackerAdd.aspx", data=args) + response.raise_for_status() + return self + + +class ListPage(Page): + def new_loader(self): + response = self._session.get(f"{BASE_URL}/TimeTrackerAdd.aspx") + response.raise_for_status() + return NewTimeForm(self._session, response.content.decode()) + + def ready( + self, + *, + project, + category, + ): + return self.new_loader().set_project(project).set_task_category(category) + + +class LoginPage(Page): + @classmethod + def visit(cls, session: Session): + content = session.get(BASE_URL).content.decode() + return cls(session, content) + + def login(self, username, password): + args = { + "ctl00$ContentPlaceHolder$UserNameTextBox": username, + "ctl00$ContentPlaceHolder$PasswordTextBox": password, + "ctl00$ContentPlaceHolder$LoginButton": "Login", + "__VIEWSTATE": self._hidden_value("__VIEWSTATE"), + "__VIEWSTATEGENERATOR": self._hidden_value("__VIEWSTATEGENERATOR"), + "__EVENTVALIDATION": self._hidden_value("__EVENTVALIDATION"), + } + response = self._session.post(BASE_URL, data=args) + response.raise_for_status() + return ListPage(self._session, response.content.decode()) + + +class TimeTrackerPage: + @staticmethod + def start() -> LoginPage: + return LoginPage.visit(LoginPage.session()) diff --git a/timetracker/timetracker.py b/timetracker/timetracker.py index 7b78d72..e4b65a4 100644 --- a/timetracker/timetracker.py +++ b/timetracker/timetracker.py @@ -1,6 +1,4 @@ import csv -import re -from copy import copy from datetime import datetime import click @@ -9,13 +7,16 @@ import toml from beautifultable import BeautifulTable from bs4 import BeautifulSoup +from .pages import TimeTrackerPage from .constants import ( - PROJECT_DROPDOWN, FOCAL_DROPDOWN, ASSIGNMENT_DROPDOWN, LOGIN_CREDENTIALS, LOAD_HOURS_OPTIONS, WEEKDAYS, BASE_URL + LOGIN_CREDENTIALS, LOAD_HOURS_OPTIONS, WEEKDAYS, BASE_URL, ) from .utils import parse_date requests.packages.urllib3.disable_warnings() +ADD_TIME_FORM_URL = '{}/TimeTrackerAdd.aspx' + def prepare_session(session): """ @@ -27,7 +28,7 @@ def prepare_session(session): 'Upgrade-Insecure-Requests': '1', 'User-Agent': 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_13_3) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/64.0.3282.186 Safari/537.36', 'Origin': 'http://timetracker.bairesdev.com', - 'Referer': 'http://timetracker.bairesdev.com/' + 'Referer': 'https://timetracker.bairesdev.com/TimeTrackerAdd.aspx', }) @@ -56,33 +57,6 @@ def login(session, credentials): return BeautifulSoup(res.content, 'html.parser') -def load_time_form(session): - """ - Go to the load time form. - """ - load_time_url = '{}/CargaTimeTracker.aspx'.format(BASE_URL) - content = session.get(load_time_url).content - return BeautifulSoup(content, 'html.parser') - - -def validate_option(form, value, name, _id): - """ - Validates that you can actually use the configured project. - """ - options = form.find('select', {'id': _id}).findAll('option') - options_available = { - opt.text: opt.get('value') - for opt in options - if opt.text - } - if not options_available.get(value): - names = ', '.join('"{}"'.format(p) for p in options_available.keys()) - raise click.BadParameter( - '{} "{}" is not available. Choose from: {}'.format(name.capitalize(), value, names) - ) - return options_available.get(value) - - def fetch_hours(session, form, start, end): """ Fetches list of loaded hours @@ -102,60 +76,15 @@ def fetch_hours(session, form, start, end): return BeautifulSoup(content, 'html.parser') -def set_project(session, form, project_option): - """ - Sets the project into the session so that assignments and focal points become available. - """ - load_time_url = '{}/CargaTimeTracker.aspx'.format(BASE_URL) - load_assigments_args = { - 'ctl00$ContentPlaceHolder$ScriptManager': 'ctl00$ContentPlaceHolder$UpdatePanel1|ctl00$ContentPlaceHolder$idProyectoDropDownList', - '__VIEWSTATE': form.find('input', {'name': '__VIEWSTATE'}).get('value'), - '__VIEWSTATEGENERATOR': form.find('input', {'name': '__VIEWSTATEGENERATOR'}).get('value'), - '__EVENTVALIDATION': form.find('input', {'name': '__EVENTVALIDATION'}).get('value'), - 'ctl00$ContentPlaceHolder$txtFrom': parse_date('today').strftime(r'%d/%m/%Y'), - 'ctl00$ContentPlaceHolder$idProyectoDropDownList': project_option, - 'ctl00$ContentPlaceHolder$DescripcionTextBox': '', - 'ctl00$ContentPlaceHolder$TiempoTextBox': '', - 'ctl00$ContentPlaceHolder$idTipoAsignacionDropDownList': '', - 'ctl00$ContentPlaceHolder$idFocalPointClientDropDownList': '', - '__ASYNCPOST': 'true' - } - content = session.post(load_time_url, data=load_assigments_args).content - - _eventtarget = re.search( - r'hiddenField\|__EVENTTARGET\|([\w*/*\+*=*]*)', str(content)).groups()[0] - _eventargument = re.search( - r'hiddenField\|__EVENTARGUMENT\|([\w*/*\+*=*]*)', str(content)).groups()[0] - _lastfocus = re.search( - r'hiddenField\|__LASTFOCUS\|([\w*/*\+*]*=*)', str(content)).groups()[0] - _viewstate = re.search( - r'hiddenField\|__VIEWSTATE\|([\w*/*\+*]*=*)', str(content)).groups()[0] - _viewstategenerator = re.search( - r'hiddenField\|__VIEWSTATEGENERATOR\|([\w*/*\+*=*]*)', str(content)).groups()[0] - _eventvalidation = re.search( - r'hiddenField\|__EVENTVALIDATION\|([\w*/*\+*]*=*)', str(content)).groups()[0] - secrets = { - '__EVENTTARGET': _eventtarget, - '__EVENTARGUMENT': _eventargument, - '__LASTFOCUS': _lastfocus, - '__VIEWSTATE': _viewstate, - '__VIEWSTATEGENERATOR': _viewstategenerator, - '__EVENTVALIDATION': _eventvalidation, - - } - - return secrets, BeautifulSoup(content, 'html.parser') - - def hours_as_table(content, current_month, full, show_weekday): """ Validates that you can actually use the configured project. """ table = BeautifulTable() if full: - column_headers = ["Date", "Hours", "Project", "Assigment Type", "Description"] + column_headers = ["Date", "Hours", "Project", "Task Category", "Task", "Comments"] else: - column_headers = ["Date", "Description"] + column_headers = ["Date", "Comments"] if show_weekday: column_headers = ["Weekday"] + column_headers @@ -169,9 +98,9 @@ def hours_as_table(content, current_month, full, show_weekday): if cols: date = cols[0].string if not current_month else cols[0].string[:2] if full: - values = [date, cols[1].string, cols[2].string, cols[3].string, cols[4].string] + values = [date, cols[1].string, cols[2].string, cols[3].string, cols[4].string, cols[5].string] else: - values = [date, cols[4].string] + values = [date, cols[5].string] if show_weekday: weekday = datetime.strptime(cols[0].string, r'%d/%m/%Y').weekday() values = [WEEKDAYS[weekday]] + values @@ -184,30 +113,6 @@ def hours_as_table(content, current_month, full, show_weekday): return table -def actually_load(session, secrets, options): - load_time_url = '{}/CargaTimeTracker.aspx'.format(BASE_URL) - load_time_args = copy(secrets) - load_time_args.update({ - 'ctl00$ContentPlaceHolder$txtFrom': options['date'], - 'ctl00$ContentPlaceHolder$idProyectoDropDownList': options['project'], - 'ctl00$ContentPlaceHolder$DescripcionTextBox': options['text'], - 'ctl00$ContentPlaceHolder$TiempoTextBox': options['hours'], - 'ctl00$ContentPlaceHolder$idTipoAsignacionDropDownList': options['assignment'], - 'ctl00$ContentPlaceHolder$idFocalPointClientDropDownList': options.get('focal'), - 'ctl00$ContentPlaceHolder$btnAceptar': 'Accept' - }) - - res = session.post(load_time_url, data=load_time_args) - - if not ( - res.history - and res.history[0].status_code == 302 - and res.status_code == 200 - and res.url == '{}/ListaTimeTracker.aspx'.format(BASE_URL) - ): - raise RuntimeError("There was a problem loading your timetracker :(") - - def check_required(name, available, required): for value in required: if value not in available: @@ -233,10 +138,10 @@ def load_hours(text, config, date, pto, vacations, hours): config = toml.load(config) credentials = config.get('credentials') options = config.get('options') - check_required('credentials', credentials, LOGIN_CREDENTIALS) check_required('options', options, LOAD_HOURS_OPTIONS) + if text is None and not pto and not vacations: raise click.BadParameter("You need to specify what you did with --text (-t)") @@ -246,63 +151,25 @@ def load_hours(text, config, date, pto, vacations, hours): if hours is None: raise click.BadParameter("You need to specify hours amount with --hours (-h) or using hours options in config.toml") - if pto: - options['project'] = 'BairesDev - Absence' - options['assignment'] = 'National Holiday' + options['task-category'] = 'Absence' + options['task-description'] = 'National Holiday' text = text if text is not None else 'PTO' if vacations: - options['project'] = 'BairesDev - Absence' - options['assignment'] = 'Vacations' + options['task-category'] = 'Absence' + options['task-description'] = 'Vacations' text = text if text is not None else 'Vacations' - - session = requests.Session() - prepare_session(session) - - try: - login(session, credentials) - except RuntimeError as e: - click.echo('{}'.format(e), err=True, color='red') - sys.exit(1) - - load_time_page = load_time_form(session) - project_option = validate_option( - load_time_page, - options.get('project'), - 'Project', - PROJECT_DROPDOWN + login_page = TimeTrackerPage.start() + list_page = login_page.login(credentials['username'], credentials['password']) + ready_page = list_page.ready(project=options['project'], category=options['task-category']) + ready_page.load( + date=date, + task=options['task-description'], + hours=hours, + comment=text, + focal=options['focal'] ) - secrets, load_assigments_page = set_project(session, load_time_page, project_option) - assignment_option = validate_option( - load_assigments_page, - options.get('assignment'), - 'Assignment', - ASSIGNMENT_DROPDOWN - ) - - data = { - 'project': project_option, - 'assignment': assignment_option, - 'text': text, - 'date': date, - 'hours': hours - } - - if not pto and not vacations: - focal_option = validate_option( - load_assigments_page, - options.get('focal'), - 'Focal', - FOCAL_DROPDOWN - ) - data['focal'] = focal_option - - try: - actually_load(session, secrets, data) - click.echo('success!') - except RuntimeError as e: - click.echo('{}'.format(e), err=True, color='red') - sys.exit(1) + click.echo('Success!') def show_hours(config, start, end, full, weekday):