Compare commits

..

12 Commits
v0.11 ... main

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
Miguel Grinberg a25d6f2f19
Chapter 21: User Notifications (v0.21) 2022-11-11 12:56:42 +00:00
Miguel Grinberg 7f32336d21
Chapter 20: Some JavaScript Magic (v0.20) 2022-11-11 12:52:59 +00:00
Miguel Grinberg b577aa6b62
Chapter 19: Deployment on Docker Containers (v0.19) 2022-11-11 12:52:59 +00:00
Miguel Grinberg 5cfb75def5
Chapter 18: Deployment on Heroku (v0.18) 2022-11-11 12:52:59 +00:00
Miguel Grinberg 94b40cf4b7
Chapter 17: Deployment on Linux (v0.17) 2022-11-11 12:52:59 +00:00
Miguel Grinberg 868ad4b410
Chapter 16: Full-Text Search (v0.16) 2022-11-11 12:52:58 +00:00
Miguel Grinberg f4a401e320
Chapter 15: A Better Application Structure (v0.15) 2022-11-11 12:52:55 +00:00
Miguel Grinberg 7f7c8ef5d7
Chapter 14: Ajax (v0.14) 2022-11-11 12:51:46 +00:00
Miguel Grinberg 8a56c34ce2
Chapter 13: I18n and L10n (v0.13) 2022-11-11 12:51:46 +00:00
Miguel Grinberg a650e6f989
Chapter 12: Dates and Times (v0.12) 2022-11-11 12:51:46 +00:00
65 changed files with 2030 additions and 406 deletions

23
Dockerfile Normal file
View File

@ -0,0 +1,23 @@
FROM python:slim
RUN useradd microblog
WORKDIR /home/microblog
COPY requirements.txt requirements.txt
RUN python -m venv venv
RUN venv/bin/pip install -r requirements.txt
RUN venv/bin/pip install gunicorn pymysql cryptography
COPY app app
COPY migrations migrations
COPY microblog.py config.py boot.sh ./
RUN chmod a+x boot.sh
ENV FLASK_APP microblog.py
RUN chown -R microblog:microblog ./
USER microblog
EXPOSE 5000
ENTRYPOINT ["./boot.sh"]

2
Procfile Normal file
View File

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

7
Vagrantfile vendored Normal file
View File

@ -0,0 +1,7 @@
Vagrant.configure("2") do |config|
config.vm.box = "ubuntu/focal64"
config.vm.network "private_network", ip: "192.168.33.10"
config.vm.provider "virtualbox" do |vb|
vb.memory = "2048"
end
end

View File

@ -1,49 +1,99 @@
import logging
from logging.handlers import SMTPHandler, RotatingFileHandler
import os
from flask import Flask
from flask import Flask, request, current_app
from flask_sqlalchemy import SQLAlchemy
from flask_migrate import Migrate
from flask_login import LoginManager
from flask_mail import Mail
from flask_bootstrap import Bootstrap
from flask_moment import Moment
from flask_babel import Babel, lazy_gettext as _l
from elasticsearch import Elasticsearch
from redis import Redis
import rq
from config import Config
app = Flask(__name__)
app.config.from_object(Config)
db = SQLAlchemy(app)
migrate = Migrate(app, db)
login = LoginManager(app)
login.login_view = 'login'
mail = Mail(app)
bootstrap = Bootstrap(app)
db = SQLAlchemy()
migrate = Migrate()
login = LoginManager()
login.login_view = 'auth.login'
login.login_message = _l('Please log in to access this page.')
mail = Mail()
bootstrap = Bootstrap()
moment = Moment()
babel = Babel()
if not app.debug:
if app.config['MAIL_SERVER']:
auth = None
if app.config['MAIL_USERNAME'] or app.config['MAIL_PASSWORD']:
auth = (app.config['MAIL_USERNAME'], app.config['MAIL_PASSWORD'])
secure = None
if app.config['MAIL_USE_TLS']:
secure = ()
mail_handler = SMTPHandler(
mailhost=(app.config['MAIL_SERVER'], app.config['MAIL_PORT']),
fromaddr='no-reply@' + app.config['MAIL_SERVER'],
toaddrs=app.config['ADMINS'], subject='Microblog Failure',
credentials=auth, secure=secure)
mail_handler.setLevel(logging.ERROR)
app.logger.addHandler(mail_handler)
if not os.path.exists('logs'):
os.mkdir('logs')
file_handler = RotatingFileHandler('logs/microblog.log', maxBytes=10240,
backupCount=10)
file_handler.setFormatter(logging.Formatter(
'%(asctime)s %(levelname)s: %(message)s [in %(pathname)s:%(lineno)d]'))
file_handler.setLevel(logging.INFO)
app.logger.addHandler(file_handler)
def create_app(config_class=Config):
app = Flask(__name__)
app.config.from_object(config_class)
app.logger.setLevel(logging.INFO)
app.logger.info('Microblog startup')
db.init_app(app)
migrate.init_app(app, db)
login.init_app(app)
mail.init_app(app)
bootstrap.init_app(app)
moment.init_app(app)
babel.init_app(app)
app.elasticsearch = Elasticsearch([app.config['ELASTICSEARCH_URL']]) \
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 import routes, models, errors
from app.errors import bp as errors_bp
app.register_blueprint(errors_bp)
from app.auth import bp as auth_bp
app.register_blueprint(auth_bp, url_prefix='/auth')
from app.main import bp as 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 app.config['MAIL_SERVER']:
auth = None
if app.config['MAIL_USERNAME'] or app.config['MAIL_PASSWORD']:
auth = (app.config['MAIL_USERNAME'],
app.config['MAIL_PASSWORD'])
secure = None
if app.config['MAIL_USE_TLS']:
secure = ()
mail_handler = SMTPHandler(
mailhost=(app.config['MAIL_SERVER'], app.config['MAIL_PORT']),
fromaddr='no-reply@' + app.config['MAIL_SERVER'],
toaddrs=app.config['ADMINS'], subject='Microblog Failure',
credentials=auth, secure=secure)
mail_handler.setLevel(logging.ERROR)
app.logger.addHandler(mail_handler)
if app.config['LOG_TO_STDOUT']:
stream_handler = logging.StreamHandler()
stream_handler.setLevel(logging.INFO)
app.logger.addHandler(stream_handler)
else:
if not os.path.exists('logs'):
os.mkdir('logs')
file_handler = RotatingFileHandler('logs/microblog.log',
maxBytes=10240, backupCount=10)
file_handler.setFormatter(logging.Formatter(
'%(asctime)s %(levelname)s: %(message)s '
'[in %(pathname)s:%(lineno)d]'))
file_handler.setLevel(logging.INFO)
app.logger.addHandler(file_handler)
app.logger.setLevel(logging.INFO)
app.logger.info('Microblog startup')
return app
@babel.localeselector
def get_locale():
return request.accept_languages.best_match(current_app.config['LANGUAGES'])
from app import models

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())

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

@ -0,0 +1,5 @@
from flask import Blueprint
bp = Blueprint('auth', __name__)
from app.auth import routes

14
app/auth/email.py Normal file
View File

@ -0,0 +1,14 @@
from flask import render_template, current_app
from flask_babel import _
from app.email import send_email
def send_password_reset_email(user):
token = user.get_reset_password_token()
send_email(_('[Microblog] Reset Your Password'),
sender=current_app.config['ADMINS'][0],
recipients=[user.email],
text_body=render_template('email/reset_password.txt',
user=user, token=token),
html_body=render_template('email/reset_password.html',
user=user, token=token))

45
app/auth/forms.py Normal file
View File

@ -0,0 +1,45 @@
from flask_wtf import FlaskForm
from wtforms import StringField, PasswordField, BooleanField, SubmitField
from wtforms.validators import ValidationError, DataRequired, Email, EqualTo
from flask_babel import _, lazy_gettext as _l
from app.models import User
class LoginForm(FlaskForm):
username = StringField(_l('Username'), validators=[DataRequired()])
password = PasswordField(_l('Password'), validators=[DataRequired()])
remember_me = BooleanField(_l('Remember Me'))
submit = SubmitField(_l('Sign In'))
class RegistrationForm(FlaskForm):
username = StringField(_l('Username'), validators=[DataRequired()])
email = StringField(_l('Email'), validators=[DataRequired(), Email()])
password = PasswordField(_l('Password'), validators=[DataRequired()])
password2 = PasswordField(
_l('Repeat Password'), validators=[DataRequired(),
EqualTo('password')])
submit = SubmitField(_l('Register'))
def validate_username(self, username):
user = User.query.filter_by(username=username.data).first()
if user is not None:
raise ValidationError(_('Please use a different username.'))
def validate_email(self, email):
user = User.query.filter_by(email=email.data).first()
if user is not None:
raise ValidationError(_('Please use a different email address.'))
class ResetPasswordRequestForm(FlaskForm):
email = StringField(_l('Email'), validators=[DataRequired(), Email()])
submit = SubmitField(_l('Request Password Reset'))
class ResetPasswordForm(FlaskForm):
password = PasswordField(_l('Password'), validators=[DataRequired()])
password2 = PasswordField(
_l('Repeat Password'), validators=[DataRequired(),
EqualTo('password')])
submit = SubmitField(_l('Request Password Reset'))

