aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorCody Hiar <codyfh@gmail.com>2018-03-03 13:49:35 -0700
committerCody Hiar <codyfh@gmail.com>2018-03-03 13:49:35 -0700
commit6ffecd93bc287d3654474fc6c834118c90c5cdc6 (patch)
treeb7ec974bb1eb252cd4a3e9ab18c3efa52bc8222f
Initial Commit
-rw-r--r--.gitignore4
-rw-r--r--Pipfile19
-rw-r--r--Pipfile.lock187
-rw-r--r--README.md58
-rw-r--r--__init__.py0
-rwxr-xr-xcli.py252
-rw-r--r--example-config.json7
-rw-r--r--pyactivecollab.py97
-rw-r--r--requirements.txt4
9 files changed, 628 insertions, 0 deletions
diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..f362d03
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,4 @@
+.venv
+.mypy_cache
+__pycache__
+config.json
diff --git a/Pipfile b/Pipfile
new file mode 100644
index 0000000..291a948
--- /dev/null
+++ b/Pipfile
@@ -0,0 +1,19 @@
+[[source]]
+
+url = "https://pypi.python.org/simple"
+verify_ssl = true
+name = "pypi"
+
+
+[packages]
+
+pyac = "*"
+requests = "*"
+ipython = "*"
+fuzzyfinder = "*"
+prompt-toolkit = "*"
+blessings = "*"
+
+
+[dev-packages]
+
diff --git a/Pipfile.lock b/Pipfile.lock
new file mode 100644
index 0000000..9d99433
--- /dev/null
+++ b/Pipfile.lock
@@ -0,0 +1,187 @@
+{
+ "_meta": {
+ "hash": {
+ "sha256": "fabe4780a4272f38ceb0b2e28a347637c2e07bd9b3554666bf9ccc63a38a964c"
+ },
+ "host-environment-markers": {
+ "implementation_name": "cpython",
+ "implementation_version": "3.6.2",
+ "os_name": "posix",
+ "platform_machine": "x86_64",
+ "platform_python_implementation": "CPython",
+ "platform_release": "4.14.17-x86_64-linode99",
+ "platform_system": "Linux",
+ "platform_version": "#1 SMP Tue Feb 6 19:09:58 UTC 2018",
+ "python_full_version": "3.6.2",
+ "python_version": "3.6",
+ "sys_platform": "linux"
+ },
+ "pipfile-spec": 6,
+ "requires": {},
+ "sources": [
+ {
+ "name": "pypi",
+ "url": "https://pypi.python.org/simple",
+ "verify_ssl": true
+ }
+ ]
+ },
+ "default": {
+ "blessings": {
+ "hashes": [
+ "sha256:26dbaf2f89c3e6dee11c10f7c0b85756ed75cf602b1bb7935b4efd8ed67a000f",
+ "sha256:466e43ff45723b272311de0437649df464b33b4daba7a54c69493212958e19c7",
+ "sha256:74919575885552e14bc24a68f8b539690bd1b5629180faa830b1a25b8c7fb6ea"
+ ],
+ "version": "==1.6.1"
+ },
+ "certifi": {
+ "hashes": [
+ "sha256:14131608ad2fd56836d33a71ee60fa1c82bc9d2c8d98b7bdbc631fe1b3cd1296",
+ "sha256:edbc3f203427eef571f79a7692bb160a2b0f7ccaa31953e99bd17e307cf63f7d"
+ ],
+ "version": "==2018.1.18"
+ },
+ "chardet": {
+ "hashes": [
+ "sha256:fc323ffcaeaed0e0a02bf4d117757b98aed530d9ed4531e3e15460124c106691",
+ "sha256:84ab92ed1c4d4f16916e05906b6b75a6c0fb5db821cc65e70cbd64a3e2a5eaae"
+ ],
+ "version": "==3.0.4"
+ },
+ "decorator": {
+ "hashes": [
+ "sha256:94d1d8905f5010d74bbbd86c30471255661a14187c45f8d7f3e5aa8540fdb2e5",
+ "sha256:7d46dd9f3ea1cf5f06ee0e4e1277ae618cf48dfb10ada7c8427cd46c42702a0e"
+ ],
+ "version": "==4.2.1"
+ },
+ "fuzzyfinder": {
+ "hashes": [
+ "sha256:aa8223e31bd12fefd3f94c3c46c60b64692594efaa05f5d93e24353e57d57572",
+ "sha256:c56d86f110866becad6690c7518f7036c20831c0f82fc87eba8fdb943132f04b"
+ ],
+ "version": "==2.1.0"
+ },
+ "idna": {
+ "hashes": [
+ "sha256:8c7309c718f94b3a625cb648ace320157ad16ff131ae0af362c9f21b80ef6ec4",
+ "sha256:2c6a5de3089009e3da7c5dde64a141dbc8551d5b7f6cf4ed7c2568d0cc520a8f"
+ ],
+ "version": "==2.6"
+ },
+ "ipython": {
+ "hashes": [
+ "sha256:fcc6d46f08c3c4de7b15ae1c426e15be1b7932bcda9d83ce1a4304e8c1129df3",
+ "sha256:51c158a6c8b899898d1c91c6b51a34110196815cc905f9be0fa5878e19355608"
+ ],
+ "version": "==6.2.1"
+ },
+ "ipython-genutils": {
+ "hashes": [
+ "sha256:72dd37233799e619666c9f639a9da83c34013a73e8bbc79a7a6348d93c61fab8",
+ "sha256:eb2e116e75ecef9d4d228fdc66af54269afa26ab4463042e33785b887c628ba8"
+ ],
+ "version": "==0.2.0"
+ },
+ "jedi": {
+ "hashes": [
+ "sha256:d795f2c2e659f5ea39a839e5230d70a0b045d0daee7ca2403568d8f348d0ad89",
+ "sha256:d6e799d04d1ade9459ed0f20de47c32f2285438956a677d083d3c98def59fa97"
+ ],
+ "version": "==0.11.1"
+ },
+ "parso": {
+ "hashes": [
+ "sha256:a7bb86fe0844304869d1c08e8bd0e52be931228483025c422917411ab82d628a",
+ "sha256:5815f3fe254e5665f3c5d6f54f086c2502035cb631a91341591b5a564203cffb"
+ ],
+ "version": "==0.1.1"
+ },
+ "pexpect": {
+ "hashes": [
+ "sha256:6ff881b07aff0cb8ec02055670443f784434395f90c3285d2ae470f921ade52a",
+ "sha256:67b85a1565968e3d5b5e7c9283caddc90c3947a2625bed1905be27bd5a03e47d"
+ ],
+ "markers": "sys_platform != 'win32'",
+ "version": "==4.4.0"
+ },
+ "pickleshare": {
+ "hashes": [
+ "sha256:c9a2541f25aeabc070f12f452e1f2a8eae2abd51e1cd19e8430402bdf4c1d8b5",
+ "sha256:84a9257227dfdd6fe1b4be1319096c20eb85ff1e82c7932f36efccfe1b09737b"
+ ],
+ "version": "==0.7.4"
+ },
+ "prompt-toolkit": {
+ "hashes": [
+ "sha256:3f473ae040ddaa52b52f97f6b4a493cfa9f5920c255a12dc56a7d34397a398a4",
+ "sha256:1df952620eccb399c53ebb359cc7d9a8d3a9538cb34c5a1344bdbeb29fbcc381",
+ "sha256:858588f1983ca497f1cf4ffde01d978a3ea02b01c8a26a8bbc5cd2e66d816917"
+ ],
+ "version": "==1.0.15"
+ },
+ "ptyprocess": {
+ "hashes": [
+ "sha256:e8c43b5eee76b2083a9badde89fd1bbce6c8942d1045146e100b7b5e014f4f1a",
+ "sha256:e64193f0047ad603b71f202332ab5527c5e52aa7c8b609704fc28c0dc20c4365"
+ ],
+ "version": "==0.5.2"
+ },
+ "pyac": {
+ "hashes": [
+ "sha256:f93681cdbbee25a60064a0e2744e62ac37116b4657b6751d948456bc27f89a3b"
+ ],
+ "version": "==0.1.5"
+ },
+ "pygments": {
+ "hashes": [
+ "sha256:78f3f434bcc5d6ee09020f92ba487f95ba50f1e3ef83ae96b9d5ffa1bab25c5d",
+ "sha256:dbae1046def0efb574852fab9e90209b23f556367b5a320c0bcb871c77c3e8cc"
+ ],
+ "version": "==2.2.0"
+ },
+ "requests": {
+ "hashes": [
+ "sha256:6a1b267aa90cac58ac3a765d067950e7dbbf75b1da07e895d1f594193a40a38b",
+ "sha256:9c443e7324ba5b85070c4a818ade28bfabedf16ea10206da1132edaa6dda237e"
+ ],
+ "version": "==2.18.4"
+ },
+ "simplegeneric": {
+ "hashes": [
+ "sha256:dc972e06094b9af5b855b3df4a646395e43d1c9d0d39ed345b7393560d0b9173"
+ ],
+ "version": "==0.8.1"
+ },
+ "six": {
+ "hashes": [
+ "sha256:832dc0e10feb1aa2c68dcc57dbb658f1c7e65b9b61af69048abc87a2db00a0eb",
+ "sha256:70e8a77beed4562e7f14fe23a786b54f6296e34344c23bc42f07b15018ff98e9"
+ ],
+ "version": "==1.11.0"
+ },
+ "traitlets": {
+ "hashes": [
+ "sha256:c6cb5e6f57c5a9bdaa40fa71ce7b4af30298fbab9ece9815b5d995ab6217c7d9",
+ "sha256:9c4bd2d267b7153df9152698efb1050a5d84982d3384a37b2c1f7723ba3e7835"
+ ],
+ "version": "==4.3.2"
+ },
+ "urllib3": {
+ "hashes": [
+ "sha256:06330f386d6e4b195fbfc736b297f58c5a892e4440e54d294d7004e3a9bbea1b",
+ "sha256:cc44da8e1145637334317feebd728bd869a35285b93cbb4cca2577da7e62db4f"
+ ],
+ "version": "==1.22"
+ },
+ "wcwidth": {
+ "hashes": [
+ "sha256:f4ebe71925af7b40a864553f761ed559b43544f8f71746c2d756c7fe788ade7c",
+ "sha256:3df37372226d6e63e1b1e1eda15c594bca98a22d33a23832a90998faa96bc65e"
+ ],
+ "version": "==0.1.7"
+ }
+ },
+ "develop": {}
+}
diff --git a/README.md b/README.md
new file mode 100644
index 0000000..bd30c0a
--- /dev/null
+++ b/README.md
@@ -0,0 +1,58 @@
+# Self Hosted Active Collab 5 CLI application.
+
+Cli app for working with the self hosted collab api. Very unstable and prone to
+breaking.
+
+https://developers.activecollab.com/api-documentation/v1/people/users/all.html
+
+# Getting Started
+
+Create a config file:
+
+```
+cp example-config.json config.json
+```
+
+Edit config settings. Leave password empty if you want to be asked for it every
+time you launch the cli. I'm not sure what client_name is for but it needs to
+be set to authenticate against the api. The client_vendor is probably the name
+of your company. Url is the full url of your active collab instance plus the
+endpoint of the api e.g `https://activecollab.example.com/api/v1`.
+
+# Features
+
+- Tab complete on almost every single field.
+- Fuzzy completion on almost every field.
+
+## Create a time Record
+
+- Value can accept standard '0:30' or '0.5' but also accepts an int of minutes.
+ E.g '15' or '120'
+- Summary can user Ctrl+x Ctrl+e to launch $EDITOR for editing the message
+
+## List daily records
+
+- Compute the daily total as well as billable/non billable hours
+
+## List weekly records
+
+- Compute the weekly total as well as billable/non billable hours
+
+# Using pyactivecollab.py for connecting to api
+
+Sample Script:
+
+```
+from pyactivecollab import Config, ActiveCollab
+import getpass
+
+# Load config, ensure password
+config = Config()
+config.load()
+if not config.password:
+ config.password = getpass.getpass()
+
+ac = ActiveCollab(config)
+ac.authenticate()
+print(ac.get_info())
+```
diff --git a/__init__.py b/__init__.py
new file mode 100644
index 0000000..e69de29
--- /dev/null
+++ b/__init__.py
diff --git a/cli.py b/cli.py
new file mode 100755
index 0000000..8b7afba
--- /dev/null
+++ b/cli.py
@@ -0,0 +1,252 @@
+#!/usr/bin/env python
+# -*- coding: utf-8 -*-
+"""CLI App."""
+import datetime
+import getpass
+import os
+
+from blessings import Terminal
+
+from fuzzyfinder import fuzzyfinder
+from prompt_toolkit import prompt
+from prompt_toolkit.completion import Completer, Completion
+from prompt_toolkit.shortcuts import confirm
+from pyactivecollab import ActiveCollab, Config
+
+t = Terminal()
+
+
+class FuzzyCompleter(Completer):
+ """Fuzzy Completer Alpha Sorted."""
+
+ def __init__(self, words):
+ """Initialize."""
+ self.words = words
+
+ def get_completions(self, document, complete_event):
+ """Use fuzzyfinder for completions."""
+ word_before_cursor = document.text_before_cursor
+ words = fuzzyfinder(word_before_cursor, self.words)
+ for x in words:
+ yield Completion(x, -len(word_before_cursor))
+
+
+class DateFuzzyCompleter(Completer):
+ """Fuzzy Completer For Dates."""
+
+ def get_completions(self, document, complete_event):
+ """Use fuzzyfinder for date completions.
+
+ The fuzzyfind auto sorts by alpha so this is to show dates relative to
+ the current date instead of by day of week.
+ """
+ base = datetime.datetime.today()
+ date_format = '%a, %Y-%m-%d'
+ date_list = [(base - datetime.timedelta(days=x)).strftime(date_format)
+ for x in range(0, 30)]
+ word_before_cursor = document.text_before_cursor
+ words = fuzzyfinder(word_before_cursor, date_list)
+
+ def sort_by_date(date_str: str):
+ return datetime.datetime.strptime(date_str, date_format)
+
+ # Re-sort by date rather than day name
+ words = sorted(words, key=sort_by_date, reverse=True)
+ for x in words:
+ yield Completion(x, -len(word_before_cursor))
+
+
+class WeekFuzzyCompleter(Completer):
+ """Fuzzy Completer For Weeks."""
+
+ def get_completions(self, document, complete_event):
+ """Use fuzzyfinder for week completions."""
+ def datetime_to_week_str(dt: datetime.datetime):
+ """Convert a datetime to weekstring.
+
+ datetime.datetime(2018, 2, 26, 0, 0) => '2018-02-26 to 2018-03-04'
+ """
+ if dt.weekday() != 0:
+ monday_dt = dt - datetime.timedelta(days=dt.weekday())
+ sunday_dt = monday_dt + datetime.timedelta(days=6)
+ return '{} to {}'.format(
+ monday_dt.strftime('%Y-%m-%d'), sunday_dt.strftime('%Y-%m-%d'))
+
+ base = datetime.datetime.today()
+ week_list = [datetime_to_week_str(base - datetime.timedelta(weeks=x))
+ for x in range(0, 5)]
+ word_before_cursor = document.text_before_cursor
+ words = fuzzyfinder(word_before_cursor, week_list)
+ words = sorted(words, reverse=True)
+ for x in words:
+ yield Completion(x, -len(word_before_cursor))
+
+
+def timestamp_to_datetime(json, fieldname):
+ """Convert field from timestamp to datetime for json.
+
+ Currenlty hardcoded to MST timezone
+ """
+ timestamp = json[fieldname]
+ mst_hours = datetime.timedelta(hours=7)
+ json[fieldname] = datetime.datetime.fromtimestamp(timestamp) + mst_hours
+ return json
+
+
+def create_time_record(ac):
+ """Super Innefficient calls to create a time record."""
+ # Get Project
+ projects = ac.get_projects()
+ suggestions = [x['name'] for x in projects]
+ completer = FuzzyCompleter(suggestions)
+ text = prompt('(Project)> ', completer=completer)
+ project = next(x for x in projects if x['name'] == text)
+ # Value
+ value = prompt('(Value)> ')
+ # If integer is passed then treat it as minutes
+ if ('.' not in value) and (':' not in value):
+ value = float(value) / 60
+ # Job Type
+ job_types = ac.get_job_types()
+ suggestions = [x['name'] for x in job_types]
+ completer = FuzzyCompleter(suggestions)
+ text = prompt('(Job Type)> ', completer=completer)
+ job_type = next(x for x in job_types if x['name'] == text)
+ # Date
+ completer = DateFuzzyCompleter()
+ text = prompt('(Date)> ', completer=completer)
+ choosen_date = datetime.datetime.strptime(text, '%a, %Y-%m-%d')
+ # Billable
+ billable_choices = {True: 1, False: 0}
+ billable = confirm('(Billable (y/n))> ')
+ billable = billable_choices[billable]
+ # Summary
+ summary = prompt('(Summary)> ', enable_open_in_editor=True)
+ # User
+ users = ac.get_users()
+ user = next(x for x in users if x['email'] == config.user)
+ data = {
+ 'value': value,
+ 'user_id': user['id'],
+ 'job_type_id': job_type['id'],
+ 'record_date': choosen_date.strftime('%Y-%m-%d'),
+ 'billable_status': billable,
+ 'summary': summary
+ }
+ url = '/projects/{}/time-records'.format(project['id'])
+ ac.post(url, data)
+
+
+def list_daily_time_records(ac):
+ """List current user's time entrys for a specific day."""
+ os.system('cal -3')
+ # Make sure that the input is valid. This should be broken out to
+ # encapsulate all auto-complete inputs
+ completer = DateFuzzyCompleter()
+ valid = False
+ while not valid:
+ try:
+ date_str = prompt('(Date)> ', completer=completer)
+ choosen_date = datetime.datetime.strptime(date_str, '%a, %Y-%m-%d')
+ except ValueError:
+ print('Bad input, try again.')
+ else:
+ valid = True
+ users = ac.get_users()
+ user = next(x for x in users if x['email'] == config.user)
+ r = ac.get_time_records(user['id'])
+ time_records = r['time_records']
+ time_records = [timestamp_to_datetime(x, 'record_date') for x in time_records]
+ daily_time_records = [x for x in time_records
+ if x['record_date'].date() == choosen_date.date()]
+ billable = 0
+ non_billable = 0
+ daily_hours = 0
+ for record in daily_time_records:
+ if record['billable_status']:
+ print(t.green('{:<6} {}'.format(record['value'], record['summary'][:60])))
+ billable += record['value']
+ else:
+ print(t.blue('{:<6} {}'.format(record['value'], record['summary'][:60])))
+ non_billable += record['value']
+ daily_hours += record['value']
+ print((t.yellow(str(daily_hours)) + ' ' +
+ t.green(str(billable)) + ' ' +
+ t.blue(str(non_billable))))
+
+
+def list_weekly_time_records(ac):
+ """List current user's time entrys for a specific week."""
+ os.system('cal -3')
+ # Make sure that the input is valid. This should be broken out to
+ # encapsulate all auto-complete inputs
+ completer = WeekFuzzyCompleter()
+ valid = False
+ while not valid:
+ try:
+ week_str = prompt('(Week)> ', completer=completer)
+ monday_dt = datetime.datetime.strptime(week_str.split()[0], '%Y-%m-%d')
+ sunday_dt = datetime.datetime.strptime(week_str.split()[2], '%Y-%m-%d')
+ except ValueError:
+ print('Bad input, try again.')
+ else:
+ valid = True
+ users = ac.get_users()
+ user = next(x for x in users if x['email'] == config.user)
+ r = ac.get_time_records(user['id'])
+ time_records = r['time_records']
+ time_records = [timestamp_to_datetime(x, 'record_date') for x in time_records]
+ weekly_time_records = [x for x in time_records if
+ x['record_date'].date() >= monday_dt.date() and
+ x['record_date'].date() <= sunday_dt.date()]
+ days = [(sunday_dt - datetime.timedelta(days=x)) for x in range(7)]
+ weekly_billable = 0
+ weekly_non_billable = 0
+ weekly_hours = 0
+ for day in days:
+ billable = 0
+ non_billable = 0
+ daily_hours = 0
+ if day.date() > datetime.datetime.now().date():
+ continue
+ daily_time_records = [x for x in weekly_time_records if
+ x['record_date'].date() == day.date()]
+ print(day)
+ for record in daily_time_records:
+ if record['billable_status']:
+ print(t.green('{:<6} {}'.format(record['value'], record['summary'][:60])))
+ billable += record['value']
+ weekly_billable += record['value']
+ else:
+ print(t.blue('{:<6} {}'.format(record['value'], record['summary'][:60])))
+ non_billable += record['value']
+ weekly_non_billable += record['value']
+ daily_hours += record['value']
+ weekly_hours += record['value']
+ print((t.yellow(str(daily_hours)) + ' ' +
+ t.green(str(billable)) + ' ' +
+ t.blue(str(non_billable))))
+ print('Weekly Hours')
+ print((t.yellow(str(weekly_hours)) + ' ' +
+ t.green(str(weekly_billable)) + ' ' +
+ t.blue(str(weekly_non_billable))))
+ print('Percent Billable: {:.2f}%'.format(100 - ((37.5-weekly_billable)/37.5*100)))
+
+
+# Load config, ensure password
+config = Config()
+config.load()
+if not config.password:
+ config.password = getpass.getpass()
+
+ac = ActiveCollab(config)
+ac.authenticate()
+actions = {
+ 'Create Time Record': create_time_record,
+ 'List Daily Time Records': list_daily_time_records,
+ 'List Weekly Time Records': list_weekly_time_records,
+}
+completer = FuzzyCompleter(actions.keys())
+while True:
+ action = prompt('(Action)> ', completer=completer)
+ actions[action](ac)
diff --git a/example-config.json b/example-config.json
new file mode 100644
index 0000000..a6398c6
--- /dev/null
+++ b/example-config.json
@@ -0,0 +1,7 @@
+{
+ "url": "https://activecollab.example.com/api/v1",
+ "user": "example@example.com",
+ "password": "secretpassword",
+ "client_name": "Cli App",
+ "client_vendor": "Company Name"
+}
diff --git a/pyactivecollab.py b/pyactivecollab.py
new file mode 100644
index 0000000..d8ae306
--- /dev/null
+++ b/pyactivecollab.py
@@ -0,0 +1,97 @@
+# -*- coding: utf-8 -*-
+"""File for connecting to Active Collab."""
+import json
+import os
+import urllib
+from typing import Dict
+
+import requests
+
+
+class AuthenticationException(Exception):
+ """Exception for authentication errors."""
+
+ pass
+
+
+class Config():
+ """Configuration for ActiveCollab Session."""
+
+ def __init__(self, filename: str='config.json') -> None:
+ """Initialize config."""
+ self.filename = filename
+ self.url = None
+ self.user = None
+ self.password = None
+ self.client_name = None
+ self.client_vendor = None
+
+ def load(self) -> None:
+ """Load the config file."""
+ with open(os.path.expanduser(self.filename), 'r') as f:
+ json_data = json.load(f)
+ self.__dict__.update(json_data)
+
+
+class ActiveCollab(object):
+ """Active Collab API."""
+
+ def __init__(self, config: Config) -> None:
+ """Initialize the class."""
+ self.config = config
+ self.session = requests.session()
+
+ def authenticate(self) -> None:
+ """Authenticate against the api."""
+ data = {
+ 'username': self.config.user,
+ 'password': self.config.password,
+ 'client_name': self.config.client_name,
+ 'client_vendor': self.config.client_vendor
+ }
+ auth_url = '{}/issue-token'.format(self.config.url)
+ r = self.session.post(auth_url, data=data)
+ try:
+ token = r.json()['token']
+ except json.decoder.JSONDecodeError:
+ raise AuthenticationException
+ else:
+ self.token = token
+
+ def get(self, url: str) -> Dict:
+ """Make a get call to the API."""
+ if not getattr(self, 'token'):
+ raise AuthenticationException
+ headers = {'X-Angie-AuthApiToken': self.token}
+ url = '{}{}'.format(self.config.url, url)
+ r = self.session.get(url, headers=headers)
+ return r.json()
+
+ def post(self, url: str, data: Dict) -> Dict:
+ """Make post call to the API."""
+ if not getattr(self, 'token'):
+ raise AuthenticationException
+ headers = {'X-Angie-AuthApiToken': self.token}
+ url = '{}{}'.format(self.config.url, url)
+ r = self.session.post(url, data=data, headers=headers)
+ return r.json()
+
+ def get_info(self) -> Dict:
+ """Get info about the system information."""
+ return self.get('/info')
+
+ def get_job_types(self) -> Dict:
+ """Get the available job types."""
+ return self.get('/job-types')
+
+ def get_projects(self) -> Dict:
+ """Get the projects."""
+ return self.get('/projects')
+
+ def get_users(self) -> Dict:
+ """Get the projects."""
+ return self.get('/users')
+
+ def get_time_records(self, user_id: str) -> Dict:
+ """Get the time records for a user."""
+ return self.get('/users/{}/time-records'.format(user_id))
diff --git a/requirements.txt b/requirements.txt
new file mode 100644
index 0000000..be697e7
--- /dev/null
+++ b/requirements.txt
@@ -0,0 +1,4 @@
+requests==2.18.4
+fuzzyfinder==2.1.0
+prompt-toolkit==1.0.15
+blessings==1.6.1