# HG changeset patch # User Anton Shestakov <engored@ya.ru> # Date 1355652048 -32400 # Node ID a02e94c5b96b12a3cd0b9a7fc6e33ca3b1136ffc Made public. diff -r 000000000000 -r a02e94c5b96b .hgignore --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/.hgignore Sun Dec 16 19:00:48 2012 +0900 @@ -0,0 +1,5 @@ +syntax: glob +*.pyc +*.pyo +venv/ +db/ diff -r 000000000000 -r a02e94c5b96b REQUIREMENTS --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/REQUIREMENTS Sun Dec 16 19:00:48 2012 +0900 @@ -0,0 +1,3 @@ +CodernityDB==0.3.63 +Flask==0.9 +Flask-RESTful==0.1.2 diff -r 000000000000 -r a02e94c5b96b app.py --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/app.py Sun Dec 16 19:00:48 2012 +0900 @@ -0,0 +1,137 @@ +#!/usr/bin/env python +#-*- coding:utf-8 -*- + +import os + +from flask import Flask, g, request, render_template +from flask.ext.restful import Resource, Api +from CodernityDB.database import RecordNotFound +from CodernityDB.database_thread_safe import ThreadSafeDatabase + +from fruitbar.indexes import ProjectIndex, TaskIndex + + +app = Flask(__name__) +api = Api(app) + + +db_path = os.path.join(os.path.dirname(__file__), 'db') +cdb = ThreadSafeDatabase(db_path) + +if cdb.exists(): + cdb.open() + cdb.reindex() +else: + cdb.create() + + cdb.add_index(ProjectIndex(cdb.path, 'project')) + cdb.add_index(TaskIndex(cdb.path, 'task')) + + cdb.insert({'_id': '29c21f00190f475ba2d855f810c1085e', '_t': 'project', 'name': 'Test Project', 'color': 'success'}) + cdb.insert({'_t': 'task', 'project': '29c21f00190f475ba2d855f810c1085e', 'name': 'Test Task', 'note': 'Task Note', 'done': True}) + + +@app.before_request +def before_request(): + g.db = cdb + + +class ResourceList(Resource): + db_index = 'id' + doc_stub = {} + + def get(self): + return [project['doc'] for project in g.db.all(self.db_index, with_doc=True)] + + def post(self): + doc = self.doc_stub.copy() + doc.update(request.json) + + response = g.db.insert(doc) + + return g.db.get('id', response['_id'], with_doc=True) + + +class CRUDResource(Resource): + """ CRUD? More like RUD! + """ + + safe_fields = tuple() + + def get(self, resource_id): + try: + doc = g.db.get('id', resource_id, with_doc=True) + except RecordNotFound: + return '', 404 + + return doc + + def put(self, resource_id): + try: + doc = g.db.get('id', resource_id, with_doc=True) + except RecordNotFound: + return '', 404 + + userdata = dict((k, v) for (k, v) in request.json.items() if k in self.safe_fields) + doc.update(userdata) + response = g.db.update(doc) + + return self.get(response['_id']) + + def delete(self, resource_id): + try: + doc = g.db.get('id', resource_id, with_doc=True) + except RecordNotFound: + return '', 404 + + g.db.delete(doc) + + return '', 200 + + +class ProjectList(ResourceList): + doc_stub = {'_t': 'project'} + db_index = 'project' + + +class Project(CRUDResource): + safe_fields = ('name', 'color') + + def delete(self, resource_id): + for task in g.db.get_many('task', resource_id, limit=-1, with_doc=True): + response = g.db.delete(task['doc']) + + return super(Project, self).delete(resource_id) + + +class TaskList(ResourceList): + doc_stub = {'_t': 'task'} + db_index = 'task' + + +class Task(CRUDResource): + safe_fields = ('name', 'note', 'done') + + +api.add_resource(ProjectList, '/projects/') +api.add_resource(Project, '/projects/<resource_id>/') +api.add_resource(TaskList, '/tasks/') +api.add_resource(Task, '/tasks/<resource_id>/') + + +@app.route('/') +def index(): + data = { + 'projects': [project['doc'] for project in g.db.all('project', with_doc=True)], + 'tasks': [task['doc'] for task in g.db.all('task', with_doc=True)] + } + + return render_template('index.html', data=data) + + +def main(): + app.run(debug=True) + + +if __name__ == '__main__': + main() diff -r 000000000000 -r a02e94c5b96b fruitbar/__init__.py diff -r 000000000000 -r a02e94c5b96b fruitbar/indexes.py --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/fruitbar/indexes.py Sun Dec 16 19:00:48 2012 +0900 @@ -0,0 +1,31 @@ +#-*- coding:utf-8 -*- + +from hashlib import md5 + +from CodernityDB.hash_index import HashIndex + + +class ProjectIndex(HashIndex): + def __init__(self, *args, **kwargs): + kwargs['key_format'] = '16s' + super(ProjectIndex, self).__init__(*args, **kwargs) + + def make_key_value(self, data): + if data['_t'] == 'project': + return md5(data['name'].encode('utf-8')).digest(), None + + def make_key(self, key): + return md5(key).digest() + + +class TaskIndex(HashIndex): + def __init__(self, *args, **kwargs): + kwargs['key_format'] = '16s' + super(TaskIndex, self).__init__(*args, **kwargs) + + def make_key_value(self, data): + if data['_t'] == 'task': + return md5(data['project'].encode('utf-8')).digest(), None + + def make_key(self, key): + return md5(key).digest() diff -r 000000000000 -r a02e94c5b96b static/css/custom.css --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/static/css/custom.css Sun Dec 16 19:00:48 2012 +0900 @@ -0,0 +1,60 @@ +.project { + padding-bottom: 20px; +} + +.project h4 { + margin-top: 0; +} + +.project .progress { + cursor: pointer; +} + +.task .task-note { + font-size: 12px; + line-height: 15px; + padding: 2px 4px; + margin-bottom: 2px; + border-radius: 3px; +} + +.task .task-note.present { + background-color: #f7f7f9; + border: 1px solid #e1e1e8; +} + +.task input[type="checkbox"] { + float: left; + margin-right: 6px; +} + +.row .ondemand { + visibility: hidden; +} + +.row:hover .ondemand { + visibility: visible; +} + +.inline-editable:hover { + color: #08c; + border-bottom: 1px dashed #08c; +} + +.inline-editor { + position: relative; + margin-bottom: 0; +} + +.inline-editor input[type="text"] { + padding-top: 2px; + padding-bottom: 2px; +} + +.inline-editor textarea { + margin-bottom: 0; +} + +.align-right { + text-align: right; +} diff -r 000000000000 -r a02e94c5b96b static/favicon.ico Binary file static/favicon.ico has changed diff -r 000000000000 -r a02e94c5b96b static/js/backbone.shard.js --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/static/js/backbone.shard.js Sun Dec 16 19:00:48 2012 +0900 @@ -0,0 +1,79 @@ +/* + * backbone.shard.js. + * + * (c) 2012 Anton Shestakov. + * + * This extension to Backbone may be freely distributed + * under the MIT license: + * http://opensource.org/licenses/mit-license.php + */ + +Backbone.Shard = function(options) { + this._collection = options.collection; + this._filter = options.filter; + + this.models = this._collection.filter(this._filter); + + this._collection + .on('all', function(event, model) { + if (event == 'change') { + if (this._filter(model) && _(this.models).contains(model)) { + this.trigger.apply(this, arguments); + } + if (this._filter(model) && !_(this.models).contains(model)) { + this.models.push(model); + this.trigger('add', model); + } + if (!this._filter(model) && _(this.models).contains(model)) { + this.models = _.without(this.models, model); + this.trigger('remove', model); + } + } else if (/^change:/.test(event) && this._filter(model) && _(this.models).contains(model)) { + this.trigger.apply(this, arguments); + } + }, this) + .on('add', function(model) { + if (this._filter(model) && !_(this.models).contains(model)) { + this.models.push(model); + this.trigger('add', model); + } + }, this) + .on('remove', function(model) { + if (this._filter(model) && _(this.models).contains(model)) { + this.models = _.without(this.models, model); + this.trigger('remove', model); + } + }, this) + .on('reset', function() { + this.models = this._collection.filter(this._filter); + this.trigger('reset'); + }, this); + + this.initialize.apply(this, arguments); +}; + +_.extend(Backbone.Shard.prototype, Backbone.Events, { + initialize: function() {}, + + pluck: function(attr) { + return _.map(this.models, function(model) { return model.get(attr); }); + }, + + chain: function () { + return _(this.models).chain(); + } +}); + +// Underscore methods that we want to implement on the Shard. +var methods = ['forEach', 'each', 'map', 'reduce', 'reduceRight', 'find', + 'detect', 'filter', 'select', 'reject', 'every', 'all', 'some', 'any', + 'include', 'contains', 'invoke', 'max', 'min', 'sortBy', 'sortedIndex', + 'toArray', 'size', 'first', 'initial', 'rest', 'last', 'without', 'indexOf', + 'shuffle', 'lastIndexOf', 'isEmpty', 'groupBy']; + +// Mix in each Underscore method as a proxy to `Shard#models`. +_.each(methods, function(method) { + Backbone.Shard.prototype[method] = function() { + return _[method].apply(_, [this.models].concat(_.toArray(arguments))); + }; +}); diff -r 000000000000 -r a02e94c5b96b static/js/framework/collections.js --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/static/js/framework/collections.js Sun Dec 16 19:00:48 2012 +0900 @@ -0,0 +1,9 @@ +var Projects = Backbone.Collection.extend({ + model: Project, + url: '/projects/' +}); + +var Tasks = Backbone.Collection.extend({ + model: Task, + url: '/tasks/' +}); diff -r 000000000000 -r a02e94c5b96b static/js/framework/models.js --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/static/js/framework/models.js Sun Dec 16 19:00:48 2012 +0900 @@ -0,0 +1,37 @@ +var Model = Backbone.Model.extend({ + url: function() { + var url = Backbone.Model.prototype.url.call(this); + return url[url.length - 1] == '/' ? url : url + '/'; + }, + + adhoc: function(data) { + var model = this; + var options = { + url: this.urlRoot + encodeURIComponent(this.id) + '/', + contentType: 'application/json', + data: JSON.stringify(data) + }; + + return (this.sync || Backbone.sync).call(this, 'update', model, options).then(function(serverAttrs) { + model.set(serverAttrs); + }); + } +}); + +var Project = Model.extend({ + idAttribute: '_id', + urlRoot: '/projects/', + + switchColor: function() { + var colorChain = [null, 'info', 'success', 'warning', 'danger']; + var currentColorIndex = colorChain.indexOf(this.get('color') || null); + var nextColor = colorChain[(currentColorIndex + 1) % colorChain.length]; + + return this.adhoc({color: nextColor}); + } +}); + +var Task = Model.extend({ + idAttribute: '_id', + urlRoot: '/tasks/' +}); diff -r 000000000000 -r a02e94c5b96b static/js/framework/views/base.js --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/static/js/framework/views/base.js Sun Dec 16 19:00:48 2012 +0900 @@ -0,0 +1,44 @@ +var CollectionView = Backbone.View.extend({ + appendItem: function() {}, + updateItem: function() {}, + removeItem: function() {}, + clear: function() {}, + repopulate: function() { + this.clear(); + this.collection.each(this.appendItem, this); + }, + bindCollection: function() { + this.repopulate(); + + this.collection + .on('add', this.appendItem, this) + .on('change', this.updateItem, this) + .on('remove', this.removeItem, this) + .on('reset', this.repopulate, this); + }, + unbindCollection: function() { + this.collection.off(null, null, this); + }, + compileTemplates: function(templates) { + this.templates = {}; + + _.each(templates, function(template, name) { + this.templates[name] = _.template(template); + }, this); + } +}); + +var CollectionCounterView = CollectionView.extend({ + initialize: function(options) { + this.bindCollection(); + }, + appendItem: function() { + this.repopulate(); + }, + removeItem: function() { + this.repopulate(); + }, + repopulate: function() { + this.$el.html(this.collection.models.length); + } +}); diff -r 000000000000 -r a02e94c5b96b static/js/framework/views/inline.js --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/static/js/framework/views/inline.js Sun Dec 16 19:00:48 2012 +0900 @@ -0,0 +1,105 @@ +var InlineEditorView = Backbone.View.extend({ + initialize: function(options) { + this.render(options); + }, + + renderInput: function(options) { + var view = this; + + this.$input = $('<input>').attr('type', 'text').addClass('input-xlarge').val(options.target.text()); + + this.$input.keyup(function(e) { + switch (e.keyCode) { + case 13: + view.$saveButton.click(); + break; + case 27: + view.$cancelButton.click(); + break; + } + }); + + this.$el.append(this.$input, this.$saveButton, this.$cancelButton); + options.target.after(this.$el); + this.$input.focus(); + + return this; + }, + + renderTextarea: function(options) { + var view = this; + + this.$input = $('<textarea>').addClass('input-xlarge').attr('rows', 3).text(options.target.text()); + + this.$input.keyup(function(e) { + switch (e.keyCode) { + case 13: + if (e.ctrlKey) { + view.$saveButton.click(); + } + break; + case 27: + view.$cancelButton.click(); + break; + } + }); + + this.$el.append(this.$input, this.$saveButton, this.$cancelButton); + options.target.after(this.$el); + this.$input.focus(); + + return this; + }, + + render: function(options) { + var view = this; + + this.$saveButton = $('<button>').addClass('btn btn-small btn-success').html('<i class="icon-white icon-ok"></i>'); + this.$cancelButton = $('<button>').addClass('btn btn-small').html('<i class="icon-remove"></i>'); + this.$el = $('<div>').addClass('inline-editor input-append'); + + this.$cancelButton.click(function() { + view.$el.remove(); + options.target.show(); + }); + + this.$saveButton.click(function() { + view.trigger('save', view.$input.val()); + }); + + switch (options.type) { + case 'input': + return this.renderInput(options); + case 'textarea': + return this.renderTextarea(options); + } + } +}); + + +var CollectionViewWithInlineEditor = CollectionView.extend({ + bindInlineEditable: function(model, selector) { + model.$item.delegate(selector, 'click', function(e) { + e.preventDefault(); + + var $this = $(this); + var attribute = $this.attr('data-model-attribute'); + var inlineEditor = new InlineEditorView({ + target: $this, + type: $this.attr('data-input-type') || 'input' + }); + + $this.hide(); + + inlineEditor.on('save', function(value) { + var data = {}; + data[attribute] = value; + + model.adhoc(data).then(function() { + inlineEditor.remove(); + $this.show(); + }); + }); + }); + } +}); diff -r 000000000000 -r a02e94c5b96b static/js/framework/views/projects.js --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/static/js/framework/views/projects.js Sun Dec 16 19:00:48 2012 +0900 @@ -0,0 +1,101 @@ +var ProjectsView = CollectionViewWithInlineEditor.extend({ + initialize: function(options) { + this.tasks = options.tasks; + this.rawTemplates = options.templates; + + this.compileTemplates(options.templates); + this.bindCollection(); + + options.controls.create.click(function() { + options.collection.create({ + name: 'new project' + }, {wait: true}); + }); + }, + + clear: function() { + this.$el.empty(); + }, + + renderItem: function(project) { + var $item = $(this.templates.project(project.toJSON())); + + $item.tasksView = new TasksView({ + el: $item.find('.tasks'), + collection: project.tasks, + templates: this.rawTemplates, + controls: { + create: $item.find('.btn-new-task') + } + }); + + $item + .find('.progress') + .click(function() { + var $progress = $(this); + $progress.addClass('active'); + project.switchColor().then(function() { + $progress.removeClass('active'); + }); + }); + + $item + .find('.btn-delete-project') + .click(function() { + project.destroy({wait: true}); + }); + + return $item; + }, + + updateProgress: function(project) { + project.$item + .find('.progress') + .removeClass('progress-info progress-success progress-warning progress-danger') + .addClass(project.get('color') ? 'progress-' + project.get('color') : '') + .find('.bar') + .css('width', (100 * project.tasks.progress()) + '%'); + }, + + appendItem: function(project) { + var tasks = this.tasks; + + project.tasks = new Backbone.Shard({ + collection: this.tasks, + filter: function(task) { return task.get('project') === project.id; } + }); + + project.tasks.progress = function() { + return this.filter(function(task) { return task.get('done') === true; }).length / this.size(); + }; + + project.tasks.create = function(attributes, options) { + attributes = attributes || {}; + attributes['project'] = project.id; + return tasks.create(attributes, options); + }; + + project.$item = this.renderItem(project); + + this.bindInlineEditable(project, '.inline-editable[data-model="project"]'); + this.updateProgress(project); + + project.on('change:name', function() { + project.$item.find('[data-model="project"][data-model-attribute="name"]').text(project.get('name')); + }); + + project.on('change:color', function() { + this.updateProgress(project); + }, this); + + project.tasks.on('add remove change:done', function() { + this.updateProgress(project); + }, this); + + this.$el.append(project.$item); + }, + + removeItem: function(project) { + project.$item.remove(); + } +}); diff -r 000000000000 -r a02e94c5b96b static/js/framework/views/tasks.js --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/static/js/framework/views/tasks.js Sun Dec 16 19:00:48 2012 +0900 @@ -0,0 +1,71 @@ +var TasksView = CollectionViewWithInlineEditor.extend({ + initialize: function(options) { + this.compileTemplates(options.templates); + this.bindCollection(); + + options.controls.create.click(function() { + options.collection.create({ + name: 'new task' + }, {wait: true}); + }); + }, + + clear: function() { + this.$el.empty(); + }, + + renderItem: function(task) { + var $item = $(this.templates.task(task.toJSON())); + + $item + .find('input:checkbox') + .change(function() { + task.adhoc({'done': this.checked}); + }); + + $item + .find('.btn-delete-task') + .click(function() { + task.destroy({wait: true}); + }); + + return $item; + }, + + updateNote: function(task) { + var $note = task.$item.find('[data-model="task"][data-model-attribute="note"]'); + + if (task.get('note')) { + $note.text(task.get('note')); + task.$item.find('.task-note').removeClass('muted').addClass('present'); + } else { + $note.text('...'); + task.$item.find('.task-note').addClass('muted').removeClass('present'); + } + }, + + appendItem: function(task) { + task.$item = this.renderItem(task); + + this.bindInlineEditable(task, '.inline-editable[data-model="task"]'); + this.updateNote(task); + + task.on('change:done', function() { + task.$item.find('input:checkbox').prop('checked', task.get('done')); + }); + + task.on('change:name', function() { + task.$item.find('[data-model="task"][data-model-attribute="name"]').text(task.get('name')); + }); + + task.on('change:note', function() { + this.updateNote(task); + }, this); + + this.$el.append(task.$item); + }, + + removeItem: function(task) { + task.$item.remove(); + } +}); diff -r 000000000000 -r a02e94c5b96b static/js/ui.js --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/static/js/ui.js Sun Dec 16 19:00:48 2012 +0900 @@ -0,0 +1,26 @@ + $(function() { + window.fruitbar = {}; + fruitbar.tasks = new Tasks(data['tasks']); + fruitbar.projects = new Projects(data['projects']); + + fruitbar.projectCounter = new CollectionCounterView({ + el: $('.project-counter'), + collection: fruitbar.projects + }); + + fruitbar.projectsView = new ProjectsView({ + el: $('.projects'), + collection: fruitbar.projects, + tasks: fruitbar.tasks, + templates: { + project: $('#project-template').html(), + task: $('#task-template').html() + }, + controls: { + create: $('.btn-new-project') + } + }); + + window.setInterval(function() {fruitbar.projects.fetch({update: true});}, 60000); + window.setInterval(function() {fruitbar.tasks.fetch({update: true});}, 60000); +}); diff -r 000000000000 -r a02e94c5b96b templates/index.html --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/templates/index.html Sun Dec 16 19:00:48 2012 +0900 @@ -0,0 +1,104 @@ +<!DOCTYPE html> +<html> + <head> + <title>Fruit Bar Progress Tracker</title> + <link type="image/x-icon" href="{{ url_for('static', filename='favicon.ico') }}" rel="shortcut icon"> + + <link type="text/css" href="//netdna.bootstrapcdn.com/twitter-bootstrap/2.2.1/css/bootstrap-combined.min.css" rel="stylesheet"> + <link type="text/css" href="{{ url_for('static', filename='css/custom.css') }}" rel="stylesheet"> + + <script type="text/javascript" src="//ajax.googleapis.com/ajax/libs/jquery/1.8.3/jquery.min.js"></script> + <script type="text/javascript" src="//underscorejs.org/underscore-min.js"></script> + <script type="text/javascript" src="//backbonejs.org/backbone-min.js"></script> + <script type="text/javascript" src="//netdna.bootstrapcdn.com/twitter-bootstrap/2.2.1/js/bootstrap.min.js"></script> + <script type="text/javascript" src="{{ url_for('static', filename='js/backbone.shard.js') }}"></script> + <script type="text/javascript" src="{{ url_for('static', filename='js/framework/models.js') }}"></script> + <script type="text/javascript" src="{{ url_for('static', filename='js/framework/collections.js') }}"></script> + <script type="text/javascript" src="{{ url_for('static', filename='js/framework/views/base.js') }}"></script> + <script type="text/javascript" src="{{ url_for('static', filename='js/framework/views/inline.js') }}"></script> + <script type="text/javascript" src="{{ url_for('static', filename='js/framework/views/projects.js') }}"></script> + <script type="text/javascript" src="{{ url_for('static', filename='js/framework/views/tasks.js') }}"></script> + <script type="text/javascript" src="{{ url_for('static', filename='js/ui.js') }}"></script> + + <script type="text/javascript"> + _.extend(_.templateSettings, { + variable: 'data' + }); + </script> + </head> + <body> + <div class="container"> + <div class="row"> + <div class="span2"> + <h3 class="align-right"> + <button class="btn btn-mini btn-success btn-new-project">New project</button> + </h3> + </div> + <div class="span8"> + <h3 class="muted"> + All projects <small>(<span class="project-counter"></span>)</small> + </h3> + <hr> + </div> + </div> + + <section class="projects"> + <script id="project-template" type="text/template"> + <div class="project"> + <div class="row"> + <div class="span2 align-right ondemand"> + <button class="btn btn-mini btn-danger btn-delete-project">Delete</button> + <button class="btn btn-mini btn-success btn-new-task">New task</button> + </div> + <div class="span4"> + <h4> + <span class="inline-editable" + data-input-type="input" + data-model="project" + data-model-attribute="name"><%- data.name %></span> + </h4> + </div> + + <div class="span4"> + <div class="progress progress-striped"> + <div class="bar"></div> + </div> + </div> + </div> + + <div class="tasks"></div> + </div> + </script> + + <script id="task-template" type="text/template"> + <div class="task"> + <div class="row"> + <div class="span2 align-right ondemand"> + <button class="btn btn-mini btn-danger btn-delete-task">Delete</button> + </div> + <div class="span4"> + <input type="checkbox" <% if (data.done) print('checked') %>> + <span class="inline-editable" + data-input-type="input" + data-model="task" + data-model-attribute="name"><%- data.name %></span> + </div> + <div class="span4"> + <div class="task-note"> + <span class="inline-editable" + data-input-type="textarea" + data-model="task" + data-model-attribute="note"></span> + </div> + </div> + </div> + </div> + </script> + </section> + </div> + + <script type="text/javascript"> + var data = {{ data|tojson|safe }}; + </script> + </body> +</html>