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