summaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
-rw-r--r--app/__init__.py8
-rw-r--r--app/models.py33
-rw-r--r--app/views.py110
-rwxr-xr-xclient/bug.py58
-rwxr-xr-xclient/bug_delete.py33
-rwxr-xr-xclient/bug_edit.py51
-rwxr-xr-xclient/bug_list.py56
-rwxr-xr-xclient/bug_open.py101
-rwxr-xr-xclient/bug_show.py55
-rw-r--r--config.py6
-rwxr-xr-xdb_create.py12
-rwxr-xr-xdb_downgrade.py8
-rwxr-xr-xdb_migrate.py17
-rwxr-xr-xdb_upgrade.py7
-rwxr-xr-xrun.py3
15 files changed, 558 insertions, 0 deletions
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 '<User %r>' % (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 '<Ticket %r>' % (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/<int:ticket_id>', 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/<int:ticket_id>', 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/<int:ticket_id>', 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] <command> [<args>...]
+
+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 <command>' 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['<command>'] in ['help', None]:
+ if not args['<args>']:
+ print(__doc__.lstrip().rstrip())
+ else:
+ if args['<args>'][0] in commands:
+ bug_mod = import_module('bug_{}'.format(args['<args>'][0]))
+ print(bug_mod.__doc__.lstrip().rstrip())
+ else:
+ exit("'{}' is not a bug.py command. See 'bug help'.".format(args['<args>'][0]))
+ elif args['<command>'] in commands:
+ if not args['--uri']:
+ exit("URI missing")
+
+ bug_mod = import_module('bug_{}'.format(args['<command>']))
+ argv = [args['<command>']] + 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['<command>']))
+
+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] <ticket_id>
+
+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['<ticket_id>'])
+
+ res = json.loads(req.text)
+
+ if req.status_code == 404:
+ print("Ticket with ID '{}' could not be deleted: {}".format(args['<ticket_id>'], res['error']))
+ elif req.status_code == 200:
+ print("Ticket with ID '{}' deleted successfully.".format(args['<ticket_id>']))
+ 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 <ticket_id>
+ bug edit [options] comment <comment_id>
+
+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] <ticket_id>
+
+ -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_id>'])
+
+ 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)