82
app/auth/routes.py Normal file
View File

@ -0,0 +1,82 @@
from flask import render_template, redirect, url_for, flash, request
from werkzeug.urls import url_parse
from flask_login import login_user, logout_user, current_user
from flask_babel import _
from app import db
from app.auth import bp
from app.auth.forms import LoginForm, RegistrationForm, \
ResetPasswordRequestForm, ResetPasswordForm
from app.models import User
from app.auth.email import send_password_reset_email
@bp.route('/login', methods=['GET', 'POST'])
def login():
if current_user.is_authenticated:
return redirect(url_for('main.index'))
form = LoginForm()
if form.validate_on_submit():
user = User.query.filter_by(username=form.username.data).first()
if user is None or not user.check_password(form.password.data):
flash(_('Invalid username or password'))
return redirect(url_for('auth.login'))
login_user(user, remember=form.remember_me.data)
next_page = request.args.get('next')
if not next_page or url_parse(next_page).netloc != '':
next_page = url_for('main.index')
return redirect(next_page)
return render_template('auth/login.html', title=_('Sign In'), form=form)
@bp.route('/logout')
def logout():
logout_user()
return redirect(url_for('main.index'))
@bp.route('/register', methods=['GET', 'POST'])
def register():
if current_user.is_authenticated:
return redirect(url_for('main.index'))
form = RegistrationForm()
if form.validate_on_submit():
user = User(username=form.username.data, email=form.email.data)
user.set_password(form.password.data)
db.session.add(user)
db.session.commit()
flash(_('Congratulations, you are now a registered user!'))
return redirect(url_for('auth.login'))
return render_template('auth/register.html', title=_('Register'),
form=form)
@bp.route('/reset_password_request', methods=['GET', 'POST'])
def reset_password_request():
if current_user.is_authenticated:
return redirect(url_for('main.index'))
form = ResetPasswordRequestForm()
if form.validate_on_submit():
user = User.query.filter_by(email=form.email.data).first()
if user:
send_password_reset_email(user)
flash(
_('Check your email for the instructions to reset your password'))
return redirect(url_for('auth.login'))
return render_template('auth/reset_password_request.html',
title=_('Reset Password'), form=form)
@bp.route('/reset_password/<token>', methods=['GET', 'POST'])
def reset_password(token):
if current_user.is_authenticated:
return redirect(url_for('main.index'))
user = User.verify_reset_password_token(token)
if not user:
return redirect(url_for('main.index'))
form = ResetPasswordForm()
if form.validate_on_submit():
user.set_password(form.password.data)
db.session.commit()
flash(_('Your password has been reset.'))
return redirect(url_for('auth.login'))
return render_template('auth/reset_password.html', form=form)

35
app/cli.py Normal file
View File

@ -0,0 +1,35 @@
import os
import click
def register(app):
@app.cli.group()
def translate():
"""Translation and localization commands."""
pass
@translate.command()
@click.argument('lang')
def init(lang):
"""Initialize a new language."""
if os.system('pybabel extract -F babel.cfg -k _l -o messages.pot .'):
raise RuntimeError('extract command failed')
if os.system(
'pybabel init -i messages.pot -d app/translations -l ' + lang):
raise RuntimeError('init command failed')
os.remove('messages.pot')
@translate.command()
def update():
"""Update all languages."""
if os.system('pybabel extract -F babel.cfg -k _l -o messages.pot .'):
raise RuntimeError('extract command failed')
if os.system('pybabel update -i messages.pot -d app/translations'):
raise RuntimeError('update command failed')
os.remove('messages.pot')
@translate.command()
def compile():
"""Compile all languages."""
if os.system('pybabel compile -d app/translations'):
raise RuntimeError('compile command failed')

View File

@ -1,7 +1,7 @@
from threading import Thread
from flask import render_template
from flask import current_app
from flask_mail import Message
from app import app, mail
from app import mail
def send_async_email(app, msg):
@ -9,19 +9,16 @@ def send_async_email(app, 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.body = text_body
msg.html = html_body
Thread(target=send_async_email, args=(app, msg)).start()
def send_password_reset_email(user):
token = user.get_reset_password_token()
send_email('[Microblog] Reset Your Password',
sender=app.config['ADMINS'][0],
recipients=[user.email],
text_body=render_template('email/reset_password.txt',
user=user, token=token),
html_body=render_template('email/reset_password.html',
user=user, token=token))
if attachments:
for attachment in attachments:
msg.attach(*attachment)
if sync:
mail.send(msg)
else:
Thread(target=send_async_email,
args=(current_app._get_current_object(), msg)).start()

View File

@ -1,13 +0,0 @@
from flask import render_template
from app import app, db
@app.errorhandler(404)
def not_found_error(error):
return render_template('404.html'), 404
@app.errorhandler(500)
def internal_error(error):
db.session.rollback()
return render_template('500.html'), 500

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

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

24
app/errors/handlers.py Normal file
View File

@ -0,0 +1,24 @@
from flask import render_template, request
from app import db
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)
def not_found_error(error):
if wants_json_response():
return api_error_response(404)
return render_template('errors/404.html'), 404
@bp.app_errorhandler(500)
def internal_error(error):
db.session.rollback()
if wants_json_response():
return api_error_response(500)
return render_template('errors/500.html'), 500

View File

@ -1,69 +0,0 @@
from flask_wtf import FlaskForm
from wtforms import StringField, PasswordField, BooleanField, SubmitField, \
TextAreaField
from wtforms.validators import ValidationError, DataRequired, Email, EqualTo, \
Length
from app.models import User
class LoginForm(FlaskForm):
username = StringField('Username', validators=[DataRequired()])
password = PasswordField('Password', validators=[DataRequired()])
remember_me = BooleanField('Remember Me')
submit = SubmitField('Sign In')
class RegistrationForm(FlaskForm):
username = StringField('Username', validators=[DataRequired()])
email = StringField('Email', validators=[DataRequired(), Email()])
password = PasswordField('Password', validators=[DataRequired()])
password2 = PasswordField(
'Repeat Password', validators=[DataRequired(), EqualTo('password')])
submit = SubmitField('Register')
def validate_username(self, username):
user = User.query.filter_by(username=username.data).first()
if user is not None:
raise ValidationError('Please use a different username.')
def validate_email(self, email):
user = User.query.filter_by(email=email.data).first()
if user is not None:
raise ValidationError('Please use a different email address.')
class ResetPasswordRequestForm(FlaskForm):
email = StringField('Email', validators=[DataRequired(), Email()])
submit = SubmitField('Request Password Reset')
class ResetPasswordForm(FlaskForm):
password = PasswordField('Password', validators=[DataRequired()])
password2 = PasswordField(
'Repeat Password', validators=[DataRequired(), EqualTo('password')])
submit = SubmitField('Request Password Reset')
class EditProfileForm(FlaskForm):
username = StringField('Username', validators=[DataRequired()])
about_me = TextAreaField('About me', validators=[Length(min=0, max=140)])
submit = SubmitField('Submit')
def __init__(self, original_username, *args, **kwargs):
super(EditProfileForm, self).__init__(*args, **kwargs)
self.original_username = original_username
def validate_username(self, username):
if username.data != self.original_username:
user = User.query.filter_by(username=self.username.data).first()
if user is not None:
raise ValidationError('Please use a different username.')
class EmptyForm(FlaskForm):
submit = SubmitField('Submit')
class PostForm(FlaskForm):
post = TextAreaField('Say something', validators=[DataRequired()])
submit = SubmitField('Submit')

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

@ -0,0 +1,5 @@
from flask import Blueprint
bp = Blueprint('main', __name__)
from app.main import routes

49
app/main/forms.py Normal file
View File

@ -0,0 +1,49 @@
from flask import request
from flask_wtf import FlaskForm
from wtforms import StringField, SubmitField, TextAreaField
from wtforms.validators import ValidationError, DataRequired, Length
from flask_babel import _, lazy_gettext as _l
from app.models import User
class EditProfileForm(FlaskForm):
username = StringField(_l('Username'), validators=[DataRequired()])
about_me = TextAreaField(_l('About me'),
validators=[Length(min=0, max=140)])
submit = SubmitField(_l('Submit'))
def __init__(self, original_username, *args, **kwargs):
super(EditProfileForm, self).__init__(*args, **kwargs)
self.original_username = original_username
def validate_username(self, username):
if username.data != self.original_username:
user = User.query.filter_by(username=self.username.data).first()
if user is not None:
raise ValidationError(_('Please use a different username.'))
class EmptyForm(FlaskForm):
submit = SubmitField('Submit')
class PostForm(FlaskForm):
post = TextAreaField(_l('Say something'), validators=[DataRequired()])
submit = SubmitField(_l('Submit'))
class SearchForm(FlaskForm):
q = StringField(_l('Search'), validators=[DataRequired()])
def __init__(self, *args, **kwargs):
if 'formdata' not in kwargs:
kwargs['formdata'] = request.args
if 'meta' not in kwargs:
kwargs['meta'] = {'csrf': False}
super(SearchForm, self).__init__(*args, **kwargs)
class MessageForm(FlaskForm):
message = TextAreaField(_l('Message'), validators=[
DataRequired(), Length(min=1, max=140)])
submit = SubmitField(_l('Submit'))

232
app/main/routes.py Normal file
View File

@ -0,0 +1,232 @@
from datetime import datetime
from flask import render_template, flash, redirect, url_for, request, g, \
jsonify, current_app
from flask_login import current_user, login_required
from flask_babel import _, get_locale
from langdetect import detect, LangDetectException
from app import db
from app.main.forms import EditProfileForm, EmptyForm, PostForm, SearchForm, \
MessageForm
from app.models import User, Post, Message, Notification
from app.translate import translate
from app.main import bp
@bp.before_app_request
def before_request():
if current_user.is_authenticated:
current_user.last_seen = datetime.utcnow()
db.session.commit()
g.search_form = SearchForm()
g.locale = str(get_locale())
@bp.route('/', methods=['GET', 'POST'])
@bp.route('/index', methods=['GET', 'POST'])
@login_required
def index():
form = PostForm()
if form.validate_on_submit():
try:
language = detect(form.post.data)
except LangDetectException:
language = ''
post = Post(body=form.post.data, author=current_user,
language=language)
db.session.add(post)
db.session.commit()
flash(_('Your post is now live!'))
return redirect(url_for('main.index'))
page = request.args.get('page', 1, type=int)
posts = current_user.followed_posts().paginate(
page=page, per_page=current_app.config['POSTS_PER_PAGE'],
error_out=False)
next_url = url_for('main.index', page=posts.next_num) \
if posts.has_next else None
prev_url = url_for('main.index', page=posts.prev_num) \
if posts.has_prev else None
return render_template('index.html', title=_('Home'), form=form,
posts=posts.items, next_url=next_url,
prev_url=prev_url)
@bp.route('/explore')
@login_required
def explore():
page = request.args.get('page', 1, type=int)
posts = Post.query.order_by(Post.timestamp.desc()).paginate(
page=page, per_page=current_app.config['POSTS_PER_PAGE'],
error_out=False)
next_url = url_for('main.explore', page=posts.next_num) \
if posts.has_next else None
prev_url = url_for('main.explore', page=posts.prev_num) \
if posts.has_prev else None
return render_template('index.html', title=_('Explore'),
posts=posts.items, next_url=next_url,
prev_url=prev_url)
@bp.route('/user/<username>')
@login_required
def user(username):
user = User.query.filter_by(username=username).first_or_404()
page = request.args.get('page', 1, type=int)
posts = user.posts.order_by(Post.timestamp.desc()).paginate(
page=page, per_page=current_app.config['POSTS_PER_PAGE'],
error_out=False)
next_url = url_for('main.user', username=user.username,
page=posts.next_num) if posts.has_next else None
prev_url = url_for('main.user', username=user.username,
page=posts.prev_num) if posts.has_prev else None
form = EmptyForm()
return render_template('user.html', user=user, posts=posts.items,
next_url=next_url, prev_url=prev_url, form=form)
@bp.route('/user/<username>/popup')
@login_required
def user_popup(username):
user = User.query.filter_by(username=username).first_or_404()
form = EmptyForm()
return render_template('user_popup.html', user=user, form=form)
@bp.route('/edit_profile', methods=['GET', 'POST'])
@login_required
def edit_profile():
form = EditProfileForm(current_user.username)
if form.validate_on_submit():
current_user.username = form.username.data
current_user.about_me = form.about_me.data
db.session.commit()
flash(_('Your changes have been saved.'))
return redirect(url_for('main.edit_profile'))
elif request.method == 'GET':
form.username.data = current_user.username
form.about_me.data = current_user.about_me
return render_template('edit_profile.html', title=_('Edit Profile'),
form=form)
@bp.route('/follow/<username>', methods=['POST'])
@login_required
def follow(username):
form = EmptyForm()
if form.validate_on_submit():
user = User.query.filter_by(username=username).first()
if user is None:
flash(_('User %(username)s not found.', username=username))
return redirect(url_for('main.index'))
if user == current_user:
flash(_('You cannot follow yourself!'))
return redirect(url_for('main.user', username=username))
current_user.follow(user)
db.session.commit()
flash(_('You are following %(username)s!', username=username))
return redirect(url_for('main.user', username=username))
else:
return redirect(url_for('main.index'))
@bp.route('/unfollow/<username>', methods=['POST'])
@login_required
def unfollow(username):
form = EmptyForm()
if form.validate_on_submit():
user = User.query.filter_by(username=username).first()
if user is None:
flash(_('User %(username)s not found.', username=username))
return redirect(url_for('main.index'))
if user == current_user:
flash(_('You cannot unfollow yourself!'))
return redirect(url_for('main.user', username=username))
current_user.unfollow(user)
db.session.commit()
flash(_('You are not following %(username)s.', username=username))
return redirect(url_for('main.user', username=username))
else:
return redirect(url_for('main.index'))
@bp.route('/translate', methods=['POST'])
@login_required
def translate_text():
return jsonify({'text': translate(request.form['text'],
request.form['source_language'],
request.form['dest_language'])})
@bp.route('/search')
@login_required
def search():
if not g.search_form.validate():
return redirect(url_for('main.explore'))
page = request.args.get('page', 1, type=int)
posts, total = Post.search(g.search_form.q.data, page,
current_app.config['POSTS_PER_PAGE'])
next_url = url_for('main.search', q=g.search_form.q.data, page=page + 1) \
if total > page * current_app.config['POSTS_PER_PAGE'] else None
prev_url = url_for('main.search', q=g.search_form.q.data, page=page - 1) \
if page > 1 else None
return render_template('search.html', title=_('Search'), posts=posts,
next_url=next_url, prev_url=prev_url)
@bp.route('/send_message/<recipient>', methods=['GET', 'POST'])
@login_required
def send_message(recipient):
user = User.query.filter_by(username=recipient).first_or_404()
form = MessageForm()
if form.validate_on_submit():
msg = Message(author=current_user, recipient=user,
body=form.message.data)
db.session.add(msg)
user.add_notification('unread_message_count', user.new_messages())
db.session.commit()
flash(_('Your message has been sent.'))
return redirect(url_for('main.user', username=recipient))
return render_template('send_message.html', title=_('Send Message'),
form=form, recipient=recipient)
@bp.route('/messages')
@login_required
def messages():
current_user.last_message_read_time = datetime.utcnow()
current_user.add_notification('unread_message_count', 0)
db.session.commit()
page = request.args.get('page', 1, type=int)
messages = current_user.messages_received.order_by(
Message.timestamp.desc()).paginate(
page=page, per_page=current_app.config['POSTS_PER_PAGE'],
error_out=False)
next_url = url_for('main.messages', page=messages.next_num) \
if messages.has_next else None
prev_url = url_for('main.messages', page=messages.prev_num) \
if messages.has_prev else None
return render_template('messages.html', messages=messages.items,
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')
@login_required
def notifications():
since = request.args.get('since', 0.0, type=float)
notifications = current_user.notifications.filter(
Notification.timestamp > since).order_by(Notification.timestamp.asc())
return jsonify([{
'name': n.name,
'data': n.get_data(),
'timestamp': n.timestamp
} for n in notifications])

View File

@ -1,10 +1,85 @@
from datetime import datetime
import base64
from datetime import datetime, timedelta
from hashlib import md5
import json
import os
from time import time
from flask import current_app, url_for
from flask_login import UserMixin
from werkzeug.security import generate_password_hash, check_password_hash
import jwt
from app import app, db, login
import redis
import rq
from app import db, login
from app.search import add_to_index, remove_from_index, query_index
class SearchableMixin(object):
@classmethod
def search(cls, expression, page, per_page):
ids, total = query_index(cls.__tablename__, expression, page, per_page)
if total == 0:
return cls.query.filter_by(id=0), 0
when = []
for i in range(len(ids)):
when.append((ids[i], i))
return cls.query.filter(cls.id.in_(ids)).order_by(
db.case(when, value=cls.id)), total
@classmethod
def before_commit(cls, session):
session._changes = {
'add': list(session.new),
'update': list(session.dirty),
'delete': list(session.deleted)
}
@classmethod
def after_commit(cls, session):
for obj in session._changes['add']:
if isinstance(obj, SearchableMixin):
add_to_index(obj.__tablename__, obj)
for obj in session._changes['update']:
if isinstance(obj, SearchableMixin):
add_to_index(obj.__tablename__, obj)
for obj in session._changes['delete']:
if isinstance(obj, SearchableMixin):
remove_from_index(obj.__tablename__, obj)
session._changes = None
@classmethod
def reindex(cls):
for obj in cls.query:
add_to_index(cls.__tablename__, obj)
db.event.listen(db.session, 'before_commit', SearchableMixin.before_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(
@ -14,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)
username = db.Column(db.String(64), index=True, unique=True)
email = db.Column(db.String(120), index=True, unique=True)
@ -22,11 +97,23 @@ class User(UserMixin, db.Model):
posts = db.relationship('Post', backref='author', lazy='dynamic')
about_me = db.Column(db.String(140))
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(
'User', secondary=followers,
primaryjoin=(followers.c.follower_id == id),
secondaryjoin=(followers.c.followed_id == id),
backref=db.backref('followers', lazy='dynamic'), lazy='dynamic')
messages_sent = db.relationship('Message',
foreign_keys='Message.sender_id',
backref='author', lazy='dynamic')
messages_received = db.relationship('Message',
foreign_keys='Message.recipient_id',
backref='recipient', lazy='dynamic')
last_message_read_time = db.Column(db.DateTime)
notifications = db.relationship('Notification', backref='user',
lazy='dynamic')
tasks = db.relationship('Task', backref='user', lazy='dynamic')
def __repr__(self):
return '<User {}>'.format(self.username)
@ -64,28 +151,143 @@ class User(UserMixin, db.Model):
def get_reset_password_token(self, expires_in=600):
return jwt.encode(
{'reset_password': self.id, 'exp': time() + expires_in},
app.config['SECRET_KEY'], algorithm='HS256')
current_app.config['SECRET_KEY'], algorithm='HS256')
@staticmethod
def verify_reset_password_token(token):
try:
id = jwt.decode(token, app.config['SECRET_KEY'],
id = jwt.decode(token, current_app.config['SECRET_KEY'],
algorithms=['HS256'])['reset_password']
except:
return
return User.query.get(id)
def new_messages(self):
last_read_time = self.last_message_read_time or datetime(1900, 1, 1)
return Message.query.filter_by(recipient=self).filter(
Message.timestamp > last_read_time).count()
def add_notification(self, name, data):
self.notifications.filter_by(name=name).delete()
n = Notification(name=name, payload_json=json.dumps(data), user=self)
db.session.add(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
def load_user(id):
return User.query.get(int(id))
class Post(db.Model):
class Post(SearchableMixin, db.Model):
__searchable__ = ['body']
id = db.Column(db.Integer, primary_key=True)
body = db.Column(db.String(140))
timestamp = db.Column(db.DateTime, index=True, default=datetime.utcnow)
user_id = db.Column(db.Integer, db.ForeignKey('user.id'))
language = db.Column(db.String(5))
def __repr__(self):
return '<Post {}>'.format(self.body)
class Message(db.Model):
id = db.Column(db.Integer, primary_key=True)
sender_id = db.Column(db.Integer, db.ForeignKey('user.id'))
recipient_id = db.Column(db.Integer, db.ForeignKey('user.id'))
body = db.Column(db.String(140))
timestamp = db.Column(db.DateTime, index=True, default=datetime.utcnow)
def __repr__(self):
return '<Message {}>'.format(self.body)
class Notification(db.Model):
id = db.Column(db.Integer, primary_key=True)
name = db.Column(db.String(128), index=True)
user_id = db.Column(db.Integer, db.ForeignKey('user.id'))
timestamp = db.Column(db.Float, index=True, default=time)
payload_json = db.Column(db.Text)
def get_data(self):
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

View File

@ -1,196 +0,0 @@
from datetime import datetime
from flask import render_template, flash, redirect, url_for, request
from flask_login import login_user, logout_user, current_user, login_required
from werkzeug.urls import url_parse
from app import app, db
from app.forms import LoginForm, RegistrationForm, EditProfileForm, \
EmptyForm, PostForm, ResetPasswordRequestForm, ResetPasswordForm
from app.models import User, Post
from app.email import send_password_reset_email
@app.before_request
def before_request():
if current_user.is_authenticated:
current_user.last_seen = datetime.utcnow()
db.session.commit()
@app.route('/', methods=['GET', 'POST'])
@app.route('/index', methods=['GET', 'POST'])
@login_required
def index():
form = PostForm()
if form.validate_on_submit():
post = Post(body=form.post.data, author=current_user)
db.session.add(post)
db.session.commit()
flash('Your post is now live!')
return redirect(url_for('index'))
page = request.args.get('page', 1, type=int)
posts = current_user.followed_posts().paginate(
page=page, per_page=app.config['POSTS_PER_PAGE'], error_out=False)
next_url = url_for('index', page=posts.next_num) \
if posts.has_next else None
prev_url = url_for('index', page=posts.prev_num) \
if posts.has_prev else None
return render_template('index.html', title='Home', form=form,
posts=posts.items, next_url=next_url,
prev_url=prev_url)
@app.route('/explore')
@login_required
def explore():
page = request.args.get('page', 1, type=int)
posts = Post.query.order_by(Post.timestamp.desc()).paginate(
page=page, per_page=app.config['POSTS_PER_PAGE'], error_out=False)
next_url = url_for('explore', page=posts.next_num) \
if posts.has_next else None
prev_url = url_for('explore', page=posts.prev_num) \
if posts.has_prev else None
return render_template('index.html', title='Explore', posts=posts.items,
next_url=next_url, prev_url=prev_url)
@app.route('/login', methods=['GET', 'POST'])
def login():
if current_user.is_authenticated:
return redirect(url_for('index'))
form = LoginForm()
if form.validate_on_submit():
user = User.query.filter_by(username=form.username.data).first()
if user is None or not user.check_password(form.password.data):
flash('Invalid username or password')
return redirect(url_for('login'))
login_user(user, remember=form.remember_me.data)
next_page = request.args.get('next')
if not next_page or url_parse(next_page).netloc != '':
next_page = url_for('index')
return redirect(next_page)
return render_template('login.html', title='Sign In', form=form)
@app.route('/logout')
def logout():
logout_user()
return redirect(url_for('index'))
@app.route('/register', methods=['GET', 'POST'])
def register():
if current_user.is_authenticated:
return redirect(url_for('index'))
form = RegistrationForm()
if form.validate_on_submit():
user = User(username=form.username.data, email=form.email.data)
user.set_password(form.password.data)
db.session.add(user)
db.session.commit()
flash('Congratulations, you are now a registered user!')
return redirect(url_for('login'))
return render_template('register.html', title='Register', form=form)
@app.route('/reset_password_request', methods=['GET', 'POST'])
def reset_password_request():
if current_user.is_authenticated:
return redirect(url_for('index'))
form = ResetPasswordRequestForm()
if form.validate_on_submit():
user = User.query.filter_by(email=form.email.data).first()
if user:
send_password_reset_email(user)
flash('Check your email for the instructions to reset your password')
return redirect(url_for('login'))
return render_template('reset_password_request.html',
title='Reset Password', form=form)
@app.route('/reset_password/<token>', methods=['GET', 'POST'])
def reset_password(token):
if current_user.is_authenticated:
return redirect(url_for('index'))
user = User.verify_reset_password_token(token)
if not user:
return redirect(url_for('index'))
form = ResetPasswordForm()
if form.validate_on_submit():
user.set_password(form.password.data)
db.session.commit()
flash('Your password has been reset.')
return redirect(url_for('login'))
return render_template('reset_password.html', form=form)
@app.route('/user/<username>')
@login_required
def user(username):
user = User.query.filter_by(username=username).first_or_404()
page = request.args.get('page', 1, type=int)
posts = user.posts.order_by(Post.timestamp.desc()).paginate(
page=page, per_page=app.config['POSTS_PER_PAGE'], error_out=False)
next_url = url_for('user', username=user.username, page=posts.next_num) \
if posts.has_next else None
prev_url = url_for('user', username=user.username, page=posts.prev_num) \
if posts.has_prev else None
form = EmptyForm()
return render_template('user.html', user=user, posts=posts.items,
next_url=next_url, prev_url=prev_url, form=form)
@app.route('/edit_profile', methods=['GET', 'POST'])
@login_required
def edit_profile():
form = EditProfileForm(current_user.username)
if form.validate_on_submit():
current_user.username = form.username.data
current_user.about_me = form.about_me.data
db.session.commit()
flash('Your changes have been saved.')
return redirect(url_for('edit_profile'))
elif request.method == 'GET':
form.username.data = current_user.username
form.about_me.data = current_user.about_me
return render_template('edit_profile.html', title='Edit Profile',
form=form)
@app.route('/follow/<username>', methods=['POST'])
@login_required
def follow(username):
form = EmptyForm()
if form.validate_on_submit():
user = User.query.filter_by(username=username).first()
if user is None:
flash('User {} not found.'.format(username))
return redirect(url_for('index'))
if user == current_user:
flash('You cannot follow yourself!')
return redirect(url_for('user', username=username))
current_user.follow(user)
db.session.commit()
flash('You are following {}!'.format(username))
return redirect(url_for('user', username=username))
else:
return redirect(url_for('index'))
@app.route('/unfollow/<username>', methods=['POST'])
@login_required
def unfollow(username):
form = EmptyForm()
if form.validate_on_submit():
user = User.query.filter_by(username=username).first()
if user is None:
flash('User {} not found.'.format(username))
return redirect(url_for('index'))
if user == current_user:
flash('You cannot unfollow yourself!')
return redirect(url_for('user', username=username))
current_user.unfollow(user)
db.session.commit()
flash('You are not following {}.'.format(username))
return redirect(url_for('user', username=username))
else:
return redirect(url_for('index'))

27
app/search.py Normal file
View File

@ -0,0 +1,27 @@
from flask import current_app
def add_to_index(index, model):
if not current_app.elasticsearch:
return
payload = {}
for field in model.__searchable__:
payload[field] = getattr(model, field)
current_app.elasticsearch.index(index=index, id=model.id, body=payload)
def remove_from_index(index, model):
if not current_app.elasticsearch:
return
current_app.elasticsearch.delete(index=index, id=model.id)
def query_index(index, query, page, per_page):
if not current_app.elasticsearch:
return [], 0
search = current_app.elasticsearch.search(
index=index,
body={'query': {'multi_match': {'query': query, 'fields': ['*']}},
'from': (page - 1) * per_page, 'size': per_page})
ids = [int(hit['_id']) for hit in search['hits']['hits']]
return ids, search['hits']['total']['value']

BIN
app/static/loading.gif Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 673 B

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

@ -1,6 +0,0 @@
{% extends "base.html" %}
{% block app_content %}
<h1>Not Found</h1>
<p><a href="{{ url_for('index') }}">Back</a></p>
{% endblock %}

View File

@ -1,7 +0,0 @@
{% extends "base.html" %}
{% block app_content %}
<h1>An unexpected error has occurred</h1>
<p>The administrator has been notified. Sorry for the inconvenience!</p>
<p><a href="{{ url_for('index') }}">Back</a></p>
{% endblock %}

View File

@ -1,17 +1,32 @@
<table class="table table-hover">
<tr>
<td width="70px">
<a href="{{ url_for('user', username=post.author.username) }}">
<a href="{{ url_for('main.user', username=post.author.username) }}">
<img src="{{ post.author.avatar(70) }}" />
</a>
</td>
<td>
<a href="{{ url_for('user', username=post.author.username) }}">
{{ post.author.username }}
</a>
says:
{% set user_link %}
<span class="user_popup">
<a href="{{ url_for('main.user', username=post.author.username) }}">
{{ post.author.username }}
</a>
</span>
{% endset %}
{{ _('%(username)s said %(when)s',
username=user_link, when=moment(post.timestamp).fromNow()) }}
<br>
{{ post.body }}
<span id="post{{ post.id }}">{{ post.body }}</span>
{% if post.language and post.language != g.locale %}
<br><br>
<span id="translation{{ post.id }}">
<a href="javascript:translate(
'#post{{ post.id }}',
'#translation{{ post.id }}',
'{{ post.language }}',
'{{ g.locale }}');">{{ _('Translate') }}</a>
</span>
{% endif %}
</td>
</tr>
</table>

View File

@ -0,0 +1,17 @@
{% extends 'base.html' %}
{% import 'bootstrap/wtf.html' as wtf %}
{% block app_content %}
<h1>{{ _('Sign In') }}</h1>
<div class="row">
<div class="col-md-4">
{{ wtf.quick_form(form) }}
</div>
</div>
<br>
<p>{{ _('New User?') }} <a href="{{ url_for('auth.register') }}">{{ _('Click to Register!') }}</a></p>
<p>
{{ _('Forgot Your Password?') }}
<a href="{{ url_for('auth.reset_password_request') }}">{{ _('Click to Reset It') }}</a>
</p>
{% endblock %}

View File

@ -2,7 +2,7 @@
{% import 'bootstrap/wtf.html' as wtf %}
{% block app_content %}
<h1>Register</h1>
<h1>{{ _('Register') }}</h1>
<div class="row">
<div class="col-md-4">
{{ wtf.quick_form(form) }}

View File

@ -2,7 +2,7 @@
{% import 'bootstrap/wtf.html' as wtf %}
{% block app_content %}
<h1>Reset Your Password</h1>
<h1>{{ _('Reset Your Password') }}</h1>
<div class="row">
<div class="col-md-4">
{{ wtf.quick_form(form) }}

View File

@ -2,7 +2,7 @@
{% import 'bootstrap/wtf.html' as wtf %}
{% block app_content %}
<h1>Reset Password</h1>
<h1>{{ _('Reset Password') }}</h1>
<div class="row">
<div class="col-md-4">
{{ wtf.quick_form(form) }}

View File

@ -1,7 +1,7 @@
{% extends 'bootstrap/base.html' %}
{% block title %}
{% if title %}{{ title }} - Microblog{% else %}Welcome to Microblog{% endif %}
{% if title %}{{ title }} - Microblog{% else %}{{ _('Welcome to Microblog') }}{% endif %}
{% endblock %}
{% block navbar %}
@ -14,19 +14,36 @@
<span class="icon-bar"></span>
<span class="icon-bar"></span>
</button>
<a class="navbar-brand" href="{{ url_for('index') }}">Microblog</a>
<a class="navbar-brand" href="{{ url_for('main.index') }}">Microblog</a>
</div>
<div class="collapse navbar-collapse" id="bs-example-navbar-collapse-1">
<ul class="nav navbar-nav">
<li><a href="{{ url_for('index') }}">Home</a></li>
<li><a href="{{ url_for('explore') }}">Explore</a></li>
<li><a href="{{ url_for('main.index') }}">{{ _('Home') }}</a></li>
<li><a href="{{ url_for('main.explore') }}">{{ _('Explore') }}</a></li>
</ul>
{% if g.search_form %}
<form class="navbar-form navbar-left" method="get" action="{{ url_for('main.search') }}">
<div class="form-group">
{{ g.search_form.q(size=20, class='form-control', placeholder=g.search_form.q.label.text) }}
</div>
</form>
{% endif %}
<ul class="nav navbar-nav navbar-right">
{% if current_user.is_anonymous %}
<li><a href="{{ url_for('login') }}">Login</a></li>
<li><a href="{{ url_for('auth.login') }}">{{ _('Login') }}</a></li>
{% else %}
<li><a href="{{ url_for('user', username=current_user.username) }}">Profile</a></li>
<li><a href="{{ url_for('logout') }}">Logout</a></li>
<li>
<a href="{{ url_for('main.messages') }}">{{ _('Messages') }}
{% set new_messages = current_user.new_messages() %}
<span id="message_count" class="badge"
style="visibility: {% if new_messages %}visible
{% else %}hidden{% endif %};">
{{ new_messages }}
</span>
</a>
</li>
<li><a href="{{ url_for('main.user', username=current_user.username) }}">{{ _('Profile') }}</a></li>
<li><a href="{{ url_for('auth.logout') }}">{{ _('Logout') }}</a></li>
{% endif %}
</ul>
</div>
@ -36,6 +53,18 @@
{% block content %}
<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() %}
{% if messages %}
{% for message in messages %}
@ -48,3 +77,95 @@
{% block app_content %}{% endblock %}
</div>
{% endblock %}
{% block scripts %}
{{ super() }}
{{ moment.include_moment() }}
{{ moment.lang(g.locale) }}
<script>
function translate(sourceElem, destElem, sourceLang, destLang) {
$(destElem).html('<img src="{{ url_for('static', filename='loading.gif') }}">');
$.post('/translate', {
text: $(sourceElem).text(),
source_language: sourceLang,
dest_language: destLang
}).done(function(response) {
$(destElem).text(response['text'])
}).fail(function() {
$(destElem).text("{{ _('Error: Could not contact server.') }}");
});
}
$(function () {
var timer = null;
var xhr = null;
$('.user_popup').hover(
function(event) {
// mouse in event handler
var elem = $(event.currentTarget);
timer = setTimeout(function() {
timer = null;
xhr = $.ajax(
'/user/' + elem.first().text().trim() + '/popup').done(
function(data) {
xhr = null;
elem.popover({
trigger: 'manual',
html: true,
animation: false,
container: elem,
content: data
}).popover('show');
flask_moment_render_all();
}
);
}, 1000);
},
function(event) {
// mouse out event handler
var elem = $(event.currentTarget);
if (timer) {
clearTimeout(timer);
timer = null;
}
else if (xhr) {
xhr.abort();
xhr = null;
}
else {
elem.popover('destroy');
}
}
);
});
function set_message_count(n) {
$('#message_count').text(n);
$('#message_count').css('visibility', n ? 'visible' : 'hidden');
}
function set_task_progress(task_id, progress) {
$('#' + task_id + '-progress').text(progress);
}
{% if current_user.is_authenticated %}
$(function() {
var since = 0;
setInterval(function() {
$.ajax('{{ url_for('main.notifications') }}?since=' + since).done(
function(notifications) {
for (var i = 0; i < notifications.length; i++) {
switch (notifications[i].name) {
case 'unread_message_count':
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;
}
}
);
}, 10000);
});
{% endif %}
</script>
{% endblock %}

View File

@ -2,7 +2,7 @@
{% import 'bootstrap/wtf.html' as wtf %}
{% block app_content %}
<h1>Edit Profile</h1>
<h1>{{ _('Edit Profile') }}</h1>
<div class="row">
<div class="col-md-4">
{{ wtf.quick_form(form) }}

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

@ -1,12 +1,12 @@
<p>Dear {{ user.username }},</p>
<p>
To reset your password
<a href="{{ url_for('reset_password', token=token, _external=True) }}">
<a href="{{ url_for('auth.reset_password', token=token, _external=True) }}">
click here
</a>.
</p>
<p>Alternatively, you can paste the following link in your browser's address bar:</p>
<p>{{ url_for('reset_password', token=token, _external=True) }}</p>
<p>{{ url_for('auth.reset_password', token=token, _external=True) }}</p>
<p>If you have not requested a password reset simply ignore this message.</p>
<p>Sincerely,</p>
<p>The Microblog Team</p>

View File

@ -2,7 +2,7 @@ Dear {{ user.username }},
To reset your password click on the following link:
{{ url_for('reset_password', token=token, _external=True) }}
{{ url_for('auth.reset_password', token=token, _external=True) }}
If you have not requested a password reset simply ignore this message.

View File

@ -0,0 +1,6 @@
{% extends "base.html" %}
{% block app_content %}
<h1>{{ _('Not Found') }}</h1>
<p><a href="{{ url_for('main.index') }}">{{ _('Back') }}</a></p>
{% endblock %}

View File

@ -0,0 +1,7 @@
{% extends "base.html" %}
{% block app_content %}
<h1>{{ _('An unexpected error has occurred') }}</h1>
<p>{{ _('The administrator has been notified. Sorry for the inconvenience!') }}</p>
<p><a href="{{ url_for('main.index') }}">{{ _('Back') }}</a></p>
{% endblock %}

View File

@ -2,7 +2,7 @@
{% import 'bootstrap/wtf.html' as wtf %}
{% block app_content %}
<h1>Hi, {{ current_user.username }}!</h1>
<h1>{{ _('Hi, %(username)s!', username=current_user.username) }}</h1>
{% if form %}
{{ wtf.quick_form(form) }}
<br>
@ -14,12 +14,12 @@
<ul class="pager">
<li class="previous{% if not prev_url %} disabled{% endif %}">
<a href="{{ prev_url or '#' }}">
<span aria-hidden="true">&larr;</span> Newer posts
<span aria-hidden="true">&larr;</span> {{ _('Newer posts') }}
</a>
</li>
<li class="next{% if not next_url %} disabled{% endif %}">
<a href="{{ next_url or '#' }}">
Older posts <span aria-hidden="true">&rarr;</span>
{{ _('Older posts') }} <span aria-hidden="true">&rarr;</span>
</a>
</li>
</ul>

View File

@ -1,17 +0,0 @@
{% extends 'base.html' %}
{% import 'bootstrap/wtf.html' as wtf %}
{% block app_content %}
<h1>Sign In</h1>
<div class="row">
<div class="col-md-4">
{{ wtf.quick_form(form) }}
</div>
</div>
<br>
<p>New User? <a href="{{ url_for('register') }}">Click to Register!</a></p>
<p>
Forgot Your Password?
<a href="{{ url_for('reset_password_request') }}">Click to Reset It</a>
</p>
{% endblock %}

View File

@ -0,0 +1,22 @@
{% extends "base.html" %}
{% block app_content %}
<h1>{{ _('Messages') }}</h1>
{% for post in messages %}
{% include '_post.html' %}
{% endfor %}
<nav aria-label="...">
<ul class="pager">
<li class="previous{% if not prev_url %} disabled{% endif %}">
<a href="{{ prev_url or '#' }}">
<span aria-hidden="true">&larr;</span> {{ _('Newer messages') }}
</a>
</li>
<li class="next{% if not next_url %} disabled{% endif %}">
<a href="{{ next_url or '#' }}">
{{ _('Older messages') }} <span aria-hidden="true">&rarr;</span>
</a>
</li>
</ul>
</nav>
{% endblock %}

22
app/templates/search.html Normal file
View File

@ -0,0 +1,22 @@
{% extends "base.html" %}
{% block app_content %}
<h1>{{ _('Search Results') }}</h1>
{% for post in posts %}
{% include '_post.html' %}
{% endfor %}
<nav aria-label="...">
<ul class="pager">
<li class="previous{% if not prev_url %} disabled{% endif %}">
<a href="{{ prev_url or '#' }}">
<span aria-hidden="true">&larr;</span> {{ _('Previous results') }}
</a>
</li>
<li class="next{% if not next_url %} disabled{% endif %}">
<a href="{{ next_url or '#' }}">
{{ _('Next results') }} <span aria-hidden="true">&rarr;</span>
</a>
</li>
</ul>
</nav>
{% endblock %}

View File

@ -0,0 +1,11 @@
{% extends "base.html" %}
{% import 'bootstrap/wtf.html' as wtf %}
{% block app_content %}
<h1>{{ _('Send Message to %(recipient)s', recipient=recipient) }}</h1>
<div class="row">
<div class="col-md-4">
{{ wtf.quick_form(form) }}
</div>
</div>
{% endblock %}

View File

@ -5,27 +5,35 @@
<tr>
<td width="256px"><img src="{{ user.avatar(256) }}"></td>
<td>
<h1>User: {{ user.username }}</h1>
<h1>{{ _('User') }}: {{ user.username }}</h1>
{% if user.about_me %}<p>{{ user.about_me }}</p>{% endif %}
{% if user.last_seen %}<p>Last seen on: {{ user.last_seen }}</p>{% endif %}
<p>{{ user.followers.count() }} followers, {{ user.followed.count() }} following.</p>
{% if user.last_seen %}
<p>{{ _('Last seen on') }}: {{ moment(user.last_seen).format('LLL') }}</p>
{% endif %}
<p>{{ _('%(count)d followers', count=user.followers.count()) }}, {{ _('%(count)d following', count=user.followed.count()) }}</p>
{% if user == current_user %}
<p><a href="{{ url_for('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) %}
<p>
<form action="{{ url_for('follow', username=user.username) }}" method="post">
<form action="{{ url_for('main.follow', username=user.username) }}" method="post">
{{ form.hidden_tag() }}
{{ form.submit(value='Follow', class_='btn btn-default') }}
{{ form.submit(value=_('Follow'), class_='btn btn-default') }}
</form>
</p>
{% else %}
<p>
<form action="{{ url_for('unfollow', username=user.username) }}" method="post">
<form action="{{ url_for('main.unfollow', username=user.username) }}" method="post">
{{ form.hidden_tag() }}
{{ form.submit(value='Unfollow', class_='btn btn-default') }}
{{ form.submit(value=_('Unfollow'), class_='btn btn-default') }}
</form>
</p>
{% endif %}
{% if user != current_user %}
<p><a href="{{ url_for('main.send_message', recipient=user.username) }}">{{ _('Send private message') }}</a></p>
{% endif %}
</td>
</tr>
</table>
@ -36,12 +44,12 @@
<ul class="pager">
<li class="previous{% if not prev_url %} disabled{% endif %}">
<a href="{{ prev_url or '#' }}">
<span aria-hidden="true">&larr;</span> Newer posts
<span aria-hidden="true">&larr;</span> {{ _('Newer posts') }}
</a>
</li>
<li class="next{% if not next_url %} disabled{% endif %}">
<a href="{{ next_url or '#' }}">
Older posts <span aria-hidden="true">&rarr;</span>
{{ _('Older posts') }} <span aria-hidden="true">&rarr;</span>
</a>
</li>
</ul>

View File

@ -0,0 +1,32 @@
<table class="table">
<tr>
<td width="64" style="border: 0px;"><img src="{{ user.avatar(64) }}"></td>
<td style="border: 0px;">
<p><a href="{{ url_for('main.user', username=user.username) }}">{{ user.username }}</a></p>
<small>
{% if user.about_me %}<p>{{ user.about_me }}</p>{% endif %}
{% if user.last_seen %}
<p>{{ _('Last seen on') }}: {{ moment(user.last_seen).format('lll') }}</p>
{% endif %}
<p>{{ _('%(count)d followers', count=user.followers.count()) }}, {{ _('%(count)d following', count=user.followed.count()) }}</p>
{% if user != current_user %}
{% if not current_user.is_following(user) %}
<p>
<form action="{{ url_for('main.follow', username=user.username) }}" method="post">
{{ form.hidden_tag() }}
{{ form.submit(value=_('Follow'), class_='btn btn-default btn-sm') }}
</form>
</p>
{% else %}
<p>
<form action="{{ url_for('main.unfollow', username=user.username) }}" method="post">
{{ form.hidden_tag() }}
{{ form.submit(value=_('Unfollow'), class_='btn btn-default btm-sm') }}
</form>
</p>
{% endif %}
{% endif %}
</small>
</td>
</tr>
</table>

21
app/translate.py Normal file
View File

@ -0,0 +1,21 @@
import json
import requests
from flask import current_app
from flask_babel import _
def translate(text, source_language, dest_language):
if 'MS_TRANSLATOR_KEY' not in current_app.config or \
not current_app.config['MS_TRANSLATOR_KEY']:
return _('Error: the translation service is not configured.')
auth = {
'Ocp-Apim-Subscription-Key': current_app.config['MS_TRANSLATOR_KEY'],
'Ocp-Apim-Subscription-Region': 'westus2'}
r = requests.post(
'https://api.cognitive.microsofttranslator.com'
'/translate?api-version=3.0&from={}&to={}'.format(
source_language, dest_language), headers=auth, json=[
{'Text': text}])
if r.status_code != 200:
return _('Error: the translation service failed.')
return r.json()[0]['translations'][0]['text']

View File

@ -0,0 +1,320 @@
# Spanish translations for PROJECT.
# Copyright (C) 2017 ORGANIZATION
# This file is distributed under the same license as the PROJECT project.
# FIRST AUTHOR <EMAIL@ADDRESS>, 2017.
#
msgid ""
msgstr ""
"Project-Id-Version: PROJECT VERSION\n"
"Report-Msgid-Bugs-To: EMAIL@ADDRESS\n"
"POT-Creation-Date: 2017-11-25 18:27-0800\n"
"PO-Revision-Date: 2017-09-29 23:25-0700\n"
"Last-Translator: FULL NAME <EMAIL@ADDRESS>\n"
"Language: es\n"
"Language-Team: es <LL@li.org>\n"
"Plural-Forms: nplurals=2; plural=(n != 1)\n"
"MIME-Version: 1.0\n"
"Content-Type: text/plain; charset=utf-8\n"
"Content-Transfer-Encoding: 8bit\n"
"Generated-By: Babel 2.5.1\n"
#: app/__init__.py:20
msgid "Please log in to access this page."
msgstr "Por favor ingrese para acceder a esta página."
#: app/translate.py:10
msgid "Error: the translation service is not configured."
msgstr "Error: el servicio de traducciones no está configurado."
#: app/translate.py:18
msgid "Error: the translation service failed."
msgstr "Error el servicio de traducciones ha fallado."
#: app/auth/email.py:8
msgid "[Microblog] Reset Your Password"
msgstr "[Microblog] Nueva Contraseña"
#: app/auth/forms.py:10 app/auth/forms.py:17 app/main/forms.py:10
msgid "Username"
msgstr "Nombre de usuario"
#: app/auth/forms.py:11 app/auth/forms.py:19 app/auth/forms.py:42
msgid "Password"
msgstr "Contraseña"
#: app/auth/forms.py:12
msgid "Remember Me"
msgstr "Recordarme"
#: app/auth/forms.py:13 app/templates/auth/login.html:5
msgid "Sign In"
msgstr "Ingresar"
#: app/auth/forms.py:18 app/auth/forms.py:37
msgid "Email"
msgstr "Email"
#: app/auth/forms.py:21 app/auth/forms.py:44
msgid "Repeat Password"
msgstr "Repetir Contraseña"
#: app/auth/forms.py:23 app/templates/auth/register.html:5
msgid "Register"
msgstr "Registrarse"
#: app/auth/forms.py:28 app/main/forms.py:23
msgid "Please use a different username."
msgstr "Por favor use un nombre de usuario diferente."
#: app/auth/forms.py:33
msgid "Please use a different email address."
msgstr "Por favor use una dirección de email diferente."
#: app/auth/forms.py:38 app/auth/forms.py:46
msgid "Request Password Reset"
msgstr "Pedir una nueva contraseña"
#: app/auth/routes.py:20
msgid "Invalid username or password"
msgstr "Nombre de usuario o contraseña inválidos"
#: app/auth/routes.py:46
msgid "Congratulations, you are now a registered user!"
msgstr "¡Felicitaciones, ya eres un usuario registrado!"
#: app/auth/routes.py:61
msgid "Check your email for the instructions to reset your password"
msgstr "Busca en tu email las instrucciones para crear una nueva contraseña"
#: app/auth/routes.py:78
msgid "Your password has been reset."
msgstr "Tu contraseña ha sido cambiada."
#: app/main/forms.py:11
msgid "About me"
msgstr "Acerca de mí"
#: app/main/forms.py:13 app/main/forms.py:28 app/main/forms.py:44
msgid "Submit"
msgstr "Enviar"
#: app/main/forms.py:27
msgid "Say something"
msgstr "Dí algo"
#: app/main/forms.py:32
msgid "Search"
msgstr "Buscar"
#: app/main/forms.py:43
msgid "Message"
msgstr "Mensaje"
#: app/main/routes.py:36
msgid "Your post is now live!"
msgstr "¡Tu artículo ha sido publicado!"
#: app/main/routes.py:94
msgid "Your changes have been saved."
msgstr "Tus cambios han sido salvados."
#: app/main/routes.py:99 app/templates/edit_profile.html:5
msgid "Edit Profile"
msgstr "Editar Perfil"
#: app/main/routes.py:108 app/main/routes.py:124
#, python-format
msgid "User %(username)s not found."
msgstr "El usuario %(username)s no ha sido encontrado."
#: app/main/routes.py:111
msgid "You cannot follow yourself!"
msgstr "¡No te puedes seguir a tí mismo!"
#: app/main/routes.py:115
#, python-format
msgid "You are following %(username)s!"
msgstr "¡Ahora estás siguiendo a %(username)s!"
#: app/main/routes.py:127
msgid "You cannot unfollow yourself!"
msgstr "¡No te puedes dejar de seguir a tí mismo!"
#: app/main/routes.py:131
#, python-format
msgid "You are not following %(username)s."
msgstr "No estás siguiendo a %(username)s."
#: app/main/routes.py:170
msgid "Your message has been sent."
msgstr "Tu mensaje ha sido enviado."
#: app/main/routes.py:172
msgid "Send Message"
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
#, python-format
msgid "%(username)s said %(when)s"
msgstr "%(username)s dijo %(when)s"
#: app/templates/_post.html:27
msgid "Translate"
msgstr "Traducir"
#: app/templates/base.html:4
msgid "Welcome to Microblog"
msgstr "Bienvenido a Microblog"
#: app/templates/base.html:21
msgid "Home"
msgstr "Inicio"
#: app/templates/base.html:22
msgid "Explore"
msgstr "Explorar"
#: app/templates/base.html:33
msgid "Login"
msgstr "Ingresar"
#: app/templates/base.html:36 app/templates/messages.html:4
msgid "Messages"
msgstr "Mensajes"
#: app/templates/base.html:45
msgid "Profile"
msgstr "Perfil"
#: app/templates/base.html:46
msgid "Logout"
msgstr "Salir"
#: app/templates/base.html:95
msgid "Error: Could not contact server."
msgstr "Error: el servidor no pudo ser contactado."
#: app/templates/index.html:5
#, python-format
msgid "Hi, %(username)s!"
msgstr "¡Hola, %(username)s!"
#: app/templates/index.html:17 app/templates/user.html:37
msgid "Newer posts"
msgstr "Artículos siguientes"
#: app/templates/index.html:22 app/templates/user.html:42
msgid "Older posts"
msgstr "Artículos previos"
#: app/templates/messages.html:12
msgid "Newer messages"
msgstr "Mensajes siguientes"
#: app/templates/messages.html:17
msgid "Older messages"
msgstr "Mensajes previos"
#: app/templates/search.html:4
msgid "Search Results"
msgstr ""
#: app/templates/search.html:12
msgid "Previous results"
msgstr ""
#: app/templates/search.html:17
msgid "Next results"
msgstr ""
#: app/templates/send_message.html:5
#, python-format
msgid "Send Message to %(recipient)s"
msgstr "Enviar Mensaje a %(recipient)s"
#: app/templates/user.html:8
msgid "User"
msgstr "Usuario"
#: app/templates/user.html:11 app/templates/user_popup.html:9
msgid "Last seen on"
msgstr "Última visita"
#: app/templates/user.html:13 app/templates/user_popup.html:11
#, python-format
msgid "%(count)d followers"
msgstr "%(count)d seguidores"
#: app/templates/user.html:13 app/templates/user_popup.html:11
#, python-format
msgid "%(count)d following"
msgstr "siguiendo a %(count)d"
#: app/templates/user.html:15
msgid "Edit your profile"
msgstr "Editar tu perfil"
#: 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"
msgstr "Seguir"
#: app/templates/user.html:22 app/templates/user_popup.html:16
msgid "Unfollow"
msgstr "Dejar de seguir"
#: app/templates/user.html:25
msgid "Send private message"
msgstr "Enviar mensaje privado"
#: app/templates/auth/login.html:12
msgid "New User?"
msgstr "¿Usuario Nuevo?"
#: app/templates/auth/login.html:12
msgid "Click to Register!"
msgstr "¡Haz click aquí para registrarte!"
#: app/templates/auth/login.html:14
msgid "Forgot Your Password?"
msgstr "¿Te olvidaste tu contraseña?"
#: app/templates/auth/login.html:15
msgid "Click to Reset It"
msgstr "Haz click aquí para pedir una nueva"
#: app/templates/auth/reset_password.html:5
msgid "Reset Your Password"
msgstr "Nueva Contraseña"
#: app/templates/auth/reset_password_request.html:5
msgid "Reset Password"
msgstr "Nueva Contraseña"
#: app/templates/errors/404.html:4
msgid "Not Found"
msgstr "Página No Encontrada"
#: app/templates/errors/404.html:5 app/templates/errors/500.html:6
msgid "Back"
msgstr "Atrás"
#: app/templates/errors/500.html:4
msgid "An unexpected error has occurred"
msgstr "Ha ocurrido un error inesperado"
#: app/templates/errors/500.html:5
msgid "The administrator has been notified. Sorry for the inconvenience!"
msgstr "El administrador ha sido notificado. ¡Lamentamos la inconveniencia!"

3
babel.cfg Normal file
View File

@ -0,0 +1,3 @@
[python: app/**.py]
[jinja2: app/templates/**.html]
extensions=jinja2.ext.autoescape,jinja2.ext.with_

13
boot.sh Executable file
View File

@ -0,0 +1,13 @@
#!/bin/bash
# this script is used to boot a Docker container
source venv/bin/activate
while true; do
flask db upgrade
if [[ "$?" == "0" ]]; then
break
fi
echo Deploy command failed, retrying in 5 secs...
sleep 5
done
flask translate compile
exec gunicorn -b :5000 --access-logfile - --error-logfile - microblog:app

View File

@ -1,16 +1,25 @@
import os
from dotenv import load_dotenv
basedir = os.path.abspath(os.path.dirname(__file__))
load_dotenv(os.path.join(basedir, '.env'))
class Config(object):
SECRET_KEY = os.environ.get('SECRET_KEY') or 'you-will-never-guess'
SQLALCHEMY_DATABASE_URI = os.environ.get('DATABASE_URL') or \
SQLALCHEMY_DATABASE_URI = os.environ.get('DATABASE_URL', '').replace(
'postgres://', 'postgresql://') or \
'sqlite:///' + os.path.join(basedir, 'app.db')
SQLALCHEMY_TRACK_MODIFICATIONS = False
LOG_TO_STDOUT = os.environ.get('LOG_TO_STDOUT')
MAIL_SERVER = os.environ.get('MAIL_SERVER')
MAIL_PORT = int(os.environ.get('MAIL_PORT') or 25)
MAIL_USE_TLS = os.environ.get('MAIL_USE_TLS') is not None
MAIL_USERNAME = os.environ.get('MAIL_USERNAME')
MAIL_PASSWORD = os.environ.get('MAIL_PASSWORD')
ADMINS = ['your-email@example.com']
LANGUAGES = ['en', 'es']
MS_TRANSLATOR_KEY = os.environ.get('MS_TRANSLATOR_KEY')
ELASTICSEARCH_URL = os.environ.get('ELASTICSEARCH_URL')
REDIS_URL = os.environ.get('REDIS_URL') or 'redis://'
POSTS_PER_PAGE = 25

View File

@ -0,0 +1,37 @@
server {
# listen on port 80 (http)
listen 80;
server_name _;
location / {
# redirect any requests to the same URL but on https
return 301 https://$host$request_uri;
}
}
server {
# listen on port 443 (https)
listen 443 ssl;
server_name _;
# location of the self-signed SSL certificate
ssl_certificate /home/ubuntu/microblog/certs/cert.pem;
ssl_certificate_key /home/ubuntu/microblog/certs/key.pem;
# write access and error logs to /var/log
access_log /var/log/microblog_access.log;
error_log /var/log/microblog_error.log;
location / {
# forward application requests to the gunicorn server
proxy_pass http://localhost:8000;
proxy_redirect off;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
}
location /static {
# handle static files directly, without forwarding to the application
alias /home/ubuntu/microblog/app/static;
expires 30d;
}
}

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

@ -0,0 +1,8 @@
[program:microblog]
command=/home/ubuntu/microblog/venv/bin/gunicorn -b localhost:8000 -w 4 microblog:app
directory=/home/ubuntu/microblog
user=ubuntu
autostart=true
autorestart=true
stopasgroup=true
killasgroup=true

View File

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

View File

@ -0,0 +1,28 @@
"""add language to posts
Revision ID: 2b017edaa91f
Revises: ae346256b650
Create Date: 2017-10-04 22:48:34.494465
"""
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = '2b017edaa91f'
down_revision = 'ae346256b650'
branch_labels = None
depends_on = None
def upgrade():
# ### commands auto generated by Alembic - please adjust! ###
op.add_column('post', sa.Column('language', sa.String(length=5), nullable=True))
# ### end Alembic commands ###
def downgrade():
# ### commands auto generated by Alembic - please adjust! ###
op.drop_column('post', 'language')
# ### end Alembic commands ###

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

@ -0,0 +1,41 @@
"""private messages
Revision ID: d049de007ccf
Revises: 834b1a697901
Create Date: 2017-11-12 23:30:28.571784
"""
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = 'd049de007ccf'
down_revision = '2b017edaa91f'
branch_labels = None
depends_on = None
def upgrade():
# ### commands auto generated by Alembic - please adjust! ###
op.create_table('message',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('sender_id', sa.Integer(), nullable=True),
sa.Column('recipient_id', sa.Integer(), nullable=True),
sa.Column('body', sa.String(length=140), nullable=True),
sa.Column('timestamp', sa.DateTime(), nullable=True),
sa.ForeignKeyConstraint(['recipient_id'], ['user.id'], ),
sa.ForeignKeyConstraint(['sender_id'], ['user.id'], ),
sa.PrimaryKeyConstraint('id')
)
op.create_index(op.f('ix_message_timestamp'), 'message', ['timestamp'], unique=False)
op.add_column('user', sa.Column('last_message_read_time', sa.DateTime(), nullable=True))
# ### end Alembic commands ###
def downgrade():
# ### commands auto generated by Alembic - please adjust! ###
op.drop_column('user', 'last_message_read_time')
op.drop_index(op.f('ix_message_timestamp'), table_name='message')
op.drop_table('message')
# ### end Alembic commands ###

View File

@ -0,0 +1,40 @@
"""notifications
Revision ID: f7ac3d27bb1d
Revises: d049de007ccf
Create Date: 2017-11-22 19:48:39.945858
"""
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = 'f7ac3d27bb1d'
down_revision = 'd049de007ccf'
branch_labels = None
depends_on = None
def upgrade():
# ### commands auto generated by Alembic - please adjust! ###
op.create_table('notification',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('name', sa.String(length=128), nullable=True),
sa.Column('user_id', sa.Integer(), nullable=True),
sa.Column('timestamp', sa.Float(), nullable=True),
sa.Column('payload_json', sa.Text(), nullable=True),
sa.ForeignKeyConstraint(['user_id'], ['user.id'], ),
sa.PrimaryKeyConstraint('id')
)
op.create_index(op.f('ix_notification_name'), 'notification', ['name'], unique=False)
op.create_index(op.f('ix_notification_timestamp'), 'notification', ['timestamp'], unique=False)
# ### end Alembic commands ###
def downgrade():
# ### commands auto generated by Alembic - please adjust! ###
op.drop_index(op.f('ix_notification_timestamp'), table_name='notification')
op.drop_index(op.f('ix_notification_name'), table_name='notification')
op.drop_table('notification')
# ### end Alembic commands ###

49
requirements.txt Normal file
View File

@ -0,0 +1,49 @@
alembic==1.6.5
Babel==2.9.1
blinker==1.4
certifi==2021.5.30
chardet==4.0.0
click==8.0.1
dnspython==2.1.0
dominate==2.6.0
elasticsearch==7.13.3
email-validator==1.1.3
Flask==2.0.1
Flask-Babel==2.0.0
Flask-Bootstrap==3.3.7.1
Flask-HTTPAuth==4.4.0
Flask-Login==0.5.0
Flask-Mail==0.9.1
Flask-Migrate==3.0.1
Flask-Moment==1.0.1
Flask-SQLAlchemy==2.5.1
Flask-WTF==0.15.1
greenlet==1.1.0
httpie==2.4.0
idna==2.10
itsdangerous==2.0.1
Jinja2==3.0.1
langdetect==1.0.9
Mako==1.1.4
MarkupSafe==2.0.1
Pygments==2.9.0
PyJWT==2.1.0
PySocks==1.7.1
python-dateutil==2.8.1
python-dotenv==0.18.0
python-editor==1.0.4
pytz==2021.1
redis==3.5.3
requests==2.25.1
requests-toolbelt==0.9.1
rq==1.9.0
six==1.16.0
SQLAlchemy==1.4.20
urllib3==1.26.6
visitor==0.1.3
Werkzeug==2.0.1
WTForms==2.3.3
# requirements for Heroku
#psycopg2==2.9.1
#gunicorn==20.1.0

View File

@ -1,16 +1,21 @@
#!/usr/bin/env python
import os
os.environ['DATABASE_URL'] = 'sqlite://'
from datetime import datetime, timedelta
import unittest
from app import app, db
from app import create_app, db
from app.models import User, Post
from config import Config
class TestConfig(Config):
TESTING = True
SQLALCHEMY_DATABASE_URI = 'sqlite://'
ELASTICSEARCH_URL = None
class UserModelCase(unittest.TestCase):
def setUp(self):
self.app_context = app.app_context()
self.app = create_app(TestConfig)
self.app_context = self.app.app_context()
self.app_context.push()
db.create_all()