Source code for trelloRadar

# -*- coding: utf-8 -*-


#   This program is free software: you can redistribute it and/or modify
#   it under the terms of the GNU General Public License as published by
#   the Free Software Foundation, either version 3 of the License, or
#   (at your option) any later version.

#   This program is distributed in the hope that it will be useful,
#   but WITHOUT ANY WARRANTY; without even the implied warranty of
#   MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
#   GNU General Public License for more details.

#   You should have received a copy of the GNU General Public License
#   along with this program.  If not, see <http://www.gnu.org/licenses/>.

#   Created on Thu Jan  4 17:12:14 2018

#   @author: Jacques Gaudin <jagaudin@gmail.com>


import requests
import configparser
import webbrowser

from pathlib import Path
from bs4 import BeautifulSoup
from datetime import datetime, timedelta

import tkinter as tk
from tkinter import ttk
from tkinter.colorchooser import askcolor
from PIL import Image, ImageTk

import clr
clr.AddReference('System.Threading')
clr.AddReference('System.Windows')
clr.AddReference('System.Windows.Forms')
import System.Windows.Forms as WinForms
from System.Threading import Thread, ThreadStart, ApartmentState
from System.Drawing import Size


[docs]class AuthDialog: """ A class to present an authorization dialog to the user. If `API_key` is not given an API key and token are looked for, otherwise only a token. :param API_key: an Trello API key, default `None` :returns: an `AuthDialog` object """ class FormBrowser(WinForms.Form): """ A class to implement a basic browser based on `Windows.Forms`. The browser is specifically tuned to retrieve the user's Trello API key and get a token. :param API_key: an Trello API key, default `None` :returns: a `FormBrowser` object """ token_url = ('https://trello.com/1/authorize?key={0}' '&name={1}&expiration={2}&response_type=token&scope={3}') name, expiry, scope = 'TrelloRadar', 'never', 'read,write' token_success_string = ('You have granted access to ' 'your Trello information.') login_url = 'https://trello.com/login' login_redirect_url = 'https://trello.com/' API_key_url = 'https://trello.com/app-key' api_key_success_string = 'Developer API Keys' def __init__(self, API_key): self.API_key = API_key self.token = '' if API_key: self.target_url = self.token_url.format( API_key, self.name, self.expiry, self.scope) else: self.target_url = self.login_url self.Text = 'Authorization' self.ClientSize = Size(800, 800) self.FormBorderStyle = WinForms.FormBorderStyle.FixedSingle self.MaximizeBox = False self.web_browser = WinForms.WebBrowser() self.web_browser.Dock = WinForms.DockStyle.Fill self.web_browser.ScriptErrorsSuppressed = True self.web_browser.IsWebBrowserContextMenuEnabled = False self.web_browser.WebBrowserShortcutsEnabled = False self.web_browser.DocumentCompleted += self.on_document_completed self.web_browser.DocumentCompleted += self.check_token if not API_key: self.web_browser.Navigated += self.on_navigated self.web_browser.DocumentCompleted += self.check_API_key self.web_browser.Visible = True self.web_browser.Navigate(self.target_url) self.Controls.Add(self.web_browser) def on_navigated(self, sender, args): """ Signal handler to redirect to the API key URL on successful login """ self.web_browser.Visible = True # redirect main user page on successful login if str(self.web_browser.Url) == self.login_redirect_url: self.web_browser.Stop() self.web_browser.Navigate(self.API_key_url) def on_document_completed(self, sender, args): """ Signal handler to parse the html content of the page """ content = self.web_browser.DocumentText self.soup = BeautifulSoup(content, 'html.parser') def check_API_key(self, sender, args): """ Signal handler to retrieve API key from html content """ try: if self.api_key_success_string in self.soup.find('h1').string: self.web_browser.Visible = False self.API_key = self.soup.find('input', id='key')['value'] self.target_url = self.token_url.format( self.API_key, self.name, self.expiry, self.scope) self.web_browser.Navigate(self.target_url) except: pass def check_token(self, sender, args): """ Signal handler to retrieve token from html content """ try: if self.token_success_string in self.soup.p.string: self.token = self.soup.find('pre').string.strip() self.Close() except: pass def __init__(self, API_key=''): def start(): self.browser = AuthDialog.FormBrowser(API_key) WinForms.Application.Run(self.browser) # Create a new thread to run the process thread = Thread(ThreadStart(start)) thread.SetApartmentState(ApartmentState.STA) thread.Start() thread.Join() @property def API_key(self): return self.browser.API_key @property def token(self): return self.browser.token
[docs]class TrelloRadarApp(): """ The main application class :returns: a `TrelloRadarApp` object """ config_path = (Path.home() / 'AppData' / 'Local' / 'TrelloRadar' / 'settings.ini') search_strings = ['@me'] sort_string = 'board list' window_geom = { 'posx': '0', 'posy': '0', 'width': '540', 'height': '700', } time_f = '%Y-%m-%dT%H:%M:%S.%fZ' def __init__(self): self.boards_by_id = {} self.boards_by_name = {} self.today = datetime.today().date() self.get_config() self.setup_gui() self.send_querystring() self.root.mainloop()
[docs] def get_config(self): """ Reads the config file :returns: `None` """ if not self.config_path.exists(): self.config_path.parent.mkdir(parents=True, exist_ok=True) self.config_path.touch() self.config = configparser.ConfigParser() self.config.read([self.config_path]) # Read 'auth' section if (self.config.has_section('auth') and self.config.has_option('auth', 'API key')): self.API_key = self.config['auth']['API key'] if self.config.has_option('auth', 'token'): self.token = self.config['auth']['token'] self.validate_credentials() else: self.get_token() else: self.get_API_key() # Read 'search' section if self.config.has_option('search', 'search strings'): search_strings = self.config['search']['search strings'] self.search_strings = search_strings.split(';') else: self.config['search'] = {} self.config['search']['search strings'] = self.search_strings[0] # Read 'sort' section if self.config.has_option('sort', 'sort string'): self.sort_string = self.config['sort']['sort string'] else: self.config['sort'] = {} self.config['sort']['sort string'] = self.sort_string # Read the 'window' section if self.config.has_section('window'): if all(self.config.has_option('window', prop) for prop in ['posx', 'posy', 'width', 'height']): self.window_geom = self.config['window']
[docs] def get_API_key(self): """ Launches an `AuthDialog` instance to retrieve the API key and token :returns: `None` """ auth_dialog = AuthDialog() self.API_key = auth_dialog.API_key self.token = auth_dialog.token self.config['auth'] = {} self.config['auth']['API key'] = self.API_key self.config['auth']['token'] = self.token self.save_config()
[docs] def get_token(self): """ Launches an `AuthDialog` instance to retrieve a token :returns: `None` """ auth_dialog = AuthDialog(self.API_key) self.token = auth_dialog.token self.config['auth']['token'] = self.token self.save_config()
[docs] def validate_credentials(self): """ Attempts a connection to Trello with the credentials present. If connection is refused, gets new credentials. :returns: `None` """ url = 'https://api.trello.com/1/members/me/' token_query = { 'key': self.API_key, 'token': self.token, } response = requests.get(url, params=token_query) if response.status_code == 200: return elif len(self.API_key) == 32 and 'invalid token' in response.text: self.get_token() else: self.get_API_key()
def record_open_item(self, item=None): for subitem in self.todo_tree.get_children(item): if self.todo_tree.get_children(subitem): self.is_item_open[subitem] = self.todo_tree.item(subitem)['open'] self.record_open_item(subitem)
[docs] def show_data(self, query_string, sorting): """ Shows the cards matching `query_string` in a tree view. :parameter query_string: a search query :returns: `None` """ self.icons = {} self.is_item_open = {} self.record_open_item() self.todo_tree.delete(*self.todo_tree.get_children()) cards = self.search_cards(query_string) cards = sorted( cards, key=lambda c: tuple(c[s]['name'] for s in sorting)) for c in cards: card_insert = '' if len(sorting): categories = { 'board': c['board']['url'], 'list': c['list']['name'], } cat_1 = categories[sorting[0]] card_insert = cat_1 if not self.todo_tree.exists(cat_1): cat_1_name = c[sorting[0]]['name'] self.todo_tree.insert('', 'end', cat_1, text=cat_1_name, tags='cat1name') is_open = self.is_item_open.get(cat_1, True) self.todo_tree.item(cat_1, open=is_open) if len(sorting) > 1: cat_2 = '|'.join(categories[s] for s in sorting) card_insert = cat_2 if not self.todo_tree.exists(cat_2): cat_2_name = c[sorting[1]]['name'] self.todo_tree.insert(cat_1, 'end', cat_2, text=cat_2_name, tags='cat2name') is_open = self.is_item_open.get(cat_2, True) self.todo_tree.item(cat_2, open=is_open) if c['due']: due_date = datetime.strptime(c['due'], self.time_f).date() if c['dueComplete']: tags = ('complete',) elif self.today > due_date: tags = ('overdue',) elif self.today == due_date: tags = ('duetoday',) elif due_date - self.today < timedelta(days=7): tags = ('duethisweek',) else: tags = () else: due_date = '' tags = () if (not tags and c['badges']['checkItems'] and c['badges']['checkItems'] == c['badges']['checkItemsChecked']): tags = tags + ('100%',) img = self.colors['no-color'] for label in c['labels']: if label['color'] in self.colors.keys(): img = Image.alpha_composite(img, self.colors[label['color']]) self.icons[c['id']] = ImageTk.PhotoImage(img) self.todo_tree.insert(card_insert, 'end', 'card|' + c['url'], text=c['name'], image=self.icons[c['id']], values=(due_date), tags=tags)
[docs] def search_cards(self, query_string, cards_limit='1000'): """ Search Trello for cards matching `query_string`. :parameter query_string: a search query :parameter cards_limit: max. number of cards returned (default `1000`) :returns: a list of the cards found """ search_url = 'https://api.trello.com/1/search' search_query = { 'key': self.API_key, 'token': self.token, 'modelTypes': 'cards', 'card_list': 'true', 'card_board': 'true', 'board_fields': 'name,url', 'query': query_string, 'cards_limit': cards_limit, } response = requests.get(search_url, params=search_query) return response.json()['cards'] if response.status_code == 200 else []
[docs] def send_querystring(self): """ Records the search query and shows results :returns: `None` """ query_string = self.entry.get() sorting = self.sorting.get().split() if not query_string: return if query_string not in self.entry['values']: self.entry['values'] = (query_string,) + self.entry['values'] self.show_data(query_string, sorting)
def back_to_cards(self, *args): self.notebook.select(tab_id='.main.main') def on_tree_button(self, *args): labels = self.todo_tree.identify_row(args[0].y).split('|') # Filter out double click on boards and lists if args[0].num == 1 and labels[0] != 'card': return self.link_tree(labels) def on_tree_return(self, *args): labels = self.todo_tree.focus().split('|') self.link_tree(labels) # Stop the event propoagation to avoid closing container return "break" def on_tree_focus(self, *args): if not self.todo_tree.focus(): self.todo_tree.focus(self.todo_tree.get_children()[0]) def on_refresh_event(self, *args): self.send_querystring() def on_bgcolor_event(self, *args): print(args) color = askcolor() self.style.configure("Treeview", background=color[1], fieldbackground=color[1]) def on_closing(self, *args): config_search_strings = ';'.join(s for s in self.entry['values']) self.config['search']['search strings'] = config_search_strings self.config['sort']['sort string'] = self.sorting.get() self.config['window'] = { 'posx': str(self.root.winfo_x()), 'posy': str(self.root.winfo_y()), 'width': str(self.root.winfo_width()), 'height': str(self.root.winfo_height()), } self.save_config() self.root.destroy() def save_config(self): self.config.write(self.config_path.open('w'))
[docs] def setup_gui(self): """ Prepares the GUI :returns: `None` """ self.root = tk.Tk() self.root.protocol('WM_DELETE_WINDOW', self.on_closing) try: self.root.iconbitmap(default='icons/transparent.ico') except: print('Icon file not found') colors = ['blue', 'purple', 'red', 'orange', 'yellow', 'green', 'no-color'] self.colors = { col: Image.open('icons/{0}.png'.format(col)) .convert('RGBA') .resize((12,12), Image.ANTIALIAS) for col in colors } self.root.geometry('{0}x{1}+{2}+{3}'.format( self.window_geom['width'], self.window_geom['height'], self.window_geom['posx'], self.window_geom['posy'], )) self.root.title('Trello Radar') self.style = ttk.Style(self.root) self.style.configure("Treeview", background="light grey", fieldbackground="light grey", foreground="black") self.notebook = ttk.Notebook(self.root, name='main') self.notebook.pack(expand=True, fill='both', padx=3, pady=3) self.mainframe = ttk.Frame(self.notebook, name='main') self.todo_tree = ttk.Treeview(self.mainframe, columns=('Due Date',)) self.todo_tree.pack(expand=True, fill='both') self.todo_tree.heading('#0', text='Task') self.todo_tree.column('#0', minwidth=200, width=450, stretch=True) self.todo_tree.heading('Due Date', text='Due Date') self.todo_tree.column('Due Date', minwidth=50, width=70, stretch=False) self.todo_tree.tag_configure('cat1name', foreground='dodger blue') self.todo_tree.tag_configure('cat2name', foreground='navy') self.todo_tree.tag_configure('overdue', foreground='red') self.todo_tree.tag_configure('complete', foreground='green') self.todo_tree.tag_configure('100%', background='honeydew') self.todo_tree.tag_configure('duetoday', background='PaleTurquoise1') self.todo_tree.tag_configure('duethisweek', background='azure') self.todo_tree.bind('<Double-1>', self.on_tree_button) self.todo_tree.bind('<Button-3>', self.on_tree_button) self.todo_tree.bind('<Return>', self.on_tree_return) self.todo_tree.bind('<FocusIn>', self.on_tree_focus) self.entry = ttk.Combobox(self.mainframe, values=self.search_strings) self.entry.insert(0, self.search_strings[0]) self.entry.pack(side='left', expand=True, fill='x') self.entry.bind('<Return>', self.on_refresh_event) self.clear_button = ttk.Button(self.mainframe, text='Clear search', command=self.clear_search) self.clear_button.pack(side='right') self.clear_button.bind('<Return>', self.clear_search) self.refresh_button = ttk.Button(self.mainframe, text='Refresh', command=self.on_refresh_event) self.refresh_button.pack(side='right') self.refresh_button.bind('<Return>', self.on_refresh_event) # Adjust tab order self.refresh_button.lower(belowThis=self.clear_button) self.notebook.add(self.mainframe, text='Cards') self.option_frame = ttk.Frame(self.notebook, name='options') self.sorting_frame = ttk.Labelframe( self.option_frame, text="Sorting order") self.sorting_frame.pack(side='top', fill='x', padx=3, pady=3) self.sorting_options = [ ('Board > List', 'board list'), ('Board', 'board'), ('List > Board', 'list board'), ('List', 'list'), ('None', ''), ] self.sorting = tk.StringVar() self.sorting.trace('w', self.on_refresh_event) self.sorting_buttons = [] for text, value in self.sorting_options: radiobutton = ttk.Radiobutton(self.sorting_frame, text=text, var=self.sorting, value=value) self.sorting_buttons.append(radiobutton) self.sorting_buttons[-1].pack(side='top', fill='x', padx=10) self.sorting.set(self.sort_string) self.color_frame = ttk.Labelframe(self.option_frame, text="Colors") self.color_frame.pack(side='top', fill='x', padx=3,pady=3) self.bgcolor_label = ttk.Label(self.color_frame, text="Background color:") self.bgcolor_label.pack(side='left', expand='True', fill='x', padx=10) self.bgcolor_button = ttk.Button(self.color_frame, text="Choose color", command=self.on_bgcolor_event) self.bgcolor_button.pack(side='right') self.bgcolor_button.bind('<Return>', self.on_bgcolor_event) self.back_button = ttk.Button(self.option_frame, text='Back to cards', command=self.back_to_cards) self.back_button.pack(side='bottom') self.back_button.bind('<Return>', self.back_to_cards) self.notebook.add(self.option_frame, text='Options')
if __name__ == '__main__': trello_todo_app = TrelloRadarApp()