From fb217ca2658c25f4df0a9b9b471ad85c608377a9 Mon Sep 17 00:00:00 2001 From: Johannes Löthberg Date: Thu, 2 Oct 2014 19:18:30 +0200 Subject: initial dump, sorta broken --- app/__init__.py | 8 ++++ app/models.py | 33 ++++++++++++++++ app/views.py | 110 +++++++++++++++++++++++++++++++++++++++++++++++++++ client/bug.py | 58 +++++++++++++++++++++++++++ client/bug_delete.py | 33 ++++++++++++++++ client/bug_edit.py | 51 ++++++++++++++++++++++++ client/bug_list.py | 56 ++++++++++++++++++++++++++ client/bug_open.py | 101 ++++++++++++++++++++++++++++++++++++++++++++++ client/bug_show.py | 55 ++++++++++++++++++++++++++ config.py | 6 +++ db_create.py | 12 ++++++ db_downgrade.py | 8 ++++ db_migrate.py | 17 ++++++++ db_upgrade.py | 7 ++++ run.py | 3 ++ 15 files changed, 558 insertions(+) create mode 100644 app/__init__.py create mode 100644 app/models.py create mode 100644 app/views.py create mode 100755 client/bug.py create mode 100755 client/bug_delete.py create mode 100755 client/bug_edit.py create mode 100755 client/bug_list.py create mode 100755 client/bug_open.py create mode 100755 client/bug_show.py create mode 100644 config.py create mode 100755 db_create.py create mode 100755 db_downgrade.py create mode 100755 db_migrate.py create mode 100755 db_upgrade.py create mode 100755 run.py diff --git a/app/__init__.py b/app/__init__.py new file mode 100644 index 0000000..f441218 --- /dev/null +++ b/app/__init__.py @@ -0,0 +1,8 @@ +from flask import Flask +from flask.ext.sqlalchemy import SQLAlchemy + +app = Flask(__name__) +app.config.from_object('config') +db = SQLAlchemy(app) + +from app import views, models diff --git a/app/models.py b/app/models.py new file mode 100644 index 0000000..4df6689 --- /dev/null +++ b/app/models.py @@ -0,0 +1,33 @@ +from app import db +import datetime + +class User(db.Model): + id = db.Column(db.Integer, primary_key=True) + nickname = db.Column(db.String(64), index=True, unique=True) + email = db.Column(db.String(120), index=True, unique=True) + opened = db.relationship('Ticket', backref='opened_by', lazy='dynamic', primaryjoin = 'Ticket.opened_by_user_id == User.id') + assigned = db.relationship('Ticket', backref='assigned_to', lazy='dynamic', primaryjoin = 'Ticket.assigned_to_user_id == User.id') + + + def __repr__(self): + return '' % (self.nickname) + +class Ticket(db.Model): + id = db.Column(db.Integer, primary_key=True) + + summary = db.Column(db.String(140), nullable=False) + body = db.Column(db.Text, nullable=False) + + opened_at = db.Column(db.DateTime, nullable=False) + updated_at = db.Column(db.DateTime) + + status = db.Column(db.String(64), nullable=False, default='open') + resolution = db.Column(db.String(64)) + reason = db.Column(db.String(140)) + deleted = db.Column(db.Boolean, default=False) + + opened_by_user_id = db.Column(db.Integer, db.ForeignKey('user.id'), nullable=False) + assigned_to_user_id = db.Column(db.Integer, db.ForeignKey('user.id')) + + def __repr__(self): + return '' % (self.id) diff --git a/app/views.py b/app/views.py new file mode 100644 index 0000000..2e6ba6f --- /dev/null +++ b/app/views.py @@ -0,0 +1,110 @@ +from flask import jsonify, abort, make_response, request, url_for +from datetime import datetime +from app import app, db, models + +def make_public_ticket(ticket): + new_ticket = ticket.copy() + new_ticket['uri'] = url_for('get_ticket', ticket_id=ticket['id'], _external=True) + return new_ticket + +def ticket_to_dict(ticket): + nt = {} + nt['id'] = ticket.id + + nt['deleted'] = ticket.deleted + + nt['summary'] = ticket.summary + nt['body'] = ticket.body + + nt['opened_at'] = ticket.opened_at.strftime('%Y-%m-%dT%H:%M:%S') + + if ticket.updated_at: + nt['updated_at'] = ticket.updated_at.strftime('%Y-%m-%dT%H:%M:%S') + else: + nt['updated_at'] = None + + nt['status'] = ticket.status + + nt['resolution'] = ticket.resolution + nt['reason'] = ticket.reason + + if ticket.opened_by: + nt['opened_by'] = { + 'id': ticket.opened_by.id, + 'nickname': ticket.opened_by.nickname, + 'email': ticket.opened_by.email, + } + else: + nt['opened_by'] = {'id': None, 'nickname': None, 'email': None} + + if ticket.assigned_to: + nt['assigned_to'] = { + 'id': ticket.assigned_to.id, + 'nickname': ticket.assigned_to.nickname, + 'email': ticket.assigned_to.email, + } + else: + nt['assigned_to'] = None + + return nt + +@app.route('/tbt/api/1.0/tickets', methods=['GET']) +def get_tickets(): + ts = models.Ticket.query.filter(models.Ticket.deleted != True).all() + tickets = map(ticket_to_dict, ts) + return jsonify({'tickets': list(map(make_public_ticket, tickets))}) + +@app.route('/tbt/api/1.0/ticket', methods=['POST']) +def create_ticket(): + if not request.json or not ('summary' and 'body' and 'user_nickname') in request.json: + abort(400) + + user = models.User.query.filter(models.User.nickname == request.json['user_nickname']).first() + + ticket = models.Ticket(summary=request.json['summary'], + body=request.json['body'], + opened_by=user, + opened_at=datetime.utcnow()) + db.session.add(ticket) + db.session.commit() + + td = ticket_to_dict(ticket) + + return jsonify({'ticket': make_public_ticket(td)}), 201 + +@app.route('/tbt/api/1.0/ticket/', methods=['GET']) +def get_ticket(ticket_id): + ticket = models.Ticket.query.get(ticket_id) + if not ticket: + abort(404) + + return jsonify({'ticket': make_public_ticket(ticket_to_dict(ticket))}) + +@app.route('/tbt/api/1.0/ticket/', methods=['PUT']) +def update_ticket(ticket_id): + ticket = next((t for t in tickets if t['id'] == ticket_id), None) + if not ticket: + abort(404) + if not request.json: + abort(400) + ticket['summary'] = request.json.get('summary', ticket['summary']) + ticket['body'] = request.json.get('body', ticket['body']) + ticket['status'] = request.json.get('status', ticket['status']) + ticket['resolution'] = request.json.get('resolution', ticket['resolution']) + ticket['reason'] = request.json.get('reason', ticket['reason']) + ticket['assigned-to'] = request.json.get('assigned-to', ticket['assigned-to']) + return jsonify({'ticket': make_public_ticket(ticket)}) + +@app.route('/tbt/api/1.0/ticket/', methods=['DELETE']) +def delete_ticket(ticket_id): + ticket = next((t for t in tickets if t['id'] == ticket_id), None) + if not ticket: + abort(404) + #tickets.remove(ticket) + ticket['deleted'] = True + return jsonify({'result': True}) + +@app.errorhandler(404) +def not_found(error): + return make_response(jsonify({'error': 'Not found'}), 404) + diff --git a/client/bug.py b/client/bug.py new file mode 100755 index 0000000..24a523c --- /dev/null +++ b/client/bug.py @@ -0,0 +1,58 @@ +#!../flask/bin/python +""" +usage: bug [options] [...] + +options: + -u, --uri ENDPOINT Project API endpoint + -h, --help Print this help text + -v, --version Print the client version + +commands: + open Open a new ticket + list List all open bugs in the project + show Show a specific ticket by ID + + [Not implemented yet:] + resolution Set the resolution status of a ticket + edit Edit a ticket + reopen Reopen a previously closed ticket + close Close a ticket + lock Lock a ticket, preventing new comments + label Apply a label to a ticket + +See 'bug help ' for more information on a specific command +""" +from importlib import import_module +from subprocess import call +from docopt import docopt + +commands = ['open', 'delete', 'show', 'list', 'edit'] +def main(): + + if args[''] in ['help', None]: + if not args['']: + print(__doc__.lstrip().rstrip()) + else: + if args[''][0] in commands: + bug_mod = import_module('bug_{}'.format(args[''][0])) + print(bug_mod.__doc__.lstrip().rstrip()) + else: + exit("'{}' is not a bug.py command. See 'bug help'.".format(args[''][0])) + elif args[''] in commands: + if not args['--uri']: + exit("URI missing") + + bug_mod = import_module('bug_{}'.format(args[''])) + argv = [args['']] + args[''] + arguments = args.copy() + arguments.update(docopt(bug_mod.__doc__, argv=argv)) + bug_mod.call(arguments) + else: + exit("'{}' is not a bug.py command. See 'bug help'.".format(args[''])) + +if __name__ == '__main__': + args = docopt(__doc__, + version='tbt client version 0.0.0.alpha', + options_first=True) + #print(args) + main() diff --git a/client/bug_delete.py b/client/bug_delete.py new file mode 100755 index 0000000..bcce88e --- /dev/null +++ b/client/bug_delete.py @@ -0,0 +1,33 @@ +#!../flask/bin/python +""" +usage: bug delete [options] + +If no arguments are given it will open your $EDITOR where the first line is +the summary following a newline and then the body of the report. Both are +required. + + -h, --help Print this help text + -i, --ticket-id ID of the ticket to delete +""" +from docopt import docopt +import json, requests + +if __name__ == '__main__': + print(docopt(__doc__)) + +def call(args): + print(args) + api_endpoint = args['--uri'] + '/api/1.0/ticket/' + + req = requests.delete(api_endpoint + args['']) + + res = json.loads(req.text) + + if req.status_code == 404: + print("Ticket with ID '{}' could not be deleted: {}".format(args[''], res['error'])) + elif req.status_code == 200: + print("Ticket with ID '{}' deleted successfully.".format(args[''])) + else: + exit("ALERT ALERT ALERT") + + #print("{} {}\n {}".format(t['id'], t['title'], t['uri'])) diff --git a/client/bug_edit.py b/client/bug_edit.py new file mode 100755 index 0000000..42eca80 --- /dev/null +++ b/client/bug_edit.py @@ -0,0 +1,51 @@ +#!../flask/bin/python +""" +usage: bug edit [options] ticket + bug edit [options] comment + +If both the -s and -b options are given, the summary and body of the ticket or +comment with the given ID will be updated to the specified strings. If -s is +given for a comment it is added to the top of the comment instead. + +If they are not given, your $EDITOR will be opened where the first line is the +summary following a newline and then the body of the ticket. Both are +required. + +options: + -h, --help Print this help text + -s, --summary STRING A short summary of the bug + -b, --body STRING The long description of the bug or the body of the + comment +""" +from docopt import docopt +import json, requests + +if __name__ == '__main__': + print(docopt(__doc__)) + +def call(args): + print(args) + api_endpoint = args['--uri'] + '/api/1.0/ticket' + + ticket = {} + if args['--summary']: + ticket['summary'] = args['--summary'] + else: + exit("Summary needed, no interactive edit yet") + + if args['--body']: + ticket['body'] = args['--body'] + else: + ticket['body'] = None + + headers = { + 'Content-Type': 'application/json', + 'Accept': 'text/plain' + } + payload = json.dumps(ticket) + + r = requests.post(api_endpoint, data=payload, headers=headers) + + t = json.loads(r.text).get('ticket') + + print("{} {}\n {}".format(t['id'], t['summary'], t['uri'])) diff --git a/client/bug_list.py b/client/bug_list.py new file mode 100755 index 0000000..6084414 --- /dev/null +++ b/client/bug_list.py @@ -0,0 +1,56 @@ +#!../flask/bin/python +""" +usage: bug list [options] + +If no arguments are given it will open your $EDITOR where the first line is +the summary following a newline and then the body of the report. Both are +required. + + -h, --help Print this help text +""" +from docopt import docopt +from textwrap import indent +from datetime import datetime +import json, requests + +if __name__ == '__main__': + print(docopt(__doc__)) + +def call(args): + print(args) + api_endpoint = args['--uri'] + '/api/1.0/tickets' + + r = requests.get(api_endpoint) + + tickets = json.loads(r.text).get('tickets') + + for ticket in tickets: + output = '[TBT#{}] '.format(ticket['id']) + + if 'deleted' in ticket and ticket['deleted'] == True: + output += '[DELETED] ' + + output += '[{}] '.format(ticket['status']) + + output += '{}\n'.format(ticket['summary']) + + if ticket['status'] != 'open': + output += 'Resolution: {}\n'.format(ticket['resolution']) + + if ticket['reason']: + output += 'Reason: {}\n'.format(ticket['reason']) + + output += 'Opened by: {} <{}>\n'.format(ticket['opened_by']['nickname'], ticket['opened_by']['email']) + output += 'Opened at: {} UTC\n'.format(datetime.strptime( ticket['opened_at'], "%Y-%m-%dT%H:%M:%S" )) + + if ticket['assigned_to']: + output += 'Assigned to: {} <{}>\n'.format(ticket['assigned_to']['nickname'], ticket['assigned_to']['email']) + else: + output += 'Assigned to: Unassigned\n' + + if ticket['updated_at']: + output += 'Updated at: {} UTC\n'.format(datetime.strptime(ticket['opened_at'], "%Y-%m-%dT%H:%M:%S" )) + + output += '\n' + indent('{}'.format(ticket['body']), ' ') + '\n' + + print(output) diff --git a/client/bug_open.py b/client/bug_open.py new file mode 100755 index 0000000..17a3e4f --- /dev/null +++ b/client/bug_open.py @@ -0,0 +1,101 @@ +#!../flask/bin/python +""" +usage: bug open [options] + +If no arguments are given it will open your $EDITOR where the first line is +the summary following a newline and then the body of the report. Both are +required. + + -h, --help Print this help text + -s, --summary STRING A short summary of the bug + -b, --body STRING The long description of the bug +""" +import os, re, tempfile, subprocess +from docopt import docopt +import json, requests +from bug_show import show_ticket + +if __name__ == '__main__': + print(docopt(__doc__)) + +def call(args): + print(args) + api_endpoint = args['--uri'] + '/api/1.0/ticket' + + if args['--summary']: + summary = args['--summary'] + else: + summary = '' + + if args['--body']: + body = args['--body'] + else: + body = '' + + if not(summary and body): + (summary, body) = editor_prompt(summary, body) + + ticket = { + 'summary': summary, + 'body': body, + 'user_nickname': 'demi' + } + + headers = { + 'Content-Type': 'application/json', + 'Accept': 'text/plain' + } + payload = json.dumps(ticket) + + r = requests.post(api_endpoint, data=payload, headers=headers) + + t = json.loads(r.text).get('ticket') + + print(t) + print(show_ticket(t)) + + +def editor_prompt(summary, body): + editor = os.environ.get('EDITOR','vim') + message='' + + if summary: + message += summary + + if body: + message += '\n\n' + body + + message += """ + +# Please enter the summary on a single line, followed +# by an empty line, then followed by the body of the +# ticket. +# +# Both the summary and body are required. If either of +# them are missing, or they aren't separated properly +# the submission will be aborted. +""" + + tmp = tempfile.NamedTemporaryFile() + tmp.write(message.encode("utf-8")) + tmp.flush() + + regx = re.compile('^(.+?)\n\n(.+)$', re.S) + + subprocess.call([editor, tmp.name]) + + tmp.seek(0) + data = tmp.read().decode("utf-8") + tmp.close() + + data = data[:-263] # Strip the commented out message + data = data.lstrip().rstrip() # Strip opening and ending whitespace + regmatch = regx.match(data) + + if len(regmatch.groups()) != 2: + exit("Error: summary and body not separated properly, aborting") + + summary = regmatch.group(1) + body = regmatch.group(2) + + return (summary, body) diff --git a/client/bug_show.py b/client/bug_show.py new file mode 100755 index 0000000..0c3c9b8 --- /dev/null +++ b/client/bug_show.py @@ -0,0 +1,55 @@ +#!../flask/bin/python +""" +usage: bug show [options] + + -h, --help Print this help text +""" +from docopt import docopt +from textwrap import indent +from datetime import datetime +import json, requests + +if __name__ == '__main__': + print(docopt(__doc__)) + +def call(args): + print(args) + api_endpoint = args['--uri'] + '/api/1.0/ticket/' + + r = requests.get(api_endpoint + args['']) + + ticket = json.loads(r.text).get('ticket') + print(ticket) + print(show_ticket(ticket)) + +def show_ticket(ticket): + output = '[TBT#{}] '.format(ticket['id']) + + if 'deleted' in ticket and ticket['deleted'] == True: + output += '[DELETED] ' + + output += '[{}] '.format(ticket['status']) + + output += '{}\n'.format(ticket['summary']) + + + if ticket['status'] != 'open': + output += 'Resolution: {}\n'.format(ticket['resolution']) + + if ticket['reason']: + output += 'Reason: {}\n'.format(ticket['reason']) + + output += 'Opened by: {} <{}>\n'.format(ticket['opened_by']['nickname'], ticket['opened_by']['email']) + output += 'Opened at: {} UTC\n'.format(datetime.strptime( ticket['opened_at'], "%Y-%m-%dT%H:%M:%S" )) + + if ticket['assigned_to']: + output += 'Assigned to: {} <{}>\n'.format(ticket['assigned_to']['nickname'], ticket['assigned_to']['email']) + else: + output += 'Assigned to: Unassigned\n' + + if ticket['updated_at']: + output += 'Updated at: {} UTC\n'.format(datetime.strptime(ticket['opened_at'], "%Y-%m-%dT%H:%M:%S" )) + + output += '\n' + indent('{}'.format(ticket['body']), ' ') + '\n' + + return output diff --git a/config.py b/config.py new file mode 100644 index 0000000..7160e5d --- /dev/null +++ b/config.py @@ -0,0 +1,6 @@ +import os + +basedir = os.path.abspath(os.path.dirname(__file__)) + +SQLALCHEMY_DATABASE_URI = 'sqlite:///' + os.path.join(basedir, 'app.db') +SQLALCHEMY_MIGRATE_REPO = os.path.join(basedir, 'db_repository') diff --git a/db_create.py b/db_create.py new file mode 100755 index 0000000..6845880 --- /dev/null +++ b/db_create.py @@ -0,0 +1,12 @@ +#!flask/bin/python +from migrate.versioning import api +from config import SQLALCHEMY_DATABASE_URI +from config import SQLALCHEMY_MIGRATE_REPO +from app import db +import os.path +db.create_all() +if not os.path.exists(SQLALCHEMY_MIGRATE_REPO): + api.create(SQLALCHEMY_MIGRATE_REPO, 'database repository') + api.version_control(SQLALCHEMY_DATABASE_URI, SQLALCHEMY_MIGRATE_REPO) +else: + api.version_control(SQLALCHEMY_DATABASE_URI, SQLALCHEMY_MIGRATE_REPO, api.version(SQLALCHEMY_MIGRATE_REPO)) diff --git a/db_downgrade.py b/db_downgrade.py new file mode 100755 index 0000000..59200a9 --- /dev/null +++ b/db_downgrade.py @@ -0,0 +1,8 @@ +#!flask/bin/python +from migrate.versioning import api +from config import SQLALCHEMY_DATABASE_URI +from config import SQLALCHEMY_MIGRATE_REPO +v = api.db_version(SQLALCHEMY_DATABASE_URI, SQLALCHEMY_MIGRATE_REPO) +api.downgrade(SQLALCHEMY_DATABASE_URI, SQLALCHEMY_MIGRATE_REPO, v - 1) +v = api.db_version(SQLALCHEMY_DATABASE_URI, SQLALCHEMY_MIGRATE_REPO) +print('Current database version: ' + str(v)) diff --git a/db_migrate.py b/db_migrate.py new file mode 100755 index 0000000..f5fce56 --- /dev/null +++ b/db_migrate.py @@ -0,0 +1,17 @@ +#!flask/bin/python +import imp +from migrate.versioning import api +from app import db +from config import SQLALCHEMY_DATABASE_URI +from config import SQLALCHEMY_MIGRATE_REPO +v = api.db_version(SQLALCHEMY_DATABASE_URI, SQLALCHEMY_MIGRATE_REPO) +migration = SQLALCHEMY_MIGRATE_REPO + ('/versions/%03d_migration.py' % (v+1)) +tmp_module = imp.new_module('old_model') +old_model = api.create_model(SQLALCHEMY_DATABASE_URI, SQLALCHEMY_MIGRATE_REPO) +exec(old_model, tmp_module.__dict__) +script = api.make_update_script_for_model(SQLALCHEMY_DATABASE_URI, SQLALCHEMY_MIGRATE_REPO, tmp_module.meta, db.metadata) +open(migration, "wt").write(script) +api.upgrade(SQLALCHEMY_DATABASE_URI, SQLALCHEMY_MIGRATE_REPO) +v = api.db_version(SQLALCHEMY_DATABASE_URI, SQLALCHEMY_MIGRATE_REPO) +print('New migration saved as ' + migration) +print('Current database version: ' + str(v)) diff --git a/db_upgrade.py b/db_upgrade.py new file mode 100755 index 0000000..d17f322 --- /dev/null +++ b/db_upgrade.py @@ -0,0 +1,7 @@ +#!flask/bin/python +from migrate.versioning import api +from config import SQLALCHEMY_DATABASE_URI +from config import SQLALCHEMY_MIGRATE_REPO +api.upgrade(SQLALCHEMY_DATABASE_URI, SQLALCHEMY_MIGRATE_REPO) +v = api.db_version(SQLALCHEMY_DATABASE_URI, SQLALCHEMY_MIGRATE_REPO) +print('Current database version: ' + str(v)) diff --git a/run.py b/run.py new file mode 100755 index 0000000..178fd8a --- /dev/null +++ b/run.py @@ -0,0 +1,3 @@ +#!flask/bin/python +from app import app +app.run(debug=True) -- cgit v1.2.3-54-g00ecf