diff options
-rw-r--r-- | .gitignore | 4 | ||||
-rw-r--r-- | Pipfile | 19 | ||||
-rw-r--r-- | Pipfile.lock | 187 | ||||
-rw-r--r-- | README.md | 58 | ||||
-rw-r--r-- | __init__.py | 0 | ||||
-rwxr-xr-x | cli.py | 252 | ||||
-rw-r--r-- | example-config.json | 7 | ||||
-rw-r--r-- | pyactivecollab.py | 97 | ||||
-rw-r--r-- | requirements.txt | 4 |
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 @@ -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 @@ -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 |