Skip to content

Instantly share code, notes, and snippets.

@coleifer

coleifer/api.py Secret

Last active July 29, 2023 02:04
Show Gist options
  • Save coleifer/69ec9d09b2efe05527eb to your computer and use it in GitHub Desktop.
Save coleifer/69ec9d09b2efe05527eb to your computer and use it in GitHub Desktop.
Note-taking app
from flask import render_template
from flask_peewee.rest import Authentication
from flask_peewee.rest import RestAPI
from flask_peewee.rest import RestResource
from app import app
from models import Note
from models import Task
# Allow GET and POST requests without requiring authentication.
auth = Authentication(protected_methods=['PUT', 'DELETE'])
api = RestAPI(app, default_auth=auth)
class NoteResource(RestResource):
fields = ('id', 'content', 'timestamp', 'status')
paginate_by = 30
def get_query(self):
return Note.public()
def prepare_data(self, obj, data):
data['rendered'] = render_template('note.html', note=obj)
return data
class TaskResource(RestResource):
paginate_by = 50
api.register(Note, NoteResource)
api.register(Task, TaskResource)
import os
from flask import Flask
from huey import RedisHuey
from micawber import bootstrap_basic
from peewee import SqliteDatabase
APP_ROOT = os.path.dirname(os.path.realpath(__file__))
DATABASE = os.path.join(APP_ROOT, 'notes.db')
DEBUG = False
app = Flask(__name__)
app.config.from_object(__name__)
db = SqliteDatabase(app.config['DATABASE'], pragmas=[('journal_mode', 'wal')])
huey = RedisHuey()
oembed = bootstrap_basic()
from app import app
from models import Note
from models import Task
from api import api
import views
api.setup()
if __name__ == '__main__':
Note.create_table(True)
Task.create_table(True)
app.run()
import datetime
from flask import Markup
from markdown import markdown
from micawber import parse_html
from peewee import *
from app import db, huey, oembed
def rich_content(content, maxwidth=300):
html = parse_html(
markdown(content),
oembed,
maxwidth=maxwidth,
urlize_all=True)
return Markup(html)
class Note(Model):
STATUS_VISIBLE = 1
STATUS_ARCHIVED = 2
STATUS_DELETED = 9
content = TextField()
timestamp = DateTimeField(default=datetime.datetime.now)
status = IntegerField(default=STATUS_VISIBLE, index=True)
reminder = DateTimeField(null=True)
reminder_task_created = BooleanField(default=False)
reminder_sent = BooleanField(default=False)
class Meta:
database = db
def html(self):
return rich_content(self.content)
def is_finished(self):
if self.tasks.exists():
return not self.tasks.where(Task.finished == False).exists()
def get_tasks(self):
return self.tasks.order_by(Task.order)
def parse_content_tasks(self):
# Split the list of tasks from the surrounding content, returning both.
content = []
tasks = []
for line in self.content.splitlines():
if line.startswith('@'):
tasks.append(line[1:].strip())
else:
content.append(line)
return '\n'.join(content), tasks
def save(self, *args, **kwargs):
# Split out the text content and any tasks.
self.content, tasks = self.parse_content_tasks()
# Determine if we need to set a reminder.
set_reminder = self.reminder and not self.reminder_task_created
self.reminder_task_created = True
# Save the note.
ret = super(Note, self).save(*args, **kwargs)
if set_reminder:
# Set a reminder to go off by enqueueing a task with huey.
send_note_reminder.schedule(args=(self.id,), eta=self.reminder)
if tasks:
# Store the tasks.
Task.delete().where(Task.note == self).execute()
for idx, title in enumerate(tasks):
Task.create(note=self, title=title, order=idx)
return ret
@classmethod
def public(cls):
return (Note
.select()
.where(Note.status == Note.STATUS_VISIBLE)
.order_by(Note.timestamp.desc()))
class Task(Model):
note = ForeignKeyField(Note, related_name='tasks')
title = CharField(max_length=255)
order = IntegerField(default=0)
finished = BooleanField(default=False)
class Meta:
database = db
def html(self):
return rich_content(self.title)
@huey.task(retries=3, retry_delay=60)
def send_note_reminder(note_id):
with database.transaction():
try:
note = Note.get(Note.id == note_id)
except Note.DoesNotExist:
app.logger.info(
'Attempting to send reminder for note id=%s, but note '
'was not found in the database.', note_id)
return
if note.status == Note.STATUS_DELETED:
app.logger.info('Attempting to send a reminder for a deleted '
'note id=%s. Skipping.', note_id)
return
try:
mailer.send(
to=app.config['REMINDER_EMAIL'],
subj='[notes] reminder',
body=note.content)
except:
app.logger.info('Sending reminder failed for note id=%s.', note_id)
raise
else:
note.reminder_sent = True
note.save()
Notes = window.Notes || {};
(function(exports, $) {
function Editor() {
// Store state, such as next and previous page.
this.state = {};
this.endpoint = '/api/note/';
// DOM nodes.
this.form = $('form#note-form');
this.content = this.form.find('textarea#content');
this.reminder = this.form.find('input[name="reminder"]');
this.container = $('ul.notes');
// Bind handlers.
this.initialize();
}
/* Initialization and event handler binding. */
Editor.prototype.initialize = function() {
this.container.masonry();
this.setupForm();
this.bindArchiveDelete();
this.bindPagination();
this.bindSearchHandler();
var page = 1;
if (window.location.hash) {
page = parseInt(window.location.hash.replace('#', ''));
}
this.getList(page || 1);
}
Editor.prototype.setupForm = function() {
var self = this;
this.content.on('keydown', function(e) {
if (e.ctrlKey && e.keyCode == 13) {
self.form.submit();
}
});
this.content.focus();
this.addMarkdownHelpers();
this.form.submit(function(e) {
e.preventDefault();
self.addNote();
});
}
Editor.prototype.bindArchiveDelete = function() {
$('a.delete-note,a.archive-note').on('click', this.changeNote);
}
Editor.prototype.bindPagination = function() {
var self = this;
var makeHandler = function (key) {
return function(e) {
e.preventDefault();
if ($(this).parent().hasClass('disabled')) return;
if (key == 'next') {
page = self.state['page'] + 1;
} else {
page = self.state['page'] - 1;
}
self.getList(page, self.state['search']);
}
}
$('ul.pager li.next a').on('click', makeHandler('next'));
$('ul.pager li.previous a').on('click', makeHandler('previous'));
}
Editor.prototype.bindSearchHandler = function() {
var self = this;
$('form#search-form').on('submit', function(e) {
e.preventDefault();
var query = $('input[name="q"]').val();
self.getList(1, query);
});
}
/* Loading and saving notes. */
Editor.prototype.getList = function(page, search) {
var requestData = {};
var self = this;
this.container.empty();
if (page) requestData['page'] = page;
if (search) requestData['content__ilike'] = '%' + search + '%';
this.makeRequest('/api/note/', 'GET', requestData, function(data) {
data.objects.reverse();
$.each(data.objects, function(idx, note) {
self.addNoteToList(note.rendered);
});
imagesLoaded(self.container, function() {
self.container.masonry('layout');
});
self.updatePagination(data);
});
}
Editor.prototype.addNoteToList = function(html) {
var self = this;
listElem = $(html);
listElem.find('a.delete-note,a.archive-note').on('click', function(e) {
e.preventDefault();
self.changeNote($(this));
});
listElem.find('input[type="checkbox"]').on('change', function(e) {
self.updateTask($(this));
});
listElem.find('img').addClass('img-responsive');
this.container.prepend(listElem);
this.container.masonry('prepended', listElem);
}
Editor.prototype.updatePagination = function(response) {
var meta = response.meta;
window.location.hash = meta.page;
this.state = {'page': meta.page};
this.state['hasNext'] = meta.next != '';
this.state['hasPrevious'] = meta.previous != '';
this.state['search'] = $('input[name="q"]').val();
var next = $('ul.pager li.next');
if (this.state.hasNext) {
next.removeClass('disabled');
} else {
next.addClass('disabled');
}
var previous = $('ul.pager li.previous');
if (this.state.hasPrevious) {
previous.removeClass('disabled');
} else {
previous.addClass('disabled');
}
}
Editor.prototype.addNote = function() {
if (!this.content.val()) {
this.content.css('color', '#dd1111');
return
}
var note = {'content': this.content.val()};
if (this.reminder.is(':visible') && this.reminder.val()) {
// Fix any bizarre date formats.
var dateTime = this.reminder.val().replace('T', ' ').split('Z')[0];
if (dateTime.split(':').length == 2) {
dateTime = dateTime + ':00';
}
note['reminder'] = dateTime;
}
var self = this;
this.content.css('color', '#464545');
this.makeRequest(this.form.attr('action'), 'POST', note, function(data) {
self.content.val('').focus();
self.resetReminder();
self.addNoteToList(data.rendered);
});
}
Editor.prototype.resetReminder = function() {
var now = new Date();
var pad = function(v) {return ('0' + v).slice(-2);}
this.reminder.val(
(now.getYear() + 1900) + '-' +
pad(now.getMonth() + 1) + '-' +
pad(now.getDate()) + 'T' +
pad(now.getHours()) + ':' + '00'
);
}
Editor.prototype.changeNote = function(noteLink) {
var noteData = {};
var panel = noteLink.parents('.panel');
var self = this;
panel.removeClass('panel-primary').addClass('panel-danger');
if (noteLink.hasClass('delete-note')) {
noteData['status'] = 9
} else {
noteData['status'] = 2
}
this.makeRequest(noteLink.attr('href'), 'POST', noteData, function(data) {
panel.remove();
self.container.masonry();
});
}
Editor.prototype.updateTask = function(elem) {
/* Read checkbox state. */
var isFinished = elem.is(':checked');
var taskId = elem.val();
var url = '/api/task/' + taskId + '/';
this.makeRequest(url, 'POST', {'finished': isFinished}, function(data) {
var label = elem.parent();
var color = label.css('color');
label.css('color', '#119911');
window.setTimeout(function() {label.css('color', color);}, 2000);
});
}
/* Make API request. */
Editor.prototype.makeRequest = function(url, method, data, callback) {
if (method == 'GET') {
$.get(url, data, callback);
} else {
$.ajax(url, {
'contentType': 'application/json; charset=UTF-8',
'data': JSON.stringify(data),
'dataType': 'json',
'success': callback,
'type': method
});
}
}
/* Markdown and other editor features. */
Editor.prototype.addMarkdownHelpers = function() {
var self = this;
this.addHelper('indent-left', function(line) {return ' ' + line;});
this.addHelper('indent-right', function(line) {return line.substring(4);});
this.addHelper('list', function(line) {return '* ' + line;});
this.addHelper('bold', function(line) {return '**' + line + '**';});
this.addHelper('italic', function(line) {return '*' + line + '*';});
this.addHelper('font', null, function() {self.focus().select();});
this.addHelper('time', null, function() {
self.reminder.toggle();
if (self.reminder.is(':visible')) {
self.resetReminder();
} else {
self.reminder.val('');
}
});
}
Editor.prototype.addHelper = function(iconClass, lineHandler, callBack) {
var link = $('<a>', {'class': 'btn btn-xs'}),
icon = $('<span>', {'class': 'glyphicon glyphicon-' + iconClass}),
self = this;
if (!callBack) {
callBack = function() {
self.modifySelection(lineHandler);
}
}
link.on('click', function(e) {
e.preventDefault();
callBack();
});
link.append(icon);
this.content.before(link);
}
Editor.prototype.modifySelection = function(lineHandler) {
var selection = this.getSelectedText();
if (!selection) return;
var lines = selection.split('\n'),
result = [];
for (var i = 0; i < lines.length; i++) {
result.push(lineHandler(lines[i]));
}
this.content.val(
this.content.val().split(selection).join(result.join('\n'))
);
}
Editor.prototype.getSelectedText = function() {
var textAreaDOM = this.content[0];
return this.content.val().substring(
textAreaDOM.selectionStart,
textAreaDOM.selectionEnd);
}
exports.Editor = Editor;
})(Notes, jQuery);
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Notes</title>
<link href="{{ url_for('static', filename='css/bootstrap.min.css') }}" rel="stylesheet">
<script src="{{ url_for('static', filename='js/jquery-1.11.0.min.js') }}"></script>
<script src="{{ url_for('static', filename='js/bootstrap.min.js') }}"></script>
<script src="{{ url_for('static', filename='js/imagesloaded.pkgd.min.js') }}"></script>
<script src="{{ url_for('static', filename='js/masonry.pkgd.min.js') }}"></script>
<script src="{{ url_for('static', filename='js/notes.js') }}"></script>
<script type="text/javascript">
$(function() {
new Notes.Editor();
});
</script>
</head>
<body>
<div class="container content">
<div class="page-header">
<form class="form-inline pull-right" id="search-form">
<div class="form-group">
<input class="form-control" name="q" placeholder="search" type="text" />
</div>
</form>
<h1>Notes</h1>
</div>
<form action="/api/note/" class="form" id="note-form" method="post">
<button class="btn btn-primary btn-xs" type="submit">
<span class="glyphicon glyphicon-plus"></span> Add Note
</button>
<textarea class="form-control" id="content" name="content"></textarea>
<input class="input-sm" name="reminder" step="600" style="display:none;" type="datetime-local" value="" />
</form>
<ul class="list-unstyled notes row" style="margin-top: 30px;"></ul>
<div style="clear:both;"></div>
<ul class="pager">
<li class="previous"><a href="#">&laquo; Previous</a></li>
<li class="next"><a href="#">Next &raquo;</a></li>
</ul>
</div>
</body>
</html>
<li class="note col-xs-12 col-sm-6 col-lg-4">
<div class="panel panel-{% if note.reminder %}warning{% else %}primary{% endif %}">
<div class="panel-heading">
<a class="btn btn-danger btn-xs delete-note pull-right" data-note="{{ note.id }}" href="/api/note/{{ note.id }}/">&times;</a>
<a class="btn btn-info btn-xs archive-note pull-right" data-note="{{ note.id }}" href="/api/note/{{ note.id }}/">a</a>
{{ note.timestamp.strftime('%b %d, %Y - %I:%M%p').lower() }}
</div>
<div class="panel-body">
{{ note.html() }}
{% for task in note.get_tasks() %}
<div class="checkbox">
<label>
<input id="task-{{ task.id }}" {% if task.finished %}checked="checked" {% endif %}name="task" type="checkbox" value="{{ task.id }}">
{{ task.html() }}
</label>
</div>
{% endfor %}
</div>
{% if note.reminder %}
<div class="panel-footer">
<span class="glyphicon glyphicon-time"></span>
{{ note.reminder.strftime('%m/%d/%Y %I:%M%p') }}
</div>
{% endif %}
</div>
</li>
from flask import render_template
from app import app
@app.route('/')
def homepage():
return render_template('homepage.html')
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment