Compare commits

...

2 Commits

Author SHA1 Message Date
Miguel Grinberg 3472187155
Chapter 23: Application Programming Interfaces (APIs) (v0.23) 2022-11-11 12:57:54 +00:00
Miguel Grinberg bb2273349f
Chapter 22: Background Jobs (v0.22) 2022-11-11 12:56:46 +00:00
23 changed files with 501 additions and 19 deletions

View File

@ -1 +1,2 @@
web: flask db upgrade; flask translate compile; gunicorn microblog:app web: flask db upgrade; flask translate compile; gunicorn microblog:app
worker: rq worker microblog-tasks

View File

@ -10,6 +10,8 @@ from flask_bootstrap import Bootstrap
from flask_moment import Moment from flask_moment import Moment
from flask_babel import Babel, lazy_gettext as _l from flask_babel import Babel, lazy_gettext as _l
from elasticsearch import Elasticsearch from elasticsearch import Elasticsearch
from redis import Redis
import rq
from config import Config from config import Config
db = SQLAlchemy() db = SQLAlchemy()
@ -36,6 +38,8 @@ def create_app(config_class=Config):
babel.init_app(app) babel.init_app(app)
app.elasticsearch = Elasticsearch([app.config['ELASTICSEARCH_URL']]) \ app.elasticsearch = Elasticsearch([app.config['ELASTICSEARCH_URL']]) \
if app.config['ELASTICSEARCH_URL'] else None if app.config['ELASTICSEARCH_URL'] else None
app.redis = Redis.from_url(app.config['REDIS_URL'])
app.task_queue = rq.Queue('microblog-tasks', connection=app.redis)
from app.errors import bp as errors_bp from app.errors import bp as errors_bp
app.register_blueprint(errors_bp) app.register_blueprint(errors_bp)
@ -46,6 +50,9 @@ def create_app(config_class=Config):
from app.main import bp as main_bp from app.main import bp as main_bp
app.register_blueprint(main_bp) app.register_blueprint(main_bp)
from app.api import bp as api_bp
app.register_blueprint(api_bp, url_prefix='/api')
if not app.debug and not app.testing: if not app.debug and not app.testing:
if app.config['MAIL_SERVER']: if app.config['MAIL_SERVER']:
auth = None auth = None

5
app/api/__init__.py Normal file
View File

@ -0,0 +1,5 @@
from flask import Blueprint
bp = Blueprint('api', __name__)
from app.api import users, errors, tokens

28
app/api/auth.py Normal file
View File

@ -0,0 +1,28 @@
from flask_httpauth import HTTPBasicAuth, HTTPTokenAuth
from app.models import User
from app.api.errors import error_response
basic_auth = HTTPBasicAuth()
token_auth = HTTPTokenAuth()
@basic_auth.verify_password
def verify_password(username, password):
user = User.query.filter_by(username=username).first()
if user and user.check_password(password):
return user
@basic_auth.error_handler
def basic_auth_error(status):
return error_response(status)
@token_auth.verify_token
def verify_token(token):
return User.check_token(token) if token else None
@token_auth.error_handler
def token_auth_error(status):
return error_response(status)

15
app/api/errors.py Normal file
View File

@ -0,0 +1,15 @@
from flask import jsonify
from werkzeug.http import HTTP_STATUS_CODES
def error_response(status_code, message=None):
payload = {'error': HTTP_STATUS_CODES.get(status_code, 'Unknown error')}
if message:
payload['message'] = message
response = jsonify(payload)
response.status_code = status_code
return response
def bad_request(message):
return error_response(400, message)

20
app/api/tokens.py Normal file
View File

@ -0,0 +1,20 @@
from flask import jsonify
from app import db
from app.api import bp
from app.api.auth import basic_auth, token_auth
@bp.route('/tokens', methods=['POST'])
@basic_auth.login_required
def get_token():
token = basic_auth.current_user().get_token()
db.session.commit()
return jsonify({'token': token})
@bp.route('/tokens', methods=['DELETE'])
@token_auth.login_required
def revoke_token():
token_auth.current_user().revoke_token()
db.session.commit()
return '', 204

80
app/api/users.py Normal file
View File

@ -0,0 +1,80 @@
from flask import jsonify, request, url_for, abort
from app import db
from app.models import User
from app.api import bp
from app.api.auth import token_auth
from app.api.errors import bad_request
@bp.route('/users/<int:id>', methods=['GET'])
@token_auth.login_required
def get_user(id):
return jsonify(User.query.get_or_404(id).to_dict())
@bp.route('/users', methods=['GET'])
@token_auth.login_required
def get_users():
page = request.args.get('page', 1, type=int)
per_page = min(request.args.get('per_page', 10, type=int), 100)
data = User.to_collection_dict(User.query, page, per_page, 'api.get_users')
return jsonify(data)
@bp.route('/users/<int:id>/followers', methods=['GET'])
@token_auth.login_required
def get_followers(id):
user = User.query.get_or_404(id)
page = request.args.get('page', 1, type=int)
per_page = min(request.args.get('per_page', 10, type=int), 100)
data = User.to_collection_dict(user.followers, page, per_page,
'api.get_followers', id=id)
return jsonify(data)
@bp.route('/users/<int:id>/followed', methods=['GET'])
@token_auth.login_required
def get_followed(id):
user = User.query.get_or_404(id)
page = request.args.get('page', 1, type=int)
per_page = min(request.args.get('per_page', 10, type=int), 100)
data = User.to_collection_dict(user.followed, page, per_page,
'api.get_followed', id=id)
return jsonify(data)
@bp.route('/users', methods=['POST'])
def create_user():
data = request.get_json() or {}
if 'username' not in data or 'email' not in data or 'password' not in data:
return bad_request('must include username, email and password fields')
if User.query.filter_by(username=data['username']).first():
return bad_request('please use a different username')
if User.query.filter_by(email=data['email']).first():
return bad_request('please use a different email address')
user = User()
user.from_dict(data, new_user=True)
db.session.add(user)
db.session.commit()
response = jsonify(user.to_dict())
response.status_code = 201
response.headers['Location'] = url_for('api.get_user', id=user.id)
return response
@bp.route('/users/<int:id>', methods=['PUT'])
@token_auth.login_required
def update_user(id):
if token_auth.current_user().id != id:
abort(403)
user = User.query.get_or_404(id)
data = request.get_json() or {}
if 'username' in data and data['username'] != user.username and \
User.query.filter_by(username=data['username']).first():
return bad_request('please use a different username')
if 'email' in data and data['email'] != user.email and \
User.query.filter_by(email=data['email']).first():
return bad_request('please use a different email address')
user.from_dict(data, new_user=False)
db.session.commit()
return jsonify(user.to_dict())

View File

@ -9,9 +9,16 @@ def send_async_email(app, msg):
mail.send(msg) mail.send(msg)
def send_email(subject, sender, recipients, text_body, html_body): def send_email(subject, sender, recipients, text_body, html_body,
attachments=None, sync=False):
msg = Message(subject, sender=sender, recipients=recipients) msg = Message(subject, sender=sender, recipients=recipients)
msg.body = text_body msg.body = text_body
msg.html = html_body msg.html = html_body
if attachments:
for attachment in attachments:
msg.attach(*attachment)
if sync:
mail.send(msg)
else:
Thread(target=send_async_email, Thread(target=send_async_email,
args=(current_app._get_current_object(), msg)).start() args=(current_app._get_current_object(), msg)).start()

View File

@ -1,14 +1,24 @@
from flask import render_template from flask import render_template, request
from app import db from app import db
from app.errors import bp from app.errors import bp
from app.api.errors import error_response as api_error_response
def wants_json_response():
return request.accept_mimetypes['application/json'] >= \
request.accept_mimetypes['text/html']
@bp.app_errorhandler(404) @bp.app_errorhandler(404)
def not_found_error(error): def not_found_error(error):
if wants_json_response():
return api_error_response(404)
return render_template('errors/404.html'), 404 return render_template('errors/404.html'), 404
@bp.app_errorhandler(500) @bp.app_errorhandler(500)
def internal_error(error): def internal_error(error):
db.session.rollback() db.session.rollback()
if wants_json_response():
return api_error_response(500)
return render_template('errors/500.html'), 500 return render_template('errors/500.html'), 500

View File

@ -208,6 +208,17 @@ def messages():
next_url=next_url, prev_url=prev_url) next_url=next_url, prev_url=prev_url)
@bp.route('/export_posts')
@login_required
def export_posts():
if current_user.get_task_in_progress('export_posts'):
flash(_('An export task is currently in progress'))
else:
current_user.launch_task('export_posts', _('Exporting posts...'))
db.session.commit()
return redirect(url_for('main.user', username=current_user.username))
@bp.route('/notifications') @bp.route('/notifications')
@login_required @login_required
def notifications(): def notifications():

View File

@ -1,11 +1,15 @@
from datetime import datetime import base64
from datetime import datetime, timedelta
from hashlib import md5 from hashlib import md5
import json import json
import os
from time import time from time import time
from flask import current_app from flask import current_app, url_for
from flask_login import UserMixin from flask_login import UserMixin
from werkzeug.security import generate_password_hash, check_password_hash from werkzeug.security import generate_password_hash, check_password_hash
import jwt import jwt
import redis
import rq
from app import db, login from app import db, login
from app.search import add_to_index, remove_from_index, query_index from app.search import add_to_index, remove_from_index, query_index
@ -53,6 +57,31 @@ db.event.listen(db.session, 'before_commit', SearchableMixin.before_commit)
db.event.listen(db.session, 'after_commit', SearchableMixin.after_commit) db.event.listen(db.session, 'after_commit', SearchableMixin.after_commit)
class PaginatedAPIMixin(object):
@staticmethod
def to_collection_dict(query, page, per_page, endpoint, **kwargs):
resources = query.paginate(page=page, per_page=per_page,
error_out=False)
data = {
'items': [item.to_dict() for item in resources.items],
'_meta': {
'page': page,
'per_page': per_page,
'total_pages': resources.pages,
'total_items': resources.total
},
'_links': {
'self': url_for(endpoint, page=page, per_page=per_page,
**kwargs),
'next': url_for(endpoint, page=page + 1, per_page=per_page,
**kwargs) if resources.has_next else None,
'prev': url_for(endpoint, page=page - 1, per_page=per_page,
**kwargs) if resources.has_prev else None
}
}
return data
followers = db.Table( followers = db.Table(
'followers', 'followers',
db.Column('follower_id', db.Integer, db.ForeignKey('user.id')), db.Column('follower_id', db.Integer, db.ForeignKey('user.id')),
@ -60,7 +89,7 @@ followers = db.Table(
) )
class User(UserMixin, db.Model): class User(UserMixin, PaginatedAPIMixin, db.Model):
id = db.Column(db.Integer, primary_key=True) id = db.Column(db.Integer, primary_key=True)
username = db.Column(db.String(64), index=True, unique=True) username = db.Column(db.String(64), index=True, unique=True)
email = db.Column(db.String(120), index=True, unique=True) email = db.Column(db.String(120), index=True, unique=True)
@ -68,6 +97,8 @@ class User(UserMixin, db.Model):
posts = db.relationship('Post', backref='author', lazy='dynamic') posts = db.relationship('Post', backref='author', lazy='dynamic')
about_me = db.Column(db.String(140)) about_me = db.Column(db.String(140))
last_seen = db.Column(db.DateTime, default=datetime.utcnow) last_seen = db.Column(db.DateTime, default=datetime.utcnow)
token = db.Column(db.String(32), index=True, unique=True)
token_expiration = db.Column(db.DateTime)
followed = db.relationship( followed = db.relationship(
'User', secondary=followers, 'User', secondary=followers,
primaryjoin=(followers.c.follower_id == id), primaryjoin=(followers.c.follower_id == id),
@ -82,6 +113,7 @@ class User(UserMixin, db.Model):
last_message_read_time = db.Column(db.DateTime) last_message_read_time = db.Column(db.DateTime)
notifications = db.relationship('Notification', backref='user', notifications = db.relationship('Notification', backref='user',
lazy='dynamic') lazy='dynamic')
tasks = db.relationship('Task', backref='user', lazy='dynamic')
def __repr__(self): def __repr__(self):
return '<User {}>'.format(self.username) return '<User {}>'.format(self.username)
@ -141,6 +173,67 @@ class User(UserMixin, db.Model):
db.session.add(n) db.session.add(n)
return n return n
def launch_task(self, name, description, *args, **kwargs):
rq_job = current_app.task_queue.enqueue('app.tasks.' + name, self.id,
*args, **kwargs)
task = Task(id=rq_job.get_id(), name=name, description=description,
user=self)
db.session.add(task)
return task
def get_tasks_in_progress(self):
return Task.query.filter_by(user=self, complete=False).all()
def get_task_in_progress(self, name):
return Task.query.filter_by(name=name, user=self,
complete=False).first()
def to_dict(self, include_email=False):
data = {
'id': self.id,
'username': self.username,
'last_seen': self.last_seen.isoformat() + 'Z',
'about_me': self.about_me,
'post_count': self.posts.count(),
'follower_count': self.followers.count(),
'followed_count': self.followed.count(),
'_links': {
'self': url_for('api.get_user', id=self.id),
'followers': url_for('api.get_followers', id=self.id),
'followed': url_for('api.get_followed', id=self.id),
'avatar': self.avatar(128)
}
}
if include_email:
data['email'] = self.email
return data
def from_dict(self, data, new_user=False):
for field in ['username', 'email', 'about_me']:
if field in data:
setattr(self, field, data[field])
if new_user and 'password' in data:
self.set_password(data['password'])
def get_token(self, expires_in=3600):
now = datetime.utcnow()
if self.token and self.token_expiration > now + timedelta(seconds=60):
return self.token
self.token = base64.b64encode(os.urandom(24)).decode('utf-8')
self.token_expiration = now + timedelta(seconds=expires_in)
db.session.add(self)
return self.token
def revoke_token(self):
self.token_expiration = datetime.utcnow() - timedelta(seconds=1)
@staticmethod
def check_token(token):
user = User.query.filter_by(token=token).first()
if user is None or user.token_expiration < datetime.utcnow():
return None
return user
@login.user_loader @login.user_loader
def load_user(id): def load_user(id):
@ -179,3 +272,22 @@ class Notification(db.Model):
def get_data(self): def get_data(self):
return json.loads(str(self.payload_json)) return json.loads(str(self.payload_json))
class Task(db.Model):
id = db.Column(db.String(36), primary_key=True)
name = db.Column(db.String(128), index=True)
description = db.Column(db.String(128))
user_id = db.Column(db.Integer, db.ForeignKey('user.id'))
complete = db.Column(db.Boolean, default=False)
def get_rq_job(self):
try:
rq_job = rq.job.Job.fetch(self.id, connection=current_app.redis)
except (redis.exceptions.RedisError, rq.exceptions.NoSuchJobError):
return None
return rq_job
def get_progress(self):
job = self.get_rq_job()
return job.meta.get('progress', 0) if job is not None else 100

51
app/tasks.py Normal file
View File

@ -0,0 +1,51 @@
import json
import sys
import time
from flask import render_template
from rq import get_current_job
from app import create_app, db
from app.models import User, Post, Task
from app.email import send_email
app = create_app()
app.app_context().push()
def _set_task_progress(progress):
job = get_current_job()
if job:
job.meta['progress'] = progress
job.save_meta()
task = Task.query.get(job.get_id())
task.user.add_notification('task_progress', {'task_id': job.get_id(),
'progress': progress})
if progress >= 100:
task.complete = True
db.session.commit()
def export_posts(user_id):
try:
user = User.query.get(user_id)
_set_task_progress(0)
data = []
i = 0
total_posts = user.posts.count()
for post in user.posts.order_by(Post.timestamp.asc()):
data.append({'body': post.body,
'timestamp': post.timestamp.isoformat() + 'Z'})
time.sleep(5)
i += 1
_set_task_progress(100 * i // total_posts)
send_email('[Microblog] Your blog posts',
sender=app.config['ADMINS'][0], recipients=[user.email],
text_body=render_template('email/export_posts.txt', user=user),
html_body=render_template('email/export_posts.html',
user=user),
attachments=[('posts.json', 'application/json',
json.dumps({'posts': data}, indent=4))],
sync=True)
except:
_set_task_progress(100)
app.logger.error('Unhandled exception', exc_info=sys.exc_info())

View File

@ -53,6 +53,18 @@
{% block content %} {% block content %}
<div class="container"> <div class="container">
{% if current_user.is_authenticated %}
{% with tasks = current_user.get_tasks_in_progress() %}
{% if tasks %}
{% for task in tasks %}
<div class="alert alert-success" role="alert">
{{ task.description }}
<span id="{{ task.id }}-progress">{{ task.get_progress() }}</span>%
</div>
{% endfor %}
{% endif %}
{% endwith %}
{% endif %}
{% with messages = get_flashed_messages() %} {% with messages = get_flashed_messages() %}
{% if messages %} {% if messages %}
{% for message in messages %} {% for message in messages %}
@ -129,6 +141,9 @@
$('#message_count').text(n); $('#message_count').text(n);
$('#message_count').css('visibility', n ? 'visible' : 'hidden'); $('#message_count').css('visibility', n ? 'visible' : 'hidden');
} }
function set_task_progress(task_id, progress) {
$('#' + task_id + '-progress').text(progress);
}
{% if current_user.is_authenticated %} {% if current_user.is_authenticated %}
$(function() { $(function() {
var since = 0; var since = 0;
@ -136,8 +151,15 @@
$.ajax('{{ url_for('main.notifications') }}?since=' + since).done( $.ajax('{{ url_for('main.notifications') }}?since=' + since).done(
function(notifications) { function(notifications) {
for (var i = 0; i < notifications.length; i++) { for (var i = 0; i < notifications.length; i++) {
if (notifications[i].name == 'unread_message_count') switch (notifications[i].name) {
case 'unread_message_count':
set_message_count(notifications[i].data); set_message_count(notifications[i].data);
break;
case 'task_progress':
set_task_progress(notifications[i].data.task_id,
notifications[i].data.progress);
break;
}
since = notifications[i].timestamp; since = notifications[i].timestamp;
} }
} }

View File

@ -0,0 +1,4 @@
<p>Dear {{ user.username }},</p>
<p>Please find attached the archive of your posts that you requested.</p>
<p>Sincerely,</p>
<p>The Microblog Team</p>

View File

@ -0,0 +1,7 @@
Dear {{ user.username }},
Please find attached the archive of your posts that you requested.
Sincerely,
The Microblog Team

View File

@ -13,6 +13,9 @@
<p>{{ _('%(count)d followers', count=user.followers.count()) }}, {{ _('%(count)d following', count=user.followed.count()) }}</p> <p>{{ _('%(count)d followers', count=user.followers.count()) }}, {{ _('%(count)d following', count=user.followed.count()) }}</p>
{% if user == current_user %} {% if user == current_user %}
<p><a href="{{ url_for('main.edit_profile') }}">{{ _('Edit your profile') }}</a></p> <p><a href="{{ url_for('main.edit_profile') }}">{{ _('Edit your profile') }}</a></p>
{% if not current_user.get_task_in_progress('export_posts') %}
<p><a href="{{ url_for('main.export_posts') }}">{{ _('Export your posts') }}</a></p>
{% endif %}
{% elif not current_user.is_following(user) %} {% elif not current_user.is_following(user) %}
<p> <p>
<form action="{{ url_for('main.follow', username=user.username) }}" method="post"> <form action="{{ url_for('main.follow', username=user.username) }}" method="post">

View File

@ -7,7 +7,7 @@ msgid ""
msgstr "" msgstr ""
"Project-Id-Version: PROJECT VERSION\n" "Project-Id-Version: PROJECT VERSION\n"
"Report-Msgid-Bugs-To: EMAIL@ADDRESS\n" "Report-Msgid-Bugs-To: EMAIL@ADDRESS\n"
"POT-Creation-Date: 2017-11-25 18:26-0800\n" "POT-Creation-Date: 2017-11-25 18:27-0800\n"
"PO-Revision-Date: 2017-09-29 23:25-0700\n" "PO-Revision-Date: 2017-09-29 23:25-0700\n"
"Last-Translator: FULL NAME <EMAIL@ADDRESS>\n" "Last-Translator: FULL NAME <EMAIL@ADDRESS>\n"
"Language: es\n" "Language: es\n"
@ -18,7 +18,7 @@ msgstr ""
"Content-Transfer-Encoding: 8bit\n" "Content-Transfer-Encoding: 8bit\n"
"Generated-By: Babel 2.5.1\n" "Generated-By: Babel 2.5.1\n"
#: app/__init__.py:18 #: app/__init__.py:20
msgid "Please log in to access this page." msgid "Please log in to access this page."
msgstr "Por favor ingrese para acceder a esta página." msgstr "Por favor ingrese para acceder a esta página."
@ -153,6 +153,14 @@ msgstr "Tu mensaje ha sido enviado."
msgid "Send Message" msgid "Send Message"
msgstr "Enviar Mensaje" msgstr "Enviar Mensaje"
#: app/main/routes.py:197
msgid "An export task is currently in progress"
msgstr "Una tarea de exportación esta en progreso"
#: app/main/routes.py:199
msgid "Exporting posts..."
msgstr "Exportando artículos..."
#: app/templates/_post.html:16 #: app/templates/_post.html:16
#, python-format #, python-format
msgid "%(username)s said %(when)s" msgid "%(username)s said %(when)s"
@ -190,7 +198,7 @@ msgstr "Perfil"
msgid "Logout" msgid "Logout"
msgstr "Salir" msgstr "Salir"
#: app/templates/base.html:83 #: app/templates/base.html:95
msgid "Error: Could not contact server." msgid "Error: Could not contact server."
msgstr "Error: el servidor no pudo ser contactado." msgstr "Error: el servidor no pudo ser contactado."
@ -199,11 +207,11 @@ msgstr "Error: el servidor no pudo ser contactado."
msgid "Hi, %(username)s!" msgid "Hi, %(username)s!"
msgstr "¡Hola, %(username)s!" msgstr "¡Hola, %(username)s!"
#: app/templates/index.html:17 app/templates/user.html:34 #: app/templates/index.html:17 app/templates/user.html:37
msgid "Newer posts" msgid "Newer posts"
msgstr "Artículos siguientes" msgstr "Artículos siguientes"
#: app/templates/index.html:22 app/templates/user.html:39 #: app/templates/index.html:22 app/templates/user.html:42
msgid "Older posts" msgid "Older posts"
msgstr "Artículos previos" msgstr "Artículos previos"
@ -254,15 +262,19 @@ msgstr "siguiendo a %(count)d"
msgid "Edit your profile" msgid "Edit your profile"
msgstr "Editar tu perfil" msgstr "Editar tu perfil"
#: app/templates/user.html:17 app/templates/user_popup.html:14 #: app/templates/user.html:17
msgid "Export your posts"
msgstr "Exportar tus artículos"
#: app/templates/user.html:20 app/templates/user_popup.html:14
msgid "Follow" msgid "Follow"
msgstr "Seguir" msgstr "Seguir"
#: app/templates/user.html:19 app/templates/user_popup.html:16 #: app/templates/user.html:22 app/templates/user_popup.html:16
msgid "Unfollow" msgid "Unfollow"
msgstr "Dejar de seguir" msgstr "Dejar de seguir"
#: app/templates/user.html:22 #: app/templates/user.html:25
msgid "Send private message" msgid "Send private message"
msgstr "Enviar mensaje privado" msgstr "Enviar mensaje privado"

View File

@ -21,4 +21,5 @@ class Config(object):
LANGUAGES = ['en', 'es'] LANGUAGES = ['en', 'es']
MS_TRANSLATOR_KEY = os.environ.get('MS_TRANSLATOR_KEY') MS_TRANSLATOR_KEY = os.environ.get('MS_TRANSLATOR_KEY')
ELASTICSEARCH_URL = os.environ.get('ELASTICSEARCH_URL') ELASTICSEARCH_URL = os.environ.get('ELASTICSEARCH_URL')
REDIS_URL = os.environ.get('REDIS_URL') or 'redis://'
POSTS_PER_PAGE = 25 POSTS_PER_PAGE = 25

View File

@ -0,0 +1,9 @@
[program:microblog-tasks]
command=/home/ubuntu/microblog/venv/bin/rq worker microblog-tasks
numprocs=1
directory=/home/ubuntu/microblog
user=ubuntu
autostart=true
autorestart=true
stopasgroup=true
killasgroup=true

View File

@ -1,5 +1,5 @@
from app import create_app, db, cli from app import create_app, db, cli
from app.models import User, Post, Message, Notification from app.models import User, Post, Message, Notification, Task
app = create_app() app = create_app()
cli.register(app) cli.register(app)
@ -8,4 +8,4 @@ cli.register(app)
@app.shell_context_processor @app.shell_context_processor
def make_shell_context(): def make_shell_context():
return {'db': db, 'User': User, 'Post': Post, 'Message': Message, return {'db': db, 'User': User, 'Post': Post, 'Message': Message,
'Notification': Notification} 'Notification': Notification, 'Task': Task}

View File

@ -0,0 +1,32 @@
"""user tokens
Revision ID: 834b1a697901
Revises: c81bac34faab
Create Date: 2017-11-05 18:41:07.996137
"""
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = '834b1a697901'
down_revision = 'c81bac34faab'
branch_labels = None
depends_on = None
def upgrade():
# ### commands auto generated by Alembic - please adjust! ###
op.add_column('user', sa.Column('token', sa.String(length=32), nullable=True))
op.add_column('user', sa.Column('token_expiration', sa.DateTime(), nullable=True))
op.create_index(op.f('ix_user_token'), 'user', ['token'], unique=True)
# ### end Alembic commands ###
def downgrade():
# ### commands auto generated by Alembic - please adjust! ###
op.drop_index(op.f('ix_user_token'), table_name='user')
op.drop_column('user', 'token_expiration')
op.drop_column('user', 'token')
# ### end Alembic commands ###

View File

@ -0,0 +1,38 @@
"""tasks
Revision ID: c81bac34faab
Revises: f7ac3d27bb1d
Create Date: 2017-11-23 10:56:49.599779
"""
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = 'c81bac34faab'
down_revision = 'f7ac3d27bb1d'
branch_labels = None
depends_on = None
def upgrade():
# ### commands auto generated by Alembic - please adjust! ###
op.create_table('task',
sa.Column('id', sa.String(length=36), nullable=False),
sa.Column('name', sa.String(length=128), nullable=True),
sa.Column('description', sa.String(length=128), nullable=True),
sa.Column('user_id', sa.Integer(), nullable=True),
sa.Column('complete', sa.Boolean(), nullable=True),
sa.ForeignKeyConstraint(['user_id'], ['user.id'], ),
sa.PrimaryKeyConstraint('id')
)
op.create_index(op.f('ix_task_name'), 'task', ['name'], unique=False)
# ### end Alembic commands ###
def downgrade():
# ### commands auto generated by Alembic - please adjust! ###
op.drop_index(op.f('ix_task_name'), table_name='task')
op.drop_table('task')
# ### end Alembic commands ###

View File

@ -11,6 +11,7 @@ email-validator==1.1.3
Flask==2.0.1 Flask==2.0.1
Flask-Babel==2.0.0 Flask-Babel==2.0.0
Flask-Bootstrap==3.3.7.1 Flask-Bootstrap==3.3.7.1
Flask-HTTPAuth==4.4.0
Flask-Login==0.5.0 Flask-Login==0.5.0
Flask-Mail==0.9.1 Flask-Mail==0.9.1
Flask-Migrate==3.0.1 Flask-Migrate==3.0.1
@ -18,18 +19,24 @@ Flask-Moment==1.0.1
Flask-SQLAlchemy==2.5.1 Flask-SQLAlchemy==2.5.1
Flask-WTF==0.15.1 Flask-WTF==0.15.1
greenlet==1.1.0 greenlet==1.1.0
httpie==2.4.0
idna==2.10 idna==2.10
itsdangerous==2.0.1 itsdangerous==2.0.1
Jinja2==3.0.1 Jinja2==3.0.1
langdetect==1.0.9 langdetect==1.0.9
Mako==1.1.4 Mako==1.1.4
MarkupSafe==2.0.1 MarkupSafe==2.0.1
Pygments==2.9.0
PyJWT==2.1.0 PyJWT==2.1.0
PySocks==1.7.1
python-dateutil==2.8.1 python-dateutil==2.8.1
python-dotenv==0.18.0 python-dotenv==0.18.0
python-editor==1.0.4 python-editor==1.0.4
pytz==2021.1 pytz==2021.1
redis==3.5.3
requests==2.25.1 requests==2.25.1
requests-toolbelt==0.9.1
rq==1.9.0
six==1.16.0 six==1.16.0
SQLAlchemy==1.4.20 SQLAlchemy==1.4.20
urllib3==1.26.6 urllib3==1.26.6